mirror of
https://github.com/portainer/portainer.git
synced 2025-08-02 20:35:25 +02:00
refactor(edge): move edge codebase to react (#7781)
This commit is contained in:
parent
75f40fe485
commit
1e4c4e2616
54 changed files with 254 additions and 187 deletions
153
app/react/edge/components/EdgeAsyncIntervalsForm.tsx
Normal file
153
app/react/edge/components/EdgeAsyncIntervalsForm.tsx
Normal file
|
@ -0,0 +1,153 @@
|
|||
import { number, object, SchemaOf } from 'yup';
|
||||
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { Select } from '@@/form-components/Input';
|
||||
|
||||
import { Options, useIntervalOptions } from './useIntervalOptions';
|
||||
|
||||
export const EDGE_ASYNC_INTERVAL_USE_DEFAULT = -1;
|
||||
|
||||
export interface EdgeAsyncIntervalsValues {
|
||||
PingInterval: number;
|
||||
SnapshotInterval: number;
|
||||
CommandInterval: number;
|
||||
}
|
||||
|
||||
export const options: Options = [
|
||||
{ label: 'Use default interval', value: -1, isDefault: true },
|
||||
{
|
||||
value: 0,
|
||||
label: 'disabled',
|
||||
},
|
||||
{
|
||||
value: 60,
|
||||
label: '1 minute',
|
||||
},
|
||||
{
|
||||
value: 60 * 60,
|
||||
label: '1 hour',
|
||||
},
|
||||
{
|
||||
value: 24 * 60 * 60,
|
||||
label: '1 day',
|
||||
},
|
||||
{
|
||||
value: 7 * 24 * 60 * 60,
|
||||
label: '1 week',
|
||||
},
|
||||
];
|
||||
|
||||
const defaultFieldSettings = {
|
||||
ping: {
|
||||
label: 'Ping interval',
|
||||
tooltip:
|
||||
'Interval used by this Edge agent to check in with the Portainer instance',
|
||||
},
|
||||
snapshot: {
|
||||
label: 'Snapshot interval',
|
||||
tooltip: 'Interval used by this Edge agent to snapshot the agent state',
|
||||
},
|
||||
command: {
|
||||
label: 'Command interval',
|
||||
tooltip:
|
||||
'Interval used by this Edge agent to fetch commands from the Portainer instance',
|
||||
},
|
||||
};
|
||||
|
||||
interface Props {
|
||||
values: EdgeAsyncIntervalsValues;
|
||||
isDefaultHidden?: boolean;
|
||||
readonly?: boolean;
|
||||
fieldSettings?: typeof defaultFieldSettings;
|
||||
onChange(value: EdgeAsyncIntervalsValues): void;
|
||||
}
|
||||
|
||||
export function EdgeAsyncIntervalsForm({
|
||||
onChange,
|
||||
values,
|
||||
isDefaultHidden = false,
|
||||
readonly = false,
|
||||
fieldSettings = defaultFieldSettings,
|
||||
}: Props) {
|
||||
const pingIntervalOptions = useIntervalOptions(
|
||||
'Edge.PingInterval',
|
||||
options,
|
||||
isDefaultHidden
|
||||
);
|
||||
|
||||
const snapshotIntervalOptions = useIntervalOptions(
|
||||
'Edge.SnapshotInterval',
|
||||
options,
|
||||
isDefaultHidden
|
||||
);
|
||||
|
||||
const commandIntervalOptions = useIntervalOptions(
|
||||
'Edge.CommandInterval',
|
||||
options,
|
||||
isDefaultHidden
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormControl
|
||||
inputId="edge_checkin_ping"
|
||||
label={fieldSettings.ping.label}
|
||||
tooltip={fieldSettings.ping.tooltip}
|
||||
>
|
||||
<Select
|
||||
value={values.PingInterval}
|
||||
name="PingInterval"
|
||||
onChange={handleChange}
|
||||
options={pingIntervalOptions}
|
||||
disabled={readonly}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl
|
||||
inputId="edge_checkin_snapshot"
|
||||
label={fieldSettings.snapshot.label}
|
||||
tooltip={fieldSettings.snapshot.tooltip}
|
||||
>
|
||||
<Select
|
||||
value={values.SnapshotInterval}
|
||||
name="SnapshotInterval"
|
||||
onChange={handleChange}
|
||||
options={snapshotIntervalOptions}
|
||||
disabled={readonly}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl
|
||||
inputId="edge_checkin_command"
|
||||
label={fieldSettings.command.label}
|
||||
tooltip={fieldSettings.command.tooltip}
|
||||
>
|
||||
<Select
|
||||
value={values.CommandInterval}
|
||||
name="CommandInterval"
|
||||
onChange={handleChange}
|
||||
options={commandIntervalOptions}
|
||||
disabled={readonly}
|
||||
/>
|
||||
</FormControl>
|
||||
</>
|
||||
);
|
||||
|
||||
function handleChange(e: React.ChangeEvent<HTMLSelectElement>) {
|
||||
onChange({ ...values, [e.target.name]: parseInt(e.target.value, 10) });
|
||||
}
|
||||
}
|
||||
|
||||
const intervals = options.map((option) => option.value);
|
||||
|
||||
export function edgeAsyncIntervalsValidation(): SchemaOf<EdgeAsyncIntervalsValues> {
|
||||
return object({
|
||||
PingInterval: number().required('This field is required.').oneOf(intervals),
|
||||
SnapshotInterval: number()
|
||||
.required('This field is required.')
|
||||
.oneOf(intervals),
|
||||
CommandInterval: number()
|
||||
.required('This field is required.')
|
||||
.oneOf(intervals),
|
||||
});
|
||||
}
|
67
app/react/edge/components/EdgeCheckInIntervalField.tsx
Normal file
67
app/react/edge/components/EdgeCheckInIntervalField.tsx
Normal file
|
@ -0,0 +1,67 @@
|
|||
import { FormControl, Size } from '@@/form-components/FormControl';
|
||||
import { Select } from '@@/form-components/Input';
|
||||
|
||||
import { Options, useIntervalOptions } from './useIntervalOptions';
|
||||
|
||||
interface Props {
|
||||
value: number;
|
||||
onChange(value: number): void;
|
||||
isDefaultHidden?: boolean;
|
||||
label?: string;
|
||||
tooltip?: string;
|
||||
readonly?: boolean;
|
||||
size?: Size;
|
||||
}
|
||||
|
||||
export const checkinIntervalOptions: Options = [
|
||||
{ label: 'Use default interval', value: 0, isDefault: true },
|
||||
{
|
||||
label: '5 seconds',
|
||||
value: 5,
|
||||
},
|
||||
{
|
||||
label: '10 seconds',
|
||||
value: 10,
|
||||
},
|
||||
{
|
||||
label: '30 seconds',
|
||||
value: 30,
|
||||
},
|
||||
{ label: '5 minutes', value: 300 },
|
||||
{ label: '1 hour', value: 3600 },
|
||||
{ label: '1 day', value: 86400 },
|
||||
];
|
||||
|
||||
export function EdgeCheckinIntervalField({
|
||||
value,
|
||||
readonly,
|
||||
onChange,
|
||||
isDefaultHidden = false,
|
||||
label = 'Poll frequency',
|
||||
tooltip = 'Interval used by this Edge agent to check in with the Portainer instance. Affects Edge environment management and Edge compute features.',
|
||||
size = 'small',
|
||||
}: Props) {
|
||||
const options = useIntervalOptions(
|
||||
'EdgeAgentCheckinInterval',
|
||||
checkinIntervalOptions,
|
||||
isDefaultHidden
|
||||
);
|
||||
|
||||
return (
|
||||
<FormControl
|
||||
inputId="edge_checkin"
|
||||
label={label}
|
||||
tooltip={tooltip}
|
||||
size={size}
|
||||
>
|
||||
<Select
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
onChange(parseInt(e.currentTarget.value, 10));
|
||||
}}
|
||||
options={options}
|
||||
disabled={readonly}
|
||||
/>
|
||||
</FormControl>
|
||||
);
|
||||
}
|
|
@ -14,18 +14,21 @@ const edgePropertiesFormInitialValues: ScriptFormValues = {
|
|||
platform: 'k8s' as Platform,
|
||||
nomadToken: '',
|
||||
authEnabled: true,
|
||||
tlsEnabled: false,
|
||||
};
|
||||
|
||||
interface Props {
|
||||
edgeInfo: EdgeInfo;
|
||||
commands: CommandTab[] | Partial<Record<OS, CommandTab[]>>;
|
||||
isNomadTokenVisible?: boolean;
|
||||
hideAsyncMode?: boolean;
|
||||
}
|
||||
|
||||
export function EdgeScriptForm({
|
||||
edgeInfo,
|
||||
commands,
|
||||
isNomadTokenVisible,
|
||||
hideAsyncMode,
|
||||
}: Props) {
|
||||
const showOsSelector = !(commands instanceof Array);
|
||||
|
||||
|
@ -60,6 +63,7 @@ export function EdgeScriptForm({
|
|||
onPlatformChange={(platform) =>
|
||||
setFieldValue('platform', platform)
|
||||
}
|
||||
hideAsyncMode={hideAsyncMode}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
|
|
@ -6,6 +6,17 @@ export function validationSchema(isNomadTokenVisible?: boolean) {
|
|||
return object().shape({
|
||||
allowSelfSignedCertificates: boolean(),
|
||||
envVars: string(),
|
||||
...(isNomadTokenVisible ? nomadTokenValidation() : {}),
|
||||
...nomadValidation(isNomadTokenVisible),
|
||||
});
|
||||
}
|
||||
|
||||
function nomadValidation(isNomadTokenVisible?: boolean) {
|
||||
if (!isNomadTokenVisible) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
tlsEnabled: boolean().default(false),
|
||||
...nomadTokenValidation(),
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { Field, useFormikContext } from 'formik';
|
||||
import { useFormikContext, Field } from 'formik';
|
||||
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { Input } from '@@/form-components/Input';
|
||||
|
@ -47,7 +47,22 @@ export function EdgeScriptSettingsFieldset({
|
|||
</>
|
||||
)}
|
||||
|
||||
{isNomadTokenVisible && <NomadTokenField />}
|
||||
{isNomadTokenVisible && (
|
||||
<>
|
||||
<NomadTokenField />
|
||||
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<SwitchField
|
||||
label="TLS"
|
||||
labelClass="col-sm-3 col-lg-2"
|
||||
checked={values.tlsEnabled}
|
||||
onChange={(checked) => setFieldValue('tlsEnabled', checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<FormControl
|
||||
label="Environment variables"
|
||||
|
|
|
@ -16,6 +16,7 @@ interface Props {
|
|||
commands: CommandTab[];
|
||||
platform?: Platform;
|
||||
onPlatformChange?(platform: Platform): void;
|
||||
hideAsyncMode?: boolean;
|
||||
}
|
||||
|
||||
export function ScriptTabs({
|
||||
|
@ -24,6 +25,7 @@ export function ScriptTabs({
|
|||
edgeId,
|
||||
commands,
|
||||
platform,
|
||||
hideAsyncMode = false,
|
||||
onPlatformChange = () => {},
|
||||
}: Props) {
|
||||
const agentDetails = useAgentDetails();
|
||||
|
@ -38,10 +40,17 @@ export function ScriptTabs({
|
|||
return null;
|
||||
}
|
||||
|
||||
const { agentSecret, agentVersion } = agentDetails;
|
||||
const { agentSecret, agentVersion, useEdgeAsyncMode } = agentDetails;
|
||||
|
||||
const options = commands.map((c) => {
|
||||
const cmd = c.command(agentVersion, edgeKey, values, edgeId, agentSecret);
|
||||
const cmd = c.command(
|
||||
agentVersion,
|
||||
edgeKey,
|
||||
values,
|
||||
!hideAsyncMode && useEdgeAsyncMode,
|
||||
edgeId,
|
||||
agentSecret
|
||||
);
|
||||
|
||||
return {
|
||||
id: c.id,
|
||||
|
|
|
@ -8,6 +8,7 @@ type CommandGenerator = (
|
|||
agentVersion: string,
|
||||
edgeKey: string,
|
||||
properties: ScriptFormValues,
|
||||
useAsyncMode: boolean,
|
||||
edgeId?: string,
|
||||
agentSecret?: string
|
||||
) => string;
|
||||
|
@ -34,6 +35,11 @@ export const commandsTabs: Record<string, CommandTab> = {
|
|||
label: 'Docker Standalone',
|
||||
command: buildLinuxStandaloneCommand,
|
||||
},
|
||||
nomadLinux: {
|
||||
id: 'nomad',
|
||||
label: 'Nomad',
|
||||
command: buildLinuxNomadCommand,
|
||||
},
|
||||
swarmWindows: {
|
||||
id: 'swarm',
|
||||
label: 'Docker Swarm',
|
||||
|
@ -58,6 +64,7 @@ export function buildLinuxStandaloneCommand(
|
|||
agentVersion: string,
|
||||
edgeKey: string,
|
||||
properties: ScriptFormValues,
|
||||
useAsyncMode: boolean,
|
||||
edgeId?: string,
|
||||
agentSecret?: string
|
||||
) {
|
||||
|
@ -69,7 +76,8 @@ export function buildLinuxStandaloneCommand(
|
|||
edgeKey,
|
||||
allowSelfSignedCertificates,
|
||||
!edgeIdGenerator ? edgeId : undefined,
|
||||
agentSecret
|
||||
agentSecret,
|
||||
useAsyncMode
|
||||
)
|
||||
);
|
||||
|
||||
|
@ -92,6 +100,7 @@ export function buildWindowsStandaloneCommand(
|
|||
agentVersion: string,
|
||||
edgeKey: string,
|
||||
properties: ScriptFormValues,
|
||||
useAsyncMode: boolean,
|
||||
edgeId?: string,
|
||||
agentSecret?: string
|
||||
) {
|
||||
|
@ -103,7 +112,8 @@ export function buildWindowsStandaloneCommand(
|
|||
edgeKey,
|
||||
allowSelfSignedCertificates,
|
||||
edgeIdGenerator ? '$Env:PORTAINER_EDGE_ID' : edgeId,
|
||||
agentSecret
|
||||
agentSecret,
|
||||
useAsyncMode
|
||||
)
|
||||
);
|
||||
|
||||
|
@ -127,6 +137,7 @@ export function buildLinuxSwarmCommand(
|
|||
agentVersion: string,
|
||||
edgeKey: string,
|
||||
properties: ScriptFormValues,
|
||||
useAsyncMode: boolean,
|
||||
edgeId?: string,
|
||||
agentSecret?: string
|
||||
) {
|
||||
|
@ -137,7 +148,8 @@ export function buildLinuxSwarmCommand(
|
|||
edgeKey,
|
||||
allowSelfSignedCertificates,
|
||||
!edgeIdGenerator ? edgeId : undefined,
|
||||
agentSecret
|
||||
agentSecret,
|
||||
useAsyncMode
|
||||
),
|
||||
'AGENT_CLUSTER_ADDR=tasks.portainer_edge_agent',
|
||||
]);
|
||||
|
@ -167,6 +179,7 @@ export function buildWindowsSwarmCommand(
|
|||
agentVersion: string,
|
||||
edgeKey: string,
|
||||
properties: ScriptFormValues,
|
||||
useAsyncMode: boolean,
|
||||
edgeId?: string,
|
||||
agentSecret?: string
|
||||
) {
|
||||
|
@ -177,7 +190,8 @@ export function buildWindowsSwarmCommand(
|
|||
edgeKey,
|
||||
allowSelfSignedCertificates,
|
||||
edgeIdGenerator ? '$Env:PORTAINER_EDGE_ID' : edgeId,
|
||||
agentSecret
|
||||
agentSecret,
|
||||
useAsyncMode
|
||||
),
|
||||
'AGENT_CLUSTER_ADDR=tasks.portainer_edge_agent',
|
||||
]);
|
||||
|
@ -208,13 +222,17 @@ export function buildLinuxKubernetesCommand(
|
|||
agentVersion: string,
|
||||
edgeKey: string,
|
||||
properties: ScriptFormValues,
|
||||
useAsyncMode: boolean,
|
||||
edgeId?: string,
|
||||
agentSecret?: string
|
||||
) {
|
||||
const { allowSelfSignedCertificates, edgeIdGenerator, envVars } = properties;
|
||||
|
||||
const agentShortVersion = getAgentShortVersion(agentVersion);
|
||||
const envVarsTrimmed = envVars.trim();
|
||||
let envVarsTrimmed = envVars.trim();
|
||||
if (useAsyncMode) {
|
||||
envVarsTrimmed += `EDGE_ASYNC=1`;
|
||||
}
|
||||
const idEnvVar = edgeIdGenerator
|
||||
? `PORTAINER_EDGE_ID=$(${edgeIdGenerator}) \n\n`
|
||||
: '';
|
||||
|
@ -224,11 +242,43 @@ export function buildLinuxKubernetesCommand(
|
|||
return `${idEnvVar}curl https://downloads.portainer.io/ee${agentShortVersion}/portainer-edge-agent-setup.sh | bash -s -- "${edgeIdVar}" "${edgeKey}" "${selfSigned}" "${agentSecret}" "${envVarsTrimmed}"`;
|
||||
}
|
||||
|
||||
export function buildLinuxNomadCommand(
|
||||
agentVersion: string,
|
||||
edgeKey: string,
|
||||
properties: ScriptFormValues,
|
||||
useAsyncMode: boolean,
|
||||
edgeId?: string,
|
||||
agentSecret?: string
|
||||
) {
|
||||
const {
|
||||
allowSelfSignedCertificates,
|
||||
edgeIdGenerator,
|
||||
envVars,
|
||||
nomadToken = '',
|
||||
tlsEnabled,
|
||||
} = properties;
|
||||
|
||||
const agentShortVersion = getAgentShortVersion(agentVersion);
|
||||
let envVarsTrimmed = envVars.trim();
|
||||
if (useAsyncMode) {
|
||||
envVarsTrimmed += `EDGE_ASYNC=1`;
|
||||
}
|
||||
|
||||
const selfSigned = allowSelfSignedCertificates ? '1' : '0';
|
||||
const idEnvVar = edgeIdGenerator
|
||||
? `PORTAINER_EDGE_ID=$(${edgeIdGenerator}) \n\n`
|
||||
: '';
|
||||
const edgeIdVar = !edgeIdGenerator && edgeId ? edgeId : '$PORTAINER_EDGE_ID';
|
||||
|
||||
return `${idEnvVar}curl https://downloads.portainer.io/ee${agentShortVersion}/portainer-edge-agent-nomad-setup.sh | bash -s -- "${nomadToken}" "${edgeIdVar}" "${edgeKey}" "${selfSigned}" "${envVarsTrimmed}" "${agentSecret}" "${tlsEnabled}"`;
|
||||
}
|
||||
|
||||
function buildDefaultEnvVars(
|
||||
edgeKey: string,
|
||||
allowSelfSignedCerts: boolean,
|
||||
edgeId = '$PORTAINER_EDGE_ID',
|
||||
agentSecret = ''
|
||||
agentSecret = '',
|
||||
useAsyncMode = false
|
||||
) {
|
||||
return _.compact([
|
||||
'EDGE=1',
|
||||
|
@ -236,5 +286,6 @@ function buildDefaultEnvVars(
|
|||
`EDGE_KEY=${edgeKey}`,
|
||||
`EDGE_INSECURE_POLL=${allowSelfSignedCerts ? 1 : 0}`,
|
||||
agentSecret ? `AGENT_SECRET=${agentSecret}` : ``,
|
||||
useAsyncMode ? 'EDGE_ASYNC=1' : '',
|
||||
]);
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ export type OS = 'win' | 'linux';
|
|||
export interface ScriptFormValues {
|
||||
nomadToken: string;
|
||||
authEnabled: boolean;
|
||||
tlsEnabled: boolean;
|
||||
|
||||
allowSelfSignedCertificates: boolean;
|
||||
envVars: string;
|
||||
|
|
67
app/react/edge/components/useIntervalOptions.ts
Normal file
67
app/react/edge/components/useIntervalOptions.ts
Normal file
|
@ -0,0 +1,67 @@
|
|||
import _ from 'lodash';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
import { useSettings } from '@/react/portainer/settings/queries';
|
||||
|
||||
type Option = {
|
||||
label: string;
|
||||
value: number;
|
||||
};
|
||||
|
||||
type DefaultOption = Option & { isDefault: true };
|
||||
|
||||
export type Options = [DefaultOption, ...Option[]];
|
||||
|
||||
export function useIntervalOptions(
|
||||
fieldName:
|
||||
| 'Edge.PingInterval'
|
||||
| 'Edge.SnapshotInterval'
|
||||
| 'Edge.CommandInterval'
|
||||
| 'EdgeAgentCheckinInterval',
|
||||
initialOptions: Options,
|
||||
isDefaultHidden: boolean
|
||||
) {
|
||||
const [{ value: defaultValue }] = initialOptions;
|
||||
const [options, setOptions] = useState<Option[]>(initialOptions);
|
||||
|
||||
const settingsQuery = useSettings(
|
||||
(settings) => _.get(settings, fieldName, 0) as number,
|
||||
!isDefaultHidden
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDefaultHidden) {
|
||||
setOptions(initialOptions.slice(1));
|
||||
}
|
||||
|
||||
if (
|
||||
!isDefaultHidden &&
|
||||
typeof settingsQuery.data !== 'undefined' &&
|
||||
settingsQuery.data !== defaultValue
|
||||
) {
|
||||
setOptions((options) => {
|
||||
let label = `${settingsQuery.data} seconds`;
|
||||
const option = options.find((o) => o.value === settingsQuery.data);
|
||||
if (option) {
|
||||
label = option.label;
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
value: defaultValue,
|
||||
label: `Use default interval (${label})`,
|
||||
},
|
||||
...options.slice(1),
|
||||
];
|
||||
});
|
||||
}
|
||||
}, [
|
||||
settingsQuery.data,
|
||||
setOptions,
|
||||
isDefaultHidden,
|
||||
initialOptions,
|
||||
defaultValue,
|
||||
]);
|
||||
|
||||
return options;
|
||||
}
|
|
@ -0,0 +1,104 @@
|
|||
import { usePagination, useTable } from 'react-table';
|
||||
|
||||
import { Device } from '@/portainer/hostmanagement/open-amt/model';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import PortainerError from '@/portainer/error';
|
||||
|
||||
import { InnerDatatable } from '@@/datatables/InnerDatatable';
|
||||
import { Table, TableContainer, TableHeaderRow, TableRow } from '@@/datatables';
|
||||
|
||||
import { useAMTDevices } from './useAMTDevices';
|
||||
import { RowProvider } from './columns/RowContext';
|
||||
import { useColumns } from './columns';
|
||||
|
||||
export interface AMTDevicesTableProps {
|
||||
environmentId: EnvironmentId;
|
||||
}
|
||||
|
||||
export function AMTDevicesDatatable({ environmentId }: AMTDevicesTableProps) {
|
||||
const columns = useColumns();
|
||||
|
||||
const { isLoading, devices, error } = useAMTDevices(environmentId);
|
||||
|
||||
const { getTableProps, getTableBodyProps, headerGroups, page, prepareRow } =
|
||||
useTable<Device>(
|
||||
{
|
||||
columns,
|
||||
data: devices,
|
||||
},
|
||||
usePagination
|
||||
);
|
||||
|
||||
const tableProps = getTableProps();
|
||||
const tbodyProps = getTableBodyProps();
|
||||
|
||||
return (
|
||||
<InnerDatatable>
|
||||
<TableContainer>
|
||||
<Table
|
||||
className={tableProps.className}
|
||||
role={tableProps.role}
|
||||
style={tableProps.style}
|
||||
>
|
||||
<thead>
|
||||
{headerGroups.map((headerGroup) => {
|
||||
const { key, className, role, style } =
|
||||
headerGroup.getHeaderGroupProps();
|
||||
return (
|
||||
<TableHeaderRow<Device>
|
||||
key={key}
|
||||
className={className}
|
||||
role={role}
|
||||
style={style}
|
||||
headers={headerGroup.headers}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</thead>
|
||||
<tbody
|
||||
className={tbodyProps.className}
|
||||
role={tbodyProps.role}
|
||||
style={tbodyProps.style}
|
||||
>
|
||||
{!isLoading && devices && devices.length > 0 ? (
|
||||
page.map((row) => {
|
||||
prepareRow(row);
|
||||
const { key, className, role, style } = row.getRowProps();
|
||||
|
||||
return (
|
||||
<RowProvider key={key} environmentId={environmentId}>
|
||||
<TableRow<Device>
|
||||
cells={row.cells}
|
||||
key={key}
|
||||
className={className}
|
||||
role={role}
|
||||
style={style}
|
||||
/>
|
||||
</RowProvider>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={5} className="text-center text-muted">
|
||||
{userMessage(isLoading, error)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</InnerDatatable>
|
||||
);
|
||||
}
|
||||
|
||||
function userMessage(isLoading: boolean, error?: PortainerError) {
|
||||
if (isLoading) {
|
||||
return 'Loading...';
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return 'No devices found';
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
import { PropsWithChildren, useMemo, useReducer } from 'react';
|
||||
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { createRowContext } from '@@/datatables/RowContext';
|
||||
|
||||
interface RowContextState {
|
||||
environmentId: EnvironmentId;
|
||||
isLoading: boolean;
|
||||
toggleIsLoading(): void;
|
||||
}
|
||||
|
||||
const { RowProvider: InternalProvider, useRowContext } =
|
||||
createRowContext<RowContextState>();
|
||||
|
||||
export { useRowContext };
|
||||
|
||||
interface Props {
|
||||
environmentId: EnvironmentId;
|
||||
}
|
||||
|
||||
export function RowProvider({
|
||||
environmentId,
|
||||
children,
|
||||
}: PropsWithChildren<Props>) {
|
||||
const [isLoading, toggleIsLoading] = useReducer((state) => !state, false);
|
||||
|
||||
const context = useMemo(
|
||||
() => ({
|
||||
isLoading,
|
||||
toggleIsLoading,
|
||||
environmentId,
|
||||
}),
|
||||
[environmentId, isLoading]
|
||||
);
|
||||
|
||||
return <InternalProvider context={context}>{children}</InternalProvider>;
|
||||
}
|
|
@ -0,0 +1,115 @@
|
|||
import { CellProps, Column } from 'react-table';
|
||||
import { useSref } from '@uirouter/react';
|
||||
import { MenuItem, MenuLink } from '@reach/menu-button';
|
||||
import { useQueryClient } from 'react-query';
|
||||
|
||||
import { Device } from '@/portainer/hostmanagement/open-amt/model';
|
||||
import { confirmAsync } from '@/portainer/services/modal.service/confirm';
|
||||
import { executeDeviceAction } from '@/portainer/hostmanagement/open-amt/open-amt.service';
|
||||
import * as notifications from '@/portainer/services/notifications';
|
||||
|
||||
import { ActionsMenu } from '@@/datatables/ActionsMenu';
|
||||
import { ActionsMenuTitle } from '@@/datatables/ActionsMenuTitle';
|
||||
|
||||
import { useRowContext } from './RowContext';
|
||||
|
||||
enum DeviceAction {
|
||||
PowerOn = 'power on',
|
||||
PowerOff = 'power off',
|
||||
Restart = 'restart',
|
||||
}
|
||||
|
||||
export const actions: Column<Device> = {
|
||||
Header: 'Actions',
|
||||
accessor: () => 'actions',
|
||||
id: 'actions',
|
||||
disableFilters: true,
|
||||
canHide: true,
|
||||
disableResizing: true,
|
||||
width: '5px',
|
||||
sortType: 'string',
|
||||
Filter: () => null,
|
||||
Cell: ActionsCell,
|
||||
};
|
||||
|
||||
export function ActionsCell({ row: { original: device } }: CellProps<Device>) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { isLoading, toggleIsLoading, environmentId } = useRowContext();
|
||||
|
||||
const kvmLinkProps = useSref('portainer.endpoints.endpoint.kvm', {
|
||||
id: environmentId,
|
||||
deviceId: device.guid,
|
||||
deviceName: device.hostname,
|
||||
});
|
||||
|
||||
return (
|
||||
<ActionsMenu>
|
||||
<ActionsMenuTitle>AMT Functions</ActionsMenuTitle>
|
||||
<MenuItem
|
||||
disabled={isLoading}
|
||||
onSelect={() => handleDeviceActionClick(DeviceAction.PowerOn)}
|
||||
>
|
||||
Power ON
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
disabled={isLoading}
|
||||
onSelect={() => handleDeviceActionClick(DeviceAction.PowerOff)}
|
||||
>
|
||||
Power OFF
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
disabled={isLoading}
|
||||
onSelect={() => handleDeviceActionClick(DeviceAction.Restart)}
|
||||
>
|
||||
Restart
|
||||
</MenuItem>
|
||||
<MenuLink
|
||||
href={kvmLinkProps.href}
|
||||
onClick={kvmLinkProps.onClick}
|
||||
disabled={isLoading}
|
||||
>
|
||||
KVM
|
||||
</MenuLink>
|
||||
</ActionsMenu>
|
||||
);
|
||||
|
||||
async function handleDeviceActionClick(action: string) {
|
||||
const confirmed = await confirmAsync({
|
||||
title: 'Confirm action',
|
||||
message: `Are you sure you want to ${action} the device?`,
|
||||
buttons: {
|
||||
cancel: {
|
||||
label: 'Cancel',
|
||||
className: 'btn-default',
|
||||
},
|
||||
confirm: {
|
||||
label: 'Confirm',
|
||||
className: 'btn-primary',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
toggleIsLoading();
|
||||
await executeDeviceAction(environmentId, device.guid, action);
|
||||
notifications.success(
|
||||
`${action} action sent successfully`,
|
||||
device.hostname
|
||||
);
|
||||
await queryClient.invalidateQueries(['amt_devices', environmentId]);
|
||||
} catch (err) {
|
||||
notifications.error(
|
||||
'Failure',
|
||||
err as Error,
|
||||
`Failed to ${action} the device`
|
||||
);
|
||||
} finally {
|
||||
toggleIsLoading();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
import { Column } from 'react-table';
|
||||
|
||||
import { Device } from '@/portainer/hostmanagement/open-amt/model';
|
||||
|
||||
export const hostname: Column<Device> = {
|
||||
Header: 'Hostname',
|
||||
accessor: (row) => row.hostname || '-',
|
||||
id: 'hostname',
|
||||
disableFilters: true,
|
||||
canHide: true,
|
||||
sortType: 'string',
|
||||
Filter: () => null,
|
||||
};
|
|
@ -0,0 +1,10 @@
|
|||
import { useMemo } from 'react';
|
||||
|
||||
import { hostname } from './hostname';
|
||||
import { status } from './status';
|
||||
import { powerState } from './power-state';
|
||||
import { actions } from './actions';
|
||||
|
||||
export function useColumns() {
|
||||
return useMemo(() => [hostname, status, powerState, actions], []);
|
||||
}
|
|
@ -0,0 +1,75 @@
|
|||
import { CellProps, Column } from 'react-table';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import { Device } from '@/portainer/hostmanagement/open-amt/model';
|
||||
|
||||
import { useRowContext } from './RowContext';
|
||||
|
||||
enum PowerState {
|
||||
Running = 'Running',
|
||||
Sleep = 'Sleep',
|
||||
Off = 'Off',
|
||||
Hibernate = 'Hibernate',
|
||||
PowerCycle = 'Power Cycle',
|
||||
}
|
||||
|
||||
enum PowerStateCode {
|
||||
On = 2,
|
||||
SleepLight = 3,
|
||||
SleepDeep = 4,
|
||||
OffHard = 6,
|
||||
Hibernate = 7,
|
||||
OffSoft = 8,
|
||||
PowerCycle = 9,
|
||||
OffHardGraceful = 13,
|
||||
}
|
||||
|
||||
export const powerState: Column<Device> = {
|
||||
Header: 'Power State',
|
||||
accessor: (row) => parsePowerState(row.powerState),
|
||||
id: 'powerState',
|
||||
disableFilters: true,
|
||||
canHide: true,
|
||||
sortType: 'string',
|
||||
Cell: PowerStateCell,
|
||||
Filter: () => null,
|
||||
};
|
||||
|
||||
export function PowerStateCell({
|
||||
row: { original: device },
|
||||
}: CellProps<Device>) {
|
||||
const { isLoading } = useRowContext();
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
className={clsx({
|
||||
'text-success': device.powerState === PowerStateCode.On,
|
||||
})}
|
||||
>
|
||||
{parsePowerState(device.powerState)}
|
||||
</span>
|
||||
<span>{isLoading && <i className="fa fa-cog fa-spin space-left" />}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function parsePowerState(value: PowerStateCode) {
|
||||
// https://app.swaggerhub.com/apis-docs/rbheopenamt/mps/1.4.0#/AMT/get_api_v1_amt_power_state__guid_
|
||||
switch (value) {
|
||||
case PowerStateCode.On:
|
||||
return PowerState.Running;
|
||||
case PowerStateCode.SleepLight:
|
||||
case PowerStateCode.SleepDeep:
|
||||
return PowerState.Sleep;
|
||||
case PowerStateCode.OffHard:
|
||||
case PowerStateCode.OffSoft:
|
||||
case PowerStateCode.OffHardGraceful:
|
||||
return PowerState.Off;
|
||||
case PowerStateCode.Hibernate:
|
||||
return PowerState.Hibernate;
|
||||
case PowerStateCode.PowerCycle:
|
||||
return PowerState.PowerCycle;
|
||||
default:
|
||||
return '-';
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
import { CellProps, Column } from 'react-table';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import { Device } from '@/portainer/hostmanagement/open-amt/model';
|
||||
|
||||
export const status: Column<Device> = {
|
||||
Header: 'MPS Status',
|
||||
id: 'status',
|
||||
disableFilters: true,
|
||||
canHide: true,
|
||||
sortType: 'string',
|
||||
Cell: StatusCell,
|
||||
Filter: () => null,
|
||||
};
|
||||
|
||||
export function StatusCell({ row: { original: device } }: CellProps<Device>) {
|
||||
return (
|
||||
<span className={clsx({ 'text-success': device.connectionStatus })}>
|
||||
{device.connectionStatus ? 'Connected' : 'Disconnected'}
|
||||
</span>
|
||||
);
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { AMTDevicesDatatable } from './AMTDevicesDatatable';
|
|
@ -0,0 +1,32 @@
|
|||
import { useEffect, useMemo } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
|
||||
import { getDevices } from '@/portainer/hostmanagement/open-amt/open-amt.service';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import PortainerError from '@/portainer/error';
|
||||
import * as notifications from '@/portainer/services/notifications';
|
||||
|
||||
export function useAMTDevices(environmentId: EnvironmentId) {
|
||||
const { isLoading, data, isError, error } = useQuery(
|
||||
['amt_devices', environmentId],
|
||||
() => getDevices(environmentId)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isError) {
|
||||
notifications.error(
|
||||
'Failure',
|
||||
error as Error,
|
||||
'Failed retrieving AMT devices'
|
||||
);
|
||||
}
|
||||
}, [isError, error]);
|
||||
|
||||
const devices = useMemo(() => data || [], [data]);
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
devices,
|
||||
error: isError ? (error as PortainerError) : undefined,
|
||||
};
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
.kvm-tip {
|
||||
padding: 8px;
|
||||
}
|
|
@ -0,0 +1,269 @@
|
|||
import { useTable, useExpanded, useSortBy, useFilters } from 'react-table';
|
||||
import { useRowSelectColumn } from '@lineup-lite/hooks';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { Environment } from '@/react/portainer/environments/types';
|
||||
import { EnvironmentGroup } from '@/react/portainer/environments/environment-groups/types';
|
||||
|
||||
import { PaginationControls } from '@@/PaginationControls';
|
||||
import {
|
||||
Table,
|
||||
TableActions,
|
||||
TableContainer,
|
||||
TableHeaderRow,
|
||||
TableRow,
|
||||
TableSettingsMenu,
|
||||
TableTitle,
|
||||
TableTitleActions,
|
||||
} from '@@/datatables';
|
||||
import { multiple } from '@@/datatables/filter-types';
|
||||
import { useTableSettings } from '@@/datatables/useTableSettings';
|
||||
import { ColumnVisibilityMenu } from '@@/datatables/ColumnVisibilityMenu';
|
||||
import { SearchBar } from '@@/datatables/SearchBar';
|
||||
import { useRowSelect } from '@@/datatables/useRowSelect';
|
||||
import { TableFooter } from '@@/datatables/TableFooter';
|
||||
import { SelectedRowsCount } from '@@/datatables/SelectedRowsCount';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
||||
import { AMTDevicesDatatable } from './AMTDevicesDatatable';
|
||||
import { EdgeDevicesDatatableActions } from './EdgeDevicesDatatableActions';
|
||||
import { EdgeDevicesDatatableSettings } from './EdgeDevicesDatatableSettings';
|
||||
import { RowProvider } from './columns/RowContext';
|
||||
import { useColumns } from './columns';
|
||||
import styles from './EdgeDevicesDatatable.module.css';
|
||||
import { EdgeDeviceTableSettings, Pagination } from './types';
|
||||
|
||||
export interface EdgeDevicesTableProps {
|
||||
storageKey: string;
|
||||
isFdoEnabled: boolean;
|
||||
isOpenAmtEnabled: boolean;
|
||||
showWaitingRoomLink: boolean;
|
||||
mpsServer: string;
|
||||
dataset: Environment[];
|
||||
groups: EnvironmentGroup[];
|
||||
setLoadingMessage(message: string): void;
|
||||
pagination: Pagination;
|
||||
onChangePagination(pagination: Partial<Pagination>): void;
|
||||
totalCount: number;
|
||||
search: string;
|
||||
onChangeSearch(search: string): void;
|
||||
}
|
||||
|
||||
export function EdgeDevicesDatatable({
|
||||
isFdoEnabled,
|
||||
isOpenAmtEnabled,
|
||||
showWaitingRoomLink,
|
||||
mpsServer,
|
||||
dataset,
|
||||
onChangeSearch,
|
||||
search,
|
||||
groups,
|
||||
setLoadingMessage,
|
||||
pagination,
|
||||
onChangePagination,
|
||||
totalCount,
|
||||
}: EdgeDevicesTableProps) {
|
||||
const { settings, setTableSettings } =
|
||||
useTableSettings<EdgeDeviceTableSettings>();
|
||||
|
||||
const columns = useColumns();
|
||||
|
||||
const {
|
||||
getTableProps,
|
||||
getTableBodyProps,
|
||||
headerGroups,
|
||||
rows,
|
||||
prepareRow,
|
||||
selectedFlatRows,
|
||||
allColumns,
|
||||
setHiddenColumns,
|
||||
} = useTable<Environment>(
|
||||
{
|
||||
defaultCanFilter: false,
|
||||
columns,
|
||||
data: dataset,
|
||||
filterTypes: { multiple },
|
||||
initialState: {
|
||||
hiddenColumns: settings.hiddenColumns,
|
||||
sortBy: [settings.sortBy],
|
||||
},
|
||||
isRowSelectable() {
|
||||
return true;
|
||||
},
|
||||
autoResetExpanded: false,
|
||||
autoResetSelectedRows: false,
|
||||
getRowId(originalRow: Environment) {
|
||||
return originalRow.Id.toString();
|
||||
},
|
||||
selectColumnWidth: 5,
|
||||
},
|
||||
useFilters,
|
||||
useSortBy,
|
||||
useExpanded,
|
||||
useRowSelect,
|
||||
useRowSelectColumn
|
||||
);
|
||||
|
||||
const columnsToHide = allColumns.filter((colInstance) => {
|
||||
const columnDef = columns.find((c) => c.id === colInstance.id);
|
||||
return columnDef?.canHide;
|
||||
});
|
||||
|
||||
const tableProps = getTableProps();
|
||||
const tbodyProps = getTableBodyProps();
|
||||
|
||||
const someDeviceHasAMTActivated = dataset.some(
|
||||
(environment) =>
|
||||
environment.AMTDeviceGUID && environment.AMTDeviceGUID !== ''
|
||||
);
|
||||
|
||||
const groupsById = _.groupBy(groups, 'Id');
|
||||
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<TableContainer>
|
||||
<TableTitle icon="box" featherIcon label="Edge Devices">
|
||||
<SearchBar value={search} onChange={handleSearchBarChange} />
|
||||
<TableActions>
|
||||
<EdgeDevicesDatatableActions
|
||||
selectedItems={selectedFlatRows.map((row) => row.original)}
|
||||
isFDOEnabled={isFdoEnabled}
|
||||
isOpenAMTEnabled={isOpenAmtEnabled}
|
||||
setLoadingMessage={setLoadingMessage}
|
||||
showWaitingRoomLink={showWaitingRoomLink}
|
||||
/>
|
||||
</TableActions>
|
||||
<TableTitleActions>
|
||||
<ColumnVisibilityMenu<Environment>
|
||||
columns={columnsToHide}
|
||||
onChange={handleChangeColumnsVisibility}
|
||||
value={settings.hiddenColumns}
|
||||
/>
|
||||
<TableSettingsMenu>
|
||||
<EdgeDevicesDatatableSettings />
|
||||
</TableSettingsMenu>
|
||||
</TableTitleActions>
|
||||
</TableTitle>
|
||||
{isOpenAmtEnabled && someDeviceHasAMTActivated && (
|
||||
<div className={styles.kvmTip}>
|
||||
<TextTip color="blue">
|
||||
For the KVM function to work you need to have the MPS server
|
||||
added to your trusted site list, browse to this{' '}
|
||||
<a
|
||||
href={`https://${mpsServer}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="space-right"
|
||||
>
|
||||
site
|
||||
</a>
|
||||
and add to your trusted site list
|
||||
</TextTip>
|
||||
</div>
|
||||
)}
|
||||
<Table
|
||||
className={tableProps.className}
|
||||
role={tableProps.role}
|
||||
style={tableProps.style}
|
||||
>
|
||||
<thead>
|
||||
{headerGroups.map((headerGroup) => {
|
||||
const { key, className, role, style } =
|
||||
headerGroup.getHeaderGroupProps();
|
||||
return (
|
||||
<TableHeaderRow<Environment>
|
||||
key={key}
|
||||
className={className}
|
||||
role={role}
|
||||
style={style}
|
||||
headers={headerGroup.headers}
|
||||
onSortChange={handleSortChange}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</thead>
|
||||
<tbody
|
||||
className={tbodyProps.className}
|
||||
role={tbodyProps.role}
|
||||
style={tbodyProps.style}
|
||||
>
|
||||
<Table.Content
|
||||
prepareRow={prepareRow}
|
||||
rows={rows}
|
||||
renderRow={(row, { key, className, role, style }) => {
|
||||
const group = groupsById[row.original.GroupId];
|
||||
|
||||
return (
|
||||
<RowProvider
|
||||
key={key}
|
||||
context={{ isOpenAmtEnabled, groupName: group[0]?.Name }}
|
||||
>
|
||||
<TableRow<Environment>
|
||||
cells={row.cells}
|
||||
key={key}
|
||||
className={className}
|
||||
role={role}
|
||||
style={style}
|
||||
/>
|
||||
{row.isExpanded && (
|
||||
<tr>
|
||||
<td />
|
||||
<td colSpan={row.cells.length - 1}>
|
||||
<AMTDevicesDatatable
|
||||
environmentId={row.original.Id}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</RowProvider>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</tbody>
|
||||
</Table>
|
||||
<TableFooter>
|
||||
<SelectedRowsCount value={selectedFlatRows.length} />
|
||||
<PaginationControls
|
||||
isPageInputVisible
|
||||
pageLimit={pagination.pageLimit}
|
||||
page={pagination.page}
|
||||
onPageChange={(p) => gotoPage(p)}
|
||||
totalCount={totalCount}
|
||||
onPageLimitChange={handlePageSizeChange}
|
||||
/>
|
||||
</TableFooter>
|
||||
</TableContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
function gotoPage(pageIndex: number) {
|
||||
onChangePagination({ page: pageIndex });
|
||||
}
|
||||
|
||||
function setPageSize(pageSize: number) {
|
||||
onChangePagination({ pageLimit: pageSize });
|
||||
}
|
||||
|
||||
function handlePageSizeChange(pageSize: number) {
|
||||
setPageSize(pageSize);
|
||||
setTableSettings((settings) => ({ ...settings, pageSize }));
|
||||
}
|
||||
|
||||
function handleChangeColumnsVisibility(hiddenColumns: string[]) {
|
||||
setHiddenColumns(hiddenColumns);
|
||||
setTableSettings((settings) => ({ ...settings, hiddenColumns }));
|
||||
}
|
||||
|
||||
function handleSearchBarChange(value: string) {
|
||||
onChangeSearch(value);
|
||||
}
|
||||
|
||||
function handleSortChange(id: string, desc: boolean) {
|
||||
setTableSettings((settings) => ({
|
||||
...settings,
|
||||
sortBy: { id, desc },
|
||||
}));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,191 @@
|
|||
import { useRouter } from '@uirouter/react';
|
||||
|
||||
import type { Environment } from '@/react/portainer/environments/types';
|
||||
import {
|
||||
confirmAsync,
|
||||
confirmDestructiveAsync,
|
||||
} from '@/portainer/services/modal.service/confirm';
|
||||
import { promptAsync } from '@/portainer/services/modal.service/prompt';
|
||||
import * as notifications from '@/portainer/services/notifications';
|
||||
import { activateDevice } from '@/portainer/hostmanagement/open-amt/open-amt.service';
|
||||
import { deleteEndpoint } from '@/react/portainer/environments/environment.service';
|
||||
|
||||
import { Button } from '@@/buttons';
|
||||
import { Link } from '@@/Link';
|
||||
|
||||
interface Props {
|
||||
selectedItems: Environment[];
|
||||
isFDOEnabled: boolean;
|
||||
isOpenAMTEnabled: boolean;
|
||||
setLoadingMessage(message: string): void;
|
||||
showWaitingRoomLink: boolean;
|
||||
}
|
||||
|
||||
enum DeployType {
|
||||
FDO = 'FDO',
|
||||
MANUAL = 'MANUAL',
|
||||
}
|
||||
|
||||
export function EdgeDevicesDatatableActions({
|
||||
selectedItems,
|
||||
isOpenAMTEnabled,
|
||||
isFDOEnabled,
|
||||
setLoadingMessage,
|
||||
showWaitingRoomLink,
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className="actionBar">
|
||||
<Button
|
||||
disabled={selectedItems.length < 1}
|
||||
color="danger"
|
||||
onClick={() => onDeleteEdgeDeviceClick()}
|
||||
icon="trash-2"
|
||||
featherIcon
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
|
||||
<Button onClick={() => onAddNewDeviceClick()} icon="plus" featherIcon>
|
||||
Add Device
|
||||
</Button>
|
||||
|
||||
{isOpenAMTEnabled && (
|
||||
<Button
|
||||
disabled={selectedItems.length !== 1}
|
||||
onClick={() => onAssociateOpenAMTClick(selectedItems)}
|
||||
icon="link"
|
||||
featherIcon
|
||||
>
|
||||
Associate with OpenAMT
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{showWaitingRoomLink && (
|
||||
<Link to="edge.devices.waiting-room">
|
||||
<Button>Waiting Room</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
async function onDeleteEdgeDeviceClick() {
|
||||
const confirmed = await confirmDestructiveAsync({
|
||||
title: 'Are you sure ?',
|
||||
message:
|
||||
'This action will remove all configurations associated to your environment(s). Continue?',
|
||||
buttons: {
|
||||
confirm: {
|
||||
label: 'Remove',
|
||||
className: 'btn-danger',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
selectedItems.map(async (environment) => {
|
||||
try {
|
||||
await deleteEndpoint(environment.Id);
|
||||
|
||||
notifications.success(
|
||||
'Environment successfully removed',
|
||||
environment.Name
|
||||
);
|
||||
} catch (err) {
|
||||
notifications.error(
|
||||
'Failure',
|
||||
err as Error,
|
||||
'Unable to remove environment'
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
await router.stateService.reload();
|
||||
}
|
||||
|
||||
async function onAddNewDeviceClick() {
|
||||
const result = isFDOEnabled
|
||||
? await promptAsync({
|
||||
title: 'How would you like to add an Edge Device?',
|
||||
inputType: 'radio',
|
||||
inputOptions: [
|
||||
{
|
||||
text: 'Provision bare-metal using Intel FDO',
|
||||
value: DeployType.FDO,
|
||||
},
|
||||
{
|
||||
text: 'Deploy agent manually',
|
||||
value: DeployType.MANUAL,
|
||||
},
|
||||
],
|
||||
buttons: {
|
||||
confirm: {
|
||||
label: 'Confirm',
|
||||
className: 'btn-primary',
|
||||
},
|
||||
},
|
||||
})
|
||||
: DeployType.MANUAL;
|
||||
|
||||
switch (result) {
|
||||
case DeployType.FDO:
|
||||
router.stateService.go('portainer.endpoints.importDevice');
|
||||
break;
|
||||
case DeployType.MANUAL:
|
||||
router.stateService.go('portainer.wizard.endpoints', {
|
||||
edgeDevice: true,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async function onAssociateOpenAMTClick(selectedItems: Environment[]) {
|
||||
const selectedEnvironment = selectedItems[0];
|
||||
|
||||
const confirmed = await confirmAsync({
|
||||
title: '',
|
||||
message: `Associate ${selectedEnvironment.Name} with OpenAMT`,
|
||||
buttons: {
|
||||
cancel: {
|
||||
label: 'Cancel',
|
||||
className: 'btn-default',
|
||||
},
|
||||
confirm: {
|
||||
label: 'Confirm',
|
||||
className: 'btn-primary',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoadingMessage(
|
||||
'Activating Active Management Technology on selected device...'
|
||||
);
|
||||
await activateDevice(selectedEnvironment.Id);
|
||||
notifications.success(
|
||||
'Successfully associated with OpenAMT',
|
||||
selectedEnvironment.Name
|
||||
);
|
||||
} catch (err) {
|
||||
notifications.error(
|
||||
'Failure',
|
||||
err as Error,
|
||||
'Unable to associate with OpenAMT'
|
||||
);
|
||||
} finally {
|
||||
setLoadingMessage('');
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,121 @@
|
|||
import { useState } from 'react';
|
||||
|
||||
import { useEnvironmentList } from '@/react/portainer/environments/queries/useEnvironmentList';
|
||||
import { EdgeTypes, Environment } from '@/react/portainer/environments/types';
|
||||
import { useDebounce } from '@/react/hooks/useDebounce';
|
||||
|
||||
import { useSearchBarState } from '@@/datatables/SearchBar';
|
||||
import {
|
||||
TableSettingsProvider,
|
||||
useTableSettings,
|
||||
} from '@@/datatables/useTableSettings';
|
||||
|
||||
import {
|
||||
EdgeDevicesDatatable,
|
||||
EdgeDevicesTableProps,
|
||||
} from './EdgeDevicesDatatable';
|
||||
import { EdgeDeviceTableSettings, Pagination } from './types';
|
||||
|
||||
export function EdgeDevicesDatatableContainer({
|
||||
...props
|
||||
}: Omit<
|
||||
EdgeDevicesTableProps,
|
||||
| 'dataset'
|
||||
| 'pagination'
|
||||
| 'onChangePagination'
|
||||
| 'totalCount'
|
||||
| 'search'
|
||||
| 'onChangeSearch'
|
||||
>) {
|
||||
const defaultSettings = {
|
||||
autoRefreshRate: 0,
|
||||
hiddenQuickActions: [],
|
||||
hiddenColumns: [],
|
||||
pageSize: 10,
|
||||
sortBy: { id: 'state', desc: false },
|
||||
};
|
||||
|
||||
const storageKey = 'edgeDevices';
|
||||
|
||||
return (
|
||||
<TableSettingsProvider defaults={defaultSettings} storageKey={storageKey}>
|
||||
<Loader storageKey={storageKey}>
|
||||
{({
|
||||
environments,
|
||||
pagination,
|
||||
totalCount,
|
||||
setPagination,
|
||||
search,
|
||||
setSearch,
|
||||
}) => (
|
||||
<EdgeDevicesDatatable
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...props}
|
||||
storageKey={storageKey}
|
||||
dataset={environments}
|
||||
pagination={pagination}
|
||||
onChangePagination={setPagination}
|
||||
totalCount={totalCount}
|
||||
search={search}
|
||||
onChangeSearch={setSearch}
|
||||
/>
|
||||
)}
|
||||
</Loader>
|
||||
</TableSettingsProvider>
|
||||
);
|
||||
}
|
||||
|
||||
interface LoaderProps {
|
||||
storageKey: string;
|
||||
children: (options: {
|
||||
environments: Environment[];
|
||||
totalCount: number;
|
||||
pagination: Pagination;
|
||||
setPagination(value: Partial<Pagination>): void;
|
||||
search: string;
|
||||
setSearch: (value: string) => void;
|
||||
}) => React.ReactNode;
|
||||
}
|
||||
|
||||
function Loader({ children, storageKey }: LoaderProps) {
|
||||
const { settings } = useTableSettings<EdgeDeviceTableSettings>();
|
||||
const [pagination, setPagination] = useState({
|
||||
pageLimit: settings.pageSize,
|
||||
page: 1,
|
||||
});
|
||||
|
||||
const [search, setSearch] = useSearchBarState(storageKey);
|
||||
const debouncedSearchValue = useDebounce(search);
|
||||
|
||||
const { environments, isLoading, totalCount } = useEnvironmentList(
|
||||
{
|
||||
edgeDevice: true,
|
||||
search: debouncedSearchValue,
|
||||
types: EdgeTypes,
|
||||
excludeSnapshots: true,
|
||||
...pagination,
|
||||
},
|
||||
settings.autoRefreshRate * 1000
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{children({
|
||||
environments,
|
||||
totalCount,
|
||||
pagination,
|
||||
setPagination: handleSetPagination,
|
||||
search,
|
||||
setSearch,
|
||||
})}
|
||||
</>
|
||||
);
|
||||
|
||||
function handleSetPagination(value: Partial<Pagination>) {
|
||||
setPagination((prev) => ({ ...prev, ...value }));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
import { TableSettingsMenuAutoRefresh } from '@@/datatables/TableSettingsMenuAutoRefresh';
|
||||
import { useTableSettings } from '@@/datatables/useTableSettings';
|
||||
|
||||
import { EdgeDeviceTableSettings } from './types';
|
||||
|
||||
export function EdgeDevicesDatatableSettings() {
|
||||
const { settings, setTableSettings } =
|
||||
useTableSettings<EdgeDeviceTableSettings>();
|
||||
|
||||
return (
|
||||
<TableSettingsMenuAutoRefresh
|
||||
value={settings.autoRefreshRate}
|
||||
onChange={handleRefreshRateChange}
|
||||
/>
|
||||
);
|
||||
|
||||
function handleRefreshRateChange(autoRefreshRate: number) {
|
||||
setTableSettings({ autoRefreshRate });
|
||||
}
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
import { createRowContext } from '@@/datatables/RowContext';
|
||||
|
||||
interface RowContextState {
|
||||
isOpenAmtEnabled: boolean;
|
||||
groupName?: string;
|
||||
}
|
||||
|
||||
const { RowProvider, useRowContext } = createRowContext<RowContextState>();
|
||||
|
||||
export { RowProvider, useRowContext };
|
|
@ -0,0 +1,79 @@
|
|||
import { CellProps, Column } from 'react-table';
|
||||
import { MenuItem, MenuLink } from '@reach/menu-button';
|
||||
import { useRouter, useSref } from '@uirouter/react';
|
||||
|
||||
import { Environment } from '@/react/portainer/environments/types';
|
||||
import { snapshotEndpoint } from '@/react/portainer/environments/environment.service';
|
||||
import * as notifications from '@/portainer/services/notifications';
|
||||
import { getDashboardRoute } from '@/react/portainer/environments/utils';
|
||||
|
||||
import { ActionsMenu } from '@@/datatables/ActionsMenu';
|
||||
|
||||
export const actions: Column<Environment> = {
|
||||
Header: 'Actions',
|
||||
accessor: () => 'actions',
|
||||
id: 'actions',
|
||||
disableFilters: true,
|
||||
canHide: true,
|
||||
disableResizing: true,
|
||||
width: '5px',
|
||||
sortType: 'string',
|
||||
Filter: () => null,
|
||||
Cell: ActionsCell,
|
||||
};
|
||||
|
||||
export function ActionsCell({
|
||||
row: { original: environment },
|
||||
}: CellProps<Environment>) {
|
||||
const router = useRouter();
|
||||
|
||||
const environmentRoute = getDashboardRoute(environment);
|
||||
const browseLinkProps = useSref(environmentRoute, {
|
||||
id: environment.Id,
|
||||
endpointId: environment.Id,
|
||||
});
|
||||
|
||||
const snapshotLinkProps = useSref('edge.browse.dashboard', {
|
||||
environmentId: environment.Id,
|
||||
});
|
||||
|
||||
const showRefreshSnapshot = false; // remove and show MenuItem when feature is available
|
||||
|
||||
return (
|
||||
<ActionsMenu>
|
||||
{environment.Edge.AsyncMode ? (
|
||||
<MenuLink
|
||||
className="!text-inherit hover:!no-underline"
|
||||
href={snapshotLinkProps.href}
|
||||
onClick={snapshotLinkProps.onClick}
|
||||
>
|
||||
Browse Snapshot
|
||||
</MenuLink>
|
||||
) : (
|
||||
<MenuLink href={browseLinkProps.href} onClick={browseLinkProps.onClick}>
|
||||
Browse
|
||||
</MenuLink>
|
||||
)}
|
||||
{showRefreshSnapshot && (
|
||||
<MenuItem hidden onSelect={() => handleRefreshSnapshotClick()}>
|
||||
Refresh Snapshot
|
||||
</MenuItem>
|
||||
)}
|
||||
</ActionsMenu>
|
||||
);
|
||||
|
||||
async function handleRefreshSnapshotClick() {
|
||||
try {
|
||||
await snapshotEndpoint(environment.Id);
|
||||
notifications.success('Success', 'Environment updated');
|
||||
} catch (err) {
|
||||
notifications.error(
|
||||
'Failure',
|
||||
err as Error,
|
||||
'An error occurred during environment snapshot'
|
||||
);
|
||||
} finally {
|
||||
await router.stateService.reload();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
import { Column } from 'react-table';
|
||||
|
||||
import { Environment } from '@/react/portainer/environments/types';
|
||||
|
||||
import { DefaultFilter } from '@@/datatables/Filter';
|
||||
|
||||
import { useRowContext } from './RowContext';
|
||||
|
||||
export const group: Column<Environment> = {
|
||||
Header: 'Group',
|
||||
accessor: (row) => row.GroupId,
|
||||
Cell: GroupCell,
|
||||
id: 'groupName',
|
||||
Filter: DefaultFilter,
|
||||
canHide: true,
|
||||
};
|
||||
|
||||
function GroupCell() {
|
||||
const { groupName } = useRowContext();
|
||||
|
||||
return groupName;
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
import { CellProps, Column } from 'react-table';
|
||||
|
||||
import { Environment } from '@/react/portainer/environments/types';
|
||||
|
||||
import { EdgeIndicator } from '@@/EdgeIndicator';
|
||||
|
||||
export const heartbeat: Column<Environment> = {
|
||||
Header: 'Heartbeat',
|
||||
accessor: 'Status',
|
||||
id: 'status',
|
||||
Cell: StatusCell,
|
||||
disableFilters: true,
|
||||
canHide: true,
|
||||
};
|
||||
|
||||
export function StatusCell({
|
||||
row: { original: environment },
|
||||
}: CellProps<Environment>) {
|
||||
return <EdgeIndicator environment={environment} />;
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
import { useMemo } from 'react';
|
||||
|
||||
import { name } from './name';
|
||||
import { heartbeat } from './heartbeat';
|
||||
import { group } from './group';
|
||||
import { actions } from './actions';
|
||||
|
||||
export function useColumns() {
|
||||
return useMemo(() => [name, heartbeat, group, actions], []);
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
import { CellProps, Column } from 'react-table';
|
||||
|
||||
import { Environment } from '@/react/portainer/environments/types';
|
||||
|
||||
import { Link } from '@@/Link';
|
||||
import { ExpandingCell } from '@@/datatables/ExpandingCell';
|
||||
|
||||
import { useRowContext } from './RowContext';
|
||||
|
||||
export const name: Column<Environment> = {
|
||||
Header: 'Name',
|
||||
accessor: (row) => row.Name,
|
||||
id: 'name',
|
||||
Cell: NameCell,
|
||||
disableFilters: true,
|
||||
Filter: () => null,
|
||||
canHide: false,
|
||||
sortType: 'string',
|
||||
};
|
||||
|
||||
export function NameCell({ value: name, row }: CellProps<Environment>) {
|
||||
const { isOpenAmtEnabled } = useRowContext();
|
||||
const showExpandedRow = !!(
|
||||
isOpenAmtEnabled &&
|
||||
row.original.AMTDeviceGUID &&
|
||||
row.original.AMTDeviceGUID.length > 0
|
||||
);
|
||||
return (
|
||||
<ExpandingCell row={row} showExpandArrow={showExpandedRow}>
|
||||
<Link
|
||||
to="portainer.endpoints.endpoint"
|
||||
params={{ id: row.original.Id }}
|
||||
title={name}
|
||||
>
|
||||
{name}
|
||||
</Link>
|
||||
</ExpandingCell>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
import {
|
||||
PaginationTableSettings,
|
||||
RefreshableTableSettings,
|
||||
SettableColumnsTableSettings,
|
||||
SortableTableSettings,
|
||||
} from '@@/datatables/types-old';
|
||||
|
||||
export interface Pagination {
|
||||
pageLimit: number;
|
||||
page: number;
|
||||
}
|
||||
|
||||
export interface EdgeDeviceTableSettings
|
||||
extends SortableTableSettings,
|
||||
PaginationTableSettings,
|
||||
SettableColumnsTableSettings,
|
||||
RefreshableTableSettings {}
|
56
app/react/edge/edge-devices/ListView/ListView.tsx
Normal file
56
app/react/edge/edge-devices/ListView/ListView.tsx
Normal file
|
@ -0,0 +1,56 @@
|
|||
import { useState } from 'react';
|
||||
|
||||
import { useSettings } from '@/react/portainer/settings/queries';
|
||||
import { useGroups } from '@/react/portainer/environments/environment-groups/queries';
|
||||
|
||||
import { PageHeader } from '@@/PageHeader';
|
||||
import { ViewLoading } from '@@/ViewLoading';
|
||||
|
||||
import { EdgeDevicesDatatableContainer } from './EdgeDevicesDatatable/EdgeDevicesDatatableContainer';
|
||||
|
||||
export function ListView() {
|
||||
const [loadingMessage, setLoadingMessage] = useState('');
|
||||
|
||||
const settingsQuery = useSettings();
|
||||
const groupsQuery = useGroups();
|
||||
|
||||
if (!settingsQuery.data || !groupsQuery.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const settings = settingsQuery.data;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Edge Devices"
|
||||
reload
|
||||
breadcrumbs={[{ label: 'EdgeDevices' }]}
|
||||
/>
|
||||
|
||||
{loadingMessage ? (
|
||||
<ViewLoading message={loadingMessage} />
|
||||
) : (
|
||||
<EdgeDevicesDatatableContainer
|
||||
setLoadingMessage={setLoadingMessage}
|
||||
isFdoEnabled={
|
||||
settings.EnableEdgeComputeFeatures &&
|
||||
settings.fdoConfiguration.enabled
|
||||
}
|
||||
showWaitingRoomLink={
|
||||
process.env.PORTAINER_EDITION === 'BE' &&
|
||||
settings.EnableEdgeComputeFeatures &&
|
||||
!settings.TrustOnFirstConnect
|
||||
}
|
||||
isOpenAmtEnabled={
|
||||
settings.EnableEdgeComputeFeatures &&
|
||||
settings.openAMTConfiguration.enabled
|
||||
}
|
||||
mpsServer={settings.openAMTConfiguration.mpsServer}
|
||||
groups={groupsQuery.data}
|
||||
storageKey="edgeDevices"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
1
app/react/edge/edge-devices/ListView/index.ts
Normal file
1
app/react/edge/edge-devices/ListView/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { ListView } from './ListView';
|
|
@ -0,0 +1,215 @@
|
|||
import {
|
||||
Column,
|
||||
useGlobalFilter,
|
||||
usePagination,
|
||||
useRowSelect,
|
||||
useSortBy,
|
||||
useTable,
|
||||
} from 'react-table';
|
||||
import { useRowSelectColumn } from '@lineup-lite/hooks';
|
||||
|
||||
import { Environment } from '@/react/portainer/environments/types';
|
||||
import { notifySuccess } from '@/portainer/services/notifications';
|
||||
|
||||
import { Button } from '@@/buttons';
|
||||
import { Table } from '@@/datatables';
|
||||
import { SearchBar, useSearchBarState } from '@@/datatables/SearchBar';
|
||||
import { SelectedRowsCount } from '@@/datatables/SelectedRowsCount';
|
||||
import { PaginationControls } from '@@/PaginationControls';
|
||||
import { useTableSettings } from '@@/datatables/useTableSettings';
|
||||
|
||||
import { useAssociateDeviceMutation } from '../queries';
|
||||
|
||||
import { TableSettings } from './types';
|
||||
|
||||
const columns: readonly Column<Environment>[] = [
|
||||
{
|
||||
Header: 'Name',
|
||||
accessor: (row) => row.Name,
|
||||
id: 'name',
|
||||
disableFilters: true,
|
||||
Filter: () => null,
|
||||
canHide: false,
|
||||
sortType: 'string',
|
||||
},
|
||||
{
|
||||
Header: 'Edge ID',
|
||||
accessor: (row) => row.EdgeID,
|
||||
id: 'edge-id',
|
||||
disableFilters: true,
|
||||
Filter: () => null,
|
||||
canHide: false,
|
||||
sortType: 'string',
|
||||
},
|
||||
] as const;
|
||||
|
||||
interface Props {
|
||||
devices: Environment[];
|
||||
isLoading: boolean;
|
||||
totalCount: number;
|
||||
storageKey: string;
|
||||
}
|
||||
|
||||
export function DataTable({
|
||||
devices,
|
||||
storageKey,
|
||||
isLoading,
|
||||
totalCount,
|
||||
}: Props) {
|
||||
const associateMutation = useAssociateDeviceMutation();
|
||||
|
||||
const [searchBarValue, setSearchBarValue] = useSearchBarState(storageKey);
|
||||
const { settings, setTableSettings } = useTableSettings<TableSettings>();
|
||||
|
||||
const {
|
||||
getTableProps,
|
||||
getTableBodyProps,
|
||||
headerGroups,
|
||||
page,
|
||||
prepareRow,
|
||||
selectedFlatRows,
|
||||
|
||||
gotoPage,
|
||||
setPageSize,
|
||||
|
||||
setGlobalFilter,
|
||||
state: { pageIndex, pageSize },
|
||||
} = useTable<Environment>(
|
||||
{
|
||||
defaultCanFilter: false,
|
||||
columns,
|
||||
data: devices,
|
||||
|
||||
initialState: {
|
||||
pageSize: settings.pageSize || 10,
|
||||
sortBy: [settings.sortBy],
|
||||
globalFilter: searchBarValue,
|
||||
},
|
||||
isRowSelectable() {
|
||||
return true;
|
||||
},
|
||||
autoResetSelectedRows: false,
|
||||
getRowId(originalRow: Environment) {
|
||||
return originalRow.Id.toString();
|
||||
},
|
||||
selectColumnWidth: 5,
|
||||
},
|
||||
useGlobalFilter,
|
||||
useSortBy,
|
||||
|
||||
usePagination,
|
||||
useRowSelect,
|
||||
useRowSelectColumn
|
||||
);
|
||||
|
||||
const tableProps = getTableProps();
|
||||
const tbodyProps = getTableBodyProps();
|
||||
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<Table.Container>
|
||||
<Table.Title label="Edge Devices Waiting Room" icon="">
|
||||
<SearchBar
|
||||
onChange={handleSearchBarChange}
|
||||
value={searchBarValue}
|
||||
/>
|
||||
<Table.Actions>
|
||||
<Button
|
||||
onClick={() =>
|
||||
handleAssociateDevice(selectedFlatRows.map((r) => r.original))
|
||||
}
|
||||
disabled={selectedFlatRows.length === 0}
|
||||
>
|
||||
Associate Device
|
||||
</Button>
|
||||
</Table.Actions>
|
||||
</Table.Title>
|
||||
|
||||
<Table
|
||||
className={tableProps.className}
|
||||
role={tableProps.role}
|
||||
style={tableProps.style}
|
||||
>
|
||||
<thead>
|
||||
{headerGroups.map((headerGroup) => {
|
||||
const { key, className, role, style } =
|
||||
headerGroup.getHeaderGroupProps();
|
||||
|
||||
return (
|
||||
<Table.HeaderRow<Environment>
|
||||
key={key}
|
||||
className={className}
|
||||
role={role}
|
||||
style={style}
|
||||
headers={headerGroup.headers}
|
||||
onSortChange={handleSortChange}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</thead>
|
||||
|
||||
<tbody
|
||||
className={tbodyProps.className}
|
||||
role={tbodyProps.role}
|
||||
style={tbodyProps.style}
|
||||
>
|
||||
<Table.Content
|
||||
emptyContent="No Edge Devices found"
|
||||
prepareRow={prepareRow}
|
||||
rows={page}
|
||||
isLoading={isLoading}
|
||||
renderRow={(row, { key, className, role, style }) => (
|
||||
<Table.Row
|
||||
cells={row.cells}
|
||||
key={key}
|
||||
className={className}
|
||||
role={role}
|
||||
style={style}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</tbody>
|
||||
</Table>
|
||||
|
||||
<Table.Footer>
|
||||
<SelectedRowsCount value={selectedFlatRows.length} />
|
||||
<PaginationControls
|
||||
showAll
|
||||
pageLimit={pageSize}
|
||||
page={pageIndex + 1}
|
||||
onPageChange={(p) => gotoPage(p - 1)}
|
||||
totalCount={totalCount}
|
||||
onPageLimitChange={handlePageLimitChange}
|
||||
/>
|
||||
</Table.Footer>
|
||||
</Table.Container>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
function handleSortChange(colId: string, desc: boolean) {
|
||||
setTableSettings({ sortBy: { id: colId, desc } });
|
||||
}
|
||||
|
||||
function handlePageLimitChange(pageSize: number) {
|
||||
setPageSize(pageSize);
|
||||
setTableSettings({ pageSize });
|
||||
}
|
||||
|
||||
function handleSearchBarChange(value: string) {
|
||||
setGlobalFilter(value);
|
||||
setSearchBarValue(value);
|
||||
}
|
||||
|
||||
function handleAssociateDevice(devices: Environment[]) {
|
||||
associateMutation.mutate(
|
||||
devices.map((d) => d.Id),
|
||||
{
|
||||
onSuccess() {
|
||||
notifySuccess('Success', 'Edge devices associated successfully');
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
import {
|
||||
PaginationTableSettings,
|
||||
SortableTableSettings,
|
||||
} from '@@/datatables/types-old';
|
||||
|
||||
export interface TableSettings
|
||||
extends SortableTableSettings,
|
||||
PaginationTableSettings {}
|
|
@ -0,0 +1,60 @@
|
|||
import { useRouter } from '@uirouter/react';
|
||||
|
||||
import { useEnvironmentList } from '@/react/portainer/environments/queries/useEnvironmentList';
|
||||
import { EdgeTypes } from '@/react/portainer/environments/types';
|
||||
|
||||
import { InformationPanel } from '@@/InformationPanel';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
import { TableSettingsProvider } from '@@/datatables/useTableSettings';
|
||||
import { PageHeader } from '@@/PageHeader';
|
||||
|
||||
import { DataTable } from './Datatable/Datatable';
|
||||
import { TableSettings } from './Datatable/types';
|
||||
|
||||
export function WaitingRoomView() {
|
||||
const storageKey = 'edge-devices-waiting-room';
|
||||
const router = useRouter();
|
||||
const { environments, isLoading, totalCount } = useEnvironmentList({
|
||||
edgeDevice: true,
|
||||
edgeDeviceUntrusted: true,
|
||||
excludeSnapshots: true,
|
||||
types: EdgeTypes,
|
||||
});
|
||||
|
||||
if (process.env.PORTAINER_EDITION !== 'BE') {
|
||||
router.stateService.go('edge.devices');
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Waiting Room"
|
||||
breadcrumbs={[
|
||||
{ label: 'Edge Devices', link: 'edge.devices' },
|
||||
{ label: 'Waiting Room' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<InformationPanel>
|
||||
<TextTip color="blue">
|
||||
Only environments generated from the AEEC script will appear here,
|
||||
manually added environments and edge devices will bypass the waiting
|
||||
room.
|
||||
</TextTip>
|
||||
</InformationPanel>
|
||||
|
||||
<TableSettingsProvider<TableSettings>
|
||||
defaults={{ pageSize: 10, sortBy: { desc: false, id: 'name' } }}
|
||||
storageKey={storageKey}
|
||||
>
|
||||
<DataTable
|
||||
devices={environments}
|
||||
totalCount={totalCount}
|
||||
isLoading={isLoading}
|
||||
storageKey={storageKey}
|
||||
/>
|
||||
</TableSettingsProvider>
|
||||
</>
|
||||
);
|
||||
}
|
1
app/react/edge/edge-devices/WaitingRoomView/index.ts
Normal file
1
app/react/edge/edge-devices/WaitingRoomView/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { WaitingRoomView } from './WaitingRoomView';
|
33
app/react/edge/edge-devices/WaitingRoomView/queries.ts
Normal file
33
app/react/edge/edge-devices/WaitingRoomView/queries.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
import { useMutation, useQueryClient } from 'react-query';
|
||||
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { promiseSequence } from '@/portainer/helpers/promise-utils';
|
||||
|
||||
export function useAssociateDeviceMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation(
|
||||
(ids: EnvironmentId[]) =>
|
||||
promiseSequence(ids.map((id) => () => associateDevice(id))),
|
||||
{
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['environments']);
|
||||
},
|
||||
meta: {
|
||||
error: {
|
||||
title: 'Failure',
|
||||
message: 'Failed to associate devices',
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function associateDevice(environmentId: EnvironmentId) {
|
||||
try {
|
||||
await axios.post(`/endpoints/${environmentId}/edge/trust`);
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error, 'Failed to associate device');
|
||||
}
|
||||
}
|
|
@ -1,8 +1,8 @@
|
|||
import _ from 'lodash';
|
||||
|
||||
import { Select } from '@@/form-components/ReactSelect';
|
||||
import { EdgeGroup } from '@/react/edge/edge-groups/types';
|
||||
|
||||
import { EdgeGroup } from '../edge-groups/types';
|
||||
import { Select } from '@@/form-components/ReactSelect';
|
||||
|
||||
type SingleValue = EdgeGroup['Id'];
|
||||
|
Loading…
Add table
Add a link
Reference in a new issue