1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-10 08:15:25 +02:00

refactor(environments): migrate item view to react [EE-2300]

fix [EE-2300]
This commit is contained in:
Chaim Lev-Ari 2024-01-31 18:06:15 +02:00
parent 7549b6cf3f
commit b0bfbc3ad7
70 changed files with 1651 additions and 1092 deletions

View file

@ -4,9 +4,6 @@ import { r2a } from '@/react-tools/react2angular';
import { withReactQuery } from '@/react-tools/withReactQuery';
import { withUIRouter } from '@/react-tools/withUIRouter';
import { AssociatedEdgeEnvironmentsSelector } from '@/react/edge/components/AssociatedEdgeEnvironmentsSelector';
import { EdgeAsyncIntervalsForm } from '@/react/edge/components/EdgeAsyncIntervalsForm';
import { EdgeCheckinIntervalField } from '@/react/edge/components/EdgeCheckInIntervalField';
import { EdgeScriptForm } from '@/react/edge/components/EdgeScriptForm';
import { EdgeGroupsSelector } from '@/react/edge/edge-stacks/components/EdgeGroupsSelector';
const ngModule = angular
@ -23,37 +20,7 @@ const ngModule = angular
'required',
])
)
.component(
'edgeScriptForm',
r2a(withReactQuery(EdgeScriptForm), [
'edgeInfo',
'commands',
'asyncMode',
'showMetaFields',
])
)
.component(
'edgeCheckinIntervalField',
r2a(withReactQuery(EdgeCheckinIntervalField), [
'value',
'onChange',
'isDefaultHidden',
'tooltip',
'label',
'readonly',
'size',
])
)
.component(
'edgeAsyncIntervalsForm',
r2a(withReactQuery(EdgeAsyncIntervalsForm), [
'values',
'onChange',
'isDefaultHidden',
'readonly',
'fieldSettings',
])
)
.component(
'associatedEdgeEnvironmentsSelector',
r2a(withReactQuery(AssociatedEdgeEnvironmentsSelector), [

View file

@ -194,8 +194,7 @@ angular
},
views: {
'content@': {
templateUrl: './views/endpoints/edit/endpoint.html',
controller: 'EndpointController',
component: 'environmentsItemView',
},
},
};

View file

@ -1,12 +0,0 @@
angular.module('portainer.app').component('porEndpointSecurity', {
templateUrl: './porEndpointSecurity.html',
controller: 'porEndpointSecurityController',
bindings: {
// This object will be populated with the form data.
// Model reference in endpointSecurityModel.js
formData: '=',
// The component will use this object to initialize the default values
// if present.
endpoint: '<',
},
});

View file

@ -1,83 +0,0 @@
<div>
<!-- tls-checkbox -->
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
label="'TLS'"
label-class="'col-sm-2'"
checked="$ctrl.formData.TLS"
on-change="($ctrl.onToggleTLS)"
tooltip="'Enable this option if you need to connect to the Docker environment with TLS.'"
></por-switch-field>
</div>
</div>
<!-- !tls-checkbox -->
<div class="col-sm-12 form-section-title" ng-if="$ctrl.formData.TLS"> TLS mode </div>
<!-- note -->
<div class="form-group" ng-if="$ctrl.formData.TLS">
<div class="col-sm-12">
<span class="small text-muted">
You can find out more information about how to protect a Docker environment with TLS in the
<a href="https://docs.docker.com/engine/security/https/" target="_blank">Docker documentation</a>.
</span>
</div>
</div>
<box-selector
ng-if="$ctrl.formData.TLS"
slim="true"
radio-name="'tls_mode'"
options="$ctrl.tlsOptions"
value="$ctrl.formData.TLSMode"
on-change="($ctrl.onChangeTLSMode)"
></box-selector>
<div class="col-sm-12 form-section-title" ng-if="$ctrl.formData.TLS && $ctrl.formData.TLSMode !== 'tls_only'"> Required TLS files </div>
<!-- tls-file-upload -->
<div ng-if="$ctrl.formData.TLS">
<!-- tls-file-ca -->
<div class="form-group" ng-if="$ctrl.formData.TLSMode === 'tls_client_ca' || $ctrl.formData.TLSMode === 'tls_ca'">
<label class="col-sm-3 col-lg-2 control-label text-left">TLS CA certificate</label>
<div class="col-sm-9 col-lg-10">
<button type="button" class="btn btn-sm btn-primary" ngf-select ng-model="$ctrl.formData.TLSCACert">Select file</button>
<span class="space-left">
{{ $ctrl.formData.TLSCACert.name }}
<pr-icon icon="'check'" ng-if="$ctrl.formData.TLSCACert && $ctrl.formData.TLSCACert === $ctrl.endpoint.TLSConfig.TLSCACert" mode="'success'"></pr-icon>
<pr-icon icon="'x'" ng-if="!$ctrl.formData.TLSCACert" mode="'danger'"></pr-icon>
</span>
</div>
</div>
<!-- !tls-file-ca -->
<!-- tls-files-cert-key -->
<div ng-if="$ctrl.formData.TLSMode === 'tls_client_ca' || $ctrl.formData.TLSMode === 'tls_client_noca'">
<!-- tls-file-cert -->
<div class="form-group">
<label for="tls_cert" class="col-sm-3 col-lg-2 control-label text-left">TLS certificate</label>
<div class="col-sm-9 col-lg-10">
<button type="button" class="btn btn-sm btn-primary" ngf-select ng-model="$ctrl.formData.TLSCert">Select file</button>
<span class="space-left">
{{ $ctrl.formData.TLSCert.name }}
<pr-icon icon="'check'" ng-if="$ctrl.formData.TLSCert && $ctrl.formData.TLSCert === $ctrl.endpoint.TLSConfig.TLSCert" mode="'success'"></pr-icon>
<pr-icon icon="'x'" ng-if="!$ctrl.formData.TLSCert" mode="'danger'"></pr-icon>
</span>
</div>
</div>
<!-- !tls-file-cert -->
<!-- tls-file-key -->
<div class="form-group">
<label class="col-sm-3 col-lg-2 control-label text-left">TLS key</label>
<div class="col-sm-9 col-lg-10">
<button type="button" class="btn btn-sm btn-primary" ngf-select ng-model="$ctrl.formData.TLSKey">Select file</button>
<span class="space-left">
{{ $ctrl.formData.TLSKey.name }}
<pr-icon icon="'check'" ng-if="$ctrl.formData.TLSKey && $ctrl.formData.TLSKey === $ctrl.endpoint.TLSConfig.TLSKey" mode="'success'"></pr-icon>
<pr-icon icon="'x'" ng-if="!$ctrl.formData.TLSKey" mode="'danger'"></pr-icon>
</span>
</div>
</div>
<!-- !tls-file-key -->
</div>
<!-- tls-files-cert-key -->
</div>
<!-- !tls-file-upload -->
</div>

View file

@ -1,56 +0,0 @@
import { tlsOptions } from '@/react/portainer/environments/ItemView/tls-options';
angular.module('portainer.app').controller('porEndpointSecurityController', [
'$scope',
function ($scope) {
var ctrl = this;
this.tlsOptions = tlsOptions;
function onChange(values) {
$scope.$evalAsync(() => {
ctrl.formData = {
...ctrl.formData,
...values,
};
});
}
ctrl.onChangeTLSMode = onChangeTLSMode;
function onChangeTLSMode(mode) {
onChange({ TLSMode: mode });
}
ctrl.onToggleTLS = onToggleTLS;
function onToggleTLS(newValue) {
onChange({ TLS: newValue });
}
this.$onInit = $onInit;
function $onInit() {
if (ctrl.endpoint) {
var endpoint = ctrl.endpoint;
var TLS = endpoint.TLSConfig.TLS;
ctrl.formData.TLS = TLS;
var CACert = endpoint.TLSConfig.TLSCACert;
ctrl.formData.TLSCACert = CACert;
var cert = endpoint.TLSConfig.TLSCert;
ctrl.formData.TLSCert = cert;
var key = endpoint.TLSConfig.TLSKey;
ctrl.formData.TLSKey = key;
if (TLS) {
if (CACert && cert && key) {
ctrl.formData.TLSMode = 'tls_client_ca';
} else if (cert && key) {
ctrl.formData.TLSMode = 'tls_client_noca';
} else if (CACert) {
ctrl.formData.TLSMode = 'tls_ca';
} else {
ctrl.formData.TLSMode = 'tls_only';
}
}
}
}
},
]);

View file

@ -1,7 +0,0 @@
export function EndpointSecurityFormData() {
this.TLS = false;
this.TLSMode = 'tls_client_ca';
this.TLSCACert = null;
this.TLSCert = null;
this.TLSKey = null;
}

View file

@ -8,9 +8,8 @@ import { boxSelectorModule } from './BoxSelector';
import { beFeatureIndicator } from './BEFeatureIndicator';
import { InformationPanelAngular } from './InformationPanel';
import { gitFormModule } from './forms/git-form';
import { tlsFieldsetModule } from './tls-fieldset';
export default angular
.module('portainer.app.components', [boxSelectorModule, widgetModule, gitFormModule, porAccessManagementModule, formComponentsModule, tlsFieldsetModule])
.module('portainer.app.components', [boxSelectorModule, widgetModule, gitFormModule, porAccessManagementModule, formComponentsModule])
.component('informationPanel', InformationPanelAngular)
.component('beFeatureIndicator', beFeatureIndicator).name;

View file

@ -1,22 +0,0 @@
import angular from 'angular';
import {
TLSFieldset,
tlsConfigValidation,
} from '@/react/components/TLSFieldset';
import { withFormValidation } from '@/react-tools/withFormValidation';
export const ngModule = angular.module(
'portainer.app.components.tls-fieldset',
[]
);
export const tlsFieldsetModule = ngModule.name;
withFormValidation(
ngModule,
TLSFieldset,
'tlsFieldset',
[],
tlsConfigValidation
);

View file

@ -1,8 +0,0 @@
export const azureEndpointConfig = {
bindings: {
applicationId: '=',
tenantId: '=',
authenticationKey: '=',
},
templateUrl: './azureEndpointConfig.html',
};

View file

@ -1,51 +0,0 @@
<div>
<div class="col-sm-12 form-section-title"> Azure configuration </div>
<!-- applicationId-input -->
<div class="form-group">
<label for="azure_credential_appid" class="col-sm-3 col-lg-2 control-label text-left">Application ID</label>
<div class="col-sm-9 col-lg-10">
<input
type="text"
class="form-control"
name="azure_credential_appid"
ng-model="$ctrl.applicationId"
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
required
data-cy="azure-credential-appid-input"
/>
</div>
</div>
<!-- !applicationId-input -->
<!-- tenantId-input -->
<div class="form-group">
<label for="azure_credential_tenantid" class="col-sm-3 col-lg-2 control-label text-left">Tenant ID</label>
<div class="col-sm-9 col-lg-10">
<input
type="text"
class="form-control"
name="azure_credential_tenantid"
ng-model="$ctrl.tenantId"
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
required
data-cy="azure-credential-tenantid-input"
/>
</div>
</div>
<!-- !tenantId-input -->
<!-- authenticationkey-input -->
<div class="form-group">
<label for="azure_credential_authkey" class="col-sm-3 col-lg-2 control-label text-left">Authentication key</label>
<div class="col-sm-9 col-lg-10">
<input
type="text"
data-cy="azure-credential-authkey-input"
class="form-control"
name="azure_credential_authkey"
ng-model="$ctrl.authenticationKey"
placeholder="cOrXoK/1D35w8YQ8nH1/8ZGwzz45JIYD5jxHKXEQknk="
required
/>
</div>
</div>
<!-- !authenticationkey-input -->
</div>

View file

@ -1,7 +1,3 @@
import angular from 'angular';
import { azureEndpointConfig } from './azure-endpoint-config/azure-endpoint-config';
export default angular
.module('portainer.environments', [])
.component('azureEndpointConfig', azureEndpointConfig).name;
export default angular.module('portainer.environments', []).name;

View file

@ -2,7 +2,6 @@ import axios, { parseAxiosError } from '@/portainer/services/axios';
import { EnvironmentId } from '@/react/portainer/environments/types';
import {
OpenAMTConfiguration,
AMTInformation,
AuthorizationResponse,
DeviceFeatures,
} from '@/react/edge/edge-devices/open-amt/types';
@ -17,21 +16,6 @@ export async function configureAMT(formValues: OpenAMTConfiguration) {
}
}
export async function getAMTInfo(environmentId: EnvironmentId) {
try {
const { data: amtInformation } = await axios.get<AMTInformation>(
`${BASE_URL}/${environmentId}/info`
);
return amtInformation;
} catch (e) {
throw parseAxiosError(
e as Error,
'Unable to retrieve environment information'
);
}
}
export async function enableDeviceFeatures(
environmentId: EnvironmentId,
deviceGUID: string,

View file

@ -1,12 +1,13 @@
import angular from 'angular';
import { r2a } from '@/react-tools/react2angular';
import { EdgeKeyDisplay } from '@/react/portainer/environments/ItemView/EdgeKeyDisplay';
import { KVMControl } from '@/react/portainer/environments/KvmView/KVMControl';
import { TagsDatatable } from '@/react/portainer/environments/TagsView/TagsDatatable';
export const environmentsModule = angular
.module('portainer.app.react.components.environments', [])
.component('edgeKeyDisplay', r2a(EdgeKeyDisplay, ['edgeKey']))
.component('kvmControl', r2a(KVMControl, ['deviceId', 'server', 'token']))
.component('tagsDatatable', r2a(TagsDatatable, ['dataset', 'onRemove'])).name;
.component('tagsDatatable', r2a(TagsDatatable, ['dataset', 'onRemove']))
.component(
'kvmControl',
r2a(KVMControl, ['deviceId', 'server', 'token'])
).name;

View file

@ -0,0 +1,19 @@
import angular from 'angular';
import { ListView } from '@/react/portainer/environments/ListView';
import { r2a } from '@/react-tools/react2angular';
import { withCurrentUser } from '@/react-tools/withCurrentUser';
import { withReactQuery } from '@/react-tools/withReactQuery';
import { withUIRouter } from '@/react-tools/withUIRouter';
import { ItemView } from '@/react/portainer/environments/ItemView/ItemView';
export const environmentsModule = angular
.module('portainer.app.environments', [])
.component(
'environmentsItemView',
r2a(withUIRouter(withReactQuery(withCurrentUser(ItemView))), [])
)
.component(
'environmentsListView',
r2a(withUIRouter(withReactQuery(withCurrentUser(ListView))), [])
).name;

View file

@ -8,7 +8,6 @@ import { withUIRouter } from '@/react-tools/withUIRouter';
import { CreateUserAccessToken } from '@/react/portainer/account/CreateAccessTokenView';
import { EdgeComputeSettingsView } from '@/react/portainer/settings/EdgeComputeView/EdgeComputeSettingsView';
import { EdgeAutoCreateScriptView } from '@/react/portainer/environments/EdgeAutoCreateScriptView';
import { ListView as EnvironmentsListView } from '@/react/portainer/environments/ListView';
import { BackupSettingsPanel } from '@/react/portainer/settings/SettingsView/BackupSettingsView/BackupSettingsPanel';
import { SettingsView } from '@/react/portainer/settings/SettingsView/SettingsView';
import { CreateHelmRepositoriesView } from '@/react/portainer/account/helm-repositories/CreateHelmRepositoryView';
@ -20,6 +19,7 @@ import { environmentGroupModule } from './env-groups';
import { registriesModule } from './registries';
import { activityLogsModule } from './activity-logs';
import { templatesModule } from './templates';
import { environmentsModule } from './environments';
export const viewsModule = angular
.module('portainer.app.react.views', [
@ -30,6 +30,7 @@ export const viewsModule = angular
registriesModule,
activityLogsModule,
templatesModule,
environmentsModule,
])
.component(
'homeView',
@ -56,10 +57,6 @@ export const viewsModule = angular
['onSubmit', 'settings']
)
)
.component(
'environmentsListView',
r2a(withUIRouter(withReactQuery(withCurrentUser(EnvironmentsListView))), [])
)
.component(
'backupSettingsPanel',
r2a(withUIRouter(withReactQuery(withCurrentUser(BackupSettingsPanel))), [])

View file

@ -1,268 +0,0 @@
<page-header ng-if="endpoint" title="'Environment details'" breadcrumbs="[{label:'Environments', link:'portainer.endpoints'}, endpoint.Name]" reload="true"> </page-header>
<div class="row">
<div ng-if="state.edgeEndpoint">
<information-panel ng-if="state.edgeAssociated" title-text="Edge information">
<span class="small text-muted">
<p class="vertical-center">
<pr-icon icon="'alert-circle'" mode="'primary'"></pr-icon>
This Edge environment is associated to an Edge environment {{ state.kubernetesEndpoint ? '(Kubernetes)' : '(Docker)' }}.
</p>
<p>
Edge key: <code>{{ endpoint.EdgeKey }}</code>
</p>
<p>
Edge identifier: <code>{{ endpoint.EdgeID }}</code>
</p>
<p>
<button
type="button"
class="btn btn-primary btn-sm"
ng-disabled="state.actionInProgress"
ng-click="onDisassociateEndpoint()"
button-spinner="state.actionInProgress"
analytics-on
analytics-event="edge-endpoint-disassociate"
analytics-category="edge"
>
<span ng-hide="state.actionInProgress">Disassociate</span>
</button>
</p>
</span>
</information-panel>
<div class="col-sm-12" ng-if="!state.edgeAssociated">
<rd-widget>
<rd-widget-body>
<div class="col-sm-12 form-section-title">Deploy an agent</div>
<span class="small text-muted">
<p class="vertical-center">
<pr-icon icon="'alert-circle'" mode="'primary'"></pr-icon>
Refer to the platform related command below to deploy the Edge agent in your remote cluster.
</p>
<p>
The agent will communicate with Portainer via <u>{{ edgeKeyDetails.instanceURL }}</u> and <u>tcp://{{ edgeKeyDetails.tunnelServerAddr }}</u>
</p>
</span>
<div class="col-sm-12 form-section-title"> Edge agent deployment script </div>
<edge-script-form edge-info="{ key: endpoint.EdgeKey, id: endpoint.EdgeID }" commands="state.edgeScriptCommands" async-mode="endpoint.Edge.AsyncMode"></edge-script-form>
<edge-key-display edge-key="endpoint.EdgeKey"> </edge-key-display>
</rd-widget-body>
</rd-widget>
</div>
</div>
<information-panel ng-if="state.kubernetesEndpoint && (!state.edgeEndpoint || state.edgeAssociated)" title-text="Kubernetes features configuration">
<span class="small text-muted vertical-center">
<pr-icon icon="'wrench'" mode="'primary'"></pr-icon>
You should configure the features available in this Kubernetes environment in the
<a ui-sref="kubernetes.cluster.setup({endpointId: endpoint.Id})">Kubernetes configuration</a> view.
</span>
</information-panel>
</div>
<div class="row mt-4">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-body>
<form class="form-horizontal" name="$ctrl.endpointForm">
<div class="col-sm-12 form-section-title"> Configuration </div>
<!-- name-input -->
<div class="form-group">
<label for="container_name" class="col-sm-3 col-lg-2 control-label text-left">Name</label>
<div class="col-sm-9 col-lg-10">
<input
type="text"
data-cy="container-name-input"
class="form-control"
id="container_name"
ng-model="endpoint.Name"
placeholder="e.g. kubernetes-cluster01 / docker-prod01"
/>
</div>
</div>
<!-- !name-input -->
<!-- endpoint-url-input -->
<div class="form-group" ng-if="!state.edgeEndpoint">
<label for="endpoint_url" class="col-sm-3 col-lg-2 control-label text-left">
<span ng-if="!state.agentEndpoint">Environment URL</span>
<span ng-if="state.agentEndpoint">Environment address</span>
<portainer-tooltip
ng-if="!state.agentEndpoint"
message="'URL or IP address of a Docker host. The Docker API must be exposed over a TCP port. Please refer to the Docker documentation to configure it.'"
>
</portainer-tooltip>
<portainer-tooltip ng-if="state.agentEndpoint" message="'The address for the Portainer agent in the format <HOST>:<PORT> or <IP>:<PORT>'"> </portainer-tooltip>
</label>
<div class="col-sm-9 col-lg-10">
<input
ng-disabled="endpointType === 'local' || state.azureEndpoint"
type="text"
data-cy="endpoint-url-input"
class="form-control"
id="endpoint_url"
ng-model="endpoint.URL"
placeholder="e.g. 10.0.0.10:2375 or mydocker.mydomain.com:2375"
/>
</div>
</div>
<!-- !endpoint-url-input -->
<!-- endpoint-public-url-input -->
<div class="form-group" ng-if="!state.azureEndpoint">
<label for="endpoint_public_url" class="col-sm-3 col-lg-2 control-label text-left">
Public IP
<portainer-tooltip message="'URL or IP address where exposed containers will be reachable. This field is optional and will default to the environment URL.'">
</portainer-tooltip>
</label>
<div class="col-sm-9 col-lg-10">
<input
type="text"
class="form-control"
id="endpoint_public_url"
ng-model="endpoint.PublicURL"
placeholder="e.g. 10.0.0.10 or mydocker.mydomain.com"
data-cy="public-url-input"
/>
</div>
</div>
<div ng-if="endpoint && state.edgeEndpoint">
<div class="col-sm-12 form-section-title"> Check-in Intervals </div>
<edge-checkin-interval-field value="endpoint.EdgeCheckinInterval" on-change="(onChangeCheckInInterval)"></edge-checkin-interval-field>
</div>
<!-- !endpoint-public-url-input -->
<tls-fieldset
ng-if="!state.edgeEndpoint && endpoint.Status !== 4 && state.showTLSConfig"
values="formValues.tlsConfig"
on-change="(onChangeTLSConfigFormValues)"
validation-data="{optionalCert: true}"
></tls-fieldset>
<azure-endpoint-config
ng-if="state.azureEndpoint"
application-id="endpoint.AzureCredentials.ApplicationID"
tenant-id="endpoint.AzureCredentials.TenantID"
authentication-key="endpoint.AzureCredentials.AuthenticationKey"
></azure-endpoint-config>
<div class="col-sm-12 form-section-title"> Metadata </div>
<!-- group -->
<div class="form-group">
<label for="endpoint_group" class="col-sm-3 col-lg-2 control-label text-left"> Group </label>
<div class="col-sm-9 col-lg-10">
<select
ng-options="group.Id as group.Name for group in groups"
ng-model="endpoint.GroupId"
id="endpoint_group"
class="form-control"
data-cy="endpoint-group-select"
></select>
</div>
</div>
<!-- !group -->
<tag-selector ng-if="endpoint" value="endpoint.TagIds" allow-create="state.allowCreate" on-change="(onChangeTags)"></tag-selector>
<!-- open-amt info -->
<div ng-if="state.showAMTInfo">
<div class="col-sm-12 form-section-title"> Open Active Management Technology </div>
<div class="form-group">
<label for="endpoint_managementinfoVersion" class="col-sm-3 col-lg-2 control-label text-left"> AMT Version </label>
<div class="col-sm-9 col-lg-10">
<input
type="text"
ng-disabled="true"
class="form-control"
id="endpoint_managementinfoVersion"
ng-model="endpoint.ManagementInfo['AMT']"
placeholder="Loading..."
data-cy="endpoint-managementinfoVersion"
/>
</div>
</div>
<div class="form-group">
<label for="endpoint_managementinfoUUID" class="col-sm-3 col-lg-2 control-label text-left"> UUID </label>
<div class="col-sm-9 col-lg-10">
<input
type="text"
ng-disabled="true"
class="form-control"
id="endpoint_managementinfoUUID"
ng-model="endpoint.ManagementInfo['UUID']"
placeholder="Loading..."
data-cy="endpoint-managementinfoUUID"
/>
</div>
</div>
<div class="form-group">
<label for="endpoint_managementinfoBuildNumber" class="col-sm-3 col-lg-2 control-label text-left"> Build Number </label>
<div class="col-sm-9 col-lg-10">
<input
type="text"
data-cy="endpoint-managementinfoBuildNumber"
ng-disabled="true"
class="form-control"
id="endpoint_managementinfoBuildNumber"
ng-model="endpoint.ManagementInfo['Build Number']"
placeholder="Loading..."
/>
</div>
</div>
<div class="form-group">
<label for="endpoint_managementinfoControlMode" class="col-sm-3 col-lg-2 control-label text-left"> Control Mode </label>
<div class="col-sm-9 col-lg-10">
<input
type="text"
data-cy="endpoint-managementinfoControlMode"
ng-disabled="true"
class="form-control"
id="endpoint_managementinfoControlMode"
ng-model="endpoint.ManagementInfo['Control Mode']"
placeholder="Loading..."
/>
</div>
</div>
<div class="form-group">
<label for="endpoint_managementinfoDNSSuffix" class="col-sm-3 col-lg-2 control-label text-left"> DNS Suffix </label>
<div class="col-sm-9 col-lg-10">
<input
type="text"
data-cy="endpoint-managementinfoDNSSuffix"
ng-disabled="true"
class="form-control"
id="endpoint_managementinfoDNSSuffix"
ng-model="endpoint.ManagementInfo['DNS Suffix']"
placeholder="Loading..."
/>
</div>
</div>
</div>
<!-- !open-amt info -->
<div class="form-group">
<div class="col-sm-12">
<button
type="button"
class="btn btn-primary btn-sm !ml-0"
ng-disabled="state.actionInProgress || !endpoint.Name || !endpoint.URL || !$ctrl.endpointForm.$valid"
ng-click="updateEndpoint()"
button-spinner="state.actionInProgress"
>
<span ng-hide="state.actionInProgress">Update environment</span>
<span ng-show="state.actionInProgress">Updating environment...</span>
</button>
<a type="button" class="btn btn-default btn-sm" ui-sref="portainer.endpoints">Cancel</a>
</div>
</div>
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>

View file

@ -1,372 +0,0 @@
import _ from 'lodash-es';
import uuidv4 from 'uuid/v4';
import { PortainerEndpointTypes } from '@/portainer/models/endpoint/models';
import EndpointHelper from '@/portainer/helpers/endpointHelper';
import { getAMTInfo } from 'Portainer/hostmanagement/open-amt/open-amt.service';
import { confirmDestructive } from '@@/modals/confirm';
import { isEdgeEnvironment, isDockerAPIEnvironment } from '@/react/portainer/environments/utils';
import { commandsTabs } from '@/react/edge/components/EdgeScriptForm/scripts';
import { confirmDisassociate } from '@/react/portainer/environments/ItemView/ConfirmDisassociateModel';
import { buildConfirmButton } from '@@/modals/utils';
import { getInfo } from '@/react/docker/proxy/queries/useInfo';
angular.module('portainer.app').controller('EndpointController', EndpointController);
/* @ngInject */
function EndpointController(
$async,
$scope,
$state,
$transition$,
$filter,
clipboard,
EndpointService,
GroupService,
Notifications,
Authentication,
SettingsService
) {
$scope.onChangeCheckInInterval = onChangeCheckInInterval;
$scope.setFieldValue = setFieldValue;
$scope.onChangeTags = onChangeTags;
$scope.onChangeTLSConfigFormValues = onChangeTLSConfigFormValues;
$scope.state = {
selectAll: false,
// displayTextFilter: false,
get selectedItemCount() {
return $scope.state.selectedItems.length || 0;
},
selectedItems: [],
uploadInProgress: false,
actionInProgress: false,
azureEndpoint: false,
kubernetesEndpoint: false,
agentEndpoint: false,
edgeEndpoint: false,
edgeAssociated: false,
allowCreate: Authentication.isAdmin(),
allowSelfSignedCerts: true,
showAMTInfo: false,
showTLSConfig: false,
edgeScriptCommands: {
linux: _.compact([commandsTabs.k8sLinux, commandsTabs.swarmLinux, commandsTabs.standaloneLinux]),
win: [commandsTabs.swarmWindows, commandsTabs.standaloneWindow],
},
};
$scope.selectAll = function () {
$scope.state.firstClickedItem = null;
for (var i = 0; i < $scope.state.filteredDataSet.length; i++) {
var item = $scope.state.filteredDataSet[i];
if (item.Checked !== $scope.state.selectAll) {
// if ($scope.allowSelection(item) && item.Checked !== $scope.state.selectAll) {
item.Checked = $scope.state.selectAll;
$scope.selectItem(item);
}
}
};
function isBetween(value, a, b) {
return (value >= a && value <= b) || (value >= b && value <= a);
}
$scope.selectItem = function (item, event) {
// Handle range select using shift
if (event && event.originalEvent.shiftKey && $scope.state.firstClickedItem) {
const firstItemIndex = $scope.state.filteredDataSet.indexOf($scope.state.firstClickedItem);
const lastItemIndex = $scope.state.filteredDataSet.indexOf(item);
const itemsInRange = _.filter($scope.state.filteredDataSet, (item, index) => {
return isBetween(index, firstItemIndex, lastItemIndex);
});
const value = $scope.state.firstClickedItem.Checked;
_.forEach(itemsInRange, (i) => {
i.Checked = value;
});
$scope.state.firstClickedItem = item;
} else if (event) {
item.Checked = !item.Checked;
$scope.state.firstClickedItem = item;
}
$scope.state.selectedItems = _.uniq(_.concat($scope.state.selectedItems, $scope.state.filteredDataSet)).filter((i) => i.Checked);
if (event && $scope.state.selectAll && $scope.state.selectedItems.length !== $scope.state.filteredDataSet.length) {
$scope.state.selectAll = false;
}
};
$scope.formValues = {
tlsConfig: {
tls: false,
skipVerify: false,
skipClientVerify: false,
caCertFile: null,
certFile: null,
keyFile: null,
},
};
$scope.onDisassociateEndpoint = async function () {
confirmDisassociate().then((confirmed) => {
if (confirmed) {
disassociateEndpoint();
}
});
};
async function disassociateEndpoint() {
var endpoint = $scope.endpoint;
try {
$scope.state.actionInProgress = true;
await EndpointService.disassociateEndpoint(endpoint.Id);
Notifications.success('Environment disassociated', $scope.endpoint.Name);
$state.reload();
} catch (err) {
Notifications.error('Failure', err, 'Unable to disassociate environment');
} finally {
$scope.state.actionInProgress = false;
}
}
function onChangeCheckInInterval(value) {
setFieldValue('EdgeCheckinInterval', value);
}
function onChangeTags(value) {
setFieldValue('TagIds', value);
}
function onChangeTLSConfigFormValues(newValues) {
return this.$async(async () => {
$scope.formValues.tlsConfig = {
...$scope.formValues.tlsConfig,
...newValues,
};
});
}
function setFieldValue(name, value) {
return $scope.$evalAsync(() => {
$scope.endpoint = {
...$scope.endpoint,
[name]: value,
};
});
}
Array.prototype.indexOf = function (val) {
for (var i = 0; i < this.length; i++) {
if (this[i] == val) return i;
}
return -1;
};
Array.prototype.remove = function (val) {
var index = this.indexOf(val);
if (index > -1) {
this.splice(index, 1);
}
};
$scope.updateEndpoint = async function () {
var endpoint = $scope.endpoint;
if (isEdgeEnvironment(endpoint.Type) && _.difference($scope.initialTagIds, endpoint.TagIds).length > 0) {
let confirmed = await confirmDestructive({
title: 'Confirm action',
message: 'Removing tags from this environment will remove the corresponding edge stacks when dynamic grouping is being used',
confirmButton: buildConfirmButton(),
});
if (!confirmed) {
return;
}
}
var payload = {
Name: endpoint.Name,
PublicURL: endpoint.PublicURL,
Gpus: endpoint.Gpus,
GroupID: endpoint.GroupId,
TagIds: endpoint.TagIds,
AzureApplicationID: endpoint.AzureCredentials.ApplicationID,
AzureTenantID: endpoint.AzureCredentials.TenantID,
AzureAuthenticationKey: endpoint.AzureCredentials.AuthenticationKey,
EdgeCheckinInterval: endpoint.EdgeCheckinInterval,
};
if (
$scope.endpointType !== 'local' &&
endpoint.Type !== PortainerEndpointTypes.AzureEnvironment &&
endpoint.Type !== PortainerEndpointTypes.KubernetesLocalEnvironment &&
endpoint.Type !== PortainerEndpointTypes.AgentOnKubernetesEnvironment
) {
payload.URL = 'tcp://' + endpoint.URL;
if (endpoint.Type === PortainerEndpointTypes.DockerEnvironment) {
var tlsConfig = $scope.formValues.tlsConfig;
payload.TLS = tlsConfig.tls;
payload.TLSSkipVerify = tlsConfig.skipVerify;
if (tlsConfig.tls && !tlsConfig.skipVerify) {
payload.TLSSkipClientVerify = tlsConfig.skipClientVerify;
payload.TLSCACert = tlsConfig.caCertFile;
payload.TLSCert = tlsConfig.certFile;
payload.TLSKey = tlsConfig.keyFile;
}
}
}
if (endpoint.Type === PortainerEndpointTypes.AgentOnKubernetesEnvironment) {
payload.URL = endpoint.URL;
}
if (endpoint.Type === PortainerEndpointTypes.KubernetesLocalEnvironment) {
payload.URL = 'https://' + endpoint.URL;
}
$scope.state.actionInProgress = true;
EndpointService.updateEndpoint(endpoint.Id, payload).then(
function success() {
Notifications.success('Environment updated', $scope.endpoint.Name);
$state.go($state.params.redirectTo || 'portainer.endpoints', {}, { reload: true });
},
function error(err) {
Notifications.error('Failure', err, 'Unable to update environment');
$scope.state.actionInProgress = false;
},
function update(evt) {
if (evt.upload) {
$scope.state.uploadInProgress = evt.upload;
}
}
);
};
function decodeEdgeKey(key) {
let keyInformation = {};
if (key === '') {
return keyInformation;
}
let decodedKey = _.split(atob(key), '|');
keyInformation.instanceURL = decodedKey[0];
keyInformation.tunnelServerAddr = decodedKey[1];
return keyInformation;
}
function configureState() {
if (
$scope.endpoint.Type === PortainerEndpointTypes.KubernetesLocalEnvironment ||
$scope.endpoint.Type === PortainerEndpointTypes.AgentOnKubernetesEnvironment ||
$scope.endpoint.Type === PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment
) {
$scope.state.kubernetesEndpoint = true;
}
if ($scope.endpoint.Type === PortainerEndpointTypes.EdgeAgentOnDockerEnvironment || $scope.endpoint.Type === PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment) {
$scope.state.edgeEndpoint = true;
}
if ($scope.endpoint.Type === PortainerEndpointTypes.AzureEnvironment) {
$scope.state.azureEndpoint = true;
}
if (
$scope.endpoint.Type === PortainerEndpointTypes.AgentOnDockerEnvironment ||
$scope.endpoint.Type === PortainerEndpointTypes.EdgeAgentOnDockerEnvironment ||
$scope.endpoint.Type === PortainerEndpointTypes.AgentOnKubernetesEnvironment ||
$scope.endpoint.Type === PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment
) {
$scope.state.agentEndpoint = true;
}
}
function configureTLS(endpoint) {
$scope.formValues = {
tlsConfig: {
tls: endpoint.TLSConfig.TLS || false,
skipVerify: endpoint.TLSConfig.TLSSkipVerify || false,
skipClientVerify: endpoint.TLSConfig.TLSSkipClientVerify || false,
},
};
}
async function initView() {
return $async(async () => {
try {
const [endpoint, groups, settings] = await Promise.all([EndpointService.endpoint($transition$.params().id), GroupService.groups(), SettingsService.settings()]);
if (isDockerAPIEnvironment(endpoint)) {
$scope.state.showTLSConfig = true;
}
// Check if the environment is docker standalone, to decide whether to show the GPU insights box
const isDockerEnvironment = endpoint.Type === PortainerEndpointTypes.DockerEnvironment;
if (isDockerEnvironment) {
try {
const dockerInfo = await getInfo(endpoint.Id);
const isDockerSwarmEnv = dockerInfo.Swarm && dockerInfo.Swarm.NodeID;
$scope.isDockerStandaloneEnv = !isDockerSwarmEnv;
} catch (err) {
// $scope.isDockerStandaloneEnv is only used to show the "GPU insights box", so fail quietly on error
}
}
if (endpoint.URL.indexOf('unix://') === 0 || endpoint.URL.indexOf('npipe://') === 0) {
$scope.endpointType = 'local';
} else {
$scope.endpointType = 'remote';
}
endpoint.URL = $filter('stripprotocol')(endpoint.URL);
if (endpoint.Type === PortainerEndpointTypes.EdgeAgentOnDockerEnvironment || endpoint.Type === PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment) {
$scope.edgeKeyDetails = decodeEdgeKey(endpoint.EdgeKey);
$scope.state.edgeAssociated = !!endpoint.EdgeID;
endpoint.EdgeID = endpoint.EdgeID || uuidv4();
}
$scope.endpoint = endpoint;
$scope.initialTagIds = endpoint.TagIds.slice();
$scope.groups = groups;
configureState();
configureTLS(endpoint);
if (EndpointHelper.isDockerEndpoint(endpoint) && $scope.state.edgeAssociated) {
$scope.state.showAMTInfo = settings && settings.openAMTConfiguration && settings.openAMTConfiguration.enabled;
}
} catch (err) {
Notifications.error('Failure', err, 'Unable to retrieve environment details');
}
if ($scope.state.showAMTInfo) {
try {
$scope.endpoint.ManagementInfo = {};
const amtInfo = await getAMTInfo($state.params.id);
try {
$scope.endpoint.ManagementInfo = JSON.parse(amtInfo.RawOutput);
} catch (err) {
clearAMTManagementInfo(amtInfo.RawOutput);
}
} catch (err) {
clearAMTManagementInfo('Unable to retrieve AMT environment details');
}
}
});
}
function clearAMTManagementInfo(versionValue) {
$scope.endpoint.ManagementInfo['AMT'] = versionValue;
$scope.endpoint.ManagementInfo['UUID'] = '-';
$scope.endpoint.ManagementInfo['Control Mode'] = '-';
$scope.endpoint.ManagementInfo['Build Number'] = '-';
$scope.endpoint.ManagementInfo['DNS Suffix'] = '-';
}
initView();
}

View file

@ -111,6 +111,7 @@ export function createMockEnvironment(): Environment {
Gpus: [],
Agent: { Version: '1.0.0' },
EnableImageNotification: false,
CloudProvider: undefined,
ChangeWindow: {
Enabled: false,
EndTime: '',
@ -120,5 +121,18 @@ export function createMockEnvironment(): Environment {
detail: '',
summary: '',
},
};
PublicURL: '',
ComposeSyntaxMaxVersion: '1',
TLSConfig: {
TLS: false,
TLSSkipVerify: false,
},
UserAccessPolicies: {},
TeamAccessPolicies: {},
LastCheckInDate: 0,
EdgeCheckinInterval: 0,
Heartbeat: true,
QueryDate: 0,
LocalTimeZone: '',
} satisfies Environment;
}

View file

@ -1,4 +1,5 @@
import { FormikErrors } from 'formik';
import { SetStateAction } from 'react';
export function isErrorType<T>(
error: string | FormikErrors<T> | undefined
@ -16,3 +17,17 @@ export function isArrayErrorType<T>(
): error is FormikErrors<T>[] {
return error !== undefined && typeof error !== 'string';
}
export interface FieldsetValues<TFieldset> {
values: TFieldset;
errors?: FormikErrors<TFieldset>;
}
export type SetFieldValue<TFieldset> = <TField>(
field: keyof TFieldset,
value: TField
) => void;
export type SetValues<TFieldset> = SetStateAction<TFieldset>;
export type OnChange<TFieldset> = (value: TFieldset) => void;

View file

@ -7,6 +7,8 @@ import {
} from 'react-select/dist/declarations/src/types';
import { OptionProps } from 'react-select/dist/declarations/src/components/Option';
import { Pair } from '@/react/portainer/settings/types';
import { Select } from '@@/form-components/ReactSelect';
import { Switch } from '@@/form-components/SwitchField/Switch';
import { Tooltip } from '@@/Tip/Tooltip';
@ -20,15 +22,10 @@ interface GpuOption {
description?: string;
}
interface GPU {
value: string;
name: string;
}
export interface Props {
values: Values;
onChange(values: Values): void;
gpus: GPU[];
gpus: Pair[];
usedGpus: string[];
usedAllGpus: boolean;
enableGpuManagement?: boolean;
@ -77,13 +74,15 @@ export function GpuFieldset({
enableGpuManagement,
}: Props) {
const options = useMemo(() => {
const options = (gpus || []).map((gpu) => ({
value: gpu.value,
label:
usedGpus.includes(gpu.value) || usedAllGpus
? `${gpu.name} (in use)`
: gpu.name,
}));
const options = (gpus || [])
.filter((gpu): gpu is { value: string; name: string } => !!gpu.value)
.map((gpu) => ({
value: gpu.value,
label:
usedGpus.includes(gpu.value) || usedAllGpus
? `${gpu.name} (in use)`
: gpu.name,
}));
options.unshift({
value: 'all',

View file

@ -1,6 +1,6 @@
import { useFormikContext, Field } from 'formik';
import { GroupField } from '@/react/portainer/environments/wizard/EnvironmentsCreationView/shared/MetadataFieldset/GroupsField';
import { GroupField } from '@/react/portainer/environments/common/MetadataFieldset/GroupsField';
import { FormControl } from '@@/form-components/FormControl';
import { Input } from '@@/form-components/Input';

View file

@ -0,0 +1,9 @@
import { EnvironmentId } from '@/react/portainer/environments/types';
export const queryKeys = {
base: (environmentId: EnvironmentId) => ['open-amt', environmentId] as const,
devices: (environmentId: EnvironmentId) =>
[...queryKeys.base(environmentId), 'devices'] as const,
info: (environmentId: EnvironmentId) =>
[...queryKeys.base(environmentId), 'info'] as const,
};

View file

@ -9,15 +9,6 @@ export interface OpenAMTConfiguration {
certFilePassword: string;
}
export interface AMTInformation {
uuid: string;
amt: string;
buildNumber: string;
controlMode: string;
dnsSuffix: string;
rawOutput: string;
}
export interface AuthorizationResponse {
server: string;
token: string;

View file

@ -5,13 +5,14 @@ import { withError } from '@/react-tools/react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { Device } from './types';
import { queryKeys } from './query-keys';
export function useAMTDevices(
environmentId: EnvironmentId,
{ enabled }: { enabled?: boolean } = {}
) {
return useQuery(
['amt_devices', environmentId],
queryKeys.devices(environmentId),
() => getDevices(environmentId),
{
...withError('Failed retrieving AMT devices'),

View file

@ -0,0 +1,38 @@
import { useQuery } from 'react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { queryKeys } from './query-keys';
interface AMTInformation {
uuid: string;
amt: string;
buildNumber: string;
controlMode: string;
dnsSuffix: string;
rawOutput: string;
}
export function useAMTInfo(
environmentId: EnvironmentId,
{ enabled = true } = {}
) {
return useQuery({
queryKey: queryKeys.info(environmentId),
queryFn: () => getAMTInfo(environmentId),
enabled,
});
}
async function getAMTInfo(environmentId: EnvironmentId) {
try {
const { data: amtInformation } = await axios.get<AMTInformation>(
`/open_amt/${environmentId}/info`
);
return amtInformation;
} catch (e) {
throw parseAxiosError(e, 'Unable to retrieve environment information');
}
}

View file

@ -26,8 +26,8 @@ export function EdgeStackStatus({ edgeStack }: { edgeStack: EdgeStack }) {
return null;
}
const hasOldVersion = environmentsQuery.environments.some((env) =>
isVersionSmaller(env.Agent.Version, '2.19.0')
const hasOldVersion = environmentsQuery.environments.some(
(env) => !env.Agent.Version || isVersionSmaller(env.Agent.Version, '2.19.0')
);
const { icon, label, mode, spin, tooltip } = getStatus(

View file

@ -461,6 +461,7 @@ function useInitialValues(
ingressClasses:
getIngressClassesFormValues(allowNoneIngressClass, ingressClasses) ||
[],
changeWindow: environment.ChangeWindow,
};
}, [environment, ingressClasses, storageClassFormValues]);
}

View file

@ -1,3 +1,8 @@
import {
DeploymentOptions,
EndpointChangeWindow,
} from '@/react/portainer/environments/types';
import { IngressControllerClassMap } from '../../ingressClass/types';
export type AccessMode = {
@ -24,5 +29,8 @@ export type ConfigureFormValues = {
ingressAvailabilityPerNamespace: boolean;
allowNoneIngressClass: boolean;
storageClasses: StorageClassFormValues[];
deploymentOptions?: DeploymentOptions;
changeWindow: EndpointChangeWindow;
timeZone?: string;
ingressClasses: IngressControllerClassMap[];
};

View file

@ -6,6 +6,13 @@ import { IngressControllerClassMap } from '../../ingressClass/types';
import { ConfigureFormValues } from './types';
const deploymentOptionsSchema = object().shape({
overrideGlobalOptions: boolean(),
hideAddWithForm: boolean(),
hideWebEditor: boolean(),
hideFileUpload: boolean(),
});
// Define Yup schema for AccessMode
const accessModeSchema = object().shape({
Description: string().required(),
@ -77,4 +84,6 @@ export const configureValidationSchema: SchemaOf<ConfigureFormValues> = object({
changeWindow: isBE ? endpointChangeWindowSchema.required() : undefined,
storageClasses: storageClassFormValuesSchema.required(),
ingressClasses: array().of(ingressControllerClassMapSchema).required(),
timeZone: string(),
deploymentOptions: deploymentOptionsSchema.nullable(),
});

View file

@ -0,0 +1,113 @@
import { useMutation, useQuery } from 'react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { withError } from '@/react-tools/react-query';
import { EnvironmentStatus } from '@/react/portainer/environments/types';
import { Option } from '@@/form-components/Input/Select';
import { AddOnFormValue } from './types';
export interface AddonsResponse {
microk8s: {
running: boolean;
};
highAvailability: {
enabled: boolean;
nodes: {
address: string;
role: string;
}[];
};
addons?: {
name: string;
status: string;
repository: string;
arguments?: string;
}[];
currentVersion: string;
kubernetesVersions: Option<string>[];
}
async function getAddons(environmentID: number) {
try {
const { data } = await axios.get<AddonsResponse>(
`cloud/endpoints/${environmentID}/addons`
);
return data;
} catch (err) {
throw parseAxiosError(err as Error, 'Unable to retrieve addons');
}
}
async function upgradeCluster(environmentID: number, nextVersion: string) {
try {
const { data } = await axios.post<AddonsResponse>(
`cloud/endpoints/${environmentID}/upgrade`,
{ nextVersion }
);
return data;
} catch (err) {
throw parseAxiosError(
err as Error,
'Unable to send upgrade cluster request'
);
}
}
async function updateAddons(
environmentID: number,
payload: { addons: AddOnFormValue[] }
) {
try {
const { data } = await axios.post<AddonsResponse>(
`cloud/endpoints/${environmentID}/addons`,
payload
);
return data;
} catch (err) {
throw parseAxiosError(err as Error, 'Unable to update addons');
}
}
export function useAddonsQuery<TSelect = AddonsResponse | null>(
environmentID?: number,
status?: number,
select?: (info: AddonsResponse | null) => TSelect
) {
return useQuery(
['environments', environmentID, 'clusterInfo', 'addons'],
() => (environmentID ? getAddons(environmentID) : null),
{
select,
enabled: !!environmentID && status !== EnvironmentStatus.Error,
}
);
}
type UpdateAddOns = {
environmentID: number;
credentialID: number;
payload: { addons: AddOnFormValue[] };
};
type UpgradeRequest = {
environmentID: number;
nextVersion: string;
};
export function useUpdateAddonsMutation() {
return useMutation(
({ environmentID, payload }: UpdateAddOns) =>
updateAddons(environmentID, payload),
withError('Failed to update addons')
);
}
export function useUpgradeClusterMutation() {
return useMutation(
({ environmentID, nextVersion }: UpgradeRequest) =>
upgradeCluster(environmentID, nextVersion),
withError('Failed to send upgrade cluster request')
);
}

View file

@ -0,0 +1,32 @@
export interface AddOnFormValue {
name: string;
arguments?: string;
repository?: string;
disableSelect?: boolean;
info?: string;
}
export type K8sAddOnsForm = {
addons: AddOnFormValue[];
currentVersion: string;
};
export type AddonsArgumentType = 'required' | 'optional' | '';
export type AddOnOption = {
label: string;
name: string;
repository?: string;
arguments?: string;
tooltip?: string;
placeholder?: string;
argumentsType?: AddonsArgumentType;
selectedLabel?: string;
};
export type GroupedAddonOptions = {
label: string;
options: AddOnOption[];
}[];

View file

@ -0,0 +1,40 @@
import { notifySuccess } from '@/portainer/services/notifications';
import { LoadingButton } from '@@/buttons';
import { Environment } from '../types';
import { useDisassociateEdgeEnvironment } from '../queries/useDisassociateEdgeEnvironment';
import { confirmDisassociate } from './ConfirmDisassociateModel';
export function DisassociateButton({
environment,
}: {
environment: Environment;
}) {
const mutation = useDisassociateEdgeEnvironment();
return (
<LoadingButton
className="!ml-0"
loadingText="Disassociating"
isLoading={mutation.isLoading}
onClick={handleClick}
data-cy="disassociate-button"
>
Disassociate
</LoadingButton>
);
async function handleClick() {
if (!(await confirmDisassociate())) {
return;
}
mutation.mutate(environment.Id, {
onSuccess() {
notifySuccess('Environment disassociated', environment.Name);
},
});
}
}

View file

@ -0,0 +1,33 @@
import { InformationPanel } from '@@/InformationPanel';
import { TextTip } from '@@/Tip/TextTip';
import { getPlatformTypeName } from '../utils';
import { Environment } from '../types';
import { DisassociateButton } from './DisassociateButton';
export function EdgeAssociationInfo({
environment,
}: {
environment: Environment;
}) {
const platform = getPlatformTypeName(environment.Type);
return (
<InformationPanel title="Edge information">
<TextTip color="blue">
This Edge environment is associated to an Edge environment ({platform}).
</TextTip>
<div className="small text-muted mt-2">
<p>
Edge key: <code>{environment.EdgeKey}</code>
</p>
<p>
Edge identifier: <code>{environment.EdgeID}</code>
</p>
</div>
<DisassociateButton environment={environment} />
</InformationPanel>
);
}

View file

@ -0,0 +1,69 @@
import _ from 'lodash';
import { EdgeScriptForm } from '@/react/edge/components/EdgeScriptForm';
import { commandsTabs } from '@/react/edge/components/EdgeScriptForm/scripts';
import { Widget } from '@@/Widget';
import { FormSection } from '@@/form-components/FormSection';
import { TextTip } from '@@/Tip/TextTip';
import { Environment } from '../types';
import { EdgeKeyDisplay } from './EdgeKeyDisplay';
export function EdgeDeploymentInfo({
environment,
}: {
environment: Environment;
}) {
const edgeKeyDetails = decodeEdgeKey(environment.EdgeKey);
return (
<Widget>
<Widget.Body>
<FormSection title="Deploy an agent">
<TextTip>
<p className="vertical-center">
Refer to the platform related command below to deploy the Edge
agent in your remote cluster.
</p>
<p>
The agent will communicate with Portainer via{' '}
<u>{edgeKeyDetails.instanceURL}</u> and{' '}
<u>tcp://{edgeKeyDetails.tunnelServerAddr}</u>
</p>
</TextTip>
</FormSection>
<FormSection title="Edge agent deployment script">
<EdgeScriptForm
edgeInfo={{ key: environment.EdgeKey, id: environment.EdgeID }}
commands={{
linux: _.compact([
commandsTabs.k8sLinux,
commandsTabs.swarmLinux,
commandsTabs.standaloneLinux,
]),
win: [commandsTabs.swarmWindows, commandsTabs.standaloneWindow],
}}
asyncMode={environment.Edge.AsyncMode}
/>
<EdgeKeyDisplay edgeKey={environment.EdgeKey} />
</FormSection>
</Widget.Body>
</Widget>
);
}
function decodeEdgeKey(key: string) {
if (key === '') {
return {};
}
const decodedKey = atob(key).split('|');
return {
instanceURL: decodedKey[0],
tunnelServerAddr: decodedKey[1],
};
}

View file

@ -0,0 +1,22 @@
import { Environment } from '../types';
import { EdgeDeploymentInfo } from './EdgeDeploymentInfo';
import { EdgeAssociationInfo } from './EdgeAssociationInfo';
export function EdgeEnvironmentDetails({
environment,
}: {
environment: Environment;
}) {
return (
<div className="row">
<div>
{environment.EdgeID ? (
<EdgeAssociationInfo environment={environment} />
) : (
<EdgeDeploymentInfo environment={environment} />
)}
</div>
</div>
);
}

View file

@ -0,0 +1,45 @@
import { useCurrentStateAndParams } from '@uirouter/react';
import { PageHeader } from '@@/PageHeader';
import { useEnvironment } from '../queries';
import { isEdgeEnvironment } from '../utils';
import { UpdateForm } from './UpdateForm/UpdateForm';
import { EdgeEnvironmentDetails } from './EdgeEnvironmentDetails';
import { KubeDetails } from './KubeDetails';
export function ItemView() {
const {
params: { id },
} = useCurrentStateAndParams();
const environmentQuery = useEnvironment(id);
if (!environmentQuery.data) {
return null;
}
const environment = environmentQuery.data;
const isEdge = isEdgeEnvironment(environment.Type);
return (
<>
<PageHeader
title="Environment details"
breadcrumbs={[
{ label: 'Environments', link: '^' },
environmentQuery.data.Name || '',
]}
reload
/>
{isEdge && <EdgeEnvironmentDetails environment={environmentQuery.data} />}
<KubeDetails environment={environmentQuery.data} />
<div className="mt-4">
<UpdateForm environment={environmentQuery.data} />
</div>
</>
);
}

View file

@ -0,0 +1,71 @@
import KubeIcon from '@/assets/ico/kube.svg?c';
import { Widget } from '@@/Widget';
import { Button } from '@@/buttons';
import { CloudProviderSettings } from '../types';
export function KaaSClusterDetails({ info }: { info: CloudProviderSettings }) {
return (
<div className="row">
<div className="col-xs-12">
<Widget>
<Widget.Title icon={KubeIcon} title="KaaS cluster details" />
<Widget.Body className="!p-0">
<table className="table">
<tbody>
<tr>
<td>Provider</td>
<td>
{info.Name}
<span className="ml-2">
<Button
as="a"
size="xsmall"
props={{
href: info.URL,
target: '_blank',
rel: 'noreferrer',
}}
className="ml-2"
data-cy="go-to-portal"
>
Go to portal
</Button>
</span>
</td>
</tr>
{!!info.Region && (
<tr>
<td>Region</td>
<td>{info.Region}</td>
</tr>
)}
{!!info.Size && (
<tr>
<td> Node Size</td>
<td>{info.Size}</td>
</tr>
)}
{!!info.NetworkID && (
<tr>
<td>Network Id</td>
<td>{info.NetworkID}</td>
</tr>
)}
{!!info.NodeIPs && (
<tr>
<td>Node IPs</td>
<td>{info.NodeIPs}</td>
</tr>
)}
</tbody>
</table>
</Widget.Body>
</Widget>
</div>
</div>
);
}

View file

@ -0,0 +1,30 @@
import { Wrench } from 'lucide-react';
import { InformationPanel } from '@@/InformationPanel';
import { TextTip } from '@@/Tip/TextTip';
import { Link } from '@@/Link';
import { EnvironmentId } from '../types';
export function KubeConfigureInstructions({
environmentId,
}: {
environmentId: EnvironmentId;
}) {
return (
<InformationPanel title="Kubernetes features configuration">
<TextTip icon={Wrench} color="blue">
You should configure the features available in this Kubernetes
environment in the{' '}
<Link
to="kubernetes.cluster.setup"
params={{ endpointId: environmentId }}
data-cy="kube-configure-instructions-link"
>
Kubernetes configuration
</Link>{' '}
view.
</TextTip>
</InformationPanel>
);
}

View file

@ -0,0 +1,31 @@
import { isEdgeEnvironment, isKubernetesEnvironment } from '../utils';
import { Environment, EnvironmentStatus } from '../types';
import { k8sInstallTitles } from '../wizard/EnvironmentsCreationView/WizardK8sInstall/types';
import { KubeConfigureInstructions } from './KubeConfigureInstructions';
import { Microk8sClusterDetails } from './Microk8sClusterDetails';
import { KaaSClusterDetails } from './KaaSClusterDetails';
export function KubeDetails({ environment }: { environment: Environment }) {
const isKube = isKubernetesEnvironment(environment.Type);
const isEdge = isEdgeEnvironment(environment.Type);
return (
<>
{isKube &&
(!isEdge || !!environment.EdgeID) &&
environment.Status !== EnvironmentStatus.Error && (
<KubeConfigureInstructions environmentId={environment.Id} />
)}
{environment.CloudProvider?.Name.toLowerCase() ===
k8sInstallTitles.microk8s.toLowerCase() ? (
<Microk8sClusterDetails environmentId={environment.Id} />
) : (
environment.CloudProvider?.URL && (
<KaaSClusterDetails info={environment.CloudProvider} />
)
)}
</>
);
}

View file

@ -0,0 +1,74 @@
import Kube from '@/assets/ico/kube.svg?c';
import { useEnvironment } from '@/react/portainer/environments/queries';
import { useAddonsQuery } from '@/react/kubernetes/cluster/microk8s/addons/addons.service';
import { useNodesQuery } from '@/react/kubernetes/cluster/HomeView/nodes.service';
import { Widget, WidgetTitle, WidgetBody } from '@@/Widget';
import { DetailsTable } from '@@/DetailsTable';
import { TextTip } from '@@/Tip/TextTip';
import { Link } from '@@/Link';
import { EnvironmentId } from '../types';
export function Microk8sClusterDetails({
environmentId,
}: {
environmentId: EnvironmentId;
}) {
const { data: environment, ...environmentQuery } =
useEnvironment(environmentId);
const { data: addonResponse, ...addonsQuery } = useAddonsQuery(
environmentId,
environment?.Status
);
const { data: nodes, ...nodesQuery } = useNodesQuery(environmentId);
const currentVersion = addonResponse?.currentVersion;
const addonNames = addonResponse?.addons
?.filter((addon) => addon.status === 'enabled')
.map((addon) => addon.name);
if (environmentQuery.isError) {
return <TextTip color="orange">Unable to load environment</TextTip>;
}
return (
<div className="row">
<div className="col-sm-12">
<Widget>
<WidgetTitle icon={Kube} title="MicroK8s cluster details" />
<WidgetBody loading={addonsQuery.isLoading || nodesQuery.isLoading}>
<DetailsTable dataCy="microk8s-cluster-details-table">
<DetailsTable.Row label="Addons" colClassName="w-1/2">
{addonsQuery.isError && 'Unable to get addons'}
{!addonNames?.length &&
addonsQuery.isSuccess &&
'No addons installed'}
{addonNames?.length && addonNames.join(', ')}
</DetailsTable.Row>
<DetailsTable.Row label="Kubernetes version" colClassName="w-1/2">
{addonsQuery.isError && 'Unable to find kubernetes version'}
{!!currentVersion && currentVersion}
</DetailsTable.Row>
<DetailsTable.Row label="Node count" colClassName="w-1/2">
{nodesQuery.isError && 'Unable to get node count'}
{nodes && nodes.length}
</DetailsTable.Row>
</DetailsTable>
<TextTip color="blue">
You can{' '}
<Link
to="kubernetes.cluster"
params={{ endpointId: environmentId }}
data-cy="cluster-details-view-link"
>
manage the cluster
</Link>{' '}
to upgrade, add/remove nodes or enable/disable addons.
</TextTip>
</WidgetBody>
</Widget>
</div>
</div>
);
}

View file

@ -0,0 +1,89 @@
import { useAMTInfo } from '@/react/edge/edge-devices/open-amt/useAmtInfo';
import { FormControl } from '@@/form-components/FormControl';
import { FormSection } from '@@/form-components/FormSection';
import { Input } from '@@/form-components/Input';
import { EnvironmentId } from '../../types';
import { useSettings } from '../../../settings/queries';
export function AmtInfo({ environmentId }: { environmentId: EnvironmentId }) {
const isAmtEnabledQuery = useSettings(
(settings) => settings.openAMTConfiguration.enabled
);
const amtQuery = useAMTInfo(environmentId, {
enabled: isAmtEnabledQuery.data,
});
if (!isAmtEnabledQuery.data) {
return null;
}
const info = amtQuery.data;
return (
<FormSection title="Open Active Management Technology">
<FormControl
label="AMT Version"
inputId="endpoint_management_info_version"
>
<Input
id="endpoint_management_info_version"
value={info?.amt}
disabled
placeholder="Loading..."
data-cy="endpoint-managementInfoVersion"
/>
</FormControl>
<FormControl label="UUID" inputId="endpoint_management_info_uuid">
<Input
id="endpoint_management_info_uuid"
value={info?.uuid}
disabled
placeholder="Loading..."
data-cy="endpoint-managementInfoUUID"
/>
</FormControl>
<FormControl
label="Build Number"
inputId="endpoint_management_info_build_number"
>
<Input
id="endpoint_management_info_build_number"
value={info?.buildNumber}
disabled
placeholder="Loading..."
data-cy="endpoint-managementInfoBuildNumber"
/>
</FormControl>
<FormControl
label="Control Mode"
inputId="endpoint_management_info_control_mode"
>
<Input
id="endpoint_management_info_control_mode"
value={info?.controlMode}
disabled
placeholder="Loading..."
data-cy="endpoint-managementInfoControlMode"
/>
</FormControl>
<FormControl
label="DNS Suffix"
inputId="endpoint_management_info_dns_suffix"
>
<Input
id="endpoint_management_info_dns_suffix"
value={info?.dnsSuffix}
disabled
placeholder="Loading..."
data-cy="endpoint-managementInfoDNSSuffix"
/>
</FormControl>
</FormSection>
);
}

View file

@ -0,0 +1,26 @@
import { useField } from 'formik';
import { FormControl } from '@@/form-components/FormControl';
import { Input } from '@@/form-components/Input';
export function AgentAddressField() {
const [{ value, onChange }, { error }] = useField('url');
return (
<FormControl
label="Environment address"
tooltip="The address for the Portainer agent in the format <HOST>:<PORT> or <IP>:<PORT>"
inputId="endpoint_url"
errors={error}
>
<Input
id="endpoint_url"
name="url"
value={value}
onChange={onChange}
placeholder="e.g. 10.0.0.10:2375 or mydocker.mydomain.com:2375"
data-cy="url-input"
/>
</FormControl>
);
}

View file

@ -0,0 +1,65 @@
import { FormControl } from '@@/form-components/FormControl';
import { FormSection } from '@@/form-components/FormSection';
import { Input } from '@@/form-components/Input';
import { FieldsetValues, SetFieldValue } from '@@/form-components/formikUtils';
export interface AzureFormValues {
applicationId: string;
tenantId: string;
authKey: string;
}
export function AzureEnvironmentConfiguration({
values,
errors,
setFieldValue,
}: FieldsetValues<AzureFormValues> & {
setFieldValue: SetFieldValue<AzureFormValues>;
}) {
return (
<FormSection title="Azure Configuration">
<FormControl
label="Application ID"
inputId="azure_application_id"
errors={errors?.applicationId}
>
<Input
id="azure_application_id"
name="azure.applicationId"
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
value={values.applicationId}
onChange={(e) => setFieldValue('applicationId', e.target.value)}
data-cy="azure-credential-appid-input"
/>
</FormControl>
<FormControl
label="Tenant ID"
inputId="azure_tenant_id"
errors={errors?.tenantId}
>
<Input
id="azure_tenant_id"
name="azure.tenantId"
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
value={values.tenantId}
onChange={(e) => setFieldValue('tenantId', e.target.value)}
data-cy="azure-credential-tenantid-input"
/>
</FormControl>
<FormControl
label="Authentication key"
inputId="azure_auth_key"
errors={errors?.authKey}
>
<Input
id="azure_auth_key"
name="azure.authKey"
placeholder="cOrXoK/1D35w8YQ8nH1/8ZGwzz45JIYD5jxHKXEQknk="
value={values.authKey}
onChange={(e) => setFieldValue('authKey', e.target.value)}
data-cy="azure-credential-authkey-input"
/>
</FormControl>
</FormSection>
);
}

View file

@ -0,0 +1,25 @@
import { useField } from 'formik';
import { FormControl } from '@@/form-components/FormControl';
import { Input } from '@@/form-components/Input';
export function PublicIPField() {
const [{ value, onChange }, { error }] = useField('publicUrl');
return (
<FormControl
label="Public IP"
tooltip="URL or IP address where exposed containers will be reachable. This field is optional and will default to the environment URL."
inputId="endpoint_public_url"
errors={error}
>
<Input
id="endpoint_public_url"
name="publicUrl"
value={value}
onChange={onChange}
placeholder="e.g. 10.0.0.10 or mydocker.mydomain.com"
data-cy="public-url-input"
/>
</FormControl>
);
}

View file

@ -0,0 +1,27 @@
import { useField } from 'formik';
import { FormControl } from '@@/form-components/FormControl';
import { Input } from '@@/form-components/Input';
export function URLField({ disabled }: { disabled?: boolean }) {
const [{ value, onChange }, { error }] = useField('url');
return (
<FormControl
label="Environment URL"
tooltip="URL or IP address of a Docker host. The Docker API must be exposed over a TCP port. Please refer to the Docker documentation to configure it."
inputId="endpoint_url"
errors={error}
>
<Input
disabled={disabled}
name="url"
id="endpoint_url"
value={value}
onChange={onChange}
placeholder="e.g. 10.0.0.10:2375 or mydocker.mydomain.com:2375"
data-cy="url-input"
/>
</FormControl>
);
}

View file

@ -0,0 +1,177 @@
import { Form, Formik } from 'formik';
import { EdgeCheckinIntervalField } from '@/react/edge/components/EdgeCheckInIntervalField';
import { EdgeAsyncIntervalsForm } from '@/react/edge/components/EdgeAsyncIntervalsForm';
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { Widget } from '@@/Widget';
import { FormSection } from '@@/form-components/FormSection';
import { TLSFieldset } from '@@/TLSFieldset';
import { FormActions } from '@@/form-components/FormActions';
import { Button } from '@@/buttons';
import { Link } from '@@/Link';
import { TextTip } from '@@/Tip/TextTip';
import { Environment, EnvironmentStatus, EnvironmentType } from '../../types';
import { NameField } from '../../common/NameField';
import {
isAgentEnvironment,
isDockerAPIEnvironment,
isDockerEnvironment,
isEdgeEnvironment,
isLocalEnvironment,
} from '../../utils';
import { MetadataFieldset } from '../../common/MetadataFieldset';
import { AzureEnvironmentConfiguration } from './AzureConfiguration';
import { URLField } from './URLField';
import { PublicIPField } from './PublicIPField';
import { FormValues } from './types';
import { useUpdateMutation } from './useUpdateMutation';
import { AgentAddressField } from './AgentEnvironmentAddress';
import { AmtInfo } from './AMTInfo';
export function UpdateForm({ environment }: { environment: Environment }) {
const isEdge = isEdgeEnvironment(environment.Type);
const isAgent = isAgentEnvironment(environment.Type);
const isLocal = isLocalEnvironment(environment);
const isAzure = environment.Type === EnvironmentType.Azure;
const { isLoading, handleSubmit } = useUpdateMutation(environment, {
isEdge,
isLocal,
isAzure,
});
const isAmtVisible =
isDockerEnvironment(environment.Type) && isEdge && !!environment.EdgeID;
const isErrorState = environment.Status === EnvironmentStatus.Error;
const initialValues: FormValues = {
name: environment.Name,
url: environment.URL,
publicUrl: environment.PublicURL || '',
tlsConfig: {
tls: environment.TLSConfig.TLS || false,
skipVerify: environment.TLSConfig.TLSSkipVerify || false,
},
meta: {
tagIds: environment.TagIds,
groupId: environment.GroupId,
},
azure: {
applicationId: environment.AzureCredentials?.ApplicationID || '',
tenantId: environment.AzureCredentials?.TenantID || '',
authKey: environment.AzureCredentials?.AuthenticationKey || '',
},
edge: {
checkInInterval: environment.EdgeCheckinInterval || 0,
CommandInterval: environment.Edge.CommandInterval || 0,
PingInterval: environment.Edge.PingInterval || 0,
SnapshotInterval: environment.Edge.SnapshotInterval || 0,
},
};
return (
<div className="row">
<div className="col-sm-12">
<Widget>
<Widget.Body>
<Formik initialValues={initialValues} onSubmit={handleSubmit}>
{({ values, errors, setFieldValue, setValues, isValid }) => (
<Form className="form-horizontal">
<FormSection title="Configuration">
<NameField disabled={isErrorState} />
{!isErrorState && (
<>
{!isEdge &&
(isAgent ? (
<AgentAddressField />
) : (
<URLField disabled={isAzure || isLocal} />
))}
{!isAzure && <PublicIPField />}
{isEdge && isBE && (
<TextTip color="blue">
Use https connection on Edge agent to use private
registries with credentials.
</TextTip>
)}
</>
)}
</FormSection>
{isEdge && (
<FormSection title="Check-in Intervals">
{environment.Edge.AsyncMode ? (
<EdgeAsyncIntervalsForm
values={values.edge}
onChange={(value) =>
setValues((values) => ({
...values,
edge: { ...values.edge, ...value },
}))
}
/>
) : (
<EdgeCheckinIntervalField
value={values.edge.checkInInterval || 0}
onChange={(value) =>
setFieldValue('edge.checkInInterval', value)
}
/>
)}
</FormSection>
)}
{!isEdge &&
environment.Status !== EnvironmentStatus.Error &&
isDockerAPIEnvironment(environment) && (
<TLSFieldset
errors={errors.tlsConfig}
values={values.tlsConfig}
onChange={(tlsConfig) =>
setValues((values) => ({
...values,
tlsConfig: { ...values.tlsConfig, ...tlsConfig },
}))
}
/>
)}
{isAzure && (
<AzureEnvironmentConfiguration
errors={errors.azure}
setFieldValue={(field, value) =>
setFieldValue(`azure.${field}`, value)
}
values={values.azure}
/>
)}
<MetadataFieldset />
{isAmtVisible && <AmtInfo environmentId={environment.Id} />}
<FormActions
isLoading={isLoading}
isValid={isValid}
loadingText="Updating environment..."
submitLabel="Update environment"
data-cy="update-environment-button"
>
<Button
as={Link}
props={{
to: '^',
'data-cy': 'cancel-update-environment-button',
}}
color="default"
data-cy="cancel-update-environment-button"
>
Cancel
</Button>
</FormActions>
</Form>
)}
</Formik>
</Widget.Body>
</Widget>
</div>
</div>
);
}

View file

@ -0,0 +1,23 @@
import { TagId } from '@/portainer/tags/types';
import { EdgeAsyncIntervalsValues } from '@/react/edge/components/EdgeAsyncIntervalsForm';
import { TLSConfig } from '@@/TLSFieldset/types';
import { EnvironmentGroupId } from '../../environment-groups/types';
import { AzureFormValues } from './AzureConfiguration';
export interface FormValues {
name: string;
url: string;
publicUrl: string;
tlsConfig: TLSConfig;
azure: AzureFormValues;
meta: {
tagIds: TagId[];
groupId: EnvironmentGroupId;
};
edge: {
checkInInterval: number;
} & EdgeAsyncIntervalsValues;
}

View file

@ -0,0 +1,106 @@
import _ from 'lodash';
import { useCurrentStateAndParams, useRouter } from '@uirouter/react';
import { notifySuccess } from '@/portainer/services/notifications';
import { confirmDestructive } from '@@/modals/confirm';
import { buildConfirmButton } from '@@/modals/utils';
import { Environment, EnvironmentType } from '../../types';
import {
UpdateEnvironmentPayload,
useUpdateEnvironmentMutation,
} from '../../queries/useUpdateEnvironmentMutation';
import { isDockerEnvironment, isKubernetesEnvironment } from '../../utils';
import { FormValues } from './types';
export function useUpdateMutation(
environment: Environment,
{
isEdge,
isLocal,
isAzure,
}: {
isEdge: boolean;
isLocal: boolean;
isAzure: boolean;
}
) {
const updateMutation = useUpdateEnvironmentMutation();
const router = useRouter();
const { params: stateParams } = useCurrentStateAndParams();
return {
handleSubmit,
isLoading: updateMutation.isLoading,
};
async function handleSubmit(values: FormValues) {
if (
isEdge &&
_.difference(environment.TagIds, values.meta.tagIds).length > 0
) {
const confirmed = await confirmDestructive({
title: 'Confirm action',
message:
'Removing tags from this environment will remove the corresponding edge stacks when dynamic grouping is being used',
confirmButton: buildConfirmButton(),
});
if (!confirmed) {
return;
}
}
const payload: UpdateEnvironmentPayload = {
Name: values.name,
PublicURL: values.publicUrl,
GroupID: values.meta.groupId,
TagIDs: values.meta.tagIds,
AzureApplicationID: values.azure.applicationId,
AzureTenantID: values.azure.tenantId,
AzureAuthenticationKey: values.azure.authKey,
EdgeCheckinInterval: values.edge.checkInInterval,
Edge: {
CommandInterval: values.edge.CommandInterval,
PingInterval: values.edge.PingInterval,
SnapshotInterval: values.edge.SnapshotInterval,
},
};
if (isLocal && !isAzure && !isKubernetesEnvironment(environment.Type)) {
payload.URL = `tcp://${values.url}`;
if (isDockerEnvironment(environment.Type)) {
const { tlsConfig } = values;
payload.TLS = tlsConfig.tls;
payload.TLSSkipVerify = tlsConfig.skipVerify || false;
if (tlsConfig.tls && !tlsConfig.skipVerify) {
// payload.TLSSkipClientVerify = tlsConfig.skipClientVerify;
payload.TLSCACert = tlsConfig.caCertFile;
payload.TLSCert = tlsConfig.certFile;
payload.TLSKey = tlsConfig.keyFile;
}
}
}
if (environment.Type === EnvironmentType.AgentOnKubernetes) {
payload.URL = values.url;
}
if (environment.Type === EnvironmentType.KubernetesLocal) {
payload.URL = `https://${values.url}`;
}
updateMutation.mutate(
{ id: environment.Id, payload },
{
onSuccess() {
notifySuccess('Environment updated', environment.Name);
router.stateService.go(stateParams.redirectTo || '^');
},
}
);
}
}

View file

@ -1,38 +0,0 @@
import { Shield } from 'lucide-react';
import { BoxSelectorOption } from '@@/BoxSelector';
export const tlsOptions: ReadonlyArray<BoxSelectorOption<string>> = [
{
id: 'tls_client_ca',
value: 'tls_client_ca',
icon: Shield,
iconType: 'badge',
label: 'TLS with server and client verification',
description: 'Use client certificates and server verification',
},
{
id: 'tls_client_noca',
value: 'tls_client_noca',
icon: Shield,
iconType: 'badge',
label: 'TLS with client verification only',
description: 'Use client certificates without server verification',
},
{
id: 'tls_ca',
value: 'tls_ca',
icon: Shield,
iconType: 'badge',
label: 'TLS with server verification only',
description: 'Only verify the server certificate',
},
{
id: 'tls_only',
value: 'tls_only',
icon: Shield,
iconType: 'badge',
label: 'TLS only',
description: 'No server/client verification',
},
] as const;

View file

@ -12,12 +12,14 @@ interface Props {
readonly?: boolean;
tooltip?: string;
placeholder?: string;
disabled?: boolean;
}
export function NameField({
readonly,
tooltip,
placeholder = 'e.g. docker-prod01 / kubernetes-cluster01',
disabled,
}: Props) {
const [{ value }, meta, { setValue }] = useField('name');
@ -35,12 +37,13 @@ export function NameField({
>
<Input
id={id}
data-cy="environmentCreate-nameInput"
data-cy="name-input"
name="name"
onChange={(e) => setDebouncedValue(e.target.value)}
value={debouncedValue}
placeholder={placeholder}
readOnly={readonly}
disabled={disabled}
/>
</FormControl>
);

View file

@ -167,14 +167,6 @@ export async function endpointsByGroup(
});
}
export async function disassociateEndpoint(id: EnvironmentId) {
try {
await axios.delete(buildUrl(id, 'association'));
} catch (e) {
throw parseAxiosError(e as Error);
}
}
export async function deleteEndpoint(id: EnvironmentId) {
try {
await axios.delete(buildUrl(id));

View file

@ -0,0 +1,26 @@
import { useMutation } from 'react-query';
import { useAnalytics } from '@/react/hooks/useAnalytics';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { EnvironmentId } from '../types';
import { buildUrl } from '../environment.service/utils';
export function useDisassociateEdgeEnvironment() {
const { trackEvent } = useAnalytics();
return useMutation({
mutationFn: (environmentId: EnvironmentId) => {
trackEvent('edge-endpoint-disassociate', { category: 'edge' });
return disassociateEnvironment(environmentId);
},
});
}
export async function disassociateEnvironment(id: EnvironmentId) {
try {
await axios.delete(buildUrl(id, 'association'));
} catch (e) {
throw parseAxiosError(e, 'Unable to disassociate environment');
}
}

View file

@ -6,14 +6,17 @@ import {
EnvironmentStatusMessage,
Environment,
KubernetesSettings,
DeploymentOptions,
EndpointChangeWindow,
DeploymentOptions,
} from '@/react/portainer/environments/types';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { TagId } from '@/portainer/tags/types';
import { EnvironmentGroupId } from '../environment-groups/types';
import { buildUrl } from '../environment.service/utils';
import { Pair } from '../../settings/types';
import {
TeamAccessPolicies,
UserAccessPolicies,
} from '../../registries/types/registry';
import { environmentQueryKeys } from './query-keys';
@ -25,30 +28,117 @@ export function useUpdateEnvironmentMutation() {
});
}
export interface UpdateEnvironmentPayload extends Partial<Environment> {
interface TLSFiles {
TLSCACert?: File;
TLSCert?: File;
TLSKey?: File;
}
Name: string;
PublicURL: string;
GroupID: EnvironmentGroupId;
TagIds: TagId[];
export interface UpdateEnvironmentPayload extends TLSFiles {
/**
* Name that will be used to identify this environment(endpoint)
*/
Name?: string;
EdgeCheckinInterval: number;
/**
* URL or IP address of a Docker host
*/
URL?: string;
TLS: boolean;
TLSSkipVerify: boolean;
TLSSkipClientVerify: boolean;
AzureApplicationID: string;
AzureTenantID: string;
AzureAuthenticationKey: string;
/**
* URL or IP address where exposed containers will be reachable. Defaults to URL if not specified
*/
PublicURL?: string;
IsSetStatusMessage: boolean;
StatusMessage: EnvironmentStatusMessage;
/**
* GPUs information
*/
Gpus?: Pair[];
/**
* Group identifier
*/
GroupID?: number;
/**
* Require TLS to connect against this environment(endpoint)
*/
TLS?: boolean;
/**
* Skip server verification when using TLS
*/
TLSSkipVerify?: boolean;
/**
* Skip client verification when using TLS
*/
TLSSkipClientVerify?: boolean;
/**
* The status of the environment(endpoint) (1 - up, 2 - down)
*/
Status?: number;
/**
* Azure application ID
*/
AzureApplicationID?: string;
/**
* Azure tenant ID
*/
AzureTenantID?: string;
/**
* Azure authentication key
*/
AzureAuthenticationKey?: string;
/**
* List of tag identifiers to which this environment(endpoint) is associated
*/
TagIDs?: number[];
/**
* User access policies for the environment
*/
UserAccessPolicies?: UserAccessPolicies;
/**
* Team access policies for the environment
*/
TeamAccessPolicies?: TeamAccessPolicies;
/**
* Associated Kubernetes data
*/
Kubernetes?: KubernetesSettings;
DeploymentOptions?: DeploymentOptions | null;
/**
* Whether GitOps update time restrictions are enabled
*/
ChangeWindow?: EndpointChangeWindow;
/**
* Hide manual deployment forms for an environment
*/
DeploymentOptions?: DeploymentOptions;
/**
* The check-in interval for edge agent (in seconds)
*/
EdgeCheckinInterval?: number;
Edge: {
PingInterval?: number;
SnapshotInterval?: number;
CommandInterval?: number;
};
IsSetStatusMessage?: boolean;
StatusMessage?: EnvironmentStatusMessage;
}
export async function updateEnvironment({

View file

@ -2,6 +2,12 @@ import { TagId } from '@/portainer/tags/types';
import { EnvironmentGroupId } from '@/react/portainer/environments/environment-groups/types';
import { DockerSnapshot } from '@/react/docker/snapshots/types';
import { Pair, TLSConfiguration } from '../settings/types';
import {
TeamAccessPolicies,
UserAccessPolicies,
} from '../registries/types/registry';
export type EnvironmentId = number;
export enum EnvironmentType {
@ -107,6 +113,54 @@ export type DeploymentOptions = {
hideFileUpload: boolean;
};
type AddonWithArgs = {
Name: string;
Args?: string;
};
export enum K8sDistributionType {
MICROK8S = 'microk8s',
}
export enum KaasProvider {
CIVO = 'civo',
LINODE = 'linode',
DIGITAL_OCEAN = 'digitalocean',
GOOGLE_CLOUD = 'gke',
AWS = 'amazon',
AZURE = 'azure',
}
export type CloudProviderSettings = {
Name:
| 'Civo'
| 'Linode'
| 'Digital Ocean'
| 'Google'
| 'Azure'
| 'Amazon'
| 'MicroK8s';
Provider: K8sDistributionType | KaasProvider;
URL: string;
Region: string | null;
Size: number | null;
NodeCount: number;
CPU: number | null;
AddonsWithArgs: AddonWithArgs[] | null;
AmiType: number | null;
CredentialID: number;
DNSPrefix: string;
HDD: number | null;
InstanceType: string | null;
KubernetesVersion: string;
NetworkID: number | null;
NodeIPs: string;
NodeVolumeSize: number | null;
PoolName: string;
RAM: number | null;
ResourceGroup: string;
Tier: string;
};
/**
* EndpointChangeWindow determine when GitOps stack/app updates may occur
*/
@ -120,42 +174,187 @@ export interface EnvironmentStatusMessage {
detail: string;
}
export type Environment = {
Agent: { Version: string };
Id: EnvironmentId;
Type: EnvironmentType;
TagIds: TagId[];
GroupId: EnvironmentGroupId;
DeploymentOptions: DeploymentOptions | null;
EnableGPUManagement: boolean;
EdgeID?: string;
EdgeKey: string;
EdgeCheckinInterval?: number;
QueryDate?: number;
Heartbeat?: boolean;
LastCheckInDate?: number;
Name: string;
Status: EnvironmentStatus;
URL: string;
Snapshots: DockerSnapshot[];
Kubernetes: KubernetesSettings;
PublicURL?: string;
UserTrusted: boolean;
AMTDeviceGUID?: string;
Edge: EnvironmentEdge;
SecuritySettings: EnvironmentSecuritySettings;
Gpus?: { name: string; value: string }[];
EnableImageNotification: boolean;
LocalTimeZone?: string;
/** GitOps update change window restriction for stacks and apps */
ChangeWindow: EndpointChangeWindow;
/**
* A message that describes the status. Should be included for Status Provisioning or Error.
*/
StatusMessage?: EnvironmentStatusMessage;
type AzureCredentials = {
ApplicationID: string;
TenantID: string;
AuthenticationKey: string;
};
/**
* Represents an environment with all the info required to connect to it.
*/
export interface Environment {
/**
* Environment Identifier
*/
Id: number;
/**
* Environment name
*/
Name: string;
/**
* Environment type
*/
Type: EnvironmentType;
/**
* URL or IP address of the Docker host associated with this environment.
*/
URL: string;
/**
* Environment group identifier
*/
GroupId: EnvironmentGroupId;
/**
* URL or IP address where exposed containers will be reachable
*/
PublicURL: string;
/**
* List of GPU configurations associated with this environment.
*/
Gpus: Pair[];
/**
* TLS configuration for connecting to the Docker host.
*/
TLSConfig: TLSConfiguration;
/**
* Azure credentials if the environment is an Azure environment.
*/
AzureCredentials?: AzureCredentials;
/**
* List of tag identifiers associated with this environment.
*/
TagIds: TagId[];
/**
* The status of the environment (1 - up, 2 - down, 3 - provisioning, 4 - error).
*/
Status: EnvironmentStatus;
/**
* A message that describes the status. Should be included for Status 3 or 4.
*/
StatusMessage: EnvironmentStatusMessage;
/**
* Cloud provider information if the environment was created using KaaS provisioning.
*/
CloudProvider?: CloudProviderSettings;
/**
* List of snapshots associated with this environment.
*/
Snapshots: DockerSnapshot[];
/**
* User access policies for connecting to this environment.
*/
UserAccessPolicies: UserAccessPolicies;
/**
* Team access policies for connecting to this environment.
*/
TeamAccessPolicies: TeamAccessPolicies;
/**
* The identifier of the edge agent associated with this environment.
*/
EdgeID?: string;
/**
* The key used to map the agent to Portainer.
*/
EdgeKey: string;
/**
* Associated Kubernetes data.
*/
Kubernetes: KubernetesSettings;
/**
* Maximum version of docker-compose.
*/
ComposeSyntaxMaxVersion: string;
/**
* Environment-specific security settings.
*/
SecuritySettings: EnvironmentSecuritySettings;
/**
* The identifier of the AMT Device associated with this environment.
*/
AMTDeviceGUID?: string;
/**
* Mark last check-in date on check-in.
*/
LastCheckInDate: number;
/**
* Query date of each query with the endpoints list.
*/
QueryDate: number;
/**
* Heartbeat status of an edge environment.
*/
Heartbeat: boolean;
/**
* Whether the device has been trusted by the user.
*/
UserTrusted: boolean;
/**
* The check-in interval for the edge agent (in seconds).
*/
EdgeCheckinInterval: number;
/**
* Edge settings for the environment.
*/
Edge: EnvironmentEdge;
/**
* Agent data for the environment.
*/
Agent: { Version?: string; PreviousVersion?: string };
/**
* Local time zone of the endpoint.
*/
LocalTimeZone: string;
/**
* Change window restriction for GitOps updates.
*/
ChangeWindow: EndpointChangeWindow;
/**
* Deployment options for the environment.
*/
DeploymentOptions?: DeploymentOptions;
/**
* Enable image notification for the environment.
*/
EnableImageNotification: boolean;
/**
* Enable GPU management for the environment.
*/
EnableGPUManagement: boolean;
}
/**
* TS reference of endpoint_create.go#EndpointCreationType iota
*/

View file

@ -14,9 +14,9 @@ import { FormControl } from '@@/form-components/FormControl';
import { BoxSelector, BoxSelectorOption } from '@@/BoxSelector';
import { BadgeIcon } from '@@/BadgeIcon';
import { NameField, useNameValidation } from '../shared/NameField';
import { NameField, useNameValidation } from '../../../common/NameField';
import { AnalyticsStateKey } from '../types';
import { metadataValidation } from '../shared/MetadataFieldset/validation';
import { metadataValidation } from '../../../common/MetadataFieldset/validation';
import { MoreSettingsSection } from '../shared/MoreSettingsSection';
interface FormValues {

View file

@ -14,7 +14,7 @@ import { LoadingButton } from '@@/buttons/LoadingButton';
import { FormControl } from '@@/form-components/FormControl';
import { Input } from '@@/form-components/Input';
import { NameField } from '../../shared/NameField';
import { NameField } from '../../../../common/NameField';
import { MoreSettingsSection } from '../../shared/MoreSettingsSection';
import { useValidation } from './APIForm.validation';

View file

@ -2,8 +2,8 @@ import { object, SchemaOf, string } from 'yup';
import { tlsConfigValidation } from '@/react/components/TLSFieldset/TLSFieldset';
import { metadataValidation } from '../../shared/MetadataFieldset/validation';
import { useNameValidation } from '../../shared/NameField';
import { metadataValidation } from '../../../../common/MetadataFieldset/validation';
import { useNameValidation } from '../../../../common/NameField';
import { FormValues } from './types';

View file

@ -11,7 +11,7 @@ import { FormControl } from '@@/form-components/FormControl';
import { Input } from '@@/form-components/Input';
import { SwitchField } from '@@/form-components/SwitchField';
import { NameField } from '../../shared/NameField';
import { NameField } from '../../../../common/NameField';
import { MoreSettingsSection } from '../../shared/MoreSettingsSection';
import { useValidation } from './SocketForm.validation';

View file

@ -1,7 +1,7 @@
import { boolean, object, SchemaOf, string } from 'yup';
import { metadataValidation } from '../../shared/MetadataFieldset/validation';
import { useNameValidation } from '../../shared/NameField';
import { metadataValidation } from '../../../../common/MetadataFieldset/validation';
import { useNameValidation } from '../../../../common/NameField';
import { FormValues } from './types';

View file

@ -0,0 +1,21 @@
import { K8sDistributionType, KaasProvider } from '../../../types';
export type ProvisionOption = KaasProvider | K8sDistributionType;
export const providerTitles: Record<KaasProvider, string> = {
civo: 'Civo',
linode: 'Linode',
digitalocean: 'DigitalOcean',
gke: 'Google Cloud',
amazon: 'AWS',
azure: 'Azure',
};
export const k8sInstallTitles: Record<K8sDistributionType, string> = {
microk8s: 'MicroK8s',
};
export const provisionOptionTitles: Record<ProvisionOption, string> = {
...providerTitles,
...k8sInstallTitles,
};

View file

@ -9,7 +9,7 @@ import { CreateAgentEnvironmentValues } from '@/react/portainer/environments/env
import { LoadingButton } from '@@/buttons/LoadingButton';
import { NameField } from '../NameField';
import { NameField } from '../../../../common/NameField';
import { MoreSettingsSection } from '../MoreSettingsSection';
import { EnvironmentUrlField } from './EnvironmentUrlField';

View file

@ -2,8 +2,8 @@ import { object, SchemaOf, string } from 'yup';
import { CreateAgentEnvironmentValues } from '@/react/portainer/environments/environment.service/create';
import { metadataValidation } from '../MetadataFieldset/validation';
import { useNameValidation } from '../NameField';
import { metadataValidation } from '../../../../common/MetadataFieldset/validation';
import { useNameValidation } from '../../../../common/NameField';
export function useValidation(): SchemaOf<CreateAgentEnvironmentValues> {
return object({

View file

@ -2,7 +2,7 @@ import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { PortainerTunnelAddrField } from '@/react/portainer/common/PortainerTunnelAddrField';
import { PortainerUrlField } from '@/react/portainer/common/PortainerUrlField';
import { NameField } from '../../NameField';
import { NameField } from '../../../../../common/NameField';
interface EdgeAgentFormProps {
readonly?: boolean;

View file

@ -7,8 +7,8 @@ import {
import { validation as urlValidation } from '@/react/portainer/common/PortainerTunnelAddrField';
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { metadataValidation } from '../../MetadataFieldset/validation';
import { useNameValidation } from '../../NameField';
import { metadataValidation } from '../../../../../common/MetadataFieldset/validation';
import { useNameValidation } from '../../../../../common/NameField';
import { FormValues } from './types';

View file

@ -2,7 +2,7 @@ import { PropsWithChildren } from 'react';
import { FormSection } from '@@/form-components/FormSection';
import { MetadataFieldset } from './MetadataFieldset';
import { MetadataFieldset } from '../../../common/MetadataFieldset';
export function MoreSettingsSection({ children }: PropsWithChildren<unknown>) {
return (

View file

@ -1,5 +1,5 @@
import { TeamId } from '@/react/portainer/users/teams/types';
import { UserId } from '@/portainer/users/types';
import { UserId } from '@/portainer/users/types/user-id';
import { TLSConfiguration } from '../../settings/types';
@ -20,12 +20,12 @@ export enum RegistryTypes {
}
export type RoleId = number;
interface AccessPolicy {
export interface AccessPolicy {
RoleId: RoleId;
}
type UserAccessPolicies = Record<UserId, AccessPolicy>; // map[UserID]AccessPolicy
type TeamAccessPolicies = Record<TeamId, AccessPolicy>;
export type UserAccessPolicies = Record<UserId, AccessPolicy>; // map[UserID]AccessPolicy
export type TeamAccessPolicies = Record<TeamId, AccessPolicy>;
export interface RegistryAccess {
UserAccessPolicies: UserAccessPolicies;