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

feat(intel): Enable OpenAMT and FDO capabilities (#6212)

* feat(openamt): add AMT Devices information in Environments view [INT-8] (#6169)

* feat(openamt): add AMT Devices Ouf of Band Managamenet actions  [INT-9] (#6171)

* feat(openamt): add AMT Devices KVM Connection [INT-10] (#6179)

* feat(openamt): Enhance the Environments MX to activate OpenAMT on compatible environments [INT-7] (#6196)

* feat(openamt): Enable KVM by default [INT-25] (#6228)

* feat(fdo): implement the FDO configuration settings INT-19 (#6238)

feat(fdo): implement the FDO configuration settings INT-19

* feat(fdo): implement Owner client INT-17 (#6231)

feat(fdo): implement Owner client INT-17

* feat(openamt): hide wireless config in OpenAMT form (#6250)

* feat(openamt): Increase OpenAMT timeouts [INT-30] (#6253)

* feat(openamt): Disable the ability to use KVM and OOB actions on a MPS disconnected device [INT-36] (#6254)

* feat(fdo): add import device UI [INT-20] (#6240)

feat(fdo): add import device UI INT-20

* refactor(fdo): fix develop merge issues

* feat(openamt): Do not fetch OpenAMT details for an unassociated Edge endpoint (#6273)

* fix(intel): Fix switches params (#6282)

* feat(openamt): preload existing AMT settings (#6283)

* feat(openamt): Better UI/UX for AMT activation loading [INT-39] (#6290)

* feat(openamt): Remove wireless config related code [INT-41] (#6291)

* yarn install

* feat(openamt): change kvm redirection for pop up, always enable features [INT-37] (#6292)

* feat(openamt): change kvm redirection for pop up, always enable features [INT-37] (#6293)

* feat(openmt): use .ts services with axios for OpenAMT (#6312)

* Minor code cleanup.

* fix(fdo): move the FDO client code to the hostmanagement folder INT-44 (#6345)

* refactor(intel): Add Edge Compute Settings view (#6351)

* feat(fdo): add FDO profiles INT-22 (#6363)

feat(fdo): add FDO profiles INT-22

* fix(fdo): fix incorrect profile URL INT-45 (#6377)

* fixed husky version

* fix go.mod with go mod tidy

* feat(edge): migrate OpenAMT devices views to Edge Devices [EE-2322] (#6373)

* feat(intel): OpenAMT UI/UX adjustments (#6394)

* only allow edge agent as edge device

* show all edge agent environments on Edge Devices view

* feat(fdo): add the ability to import multiple ownership vouchers at once EE-2324 (#6395)

* fix(edge): settings edge compute alert (#6402)

* remove pagination, add useMemo for devices result array (#6409)

* feat(edge): minor Edge Devices (AMT) UI fixes (#6410)

* chore(eslint): fix versions

* chore(app): reformat codebase

* change add edge agent modal behaviour, fix yarn.lock

* fix use pagination

* remove extractedTranslations folder

* feat(edge): add FDO Profiles Datatable [EE-2406] (#6415)

* feat(edge): add KVM workaround tooltip (#6441)

* feat(edge): Add default FDO profile (#6450)

* feat(edge): add settings to disable trust on first connect and enforce Edge ID INT-1 EE-2410 (#6429)

Co-authored-by: andres-portainer <91705312+andres-portainer@users.noreply.github.com>
Co-authored-by: Anthony Lapenna <anthony.lapenna@portainer.io>
Co-authored-by: andres-portainer <andres-portainer@users.noreply.github.com>
Co-authored-by: Chaim Lev-Ari <chiptus@gmail.com>
This commit is contained in:
Marcelo Rydel 2022-01-23 16:48:04 -03:00 committed by GitHub
parent 3ed92e5fee
commit 2c4c638f46
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
170 changed files with 6834 additions and 819 deletions

View file

@ -205,6 +205,51 @@ angular
},
};
var edgeDeviceCreation = {
name: 'portainer.endpoints.newEdgeDevice',
url: '/newEdgeDevice',
params: {
isEdgeDevice: true,
},
views: {
'content@': {
templateUrl: './views/endpoints/create/createendpoint.html',
controller: 'CreateEndpointController',
},
},
};
var deviceImport = {
name: 'portainer.endpoints.importDevice',
url: '/device',
views: {
'content@': {
templateUrl: './views/devices/import/importDevice.html',
controller: 'ImportDeviceController',
},
},
};
var addFDOProfile = {
name: 'portainer.endpoints.profile',
url: '/profile',
views: {
'content@': {
component: 'addProfileView',
},
},
};
var editFDOProfile = {
name: 'portainer.endpoints.profile.edit',
url: '/:id',
views: {
'content@': {
component: 'editProfileView',
},
},
};
var endpointAccess = {
name: 'portainer.endpoints.endpoint.access',
url: '/access',
@ -217,6 +262,17 @@ angular
},
};
var endpointKVM = {
name: 'portainer.endpoints.endpoint.kvm',
url: '/kvm?deviceId&deviceName',
views: {
'content@': {
templateUrl: './views/endpoints/kvm/endpointKVM.html',
controller: 'EndpointKVMController',
},
},
};
var groups = {
name: 'portainer.groups',
url: '/groups',
@ -378,6 +434,16 @@ angular
},
};
var settingsEdgeCompute = {
name: 'portainer.settings.edgeCompute',
url: '/edge',
views: {
'content@': {
component: 'settingsEdgeComputeView',
},
},
};
var tags = {
name: 'portainer.tags',
url: '/tags',
@ -444,7 +510,12 @@ angular
$stateRegistryProvider.register(endpoint);
$stateRegistryProvider.register(k8sendpoint);
$stateRegistryProvider.register(endpointAccess);
$stateRegistryProvider.register(endpointKVM);
$stateRegistryProvider.register(edgeDeviceCreation);
$stateRegistryProvider.register(endpointCreation);
$stateRegistryProvider.register(deviceImport);
$stateRegistryProvider.register(addFDOProfile);
$stateRegistryProvider.register(editFDOProfile);
$stateRegistryProvider.register(endpointKubernetesConfiguration);
$stateRegistryProvider.register(groups);
$stateRegistryProvider.register(group);
@ -461,6 +532,7 @@ angular
$stateRegistryProvider.register(registryCreation);
$stateRegistryProvider.register(settings);
$stateRegistryProvider.register(settingsAuthentication);
$stateRegistryProvider.register(settingsEdgeCompute);
$stateRegistryProvider.register(tags);
$stateRegistryProvider.register(users);
$stateRegistryProvider.register(user);

View file

@ -3,14 +3,23 @@ import clsx from 'clsx';
import styles from './TextTip.module.css';
export function TextTip({ children }: PropsWithChildren<unknown>) {
type Color = 'orange' | 'blue';
export interface Props {
color?: Color;
}
export function TextTip({
color = 'orange',
children,
}: PropsWithChildren<Props>) {
return (
<p className="text-muted small">
<i
aria-hidden="true"
className={clsx(
'fa fa-exclamation-circle',
'orange-icon',
`${color}-icon`,
styles.textMargin
)}
/>

View file

@ -0,0 +1,25 @@
.actions {
float: right;
margin: 5px 10px 0 0;
}
.actions-active {
color: var(--blue-2);
}
.table-actions-menu-list {
padding: 0 10px 0 10px;
}
.table-actions-menu-list [data-reach-menu-item] {
padding: 5px 5px !important;
}
.table-actions-menu-btn {
border: none;
background: none;
}
[data-reach-menu-link] {
text-decoration: none !important;
}

View file

@ -0,0 +1,31 @@
import { ReactNode } from 'react';
import clsx from 'clsx';
import { Menu, MenuList, MenuButton } from '@reach/menu-button';
import styles from './ActionsMenu.module.css';
interface Props {
children: ReactNode;
}
export function ActionsMenu({ children }: Props) {
return (
<Menu className={styles.actions}>
{({ isExpanded }) => (
<>
<MenuButton
className={clsx(
styles.tableActionsMenuBtn,
isExpanded && styles.actionsActive
)}
>
<i className="fa fa-ellipsis-v" aria-hidden="true" />
</MenuButton>
<MenuList>
<div className={styles.tableActionsMenuList}>{children}</div>
</MenuList>
</>
)}
</Menu>
);
}

View file

@ -0,0 +1,3 @@
.table-actions-title {
color: var(--blue-2);
}

View file

@ -0,0 +1,11 @@
import { ReactNode } from 'react';
import styles from './ActionsMenuTitle.module.css';
interface Props {
children: ReactNode;
}
export function ActionsMenuTitle({ children }: Props) {
return <div className={styles.tableActionsTitle}>{children}</div>;
}

View file

@ -3,17 +3,20 @@ import { Menu, MenuButton, MenuList } from '@reach/menu-button';
import { ColumnInstance } from 'react-table';
import { Checkbox } from '@/portainer/components/form-components/Checkbox';
import type { DockerContainer } from '@/docker/containers/types';
import { useTableContext } from './TableContainer';
interface Props {
columns: ColumnInstance<DockerContainer>[];
interface Props<D extends object> {
columns: ColumnInstance<D>[];
onChange: (value: string[]) => void;
value: string[];
}
export function ColumnVisibilityMenu({ columns, onChange, value }: Props) {
export function ColumnVisibilityMenu<D extends object>({
columns,
onChange,
value,
}: Props<D>) {
useTableContext();
return (

View file

@ -0,0 +1,26 @@
import { PropsWithChildren } from 'react';
import { Row, TableInstance } from 'react-table';
interface Props {
row: Row<TableInstance>;
}
export function ExpandingCell({ children, row }: PropsWithChildren<Props>) {
return (
// eslint-disable-next-line react/jsx-props-no-spreading
<div {...row.getToggleRowExpandedProps()}>
<i
className={`fas ${arrowClass(row.isExpanded)} space-right`}
aria-hidden="true"
/>
{children}
</div>
);
function arrowClass(isExpanded: boolean) {
if (isExpanded) {
return 'fa-angle-down';
}
return 'fa-angle-right';
}
}

View file

@ -0,0 +1,3 @@
.inner-datatable thead {
background-color: var(--bg-inner-datatable-thead) !important;
}

View file

@ -0,0 +1,7 @@
import { PropsWithChildren } from 'react';
import styles from './InnerDatatable.module.css';
export function InnerDatatable({ children }: PropsWithChildren<unknown>) {
return <div className={styles.innerDatatable}>{children}</div>;
}

View file

@ -5,7 +5,7 @@ import { TableHeaderCell } from './TableHeaderCell';
interface Props<D extends Record<string, unknown> = Record<string, unknown>> {
headers: HeaderGroup<D>[];
onSortChange(colId: string, desc: boolean): void;
onSortChange?(colId: string, desc: boolean): void;
}
export function TableHeaderRow<
@ -26,13 +26,18 @@ export function TableHeaderRow<
headerProps={{
...column.getHeaderProps({
className: column.className,
style: {
width: column.disableResizing ? column.width : '',
},
}),
}}
key={column.id}
canSort={column.canSort}
onSortClick={(desc) => {
column.toggleSortBy(desc);
onSortChange(column.id, desc);
if (onSortChange) {
onSortChange(column.id, desc);
}
}}
isSorted={column.isSorted}
isSortedDesc={column.isSortedDesc}

View file

@ -5,7 +5,7 @@ import { PropsWithChildren, ReactNode } from 'react';
import { useTableContext } from './TableContainer';
interface Props {
quickActions: ReactNode;
quickActions?: ReactNode;
}
export function TableSettingsMenu({

View file

@ -15,7 +15,7 @@
<i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Remove
</button>
<button type="button" class="btn btn-sm btn-primary" ui-sref="portainer.endpoints.new" data-cy="endpoint-addEndpointButton">
<i class="fa fa-plus space-right" aria-hidden="true"></i>Add environment
<i class="fa fa-plus-circle space-right" aria-hidden="true"></i>Add environment
</button>
</div>
<div class="searchBar">

View file

@ -3,6 +3,7 @@ angular.module('portainer.app').controller('EndpointsDatatableController', [
'$controller',
'DatatableService',
'PaginationService',
'Notifications',
function ($scope, $controller, DatatableService, PaginationService) {
angular.extend(this, $controller('GenericDatatableController', { $scope: $scope }));

View file

@ -1,3 +1,7 @@
.file-input {
display: none !important;
}
.file-button {
margin-left: 0 !important;
}

View file

@ -8,6 +8,7 @@ import styles from './FileUploadField.module.css';
export interface Props {
onChange(value: File): void;
value?: File;
accept?: string;
title?: string;
required?: boolean;
}
@ -15,6 +16,7 @@ export interface Props {
export function FileUploadField({
onChange,
value,
accept,
title = 'Select a file',
required = false,
}: Props) {
@ -25,12 +27,18 @@ export function FileUploadField({
<input
ref={fileRef}
type="file"
accept={accept}
required={required}
className={styles.fileInput}
onChange={changeHandler}
aria-label="file-input"
/>
<Button size="small" color="primary" onClick={handleButtonClick}>
<Button
size="small"
color="primary"
onClick={handleButtonClick}
className={styles.fileButton}
>
{title}
</Button>

View file

@ -5,9 +5,12 @@ import { Tooltip } from '@/portainer/components/Tip/Tooltip';
import styles from './FormControl.module.css';
type Size = 'small' | 'medium' | 'large';
export interface Props {
inputId: string;
label: string | ReactNode;
size?: Size;
tooltip?: string;
children: ReactNode;
errors?: string | ReactNode;
@ -16,6 +19,7 @@ export interface Props {
export function FormControl({
inputId,
label,
size = 'small',
tooltip = '',
children,
errors,
@ -25,13 +29,13 @@ export function FormControl({
<div className={clsx('form-group', styles.container)}>
<label
htmlFor={inputId}
className="col-sm-3 col-lg-2 control-label text-left"
className={`${sizeClassLabel(size)} control-label text-left`}
>
{label}
{tooltip && <Tooltip message={tooltip} />}
</label>
<div className="col-sm-9 col-lg-10">{children}</div>
<div className={`${sizeClassChildren(size)}`}>{children}</div>
</div>
{errors && (
@ -48,3 +52,25 @@ export function FormControl({
</div>
);
}
function sizeClassLabel(size?: Size) {
switch (size) {
case 'large':
return 'col-sm-5 col-lg-4';
case 'medium':
return 'col-sm-4 col-lg-3';
default:
return 'col-sm-3 col-lg-2';
}
}
function sizeClassChildren(size?: Size) {
switch (size) {
case 'large':
return 'col-sm-7 col-lg-8';
case 'medium':
return 'col-sm-8 col-lg-9';
default:
return 'col-sm-9 col-lg-10';
}
}

View file

@ -94,6 +94,7 @@ interface EndpointOptions {
checkinInterval?: number;
azure?: AzureSettings;
tls?: TLSSettings;
isEdgeDevice?: boolean;
}
export async function createRemoteEndpoint(
@ -134,6 +135,7 @@ async function createEndpoint(
GroupID: options.groupId,
TagIds: arrayToJson(options.tagIds),
CheckinInterval: options.checkinInterval,
IsEdgeDevice: options.isEdgeDevice,
};
const { tls, azure } = options;

View file

@ -20,6 +20,7 @@ interface EndpointsQuery {
endpointIds?: EnvironmentId[];
tagsPartialMatch?: boolean;
groupId?: EnvironmentGroupId;
edgeDeviceFilter?: boolean;
}
export async function getEndpoints(
@ -222,3 +223,14 @@ export async function updateSettings(
throw parseAxiosError(e as Error);
}
}
export async function trustEndpoint(id: EnvironmentId) {
try {
const { data: endpoint } = await axios.put<Environment>(buildUrl(id), {
UserTrusted: true,
});
return endpoint;
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to update environment');
}
}

View file

@ -58,7 +58,7 @@ export interface KubernetesSettings {
Snapshots: KubernetesSnapshot[];
}
export interface Environment {
export type Environment = {
Id: EnvironmentId;
Type: EnvironmentType;
TagIds: TagId[];
@ -72,7 +72,10 @@ export interface Environment {
Snapshots: DockerSnapshot[];
Kubernetes: KubernetesSettings;
PublicURL?: string;
}
IsEdgeDevice?: boolean;
UserTrusted: boolean;
AMTDeviceGUID?: string;
};
/**
* TS reference of endpoint_create.go#EndpointCreationType iota
@ -87,6 +90,12 @@ export enum EnvironmentCreationTypes {
export type EnvironmentGroupId = number;
export enum PlatformType {
Docker,
Kubernetes,
Azure,
}
export interface EnvironmentSettings {
// Whether non-administrator should be able to use bind mounts when creating containers
allowBindMountsForRegularUsers: boolean;

View file

@ -0,0 +1,44 @@
import { Environment, EnvironmentType, PlatformType } from './types';
export function getPlatformType(envType: EnvironmentType) {
switch (envType) {
case EnvironmentType.KubernetesLocal:
case EnvironmentType.AgentOnKubernetes:
case EnvironmentType.EdgeAgentOnKubernetes:
return PlatformType.Kubernetes;
case EnvironmentType.Docker:
case EnvironmentType.AgentOnDocker:
case EnvironmentType.EdgeAgentOnDocker:
return PlatformType.Docker;
case EnvironmentType.Azure:
return PlatformType.Azure;
default:
throw new Error(`Environment Type ${envType} is not supported`);
}
}
export function isEdgeEnvironment(envType: EnvironmentType) {
return [
EnvironmentType.EdgeAgentOnDocker,
EnvironmentType.EdgeAgentOnKubernetes,
].includes(envType);
}
export function getRoute(environment: Environment) {
if (isEdgeEnvironment(environment.Type) && !environment.EdgeID) {
return 'portainer.endpoints.endpoint';
}
const platform = getPlatformType(environment.Type);
switch (platform) {
case PlatformType.Azure:
return 'azure.dashboard';
case PlatformType.Docker:
return 'docker.dashboard';
case PlatformType.Kubernetes:
return 'kubernetes.dashboard';
default:
return '';
}
}

View file

@ -12,6 +12,10 @@ export default class EndpointHelper {
return endpoint.URL.includes('unix://') || endpoint.URL.includes('npipe://') || endpoint.Type === PortainerEndpointTypes.KubernetesLocalEnvironment;
}
static isDockerEndpoint(endpoint) {
return [PortainerEndpointTypes.DockerEnvironment, PortainerEndpointTypes.AgentOnDockerEnvironment, PortainerEndpointTypes.EdgeAgentOnDockerEnvironment].includes(endpoint.Type);
}
static isAgentEndpoint(endpoint) {
return [
PortainerEndpointTypes.AgentOnDockerEnvironment,

View file

@ -0,0 +1,99 @@
import axios, { parseAxiosError } from 'Portainer/services/axios';
import { FDOConfiguration, DeviceConfiguration, Profile } from './model';
const BASE_URL = '/fdo';
export async function configureFDO(formValues: FDOConfiguration) {
try {
await axios.post(`${BASE_URL}/configure`, formValues);
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to configure FDO');
}
}
export async function configureDevice(
deviceId: string,
deviceConfig: DeviceConfiguration
) {
try {
await axios.post(`${BASE_URL}/configure/${deviceId}`, deviceConfig);
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to configure device');
}
}
export async function createProfile(
name: string,
method: string,
profileFileContent: string
) {
const payload = {
name,
profileFileContent,
};
try {
await axios.post(`${BASE_URL}/profiles`, payload, {
params: { method },
});
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to create profile');
}
}
export async function getProfiles() {
try {
const { data: profiles } = await axios.get<Profile[]>(
`${BASE_URL}/profiles`
);
return profiles;
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to retrieve the profiles');
}
}
export async function getProfile(profileId: number) {
try {
const { data: profile } = await axios.get<Profile>(
`${BASE_URL}/profiles/${profileId}`
);
return profile;
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to retrieve profile');
}
}
export async function deleteProfile(profileId: number) {
try {
await axios.delete(`${BASE_URL}/profiles/${profileId}`);
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to delete profile');
}
}
export async function updateProfile(
id: number,
name: string,
profileFileContent: string
) {
const payload = {
name,
profileFileContent,
};
try {
await axios.put(`${BASE_URL}/profiles/${id}`, payload);
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to update profile');
}
}
export async function duplicateProfile(id: number) {
try {
const { data: profile } = await axios.post<Profile>(
`${BASE_URL}/profiles/${id}/duplicate`
);
return profile;
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to duplicate profile');
}
}

View file

@ -0,0 +1,20 @@
export interface FDOConfiguration {
enabled: boolean;
ownerURL: string;
ownerUsername: string;
ownerPassword: string;
}
export interface DeviceConfiguration {
edgeID: string;
edgeKey: string;
name: string;
profile: string;
}
export type Profile = {
id: number;
name: string;
fileContent: string;
dateCreated: string;
};

View file

@ -0,0 +1,40 @@
export interface OpenAMTConfiguration {
enabled: boolean;
mpsServer: string;
mpsUser: string;
mpsPassword: string;
domainName: string;
certFileName: string;
certFileContent: string;
certFilePassword: string;
}
export interface AMTInformation {
uuid: string;
amt: string;
buildNumber: string;
controlMode: string;
dnsSuffix: string;
rawOutput: string;
}
export interface AuthorizationResponse {
server: string;
token: string;
}
export interface DeviceFeatures {
ider: boolean;
kvm: boolean;
sol: boolean;
redirection: boolean;
userConsent: string;
}
export type Device = {
guid: string;
hostname: string;
powerState: number;
connectionStatus: boolean;
features?: DeviceFeatures;
};

View file

@ -0,0 +1,90 @@
import axios, { parseAxiosError } from 'Portainer/services/axios';
import { EnvironmentId } from '@/portainer/environments/types';
import {
OpenAMTConfiguration,
AMTInformation,
AuthorizationResponse,
Device,
DeviceFeatures,
} from './model';
const BASE_URL = '/open_amt';
export async function configureAMT(formValues: OpenAMTConfiguration) {
try {
await axios.post(`${BASE_URL}/configure`, formValues);
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to configure AMT');
}
}
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 activateDevice(environmentId: EnvironmentId) {
try {
await axios.post(`${BASE_URL}/${environmentId}/activate`);
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to activate device');
}
}
export async function getDevices(environmentId: EnvironmentId) {
try {
const { data: devices } = await axios.get<Device[]>(
`${BASE_URL}/${environmentId}/devices`
);
return devices;
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to retrieve device information');
}
}
export async function executeDeviceAction(
environmentId: EnvironmentId,
deviceGUID: string,
action: string
) {
try {
const actionPayload = { action };
await axios.post(
`${BASE_URL}/${environmentId}/devices/${deviceGUID}/action`,
actionPayload
);
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to execute device action');
}
}
export async function enableDeviceFeatures(
environmentId: EnvironmentId,
deviceGUID: string,
features: DeviceFeatures
) {
try {
const featuresPayload = { features };
const { data: authorizationResponse } =
await axios.post<AuthorizationResponse>(
`${BASE_URL}/${environmentId}/devices/${deviceGUID}/features`,
featuresPayload
);
return authorizationResponse;
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to enable device features');
}
}

View file

@ -4,7 +4,8 @@ export function SettingsViewModel(data) {
this.AuthenticationMethod = data.AuthenticationMethod;
this.LDAPSettings = data.LDAPSettings;
this.OAuthSettings = new OAuthSettingsViewModel(data.OAuthSettings);
this.OpenAMTConfiguration = data.OpenAMTConfiguration;
this.openAMTConfiguration = data.openAMTConfiguration;
this.fdoConfiguration = data.fdoConfiguration;
this.SnapshotInterval = data.SnapshotInterval;
this.TemplatesURL = data.TemplatesURL;
this.EdgeAgentCheckinInterval = data.EdgeAgentCheckinInterval;
@ -14,11 +15,15 @@ export function SettingsViewModel(data) {
this.EnableTelemetry = data.EnableTelemetry;
this.KubeconfigExpiry = data.KubeconfigExpiry;
this.HelmRepositoryURL = data.HelmRepositoryURL;
this.DisableTrustOnFirstConnect = data.DisableTrustOnFirstConnect;
this.EnforceEdgeID = data.EnforceEdgeID;
}
export function PublicSettingsViewModel(settings) {
this.AuthenticationMethod = settings.AuthenticationMethod;
this.EnableEdgeComputeFeatures = settings.EnableEdgeComputeFeatures;
this.DisableTrustOnFirstConnect = settings.DisableTrustOnFirstConnect;
this.EnforceEdgeID = settings.EnforceEdgeID;
this.FeatureFlagSettings = settings.FeatureFlagSettings;
this.LogoURL = settings.LogoURL;
this.OAuthLoginURI = settings.OAuthLoginURI;

View file

@ -1,16 +0,0 @@
import angular from 'angular';
const API_ENDPOINT_OPEN_AMT = 'api/open_amt';
angular.module('portainer.app').factory('OpenAMT', OpenAMTFactory);
/* @ngInject */
function OpenAMTFactory($resource) {
return $resource(
API_ENDPOINT_OPEN_AMT,
{},
{
submit: { method: 'POST' },
}
);
}

View file

@ -103,7 +103,8 @@ angular.module('portainer.app').factory('EndpointService', [
TLSCAFile,
TLSCertFile,
TLSKeyFile,
checkinInterval
checkinInterval,
isEdgeDevice
) {
var deferred = $q.defer();
@ -125,7 +126,8 @@ angular.module('portainer.app').factory('EndpointService', [
TLSCAFile,
TLSCertFile,
TLSKeyFile,
checkinInterval
checkinInterval,
isEdgeDevice
)
.then(function success(response) {
deferred.resolve(response.data);
@ -165,6 +167,10 @@ angular.module('portainer.app').factory('EndpointService', [
return deferred.promise;
};
service.trust = function (id) {
Endpoints.updateEndpoint({ id }, { UserTrusted: true }).$promise;
};
function updateRegistryAccess(id, registryId, endpointAccesses) {
return Endpoints.updateRegistryAccess({ registryId, id }, endpointAccesses).$promise;
}

View file

@ -1,14 +0,0 @@
import angular from 'angular';
angular.module('portainer.app').service('OpenAMTService', OpenAMTServiceFactory);
/* @ngInject */
function OpenAMTServiceFactory(OpenAMT) {
return {
submit,
};
function submit(formValues) {
return OpenAMT.submit(formValues).$promise;
}
}

View file

@ -136,7 +136,8 @@ angular.module('portainer.app').factory('FileUploadService', [
TLSCAFile,
TLSCertFile,
TLSKeyFile,
checkinInterval
checkinInterval,
isEdgeDevice
) {
return Upload.upload({
url: 'api/endpoints',
@ -154,6 +155,7 @@ angular.module('portainer.app').factory('FileUploadService', [
TLSCertFile: TLSCertFile,
TLSKeyFile: TLSKeyFile,
CheckinInterval: checkinInterval,
IsEdgeDevice: isEdgeDevice,
},
ignoreLoadingBar: true,
});
@ -207,6 +209,16 @@ angular.module('portainer.app').factory('FileUploadService', [
return $q.all(queue);
};
service.uploadOwnershipVoucher = function (voucherFile) {
return Upload.upload({
url: 'api/fdo/register',
data: {
voucher: voucherFile,
},
ignoreLoadingBar: true,
});
};
return service;
},
]);

View file

@ -30,6 +30,15 @@ interface PromptOptions {
callback: PromptCallback;
}
export async function promptAsync(options: Omit<PromptOptions, 'callback'>) {
return new Promise((resolve) => {
prompt({
...options,
callback: (result: string | string[]) => resolve(result),
});
});
}
export function prompt(options: PromptOptions) {
const box = bootbox.prompt({
title: options.title,

View file

@ -0,0 +1,162 @@
import { useTable, usePagination, useSortBy } from 'react-table';
import { useRowSelectColumn } from '@lineup-lite/hooks';
import { FDOProfilesDatatableActions } from 'Portainer/settings/edge-compute/FDOProfilesDatatable/FDOProfilesDatatableActions';
import { SelectedRowsCount } from 'Portainer/components/datatables/components/SelectedRowsCount';
import { PaginationControls } from 'Portainer/components/pagination-controls';
import { TableFooter } from 'Portainer/components/datatables/components/TableFooter';
import { useTableSettings } from 'Portainer/components/datatables/components/useTableSettings';
import { useRowSelect } from 'Portainer/components/datatables/components/useRowSelect';
import { Profile } from '@/portainer/hostmanagement/fdo/model';
import PortainerError from '@/portainer/error';
import {
Table,
TableActions,
TableContainer,
TableHeaderRow,
TableRow,
TableTitle,
} from '@/portainer/components/datatables/components';
import { FDOProfilesTableSettings } from '@/edge/devices/types';
import { useFDOProfiles } from '@/portainer/settings/edge-compute/FDOProfilesDatatable/useFDOProfiles';
import { useColumns } from './columns';
export interface FDOProfilesDatatableProps {
isFDOEnabled: boolean;
}
export function FDOProfilesDatatable({
isFDOEnabled,
}: FDOProfilesDatatableProps) {
const { settings, setTableSettings } =
useTableSettings<FDOProfilesTableSettings>();
const columns = useColumns();
const { isLoading, profiles, error } = useFDOProfiles();
const {
getTableProps,
getTableBodyProps,
headerGroups,
page,
prepareRow,
selectedFlatRows,
gotoPage,
setPageSize,
state: { pageIndex, pageSize },
} = useTable<Profile>(
{
defaultCanFilter: false,
columns,
data: profiles,
initialState: {
pageSize: settings.pageSize || 10,
sortBy: [settings.sortBy],
},
isRowSelectable() {
return isFDOEnabled;
},
selectColumnWidth: 5,
},
useSortBy,
usePagination,
useRowSelect,
useRowSelectColumn
);
const tableProps = getTableProps();
const tbodyProps = getTableBodyProps();
return (
<TableContainer>
<TableTitle icon="" label="Device Profiles" />
<TableActions>
<FDOProfilesDatatableActions
isFDOEnabled={isFDOEnabled}
selectedItems={selectedFlatRows.map((row) => row.original)}
/>
</TableActions>
<Table
className={tableProps.className}
role={tableProps.role}
style={tableProps.style}
>
<thead>
{headerGroups.map((headerGroup) => {
const { key, className, role, style } =
headerGroup.getHeaderGroupProps();
return (
<TableHeaderRow<Profile>
key={key}
className={className}
role={role}
style={style}
headers={headerGroup.headers}
/>
);
})}
</thead>
<tbody
className={tbodyProps.className}
role={tbodyProps.role}
style={tbodyProps.style}
>
{!isLoading && profiles && profiles.length > 0 ? (
page.map((row) => {
prepareRow(row);
const { key, className, role, style } = row.getRowProps();
return (
<TableRow<Profile>
cells={row.cells}
key={key}
className={className}
role={role}
style={style}
/>
);
})
) : (
<tr>
<td colSpan={5} className="text-center text-muted">
{userMessage(isLoading, error)}
</td>
</tr>
)}
</tbody>
</Table>
<TableFooter>
<SelectedRowsCount value={selectedFlatRows.length} />
<PaginationControls
showAll
pageLimit={pageSize}
page={pageIndex + 1}
onPageChange={(p) => gotoPage(p - 1)}
totalCount={profiles ? profiles.length : 0}
onPageLimitChange={handlePageSizeChange}
/>
</TableFooter>
</TableContainer>
);
function handlePageSizeChange(pageSize: number) {
setPageSize(pageSize);
setTableSettings((settings) => ({ ...settings, pageSize }));
}
}
function userMessage(isLoading: boolean, error?: PortainerError) {
if (isLoading) {
return 'Loading...';
}
if (error) {
return error.message;
}
return 'No profiles found';
}

View file

@ -0,0 +1,122 @@
import { useQueryClient } from 'react-query';
import { useRouter } from '@uirouter/react';
import { Button } from '@/portainer/components/Button';
import { Profile } from '@/portainer/hostmanagement/fdo/model';
import { Link } from '@/portainer/components/Link';
import { confirmAsync } from '@/portainer/services/modal.service/confirm';
import * as notifications from '@/portainer/services/notifications';
import {
deleteProfile,
duplicateProfile,
} from '@/portainer/hostmanagement/fdo/fdo.service';
interface Props {
isFDOEnabled: boolean;
selectedItems: Profile[];
}
export function FDOProfilesDatatableActions({
isFDOEnabled,
selectedItems,
}: Props) {
const router = useRouter();
const queryClient = useQueryClient();
return (
<div className="actionBar">
<Link to="portainer.endpoints.profile" className="space-left">
<Button disabled={!isFDOEnabled}>
<i className="fa fa-plus-circle space-right" aria-hidden="true" />
Add Profile
</Button>
</Link>
<Button
disabled={!isFDOEnabled || selectedItems.length !== 1}
onClick={() => onDuplicateProfileClick()}
>
<i className="fa fa-plus-circle space-right" aria-hidden="true" />
Duplicate
</Button>
<Button
disabled={!isFDOEnabled || selectedItems.length < 1}
color="danger"
onClick={() => onDeleteProfileClick()}
>
<i className="fa fa-trash-alt space-right" aria-hidden="true" />
Remove
</Button>
</div>
);
async function onDuplicateProfileClick() {
const confirmed = await confirmAsync({
title: 'Are you sure ?',
message: 'This action will duplicate the selected profile. Continue?',
buttons: {
confirm: {
label: 'Confirm',
className: 'btn-primary',
},
},
});
if (!confirmed) {
return;
}
try {
const profile = selectedItems[0];
const newProfile = await duplicateProfile(profile.id);
console.log({ newProfile });
notifications.success('Profile successfully duplicated', profile.name);
router.stateService.go('portainer.endpoints.profile.edit', {
id: newProfile.id,
});
} catch (err) {
console.log(err);
notifications.error(
'Failure',
err as Error,
'Unable to duplicate profile'
);
}
}
async function onDeleteProfileClick() {
const confirmed = await confirmAsync({
title: 'Are you sure ?',
message: 'This action will delete the selected profile(s). Continue?',
buttons: {
confirm: {
label: 'Remove',
className: 'btn-danger',
},
},
});
if (!confirmed) {
return;
}
await Promise.all(
selectedItems.map(async (profile) => {
try {
await deleteProfile(profile.id);
notifications.success('Profile successfully removed', profile.name);
} catch (err) {
notifications.error(
'Failure',
err as Error,
'Unable to remove profile'
);
}
})
);
await queryClient.invalidateQueries('fdo_profiles');
}
}

View file

@ -0,0 +1,22 @@
import { TableSettingsProvider } from '@/portainer/components/datatables/components/useTableSettings';
import {
FDOProfilesDatatable,
FDOProfilesDatatableProps,
} from './FDOProfilesDatatable';
export function FDOProfilesDatatableContainer({
...props
}: FDOProfilesDatatableProps) {
const defaultSettings = {
pageSize: 10,
sortBy: { id: 'name', desc: false },
};
return (
<TableSettingsProvider defaults={defaultSettings} storageKey="fdoProfiles">
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<FDOProfilesDatatable {...props} />
</TableSettingsProvider>
);
}

View file

@ -0,0 +1,14 @@
import { Column } from 'react-table';
import { isoDateFromTimestamp } from '@/portainer/filters/filters';
import { Profile } from '@/portainer/hostmanagement/fdo/model';
export const created: Column<Profile> = {
Header: 'Created',
accessor: 'dateCreated',
id: 'created',
Cell: ({ value }) => isoDateFromTimestamp(value),
disableFilters: true,
canHide: true,
Filter: () => null,
};

View file

@ -0,0 +1,8 @@
import { useMemo } from 'react';
import { created } from './created';
import { name } from './name';
export function useColumns() {
return useMemo(() => [name, created], []);
}

View file

@ -0,0 +1,30 @@
import { CellProps, Column } from 'react-table';
import { useSref } from '@uirouter/react';
import { Profile } from '@/portainer/hostmanagement/fdo/model';
export const name: Column<Profile> = {
Header: 'Name',
accessor: 'name',
id: 'name',
Cell: NameCell,
disableFilters: true,
Filter: () => null,
canHide: true,
sortType: 'string',
};
export function NameCell({
value: name,
row: { original: profile },
}: CellProps<Profile>) {
const linkProps = useSref('portainer.endpoints.profile.edit', {
id: profile.id,
});
return (
<a href={linkProps.href} onClick={linkProps.onClick} title={name}>
{name}
</a>
);
}

View file

@ -0,0 +1,30 @@
import { useEffect, useMemo } from 'react';
import { useQuery } from 'react-query';
import PortainerError from '@/portainer/error';
import * as notifications from '@/portainer/services/notifications';
import { getProfiles } from '@/portainer/hostmanagement/fdo/fdo.service';
export function useFDOProfiles() {
const { isLoading, data, isError, error } = useQuery('fdo_profiles', () =>
getProfiles()
);
useEffect(() => {
if (isError) {
notifications.error(
'Failure',
error as Error,
'Failed retrieving FDO profiles'
);
}
}, [isError, error]);
const profiles = useMemo(() => data || [], [data]);
return {
isLoading,
profiles,
error: isError ? (error as PortainerError) : undefined,
};
}

View file

@ -0,0 +1,3 @@
.save-button {
margin-top: 2rem;
}

View file

@ -0,0 +1,173 @@
import { Formik, Form } from 'formik';
import { Switch } from '@/portainer/components/form-components/SwitchField/Switch';
import { FormControl } from '@/portainer/components/form-components/FormControl';
import { Select } from '@/portainer/components/form-components/Input/Select';
import { Widget, WidgetBody, WidgetTitle } from '@/portainer/components/widget';
import { LoadingButton } from '@/portainer/components/Button/LoadingButton';
import { TextTip } from '@/portainer/components/Tip/TextTip';
import styles from './SettingsEdgeCompute.module.css';
import { validationSchema } from './SettingsEdgeCompute.validation';
export interface FormValues {
EdgeAgentCheckinInterval: number;
EnableEdgeComputeFeatures: boolean;
DisableTrustOnFirstConnect: boolean;
EnforceEdgeID: boolean;
}
interface Props {
settings: FormValues;
onSubmit(values: FormValues): void;
}
const checkinIntervalOptions = [
{
value: 5,
label: '5 seconds',
},
{
value: 10,
label: '10 seconds',
},
{
value: 30,
label: '30 seconds',
},
];
export function SettingsEdgeCompute({ settings, onSubmit }: Props) {
const initialValues = {
EdgeAgentCheckinInterval: settings ? settings.EdgeAgentCheckinInterval : 5,
EnableEdgeComputeFeatures: settings
? settings.EnableEdgeComputeFeatures
: false,
DisableTrustOnFirstConnect: settings
? settings.DisableTrustOnFirstConnect
: false,
EnforceEdgeID: settings ? settings.EnforceEdgeID : false,
};
return (
<div className="row">
<Widget>
<WidgetTitle icon="fa-laptop" title="Edge Compute settings" />
<WidgetBody>
<Formik
initialValues={initialValues}
enableReinitialize
validationSchema={() => validationSchema()}
onSubmit={onSubmit}
validateOnMount
>
{({
values,
errors,
handleSubmit,
setFieldValue,
isSubmitting,
isValid,
dirty,
}) => (
<Form
className="form-horizontal"
onSubmit={handleSubmit}
noValidate
>
<FormControl
inputId="edge_checkin"
label="Edge agent default poll frequency"
size="medium"
tooltip="Interval used by default by each Edge agent to check in with the Portainer instance. Affects Edge environment management and Edge compute features."
errors={errors.EdgeAgentCheckinInterval}
>
<Select
value={values.EdgeAgentCheckinInterval}
onChange={(e) =>
setFieldValue(
'EdgeAgentCheckinInterval',
parseInt(e.currentTarget.value, 10)
)
}
options={checkinIntervalOptions}
/>
</FormControl>
<FormControl
inputId="edge_checkin"
label="Enable Edge Compute features"
size="medium"
errors={errors.EnableEdgeComputeFeatures}
>
<Switch
id="edge_enable"
name="edge_enable"
className="space-right"
checked={values.EnableEdgeComputeFeatures}
onChange={(e) =>
setFieldValue('EnableEdgeComputeFeatures', e)
}
/>
</FormControl>
<TextTip color="blue">
When enabled, this will enable Portainer to execute Edge
Device features.
</TextTip>
<FormControl
inputId="edge_enforce_id"
label="Enforce environment ID"
size="medium"
errors={errors.EnforceEdgeID}
>
<Switch
id="edge_enforce_id"
name="edge_enforce_id"
className="space-right"
checked={values.EnforceEdgeID}
onChange={(e) =>
setFieldValue('EnforceEdgeID', e.valueOf())
}
/>
</FormControl>
<FormControl
inputId="edge_tofc"
label="Trust on first connect"
size="medium"
errors={errors.DisableTrustOnFirstConnect}
>
<Switch
id="edge_disable_tofc"
name="edge_disable_tofc"
className="space-right"
checked={!values.DisableTrustOnFirstConnect}
onChange={(e) =>
setFieldValue('DisableTrustOnFirstConnect', !e.valueOf())
}
/>
</FormControl>
<div className="form-group">
<div className="col-sm-12">
<LoadingButton
disabled={!isValid || !dirty}
dataCy="settings-edgeComputeButton"
className={styles.saveButton}
isLoading={isSubmitting}
loadingText="Saving settings..."
>
Save Settings
</LoadingButton>
</div>
</div>
</Form>
)}
</Formik>
</WidgetBody>
</Widget>
</div>
);
}

View file

@ -0,0 +1,10 @@
import { boolean, number, object } from 'yup';
export function validationSchema() {
return object().shape({
EdgeAgentCheckinInterval: number().required('This field is required.'),
EnableEdgeComputeFeatures: boolean().required('This field is required.'),
DisableTrustOnFirstConnect: boolean().required('This field is required.'),
EnforceEdgeID: boolean().required('This field is required.'),
});
}

View file

@ -0,0 +1,9 @@
import { react2angular } from '@/react-tools/react2angular';
import { SettingsEdgeCompute } from './SettingsEdgeCompute';
const SettingsEdgeComputeAngular = react2angular(SettingsEdgeCompute, [
'settings',
'onSubmit',
]);
export { SettingsEdgeCompute, SettingsEdgeComputeAngular };

View file

@ -0,0 +1,7 @@
.save-button {
margin-top: 2rem;
}
.fdo-table {
margin-top: 3em;
}

View file

@ -0,0 +1,186 @@
import { useEffect, useState } from 'react';
import { Formik, Field, Form } from 'formik';
import { Switch } from '@/portainer/components/form-components/SwitchField/Switch';
import { FormControl } from '@/portainer/components/form-components/FormControl';
import { FormSectionTitle } from '@/portainer/components/form-components/FormSectionTitle';
import { Widget, WidgetBody, WidgetTitle } from '@/portainer/components/widget';
import { LoadingButton } from '@/portainer/components/Button/LoadingButton';
import { TextTip } from '@/portainer/components/Tip/TextTip';
import { Input } from '@/portainer/components/form-components/Input';
import { FDOConfiguration } from '@/portainer/hostmanagement/fdo/model';
import { FDOProfilesDatatableContainer } from '@/portainer/settings/edge-compute/FDOProfilesDatatable/FDOProfilesDatatableContainer';
import styles from './SettingsFDO.module.css';
import { validationSchema } from './SettingsFDO.validation';
export interface Settings {
fdoConfiguration: FDOConfiguration;
EnableEdgeComputeFeatures: boolean;
}
interface Props {
settings: Settings;
onSubmit(values: FDOConfiguration): void;
}
export function SettingsFDO({ settings, onSubmit }: Props) {
const fdoConfiguration = settings ? settings.fdoConfiguration : null;
const initialFDOEnabled = fdoConfiguration ? fdoConfiguration.enabled : false;
const [isFDOEnabled, setIsFDOEnabled] = useState(initialFDOEnabled);
useEffect(() => {
setIsFDOEnabled(settings?.fdoConfiguration?.enabled);
}, [settings]);
const initialValues = {
enabled: initialFDOEnabled,
ownerURL: fdoConfiguration ? fdoConfiguration.ownerURL : '',
ownerUsername: fdoConfiguration ? fdoConfiguration.ownerUsername : '',
ownerPassword: fdoConfiguration ? fdoConfiguration.ownerPassword : '',
};
const edgeComputeFeaturesEnabled = settings
? settings.EnableEdgeComputeFeatures
: false;
return (
<div className="row">
<Widget>
<WidgetTitle icon="fa-laptop" title="FDO" />
<WidgetBody>
<Formik
initialValues={initialValues}
onSubmit={onSubmit}
enableReinitialize
validationSchema={() => validationSchema()}
validateOnChange
validateOnMount
>
{({
values,
errors,
handleSubmit,
setFieldValue,
isSubmitting,
isValid,
dirty,
}) => (
<Form className="form-horizontal" onSubmit={handleSubmit}>
<FormControl
inputId="edge_enableFDO"
label="Enable FDO Management Service"
size="medium"
errors={errors.enabled}
>
<Switch
id="edge_enableFDO"
name="edge_enableFDO"
className="space-right"
disabled={!edgeComputeFeaturesEnabled}
checked={edgeComputeFeaturesEnabled && values.enabled}
onChange={(e) => onChangedEnabled(e, setFieldValue)}
/>
</FormControl>
<TextTip color="blue">
When enabled, this will allow Portainer to interact with FDO
Services.
</TextTip>
{edgeComputeFeaturesEnabled && values.enabled && (
<>
<hr />
<FormControl
inputId="owner_url"
label="Owner Service Server"
errors={errors.ownerURL}
>
<Field
as={Input}
name="ownerURL"
id="owner_url"
placeholder="http://127.0.0.1:8042"
value={values.ownerURL}
data-cy="fdo-serverInput"
/>
</FormControl>
<FormControl
inputId="owner_username"
label="Owner Service Username"
errors={errors.ownerUsername}
>
<Field
as={Input}
name="ownerUsername"
id="owner_username"
placeholder="username"
value={values.ownerUsername}
data-cy="fdo-usernameInput"
/>
</FormControl>
<FormControl
inputId="owner_password"
label="Owner Service Password"
errors={errors.ownerPassword}
>
<Field
as={Input}
type="password"
name="ownerPassword"
id="owner_password"
placeholder="password"
value={values.ownerPassword}
data-cy="fdo-passwordInput"
/>
</FormControl>
</>
)}
<div className="form-group">
<div className="col-sm-12">
<LoadingButton
disabled={!isValid || !dirty}
dataCy="settings-fdoButton"
className={styles.saveButton}
isLoading={isSubmitting}
loadingText="Saving settings..."
>
Save Settings
</LoadingButton>
</div>
</div>
</Form>
)}
</Formik>
{edgeComputeFeaturesEnabled && isFDOEnabled && (
<div className={styles.fdoTable}>
<FormSectionTitle>Device Profiles</FormSectionTitle>
<TextTip color="blue">
Add, Edit and Manage the list of device profiles available
during FDO device setup
</TextTip>
<FDOProfilesDatatableContainer isFDOEnabled={initialFDOEnabled} />
</div>
)}
</WidgetBody>
</Widget>
</div>
);
async function onChangedEnabled(
e: boolean,
setFieldValue: (
field: string,
value: unknown,
shouldValidate?: boolean
) => void
) {
setIsFDOEnabled(e);
setFieldValue('enabled', e);
}
}

View file

@ -0,0 +1,18 @@
import { object, string } from 'yup';
export function validationSchema() {
return object().shape({
ownerURL: string().when('enabled', {
is: true,
then: string().required('Field is required'),
}),
ownerUsername: string().when('enabled', {
is: true,
then: string().required('Field is required'),
}),
ownerPassword: string().when('enabled', {
is: true,
then: string().required('Field is required'),
}),
});
}

View file

@ -0,0 +1,6 @@
import { react2angular } from '@/react-tools/react2angular';
import { SettingsFDO } from './SettingsFDO';
const SettingsFDOAngular = react2angular(SettingsFDO, ['settings', 'onSubmit']);
export { SettingsFDO, SettingsFDOAngular };

View file

@ -0,0 +1,3 @@
.save-button {
margin-top: 2rem;
}

View file

@ -0,0 +1,266 @@
import { useState } from 'react';
import { Formik, Field, Form } from 'formik';
import { Switch } from '@/portainer/components/form-components/SwitchField/Switch';
import { FormControl } from '@/portainer/components/form-components/FormControl';
import { Widget, WidgetBody, WidgetTitle } from '@/portainer/components/widget';
import { LoadingButton } from '@/portainer/components/Button/LoadingButton';
import { TextTip } from '@/portainer/components/Tip/TextTip';
import { Input } from '@/portainer/components/form-components/Input';
import { FileUploadField } from '@/portainer/components/form-components/FileUpload';
import { OpenAMTConfiguration } from '@/portainer/hostmanagement/open-amt/model';
import styles from './SettingsOpenAMT.module.css';
import { validationSchema } from './SettingsOpenAMT.validation';
export interface Settings {
openAMTConfiguration: OpenAMTConfiguration;
EnableEdgeComputeFeatures: boolean;
}
interface Props {
settings: Settings;
onSubmit(values: OpenAMTConfiguration): void;
}
export function SettingsOpenAMT({ settings, onSubmit }: Props) {
const [certFile, setCertFile] = useState<File>();
async function handleFileUpload(
file: File,
setFieldValue: (
field: string,
value: unknown,
shouldValidate?: boolean
) => void
) {
if (file) {
setCertFile(file);
const fileContent = await readFileContent(file);
setFieldValue('certFileContent', fileContent);
setFieldValue('certFileName', file.name);
}
}
function readFileContent(file: File) {
return new Promise((resolve, reject) => {
const fileReader = new FileReader();
fileReader.onload = (e) => {
if (e.target == null || e.target.result == null) {
resolve('');
return;
}
const base64 = e.target.result.toString();
// remove prefix of "data:application/x-pkcs12;base64," returned by "readAsDataURL()"
const index = base64.indexOf('base64,');
const cert = base64.substring(index + 7, base64.length);
resolve(cert);
};
fileReader.onerror = () => {
reject(new Error('error reading provisioning certificate file'));
};
fileReader.readAsDataURL(file);
});
}
const openAMTConfiguration = settings ? settings.openAMTConfiguration : null;
const initialValues = {
enabled: openAMTConfiguration ? openAMTConfiguration.enabled : false,
mpsServer: openAMTConfiguration ? openAMTConfiguration.mpsServer : '',
mpsUser: openAMTConfiguration ? openAMTConfiguration.mpsUser : '',
mpsPassword: openAMTConfiguration ? openAMTConfiguration.mpsPassword : '',
domainName: openAMTConfiguration ? openAMTConfiguration.domainName : '',
certFileContent: openAMTConfiguration
? openAMTConfiguration.certFileContent
: '',
certFileName: openAMTConfiguration ? openAMTConfiguration.certFileName : '',
certFilePassword: openAMTConfiguration
? openAMTConfiguration.certFilePassword
: '',
};
if (
initialValues.certFileContent &&
initialValues.certFileName &&
!certFile
) {
setCertFile(new File([], initialValues.certFileName));
}
const edgeComputeFeaturesEnabled = settings
? settings.EnableEdgeComputeFeatures
: false;
return (
<div className="row">
<Widget>
<WidgetTitle icon="fa-laptop" title="Intel OpenAMT" />
<WidgetBody>
<Formik
initialValues={initialValues}
onSubmit={onSubmit}
enableReinitialize
validationSchema={() => validationSchema()}
validateOnChange
validateOnMount
>
{({
values,
errors,
handleSubmit,
setFieldValue,
isSubmitting,
isValid,
dirty,
}) => (
<Form className="form-horizontal" onSubmit={handleSubmit}>
<FormControl
inputId="edge_enableOpenAMT"
label="Enable OpenAMT"
errors={errors.enabled}
>
<Switch
id="edge_enableOpenAMT"
name="edge_enableOpenAMT"
className="space-right"
disabled={!edgeComputeFeaturesEnabled}
checked={edgeComputeFeaturesEnabled && values.enabled}
onChange={(e) => setFieldValue('enabled', e)}
/>
</FormControl>
<TextTip color="blue">
When enabled, this will allow Portainer to interact with an
OpenAMT MPS API.
</TextTip>
{edgeComputeFeaturesEnabled && values.enabled && (
<>
<hr />
<FormControl
inputId="mps_server"
label="MPS Server"
size="medium"
errors={errors.mpsServer}
>
<Field
as={Input}
name="mpsServer"
id="mps_server"
placeholder="Enter the MPS Server"
value={values.mpsServer}
data-cy="openAMT-serverInput"
/>
</FormControl>
<FormControl
inputId="mps_username"
label="MPS User"
size="medium"
errors={errors.mpsUser}
>
<Field
as={Input}
name="mpsUser"
id="mps_username"
placeholder="Enter the MPS User"
value={values.mpsUser}
data-cy="openAMT-usernameInput"
/>
</FormControl>
<FormControl
inputId="mps_password"
label="MPS Password"
size="medium"
tooltip="Needs to be 8-32 characters including one uppercase, one lowercase letters, one base-10 digit and one special character."
errors={errors.mpsPassword}
>
<Field
as={Input}
type="password"
name="mpsPassword"
id="mps_password"
placeholder="Enter the MPS Password"
value={values.mpsPassword}
data-cy="openAMT-passwordInput"
/>
</FormControl>
<hr />
<FormControl
inputId="domain_name"
label="Domain Name"
size="medium"
tooltip="Enter the FQDN that is associated with the provisioning certificate (i.e amtdomain.com)"
errors={errors.domainName}
>
<Field
as={Input}
name="domainName"
id="domain_name"
placeholder="Enter the Domain Name"
value={values.domainName}
data-cy="openAMT-domainInput"
/>
</FormControl>
<FormControl
inputId="certificate_file"
label="Provisioning Certificate File (.pfx)"
size="medium"
tooltip="Supported CAs are Comodo, DigiCert, Entrust and GoDaddy.<br>The certificate must contain the private key.<br>On AMT 15 based devices you need to use SHA2."
errors={errors.certFileContent}
>
<FileUploadField
title="Upload file"
accept=".pfx"
value={certFile}
onChange={(file) =>
handleFileUpload(file, setFieldValue)
}
/>
</FormControl>
<FormControl
inputId="certificate_password"
label="Provisioning Certificate Password"
size="medium"
tooltip="Needs to be 8-32 characters including one uppercase, one lowercase letters, one base-10 digit and one special character."
errors={errors.certFilePassword}
>
<Field
as={Input}
type="password"
name="certFilePassword"
id="certificate_password"
placeholder="**********"
value={values.certFilePassword}
data-cy="openAMT-certPasswordInput"
/>
</FormControl>
</>
)}
<div className="form-group">
<div className="col-sm-12">
<LoadingButton
disabled={!isValid || !dirty}
dataCy="settings-fdoButton"
className={styles.saveButton}
isLoading={isSubmitting}
loadingText="Saving settings..."
>
Save Settings
</LoadingButton>
</div>
</div>
</Form>
)}
</Formik>
</WidgetBody>
</Widget>
</div>
);
}

View file

@ -0,0 +1,34 @@
import { object, string } from 'yup';
export function validationSchema() {
return object().shape({
mpsServer: string().when('enabled', {
is: true,
then: string().required('Field is required'),
}),
mpsUser: string().when('enabled', {
is: true,
then: string().required('Field is required'),
}),
mpsPassword: string().when('enabled', {
is: true,
then: string().required('Field is required'),
}),
domainName: string().when('enabled', {
is: true,
then: string().required('Field is required'),
}),
certFileContent: string().when('enabled', {
is: true,
then: string().required('Field is required'),
}),
certFileName: string().when('enabled', {
is: true,
then: string().required('Field is required'),
}),
certFilePassword: string().when('enabled', {
is: true,
then: string().required('Field is required'),
}),
});
}

View file

@ -0,0 +1,9 @@
import { react2angular } from '@/react-tools/react2angular';
import { SettingsOpenAMT } from './SettingsOpenAMT';
const SettingsOpenAMTAngular = react2angular(SettingsOpenAMT, [
'settings',
'onSubmit',
]);
export { SettingsOpenAMT, SettingsOpenAMTAngular };

View file

@ -1,6 +1,5 @@
import angular from 'angular';
import { sslCertificate } from './ssl-certificate';
import { openAMT } from './open-amt';
export default angular.module('portainer.settings.general', []).component('sslCertificateSettings', sslCertificate).component('openAmtSettings', openAMT).name;
export default angular.module('portainer.settings.general', []).component('sslCertificateSettings', sslCertificate).name;

View file

@ -1,6 +0,0 @@
import controller from './open-amt.controller.js';
export const openAMT = {
templateUrl: './open-amt.html',
controller,
};

View file

@ -1,118 +0,0 @@
class OpenAmtController {
/* @ngInject */
constructor($async, $scope, $state, OpenAMTService, SettingsService, Notifications) {
Object.assign(this, { $async, $scope, $state, OpenAMTService, SettingsService, Notifications });
this.originalValues = {};
this.formValues = {
enableOpenAMT: false,
mpsServer: '',
mpsUser: '',
mpsPassword: '',
domainName: '',
certFile: null,
certPassword: '',
useWirelessConfig: false,
wifiAuthenticationMethod: '4',
wifiEncryptionMethod: '3',
wifiSsid: '',
wifiPskPass: '',
};
this.originalValues = {
...this.formValues,
};
this.state = {
actionInProgress: false,
};
this.save = this.save.bind(this);
this.onChangeEnableOpenAMT = this.onChangeEnableOpenAMT.bind(this);
}
onChangeEnableOpenAMT(checked) {
return this.$scope.$evalAsync(() => {
this.formValues.enableOpenAMT = checked;
});
}
isFormChanged() {
return Object.entries(this.originalValues).some(([key, value]) => value !== this.formValues[key]);
}
isFormValid() {
return !this.formValues.enableOpenAMT || this.formValues.certFile != null;
}
async readFile() {
return new Promise((resolve, reject) => {
const file = this.formValues.certFile;
if (file) {
const fileReader = new FileReader();
fileReader.fileName = file.name;
fileReader.onload = (e) => {
const base64 = e.target.result;
// remove prefix of "data:application/x-pkcs12;base64," returned by "readAsDataURL()"
const index = base64.indexOf('base64,');
const cert = base64.substring(index + 7, base64.length);
resolve(cert);
};
fileReader.onerror = () => {
reject(new Error('error reading provisioning certificate file'));
};
fileReader.readAsDataURL(file);
}
});
}
async save() {
return this.$async(async () => {
this.state.actionInProgress = true;
try {
this.formValues.certFileText = this.formValues.certFile ? await this.readFile(this.formValues.certFile) : null;
await this.OpenAMTService.submit(this.formValues);
await new Promise((resolve) => setTimeout(resolve, 2000));
this.Notifications.success(`OpenAMT successfully ${this.formValues.enableOpenAMT ? 'enabled' : 'disabled'}`);
} catch (err) {
this.Notifications.error('Failure', err, 'Failed applying changes');
}
this.state.actionInProgress = false;
});
}
async $onInit() {
return this.$async(async () => {
try {
const data = await this.SettingsService.settings();
const config = data.OpenAMTConfiguration;
if (config) {
this.formValues = {
...this.formValues,
enableOpenAMT: config.Enabled,
mpsServer: config.MPSServer,
mpsUser: config.Credentials.MPSUser,
domainName: config.DomainConfiguration.DomainName,
};
if (config.WirelessConfiguration) {
this.formValues.useWirelessConfig = true;
this.formValues.wifiAuthenticationMethod = config.WirelessConfiguration.AuthenticationMethod;
this.formValues.wifiEncryptionMethod = config.WirelessConfiguration.EncryptionMethod;
this.formValues.wifiSsid = config.WirelessConfiguration.SSID;
}
this.originalValues = {
...this.formValues,
};
}
} catch (err) {
this.Notifications.error('Failure', err, 'Failed loading settings');
}
});
}
}
export default OpenAmtController;

View file

@ -1,249 +0,0 @@
<div class="row">
<div class="col-sm-12">
<rd-widget>
<rd-widget-header icon="fa-laptop" title-text="Intel OpenAMT"></rd-widget-header>
<rd-widget-body>
<form class="form-horizontal" name="openAMTForm">
<por-switch-field checked="$ctrl.formValues.enableOpenAMT" label="'Enable edge OpenAMT'" on-change="($ctrl.onChangeEnableOpenAMT)"></por-switch-field>
<span class="small">
<p class="text-muted" style="margin-top: 10px">
<i class="fa fa-exclamation-circle blue-icon" aria-hidden="true" style="margin-right: 2px"></i>
When enabled, this will allow Portainer to interact with an OpenAMT MPS API.
</p>
</span>
<div ng-show="$ctrl.formValues.enableOpenAMT">
<hr />
<div class="form-group">
<label for="mps_server" class="col-sm-3 control-label text-left"> MPS Server </label>
<div class="col-sm-8">
<input
type="text"
class="form-control"
ng-model="$ctrl.formValues.mpsServer"
id="mps_server"
name="mps_server"
placeholder="Enter the MPS Server"
ng-required="$ctrl.formValues.enableOpenAMT"
/>
</div>
</div>
<div class="form-group col-md-12" ng-show="openAMTForm.mps_server.$invalid">
<div class="small text-warning">
<div ng-messages="openAMTForm.mps_server.$error">
<p ng-message="required"> <i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field is required.</p>
</div>
</div>
</div>
<div class="form-group">
<label for="mps_user" class="col-sm-3 control-label text-left"> MPS User </label>
<div class="col-sm-8">
<input
type="text"
class="form-control"
ng-model="$ctrl.formValues.mpsUser"
id="mps_user"
name="mps_user"
placeholder="Enter the MPS User"
ng-required="$ctrl.formValues.enableOpenAMT"
/>
</div>
</div>
<div class="form-group col-md-12" ng-show="openAMTForm.mps_user.$invalid">
<div class="small text-warning">
<div ng-messages="openAMTForm.mps_user.$error">
<p ng-message="required"> <i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field is required.</p>
</div>
</div>
</div>
<div class="form-group">
<label for="mps_user" class="col-sm-3 control-label text-left">
MPS Password
<portainer-tooltip
position="bottom"
message="Needs to be 8-32 characters including one uppercase, one lowercase letters, one base-10 digit and one special character."
></portainer-tooltip>
</label>
<div class="col-sm-8">
<input
type="password"
class="form-control"
ng-model="$ctrl.formValues.mpsPassword"
id="mps_password"
name="mps_password"
placeholder="Enter the MPS Password"
ng-required="$ctrl.formValues.enableOpenAMT"
/>
</div>
</div>
<div class="form-group col-md-12" ng-show="openAMTForm.mps_password.$invalid">
<div class="small text-warning">
<div ng-messages="openAMTForm.mps_password.$error">
<p ng-message="required"> <i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field is required.</p>
</div>
</div>
</div>
<hr />
<div class="form-group">
<label for="domain_name" class="col-sm-3 control-label text-left">
Domain Name
<portainer-tooltip position="bottom" message="Enter the FQDN that is associated with the provisioning certificate (i.e amtdomain.com)"></portainer-tooltip>
</label>
<div class="col-sm-8">
<input
type="text"
class="form-control"
ng-model="$ctrl.formValues.domainName"
id="domain_name"
name="domain_name"
placeholder="Enter the Domain Name"
ng-required="$ctrl.formValues.enableOpenAMT"
/>
</div>
</div>
<div class="form-group col-md-12" ng-show="openAMTForm.domain_name.$invalid">
<div class="small text-warning">
<div ng-messages="openAMTForm.domain_name.$error">
<p ng-message="required"> <i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field is required.</p>
</div>
</div>
</div>
<div class="form-group">
<label for="certificate_file" class="col-sm-3 control-label text-left">
Provisioning Certificate File (.pfx)
<portainer-tooltip
position="bottom"
message="Supported CAs are Comodo, DigiCert, Entrust and GoDaddy. The certificate must contain the private key."
></portainer-tooltip>
</label>
<div class="col-sm-8">
<button style="margin-left: 0px !important" class="btn btn-sm btn-primary" ngf-select ng-model="$ctrl.formValues.certFile" ngf-pattern=".pfx" name="certFile">
Upload file
</button>
<span style="margin-left: 5px">
{{ $ctrl.formValues.certFile.name }}
<i class="fa fa-times red-icon" ng-if="!$ctrl.formValues.certFile" aria-hidden="true"></i>
</span>
</div>
</div>
<div class="form-group col-md-12" ng-show="openAMTForm.certFile.$invalid">
<div class="small text-warning">
<div ng-messages="openAMTForm.certFile.$error">
<p ng-message="pattern"> <i class="fa fa-exclamation-triangle" aria-hidden="true"></i> File type is invalid.</p>
</div>
</div>
</div>
<div class="form-group">
<label for="certificate_password" class="col-sm-3 control-label text-left">
Provisioning Certificate Password
<portainer-tooltip
position="bottom"
message="Needs to be 8-32 characters including one uppercase, one lowercase letters, one base-10 digit and one special character."
></portainer-tooltip>
</label>
<div class="col-sm-8">
<input
type="password"
class="form-control"
ng-model="$ctrl.formValues.certPassword"
id="certificate_password"
name="certificate_password"
placeholder="**********"
ng-required="$ctrl.formValues.enableOpenAMT"
/>
</div>
</div>
<div class="form-group col-md-12" ng-show="openAMTForm.certificate_password.$invalid">
<div class="small text-warning">
<div ng-messages="openAMTForm.certificate_password.$error">
<p ng-message="required"> <i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field is required.</p>
</div>
</div>
</div>
<hr />
<div class="form-group">
<div class="col-sm-12">
<!-- <por-switch-field checked="$ctrl.formValues.useWirelessConfig" label="'Wireless Configuration'" on-change="$ctrl.onChangeUseWirelessConfig"></por-switch-field> -->
</div>
</div>
<div ng-show="$ctrl.formValues.useWirelessConfig">
<div class="form-group">
<label for="wifi_auth_method" class="col-sm-3 control-label text-left"> Authentication Method </label>
<div class="col-sm-8">
<select class="form-control" ng-model="$ctrl.formValues.wifiAuthenticationMethod">
<option value="4">WPA PSK</option>
<option value="6">WPA2 PSK</option>
</select>
</div>
</div>
<div class="form-group">
<label for="wifi_encrypt_method" class="col-sm-3 control-label text-left"> Encryption Method </label>
<div class="col-sm-8">
<select class="form-control" ng-model="$ctrl.formValues.wifiEncryptionMethod" id="wifi_encrypt_method">
<option value="3">TKIP</option>
<option value="4">CCMP</option>
</select>
</div>
</div>
<div class="form-group">
<label for="wifi_ssid" class="col-sm-3 control-label text-left"> SSID </label>
<div class="col-sm-8">
<input
type="text"
class="form-control"
ng-model="$ctrl.formValues.wifiSsid"
id="wifi_ssid"
placeholder="SSIID"
ng-required="$ctrl.formValues.enableOpenAMT && $ctrl.formValues.useWirelessConfig"
/>
</div>
</div>
<div class="form-group">
<label for="wifi_pass" class="col-sm-3 control-label text-left">
PSK Passphrase
<portainer-tooltip position="bottom" message="PSK Passphrase length should be greater than or equal to 8 and less than or equal to 63"></portainer-tooltip>
</label>
<div class="col-sm-8">
<input
type="password"
class="form-control"
ng-model="$ctrl.formValues.wifiPskPass"
id="wifi_pass"
placeholder="******"
ng-required="$ctrl.formValues.enableOpenAMT && $ctrl.formValues.useWirelessConfig"
/>
</div>
</div>
</div>
</div>
<hr />
<div class="form-group">
<div class="col-sm-12">
<button
type="button"
class="btn btn-primary btn-sm"
ng-disabled="$ctrl.state.actionInProgress || !openAMTForm.$valid || !$ctrl.isFormChanged() || !$ctrl.isFormValid()"
ng-click="$ctrl.save()"
button-spinner="$ctrl.state.actionInProgress"
>
<span ng-hide="$ctrl.state.actionInProgress">Save Settings</span>
<span ng-show="$ctrl.state.actionInProgress">In progress...</span>
</button>
<span class="text-danger" ng-if="state.formValidationError" style="margin-left: 5px">{{ state.formValidationError }}</span>
</div>
</div>
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>

View file

@ -3,4 +3,12 @@ import angular from 'angular';
import authenticationModule from './authentication';
import generalModule from './general';
export default angular.module('portainer.settings', [authenticationModule, generalModule]).name;
import { SettingsEdgeComputeAngular } from './edge-compute/SettingsEdgeCompute';
import { SettingsFDOAngular } from './edge-compute/SettingsFDO';
import { SettingsOpenAMTAngular } from './edge-compute/SettingsOpenAMT';
export default angular
.module('portainer.settings', [authenticationModule, generalModule])
.component('settingsEdgeCompute', SettingsEdgeComputeAngular)
.component('settingsFdo', SettingsFDOAngular)
.component('settingsOpenAmt', SettingsOpenAMTAngular).name;

View file

@ -0,0 +1,220 @@
<rd-header>
<rd-header-title title-text="FDO Device Configuration"></rd-header-title>
<rd-header-content> <a ui-sref="portainer.endpoints">Environments</a> &gt; Import FDO Device </rd-header-content>
</rd-header>
<div class="row">
<div class="col-sm-12">
<rd-widget>
<rd-widget-header icon="fa-magic" title-text="Import Device Set up"></rd-widget-header>
<rd-widget-body>
<form class="form-horizontal" name="fdoForm">
<!-- info -->
<span class="small">
<p class="text-muted" style="margin-top: 10px">
<i class="fa fa-exclamation-circle blue-icon" aria-hidden="true" style="margin-right: 2px"></i>
You are setting up a Portainer Edge Agent that will initiate the communications with the Portainer instance and your FDO Devices.
</p>
</span>
<!-- !info -->
<!-- import voucher -->
<div class="col-sm-12 form-section-title"> Import Voucher </div>
<div>
<div class="form-group" ng-show="!state.vouchersUploaded">
<span class="small col-sm-12">
<p class="text-muted" style="margin-top: 10px">
<i class="fa fa-exclamation-circle blue-icon" aria-hidden="true" style="margin-right: 2px"></i>
Import one or more Manufacturer's Ownership Vouchers to initiate device attestation
</p>
</span>
<div class="col-sm-8">
<button
style="margin-left: 0px !important"
class="btn btn-sm btn-primary"
ngf-select="onVoucherFilesChange()"
ng-model="formValues.VoucherFiles"
name="VoucherFiles"
ng-disabled="state.vouchersUploading"
button-spinner="state.vouchersUploading"
multiple
>
<span ng-hide="state.vouchersUploading">Upload <i class="fa fa-upload" aria-hidden="true" style="margin-left: 5px"></i></span>
<span ng-show="state.vouchersUploading">Uploading Voucher...</span>
</button>
</div>
</div>
<div class="form-group" ng-show="state.vouchersUploading">
<div class="col-sm-12 small text-success">
<p>Connecting to Manufacturer's Rendezvous Server...</p>
</div>
</div>
<div class="form-group" ng-show="state.vouchersUploaded">
<div class="col-sm-12">
<p>Ownership Voucher Uploaded <i class="fa fa-check green-icon" aria-hidden="true" style="margin-left: 5px"></i></p>
</div>
</div>
</div>
<!-- !import voucher -->
<!-- device details -->
<div class="col-sm-12 form-section-title"> Device details </div>
<div>
<span class="small">
<p class="text-muted" style="margin-top: 10px">
<i class="fa fa-exclamation-circle blue-icon" aria-hidden="true" style="margin-right: 2px"></i>
Device name will serve as your reference name in Portainer
</p>
</span>
<!-- device name input -->
<div class="form-group">
<label for="device_name" class="col-sm-3 col-lg-2 control-label text-left">Device Name</label>
<div class="col-sm-9 col-lg-10">
<input
type="text"
class="form-control"
name="device_name"
placeholder="e.g. FDO-Test01"
ng-model="formValues.DeviceName"
ng-required="state.vouchersUploaded"
ng-disabled="!state.vouchersUploaded"
auto-focus
data-cy="deviceImport-deviceNameInput"
/>
</div>
</div>
<div class="form-group" ng-show="fdoForm.device_name.$invalid">
<div class="col-sm-12 small text-warning">
<div ng-messages="fdoForm.device_name.$error">
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field is required.</p>
</div>
</div>
</div>
<!-- !device name input -->
<!-- suffix input -->
<span class="small">
<p class="text-muted" style="margin-top: 10px">
<i class="fa fa-exclamation-circle blue-icon" aria-hidden="true" style="margin-right: 2px"></i>
Suffix starting number will be appended to the end of the Device name, if initiating multiple devices this will be incrementally increased
</p>
</span>
<div class="form-group">
<label for="suffix" class="col-sm-3 col-lg-2 control-label text-left"> Suffix starting number </label>
<div class="col-sm-9 col-lg-10">
<input
type="text"
class="form-control"
name="suffix"
ng-model="formValues.Suffix"
ng-required="state.vouchersUploaded"
ng-disabled="!state.vouchersUploaded"
ng-pattern="/^[0-9]+$/"
placeholder="1"
required
data-cy="deviceImport-suffixInput"
/>
</div>
</div>
<div class="form-group" ng-show="fdoForm.suffix.$invalid">
<div class="col-sm-12 small text-warning">
<div ng-messages="fdoForm.suffix.$error">
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field is required.</p>
<p ng-message="pattern"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field needs to be a positive integer number.</p>
</div>
</div>
</div>
<!-- !suffix input -->
<!-- portainer-instance-input -->
<div class="form-group">
<label for="endpoint_url" class="col-sm-3 col-lg-2 control-label text-left">
Portainer server URL
<portainer-tooltip position="bottom" message="URL of the Portainer instance that the agent will use to initiate the communications."></portainer-tooltip>
</label>
<div class="col-sm-9 col-lg-10">
<input
type="text"
class="form-control"
name="endpoint_url"
ng-model="formValues.PortainerURL"
ng-required="state.vouchersUploaded"
ng-disabled="!state.vouchersUploaded"
placeholder="e.g. http://10.0.0.10:9443"
required
data-cy="deviceImport-portainerServerUrlInput"
/>
</div>
</div>
<div class="form-group" ng-show="fdoForm.endpoint_url.$invalid">
<div class="col-sm-12 small text-warning">
<div ng-messages="fdoForm.endpoint_url.$error">
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field is required.</p>
</div>
</div>
</div>
<!-- !portainer-instance-input -->
</div>
<!-- device profile input -->
<div class="form-group">
<label for="device_profile" class="col-sm-3 col-lg-2 control-label text-left">Device Profile</label>
<div class="col-sm-9 col-lg-10">
<select id="device_profile" ng-model="formValues.DeviceProfile" class="form-control" ng-required="state.vouchersUploaded" ng-disabled="!state.vouchersUploaded">
<option selected disabled hidden value="">Select a profile for your device</option>
<option ng-repeat="profile in profiles | orderBy: 'name'" ng-value="profile.id">{{ profile.name }}</option>
</select>
</div>
</div>
<!-- !device profile input -->
<!-- !device details -->
<!-- tags -->
<div class="col-sm-12 form-section-title"> Set up Tags </div>
<div>
<span class="small">
<p class="text-muted" style="margin-top: 10px">
<i class="fa fa-exclamation-circle blue-icon" aria-hidden="true" style="margin-right: 2px"></i>
This is just an option if your device is under a certain group
</p>
</span>
<!-- group -->
<div class="form-group">
<label for="device_group" class="col-sm-3 col-lg-2 control-label text-left"> Group </label>
<div class="col-sm-9 col-lg-10">
<select
class="form-control"
ng-options="group.Id as group.Name for group in groups"
ng-model="formValues.GroupId"
id="device_group"
ng-required="state.vouchersUploaded"
ng-disabled="!state.vouchersUploaded"
data-cy="deviceImport-deviceGroup"
></select>
</div>
</div>
<!-- !group -->
<!-- tags -->
<div class="form-group">
<tag-selector ng-if="formValues && availableTags" tags="availableTags" model="formValues.TagIds" allow-create="state.allowCreateTag" on-create="(onCreateTag)">
</tag-selector>
</div>
<!-- !tags -->
<!-- actions -->
<div class="form-group">
<div class="col-sm-12">
<button
type="button"
class="btn btn-primary btn-sm"
ng-click="createEndpointAndConfigureDevice()"
ng-disabled="state.actionInProgress || !state.vouchersUploaded || !fdoForm.$valid"
button-spinner="state.actionInProgress"
data-cy="deviceImport-saveDeviceButton"
>
<span ng-hide="state.actionInProgress">Save Configuration</span>
<span ng-show="state.actionInProgress">Saving...</span>
</button>
<a type="button" class="btn btn-default btn-sm" ui-sref="portainer.endpoints">Cancel</a>
</div>
</div>
<!-- !actions -->
</div>
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>

View file

@ -0,0 +1,144 @@
import { PortainerEndpointCreationTypes } from 'Portainer/models/endpoint/models';
import { configureDevice, getProfiles } from 'Portainer/hostmanagement/fdo/fdo.service';
angular
.module('portainer.app')
.controller(
'ImportDeviceController',
function ImportDeviceController($async, $q, $scope, $state, EndpointService, GroupService, TagService, Notifications, Authentication, FileUploadService) {
$scope.state = {
actionInProgress: false,
vouchersUploading: false,
vouchersUploaded: false,
deviceIDs: [],
allowCreateTag: Authentication.isAdmin(),
};
$scope.formValues = {
DeviceName: '',
DeviceProfile: '',
GroupId: 1,
TagIds: [],
VoucherFiles: [],
PortainerURL: '',
Suffix: 1,
};
$scope.profiles = [];
$scope.onVoucherFilesChange = function () {
if ($scope.formValues.VoucherFiles.length < 1) {
return;
}
$scope.state.vouchersUploading = true;
let uploads = $scope.formValues.VoucherFiles.map((f) => FileUploadService.uploadOwnershipVoucher(f));
$q.all(uploads)
.then(function success(responses) {
$scope.state.vouchersUploading = false;
$scope.state.vouchersUploaded = true;
$scope.state.deviceIDs = responses.map((r) => r.data.guid);
})
.catch(function error(err) {
$scope.state.vouchersUploading = false;
if ($scope.formValues.VoucherFiles.length === 1) {
Notifications.error('Failure', err, 'Unable to upload the Ownership Voucher');
} else {
Notifications.error('Failure', null, 'Unable to upload the Ownership Vouchers, please check the logs');
}
});
};
$scope.onCreateTag = function onCreateTag(tagName) {
return $async(onCreateTagAsync, tagName);
};
async function onCreateTagAsync(tagName) {
try {
const tag = await TagService.createTag(tagName);
$scope.availableTags = $scope.availableTags.concat(tag);
$scope.formValues.TagIds = $scope.formValues.TagIds.concat(tag.Id);
} catch (err) {
Notifications.error('Failure', err, 'Unable to create tag');
}
}
$scope.createEndpointAndConfigureDevice = function () {
return $async(async () => {
$scope.state.actionInProgress = true;
let suffix = $scope.formValues.Suffix;
for (const deviceID of $scope.state.deviceIDs) {
try {
var endpoint = await EndpointService.createRemoteEndpoint(
$scope.formValues.DeviceName + suffix,
PortainerEndpointCreationTypes.EdgeAgentEnvironment,
$scope.formValues.PortainerURL,
'',
$scope.formValues.GroupId,
$scope.formValues.TagIds,
false,
false,
false,
null,
null,
null,
null,
true
);
} catch (err) {
Notifications.error('Failure', err, 'Unable to create the environment');
return;
}
suffix++;
const config = {
edgeID: endpoint.EdgeID,
edgeKey: endpoint.EdgeKey,
name: $scope.formValues.DeviceName,
profile: $scope.formValues.DeviceProfile,
};
try {
await configureDevice(deviceID, config);
} catch (err) {
Notifications.error('Failure', err, 'Unable to import device');
return;
} finally {
$scope.state.actionInProgress = false;
}
}
Notifications.success('Device(s) successfully imported');
$state.go('edge.devices');
});
};
async function initView() {
try {
$scope.profiles = await getProfiles();
} catch (err) {
Notifications.error('Failure', err, 'Unable to load profiles');
return;
}
$q.all({
groups: GroupService.groups(),
tags: TagService.tags(),
})
.then(function success(data) {
$scope.groups = data.groups;
$scope.availableTags = data.tags;
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to load groups');
});
}
initView();
}
);

View file

@ -0,0 +1,73 @@
<rd-header>
<rd-header-title title-text="Create profile"></rd-header-title>
<rd-header-content> <a ui-sref="portainer.settings">Settings</a> &gt; Edge Compute </rd-header-content>
</rd-header>
<div class="row">
<div class="col-sm-12">
<rd-widget>
<rd-widget-body>
<form class="form-horizontal" name="createProfileForm">
<!-- name-input -->
<div class="col-sm-12 form-section-title">Device Profile Details </div>
<div class="form-group">
<label for="stack_name" class="col-sm-1 control-label text-left">Name</label>
<div class="col-sm-11">
<input type="text" class="form-control" ng-model="formValues.name" id="profile_name" name="profile_name" placeholder="e.g. myprofile" auto-focus />
</div>
</div>
<!-- !name-input -->
<!-- build-method -->
<div class="col-sm-12 form-section-title"> Profile configuration </div>
<div class="form-group"></div>
<div class="form-group" style="margin-bottom: 0">
<div class="boxselector_wrapper">
<div>
<input type="radio" id="method_editor" ng-model="state.method" value="editor" />
<label for="method_editor">
<div class="boxselector_header">
<i class="fa fa-edit" aria-hidden="true" style="margin-right: 2px"></i>
Web editor
</div>
<p>Use our Web editor</p>
</label>
</div>
</div>
</div>
<!-- !build-method -->
<web-editor-form
ng-if="state.method === 'editor'"
identifier="profile-creation-editor"
value="formValues.profileFileContent"
on-change="(onChangeFileContent)"
ng-required="true"
placeholder="#"
>
</web-editor-form>
<!-- actions -->
<div class="form-group">
<div class="col-sm-12">
<a type="button" class="btn btn-default btn-sm" ui-sref="portainer.settings.edgeCompute">Cancel</a>
<button
type="button"
class="btn btn-primary btn-sm"
ng-disabled="state.actionInProgress
|| !createProfileForm.$valid
|| !formValues.profileFileContent
|| !formValues.name"
ng-click="createProfileAsync()"
button-spinner="state.actionInProgress"
>
<span ng-hide="state.actionInProgress">Save Profile</span>
<span ng-show="state.actionInProgress">Saving...</span>
</button>
</div>
</div>
<!-- !actions -->
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>

View file

@ -0,0 +1,66 @@
import angular from 'angular';
import { createProfile } from 'Portainer/hostmanagement/fdo/fdo.service';
angular.module('portainer.app').controller('AddProfileController', AddProfileController);
export default function AddProfileController($scope, $async, $state, $window, ModalService, Authentication, Notifications) {
$scope.formValues = {
name: '',
profileFileContent: '',
};
$scope.state = {
method: 'editor',
actionInProgress: false,
isEditorDirty: false,
};
$window.onbeforeunload = () => {
if ($scope.state.method === 'editor' && $scope.formValues.profileFileContent && $scope.state.isEditorDirty) {
return '';
}
};
$scope.$on('$destroy', function () {
$scope.state.isEditorDirty = false;
});
$scope.onChangeFormValues = onChangeFormValues;
$scope.createProfileAsync = function () {
return $async(async () => {
const method = $scope.state.method;
const name = $scope.formValues.name;
const fileContent = $scope.formValues.profileFileContent;
if (method !== 'editor' && fileContent === '') {
$scope.state.formValidationError = 'Profile file content must not be empty';
return;
}
$scope.state.actionInProgress = true;
try {
await createProfile(name, method, fileContent);
Notifications.success('Profile successfully created');
$scope.state.isEditorDirty = false;
$state.go('portainer.settings.edgeCompute');
} catch (err) {
Notifications.error('Failure', err, 'Unable to create Profile');
} finally {
$scope.state.actionInProgress = false;
}
});
};
$scope.onChangeFileContent = function onChangeFileContent(value) {
$scope.formValues.profileFileContent = value;
$scope.state.isEditorDirty = true;
};
function onChangeFormValues(newValues) {
$scope.formValues = newValues;
}
}

View file

@ -0,0 +1,8 @@
import angular from 'angular';
import controller from './addProfileController';
angular.module('portainer.app').component('addProfileView', {
templateUrl: './addProfile.html',
controller,
});

View file

@ -0,0 +1,73 @@
<rd-header>
<rd-header-title title-text="Edit profile"></rd-header-title>
<rd-header-content> <a ui-sref="portainer.settings">Settings</a> &gt; Edge Compute </rd-header-content>
</rd-header>
<div class="row">
<div class="col-sm-12">
<rd-widget>
<rd-widget-body>
<form class="form-horizontal" name="editProfileForm">
<!-- name-input -->
<div class="col-sm-12 form-section-title">Device Profile Details </div>
<div class="form-group">
<label for="stack_name" class="col-sm-1 control-label text-left">Name</label>
<div class="col-sm-11">
<input type="text" class="form-control" ng-model="formValues.name" id="profile_name" name="profile_name" placeholder="e.g. myprofile" auto-focus />
</div>
</div>
<!-- !name-input -->
<!-- build-method -->
<div class="col-sm-12 form-section-title"> Profile configuration </div>
<div class="form-group"></div>
<div class="form-group" style="margin-bottom: 0">
<div class="boxselector_wrapper">
<div>
<input type="radio" id="method_editor" ng-model="state.method" value="editor" />
<label for="method_editor">
<div class="boxselector_header">
<i class="fa fa-edit" aria-hidden="true" style="margin-right: 2px"></i>
Web editor
</div>
<p>Use our Web editor</p>
</label>
</div>
</div>
</div>
<!-- !build-method -->
<web-editor-form
ng-if="state.method === 'editor'"
identifier="profile-creation-editor"
value="formValues.profileFileContent"
on-change="(onChangeFileContent)"
ng-required="true"
placeholder="#"
>
</web-editor-form>
<!-- actions -->
<div class="form-group">
<div class="col-sm-12">
<a type="button" class="btn btn-default btn-sm" ui-sref="portainer.settings.edgeCompute">Cancel</a>
<button
type="button"
class="btn btn-primary btn-sm"
ng-disabled="state.actionInProgress
|| !editProfileForm.$valid
|| !formValues.profileFileContent
|| !formValues.name"
ng-click="updateProfileAsync()"
button-spinner="state.actionInProgress"
>
<span ng-hide="state.actionInProgress">Update Profile</span>
<span ng-show="state.actionInProgress">Saving...</span>
</button>
</div>
</div>
<!-- !actions -->
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>

View file

@ -0,0 +1,84 @@
import angular from 'angular';
import { getProfile, updateProfile } from 'Portainer/hostmanagement/fdo/fdo.service';
angular.module('portainer.app').controller('EditProfileController', EditProfileController);
export default function EditProfileController($scope, $async, $state, $window, ModalService, Authentication, Notifications) {
$scope.formValues = {
name: '',
profileFileContent: '',
};
$scope.state = {
profileID: $state.params.id,
method: 'editor',
actionInProgress: false,
isEditorDirty: false,
};
$window.onbeforeunload = () => {
if ($scope.state.method === 'editor' && $scope.formValues.profileFileContent && $scope.state.isEditorDirty) {
return '';
}
};
$scope.$on('$destroy', function () {
$scope.state.isEditorDirty = false;
});
$scope.onChangeFormValues = onChangeFormValues;
$scope.updateProfileAsync = function () {
return $async(async () => {
const method = $scope.state.method;
const name = $scope.formValues.name;
const fileContent = $scope.formValues.profileFileContent;
if (method !== 'editor' && fileContent === '') {
$scope.state.formValidationError = 'Profile file content must not be empty';
return;
}
$scope.state.actionInProgress = true;
try {
await updateProfile($scope.state.profileID, name, fileContent);
Notifications.success('Profile successfully updated');
$scope.state.isEditorDirty = false;
$state.go('portainer.settings.edgeCompute');
} catch (err) {
Notifications.error('Failure', err, 'Unable to update Profile');
} finally {
$scope.state.actionInProgress = false;
}
});
};
$scope.onChangeFileContent = function onChangeFileContent(value) {
$scope.formValues.profileFileContent = value;
$scope.state.isEditorDirty = true;
};
function onChangeFormValues(newValues) {
$scope.formValues = newValues;
}
async function initView() {
return $async(async () => {
try {
const profile = await getProfile($scope.state.profileID);
$scope.formValues = {
name: profile.name,
profileFileContent: profile.fileContent,
};
$scope.state.isEditorDirty = false;
} catch (err) {
Notifications.error('Failure', err, 'Unable to retrieve profile details');
}
});
}
initView();
}

View file

@ -0,0 +1,8 @@
import angular from 'angular';
import controller from './editProfileController';
angular.module('portainer.app').component('editProfileView', {
templateUrl: './editProfile.html',
controller,
});

View file

@ -24,11 +24,12 @@ angular
StateManager
) {
$scope.state = {
EnvironmentType: 'agent',
EnvironmentType: $state.params.isEdgeDevice ? 'edge_agent' : 'agent',
PlatformType: 'linux',
actionInProgress: false,
deploymentTab: 0,
allowCreateTag: Authentication.isAdmin(),
isEdgeDevice: $state.params.isEdgeDevice,
availableEdgeAgentCheckinOptions: [
{ key: 'Use default interval', value: 0 },
{
@ -271,7 +272,8 @@ angular
TLSCAFile,
TLSCertFile,
TLSKeyFile,
CheckinInterval
CheckinInterval,
$scope.state.isEdgeDevice
);
Notifications.success('Environment created', name);

View file

@ -12,7 +12,7 @@
<div class="form-group"></div>
<div class="form-group" style="margin-bottom: 0">
<div class="boxselector_wrapper">
<div ng-click="resetEndpointURL()">
<div ng-show="!state.isEdgeDevice" ng-click="resetEndpointURL()">
<input type="radio" id="agent_endpoint" ng-model="state.EnvironmentType" value="agent" />
<label for="agent_endpoint" data-cy="endpointCreate-agentSelectButton">
<div class="boxselector_header">
@ -32,7 +32,7 @@
<p>Portainer Edge agent</p>
</label>
</div>
<div ng-click="resetEndpointURL()">
<div ng-show="!state.isEdgeDevice" ng-click="resetEndpointURL()">
<input type="radio" id="docker_endpoint" ng-model="state.EnvironmentType" value="docker" />
<label for="docker_endpoint" data-cy="endpointCreate-dockerSelectButton">
<div class="boxselector_header">
@ -42,7 +42,7 @@
<p>Directly connect to the Docker API</p>
</label>
</div>
<div ng-click="resetEndpointURL()">
<div ng-show="!state.isEdgeDevice" ng-click="resetEndpointURL()">
<input type="radio" id="kubernetes_endpoint" ng-model="state.EnvironmentType" value="kubernetes" />
<label for="kubernetes_endpoint" data-cy="endpointCreate-kubeSelectButton">
<div class="boxselector_header">
@ -52,7 +52,7 @@
<p>Local Kubernetes environment</p>
</label>
</div>
<div>
<div ng-show="!state.isEdgeDevice">
<input type="radio" id="azure_endpoint" ng-model="state.EnvironmentType" value="azure" />
<label for="azure_endpoint" data-cy="endpointCreate-azureSelectButton">
<div class="boxselector_header">

View file

@ -10,7 +10,7 @@
</rd-header>
<div class="row">
<information-panel ng-if="state.edgeEndpoint && endpoint.EdgeID" title-text="Edge information">
<information-panel ng-if="state.edgeEndpoint && endpoint.EdgeID && endpoint.LastCheckInDate" title-text="Edge information">
<span class="small text-muted">
<p>
<i class="fa fa-info-circle blue-icon" aria-hidden="true" style="margin-right: 2px"></i>
@ -38,7 +38,7 @@
</p>
</span>
</information-panel>
<information-panel ng-if="state.edgeEndpoint && !endpoint.EdgeID" title-text="Deploy an agent">
<information-panel ng-if="state.edgeEndpoint && !endpoint.LastCheckInDate" title-text="Deploy an agent">
<span class="small text-muted">
<p>
<i class="fa fa-info-circle blue-icon" aria-hidden="true" style="margin-right: 2px"></i>
@ -65,17 +65,17 @@
<uib-tabset active="state.deploymentTab">
<uib-tab index="'kubernetes'" heading="Kubernetes" ng-if="state.platformType === 'linux'">
<code style="display: block; white-space: pre-wrap; padding: 16px 45px">
{{ dockerCommands[state.deploymentTab][state.platformType](agentVersion, agentShortVersion, randomEdgeID, endpoint.EdgeKey, state.allowSelfSignedCerts) }}
{{ dockerCommands[state.deploymentTab][state.platformType](agentVersion, agentShortVersion, endpoint.EdgeID, endpoint.EdgeKey, state.allowSelfSignedCerts) }}
</code>
</uib-tab>
<uib-tab index="'swarm'" heading="Docker Swarm">
<code style="display: block; white-space: pre-wrap; padding: 16px 45px">
{{ dockerCommands[state.deploymentTab][state.platformType](agentVersion, agentShortVersion, randomEdgeID, endpoint.EdgeKey, state.allowSelfSignedCerts) }}
{{ dockerCommands[state.deploymentTab][state.platformType](agentVersion, agentShortVersion, endpoint.EdgeID, endpoint.EdgeKey, state.allowSelfSignedCerts) }}
</code>
</uib-tab>
<uib-tab index="'standalone'" heading="Docker Standalone">
<code style="display: block; white-space: pre-wrap; padding: 16px 45px">
{{ dockerCommands[state.deploymentTab][state.platformType](agentVersion, agentShortVersion, randomEdgeID, endpoint.EdgeKey, state.allowSelfSignedCerts) }}
{{ dockerCommands[state.deploymentTab][state.platformType](agentVersion, agentShortVersion, endpoint.EdgeID, endpoint.EdgeKey, state.allowSelfSignedCerts) }}
</code>
</uib-tab>
</uib-tabset>
@ -204,6 +204,67 @@
<por-endpoint-security form-data="formValues.SecurityFormData" endpoint="endpoint"></por-endpoint-security>
</div>
<!-- !endpoint-security -->
<!-- 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..." />
</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..." />
</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"
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"
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"
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

View file

@ -4,6 +4,8 @@ import uuidv4 from 'uuid/v4';
import { PortainerEndpointTypes } from '@/portainer/models/endpoint/models';
import { EndpointSecurityFormData } from '@/portainer/components/endpointSecurity/porEndpointSecurityModel';
import { getAgentShortVersion } from 'Portainer/views/endpoints/helpers';
import EndpointHelper from '@/portainer/helpers/endpointHelper';
import { getAMTInfo } from 'Portainer/hostmanagement/open-amt/open-amt.service';
angular.module('portainer.app').controller('EndpointController', EndpointController);
@ -65,6 +67,7 @@ function EndpointController(
{ key: '1 day', value: 86400 },
],
allowSelfSignedCerts: true,
showAMTInfo: false,
};
$scope.agentVersion = StateManager.getState().application.version;
@ -94,7 +97,7 @@ function EndpointController(
const command = $scope.dockerCommands[$scope.state.deploymentTab][$scope.state.platformType](
$scope.agentVersion,
$scope.agentShortVersion,
$scope.randomEdgeID,
$scope.endpoint.EdgeID,
$scope.endpoint.EdgeKey,
$scope.state.allowSelfSignedCerts
);
@ -268,7 +271,7 @@ function EndpointController(
if (endpoint.Type === PortainerEndpointTypes.EdgeAgentOnDockerEnvironment || endpoint.Type === PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment) {
$scope.edgeKeyDetails = decodeEdgeKey(endpoint.EdgeKey);
$scope.randomEdgeID = uuidv4();
endpoint.EdgeID = endpoint.EdgeID || uuidv4();
$scope.state.availableEdgeAgentCheckinOptions[0].key += ` (${settings.EdgeAgentCheckinInterval} seconds)`;
}
@ -278,12 +281,41 @@ function EndpointController(
$scope.availableTags = tags;
configureState();
const disconnectedEdge = $scope.state.edgeEndpoint && !endpoint.EdgeID;
if (EndpointHelper.isDockerEndpoint(endpoint) && !disconnectedEdge) {
$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) {
console.log('Failure', err, 'Unable to JSON parse AMT info: ' + amtInfo.RawOutput);
clearAMTManagementInfo(amtInfo.RawOutput);
}
} catch (err) {
console.log('Failure', 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'] = '-';
}
function buildLinuxStandaloneCommand(agentVersion, agentShortVersion, edgeId, edgeKey, allowSelfSignedCerts) {
return `
docker run -d \\

View file

@ -7,7 +7,24 @@
<rd-header-content>Environment management</rd-header-content>
</rd-header>
<div class="row">
<div
class="row"
style="width: 100%; height: 100%; text-align: center; display: flex; flex-direction: column; align-items: center; justify-content: center"
ng-if="state.loadingMessage"
>
<div class="sk-fold">
<div class="sk-fold-cube"></div>
<div class="sk-fold-cube"></div>
<div class="sk-fold-cube"></div>
<div class="sk-fold-cube"></div>
</div>
<span style="margin-top: 25px">
{{ state.loadingMessage }}
<i class="fa fa-cog fa-spin"></i>
</span>
</div>
<div class="row" ng-if="!state.loadingMessage">
<div class="col-sm-12">
<endpoints-datatable
title-text="Environments"
@ -16,6 +33,7 @@
order-by="Name"
remove-action="removeAction"
retrieve-page="getPaginatedEndpoints"
set-loading-message="setLoadingMessage"
></endpoints-datatable>
</div>
</div>

View file

@ -4,8 +4,16 @@ import EndpointHelper from 'Portainer/helpers/endpointHelper';
angular.module('portainer.app').controller('EndpointsController', EndpointsController);
function EndpointsController($q, $scope, $state, $async, EndpointService, GroupService, ModalService, Notifications, EndpointProvider, StateManager) {
$scope.removeAction = removeAction;
$scope.state = {
loadingMessage: '',
};
$scope.setLoadingMessage = setLoadingMessage;
function setLoadingMessage(message) {
$scope.state.loadingMessage = message;
}
$scope.removeAction = removeAction;
function removeAction(endpoints) {
ModalService.confirmDeletion('This action will remove all configurations associated to your environment(s). Continue?', (confirmed) => {
if (!confirmed) {

View file

@ -0,0 +1,19 @@
.canvas-container .header {
display: none;
}
.canvas-container {
display: contents;
}
.kvm-maximized {
position: fixed;
background: white;
bottom: 0;
top: 0;
left: 0;
width: 100vw;
z-index: 1000;
max-height: 100% !important;
overflow-y: scroll;
}

View file

@ -0,0 +1,45 @@
import { useEffect, createRef } from 'react';
import { KVM } from '@open-amt-cloud-toolkit/ui-toolkit-react/reactjs/src/kvm.bundle';
import { react2angular } from '@/react-tools/react2angular';
import './KVMControl.css';
export interface KVMControlProps {
deviceId: string;
server: string;
token: string;
}
export function KVMControl({ deviceId, server, token }: KVMControlProps) {
const divRef = createRef<HTMLInputElement>();
useEffect(() => {
if (divRef.current) {
const connectButton = divRef.current.querySelector('button');
if (connectButton) {
connectButton.click();
}
}
});
if (!deviceId || !server || !token) return <div>Loading...</div>;
return (
<div ref={divRef}>
<KVM
deviceId={deviceId}
mpsServer={`https://${server}/mps/ws/relay`}
authToken={token}
mouseDebounceTime="200"
canvasHeight="100%"
canvasWidth="100%"
/>
</div>
);
}
export const KVMControlAngular = react2angular(KVMControl, [
'deviceId',
'server',
'token',
]);

View file

@ -0,0 +1,27 @@
<rd-header>
<rd-header-title title-text="KVM Control"></rd-header-title>
<rd-header-content>
<a ui-sref="portainer.endpoints">Environments</a> &gt; <a ui-sref="portainer.endpoints.endpoint({id: $state.endpoint.Id})">{{ $state.endpoint.Name }}</a> &gt;
{{ $state.deviceName }} > KVM Control
</rd-header-content>
</rd-header>
<div class="row">
<div class="col-sm-12">
<div class="datatable" ng-class="{ 'kvm-maximized': $state.maximized }">
<rd-widget>
<rd-widget-body classes="no-padding">
<div class="toolBar">
<div class="toolBarTitle">KVM Control</div>
<i class="interactive fa fa-times fa-lg pull-right" aria-hidden="true" ui-sref="edge.devices"></i>
<i ng-click="maximize()" title="Maximize" class="interactive fa fa-window-maximize fa-lg pull-right" style="margin-right: 10px" aria-hidden="true"></i>
<i ng-click="minimize()" title="Minimize" class="interactive fa fa-window-minimize fa-lg pull-right" style="margin-right: 10px" aria-hidden="true"></i>
</div>
<div class="actionBar">
<kvm-control device-id="$state.deviceId" server="$state.mpsServer" token="$state.mpsToken"> </kvm-control>
</div>
</rd-widget-body>
</rd-widget>
</div>
</div>
</div>

View file

@ -0,0 +1,57 @@
import angular from 'angular';
import { enableDeviceFeatures } from 'Portainer/hostmanagement/open-amt/open-amt.service';
class EndpointKVMController {
/* @ngInject */
constructor($state, $scope, $async, $transition$, EndpointService, Notifications) {
this.$state = $state;
this.$scope = $scope;
this.$async = $async;
this.$transition$ = $transition$;
this.Notifications = Notifications;
this.EndpointService = EndpointService;
this.$state.maximized = false;
this.$state.endpointId = $transition$.params().id;
this.$state.deviceId = $transition$.params().deviceId;
this.$state.deviceName = $transition$.params().deviceName;
$scope.maximize = function () {
this.$state.maximized = true;
};
$scope.minimize = function () {
this.$state.maximized = false;
};
}
async $onInit() {
this.$async(async () => {
try {
this.$state.endpoint = await this.EndpointService.endpoint(this.$state.endpointId);
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve environment information');
}
try {
const features = {
ider: true,
kvm: true,
sol: true,
redirection: true,
userConsent: 'none',
};
const mpsAuthorization = await enableDeviceFeatures(this.$state.endpointId, this.$state.deviceId, features);
this.$state.mpsServer = mpsAuthorization.Server;
this.$state.mpsToken = mpsAuthorization.Token;
} catch (e) {
this.Notifications.error('Failure', e, `Failed to load kvm for device`);
}
});
}
}
export default EndpointKVMController;
angular.module('portainer.app').controller('EndpointKVMController', EndpointKVMController);

View file

@ -0,0 +1,5 @@
import angular from 'angular';
import { KVMControlAngular } from '@/portainer/views/endpoints/kvm/KVMControl';
angular.module('portainer.app').component('kvmControl', KVMControlAngular).name;

View file

@ -1,4 +1,5 @@
import EndpointHelper from 'Portainer/helpers/endpointHelper';
import { getEndpoints } from 'Portainer/environments/environment.service';
angular
.module('portainer.app')
@ -65,7 +66,7 @@ angular
function getPaginatedEndpoints(lastId, limit, search) {
const deferred = $q.defer();
$q.all({
endpoints: EndpointService.endpoints(lastId, limit, { search }),
endpoints: getEndpoints(lastId, limit, { search, edgeDeviceFilter: false }),
groups: GroupService.groups(),
})
.then(function success(data) {

View file

@ -0,0 +1,7 @@
import angular from 'angular';
import controller from './settingsEdgeComputeController';
angular.module('portainer.app').component('settingsEdgeComputeView', {
templateUrl: './settingsEdgeCompute.html',
controller,
});

View file

@ -0,0 +1,22 @@
<rd-header>
<rd-header-title title-text="Settings"></rd-header-title>
<rd-header-content> <a ui-sref="portainer.settings">Settings</a> &gt; Edge Compute </rd-header-content>
</rd-header>
<div class="row">
<div class="col-sm-12">
<settings-edge-compute on-submit="($ctrl.onSubmitEdgeCompute)" settings="($ctrl.settings)"></settings-edge-compute>
</div>
</div>
<div class="row">
<div class="col-sm-12">
<settings-open-amt on-submit="($ctrl.onSubmitOpenAMT)" settings="($ctrl.settings)"></settings-open-amt>
</div>
</div>
<div class="row">
<div class="col-sm-12">
<settings-fdo on-submit="($ctrl.onSubmitFDO)" settings="($ctrl.settings)"></settings-fdo>
</div>
</div>

View file

@ -0,0 +1,54 @@
import angular from 'angular';
import { configureFDO } from '@/portainer/hostmanagement/fdo/fdo.service';
import { configureAMT } from 'Portainer/hostmanagement/open-amt/open-amt.service';
angular.module('portainer.app').controller('SettingsEdgeComputeController', SettingsEdgeComputeController);
/* @ngInject */
export default function SettingsEdgeComputeController($q, $async, $state, Notifications, SettingsService, StateManager) {
var ctrl = this;
this.onSubmitEdgeCompute = async function (settings) {
try {
await SettingsService.update(settings);
Notifications.success('Settings updated');
StateManager.updateEnableEdgeComputeFeatures(settings.EnableEdgeComputeFeatures);
$state.reload();
} catch (err) {
Notifications.error('Failure', err, 'Unable to update settings');
}
};
this.onSubmitOpenAMT = async function (formValues) {
try {
await configureAMT(formValues);
Notifications.success(`OpenAMT successfully ${formValues.enabled ? 'enabled' : 'disabled'}`);
$state.reload();
} catch (err) {
Notifications.error('Failure', err, 'Failed applying changes');
}
};
this.onSubmitFDO = async function (formValues) {
try {
await configureFDO(formValues);
Notifications.success(`FDO successfully ${formValues.enabled ? 'enabled' : 'disabled'}`);
$state.reload();
} catch (err) {
Notifications.error('Failure', err, 'Failed applying changes');
}
};
function initView() {
$async(async () => {
try {
ctrl.settings = await SettingsService.settings();
} catch (err) {
Notifications.error('Failure', err, 'Unable to retrieve application settings');
}
});
}
initView();
}

View file

@ -91,40 +91,10 @@
</div>
<!-- !helm charts -->
<!-- host-filesystem -->
<!-- edge -->
<div class="col-sm-12 form-section-title"> Edge Compute </div>
<div class="form-group">
<label for="edge_checkin" class="col-sm-2 control-label text-left">
Edge agent default poll frequency
<portainer-tooltip
position="bottom"
message="Interval used by default by each Edge agent to check in with the Portainer instance. Affects Edge environment management and Edge compute features."
></portainer-tooltip>
</label>
<div class="col-sm-10">
<select
id="edge_checkin"
class="form-control"
ng-model="settings.EdgeAgentCheckinInterval"
ng-options="+(opt.value) as opt.key for opt in state.availableEdgeAgentCheckinOptions"
data-cy="settings-pollFrequencySelect"
></select>
</div>
</div>
<div class="form-group">
<div class="col-sm-12">
<label for="toggle_enableEdgeComputeFeatures" class="control-label text-left"> Enable edge compute features </label>
<label class="switch" style="margin-left: 20px" data-cy="settings-edgeToggle">
<input type="checkbox" name="toggle_enableEdgeComputeFeatures" ng-model="formValues.enableEdgeComputeFeatures" /><i></i>
</label>
</div>
</div>
<!-- !edge -->
<!-- kube -->
<div class="col-sm-12 form-section-title"> Kubernetes </div>
<div class="form-group">
<label for="edge_checkin" class="col-sm-2 control-label text-left"> Kubeconfig expiry </label>
<label for="kubeconfig_expiry" class="col-sm-2 control-label text-left"> Kubeconfig expiry </label>
<div class="col-sm-10">
<select
id="kubeconfig_expiry"
@ -160,8 +130,6 @@
<ssl-certificate-settings></ssl-certificate-settings>
<open-amt-settings ng-if="settings.FeatureFlagSettings && settings.FeatureFlagSettings['open-amt']"></open-amt-settings>
<div class="row">
<div class="col-sm-12">
<rd-widget>

View file

@ -21,20 +21,6 @@ angular.module('portainer.app').controller('SettingsController', [
$scope.state = {
actionInProgress: false,
availableEdgeAgentCheckinOptions: [
{
key: '5 seconds',
value: 5,
},
{
key: '10 seconds',
value: 10,
},
{
key: '30 seconds',
value: 30,
},
],
availableKubeconfigExpiryOptions: [
{
key: '1 day',
@ -67,7 +53,6 @@ angular.module('portainer.app').controller('SettingsController', [
customLogo: false,
labelName: '',
labelValue: '',
enableEdgeComputeFeatures: false,
enableTelemetry: false,
passwordProtect: false,
password: '',
@ -132,7 +117,6 @@ angular.module('portainer.app').controller('SettingsController', [
settings.LogoURL = '';
}
settings.EnableEdgeComputeFeatures = $scope.formValues.enableEdgeComputeFeatures;
settings.EnableTelemetry = $scope.formValues.enableTelemetry;
$scope.state.actionInProgress = true;
@ -145,7 +129,6 @@ angular.module('portainer.app').controller('SettingsController', [
Notifications.success('Settings updated');
StateManager.updateLogo(settings.LogoURL);
StateManager.updateSnapshotInterval(settings.SnapshotInterval);
StateManager.updateEnableEdgeComputeFeatures(settings.EnableEdgeComputeFeatures);
StateManager.updateEnableTelemetry(settings.EnableTelemetry);
$state.reload();
})
@ -166,7 +149,6 @@ angular.module('portainer.app').controller('SettingsController', [
if (settings.LogoURL !== '') {
$scope.formValues.customLogo = true;
}
$scope.formValues.enableEdgeComputeFeatures = settings.EnableEdgeComputeFeatures;
$scope.formValues.enableTelemetry = settings.EnableTelemetry;
})
.catch(function error(err) {

View file

@ -60,6 +60,9 @@
</sidebar-section>
<sidebar-section title="Edge compute" ng-if="isAdmin && applicationState.application.enableEdgeComputeFeatures">
<sidebar-menu-item path="edge.devices" icon-class="fas fa-laptop-code fa-fw" class-name="sidebar-list" data-cy="portainerSidebar-edgeDevices">
Edge Devices
</sidebar-menu-item>
<sidebar-menu-item path="edge.groups" icon-class="fa-object-group fa-fw" class-name="sidebar-list" data-cy="portainerSidebar-edgeGroups">Edge Groups</sidebar-menu-item>
<sidebar-menu-item path="edge.stacks" icon-class="fa-layer-group fa-fw" class-name="sidebar-list" data-cy="portainerSidebar-edgeStacks">Edge Stacks</sidebar-menu-item>
<sidebar-menu-item path="edge.jobs" icon-class="fa-clock fa-fw" class-name="sidebar-list" data-cy="portainerSidebar-edgeJobs">Edge Jobs</sidebar-menu-item>
@ -97,8 +100,15 @@
<sidebar-menu-item path="portainer.activityLogs" class-name="sidebar-sublist" data-cy="portainerSidebar-activityLogs">Activity Logs</sidebar-menu-item>
</sidebar-menu>
<sidebar-menu label="Settings" icon-class="fa-cogs fa-fw" path="portainer.settings" is-sidebar-open="toggle" children-paths="['portainer.settings.authentication']">
<sidebar-menu
label="Settings"
icon-class="fa-cogs fa-fw"
path="portainer.settings"
is-sidebar-open="toggle"
children-paths="['portainer.settings.authentication', 'portainer.settings.edgeCompute']"
>
<sidebar-menu-item path="portainer.settings.authentication" class-name="sidebar-sublist" data-cy="portainerSidebar-authentication">Authentication</sidebar-menu-item>
<sidebar-menu-item path="portainer.settings.edgeCompute" class-name="sidebar-sublist" data-cy="portainerSidebar-edge-compute">Edge Compute</sidebar-menu-item>
<div class="sidebar-sublist">
<a href="https://www.portainer.io/community_help" target="_blank" data-cy="portainerSidebar-help">Help / About</a>