Compare commits

...

29 Commits

Author SHA1 Message Date
Matt Hook
1bb253479a bump version to 2.14.1 (#7237) 2022-07-12 11:03:49 +12:00
Dakota Walsh
f0a13a2ad1 fix(migration): close the database before running backups EE-3627 (#7217)
* fix(migration): close the database before running backups

On certain filesystems, particuarly NTFS when a network mounted windows
file server is used to store portainer's database, you are unable to
copy the database while it is open. To fix this we simply close the
database and then re-open it after a backup.

* handle close and open errors

* dont return error on nil
2022-07-08 21:04:55 +12:00
Matt Hook
f9b28aa0a1 fix(compose): use docker-compose plugin directly [EE-3631] (#7201)
* use simplifed method of calling compose directly with new compose wrapper

* download compose binary to docker-compose

* update to newer wrapper that fixes -H issue

* update to released
2022-07-08 16:02:37 +12:00
LP B
d26e1b6983 fix(k8s/app-templates): display moustache variables fields when deploying from app template (#7185) 2022-07-08 14:15:16 +12:00
Dmitry Salakhov
7b00fdd208 fix(users): enable manual user addition [EE-3639] (#7196) 2022-07-06 15:47:51 +12:00
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
83 changed files with 871 additions and 239 deletions

View File

@@ -103,8 +103,26 @@ func (store *Store) backupWithOptions(options *BackupOptions) (string, error) {
store.createBackupFolders()
options = store.setupOptions(options)
dbPath := store.databasePath()
return options.BackupPath, store.copyDBFile(store.databasePath(), options.BackupPath)
if err := store.Close(); err != nil {
return options.BackupPath, fmt.Errorf(
"error closing datastore before creating backup: %v",
err,
)
}
if err := store.copyDBFile(dbPath, options.BackupPath); err != nil {
return options.BackupPath, err
}
if _, err := store.Open(); err != nil {
return options.BackupPath, fmt.Errorf(
"error opening datastore after creating backup: %v",
err,
)
}
return options.BackupPath, nil
}
// RestoreWithOptions previously saved backup for the current Edition with options

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,
@@ -898,7 +910,7 @@
],
"version": {
"DB_UPDATING": "false",
"DB_VERSION": "50",
"DB_VERSION": "51",
"INSTANCE_ID": "null"
}
}

View File

@@ -81,7 +81,7 @@ func (manager *ComposeStackManager) Down(ctx context.Context, stack *portainer.S
}
filePaths := stackutils.GetStackFilePaths(stack)
err = manager.deployer.Remove(ctx, stack.ProjectPath, url, stack.Name, filePaths)
err = manager.deployer.Remove(ctx, stack.ProjectPath, url, stack.Name, filePaths, "")
return errors.Wrap(err, "failed to remove a stack")
}

View File

@@ -32,7 +32,7 @@ require (
github.com/koding/websocketproxy v0.0.0-20181220232114-7ed82d81a28c
github.com/orcaman/concurrent-map v0.0.0-20190826125027-8c72a8bb44f6
github.com/pkg/errors v0.9.1
github.com/portainer/docker-compose-wrapper v0.0.0-20220531190153-c597b853e410
github.com/portainer/docker-compose-wrapper v0.0.0-20220708023447-a69a4ebaa021
github.com/portainer/libcrypto v0.0.0-20210422035235-c652195c5c3a
github.com/portainer/libhelm v0.0.0-20210929000907-825e93d62108
github.com/portainer/libhttp v0.0.0-20211208103139-07a5f798eb3f

View File

@@ -807,10 +807,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/portainer/docker-compose-wrapper v0.0.0-20220526210722-e1574867298e h1:gW1Ooaj7RZ9YkwHxesnNEyOB5nUD71FlZ7cdb5h63vw=
github.com/portainer/docker-compose-wrapper v0.0.0-20220526210722-e1574867298e/go.mod h1:WxDlJWZxCnicdLCPnLNEv7/gRhjeIVuCGmsv+iOPH3c=
github.com/portainer/docker-compose-wrapper v0.0.0-20220531190153-c597b853e410 h1:LjxLd8UGR8ae73ov/vLrt/0jedj/nh98XnONkr8DJj8=
github.com/portainer/docker-compose-wrapper v0.0.0-20220531190153-c597b853e410/go.mod h1:WxDlJWZxCnicdLCPnLNEv7/gRhjeIVuCGmsv+iOPH3c=
github.com/portainer/docker-compose-wrapper v0.0.0-20220708023447-a69a4ebaa021 h1:GFTn2e5AyIoBuK6hXbdVNkuV2m450DQnYmgQDZRU3x8=
github.com/portainer/docker-compose-wrapper v0.0.0-20220708023447-a69a4ebaa021/go.mod h1:WxDlJWZxCnicdLCPnLNEv7/gRhjeIVuCGmsv+iOPH3c=
github.com/portainer/libcrypto v0.0.0-20210422035235-c652195c5c3a h1:qY8TbocN75n5PDl16o0uVr5MevtM5IhdwSelXEd4nFM=
github.com/portainer/libcrypto v0.0.0-20210422035235-c652195c5c3a/go.mod h1:n54EEIq+MM0NNtqLeCby8ljL+l275VpolXO0ibHegLE=
github.com/portainer/libhelm v0.0.0-20210929000907-825e93d62108 h1:5e8KAnDa2G3cEHK7aV/ue8lOaoQwBZUzoALslwWkR04=

View File

@@ -80,7 +80,7 @@ type Handler struct {
}
// @title PortainerCE API
// @version 2.14.0
// @version 2.14.1
// @description.markdown api-description.md
// @termsOfService

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
@@ -1363,9 +1385,9 @@ type (
const (
// APIVersion is the version number of the Portainer API
APIVersion = "2.14.0"
APIVersion = "2.14.1"
// DBVersion is the version number of the Portainer database
DBVersion = 50
DBVersion = 51
// ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax
ComposeSyntaxMaxVersion = "3.9"
// AssetsServerURL represents the URL of the Portainer asset server

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

@@ -5,6 +5,8 @@ import { AccessControlFormData } from '@/portainer/components/accessControlForm/
import { STACK_NAME_VALIDATION_REGEX } from '@/constants';
import { RepositoryMechanismTypes } from '@/kubernetes/models/deploy';
import { FeatureId } from 'Portainer/feature-flags/enums';
import { isBE } from '@/portainer/feature-flags/feature-flags.service';
import { renderTemplate } from '@/react/portainer/custom-templates/components/utils';
angular
.module('portainer.app')
@@ -31,6 +33,8 @@ angular
endpoint
) {
$scope.onChangeTemplateId = onChangeTemplateId;
$scope.onChangeTemplateVariables = onChangeTemplateVariables;
$scope.isTemplateVariablesEnabled = isBE;
$scope.buildAnalyticsProperties = buildAnalyticsProperties;
$scope.stackWebhookFeature = FeatureId.STACK_WEBHOOK;
$scope.STACK_NAME_VALIDATION_REGEX = STACK_NAME_VALIDATION_REGEX;
@@ -53,6 +57,7 @@ angular
RepositoryMechanism: RepositoryMechanismTypes.INTERVAL,
RepositoryFetchInterval: '5m',
RepositoryWebhookURL: WebhookHelper.returnStackWebhookUrl(uuidv4()),
Variables: {},
};
$scope.state = {
@@ -265,11 +270,12 @@ angular
});
};
$scope.onChangeFileContent = function onChangeFileContent(value) {
$scope.onChangeFileContent = onChangeFileContent;
function onChangeFileContent(value) {
$scope.formValues.StackFileContent = value;
$scope.state.editorYamlValidationError = StackHelper.validateYAML($scope.formValues.StackFileContent, $scope.containerNames);
$scope.state.isEditorDirty = true;
};
}
async function onFileLoadAsync(event) {
$scope.state.uploadYamlValidationError = StackHelper.validateYAML(event.target.result, $scope.containerNames);
@@ -292,18 +298,38 @@ angular
function onChangeTemplateId(templateId, template) {
return $async(async () => {
if (!template || ($scope.state.selectedTemplateId === templateId && $scope.state.selectedTemplate === template)) {
return;
}
try {
$scope.state.selectedTemplateId = templateId;
$scope.state.selectedTemplate = template;
const fileContent = await CustomTemplateService.customTemplateFile(templateId);
$scope.onChangeFileContent(fileContent);
$scope.state.templateContent = fileContent;
onChangeFileContent(fileContent);
if (template.Variables && template.Variables.length > 0) {
const variables = Object.fromEntries(template.Variables.map((variable) => [variable.name, '']));
onChangeTemplateVariables(variables);
}
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve Custom Template file');
Notifications.error('Failure', err, 'Unable to retrieve Custom Template file');
}
});
}
function onChangeTemplateVariables(value) {
onChangeFormValues({ Variables: value });
if (!$scope.isTemplateVariablesEnabled) {
return;
}
const rendered = renderTemplate($scope.state.templateContent, $scope.formValues.Variables, $scope.state.selectedTemplate.Variables);
onChangeFormValues({ StackFileContent: rendered });
}
async function initView() {
var endpointMode = $scope.applicationState.endpoint.mode;
$scope.state.StackType = 2;
@@ -328,8 +354,11 @@ angular
initView();
function onChangeFormValues(newValues) {
$scope.formValues = newValues;
function onChangeFormValues(values) {
$scope.formValues = {
...$scope.formValues,
...values,
};
}
}
);

View File

@@ -131,13 +131,21 @@
path-placeholder="docker-compose.yml"
></git-form>
<custom-template-selector
ng-show="state.Method === 'template'"
new-template-path="docker.templates.custom.new"
stack-type="state.StackType"
on-change="(onChangeTemplateId)"
value="state.selectedTemplateId"
></custom-template-selector>
<div ng-show="state.Method === 'template'">
<custom-template-selector
new-template-path="docker.templates.custom.new"
stack-type="state.StackType"
on-change="(onChangeTemplateId)"
value="state.selectedTemplateId"
></custom-template-selector>
<custom-templates-variables-field
ng-if="isTemplateVariablesEnabled && state.selectedTemplate"
definitions="state.selectedTemplate.Variables"
value="formValues.Variables"
on-change="(onChangeTemplateVariables)"
></custom-templates-variables-field>
</div>
<web-editor-form
ng-if="state.Method === 'editor' || (state.Method === 'template' && state.selectedTemplateId)"

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 -->
@@ -140,7 +140,7 @@
<button
type="button"
class="btn btn-primary btn-sm"
ng-disabled="state.actionInProgress || !state.validUsername || formValues.Username === '' || !formValues.Password || !formValues.ConfirmPassword || (AuthenticationMethod === 1 && form.$invalid) || (AuthenticationMethod === 1 && form.password.$viewValue !== formValues.ConfirmPassword)"
ng-disabled="state.actionInProgress || !state.validUsername || formValues.Username === '' || (AuthenticationMethod === 1 && (!formValues.Password || form.$invalid || formValues.Password !== formValues.ConfirmPassword))"
ng-click="addUser()"
button-spinner="state.actionInProgress"
data-cy="user-createUserButton"

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}"

View File

@@ -21,10 +21,10 @@ fi
if [[ "$PLATFORM" == "windows" ]]; then
wget -O "dist/docker-compose.plugin.exe" "https://github.com/docker/compose/releases/download/$COMPOSE_VERSION/docker-compose-windows-${ARCH}.exe"
chmod +x "dist/docker-compose.plugin.exe"
wget -O "dist/docker-compose.exe" "https://github.com/docker/compose/releases/download/$COMPOSE_VERSION/docker-compose-windows-${ARCH}.exe"
chmod +x "dist/docker-compose.exe"
else
wget -O "dist/docker-compose.plugin" "https://github.com/docker/compose/releases/download/$COMPOSE_VERSION/docker-compose-${PLATFORM}-${ARCH}"
chmod +x "dist/docker-compose.plugin"
wget -O "dist/docker-compose" "https://github.com/docker/compose/releases/download/$COMPOSE_VERSION/docker-compose-${PLATFORM}-${ARCH}"
chmod +x "dist/docker-compose"
fi

View File

@@ -2,7 +2,7 @@
"author": "Portainer.io",
"name": "portainer",
"homepage": "http://portainer.io",
"version": "2.14.0",
"version": "2.14.1",
"repository": {
"type": "git",
"url": "git@github.com:portainer/portainer.git"