Compare commits

...

24 Commits

Author SHA1 Message Date
Steven Kang 14b998d270 Set static DOCKER_VERSION for ppc64le and s390x (#7135)
Test / test-client (push) Has been cancelled
2022-06-28 11:38:12 +12:00
Chaim Lev-Ari 605ff8c1da fix(environments): hide async mode on deployment [EE-3380] (#7129)
fixes [EE-3380]
2022-06-28 10:23:07 +12:00
Chaim Lev-Ari 13f93f4262 fix(analytics): load public settings [EE-3590] (#7127) 2022-06-27 19:29:06 +03:00
Steven Kang 16be5ed329 feat(build): set static DOCKER_VERSION for ppc64le and s390x (#7124) 2022-06-27 09:54:04 +12:00
Chaim Lev-Ari c6612898f3 fix(api): add missing edge types [EE-3590] (#7117) 2022-06-26 08:38:20 +03:00
andres-portainer 564f34b0ba fix(wizard): replace the YAML file by the docker commands EE-3589 (#7112) 2022-06-24 14:59:00 -03:00
LP B 392fbdb4a7 fix(app/account): ensure newTransition exists in uiCanExit [EE-3336] (#7109) 2022-06-24 17:35:39 +02:00
Chaim Lev-Ari a826c78786 fix(edge): show heartbeat for async env [EE-3380] (#7096) 2022-06-22 20:11:42 +03:00
Matt Hook a35f0607f1 fix docker download path for mac platforms (#7101) 2022-06-22 10:06:28 +12:00
LP B 081d32af0d fix(app/account): create access token button (#7091)
* fix(app/account): create access token button

* fix(app/formcontrol): error message overlapping input on smaller screens
2022-06-20 14:14:41 +02:00
itsconquest 4cc0b1f567 fix(auth): track skips per user [EE-3318] (#7088) 2022-06-20 17:00:00 +12:00
Chaim Lev-Ari d4da7e1760 fix(docker/networks): show correct resource control data [EE-3401] (#7061) 2022-06-17 19:21:38 +03:00
itsconquest aced418880 fix(auth): clear skips when using new instance [EE-3331] (#7026) 2022-06-17 14:45:42 +12:00
Chaim Lev-Ari 614f42fe5a feat(custom-templates): hide variables [EE-2602] (#7069) 2022-06-16 08:32:43 +03:00
itsconquest 58736fe93b feat(auth): allow single char passwords [EE-3385] (#7049)
* feat(auth): allow single character passwords

* match weak password modal logic to slider
2022-06-16 12:31:39 +12:00
Matt Hook b78330b10d fix(swarm): don't stomp on the x-registry-auth header EE-3308 (#7037)
* don't stomp on the x-registry-auth header

* del header if empty json provided for registry auth
2022-06-16 09:54:06 +12:00
itsconquest eed4a92ca8 fix(auth): notify user password requirements [EE-3344] (#7041)
* fix(auth): notify user password requirements [EE-3344]

* fix angular code
2022-06-15 17:15:38 +12:00
Dmitry Salakhov 0e7468a1e8 fix: clarify password change error (#7020) 2022-06-15 15:44:54 +12:00
congs b807481f1c fix(teamleader): EE-3411 normal users get an unauthorized error (#7053) 2022-06-14 14:12:33 +12:00
Ali da27de2154 fix(wizard): return back to envs page EE-3419 (#7064) 2022-06-13 14:59:23 +12:00
congs 6743e4fbb2 fix(teamleader): EE-3383 allow teamleader promote member to teamleader (#7039) 2022-06-10 17:13:23 +12:00
Ali b489ffaa63 fix(wizard): show teasers for kaas and kubeconfig features [EE-3316] (#7033)
* fix(wizard): add nomad, kaas, kubeconfig teasers
2022-06-10 09:16:43 +12:00
congs 6e12499d61 fix(teamleader): EE-3332 hide name and leaders (#7032) 2022-06-09 14:22:42 +12:00
Ali f7acbe16ba fix(wizard): use 'New Environments' title EE-3329 (#7035) 2022-06-08 16:37:53 +12:00
74 changed files with 788 additions and 209 deletions
@@ -35,6 +35,12 @@
"TenantID": "" "TenantID": ""
}, },
"ComposeSyntaxMaxVersion": "", "ComposeSyntaxMaxVersion": "",
"Edge": {
"AsyncMode": false,
"CommandInterval": 0,
"PingInterval": 0,
"SnapshotInterval": 0
},
"EdgeCheckinInterval": 0, "EdgeCheckinInterval": 0,
"EdgeKey": "", "EdgeKey": "",
"GroupId": 1, "GroupId": 1,
@@ -682,6 +688,12 @@
"BlackListedLabels": [], "BlackListedLabels": [],
"DisplayDonationHeader": false, "DisplayDonationHeader": false,
"DisplayExternalContributors": false, "DisplayExternalContributors": false,
"Edge": {
"AsyncMode": false,
"CommandInterval": 0,
"PingInterval": 0,
"SnapshotInterval": 0
},
"EdgeAgentCheckinInterval": 5, "EdgeAgentCheckinInterval": 5,
"EdgePortainerUrl": "", "EdgePortainerUrl": "",
"EnableEdgeComputeFeatures": false, "EnableEdgeComputeFeatures": false,
@@ -30,6 +30,19 @@ type publicSettingsResponse struct {
KubeconfigExpiry string `example:"24h" default:"0"` KubeconfigExpiry string `example:"24h" default:"0"`
// Whether team sync is enabled // Whether team sync is enabled
TeamSync bool `json:"TeamSync" example:"true"` TeamSync bool `json:"TeamSync" example:"true"`
Edge struct {
// Whether the device has been started in edge async mode
AsyncMode bool
// The ping interval for edge agent - used in edge async mode [seconds]
PingInterval int `json:"PingInterval" example:"60"`
// The snapshot interval for edge agent - used in edge async mode [seconds]
SnapshotInterval int `json:"SnapshotInterval" example:"60"`
// The command list interval for edge agent - used in edge async mode [seconds]
CommandInterval int `json:"CommandInterval" example:"60"`
// The check in interval for edge agent (in seconds) - used in non async mode [seconds]
CheckinInterval int `example:"60"`
}
} }
// @id SettingsPublic // @id SettingsPublic
@@ -61,6 +74,13 @@ func generatePublicSettings(appSettings *portainer.Settings) *publicSettingsResp
KubeconfigExpiry: appSettings.KubeconfigExpiry, KubeconfigExpiry: appSettings.KubeconfigExpiry,
Features: appSettings.FeatureFlagSettings, Features: appSettings.FeatureFlagSettings,
} }
publicSettings.Edge.AsyncMode = appSettings.Edge.AsyncMode
publicSettings.Edge.PingInterval = appSettings.Edge.PingInterval
publicSettings.Edge.SnapshotInterval = appSettings.Edge.SnapshotInterval
publicSettings.Edge.CommandInterval = appSettings.Edge.CommandInterval
publicSettings.Edge.CheckinInterval = appSettings.EdgeAgentCheckinInterval
//if OAuth authentication is on, compose the related fields from application settings //if OAuth authentication is on, compose the related fields from application settings
if publicSettings.AuthenticationMethod == portainer.AuthenticationOAuth { if publicSettings.AuthenticationMethod == portainer.AuthenticationOAuth {
publicSettings.OAuthLogoutURI = appSettings.OAuthSettings.LogoutURI publicSettings.OAuthLogoutURI = appSettings.OAuthSettings.LogoutURI
+4 -1
View File
@@ -24,11 +24,14 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler {
adminRouter := h.NewRoute().Subrouter() adminRouter := h.NewRoute().Subrouter()
adminRouter.Use(bouncer.AdminAccess) adminRouter.Use(bouncer.AdminAccess)
restrictedRouter := h.NewRoute().Subrouter()
restrictedRouter.Use(bouncer.RestrictedAccess)
teamLeaderRouter := h.NewRoute().Subrouter() teamLeaderRouter := h.NewRoute().Subrouter()
teamLeaderRouter.Use(bouncer.TeamLeaderAccess) teamLeaderRouter.Use(bouncer.TeamLeaderAccess)
adminRouter.Handle("/teams", httperror.LoggerHandler(h.teamCreate)).Methods(http.MethodPost) adminRouter.Handle("/teams", httperror.LoggerHandler(h.teamCreate)).Methods(http.MethodPost)
teamLeaderRouter.Handle("/teams", httperror.LoggerHandler(h.teamList)).Methods(http.MethodGet) restrictedRouter.Handle("/teams", httperror.LoggerHandler(h.teamList)).Methods(http.MethodGet)
teamLeaderRouter.Handle("/teams/{id}", httperror.LoggerHandler(h.teamInspect)).Methods(http.MethodGet) teamLeaderRouter.Handle("/teams/{id}", httperror.LoggerHandler(h.teamInspect)).Methods(http.MethodGet)
adminRouter.Handle("/teams/{id}", httperror.LoggerHandler(h.teamUpdate)).Methods(http.MethodPut) adminRouter.Handle("/teams/{id}", httperror.LoggerHandler(h.teamUpdate)).Methods(http.MethodPut)
adminRouter.Handle("/teams/{id}", httperror.LoggerHandler(h.teamDelete)).Methods(http.MethodDelete) adminRouter.Handle("/teams/{id}", httperror.LoggerHandler(h.teamDelete)).Methods(http.MethodDelete)
@@ -82,7 +82,7 @@ func (handler *Handler) userUpdatePassword(w http.ResponseWriter, r *http.Reques
err = handler.CryptoService.CompareHashAndData(user.Password, payload.Password) err = handler.CryptoService.CompareHashAndData(user.Password, payload.Password)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusForbidden, "Specified password do not match actual password", httperrors.ErrUnauthorized} return &httperror.HandlerError{http.StatusForbidden, "Current password doesn't match", errors.New("Current password does not match the password provided. Please try again")}
} }
if !handler.passwordStrengthChecker.Check(payload.NewPassword) { if !handler.passwordStrengthChecker.Check(payload.NewPassword) {
+1 -1
View File
@@ -23,7 +23,7 @@ type (
} }
portainerRegistryAuthenticationHeader struct { portainerRegistryAuthenticationHeader struct {
RegistryId portainer.RegistryID `json:"registryId"` RegistryId *portainer.RegistryID `json:"registryId"`
} }
) )
+14 -1
View File
@@ -446,7 +446,20 @@ func (transport *Transport) decorateRegistryAuthenticationHeader(request *http.R
return err return err
} }
authenticationHeader, err := createRegistryAuthenticationHeader(transport.dataStore, originalHeaderData.RegistryId, accessContext) // delete header and exist function without error if Front End
// passes empty json. This is to restore original behavior which
// never originally passed this header
if string(decodedHeaderData) == "{}" {
request.Header.Del("X-Registry-Auth")
return nil
}
// only set X-Registry-Auth if registryId is defined
if originalHeaderData.RegistryId == nil {
return nil
}
authenticationHeader, err := createRegistryAuthenticationHeader(transport.dataStore, *originalHeaderData.RegistryId, accessContext)
if err != nil { if err != nil {
return err return err
} }
+22
View File
@@ -345,6 +345,17 @@ type (
// Whether the device has been trusted or not by the user // Whether the device has been trusted or not by the user
UserTrusted bool UserTrusted bool
Edge struct {
// Whether the device has been started in edge async mode
AsyncMode bool
// The ping interval for edge agent - used in edge async mode [seconds]
PingInterval int `json:"PingInterval" example:"60"`
// The snapshot interval for edge agent - used in edge async mode [seconds]
SnapshotInterval int `json:"SnapshotInterval" example:"60"`
// The command list interval for edge agent - used in edge async mode [seconds]
CommandInterval int `json:"CommandInterval" example:"60"`
}
// Deprecated fields // Deprecated fields
// Deprecated in DBVersion == 4 // Deprecated in DBVersion == 4
TLS bool `json:"TLS,omitempty"` TLS bool `json:"TLS,omitempty"`
@@ -837,6 +848,17 @@ type (
// EdgePortainerURL is the URL that is exposed to edge agents // EdgePortainerURL is the URL that is exposed to edge agents
EdgePortainerURL string `json:"EdgePortainerUrl"` EdgePortainerURL string `json:"EdgePortainerUrl"`
Edge struct {
// The command list interval for edge agent - used in edge async mode (in seconds)
CommandInterval int `json:"CommandInterval" example:"5"`
// The ping interval for edge agent - used in edge async mode (in seconds)
PingInterval int `json:"PingInterval" example:"5"`
// The snapshot interval for edge agent - used in edge async mode (in seconds)
SnapshotInterval int `json:"SnapshotInterval" example:"5"`
// EdgeAsyncMode enables edge async mode by default
AsyncMode bool
}
// Deprecated fields // Deprecated fields
DisplayDonationHeader bool DisplayDonationHeader bool
DisplayExternalContributors bool DisplayExternalContributors bool
+4 -2
View File
@@ -1,6 +1,6 @@
import _ from 'lodash'; import _ from 'lodash';
import { useSettings } from '@/portainer/settings/queries'; import { usePublicSettings } from '@/portainer/settings/queries';
const categories = [ const categories = [
'docker', 'docker',
@@ -64,7 +64,9 @@ export function push(
} }
export function useAnalytics() { export function useAnalytics() {
const telemetryQuery = useSettings((settings) => settings.EnableTelemetry); const telemetryQuery = usePublicSettings({
select: (settings) => settings.EnableTelemetry,
});
return { trackEvent: handleTrackEvent }; return { trackEvent: handleTrackEvent };
+1
View File
@@ -20,3 +20,4 @@ import './app.css';
import './theme.css'; import './theme.css';
import './vendor-override.css'; import './vendor-override.css';
import '../fonts/nomad-icon.css';
+32
View File
@@ -0,0 +1,32 @@
/* created using https://icomoon.io/app */
/* https://stackoverflow.com/a/35092005/681629 */
/* for additional icons, we should create a new set that includes the existing icons */
@font-face {
font-family: 'nomad-icon';
src: url('nomad-icon/nomad-icon.eot?6tre2n');
src: url('nomad-icon/nomad-icon.eot?6tre2n#iefix') format('embedded-opentype'), url('nomad-icon/nomad-icon.ttf?6tre2n') format('truetype'),
url('nomad-icon/nomad-icon.woff?6tre2n') format('woff'), url('nomad-icon/nomad-icon.svg?6tre2n#nomad-icon') format('svg');
font-weight: normal;
font-style: normal;
font-display: block;
}
.nomad-icon {
/* use !important to prevent issues with browser extensions that change fonts */
font-family: 'nomad-icon' !important;
speak: never;
font-style: normal;
font-weight: normal;
font-variant: normal;
text-transform: none;
line-height: 1;
/* Better Font Rendering =========== */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.nomad-icon:before {
content: '\e900';
}
Binary file not shown.
@@ -0,0 +1,11 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" >
<svg xmlns="http://www.w3.org/2000/svg">
<metadata>Generated by IcoMoon</metadata>
<defs>
<font id="icomoon" horiz-adv-x="1024">
<font-face units-per-em="1024" ascent="960" descent="-64" />
<missing-glyph horiz-adv-x="1024" />
<glyph unicode="&#x20;" horiz-adv-x="512" d="" />
<glyph unicode="&#xe900;" glyph-name="nomad_black" d="M507.999 959.562l-443.079-255.649v-511.675l443.079-255.8 443.079 255.8v511.675l-443.079 255.649zM705.402 396.893l-118.079-67.992-142.631 77.435v-163.256l-134.095-84.839v340.865l106.369 65.121 147.617-77.813v166.202l140.894 84.612-0.076-340.336z" />
</font></defs></svg>

After

Width:  |  Height:  |  Size: 738 B

Binary file not shown.
Binary file not shown.
+2 -8
View File
@@ -1,7 +1,5 @@
import { import { AccessControlFormData } from '@/portainer/access-control/types';
AccessControlFormData, import { PortainerMetadata } from '@/react/docker/types';
ResourceControlResponse,
} from '@/portainer/access-control/types';
import { PortMapping } from './ContainerInstances/CreateContainerInstanceForm/PortsMappingField'; import { PortMapping } from './ContainerInstances/CreateContainerInstanceForm/PortsMappingField';
@@ -21,10 +19,6 @@ export interface ContainerInstanceFormValues {
accessControl: AccessControlFormData; accessControl: AccessControlFormData;
} }
interface PortainerMetadata {
ResourceControl: ResourceControlResponse;
}
interface Container { interface Container {
name: string; name: string;
properties: { properties: {
@@ -1,7 +1,6 @@
import { render } from '@/react-tools/test-utils'; import { render } from '@/react-tools/test-utils';
import { UserContext } from '@/portainer/hooks/useUser'; import { UserContext } from '@/portainer/hooks/useUser';
import { UserViewModel } from '@/portainer/models/user'; import { UserViewModel } from '@/portainer/models/user';
import { ResourceControlOwnership } from '@/portainer/access-control/types';
import { DockerNetwork } from '../types'; import { DockerNetwork } from '../types';
@@ -113,9 +112,9 @@ function getNetwork(networkName: string): DockerNetwork {
}, },
], ],
TeamAccesses: [], TeamAccesses: [],
Ownership: ResourceControlOwnership.PUBLIC,
Public: true, Public: true,
System: false, System: false,
AdministratorsOnly: true,
}, },
}, },
Scope: 'local', Scope: 'local',
@@ -9,6 +9,7 @@ import { confirmDeletionAsync } from '@/portainer/services/modal.service/confirm
import { AccessControlPanel } from '@/portainer/access-control/AccessControlPanel/AccessControlPanel'; import { AccessControlPanel } from '@/portainer/access-control/AccessControlPanel/AccessControlPanel';
import { ResourceControlType } from '@/portainer/access-control/types'; import { ResourceControlType } from '@/portainer/access-control/types';
import { DockerContainer } from '@/docker/containers/types'; import { DockerContainer } from '@/docker/containers/types';
import { ResourceControlViewModel } from '@/portainer/access-control/models/ResourceControlViewModel';
import { useNetwork, useDeleteNetwork } from '../queries'; import { useNetwork, useDeleteNetwork } from '../queries';
import { isSystemNetwork } from '../network.helper'; import { isSystemNetwork } from '../network.helper';
@@ -50,6 +51,12 @@ export function NetworkDetailsView() {
return null; return null;
} }
const network = networkQuery.data;
const resourceControl = network.Portainer?.ResourceControl
? new ResourceControlViewModel(network.Portainer.ResourceControl)
: undefined;
return ( return (
<> <>
<PageHeader <PageHeader
@@ -77,7 +84,7 @@ export function NetworkDetailsView() {
networkId, networkId,
]) ])
} }
resourceControl={networkQuery.data.Portainer?.ResourceControl} resourceControl={resourceControl}
resourceType={ResourceControlType.Network} resourceType={ResourceControlType.Network}
disableOwnershipChange={isSystemNetwork(networkQuery.data.Name)} disableOwnershipChange={isSystemNetwork(networkQuery.data.Name)}
resourceId={networkId} resourceId={networkId}
+2 -2
View File
@@ -1,4 +1,4 @@
import { ResourceControlViewModel } from '@/portainer/access-control/models/ResourceControlViewModel'; import { PortainerMetadata } from '@/react/docker/types';
import { ContainerId } from '../containers/types'; import { ContainerId } from '../containers/types';
@@ -44,7 +44,7 @@ export interface DockerNetwork {
Driver: string; Driver: string;
Options: IpamOptions; Options: IpamOptions;
}; };
Portainer: { ResourceControl?: ResourceControlViewModel }; Portainer?: PortainerMetadata;
Options: NetworkOptions; Options: NetworkOptions;
Containers: NetworkResponseContainers; Containers: NetworkResponseContainers;
} }
@@ -15,12 +15,5 @@ export const heartbeat: Column<Environment> = {
export function StatusCell({ export function StatusCell({
row: { original: environment }, row: { original: environment },
}: CellProps<Environment>) { }: CellProps<Environment>) {
return ( return <EdgeIndicator environment={environment} />;
<EdgeIndicator
checkInInterval={environment.EdgeCheckinInterval}
edgeId={environment.EdgeID}
lastCheckInDate={environment.LastCheckInDate}
queryDate={environment.QueryDate}
/>
);
} }
@@ -4,6 +4,8 @@ import { TableSettingsProvider } from '@/portainer/components/datatables/compone
import { PageHeader } from '@/portainer/components/PageHeader'; import { PageHeader } from '@/portainer/components/PageHeader';
import { useEnvironmentList } from '@/portainer/environments/queries/useEnvironmentList'; import { useEnvironmentList } from '@/portainer/environments/queries/useEnvironmentList';
import { r2a } from '@/react-tools/react2angular'; import { r2a } from '@/react-tools/react2angular';
import { InformationPanel } from '@/portainer/components/InformationPanel';
import { TextTip } from '@/portainer/components/Tip/TextTip';
import { DataTable } from './Datatable/Datatable'; import { DataTable } from './Datatable/Datatable';
import { TableSettings } from './Datatable/types'; import { TableSettings } from './Datatable/types';
@@ -29,6 +31,15 @@ export function WaitingRoomView() {
{ label: 'Waiting Room' }, { label: 'Waiting Room' },
]} ]}
/> />
<InformationPanel>
<TextTip color="blue">
Only environments generated from the AEEC script will appear here,
manually added environments and edge devices will bypass the waiting
room.
</TextTip>
</InformationPanel>
<TableSettingsProvider<TableSettings> <TableSettingsProvider<TableSettings>
defaults={{ pageSize: 10, sortBy: { desc: false, id: 'name' } }} defaults={{ pageSize: 10, sortBy: { desc: false, id: 'name' } }}
storageKey={storageKey} storageKey={storageKey}
@@ -1,6 +1,7 @@
import { buildOption } from '@/portainer/components/BoxSelector'; import { buildOption } from '@/portainer/components/BoxSelector';
import { AccessControlFormData } from '@/portainer/components/accessControlForm/porAccessControlFormModel'; import { AccessControlFormData } from '@/portainer/components/accessControlForm/porAccessControlFormModel';
import { getTemplateVariables, intersectVariables } from '@/react/portainer/custom-templates/components/utils'; import { getTemplateVariables, intersectVariables } from '@/react/portainer/custom-templates/components/utils';
import { isBE } from '@/portainer/feature-flags/feature-flags.service';
class KubeCreateCustomTemplateViewController { class KubeCreateCustomTemplateViewController {
/* @ngInject */ /* @ngInject */
@@ -13,6 +14,7 @@ class KubeCreateCustomTemplateViewController {
]; ];
this.templates = null; this.templates = null;
this.isTemplateVariablesEnabled = isBE;
this.state = { this.state = {
method: 'editor', method: 'editor',
@@ -53,6 +55,10 @@ class KubeCreateCustomTemplateViewController {
} }
parseTemplate(templateStr) { parseTemplate(templateStr) {
if (!this.isTemplateVariablesEnabled) {
return;
}
const variables = getTemplateVariables(templateStr); const variables = getTemplateVariables(templateStr);
const isValid = !!variables; const isValid = !!variables;
@@ -37,6 +37,7 @@
</file-upload-form> </file-upload-form>
<custom-templates-variables-definition-field <custom-templates-variables-definition-field
ng-if="$ctrl.isTemplateVariablesEnabled"
value="$ctrl.formValues.Variables" value="$ctrl.formValues.Variables"
on-change="($ctrl.onVariablesChange)" on-change="($ctrl.onVariablesChange)"
is-variables-names-from-parent="$ctrl.state.method === 'editor'" is-variables-names-from-parent="$ctrl.state.method === 'editor'"
@@ -1,5 +1,6 @@
import { ResourceControlViewModel } from '@/portainer/access-control/models/ResourceControlViewModel'; import { ResourceControlViewModel } from '@/portainer/access-control/models/ResourceControlViewModel';
import { AccessControlFormData } from '@/portainer/components/accessControlForm/porAccessControlFormModel'; import { AccessControlFormData } from '@/portainer/components/accessControlForm/porAccessControlFormModel';
import { isBE } from '@/portainer/feature-flags/feature-flags.service';
import { getTemplateVariables, intersectVariables } from '@/react/portainer/custom-templates/components/utils'; import { getTemplateVariables, intersectVariables } from '@/react/portainer/custom-templates/components/utils';
class KubeEditCustomTemplateViewController { class KubeEditCustomTemplateViewController {
@@ -7,6 +8,8 @@ class KubeEditCustomTemplateViewController {
constructor($async, $state, ModalService, Authentication, CustomTemplateService, FormValidator, Notifications, ResourceControlService) { constructor($async, $state, ModalService, Authentication, CustomTemplateService, FormValidator, Notifications, ResourceControlService) {
Object.assign(this, { $async, $state, ModalService, Authentication, CustomTemplateService, FormValidator, Notifications, ResourceControlService }); Object.assign(this, { $async, $state, ModalService, Authentication, CustomTemplateService, FormValidator, Notifications, ResourceControlService });
this.isTemplateVariablesEnabled = isBE;
this.formValues = null; this.formValues = null;
this.state = { this.state = {
formValidationError: '', formValidationError: '',
@@ -60,6 +63,10 @@ class KubeEditCustomTemplateViewController {
} }
parseTemplate(templateStr) { parseTemplate(templateStr) {
if (!this.isTemplateVariablesEnabled) {
return;
}
const variables = getTemplateVariables(templateStr); const variables = getTemplateVariables(templateStr);
const isValid = !!variables; const isValid = !!variables;
@@ -32,6 +32,7 @@
</web-editor-form> </web-editor-form>
<custom-templates-variables-definition-field <custom-templates-variables-definition-field
ng-if="$ctrl.isTemplateVariablesEnabled"
value="$ctrl.formValues.Variables" value="$ctrl.formValues.Variables"
on-change="($ctrl.onVariablesChange)" on-change="($ctrl.onVariablesChange)"
is-variables-names-from-parent="true" is-variables-names-from-parent="true"
+1 -1
View File
@@ -93,7 +93,7 @@
></custom-template-selector> ></custom-template-selector>
<custom-templates-variables-field <custom-templates-variables-field
ng-if="ctrl.state.template" ng-if="$ctrl.isTemplateVariablesEnabled && ctrl.state.template"
definitions="ctrl.state.template.Variables" definitions="ctrl.state.template.Variables"
value="ctrl.formValues.Variables" value="ctrl.formValues.Variables"
on-change="(ctrl.onChangeTemplateVariables)" on-change="(ctrl.onChangeTemplateVariables)"
@@ -7,6 +7,7 @@ import PortainerError from '@/portainer/error';
import { KubernetesDeployManifestTypes, KubernetesDeployBuildMethods, KubernetesDeployRequestMethods, RepositoryMechanismTypes } from 'Kubernetes/models/deploy'; import { KubernetesDeployManifestTypes, KubernetesDeployBuildMethods, KubernetesDeployRequestMethods, RepositoryMechanismTypes } from 'Kubernetes/models/deploy';
import { buildOption } from '@/portainer/components/BoxSelector'; import { buildOption } from '@/portainer/components/BoxSelector';
import { renderTemplate } from '@/react/portainer/custom-templates/components/utils'; import { renderTemplate } from '@/react/portainer/custom-templates/components/utils';
import { isBE } from '@/portainer/feature-flags/feature-flags.service';
class KubernetesDeployController { class KubernetesDeployController {
/* @ngInject */ /* @ngInject */
@@ -23,6 +24,8 @@ class KubernetesDeployController {
this.CustomTemplateService = CustomTemplateService; this.CustomTemplateService = CustomTemplateService;
this.DeployMethod = 'manifest'; this.DeployMethod = 'manifest';
this.isTemplateVariablesEnabled = isBE;
this.deployOptions = [ this.deployOptions = [
buildOption('method_kubernetes', 'fa fa-cubes', 'Kubernetes', 'Kubernetes manifest format', KubernetesDeployManifestTypes.KUBERNETES), buildOption('method_kubernetes', 'fa fa-cubes', 'Kubernetes', 'Kubernetes manifest format', KubernetesDeployManifestTypes.KUBERNETES),
buildOption('method_compose', 'fab fa-docker', 'Compose', 'docker-compose format', KubernetesDeployManifestTypes.COMPOSE), buildOption('method_compose', 'fab fa-docker', 'Compose', 'docker-compose format', KubernetesDeployManifestTypes.COMPOSE),
@@ -83,6 +86,10 @@ class KubernetesDeployController {
} }
renderTemplate() { renderTemplate() {
if (!this.isTemplateVariablesEnabled) {
return;
}
const rendered = renderTemplate(this.state.templateContent, this.formValues.Variables, this.state.template.Variables); const rendered = renderTemplate(this.state.templateContent, this.formValues.Variables, this.state.template.Variables);
this.onChangeFormValues({ EditorContent: rendered }); this.onChangeFormValues({ EditorContent: rendered });
} }
@@ -33,11 +33,11 @@ export class ResourceControlViewModel {
this.TeamAccesses = data.TeamAccesses; this.TeamAccesses = data.TeamAccesses;
this.Public = data.Public; this.Public = data.Public;
this.System = data.System; this.System = data.System;
this.Ownership = determineOwnership(this); this.Ownership = determineOwnership(data);
} }
} }
function determineOwnership(resourceControl: ResourceControlViewModel) { function determineOwnership(resourceControl: ResourceControlResponse) {
if (resourceControl.Public) { if (resourceControl.Public) {
return ResourceControlOwnership.PUBLIC; return ResourceControlOwnership.PUBLIC;
} }
@@ -1,4 +1,5 @@
import { PropsWithChildren } from 'react'; import { PropsWithChildren } from 'react';
import clsx from 'clsx';
import { FeatureId } from '@/portainer/feature-flags/enums'; import { FeatureId } from '@/portainer/feature-flags/enums';
@@ -6,11 +7,15 @@ import { getFeatureDetails } from './utils';
export interface Props { export interface Props {
featureId?: FeatureId; featureId?: FeatureId;
showIcon?: boolean;
className?: string;
} }
export function BEFeatureIndicator({ export function BEFeatureIndicator({
featureId, featureId,
children, children,
showIcon = true,
className = '',
}: PropsWithChildren<Props>) { }: PropsWithChildren<Props>) {
const { url, limitedToBE } = getFeatureDetails(featureId); const { url, limitedToBE } = getFeatureDetails(featureId);
@@ -20,14 +25,18 @@ export function BEFeatureIndicator({
return ( return (
<a <a
className="be-indicator" className={clsx('be-indicator', className)}
href={url} href={url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
{children} {children}
<i className="fas fa-briefcase space-right be-indicator-icon" /> {showIcon && (
<span className="be-indicator-label">Business Edition Feature</span> <i className="fas fa-briefcase space-right be-indicator-icon" />
)}
<span className="be-indicator-label break-words">
Business Edition Feature
</span>
</a> </a>
); );
} }
@@ -4,7 +4,7 @@ import { Button } from '../Button';
import { Widget, WidgetBody } from '../widget'; import { Widget, WidgetBody } from '../widget';
interface Props { interface Props {
title: string; title?: string;
onDismiss?(): void; onDismiss?(): void;
bodyClassName?: string; bodyClassName?: string;
wrapperStyle?: Record<string, string>; wrapperStyle?: Record<string, string>;
@@ -23,21 +23,23 @@ export function InformationPanel({
<Widget> <Widget>
<WidgetBody className={bodyClassName}> <WidgetBody className={bodyClassName}>
<div style={wrapperStyle}> <div style={wrapperStyle}>
<div className="col-sm-12 form-section-title"> {title && (
<span style={{ float: 'left' }}>{title}</span> <div className="col-sm-12 form-section-title">
{!!onDismiss && ( <span style={{ float: 'left' }}>{title}</span>
<span {!!onDismiss && (
className="small" <span
style={{ float: 'right' }} className="small"
ng-if="dismissAction" style={{ float: 'right' }}
> ng-if="dismissAction"
<Button color="link" onClick={() => onDismiss()}> >
<i className="fa fa-times" /> dismiss <Button color="link" onClick={() => onDismiss()}>
</Button> <i className="fa fa-times" /> dismiss
</span> </Button>
)} </span>
</div> )}
<div className="form-group">{children}</div> </div>
)}
<div>{children}</div>
</div> </div>
</WidgetBody> </WidgetBody>
</Widget> </Widget>
+18 -2
View File
@@ -1,7 +1,15 @@
import { react2angular } from '@/react-tools/react2angular'; import { react2angular } from '@/react-tools/react2angular';
import { usePublicSettings } from '@/portainer/settings/queries'; import { usePublicSettings } from '@/portainer/settings/queries';
export function PasswordCheckHint() { interface Props {
passwordValid: boolean;
forceChangePassword?: boolean;
}
export function PasswordCheckHint({
passwordValid,
forceChangePassword,
}: Props) {
const settingsQuery = usePublicSettings(); const settingsQuery = usePublicSettings();
const minPasswordLength = settingsQuery.data?.RequiredPasswordLength; const minPasswordLength = settingsQuery.data?.RequiredPasswordLength;
@@ -12,10 +20,18 @@ export function PasswordCheckHint() {
className="fa fa-exclamation-triangle orange-icon space-right" className="fa fa-exclamation-triangle orange-icon space-right"
aria-hidden="true" aria-hidden="true"
/> />
{forceChangePassword &&
'An administrator has changed your password requirements, '}
The password must be at least {minPasswordLength} characters long. The password must be at least {minPasswordLength} characters long.
{passwordValid && (
<i className="fa fa-check green-icon space-left" aria-hidden="true" />
)}
</p> </p>
</div> </div>
); );
} }
export const PasswordCheckHintAngular = react2angular(PasswordCheckHint, []); export const PasswordCheckHintAngular = react2angular(PasswordCheckHint, [
'passwordValid',
'forceChangePassword',
]);
@@ -14,7 +14,7 @@
> >
<i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Remove <i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Remove
</button> </button>
<button type="button" class="btn btn-sm btn-primary" ng-click="$ctrl.setRefferer()" ui-sref="portainer.wizard.endpoints" data-cy="endpoint-addEndpointButton"> <button type="button" class="btn btn-sm btn-primary" ng-click="$ctrl.setReferrer()" ui-sref="portainer.wizard.endpoints" data-cy="endpoint-addEndpointButton">
<i class="fa fa-plus-circle space-right" aria-hidden="true"></i>Add environment <i class="fa fa-plus-circle space-right" aria-hidden="true"></i>Add environment
</button> </button>
</div> </div>
@@ -34,8 +34,8 @@ angular.module('portainer.app').controller('EndpointsDatatableController', [
this.paginationChanged(); this.paginationChanged();
}; };
this.setRefferer = function () { this.setReferrer = function () {
window.localStorage.setItem('wizardRefferer', 'environments'); window.localStorage.setItem('wizardReferrer', 'environments');
}; };
/** /**
@@ -29,26 +29,29 @@ export function FormControl({
required, required,
}: PropsWithChildren<Props>) { }: PropsWithChildren<Props>) {
return ( return (
<div className={clsx('form-group', styles.container)}> <>
<label <div className={clsx('form-group', styles.container)}>
htmlFor={inputId} <label
className={clsx(sizeClassLabel(size), 'control-label', 'text-left')} htmlFor={inputId}
> className={clsx(sizeClassLabel(size), 'control-label', 'text-left')}
{label} >
{label}
{required && <span className="text-danger">*</span>} {required && <span className="text-danger">*</span>}
{tooltip && <Tooltip message={tooltip} />} {tooltip && <Tooltip message={tooltip} />}
</label> </label>
<div className={sizeClassChildren(size)}>{children}</div>
<div className={sizeClassChildren(size)}>{children}</div>
</div>
{errors && ( {errors && (
<div className="col-md-12"> <div className="form-group">
<FormError>{errors}</FormError> <div className="col-md-12">
<FormError>{errors}</FormError>
</div>
</div> </div>
)} )}
</div> </>
); );
} }
+9
View File
@@ -54,6 +54,13 @@ export interface KubernetesSettings {
Snapshots?: KubernetesSnapshot[] | null; Snapshots?: KubernetesSnapshot[] | null;
} }
export type EnvironmentEdge = {
AsyncMode: boolean;
PingInterval: number;
SnapshotInterval: number;
CommandInterval: number;
};
export type Environment = { export type Environment = {
Id: EnvironmentId; Id: EnvironmentId;
Type: EnvironmentType; Type: EnvironmentType;
@@ -73,6 +80,7 @@ export type Environment = {
IsEdgeDevice?: boolean; IsEdgeDevice?: boolean;
UserTrusted: boolean; UserTrusted: boolean;
AMTDeviceGUID?: string; AMTDeviceGUID?: string;
Edge: EnvironmentEdge;
}; };
/** /**
* TS reference of endpoint_create.go#EndpointCreationType iota * TS reference of endpoint_create.go#EndpointCreationType iota
@@ -83,6 +91,7 @@ export enum EnvironmentCreationTypes {
AzureEnvironment, AzureEnvironment,
EdgeAgentEnvironment, EdgeAgentEnvironment,
LocalKubernetesEnvironment, LocalKubernetesEnvironment,
KubeConfigEnvironment,
} }
export enum PlatformType { export enum PlatformType {
+3
View File
@@ -12,6 +12,9 @@ export enum FeatureState {
export enum FeatureId { export enum FeatureId {
K8S_RESOURCE_POOL_LB_QUOTA = 'k8s-resourcepool-Ibquota', K8S_RESOURCE_POOL_LB_QUOTA = 'k8s-resourcepool-Ibquota',
K8S_RESOURCE_POOL_STORAGE_QUOTA = 'k8s-resourcepool-storagequota', K8S_RESOURCE_POOL_STORAGE_QUOTA = 'k8s-resourcepool-storagequota',
K8S_CREATE_FROM_KUBECONFIG = 'k8s-create-from-kubeconfig',
KAAS_PROVISIONING = 'kaas-provisioning',
NOMAD = 'nomad',
RBAC_ROLES = 'rbac-roles', RBAC_ROLES = 'rbac-roles',
REGISTRY_MANAGEMENT = 'registry-management', REGISTRY_MANAGEMENT = 'registry-management',
K8S_SETUP_DEFAULT = 'k8s-setup-default', K8S_SETUP_DEFAULT = 'k8s-setup-default',
@@ -1,5 +1,6 @@
import { Edition, FeatureId, FeatureState } from './enums'; import { Edition, FeatureId, FeatureState } from './enums';
export const isBE = process.env.PORTAINER_EDITION === 'BE';
interface ServiceState { interface ServiceState {
currentEdition: Edition; currentEdition: Edition;
features: Record<FeatureId, Edition>; features: Record<FeatureId, Edition>;
@@ -16,6 +17,9 @@ export async function init(edition: Edition) {
const features = { const features = {
[FeatureId.K8S_RESOURCE_POOL_LB_QUOTA]: Edition.BE, [FeatureId.K8S_RESOURCE_POOL_LB_QUOTA]: Edition.BE,
[FeatureId.K8S_RESOURCE_POOL_STORAGE_QUOTA]: Edition.BE, [FeatureId.K8S_RESOURCE_POOL_STORAGE_QUOTA]: Edition.BE,
[FeatureId.K8S_CREATE_FROM_KUBECONFIG]: Edition.BE,
[FeatureId.KAAS_PROVISIONING]: Edition.BE,
[FeatureId.NOMAD]: Edition.BE,
[FeatureId.ACTIVITY_AUDIT]: Edition.BE, [FeatureId.ACTIVITY_AUDIT]: Edition.BE,
[FeatureId.EXTERNAL_AUTH_LDAP]: Edition.BE, [FeatureId.EXTERNAL_AUTH_LDAP]: Edition.BE,
[FeatureId.HIDE_INTERNAL_AUTH]: Edition.BE, [FeatureId.HIDE_INTERNAL_AUTH]: Edition.BE,
@@ -1,35 +1,44 @@
import { render } from '@/react-tools/test-utils'; import { createMockEnvironment } from '@/react-tools/test-mocks';
import { renderWithQueryClient } from '@/react-tools/test-utils';
import { rest, server } from '@/setup-tests/server';
import { EdgeIndicator } from './EdgeIndicator'; import { EdgeIndicator } from './EdgeIndicator';
test('when edge id is not set, should show unassociated label', () => { test('when edge id is not set, should show unassociated label', async () => {
const { queryByLabelText } = renderComponent(); const { queryByLabelText } = await renderComponent();
const unassociatedLabel = queryByLabelText('unassociated'); const unassociatedLabel = queryByLabelText('unassociated');
expect(unassociatedLabel).toBeVisible(); expect(unassociatedLabel).toBeVisible();
}); });
test('given edge id and last checkin is set, should show heartbeat', () => { // test('given edge id and last checkin is set, should show heartbeat', async () => {
const { queryByLabelText } = renderComponent('id', 1); // const { queryByLabelText } = await renderComponent('id', 1);
expect(queryByLabelText('edge-heartbeat')).toBeVisible(); // expect(queryByLabelText('edge-heartbeat')).toBeVisible();
expect(queryByLabelText('edge-last-checkin')).toBeVisible(); // expect(queryByLabelText('edge-last-checkin')).toBeVisible();
}); // });
function renderComponent( async function renderComponent(
edgeId = '', edgeId = '',
lastCheckInDate = 0, lastCheckInDate = 0,
checkInInterval = 0, checkInInterval = 0,
queryDate = 0 queryDate = 0
) { ) {
return render( server.use(rest.get('/api/settings', (req, res, ctx) => res(ctx.json({}))));
<EdgeIndicator
edgeId={edgeId} const environment = createMockEnvironment();
lastCheckInDate={lastCheckInDate}
checkInInterval={checkInInterval} environment.EdgeID = edgeId;
queryDate={queryDate} environment.LastCheckInDate = lastCheckInDate;
showLastCheckInDate environment.EdgeCheckinInterval = checkInInterval;
/> environment.QueryDate = queryDate;
const queries = renderWithQueryClient(
<EdgeIndicator environment={environment} showLastCheckInDate />
); );
await expect(queries.findByRole('status')).resolves.toBeVisible();
return queries;
} }
@@ -1,56 +1,114 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { isoDateFromTimestamp } from '@/portainer/filters/filters'; import { isoDateFromTimestamp } from '@/portainer/filters/filters';
import { Environment } from '@/portainer/environments/types';
import { usePublicSettings } from '@/portainer/settings/queries';
import { PublicSettingsViewModel } from '@/portainer/models/settings';
interface Props { interface Props {
checkInInterval?: number;
edgeId?: string;
queryDate?: number;
lastCheckInDate?: number;
showLastCheckInDate?: boolean; showLastCheckInDate?: boolean;
environment: Environment;
} }
export function EdgeIndicator({ export function EdgeIndicator({
edgeId, environment,
lastCheckInDate,
checkInInterval,
queryDate,
showLastCheckInDate = false, showLastCheckInDate = false,
}: Props) { }: Props) {
if (!edgeId) { const associated = !!environment.EdgeID;
const isValid = useHasHeartbeat(environment, associated);
if (isValid === null) {
return null;
}
if (!associated) {
return ( return (
<span className="label label-default" aria-label="unassociated"> <span role="status" aria-label="edge-status">
<s>associated</s> <span className="label label-default" aria-label="unassociated">
<s>associated</s>
</span>
</span> </span>
); );
} }
// give checkIn some wiggle room
let isCheckValid = false;
if (checkInInterval && queryDate && lastCheckInDate) {
isCheckValid = queryDate - lastCheckInDate <= checkInInterval * 2 + 20;
}
return ( return (
<span> <span role="status" aria-label="edge-status">
<span <span
className={clsx('label', { className={clsx('label', {
'label-danger': !isCheckValid, 'label-danger': !isValid,
'label-success': isCheckValid, 'label-success': isValid,
})} })}
aria-label="edge-heartbeat" aria-label="edge-heartbeat"
> >
heartbeat heartbeat
</span> </span>
{showLastCheckInDate && !!lastCheckInDate && ( {showLastCheckInDate && !!environment.LastCheckInDate && (
<span <span
className="space-left small text-muted" className="space-left small text-muted"
aria-label="edge-last-checkin" aria-label="edge-last-checkin"
> >
{isoDateFromTimestamp(lastCheckInDate)} {isoDateFromTimestamp(environment.LastCheckInDate)}
</span> </span>
)} )}
</span> </span>
); );
} }
function useHasHeartbeat(environment: Environment, associated: boolean) {
const settingsQuery = usePublicSettings({ enabled: associated });
if (!associated) {
return false;
}
const { LastCheckInDate, QueryDate } = environment;
const settings = settingsQuery.data;
if (!settings) {
return null;
}
const checkInInterval = getCheckinInterval(environment, settings);
if (checkInInterval && QueryDate && LastCheckInDate) {
return QueryDate - LastCheckInDate <= checkInInterval * 2 + 20;
}
return false;
}
function getCheckinInterval(
environment: Environment,
settings: PublicSettingsViewModel
) {
const asyncMode = environment.Edge.AsyncMode;
if (asyncMode) {
const intervals = [
environment.Edge.PingInterval > 0
? environment.Edge.PingInterval
: settings.Edge.PingInterval,
environment.Edge.SnapshotInterval > 0
? environment.Edge.SnapshotInterval
: settings.Edge.SnapshotInterval,
environment.Edge.CommandInterval > 0
? environment.Edge.CommandInterval
: settings.Edge.CommandInterval,
].filter((n) => n > 0);
return intervals.length > 0 ? Math.min(...intervals) : 60;
}
if (
!environment.EdgeCheckinInterval ||
environment.EdgeCheckinInterval === 0
) {
return settings.Edge.CheckinInterval;
}
return environment.EdgeCheckinInterval;
}
@@ -5,6 +5,7 @@ import {
EnvironmentStatus, EnvironmentStatus,
EnvironmentType, EnvironmentType,
} from '@/portainer/environments/types'; } from '@/portainer/environments/types';
import { createMockEnvironment } from '@/react-tools/test-mocks';
import { EnvironmentItem } from './EnvironmentItem'; import { EnvironmentItem } from './EnvironmentItem';
@@ -57,19 +58,9 @@ KubernetesEdgeEnvironment.args = {
}; };
function mockEnvironment(type: EnvironmentType): Environment { function mockEnvironment(type: EnvironmentType): Environment {
return { const env = createMockEnvironment();
Id: 1, env.Type = type;
Name: 'environment', env.Status = EnvironmentStatus.Up;
GroupId: 1,
Snapshots: [], return env;
Status: EnvironmentStatus.Up,
TagIds: [],
Type: type,
Kubernetes: {
Snapshots: [],
},
URL: 'url',
UserTrusted: false,
EdgeKey: '',
};
} }
@@ -6,25 +6,14 @@ import { Environment } from '@/portainer/environments/types';
import { UserContext } from '@/portainer/hooks/useUser'; import { UserContext } from '@/portainer/hooks/useUser';
import { UserViewModel } from '@/portainer/models/user'; import { UserViewModel } from '@/portainer/models/user';
import { Tag } from '@/portainer/tags/types'; import { Tag } from '@/portainer/tags/types';
import { createMockEnvironment } from '@/react-tools/test-mocks';
import { renderWithQueryClient } from '@/react-tools/test-utils'; import { renderWithQueryClient } from '@/react-tools/test-utils';
import { server, rest } from '@/setup-tests/server'; import { server, rest } from '@/setup-tests/server';
import { EnvironmentItem } from './EnvironmentItem'; import { EnvironmentItem } from './EnvironmentItem';
test('loads component', async () => { test('loads component', async () => {
const env: Environment = { const env = createMockEnvironment();
TagIds: [],
GroupId: 1,
Type: 1,
Name: 'environment',
Status: 1,
URL: 'url',
Snapshots: [],
Kubernetes: { Snapshots: [] },
Id: 3,
UserTrusted: false,
EdgeKey: '',
};
const { getByText } = renderComponent(env); const { getByText } = renderComponent(env);
expect(getByText(env.Name)).toBeInTheDocument(); expect(getByText(env.Name)).toBeInTheDocument();
@@ -34,19 +23,8 @@ test('shows group name', async () => {
const groupName = 'group-name'; const groupName = 'group-name';
const groupId: EnvironmentGroupId = 14; const groupId: EnvironmentGroupId = 14;
const env: Environment = { const env = createMockEnvironment();
TagIds: [], env.GroupId = groupId;
GroupId: groupId,
Type: 1,
Name: 'environment',
Status: 1,
URL: 'url',
Snapshots: [],
Kubernetes: { Snapshots: [] },
Id: 3,
UserTrusted: false,
EdgeKey: '',
};
const { findByText } = renderComponent(env, { Name: groupName }); const { findByText } = renderComponent(env, { Name: groupName });
@@ -68,10 +68,8 @@ export function EnvironmentItem({ environment, onClick, groupName }: Props) {
<span className="space-left blocklist-item-subtitle"> <span className="space-left blocklist-item-subtitle">
{isEdge ? ( {isEdge ? (
<EdgeIndicator <EdgeIndicator
edgeId={environment.EdgeID} environment={environment}
checkInInterval={environment.EdgeCheckinInterval} showLastCheckInDate
lastCheckInDate={environment.LastCheckInDate}
queryDate={environment.QueryDate}
/> />
) : ( ) : (
<> <>
+9
View File
@@ -34,6 +34,8 @@ export function PublicSettingsViewModel(settings) {
this.EnableTelemetry = settings.EnableTelemetry; this.EnableTelemetry = settings.EnableTelemetry;
this.OAuthLogoutURI = settings.OAuthLogoutURI; this.OAuthLogoutURI = settings.OAuthLogoutURI;
this.KubeconfigExpiry = settings.KubeconfigExpiry; this.KubeconfigExpiry = settings.KubeconfigExpiry;
this.Features = settings.Features;
this.Edge = new EdgeSettingsViewModel(settings.Edge);
} }
export function InternalAuthSettingsViewModel(data) { export function InternalAuthSettingsViewModel(data) {
@@ -75,3 +77,10 @@ export function OAuthSettingsViewModel(data) {
this.SSO = data.SSO; this.SSO = data.SSO;
this.LogoutURI = data.LogoutURI; this.LogoutURI = data.LogoutURI;
} }
export function EdgeSettingsViewModel(data = {}) {
this.CheckinInterval = data.CheckinInterval;
this.PingInterval = data.PingInterval;
this.SnapshotInterval = data.SnapshotInterval;
this.CommandInterval = data.CommandInterval;
}
+13 -7
View File
@@ -25,6 +25,7 @@ function StateManagerFactory(
UI: { UI: {
dismissedInfoPanels: {}, dismissedInfoPanels: {},
dismissedInfoHash: '', dismissedInfoHash: '',
timesPasswordChangeSkipped: {},
}, },
}; };
@@ -49,13 +50,13 @@ function StateManagerFactory(
}; };
manager.setPasswordChangeSkipped = function (userID) { manager.setPasswordChangeSkipped = function (userID) {
state.UI.timesPasswordChangeSkipped = state.UI.timesPasswordChangeSkipped || {}; state.UI.instanceId = state.UI.instanceId || state.application.instanceId;
state.UI.timesPasswordChangeSkipped[userID] = state.UI.timesPasswordChangeSkipped[userID] + 1 || 1; state.UI.timesPasswordChangeSkipped[userID] = state.UI.timesPasswordChangeSkipped[userID] + 1 || 1;
LocalStorage.storeUIState(state.UI); LocalStorage.storeUIState(state.UI);
}; };
manager.resetPasswordChangeSkips = function (userID) { manager.resetPasswordChangeSkips = function (userID) {
if (state.UI.timesPasswordChangeSkipped && state.UI.timesPasswordChangeSkipped[userID]) state.UI.timesPasswordChangeSkipped[userID] = 0; if (state.UI.timesPasswordChangeSkipped[userID]) state.UI.timesPasswordChangeSkipped[userID] = 0;
LocalStorage.storeUIState(state.UI); LocalStorage.storeUIState(state.UI);
}; };
@@ -141,11 +142,6 @@ function StateManagerFactory(
manager.initialize = initialize; manager.initialize = initialize;
async function initialize() { async function initialize() {
return $async(async () => { return $async(async () => {
const UIState = LocalStorage.getUIState();
if (UIState) {
state.UI = UIState;
}
const endpointState = LocalStorage.getEndpointState(); const endpointState = LocalStorage.getEndpointState();
if (endpointState) { if (endpointState) {
state.endpoint = endpointState; state.endpoint = endpointState;
@@ -158,6 +154,16 @@ function StateManagerFactory(
await loadApplicationState(); await loadApplicationState();
} }
const UIState = LocalStorage.getUIState();
if (UIState) {
state.UI = UIState;
if (state.UI.instanceId && state.UI.instanceId !== state.application.instanceId) {
state.UI.instanceId = state.application.instanceId;
state.UI.timesPasswordChangeSkipped = {};
LocalStorage.storeUIState(state.UI);
}
}
state.loading = false; state.loading = false;
$analytics.setPortainerStatus(state.application.instanceId, state.application.version); $analytics.setPortainerStatus(state.application.instanceId, state.application.version);
$analytics.setOptOut(!state.application.enableTelemetry); $analytics.setOptOut(!state.application.enableTelemetry);
@@ -1,5 +1,6 @@
import { FormSectionTitle } from '@/portainer/components/form-components/FormSectionTitle'; import { FormSectionTitle } from '@/portainer/components/form-components/FormSectionTitle';
import { react2angular } from '@/react-tools/react2angular'; import { react2angular } from '@/react-tools/react2angular';
import { confirm } from '@/portainer/services/modal.service/confirm';
import { SaveAuthSettingsButton } from '../components/SaveAuthSettingsButton'; import { SaveAuthSettingsButton } from '../components/SaveAuthSettingsButton';
import { Settings } from '../../types'; import { Settings } from '../../types';
@@ -19,6 +20,27 @@ export function InternalAuth({
value, value,
onChange, onChange,
}: Props) { }: Props) {
function onSubmit() {
if (value.RequiredPasswordLength < 10) {
confirm({
title: 'Allow weak passwords?',
message:
'You have set an insecure minimum password length. This could leave your system vulnerable to attack, are you sure?',
buttons: {
confirm: {
label: 'Yes',
className: 'btn-danger',
},
},
callback: function onConfirm(confirmed) {
if (confirmed) onSaveSettings();
},
});
} else {
onSaveSettings();
}
}
return ( return (
<> <>
<FormSectionTitle>Information</FormSectionTitle> <FormSectionTitle>Information</FormSectionTitle>
@@ -34,7 +56,7 @@ export function InternalAuth({
<div className="form-group"> <div className="form-group">
<PasswordLengthSlider <PasswordLengthSlider
min={8} min={1}
max={18} max={18}
step={1} step={1}
value={value.RequiredPasswordLength} value={value.RequiredPasswordLength}
@@ -42,7 +64,7 @@ export function InternalAuth({
/> />
</div> </div>
<SaveAuthSettingsButton onSubmit={onSaveSettings} isLoading={isLoading} /> <SaveAuthSettingsButton onSubmit={onSubmit} isLoading={isLoading} />
</> </>
); );
} }
+16 -2
View File
@@ -2,6 +2,8 @@ import { useMutation, useQuery, useQueryClient } from 'react-query';
import { notifyError } from '@/portainer/services/notifications'; import { notifyError } from '@/portainer/services/notifications';
import { PublicSettingsViewModel } from '../models/settings';
import { import {
publicSettings, publicSettings,
getSettings, getSettings,
@@ -9,17 +11,29 @@ import {
} from './settings.service'; } from './settings.service';
import { Settings } from './types'; import { Settings } from './types';
export function usePublicSettings() { export function usePublicSettings<T = PublicSettingsViewModel>({
enabled,
select,
}: {
select?: (settings: PublicSettingsViewModel) => T;
enabled?: boolean;
} = {}) {
return useQuery(['settings', 'public'], () => publicSettings(), { return useQuery(['settings', 'public'], () => publicSettings(), {
onError: (err) => { onError: (err) => {
notifyError('Failure', err as Error, 'Unable to retrieve settings'); notifyError('Failure', err as Error, 'Unable to retrieve settings');
}, },
select,
enabled,
}); });
} }
export function useSettings<T = Settings>(select?: (settings: Settings) => T) { export function useSettings<T = Settings>(
select?: (settings: Settings) => T,
enabled?: boolean
) {
return useQuery(['settings'], getSettings, { return useQuery(['settings'], getSettings, {
select, select,
enabled,
meta: { meta: {
error: { error: {
title: 'Failure', title: 'Failure',
+6
View File
@@ -125,4 +125,10 @@ export interface Settings {
AllowStackManagementForRegularUsers: boolean; AllowStackManagementForRegularUsers: boolean;
AllowDeviceMappingForRegularUsers: boolean; AllowDeviceMappingForRegularUsers: boolean;
AllowContainerCapabilitiesForRegularUsers: boolean; AllowContainerCapabilitiesForRegularUsers: boolean;
Edge: {
PingInterval: number;
SnapshotInterval: number;
CommandInterval: number;
AsyncMode: boolean;
};
} }
@@ -61,7 +61,7 @@ export function CreateAccessToken({
return ( return (
<Widget> <Widget>
<WidgetBody> <WidgetBody>
<div> <div className="form-horizontal">
<FormControl <FormControl
inputId="input" inputId="input"
label={t('Description')} label={t('Description')}
+1 -1
View File
@@ -54,7 +54,7 @@
</div> </div>
</div> </div>
</div> </div>
<password-check-hint ng-if="!form.new_password.$valid || (forceChangePassword && !formValues.newPassword)"></password-check-hint> <password-check-hint password-valid="form.new_password.$valid && formValues.newPassword" force-change-password="forceChangePassword"></password-check-hint>
<div ng-if="userRole === 1"> <div ng-if="userRole === 1">
<p class="text-muted"> <p class="text-muted">
<i class="fa fa-exclamation-circle blue-icon" aria-hidden="true"></i> <i class="fa fa-exclamation-circle blue-icon" aria-hidden="true"></i>
@@ -22,7 +22,7 @@ angular.module('portainer.app').controller('AccountController', [
try { try {
await UserService.updateUserPassword($scope.userID, $scope.formValues.currentPassword, $scope.formValues.newPassword); await UserService.updateUserPassword($scope.userID, $scope.formValues.currentPassword, $scope.formValues.newPassword);
Notifications.success('Success', 'Password successfully updated'); Notifications.success('Success', 'Password successfully updated');
StateManager.resetPasswordChangeSkips($scope.userID); StateManager.resetPasswordChangeSkips($scope.userID.toString());
$scope.forceChangePassword = false; $scope.forceChangePassword = false;
$state.go('portainer.logout'); $state.go('portainer.logout');
} catch (err) { } catch (err) {
@@ -34,7 +34,7 @@ angular.module('portainer.app').controller('AccountController', [
$scope.skipPasswordChange = async function () { $scope.skipPasswordChange = async function () {
try { try {
if ($scope.userCanSkip()) { if ($scope.userCanSkip()) {
StateManager.setPasswordChangeSkipped($scope.userID); StateManager.setPasswordChangeSkipped($scope.userID.toString());
$scope.forceChangePassword = false; $scope.forceChangePassword = false;
$state.go('portainer.home'); $state.go('portainer.home');
} }
@@ -48,8 +48,13 @@ angular.module('portainer.app').controller('AccountController', [
}; };
this.uiCanExit = (newTransition) => { this.uiCanExit = (newTransition) => {
if ($scope.userRole === 1 && newTransition.to().name === 'portainer.settings.authentication') { if (newTransition) {
return true; if ($scope.userRole === 1 && newTransition.to().name === 'portainer.settings.authentication') {
return true;
}
if (newTransition.to().name === 'portainer.logout') {
return true;
}
} }
if ($scope.forceChangePassword) { if ($scope.forceChangePassword) {
ModalService.confirmForceChangePassword(); ModalService.confirmForceChangePassword();
@@ -127,11 +132,13 @@ angular.module('portainer.app').controller('AccountController', [
$scope.AuthenticationMethod = data.AuthenticationMethod; $scope.AuthenticationMethod = data.AuthenticationMethod;
if (state.UI.requiredPasswordLength && state.UI.requiredPasswordLength !== data.RequiredPasswordLength) { if (state.UI.requiredPasswordLength && state.UI.requiredPasswordLength !== data.RequiredPasswordLength) {
StateManager.clearPasswordChangeSkips($scope.userID); StateManager.clearPasswordChangeSkips();
} }
$scope.timesPasswordChangeSkipped = $scope.timesPasswordChangeSkipped =
state.UI.timesPasswordChangeSkipped && state.UI.timesPasswordChangeSkipped[$scope.userID] ? state.UI.timesPasswordChangeSkipped[$scope.userID] : 0; state.UI.timesPasswordChangeSkipped && state.UI.timesPasswordChangeSkipped[$scope.userID.toString()]
? state.UI.timesPasswordChangeSkipped[$scope.userID.toString()]
: 0;
$scope.requiredPasswordLength = data.RequiredPasswordLength; $scope.requiredPasswordLength = data.RequiredPasswordLength;
StateManager.setRequiredPasswordLength(data.RequiredPasswordLength); StateManager.setRequiredPasswordLength(data.RequiredPasswordLength);
@@ -108,6 +108,7 @@
</div> </div>
<custom-templates-variables-definition-field <custom-templates-variables-definition-field
ng-if="$ctrl.isTemplateVariablesEnabled"
value="$ctrl.formValues.Variables" value="$ctrl.formValues.Variables"
on-change="($ctrl.onVariablesChange)" on-change="($ctrl.onVariablesChange)"
is-variables-names-from-parent="$ctrl.state.Method === 'editor'" is-variables-names-from-parent="$ctrl.state.Method === 'editor'"
@@ -2,6 +2,7 @@ import _ from 'lodash';
import { AccessControlFormData } from 'Portainer/components/accessControlForm/porAccessControlFormModel'; import { AccessControlFormData } from 'Portainer/components/accessControlForm/porAccessControlFormModel';
import { TEMPLATE_NAME_VALIDATION_REGEX } from '@/constants'; import { TEMPLATE_NAME_VALIDATION_REGEX } from '@/constants';
import { getTemplateVariables, intersectVariables } from '@/react/portainer/custom-templates/components/utils'; import { getTemplateVariables, intersectVariables } from '@/react/portainer/custom-templates/components/utils';
import { isBE } from '@/portainer/feature-flags/feature-flags.service';
class CreateCustomTemplateViewController { class CreateCustomTemplateViewController {
/* @ngInject */ /* @ngInject */
@@ -20,6 +21,8 @@ class CreateCustomTemplateViewController {
StateManager, StateManager,
}); });
this.isTemplateVariablesEnabled = isBE;
this.formValues = { this.formValues = {
Title: '', Title: '',
FileContent: '', FileContent: '',
@@ -176,6 +179,10 @@ class CreateCustomTemplateViewController {
} }
parseTemplate(templateStr) { parseTemplate(templateStr) {
if (!this.isTemplateVariablesEnabled) {
return;
}
const variables = getTemplateVariables(templateStr); const variables = getTemplateVariables(templateStr);
const isValid = !!variables; const isValid = !!variables;
@@ -18,6 +18,7 @@
> >
<advanced-form> <advanced-form>
<custom-templates-variables-field <custom-templates-variables-field
ng-if="$ctrl.isTemplateVariablesEnabled"
definitions="$ctrl.state.selectedTemplate.Variables" definitions="$ctrl.state.selectedTemplate.Variables"
value="$ctrl.formValues.variables" value="$ctrl.formValues.variables"
on-change="($ctrl.onChangeTemplateVariables)" on-change="($ctrl.onChangeTemplateVariables)"
@@ -2,6 +2,7 @@ import _ from 'lodash-es';
import { AccessControlFormData } from 'Portainer/components/accessControlForm/porAccessControlFormModel'; import { AccessControlFormData } from 'Portainer/components/accessControlForm/porAccessControlFormModel';
import { TEMPLATE_NAME_VALIDATION_REGEX } from '@/constants'; import { TEMPLATE_NAME_VALIDATION_REGEX } from '@/constants';
import { renderTemplate } from '@/react/portainer/custom-templates/components/utils'; import { renderTemplate } from '@/react/portainer/custom-templates/components/utils';
import { isBE } from '@/portainer/feature-flags/feature-flags.service';
class CustomTemplatesViewController { class CustomTemplatesViewController {
/* @ngInject */ /* @ngInject */
@@ -34,6 +35,8 @@ class CustomTemplatesViewController {
this.StateManager = StateManager; this.StateManager = StateManager;
this.StackService = StackService; this.StackService = StackService;
this.isTemplateVariablesEnabled = isBE;
this.DOCKER_STANDALONE = 'DOCKER_STANDALONE'; this.DOCKER_STANDALONE = 'DOCKER_STANDALONE';
this.DOCKER_SWARM_MODE = 'DOCKER_SWARM_MODE'; this.DOCKER_SWARM_MODE = 'DOCKER_SWARM_MODE';
@@ -119,6 +122,10 @@ class CustomTemplatesViewController {
} }
renderTemplate() { renderTemplate() {
if (!this.isTemplateVariablesEnabled) {
return;
}
const fileContent = renderTemplate(this.state.templateContent, this.formValues.variables, this.state.selectedTemplate.Variables); const fileContent = renderTemplate(this.state.templateContent, this.formValues.variables, this.state.selectedTemplate.Variables);
this.onChangeFormValues({ fileContent }); this.onChangeFormValues({ fileContent });
} }
@@ -46,6 +46,7 @@
</div> </div>
<custom-templates-variables-definition-field <custom-templates-variables-definition-field
ng-if="$ctrl.isTemplateVariablesEnabled"
value="$ctrl.formValues.Variables" value="$ctrl.formValues.Variables"
on-change="($ctrl.onVariablesChange)" on-change="($ctrl.onVariablesChange)"
is-variables-names-from-parent="true" is-variables-names-from-parent="true"
@@ -3,12 +3,15 @@ import { ResourceControlViewModel } from '@/portainer/access-control/models/Reso
import { AccessControlFormData } from 'Portainer/components/accessControlForm/porAccessControlFormModel'; import { AccessControlFormData } from 'Portainer/components/accessControlForm/porAccessControlFormModel';
import { getTemplateVariables, intersectVariables } from '@/react/portainer/custom-templates/components/utils'; import { getTemplateVariables, intersectVariables } from '@/react/portainer/custom-templates/components/utils';
import { isBE } from '@/portainer/feature-flags/feature-flags.service';
class EditCustomTemplateViewController { class EditCustomTemplateViewController {
/* @ngInject */ /* @ngInject */
constructor($async, $state, $window, ModalService, Authentication, CustomTemplateService, FormValidator, Notifications, ResourceControlService) { constructor($async, $state, $window, ModalService, Authentication, CustomTemplateService, FormValidator, Notifications, ResourceControlService) {
Object.assign(this, { $async, $state, $window, ModalService, Authentication, CustomTemplateService, FormValidator, Notifications, ResourceControlService }); Object.assign(this, { $async, $state, $window, ModalService, Authentication, CustomTemplateService, FormValidator, Notifications, ResourceControlService });
this.isTemplateVariablesEnabled = isBE;
this.formValues = null; this.formValues = null;
this.state = { this.state = {
formValidationError: '', formValidationError: '',
@@ -127,6 +130,10 @@ class EditCustomTemplateViewController {
} }
parseTemplate(templateStr) { parseTemplate(templateStr) {
if (!this.isTemplateVariablesEnabled) {
return;
}
const variables = getTemplateVariables(templateStr); const variables = getTemplateVariables(templateStr);
const isValid = !!variables; const isValid = !!variables;
@@ -59,6 +59,7 @@
edge-info="{ key: endpoint.EdgeKey, id: endpoint.EdgeID }" edge-info="{ key: endpoint.EdgeKey, id: endpoint.EdgeID }"
commands="state.edgeScriptCommands" commands="state.edgeScriptCommands"
is-nomad-token-visible="state.showNomad" is-nomad-token-visible="state.showNomad"
hide-async-mode="!endpoint.IsEdgeDevice"
></edge-script-form> ></edge-script-form>
<span class="small text-muted"> <span class="small text-muted">
@@ -67,10 +67,11 @@
<!-- !confirm-password-input --> <!-- !confirm-password-input -->
<!-- note --> <!-- note -->
<div class="form-group"> <div class="form-group">
<div class="col-sm-12 text-muted" ng-if="!form.password.$valid"> <div class="col-sm-12 text-muted">
<p> <p>
<i class="fa fa-exclamation-triangle orange-icon space-right" aria-hidden="true"></i> <i class="fa fa-exclamation-triangle orange-icon space-right" aria-hidden="true"></i>
<span>The password must be at least {{ requiredPasswordLength }} characters long.</span> <span>The password must be at least {{ requiredPasswordLength }} characters long.</span>
<i class="fa fa-check green-icon space-left" aria-hidden="true" ng-if="form.password.$valid && formValues.Password"></i>
</p> </p>
</div> </div>
</div> </div>
+6 -4
View File
@@ -15,7 +15,7 @@
<tr> <tr>
<td>Name</td> <td>Name</td>
<td> <td>
{{ team.Name }} <span ng-if="!settings.TeamSync">{{ team.Name }}</span>
<button class="btn btn-xs btn-danger" ng-if="isAdmin" ng-click="deleteTeam()" <button class="btn btn-xs btn-danger" ng-if="isAdmin" ng-click="deleteTeam()"
><i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Delete this team</button ><i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Delete this team</button
> >
@@ -23,7 +23,9 @@
</tr> </tr>
<tr> <tr>
<td>Leaders</td> <td>Leaders</td>
<td>{{ leaderCount }}</td> <td>
<span ng-if="!settings.TeamSync">{{ leaderCount }}</span>
</td>
</tr> </tr>
<tr> <tr>
<td>Total users in team</td> <td>Total users in team</td>
@@ -175,11 +177,11 @@
<i ng-if="user.TeamRole === 'Leader'" class="fa fa-user-plus" aria-hidden="true" style="margin-right: 2px"></i> <i ng-if="user.TeamRole === 'Leader'" class="fa fa-user-plus" aria-hidden="true" style="margin-right: 2px"></i>
<i ng-if="user.TeamRole === 'Member'" class="fa fa-user" aria-hidden="true" style="margin-right: 2px"></i> <i ng-if="user.TeamRole === 'Member'" class="fa fa-user" aria-hidden="true" style="margin-right: 2px"></i>
{{ user.TeamRole }} {{ user.TeamRole }}
<span style="margin-left: 5px" ng-if="isAdmin"> <span style="margin-left: 5px">
<a style="margin-left: 5px" ng-click="promoteToLeader(user)" ng-if="user.TeamRole === 'Member'" ng-class="{ 'btn disabled py-0': settings.TeamSync }" <a style="margin-left: 5px" ng-click="promoteToLeader(user)" ng-if="user.TeamRole === 'Member'" ng-class="{ 'btn disabled py-0': settings.TeamSync }"
><i class="fa fa-user-plus space-right" aria-hidden="true"></i>Leader</a ><i class="fa fa-user-plus space-right" aria-hidden="true"></i>Leader</a
> >
<a style="margin-left: 5px" ng-click="demoteToMember(user)" ng-if="user.TeamRole === 'Leader'" ng-class="{ 'btn disabled py-0': settings.TeamSync }" <a style="margin-left: 5px" ng-click="demoteToMember(user)" ng-if="isAdmin && user.TeamRole === 'Leader'" ng-class="{ 'btn disabled py-0': settings.TeamSync }"
><i class="fa fa-user-times space-right" aria-hidden="true"></i>Member</a ><i class="fa fa-user-times space-right" aria-hidden="true"></i>Member</a
> >
</span> </span>
+1 -1
View File
@@ -83,7 +83,7 @@
</div> </div>
</div> </div>
<!-- !confirm-password-input --> <!-- !confirm-password-input -->
<password-check-hint ng-if="!form.new_password.$valid"></password-check-hint> <password-check-hint password-valid="form.new_password.$valid && formValues.newPassword"></password-check-hint>
<div class="form-group"> <div class="form-group">
<div class="col-sm-2"> <div class="col-sm-2">
<button <button
+2 -2
View File
@@ -80,10 +80,10 @@
<!-- !confirm-password-input --> <!-- !confirm-password-input -->
<!-- password-check-hint --> <!-- password-check-hint -->
<div class="form-group" ng-if="AuthenticationMethod === 1 && !form.password.$valid"> <div class="form-group" ng-if="AuthenticationMethod === 1">
<div class="col-sm-3 col-lg-2"></div> <div class="col-sm-3 col-lg-2"></div>
<div class="col-sm-8"> <div class="col-sm-8">
<password-check-hint></password-check-hint> <password-check-hint password-valid="form.password.$valid && formValues.Password"></password-check-hint>
</div> </div>
</div> </div>
<!-- ! password-check-hint --> <!-- ! password-check-hint -->
+23
View File
@@ -2,6 +2,7 @@ import _ from 'lodash';
import { Team } from '@/portainer/teams/types'; import { Team } from '@/portainer/teams/types';
import { Role, User, UserId } from '@/portainer/users/types'; import { Role, User, UserId } from '@/portainer/users/types';
import { Environment } from '@/portainer/environments/types';
export function createMockUsers( export function createMockUsers(
count: number, count: number,
@@ -59,3 +60,25 @@ export function createMockResourceGroups(subscription: string, count: number) {
return { value: resourceGroups }; return { value: resourceGroups };
} }
export function createMockEnvironment(): Environment {
return {
TagIds: [],
GroupId: 1,
Type: 1,
Name: 'environment',
Status: 1,
URL: 'url',
Snapshots: [],
Kubernetes: { Snapshots: [] },
EdgeKey: '',
Id: 3,
UserTrusted: false,
Edge: {
AsyncMode: false,
PingInterval: 0,
CommandInterval: 0,
SnapshotInterval: 0,
},
};
}
+5
View File
@@ -0,0 +1,5 @@
import { ResourceControlResponse } from '@/portainer/access-control/types';
export interface PortainerMetadata {
ResourceControl: ResourceControlResponse;
}
@@ -23,6 +23,7 @@ export function EnvironmentSelector({ value, onChange }: Props) {
{environmentTypes.map((eType) => ( {environmentTypes.map((eType) => (
<Option <Option
key={eType.id} key={eType.id}
featureId={eType.featureId}
title={eType.title} title={eType.title}
description={eType.description} description={eType.description}
icon={eType.icon} icon={eType.icon}
@@ -0,0 +1,8 @@
.selected .mask-icon {
color: var(--selected-item-color);
}
.mask-icon {
color: var(--bg-boxselector-color);
transform: scale(1.2);
}
@@ -0,0 +1,21 @@
import clsx from 'clsx';
import styles from './KaaSIcon.module.css';
export interface Props {
selected?: boolean;
className?: string;
}
export function KaaSIcon({ selected, className }: Props) {
return (
<span
className={clsx('fa-stack fa-1x', styles.root, className, {
[styles.selected]: selected,
})}
>
<i className="fas fa-cloud fa-stack-2x" />
<i className={clsx('fas fa-dharmachakra fa-stack-1x', styles.maskIcon)} />
</span>
);
}
@@ -1,4 +1,16 @@
export const environmentTypes = [ import { FeatureId } from '@/portainer/feature-flags/enums';
import { KaaSIcon, Props as KaaSIconProps } from './KaaSIcon';
interface WizardEnvironmentOption {
id: string;
title: string;
icon: string | { ({ selected, className }: KaaSIconProps): JSX.Element };
description: string;
featureId?: FeatureId;
}
export const environmentTypes: WizardEnvironmentOption[] = [
{ {
id: 'docker', id: 'docker',
title: 'Docker', title: 'Docker',
@@ -18,4 +30,18 @@ export const environmentTypes = [
description: 'Connect to ACI environment via API', description: 'Connect to ACI environment via API',
icon: 'fab fa-microsoft', icon: 'fab fa-microsoft',
}, },
] as const; {
id: 'nomad',
title: 'Nomad',
description: 'Connect to HashiCorp Nomad environment via API',
icon: 'nomad-icon',
featureId: FeatureId.NOMAD,
},
{
id: 'kaas',
title: 'KaaS',
description: 'Provision a Kubernetes environment with a cloud provider',
icon: KaaSIcon,
featureId: FeatureId.KAAS_PROVISIONING,
},
];
@@ -118,8 +118,8 @@ export function EnvironmentCreationView() {
]) ])
), ),
}); });
if (localStorage.getItem('wizardRefferer') === 'environments') { if (localStorage.getItem('wizardReferrer') === 'environments') {
localStorage.removeItem('wizardRefferer'); localStorage.removeItem('wizardReferrer');
router.stateService.go('portainer.endpoints'); router.stateService.go('portainer.endpoints');
return; return;
} }
@@ -3,7 +3,6 @@ import { useState } from 'react';
import { CopyButton } from '@/portainer/components/Button/CopyButton'; import { CopyButton } from '@/portainer/components/Button/CopyButton';
import { Code } from '@/portainer/components/Code'; import { Code } from '@/portainer/components/Code';
import { NavTabs } from '@/portainer/components/NavTabs/NavTabs'; import { NavTabs } from '@/portainer/components/NavTabs/NavTabs';
import { getAgentShortVersion } from '@/portainer/views/endpoints/helpers';
import { useAgentDetails } from '@/portainer/environments/queries/useAgentDetails'; import { useAgentDetails } from '@/portainer/environments/queries/useAgentDetails';
const deployments = [ const deployments = [
@@ -28,10 +27,10 @@ export function DeploymentScripts() {
return null; return null;
} }
const { agentVersion } = agentDetailsQuery; const { agentVersion, agentSecret } = agentDetailsQuery;
const options = deployments.map((c) => { const options = deployments.map((c) => {
const code = c.command(agentVersion); const code = c.command(agentVersion, agentSecret);
return { return {
id: c.id, id: c.id,
@@ -65,14 +64,41 @@ function DeployCode({ code }: DeployCodeProps) {
); );
} }
function linuxCommand(agentVersion: string) { function linuxCommand(agentVersion: string, agentSecret: string) {
const agentShortVersion = getAgentShortVersion(agentVersion); const secret =
agentSecret === '' ? '' : `\\\n -e AGENT_SECRET=${agentSecret} `;
return `curl -L https://downloads.portainer.io/ee${agentShortVersion}/agent-stack.yml -o agent-stack.yml && docker stack deploy --compose-file=agent-stack.yml portainer-agent`; return `docker network create \\
--driver overlay \\
portainer_agent_network
docker service create \\
--name portainer_agent \\
--network portainer_agent_network \\
-p 9001:9001/tcp ${secret}\\
--mode global \\
--constraint 'node.platform.os == linux' \\
--mount type=bind,src=//var/run/docker.sock,dst=/var/run/docker.sock \\
--mount type=bind,src=//var/lib/docker/volumes,dst=/var/lib/docker/volumes \\
portainer/agent:${agentVersion}
`;
} }
function winCommand(agentVersion: string) { function winCommand(agentVersion: string, agentSecret: string) {
const agentShortVersion = getAgentShortVersion(agentVersion); const secret =
agentSecret === '' ? '' : `\\\n -e AGENT_SECRET=${agentSecret} `;
return `curl -L https://downloads.portainer.io/ee${agentShortVersion}/agent-stack-windows.yml -o agent-stack-windows.yml && docker stack deploy --compose-file=agent-stack-windows.yml portainer-agent `; return `docker network create \\
--driver overlay \\
portainer_agent_network && \\
docker service create \\
--name portainer_agent \\
--network portainer_agent_network \\
-p 9001:9001/tcp ${secret}\\
--mode global \\
--constraint 'node.platform.os == windows' \\
--mount type=npipe,src=\\\\.\\pipe\\docker_engine,dst=\\\\.\\pipe\\docker_engine \\
--mount type=bind,src=C:\\ProgramData\\docker\\volumes,dst=C:\\ProgramData\\docker\\volumes \\
portainer/agent:${agentVersion}
`;
} }
@@ -43,7 +43,7 @@ export function WizardEndpointsList({ environmentIds }: Props) {
return ( return (
<Widget> <Widget>
<WidgetTitle icon="fa-plug" title="Connected Environments" /> <WidgetTitle icon="fa-plug" title="New Environments" />
<WidgetBody> <WidgetBody>
{environments.map((environment) => ( {environments.map((environment) => (
<div className={styles.wizardListWrapper} key={environment.Id}> <div className={styles.wizardListWrapper} key={environment.Id}>
@@ -65,12 +65,7 @@ export function WizardEndpointsList({ environmentIds }: Props) {
</div> </div>
{isEdgeEnvironment(environment.Type) && ( {isEdgeEnvironment(environment.Type) && (
<div className={styles.wizardListEdgeStatus}> <div className={styles.wizardListEdgeStatus}>
<EdgeIndicator <EdgeIndicator environment={environment} />
edgeId={environment.EdgeID}
checkInInterval={environment.EdgeCheckinInterval}
queryDate={environment.QueryDate}
lastCheckInDate={environment.LastCheckInDate}
/>
</div> </div>
)} )}
</div> </div>
@@ -0,0 +1,99 @@
import { Field, Form, Formik } from 'formik';
import { LoadingButton } from '@/portainer/components/Button/LoadingButton';
import { FormControl } from '@/portainer/components/form-components/FormControl';
import { FormSectionTitle } from '@/portainer/components/form-components/FormSectionTitle';
import { Input } from '@/portainer/components/form-components/Input';
import { Button } from '@/portainer/components/Button';
const initialValues = {
kubeConfig: '',
name: '',
meta: {
groupId: 1,
tagIds: [],
},
};
export function KubeConfigTeaserForm() {
return (
<Formik initialValues={initialValues} onSubmit={() => {}} validateOnMount>
{() => (
<Form className="mt-5">
<FormSectionTitle>Environment details</FormSectionTitle>
<div className="form-group">
<div className="col-sm-12">
<span className="text-primary">
<i
className="fa fa-exclamation-circle space-right"
aria-hidden="true"
/>
</span>
<span className="text-muted small">
Import the
<a
href="https://kubernetes.io/docs/concepts/configuration/organize-cluster-access-kubeconfig/"
target="_blank"
className="space-right space-left"
rel="noreferrer"
>
kubeconfig file
</a>
of an existing Kubernetes cluster located on-premise or on a
cloud platform. This will create a corresponding environment in
Portainer and install the agent on the cluster. Please ensure:
</span>
</div>
<div className="col-sm-12 text-muted small">
<ul className="p-2 pl-4">
<li>You have a load balancer enabled in your cluster</li>
<li>You specify current-context in your kubeconfig</li>
<li>
The kubeconfig is self-contained - including any required
credentials.
</li>
</ul>
<p>
Note: Officially supported cloud providers are Civo, Linode,
DigitalOcean and Microsoft Azure (others are not guaranteed to
work at present)
</p>
</div>
</div>
<FormControl label="Name" required>
<Field
name="name"
as={Input}
data-cy="endpointCreate-nameInput"
placeholder="e.g. docker-prod01 / kubernetes-cluster01"
readOnly
/>
</FormControl>
<FormControl
label="Kubeconfig file"
required
inputId="kubeconfig_file"
>
<Button disabled>Select a file</Button>
</FormControl>
<div className="form-group">
<div className="col-sm-12">
<LoadingButton
className="wizard-connect-button"
loadingText="Connecting environment..."
isLoading={false}
disabled
>
<i className="fa fa-plug" aria-hidden="true" /> Connect
</LoadingButton>
</div>
</div>
</Form>
)}
</Formik>
);
}
@@ -7,11 +7,14 @@ import {
} from '@/portainer/environments/types'; } from '@/portainer/environments/types';
import { BoxSelectorOption } from '@/portainer/components/BoxSelector/types'; import { BoxSelectorOption } from '@/portainer/components/BoxSelector/types';
import { commandsTabs } from '@/react/edge/components/EdgeScriptForm/scripts'; import { commandsTabs } from '@/react/edge/components/EdgeScriptForm/scripts';
import { FeatureId } from '@/portainer/feature-flags/enums';
import { BEFeatureIndicator } from '@/portainer/components/BEFeatureIndicator';
import { AnalyticsStateKey } from '../types'; import { AnalyticsStateKey } from '../types';
import { EdgeAgentTab } from '../shared/EdgeAgentTab'; import { EdgeAgentTab } from '../shared/EdgeAgentTab';
import { AgentPanel } from './AgentPanel'; import { AgentPanel } from './AgentPanel';
import { KubeConfigTeaserForm } from './KubeConfigTeaserForm';
interface Props { interface Props {
onCreate(environment: Environment, analytics: AnalyticsStateKey): void; onCreate(environment: Environment, analytics: AnalyticsStateKey): void;
@@ -20,6 +23,7 @@ interface Props {
const options: BoxSelectorOption< const options: BoxSelectorOption<
| EnvironmentCreationTypes.AgentEnvironment | EnvironmentCreationTypes.AgentEnvironment
| EnvironmentCreationTypes.EdgeAgentEnvironment | EnvironmentCreationTypes.EdgeAgentEnvironment
| EnvironmentCreationTypes.KubeConfigEnvironment
>[] = [ >[] = [
{ {
id: 'agent_endpoint', id: 'agent_endpoint',
@@ -35,6 +39,14 @@ const options: BoxSelectorOption<
description: '', description: '',
value: EnvironmentCreationTypes.EdgeAgentEnvironment, value: EnvironmentCreationTypes.EdgeAgentEnvironment,
}, },
{
id: 'kubeconfig_endpoint',
icon: 'fas fa-cloud-upload-alt',
label: 'Import',
value: EnvironmentCreationTypes.KubeConfigEnvironment,
description: 'Import an existing Kubernetes config',
feature: FeatureId.K8S_CREATE_FROM_KUBECONFIG,
},
]; ];
export function WizardKubernetes({ onCreate }: Props) { export function WizardKubernetes({ onCreate }: Props) {
@@ -72,6 +84,15 @@ export function WizardKubernetes({ onCreate }: Props) {
commands={[{ ...commandsTabs.k8sLinux, label: 'Linux' }]} commands={[{ ...commandsTabs.k8sLinux, label: 'Linux' }]}
/> />
); );
case EnvironmentCreationTypes.KubeConfigEnvironment:
return (
<div className="px-1 py-5 border border-solid border-orange-1">
<BEFeatureIndicator
featureId={options.find((o) => o.value === type)?.feature}
/>
<KubeConfigTeaserForm />
</div>
);
default: default:
throw new Error('Creation type not supported'); throw new Error('Creation type not supported');
} }
@@ -1,22 +1,34 @@
.root { .optionTile {
--selected-item-color: var(--blue-2);
display: block;
width: 200px;
height: 300px;
border: 1px solid rgb(163, 163, 163);
border-radius: 5px; border-radius: 5px;
padding: 25px 20px; padding: 25px 20px;
cursor: pointer; cursor: pointer;
box-shadow: 0 3px 10px -2px rgb(161 170 166 / 60%); box-shadow: 0 3px 10px -2px rgb(161 170 166 / 60%);
margin: 0; margin: 0;
display: block;
width: 200px;
min-height: 300px;
} }
.root:hover { .feature {
--selected-item-color: var(--blue-2);
border: 1px solid rgb(163, 163, 163);
}
.feature:hover {
box-shadow: 0 3px 10px -2px rgb(161 170 166 / 80%); box-shadow: 0 3px 10px -2px rgb(161 170 166 / 80%);
border: 1px solid var(--blue-2); border: 1px solid var(--blue-2);
color: #337ab7; color: #337ab7;
} }
.teaser {
border: 2px solid var(--BE-only) !important;
color: var(--text-muted-color);
}
.teaser:hover {
box-shadow: 0 3px 10px -2px rgb(161 170 166 / 80%);
}
.active:hover { .active:hover {
color: #fff; color: #fff;
} }
@@ -1,6 +1,10 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { ComponentType } from 'react'; import { ComponentType } from 'react';
import { BEFeatureIndicator } from '@/portainer/components/BEFeatureIndicator';
import { FeatureId } from '@/portainer/feature-flags/enums';
import { isLimitedToBE } from '@/portainer/feature-flags/feature-flags.service';
import styles from './Option.module.css'; import styles from './Option.module.css';
export interface SelectorItemType { export interface SelectorItemType {
@@ -12,6 +16,7 @@ export interface SelectorItemType {
interface Props extends SelectorItemType { interface Props extends SelectorItemType {
active?: boolean; active?: boolean;
onClick?(): void; onClick?(): void;
featureId?: FeatureId;
} }
export function Option({ export function Option({
@@ -20,13 +25,22 @@ export function Option({
description, description,
title, title,
onClick = () => {}, onClick = () => {},
featureId,
}: Props) { }: Props) {
const Icon = typeof icon !== 'string' ? icon : null; const Icon = typeof icon !== 'string' ? icon : null;
const isLimited = isLimitedToBE(featureId);
return ( return (
<button <button
className={clsx('border-0', styles.root, { [styles.active]: active })} className={clsx(
styles.optionTile,
isLimited ? styles.teaser : styles.feature,
'border-0',
{
[styles.active]: active,
}
)}
type="button" type="button"
disabled={isLimited}
onClick={onClick} onClick={onClick}
> >
<div className="text-center mt-2"> <div className="text-center mt-2">
@@ -37,9 +51,16 @@ export function Option({
)} )}
</div> </div>
<div className="mt-3 text-center"> <div className="mt-3 text-center flex flex-col">
<h3>{title}</h3> <h3>{title}</h3>
<h5>{description}</h5> <h5>{description}</h5>
{isLimited && (
<BEFeatureIndicator
showIcon={false}
featureId={featureId}
className="!whitespace-normal"
/>
)}
</div> </div>
</button> </button>
); );
+7
View File
@@ -11,6 +11,9 @@ ARCH=$2
DOCKER_VERSION=${3:1} DOCKER_VERSION=${3:1}
DOWNLOAD_FOLDER=".tmp/download" DOWNLOAD_FOLDER=".tmp/download"
if [[ ${PLATFORM} == "darwin" ]]; then
PLATFORM="mac"
fi
if [[ ${ARCH} == "amd64" ]]; then if [[ ${ARCH} == "amd64" ]]; then
ARCH="x86_64" ARCH="x86_64"
@@ -18,6 +21,10 @@ elif [[ ${ARCH} == "arm" ]]; then
ARCH="armhf" ARCH="armhf"
elif [[ ${ARCH} == "arm64" ]]; then elif [[ ${ARCH} == "arm64" ]]; then
ARCH="aarch64" ARCH="aarch64"
elif [[ ${ARCH} == "ppc64le" ]]; then
DOCKER_VERSION="18.06.3-ce"
elif [[ ${ARCH} == "s390x" ]]; then
DOCKER_VERSION="18.06.3-ce"
fi fi
rm -rf "${DOWNLOAD_FOLDER}" rm -rf "${DOWNLOAD_FOLDER}"