Compare commits

...

24 Commits

Author SHA1 Message Date
Steven Kang
14b998d270 Set static DOCKER_VERSION for ppc64le and s390x (#7135) 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

View File

@@ -35,6 +35,12 @@
"TenantID": ""
},
"ComposeSyntaxMaxVersion": "",
"Edge": {
"AsyncMode": false,
"CommandInterval": 0,
"PingInterval": 0,
"SnapshotInterval": 0
},
"EdgeCheckinInterval": 0,
"EdgeKey": "",
"GroupId": 1,
@@ -682,6 +688,12 @@
"BlackListedLabels": [],
"DisplayDonationHeader": false,
"DisplayExternalContributors": false,
"Edge": {
"AsyncMode": false,
"CommandInterval": 0,
"PingInterval": 0,
"SnapshotInterval": 0
},
"EdgeAgentCheckinInterval": 5,
"EdgePortainerUrl": "",
"EnableEdgeComputeFeatures": false,

View File

@@ -30,6 +30,19 @@ type publicSettingsResponse struct {
KubeconfigExpiry string `example:"24h" default:"0"`
// Whether team sync is enabled
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
@@ -61,6 +74,13 @@ func generatePublicSettings(appSettings *portainer.Settings) *publicSettingsResp
KubeconfigExpiry: appSettings.KubeconfigExpiry,
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 publicSettings.AuthenticationMethod == portainer.AuthenticationOAuth {
publicSettings.OAuthLogoutURI = appSettings.OAuthSettings.LogoutURI

View File

@@ -24,11 +24,14 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler {
adminRouter := h.NewRoute().Subrouter()
adminRouter.Use(bouncer.AdminAccess)
restrictedRouter := h.NewRoute().Subrouter()
restrictedRouter.Use(bouncer.RestrictedAccess)
teamLeaderRouter := h.NewRoute().Subrouter()
teamLeaderRouter.Use(bouncer.TeamLeaderAccess)
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)
adminRouter.Handle("/teams/{id}", httperror.LoggerHandler(h.teamUpdate)).Methods(http.MethodPut)
adminRouter.Handle("/teams/{id}", httperror.LoggerHandler(h.teamDelete)).Methods(http.MethodDelete)

View File

@@ -82,7 +82,7 @@ func (handler *Handler) userUpdatePassword(w http.ResponseWriter, r *http.Reques
err = handler.CryptoService.CompareHashAndData(user.Password, payload.Password)
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) {

View File

@@ -23,7 +23,7 @@ type (
}
portainerRegistryAuthenticationHeader struct {
RegistryId portainer.RegistryID `json:"registryId"`
RegistryId *portainer.RegistryID `json:"registryId"`
}
)

View File

@@ -446,7 +446,20 @@ func (transport *Transport) decorateRegistryAuthenticationHeader(request *http.R
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 {
return err
}

View File

@@ -345,6 +345,17 @@ type (
// Whether the device has been trusted or not by the user
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 in DBVersion == 4
TLS bool `json:"TLS,omitempty"`
@@ -837,6 +848,17 @@ type (
// EdgePortainerURL is the URL that is exposed to edge agents
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
DisplayDonationHeader bool
DisplayExternalContributors bool

View File

@@ -1,6 +1,6 @@
import _ from 'lodash';
import { useSettings } from '@/portainer/settings/queries';
import { usePublicSettings } from '@/portainer/settings/queries';
const categories = [
'docker',
@@ -64,7 +64,9 @@ export function push(
}
export function useAnalytics() {
const telemetryQuery = useSettings((settings) => settings.EnableTelemetry);
const telemetryQuery = usePublicSettings({
select: (settings) => settings.EnableTelemetry,
});
return { trackEvent: handleTrackEvent };

View File

@@ -20,3 +20,4 @@ import './app.css';
import './theme.css';
import './vendor-override.css';
import '../fonts/nomad-icon.css';

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.

View File

@@ -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.

View File

@@ -1,7 +1,5 @@
import {
AccessControlFormData,
ResourceControlResponse,
} from '@/portainer/access-control/types';
import { AccessControlFormData } from '@/portainer/access-control/types';
import { PortainerMetadata } from '@/react/docker/types';
import { PortMapping } from './ContainerInstances/CreateContainerInstanceForm/PortsMappingField';
@@ -21,10 +19,6 @@ export interface ContainerInstanceFormValues {
accessControl: AccessControlFormData;
}
interface PortainerMetadata {
ResourceControl: ResourceControlResponse;
}
interface Container {
name: string;
properties: {

View File

@@ -1,7 +1,6 @@
import { render } from '@/react-tools/test-utils';
import { UserContext } from '@/portainer/hooks/useUser';
import { UserViewModel } from '@/portainer/models/user';
import { ResourceControlOwnership } from '@/portainer/access-control/types';
import { DockerNetwork } from '../types';
@@ -113,9 +112,9 @@ function getNetwork(networkName: string): DockerNetwork {
},
],
TeamAccesses: [],
Ownership: ResourceControlOwnership.PUBLIC,
Public: true,
System: false,
AdministratorsOnly: true,
},
},
Scope: 'local',

View File

@@ -9,6 +9,7 @@ import { confirmDeletionAsync } from '@/portainer/services/modal.service/confirm
import { AccessControlPanel } from '@/portainer/access-control/AccessControlPanel/AccessControlPanel';
import { ResourceControlType } from '@/portainer/access-control/types';
import { DockerContainer } from '@/docker/containers/types';
import { ResourceControlViewModel } from '@/portainer/access-control/models/ResourceControlViewModel';
import { useNetwork, useDeleteNetwork } from '../queries';
import { isSystemNetwork } from '../network.helper';
@@ -50,6 +51,12 @@ export function NetworkDetailsView() {
return null;
}
const network = networkQuery.data;
const resourceControl = network.Portainer?.ResourceControl
? new ResourceControlViewModel(network.Portainer.ResourceControl)
: undefined;
return (
<>
<PageHeader
@@ -77,7 +84,7 @@ export function NetworkDetailsView() {
networkId,
])
}
resourceControl={networkQuery.data.Portainer?.ResourceControl}
resourceControl={resourceControl}
resourceType={ResourceControlType.Network}
disableOwnershipChange={isSystemNetwork(networkQuery.data.Name)}
resourceId={networkId}

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';
@@ -44,7 +44,7 @@ export interface DockerNetwork {
Driver: string;
Options: IpamOptions;
};
Portainer: { ResourceControl?: ResourceControlViewModel };
Portainer?: PortainerMetadata;
Options: NetworkOptions;
Containers: NetworkResponseContainers;
}

View File

@@ -15,12 +15,5 @@ export const heartbeat: Column<Environment> = {
export function StatusCell({
row: { original: environment },
}: CellProps<Environment>) {
return (
<EdgeIndicator
checkInInterval={environment.EdgeCheckinInterval}
edgeId={environment.EdgeID}
lastCheckInDate={environment.LastCheckInDate}
queryDate={environment.QueryDate}
/>
);
return <EdgeIndicator environment={environment} />;
}

View File

@@ -4,6 +4,8 @@ import { TableSettingsProvider } from '@/portainer/components/datatables/compone
import { PageHeader } from '@/portainer/components/PageHeader';
import { useEnvironmentList } from '@/portainer/environments/queries/useEnvironmentList';
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 { TableSettings } from './Datatable/types';
@@ -29,6 +31,15 @@ export function WaitingRoomView() {
{ 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>
defaults={{ pageSize: 10, sortBy: { desc: false, id: 'name' } }}
storageKey={storageKey}

View File

@@ -1,6 +1,7 @@
import { buildOption } from '@/portainer/components/BoxSelector';
import { AccessControlFormData } from '@/portainer/components/accessControlForm/porAccessControlFormModel';
import { getTemplateVariables, intersectVariables } from '@/react/portainer/custom-templates/components/utils';
import { isBE } from '@/portainer/feature-flags/feature-flags.service';
class KubeCreateCustomTemplateViewController {
/* @ngInject */
@@ -13,6 +14,7 @@ class KubeCreateCustomTemplateViewController {
];
this.templates = null;
this.isTemplateVariablesEnabled = isBE;
this.state = {
method: 'editor',
@@ -53,6 +55,10 @@ class KubeCreateCustomTemplateViewController {
}
parseTemplate(templateStr) {
if (!this.isTemplateVariablesEnabled) {
return;
}
const variables = getTemplateVariables(templateStr);
const isValid = !!variables;

View File

@@ -37,6 +37,7 @@
</file-upload-form>
<custom-templates-variables-definition-field
ng-if="$ctrl.isTemplateVariablesEnabled"
value="$ctrl.formValues.Variables"
on-change="($ctrl.onVariablesChange)"
is-variables-names-from-parent="$ctrl.state.method === 'editor'"

View File

@@ -1,5 +1,6 @@
import { ResourceControlViewModel } from '@/portainer/access-control/models/ResourceControlViewModel';
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';
class KubeEditCustomTemplateViewController {
@@ -7,6 +8,8 @@ class KubeEditCustomTemplateViewController {
constructor($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.state = {
formValidationError: '',
@@ -60,6 +63,10 @@ class KubeEditCustomTemplateViewController {
}
parseTemplate(templateStr) {
if (!this.isTemplateVariablesEnabled) {
return;
}
const variables = getTemplateVariables(templateStr);
const isValid = !!variables;

View File

@@ -32,6 +32,7 @@
</web-editor-form>
<custom-templates-variables-definition-field
ng-if="$ctrl.isTemplateVariablesEnabled"
value="$ctrl.formValues.Variables"
on-change="($ctrl.onVariablesChange)"
is-variables-names-from-parent="true"

View File

@@ -93,7 +93,7 @@
></custom-template-selector>
<custom-templates-variables-field
ng-if="ctrl.state.template"
ng-if="$ctrl.isTemplateVariablesEnabled && ctrl.state.template"
definitions="ctrl.state.template.Variables"
value="ctrl.formValues.Variables"
on-change="(ctrl.onChangeTemplateVariables)"

View File

@@ -7,6 +7,7 @@ import PortainerError from '@/portainer/error';
import { KubernetesDeployManifestTypes, KubernetesDeployBuildMethods, KubernetesDeployRequestMethods, RepositoryMechanismTypes } from 'Kubernetes/models/deploy';
import { buildOption } from '@/portainer/components/BoxSelector';
import { renderTemplate } from '@/react/portainer/custom-templates/components/utils';
import { isBE } from '@/portainer/feature-flags/feature-flags.service';
class KubernetesDeployController {
/* @ngInject */
@@ -23,6 +24,8 @@ class KubernetesDeployController {
this.CustomTemplateService = CustomTemplateService;
this.DeployMethod = 'manifest';
this.isTemplateVariablesEnabled = isBE;
this.deployOptions = [
buildOption('method_kubernetes', 'fa fa-cubes', 'Kubernetes', 'Kubernetes manifest format', KubernetesDeployManifestTypes.KUBERNETES),
buildOption('method_compose', 'fab fa-docker', 'Compose', 'docker-compose format', KubernetesDeployManifestTypes.COMPOSE),
@@ -83,6 +86,10 @@ class KubernetesDeployController {
}
renderTemplate() {
if (!this.isTemplateVariablesEnabled) {
return;
}
const rendered = renderTemplate(this.state.templateContent, this.formValues.Variables, this.state.template.Variables);
this.onChangeFormValues({ EditorContent: rendered });
}

View File

@@ -33,11 +33,11 @@ export class ResourceControlViewModel {
this.TeamAccesses = data.TeamAccesses;
this.Public = data.Public;
this.System = data.System;
this.Ownership = determineOwnership(this);
this.Ownership = determineOwnership(data);
}
}
function determineOwnership(resourceControl: ResourceControlViewModel) {
function determineOwnership(resourceControl: ResourceControlResponse) {
if (resourceControl.Public) {
return ResourceControlOwnership.PUBLIC;
}

View File

@@ -1,4 +1,5 @@
import { PropsWithChildren } from 'react';
import clsx from 'clsx';
import { FeatureId } from '@/portainer/feature-flags/enums';
@@ -6,11 +7,15 @@ import { getFeatureDetails } from './utils';
export interface Props {
featureId?: FeatureId;
showIcon?: boolean;
className?: string;
}
export function BEFeatureIndicator({
featureId,
children,
showIcon = true,
className = '',
}: PropsWithChildren<Props>) {
const { url, limitedToBE } = getFeatureDetails(featureId);
@@ -20,14 +25,18 @@ export function BEFeatureIndicator({
return (
<a
className="be-indicator"
className={clsx('be-indicator', className)}
href={url}
target="_blank"
rel="noopener noreferrer"
>
{children}
<i className="fas fa-briefcase space-right be-indicator-icon" />
<span className="be-indicator-label">Business Edition Feature</span>
{showIcon && (
<i className="fas fa-briefcase space-right be-indicator-icon" />
)}
<span className="be-indicator-label break-words">
Business Edition Feature
</span>
</a>
);
}

View File

@@ -4,7 +4,7 @@ import { Button } from '../Button';
import { Widget, WidgetBody } from '../widget';
interface Props {
title: string;
title?: string;
onDismiss?(): void;
bodyClassName?: string;
wrapperStyle?: Record<string, string>;
@@ -23,21 +23,23 @@ export function InformationPanel({
<Widget>
<WidgetBody className={bodyClassName}>
<div style={wrapperStyle}>
<div className="col-sm-12 form-section-title">
<span style={{ float: 'left' }}>{title}</span>
{!!onDismiss && (
<span
className="small"
style={{ float: 'right' }}
ng-if="dismissAction"
>
<Button color="link" onClick={() => onDismiss()}>
<i className="fa fa-times" /> dismiss
</Button>
</span>
)}
</div>
<div className="form-group">{children}</div>
{title && (
<div className="col-sm-12 form-section-title">
<span style={{ float: 'left' }}>{title}</span>
{!!onDismiss && (
<span
className="small"
style={{ float: 'right' }}
ng-if="dismissAction"
>
<Button color="link" onClick={() => onDismiss()}>
<i className="fa fa-times" /> dismiss
</Button>
</span>
)}
</div>
)}
<div>{children}</div>
</div>
</WidgetBody>
</Widget>

View File

@@ -1,7 +1,15 @@
import { react2angular } from '@/react-tools/react2angular';
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 minPasswordLength = settingsQuery.data?.RequiredPasswordLength;
@@ -12,10 +20,18 @@ export function PasswordCheckHint() {
className="fa fa-exclamation-triangle orange-icon space-right"
aria-hidden="true"
/>
{forceChangePassword &&
'An administrator has changed your password requirements, '}
The password must be at least {minPasswordLength} characters long.
{passwordValid && (
<i className="fa fa-check green-icon space-left" aria-hidden="true" />
)}
</p>
</div>
);
}
export const PasswordCheckHintAngular = react2angular(PasswordCheckHint, []);
export const PasswordCheckHintAngular = react2angular(PasswordCheckHint, [
'passwordValid',
'forceChangePassword',
]);

View File

@@ -14,7 +14,7 @@
>
<i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Remove
</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
</button>
</div>

View File

@@ -34,8 +34,8 @@ angular.module('portainer.app').controller('EndpointsDatatableController', [
this.paginationChanged();
};
this.setRefferer = function () {
window.localStorage.setItem('wizardRefferer', 'environments');
this.setReferrer = function () {
window.localStorage.setItem('wizardReferrer', 'environments');
};
/**

View File

@@ -29,26 +29,29 @@ export function FormControl({
required,
}: PropsWithChildren<Props>) {
return (
<div className={clsx('form-group', styles.container)}>
<label
htmlFor={inputId}
className={clsx(sizeClassLabel(size), 'control-label', 'text-left')}
>
{label}
<>
<div className={clsx('form-group', styles.container)}>
<label
htmlFor={inputId}
className={clsx(sizeClassLabel(size), 'control-label', 'text-left')}
>
{label}
{required && <span className="text-danger">*</span>}
{required && <span className="text-danger">*</span>}
{tooltip && <Tooltip message={tooltip} />}
</label>
<div className={sizeClassChildren(size)}>{children}</div>
{tooltip && <Tooltip message={tooltip} />}
</label>
<div className={sizeClassChildren(size)}>{children}</div>
</div>
{errors && (
<div className="col-md-12">
<FormError>{errors}</FormError>
<div className="form-group">
<div className="col-md-12">
<FormError>{errors}</FormError>
</div>
</div>
)}
</div>
</>
);
}

View File

@@ -54,6 +54,13 @@ export interface KubernetesSettings {
Snapshots?: KubernetesSnapshot[] | null;
}
export type EnvironmentEdge = {
AsyncMode: boolean;
PingInterval: number;
SnapshotInterval: number;
CommandInterval: number;
};
export type Environment = {
Id: EnvironmentId;
Type: EnvironmentType;
@@ -73,6 +80,7 @@ export type Environment = {
IsEdgeDevice?: boolean;
UserTrusted: boolean;
AMTDeviceGUID?: string;
Edge: EnvironmentEdge;
};
/**
* TS reference of endpoint_create.go#EndpointCreationType iota
@@ -83,6 +91,7 @@ export enum EnvironmentCreationTypes {
AzureEnvironment,
EdgeAgentEnvironment,
LocalKubernetesEnvironment,
KubeConfigEnvironment,
}
export enum PlatformType {

View File

@@ -12,6 +12,9 @@ export enum FeatureState {
export enum FeatureId {
K8S_RESOURCE_POOL_LB_QUOTA = 'k8s-resourcepool-Ibquota',
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',
REGISTRY_MANAGEMENT = 'registry-management',
K8S_SETUP_DEFAULT = 'k8s-setup-default',

View File

@@ -1,5 +1,6 @@
import { Edition, FeatureId, FeatureState } from './enums';
export const isBE = process.env.PORTAINER_EDITION === 'BE';
interface ServiceState {
currentEdition: Edition;
features: Record<FeatureId, Edition>;
@@ -16,6 +17,9 @@ export async function init(edition: Edition) {
const features = {
[FeatureId.K8S_RESOURCE_POOL_LB_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.EXTERNAL_AUTH_LDAP]: Edition.BE,
[FeatureId.HIDE_INTERNAL_AUTH]: Edition.BE,

View File

@@ -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';
test('when edge id is not set, should show unassociated label', () => {
const { queryByLabelText } = renderComponent();
test('when edge id is not set, should show unassociated label', async () => {
const { queryByLabelText } = await renderComponent();
const unassociatedLabel = queryByLabelText('unassociated');
expect(unassociatedLabel).toBeVisible();
});
test('given edge id and last checkin is set, should show heartbeat', () => {
const { queryByLabelText } = renderComponent('id', 1);
// test('given edge id and last checkin is set, should show heartbeat', async () => {
// const { queryByLabelText } = await renderComponent('id', 1);
expect(queryByLabelText('edge-heartbeat')).toBeVisible();
expect(queryByLabelText('edge-last-checkin')).toBeVisible();
});
// expect(queryByLabelText('edge-heartbeat')).toBeVisible();
// expect(queryByLabelText('edge-last-checkin')).toBeVisible();
// });
function renderComponent(
async function renderComponent(
edgeId = '',
lastCheckInDate = 0,
checkInInterval = 0,
queryDate = 0
) {
return render(
<EdgeIndicator
edgeId={edgeId}
lastCheckInDate={lastCheckInDate}
checkInInterval={checkInInterval}
queryDate={queryDate}
showLastCheckInDate
/>
server.use(rest.get('/api/settings', (req, res, ctx) => res(ctx.json({}))));
const environment = createMockEnvironment();
environment.EdgeID = edgeId;
environment.LastCheckInDate = lastCheckInDate;
environment.EdgeCheckinInterval = checkInInterval;
environment.QueryDate = queryDate;
const queries = renderWithQueryClient(
<EdgeIndicator environment={environment} showLastCheckInDate />
);
await expect(queries.findByRole('status')).resolves.toBeVisible();
return queries;
}

View File

@@ -1,56 +1,114 @@
import clsx from 'clsx';
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 {
checkInInterval?: number;
edgeId?: string;
queryDate?: number;
lastCheckInDate?: number;
showLastCheckInDate?: boolean;
environment: Environment;
}
export function EdgeIndicator({
edgeId,
lastCheckInDate,
checkInInterval,
queryDate,
environment,
showLastCheckInDate = false,
}: Props) {
if (!edgeId) {
const associated = !!environment.EdgeID;
const isValid = useHasHeartbeat(environment, associated);
if (isValid === null) {
return null;
}
if (!associated) {
return (
<span className="label label-default" aria-label="unassociated">
<s>associated</s>
<span role="status" aria-label="edge-status">
<span className="label label-default" aria-label="unassociated">
<s>associated</s>
</span>
</span>
);
}
// give checkIn some wiggle room
let isCheckValid = false;
if (checkInInterval && queryDate && lastCheckInDate) {
isCheckValid = queryDate - lastCheckInDate <= checkInInterval * 2 + 20;
}
return (
<span>
<span role="status" aria-label="edge-status">
<span
className={clsx('label', {
'label-danger': !isCheckValid,
'label-success': isCheckValid,
'label-danger': !isValid,
'label-success': isValid,
})}
aria-label="edge-heartbeat"
>
heartbeat
</span>
{showLastCheckInDate && !!lastCheckInDate && (
{showLastCheckInDate && !!environment.LastCheckInDate && (
<span
className="space-left small text-muted"
aria-label="edge-last-checkin"
>
{isoDateFromTimestamp(lastCheckInDate)}
{isoDateFromTimestamp(environment.LastCheckInDate)}
</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;
}

View File

@@ -5,6 +5,7 @@ import {
EnvironmentStatus,
EnvironmentType,
} from '@/portainer/environments/types';
import { createMockEnvironment } from '@/react-tools/test-mocks';
import { EnvironmentItem } from './EnvironmentItem';
@@ -57,19 +58,9 @@ KubernetesEdgeEnvironment.args = {
};
function mockEnvironment(type: EnvironmentType): Environment {
return {
Id: 1,
Name: 'environment',
GroupId: 1,
Snapshots: [],
Status: EnvironmentStatus.Up,
TagIds: [],
Type: type,
Kubernetes: {
Snapshots: [],
},
URL: 'url',
UserTrusted: false,
EdgeKey: '',
};
const env = createMockEnvironment();
env.Type = type;
env.Status = EnvironmentStatus.Up;
return env;
}

View File

@@ -6,25 +6,14 @@ import { Environment } from '@/portainer/environments/types';
import { UserContext } from '@/portainer/hooks/useUser';
import { UserViewModel } from '@/portainer/models/user';
import { Tag } from '@/portainer/tags/types';
import { createMockEnvironment } from '@/react-tools/test-mocks';
import { renderWithQueryClient } from '@/react-tools/test-utils';
import { server, rest } from '@/setup-tests/server';
import { EnvironmentItem } from './EnvironmentItem';
test('loads component', async () => {
const env: Environment = {
TagIds: [],
GroupId: 1,
Type: 1,
Name: 'environment',
Status: 1,
URL: 'url',
Snapshots: [],
Kubernetes: { Snapshots: [] },
Id: 3,
UserTrusted: false,
EdgeKey: '',
};
const env = createMockEnvironment();
const { getByText } = renderComponent(env);
expect(getByText(env.Name)).toBeInTheDocument();
@@ -34,19 +23,8 @@ test('shows group name', async () => {
const groupName = 'group-name';
const groupId: EnvironmentGroupId = 14;
const env: Environment = {
TagIds: [],
GroupId: groupId,
Type: 1,
Name: 'environment',
Status: 1,
URL: 'url',
Snapshots: [],
Kubernetes: { Snapshots: [] },
Id: 3,
UserTrusted: false,
EdgeKey: '',
};
const env = createMockEnvironment();
env.GroupId = groupId;
const { findByText } = renderComponent(env, { Name: groupName });

View File

@@ -68,10 +68,8 @@ export function EnvironmentItem({ environment, onClick, groupName }: Props) {
<span className="space-left blocklist-item-subtitle">
{isEdge ? (
<EdgeIndicator
edgeId={environment.EdgeID}
checkInInterval={environment.EdgeCheckinInterval}
lastCheckInDate={environment.LastCheckInDate}
queryDate={environment.QueryDate}
environment={environment}
showLastCheckInDate
/>
) : (
<>

View File

@@ -34,6 +34,8 @@ export function PublicSettingsViewModel(settings) {
this.EnableTelemetry = settings.EnableTelemetry;
this.OAuthLogoutURI = settings.OAuthLogoutURI;
this.KubeconfigExpiry = settings.KubeconfigExpiry;
this.Features = settings.Features;
this.Edge = new EdgeSettingsViewModel(settings.Edge);
}
export function InternalAuthSettingsViewModel(data) {
@@ -75,3 +77,10 @@ export function OAuthSettingsViewModel(data) {
this.SSO = data.SSO;
this.LogoutURI = data.LogoutURI;
}
export function EdgeSettingsViewModel(data = {}) {
this.CheckinInterval = data.CheckinInterval;
this.PingInterval = data.PingInterval;
this.SnapshotInterval = data.SnapshotInterval;
this.CommandInterval = data.CommandInterval;
}

View File

@@ -25,6 +25,7 @@ function StateManagerFactory(
UI: {
dismissedInfoPanels: {},
dismissedInfoHash: '',
timesPasswordChangeSkipped: {},
},
};
@@ -49,13 +50,13 @@ function StateManagerFactory(
};
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;
LocalStorage.storeUIState(state.UI);
};
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);
};
@@ -141,11 +142,6 @@ function StateManagerFactory(
manager.initialize = initialize;
async function initialize() {
return $async(async () => {
const UIState = LocalStorage.getUIState();
if (UIState) {
state.UI = UIState;
}
const endpointState = LocalStorage.getEndpointState();
if (endpointState) {
state.endpoint = endpointState;
@@ -158,6 +154,16 @@ function StateManagerFactory(
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;
$analytics.setPortainerStatus(state.application.instanceId, state.application.version);
$analytics.setOptOut(!state.application.enableTelemetry);

View File

@@ -1,5 +1,6 @@
import { FormSectionTitle } from '@/portainer/components/form-components/FormSectionTitle';
import { react2angular } from '@/react-tools/react2angular';
import { confirm } from '@/portainer/services/modal.service/confirm';
import { SaveAuthSettingsButton } from '../components/SaveAuthSettingsButton';
import { Settings } from '../../types';
@@ -19,6 +20,27 @@ export function InternalAuth({
value,
onChange,
}: 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 (
<>
<FormSectionTitle>Information</FormSectionTitle>
@@ -34,7 +56,7 @@ export function InternalAuth({
<div className="form-group">
<PasswordLengthSlider
min={8}
min={1}
max={18}
step={1}
value={value.RequiredPasswordLength}
@@ -42,7 +64,7 @@ export function InternalAuth({
/>
</div>
<SaveAuthSettingsButton onSubmit={onSaveSettings} isLoading={isLoading} />
<SaveAuthSettingsButton onSubmit={onSubmit} isLoading={isLoading} />
</>
);
}

View File

@@ -2,6 +2,8 @@ import { useMutation, useQuery, useQueryClient } from 'react-query';
import { notifyError } from '@/portainer/services/notifications';
import { PublicSettingsViewModel } from '../models/settings';
import {
publicSettings,
getSettings,
@@ -9,17 +11,29 @@ import {
} from './settings.service';
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(), {
onError: (err) => {
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, {
select,
enabled,
meta: {
error: {
title: 'Failure',

View File

@@ -125,4 +125,10 @@ export interface Settings {
AllowStackManagementForRegularUsers: boolean;
AllowDeviceMappingForRegularUsers: boolean;
AllowContainerCapabilitiesForRegularUsers: boolean;
Edge: {
PingInterval: number;
SnapshotInterval: number;
CommandInterval: number;
AsyncMode: boolean;
};
}

View File

@@ -61,7 +61,7 @@ export function CreateAccessToken({
return (
<Widget>
<WidgetBody>
<div>
<div className="form-horizontal">
<FormControl
inputId="input"
label={t('Description')}

View File

@@ -54,7 +54,7 @@
</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">
<p class="text-muted">
<i class="fa fa-exclamation-circle blue-icon" aria-hidden="true"></i>

View File

@@ -22,7 +22,7 @@ angular.module('portainer.app').controller('AccountController', [
try {
await UserService.updateUserPassword($scope.userID, $scope.formValues.currentPassword, $scope.formValues.newPassword);
Notifications.success('Success', 'Password successfully updated');
StateManager.resetPasswordChangeSkips($scope.userID);
StateManager.resetPasswordChangeSkips($scope.userID.toString());
$scope.forceChangePassword = false;
$state.go('portainer.logout');
} catch (err) {
@@ -34,7 +34,7 @@ angular.module('portainer.app').controller('AccountController', [
$scope.skipPasswordChange = async function () {
try {
if ($scope.userCanSkip()) {
StateManager.setPasswordChangeSkipped($scope.userID);
StateManager.setPasswordChangeSkipped($scope.userID.toString());
$scope.forceChangePassword = false;
$state.go('portainer.home');
}
@@ -48,8 +48,13 @@ angular.module('portainer.app').controller('AccountController', [
};
this.uiCanExit = (newTransition) => {
if ($scope.userRole === 1 && newTransition.to().name === 'portainer.settings.authentication') {
return true;
if (newTransition) {
if ($scope.userRole === 1 && newTransition.to().name === 'portainer.settings.authentication') {
return true;
}
if (newTransition.to().name === 'portainer.logout') {
return true;
}
}
if ($scope.forceChangePassword) {
ModalService.confirmForceChangePassword();
@@ -127,11 +132,13 @@ angular.module('portainer.app').controller('AccountController', [
$scope.AuthenticationMethod = data.AuthenticationMethod;
if (state.UI.requiredPasswordLength && state.UI.requiredPasswordLength !== data.RequiredPasswordLength) {
StateManager.clearPasswordChangeSkips($scope.userID);
StateManager.clearPasswordChangeSkips();
}
$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;
StateManager.setRequiredPasswordLength(data.RequiredPasswordLength);

View File

@@ -108,6 +108,7 @@
</div>
<custom-templates-variables-definition-field
ng-if="$ctrl.isTemplateVariablesEnabled"
value="$ctrl.formValues.Variables"
on-change="($ctrl.onVariablesChange)"
is-variables-names-from-parent="$ctrl.state.Method === 'editor'"

View File

@@ -2,6 +2,7 @@ import _ from 'lodash';
import { AccessControlFormData } from 'Portainer/components/accessControlForm/porAccessControlFormModel';
import { TEMPLATE_NAME_VALIDATION_REGEX } from '@/constants';
import { getTemplateVariables, intersectVariables } from '@/react/portainer/custom-templates/components/utils';
import { isBE } from '@/portainer/feature-flags/feature-flags.service';
class CreateCustomTemplateViewController {
/* @ngInject */
@@ -20,6 +21,8 @@ class CreateCustomTemplateViewController {
StateManager,
});
this.isTemplateVariablesEnabled = isBE;
this.formValues = {
Title: '',
FileContent: '',
@@ -176,6 +179,10 @@ class CreateCustomTemplateViewController {
}
parseTemplate(templateStr) {
if (!this.isTemplateVariablesEnabled) {
return;
}
const variables = getTemplateVariables(templateStr);
const isValid = !!variables;

View File

@@ -18,6 +18,7 @@
>
<advanced-form>
<custom-templates-variables-field
ng-if="$ctrl.isTemplateVariablesEnabled"
definitions="$ctrl.state.selectedTemplate.Variables"
value="$ctrl.formValues.variables"
on-change="($ctrl.onChangeTemplateVariables)"

View File

@@ -2,6 +2,7 @@ import _ from 'lodash-es';
import { AccessControlFormData } from 'Portainer/components/accessControlForm/porAccessControlFormModel';
import { TEMPLATE_NAME_VALIDATION_REGEX } from '@/constants';
import { renderTemplate } from '@/react/portainer/custom-templates/components/utils';
import { isBE } from '@/portainer/feature-flags/feature-flags.service';
class CustomTemplatesViewController {
/* @ngInject */
@@ -34,6 +35,8 @@ class CustomTemplatesViewController {
this.StateManager = StateManager;
this.StackService = StackService;
this.isTemplateVariablesEnabled = isBE;
this.DOCKER_STANDALONE = 'DOCKER_STANDALONE';
this.DOCKER_SWARM_MODE = 'DOCKER_SWARM_MODE';
@@ -119,6 +122,10 @@ class CustomTemplatesViewController {
}
renderTemplate() {
if (!this.isTemplateVariablesEnabled) {
return;
}
const fileContent = renderTemplate(this.state.templateContent, this.formValues.variables, this.state.selectedTemplate.Variables);
this.onChangeFormValues({ fileContent });
}

View File

@@ -46,6 +46,7 @@
</div>
<custom-templates-variables-definition-field
ng-if="$ctrl.isTemplateVariablesEnabled"
value="$ctrl.formValues.Variables"
on-change="($ctrl.onVariablesChange)"
is-variables-names-from-parent="true"

View File

@@ -3,12 +3,15 @@ import { ResourceControlViewModel } from '@/portainer/access-control/models/Reso
import { AccessControlFormData } from 'Portainer/components/accessControlForm/porAccessControlFormModel';
import { getTemplateVariables, intersectVariables } from '@/react/portainer/custom-templates/components/utils';
import { isBE } from '@/portainer/feature-flags/feature-flags.service';
class EditCustomTemplateViewController {
/* @ngInject */
constructor($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.state = {
formValidationError: '',
@@ -127,6 +130,10 @@ class EditCustomTemplateViewController {
}
parseTemplate(templateStr) {
if (!this.isTemplateVariablesEnabled) {
return;
}
const variables = getTemplateVariables(templateStr);
const isValid = !!variables;

View File

@@ -59,6 +59,7 @@
edge-info="{ key: endpoint.EdgeKey, id: endpoint.EdgeID }"
commands="state.edgeScriptCommands"
is-nomad-token-visible="state.showNomad"
hide-async-mode="!endpoint.IsEdgeDevice"
></edge-script-form>
<span class="small text-muted">

View File

@@ -67,10 +67,11 @@
<!-- !confirm-password-input -->
<!-- note -->
<div class="form-group">
<div class="col-sm-12 text-muted" ng-if="!form.password.$valid">
<div class="col-sm-12 text-muted">
<p>
<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>
<i class="fa fa-check green-icon space-left" aria-hidden="true" ng-if="form.password.$valid && formValues.Password"></i>
</p>
</div>
</div>

View File

@@ -15,7 +15,7 @@
<tr>
<td>Name</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()"
><i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Delete this team</button
>
@@ -23,7 +23,9 @@
</tr>
<tr>
<td>Leaders</td>
<td>{{ leaderCount }}</td>
<td>
<span ng-if="!settings.TeamSync">{{ leaderCount }}</span>
</td>
</tr>
<tr>
<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 === 'Member'" class="fa fa-user" aria-hidden="true" style="margin-right: 2px"></i>
{{ 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 }"
><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
>
</span>

View File

@@ -83,7 +83,7 @@
</div>
</div>
<!-- !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="col-sm-2">
<button

View File

@@ -80,10 +80,10 @@
<!-- !confirm-password-input -->
<!-- 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-8">
<password-check-hint></password-check-hint>
<password-check-hint password-valid="form.password.$valid && formValues.Password"></password-check-hint>
</div>
</div>
<!-- ! password-check-hint -->

View File

@@ -2,6 +2,7 @@ import _ from 'lodash';
import { Team } from '@/portainer/teams/types';
import { Role, User, UserId } from '@/portainer/users/types';
import { Environment } from '@/portainer/environments/types';
export function createMockUsers(
count: number,
@@ -59,3 +60,25 @@ export function createMockResourceGroups(subscription: string, count: number) {
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,
},
};
}

View File

@@ -0,0 +1,5 @@
import { ResourceControlResponse } from '@/portainer/access-control/types';
export interface PortainerMetadata {
ResourceControl: ResourceControlResponse;
}

View File

@@ -23,6 +23,7 @@ export function EnvironmentSelector({ value, onChange }: Props) {
{environmentTypes.map((eType) => (
<Option
key={eType.id}
featureId={eType.featureId}
title={eType.title}
description={eType.description}
icon={eType.icon}

View File

@@ -0,0 +1,8 @@
.selected .mask-icon {
color: var(--selected-item-color);
}
.mask-icon {
color: var(--bg-boxselector-color);
transform: scale(1.2);
}

View File

@@ -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>
);
}

View File

@@ -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',
title: 'Docker',
@@ -18,4 +30,18 @@ export const environmentTypes = [
description: 'Connect to ACI environment via API',
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,
},
];

View File

@@ -118,8 +118,8 @@ export function EnvironmentCreationView() {
])
),
});
if (localStorage.getItem('wizardRefferer') === 'environments') {
localStorage.removeItem('wizardRefferer');
if (localStorage.getItem('wizardReferrer') === 'environments') {
localStorage.removeItem('wizardReferrer');
router.stateService.go('portainer.endpoints');
return;
}

View File

@@ -3,7 +3,6 @@ import { useState } from 'react';
import { CopyButton } from '@/portainer/components/Button/CopyButton';
import { Code } from '@/portainer/components/Code';
import { NavTabs } from '@/portainer/components/NavTabs/NavTabs';
import { getAgentShortVersion } from '@/portainer/views/endpoints/helpers';
import { useAgentDetails } from '@/portainer/environments/queries/useAgentDetails';
const deployments = [
@@ -28,10 +27,10 @@ export function DeploymentScripts() {
return null;
}
const { agentVersion } = agentDetailsQuery;
const { agentVersion, agentSecret } = agentDetailsQuery;
const options = deployments.map((c) => {
const code = c.command(agentVersion);
const code = c.command(agentVersion, agentSecret);
return {
id: c.id,
@@ -65,14 +64,41 @@ function DeployCode({ code }: DeployCodeProps) {
);
}
function linuxCommand(agentVersion: string) {
const agentShortVersion = getAgentShortVersion(agentVersion);
function linuxCommand(agentVersion: string, agentSecret: string) {
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) {
const agentShortVersion = getAgentShortVersion(agentVersion);
function winCommand(agentVersion: string, agentSecret: string) {
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}
`;
}

View File

@@ -43,7 +43,7 @@ export function WizardEndpointsList({ environmentIds }: Props) {
return (
<Widget>
<WidgetTitle icon="fa-plug" title="Connected Environments" />
<WidgetTitle icon="fa-plug" title="New Environments" />
<WidgetBody>
{environments.map((environment) => (
<div className={styles.wizardListWrapper} key={environment.Id}>
@@ -65,12 +65,7 @@ export function WizardEndpointsList({ environmentIds }: Props) {
</div>
{isEdgeEnvironment(environment.Type) && (
<div className={styles.wizardListEdgeStatus}>
<EdgeIndicator
edgeId={environment.EdgeID}
checkInInterval={environment.EdgeCheckinInterval}
queryDate={environment.QueryDate}
lastCheckInDate={environment.LastCheckInDate}
/>
<EdgeIndicator environment={environment} />
</div>
)}
</div>

View File

@@ -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>
);
}

View File

@@ -7,11 +7,14 @@ import {
} from '@/portainer/environments/types';
import { BoxSelectorOption } from '@/portainer/components/BoxSelector/types';
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 { EdgeAgentTab } from '../shared/EdgeAgentTab';
import { AgentPanel } from './AgentPanel';
import { KubeConfigTeaserForm } from './KubeConfigTeaserForm';
interface Props {
onCreate(environment: Environment, analytics: AnalyticsStateKey): void;
@@ -20,6 +23,7 @@ interface Props {
const options: BoxSelectorOption<
| EnvironmentCreationTypes.AgentEnvironment
| EnvironmentCreationTypes.EdgeAgentEnvironment
| EnvironmentCreationTypes.KubeConfigEnvironment
>[] = [
{
id: 'agent_endpoint',
@@ -35,6 +39,14 @@ const options: BoxSelectorOption<
description: '',
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) {
@@ -72,6 +84,15 @@ export function WizardKubernetes({ onCreate }: Props) {
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:
throw new Error('Creation type not supported');
}

View File

@@ -1,22 +1,34 @@
.root {
--selected-item-color: var(--blue-2);
display: block;
width: 200px;
height: 300px;
border: 1px solid rgb(163, 163, 163);
.optionTile {
border-radius: 5px;
padding: 25px 20px;
cursor: pointer;
box-shadow: 0 3px 10px -2px rgb(161 170 166 / 60%);
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%);
border: 1px solid var(--blue-2);
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 {
color: #fff;
}

View File

@@ -1,6 +1,10 @@
import clsx from 'clsx';
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';
export interface SelectorItemType {
@@ -12,6 +16,7 @@ export interface SelectorItemType {
interface Props extends SelectorItemType {
active?: boolean;
onClick?(): void;
featureId?: FeatureId;
}
export function Option({
@@ -20,13 +25,22 @@ export function Option({
description,
title,
onClick = () => {},
featureId,
}: Props) {
const Icon = typeof icon !== 'string' ? icon : null;
const isLimited = isLimitedToBE(featureId);
return (
<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"
disabled={isLimited}
onClick={onClick}
>
<div className="text-center mt-2">
@@ -37,9 +51,16 @@ export function Option({
)}
</div>
<div className="mt-3 text-center">
<div className="mt-3 text-center flex flex-col">
<h3>{title}</h3>
<h5>{description}</h5>
{isLimited && (
<BEFeatureIndicator
showIcon={false}
featureId={featureId}
className="!whitespace-normal"
/>
)}
</div>
</button>
);

View File

@@ -11,6 +11,9 @@ ARCH=$2
DOCKER_VERSION=${3:1}
DOWNLOAD_FOLDER=".tmp/download"
if [[ ${PLATFORM} == "darwin" ]]; then
PLATFORM="mac"
fi
if [[ ${ARCH} == "amd64" ]]; then
ARCH="x86_64"
@@ -18,6 +21,10 @@ elif [[ ${ARCH} == "arm" ]]; then
ARCH="armhf"
elif [[ ${ARCH} == "arm64" ]]; then
ARCH="aarch64"
elif [[ ${ARCH} == "ppc64le" ]]; then
DOCKER_VERSION="18.06.3-ce"
elif [[ ${ARCH} == "s390x" ]]; then
DOCKER_VERSION="18.06.3-ce"
fi
rm -rf "${DOWNLOAD_FOLDER}"