mirror of
https://github.com/portainer/portainer.git
synced 2025-08-02 20:35:25 +02:00
feat(home): move edge device to view [EE-4559] (#8189)
Co-authored-by: matias.spinarolli <matias.spinarolli@portainer.io>
This commit is contained in:
parent
b4a6f6911c
commit
7fe0712b61
72 changed files with 988 additions and 1593 deletions
|
@ -1,33 +0,0 @@
|
|||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import PortainerError from '@/portainer/error';
|
||||
|
||||
import { NestedDatatable } from '@@/datatables/NestedDatatable';
|
||||
|
||||
import { useAMTDevices } from './useAMTDevices';
|
||||
import { columns } from './columns';
|
||||
|
||||
export interface AMTDevicesTableProps {
|
||||
environmentId: EnvironmentId;
|
||||
}
|
||||
|
||||
export function AMTDevicesDatatable({ environmentId }: AMTDevicesTableProps) {
|
||||
const devicesQuery = useAMTDevices(environmentId);
|
||||
|
||||
return (
|
||||
<NestedDatatable
|
||||
columns={columns}
|
||||
dataset={devicesQuery.devices}
|
||||
isLoading={devicesQuery.isLoading}
|
||||
emptyContentLabel={userMessage(devicesQuery.error)}
|
||||
defaultSortBy="hostname"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function userMessage(error?: PortainerError) {
|
||||
if (error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return 'No devices found';
|
||||
}
|
|
@ -1,38 +0,0 @@
|
|||
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>;
|
||||
}
|
|
@ -1,115 +0,0 @@
|
|||
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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
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,
|
||||
};
|
|
@ -1,6 +0,0 @@
|
|||
import { hostname } from './hostname';
|
||||
import { status } from './status';
|
||||
import { powerState } from './power-state';
|
||||
import { actions } from './actions';
|
||||
|
||||
export const columns = [hostname, status, powerState, actions];
|
|
@ -1,82 +0,0 @@
|
|||
import { CellProps, Column } from 'react-table';
|
||||
import clsx from 'clsx';
|
||||
import { Settings } from 'lucide-react';
|
||||
|
||||
import { Device } from '@/portainer/hostmanagement/open-amt/model';
|
||||
|
||||
import { Icon } from '@@/Icon';
|
||||
|
||||
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 && (
|
||||
<Icon icon={Settings} className="animate-spin-slow !ml-1" />
|
||||
)}
|
||||
</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 '-';
|
||||
}
|
||||
}
|
|
@ -1,22 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
export { AMTDevicesDatatable } from './AMTDevicesDatatable';
|
|
@ -1,32 +0,0 @@
|
|||
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,
|
||||
};
|
||||
}
|
|
@ -1,3 +0,0 @@
|
|||
.kvm-tip {
|
||||
padding: 8px;
|
||||
}
|
|
@ -1,150 +0,0 @@
|
|||
import _ from 'lodash';
|
||||
import { useStore } from 'zustand';
|
||||
import { Box } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { EdgeTypes, Environment } from '@/react/portainer/environments/types';
|
||||
import { EnvironmentGroup } from '@/react/portainer/environments/environment-groups/types';
|
||||
import { useEnvironmentList } from '@/react/portainer/environments/queries/useEnvironmentList';
|
||||
|
||||
import { ExpandableDatatable } from '@@/datatables/ExpandableDatatable';
|
||||
import { TableSettingsMenu } from '@@/datatables';
|
||||
import { ColumnVisibilityMenu } from '@@/datatables/ColumnVisibilityMenu';
|
||||
import { InformationPanel } from '@@/InformationPanel';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
import { useSearchBarState } from '@@/datatables/SearchBar';
|
||||
|
||||
import { AMTDevicesDatatable } from './AMTDevicesDatatable';
|
||||
import { columns } from './columns';
|
||||
import { EdgeDevicesDatatableActions } from './EdgeDevicesDatatableActions';
|
||||
import { EdgeDevicesDatatableSettings } from './EdgeDevicesDatatableSettings';
|
||||
import { RowProvider } from './columns/RowContext';
|
||||
import styles from './EdgeDevicesDatatable.module.css';
|
||||
import { createStore } from './datatable-store';
|
||||
|
||||
export interface EdgeDevicesTableProps {
|
||||
storageKey: string;
|
||||
isFdoEnabled: boolean;
|
||||
isOpenAmtEnabled: boolean;
|
||||
showWaitingRoomLink: boolean;
|
||||
mpsServer: string;
|
||||
groups: EnvironmentGroup[];
|
||||
}
|
||||
const storageKey = 'edgeDevices';
|
||||
|
||||
const settingsStore = createStore(storageKey);
|
||||
|
||||
export function EdgeDevicesDatatable({
|
||||
isFdoEnabled,
|
||||
isOpenAmtEnabled,
|
||||
showWaitingRoomLink,
|
||||
mpsServer,
|
||||
groups,
|
||||
}: EdgeDevicesTableProps) {
|
||||
const settings = useStore(settingsStore);
|
||||
const [page, setPage] = useState(0);
|
||||
|
||||
const [search, setSearch] = useSearchBarState(storageKey);
|
||||
|
||||
const hidableColumns = _.compact(
|
||||
columns.filter((col) => col.canHide).map((col) => col.id)
|
||||
);
|
||||
|
||||
const { environments, isLoading, totalCount } = useEnvironmentList(
|
||||
{
|
||||
edgeDevice: true,
|
||||
search,
|
||||
types: EdgeTypes,
|
||||
excludeSnapshots: true,
|
||||
page: page + 1,
|
||||
pageLimit: settings.pageSize,
|
||||
sort: settings.sortBy.id,
|
||||
order: settings.sortBy.desc ? 'desc' : 'asc',
|
||||
},
|
||||
settings.autoRefreshRate * 1000
|
||||
);
|
||||
|
||||
const someDeviceHasAMTActivated = environments.some(
|
||||
(environment) =>
|
||||
environment.AMTDeviceGUID && environment.AMTDeviceGUID !== ''
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isOpenAmtEnabled && someDeviceHasAMTActivated && (
|
||||
<InformationPanel>
|
||||
<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="mx-px"
|
||||
>
|
||||
site
|
||||
</a>
|
||||
and add to your trusted site list
|
||||
</TextTip>
|
||||
</div>
|
||||
</InformationPanel>
|
||||
)}
|
||||
<RowProvider context={{ isOpenAmtEnabled, groups }}>
|
||||
<ExpandableDatatable
|
||||
dataset={environments}
|
||||
columns={columns}
|
||||
isLoading={isLoading}
|
||||
totalCount={totalCount}
|
||||
title="Edge Devices"
|
||||
titleIcon={Box}
|
||||
initialPageSize={settings.pageSize}
|
||||
onPageSizeChange={settings.setPageSize}
|
||||
initialSortBy={settings.sortBy}
|
||||
onSortByChange={settings.setSortBy}
|
||||
searchValue={search}
|
||||
onSearchChange={setSearch}
|
||||
renderSubRow={(row) => (
|
||||
<tr>
|
||||
<td />
|
||||
<td colSpan={row.cells.length - 1}>
|
||||
<AMTDevicesDatatable environmentId={row.original.Id} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
initialTableState={{ pageIndex: page }}
|
||||
pageCount={Math.ceil(totalCount / settings.pageSize)}
|
||||
renderTableActions={(selectedRows) => (
|
||||
<EdgeDevicesDatatableActions
|
||||
selectedItems={selectedRows}
|
||||
isFDOEnabled={isFdoEnabled}
|
||||
isOpenAMTEnabled={isOpenAmtEnabled}
|
||||
showWaitingRoomLink={showWaitingRoomLink}
|
||||
/>
|
||||
)}
|
||||
renderTableSettings={(tableInstance) => {
|
||||
const columnsToHide = tableInstance.allColumns.filter(
|
||||
(colInstance) => hidableColumns?.includes(colInstance.id)
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<ColumnVisibilityMenu<Environment>
|
||||
columns={columnsToHide}
|
||||
onChange={(hiddenColumns) => {
|
||||
settings.setHiddenColumns(hiddenColumns);
|
||||
tableInstance.setHiddenColumns(hiddenColumns);
|
||||
}}
|
||||
value={settings.hiddenColumns}
|
||||
/>
|
||||
<TableSettingsMenu>
|
||||
<EdgeDevicesDatatableSettings settings={settings} />
|
||||
</TableSettingsMenu>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</RowProvider>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,179 +0,0 @@
|
|||
import { useRouter } from '@uirouter/react';
|
||||
import { Plus, Trash2, Link as LinkIcon } from 'lucide-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 { deleteEndpoint } from '@/react/portainer/environments/environment.service';
|
||||
import { useActivateDeviceMutation } from '@/portainer/hostmanagement/open-amt/queries';
|
||||
|
||||
import { Button } from '@@/buttons';
|
||||
import { Link } from '@@/Link';
|
||||
|
||||
interface Props {
|
||||
selectedItems: Environment[];
|
||||
isFDOEnabled: boolean;
|
||||
isOpenAMTEnabled: boolean;
|
||||
showWaitingRoomLink: boolean;
|
||||
}
|
||||
|
||||
enum DeployType {
|
||||
FDO = 'FDO',
|
||||
MANUAL = 'MANUAL',
|
||||
}
|
||||
|
||||
export function EdgeDevicesDatatableActions({
|
||||
selectedItems,
|
||||
isOpenAMTEnabled,
|
||||
isFDOEnabled,
|
||||
showWaitingRoomLink,
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
const activateDeviceMutation = useActivateDeviceMutation();
|
||||
|
||||
return (
|
||||
<div className="actionBar">
|
||||
<Button
|
||||
disabled={selectedItems.length < 1}
|
||||
color="danger"
|
||||
onClick={() => onDeleteEdgeDeviceClick()}
|
||||
icon={Trash2}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
|
||||
<Button onClick={() => onAddNewDeviceClick()} icon={Plus}>
|
||||
Add Device
|
||||
</Button>
|
||||
|
||||
{isOpenAMTEnabled && (
|
||||
<Button
|
||||
disabled={selectedItems.length !== 1}
|
||||
onClick={() => onAssociateOpenAMTClick(selectedItems)}
|
||||
icon={LinkIcon}
|
||||
>
|
||||
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;
|
||||
}
|
||||
|
||||
activateDeviceMutation.mutate(selectedEnvironment.Id, {
|
||||
onSuccess() {
|
||||
notifications.notifySuccess(
|
||||
'Successfully associated with OpenAMT',
|
||||
selectedEnvironment.Name
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
|
@ -1,19 +0,0 @@
|
|||
import { TableSettingsMenuAutoRefresh } from '@@/datatables/TableSettingsMenuAutoRefresh';
|
||||
import { RefreshableTableSettings } from '@@/datatables/types';
|
||||
|
||||
interface Props {
|
||||
settings: RefreshableTableSettings;
|
||||
}
|
||||
|
||||
export function EdgeDevicesDatatableSettings({ settings }: Props) {
|
||||
return (
|
||||
<TableSettingsMenuAutoRefresh
|
||||
value={settings.autoRefreshRate}
|
||||
onChange={handleRefreshRateChange}
|
||||
/>
|
||||
);
|
||||
|
||||
function handleRefreshRateChange(autoRefreshRate: number) {
|
||||
settings.setAutoRefreshRate(autoRefreshRate);
|
||||
}
|
||||
}
|
|
@ -1,12 +0,0 @@
|
|||
import { EnvironmentGroup } from '@/react/portainer/environments/environment-groups/types';
|
||||
|
||||
import { createRowContext } from '@@/datatables/RowContext';
|
||||
|
||||
interface RowContextState {
|
||||
isOpenAmtEnabled: boolean;
|
||||
groups: EnvironmentGroup[];
|
||||
}
|
||||
|
||||
const { RowProvider, useRowContext } = createRowContext<RowContextState>();
|
||||
|
||||
export { RowProvider, useRowContext };
|
|
@ -1,79 +0,0 @@
|
|||
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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,24 +0,0 @@
|
|||
import { Column } from 'react-table';
|
||||
|
||||
import { Environment } from '@/react/portainer/environments/types';
|
||||
import { EnvironmentGroupId } from '@/react/portainer/environments/environment-groups/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({ value }: { value: EnvironmentGroupId }) {
|
||||
const { groups } = useRowContext();
|
||||
const group = groups.find((g) => g.Id === value);
|
||||
|
||||
return group?.Name || '';
|
||||
}
|
|
@ -1,53 +0,0 @@
|
|||
import { CellProps, Column } from 'react-table';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import { Environment } from '@/react/portainer/environments/types';
|
||||
import { useHasHeartbeat } from '@/react/edge/hooks/useHasHeartbeat';
|
||||
|
||||
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} />;
|
||||
}
|
||||
|
||||
function EdgeIndicator({ environment }: { environment: Environment }) {
|
||||
const isValid = useHasHeartbeat(environment);
|
||||
|
||||
if (isValid === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const associated = !!environment.EdgeID;
|
||||
if (!associated) {
|
||||
return (
|
||||
<span role="status" aria-label="edge-status">
|
||||
<span className="label label-default" aria-label="unassociated">
|
||||
<s>associated</s>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span role="status" aria-label="edge-status">
|
||||
<span
|
||||
className={clsx('label', {
|
||||
'label-danger': !isValid,
|
||||
'label-success': isValid,
|
||||
})}
|
||||
aria-label="edge-heartbeat"
|
||||
>
|
||||
heartbeat
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
import { name } from './name';
|
||||
import { heartbeat } from './heartbeat';
|
||||
import { group } from './group';
|
||||
import { actions } from './actions';
|
||||
|
||||
export const columns = [name, heartbeat, group, actions];
|
|
@ -1,39 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
|
@ -1,14 +0,0 @@
|
|||
import {
|
||||
refreshableSettings,
|
||||
hiddenColumnsSettings,
|
||||
createPersistedStore,
|
||||
} from '@@/datatables/types';
|
||||
|
||||
import { TableSettings } from './types';
|
||||
|
||||
export function createStore(storageKey: string) {
|
||||
return createPersistedStore<TableSettings>(storageKey, 'Name', (set) => ({
|
||||
...hiddenColumnsSettings(set),
|
||||
...refreshableSettings(set),
|
||||
}));
|
||||
}
|
|
@ -1,10 +0,0 @@
|
|||
import {
|
||||
BasicTableSettings,
|
||||
RefreshableTableSettings,
|
||||
SettableColumnsTableSettings,
|
||||
} from '@@/datatables/types';
|
||||
|
||||
export interface TableSettings
|
||||
extends BasicTableSettings,
|
||||
SettableColumnsTableSettings,
|
||||
RefreshableTableSettings {}
|
|
@ -1,60 +0,0 @@
|
|||
import { useIsMutating } from 'react-query';
|
||||
|
||||
import { useSettings } from '@/react/portainer/settings/queries';
|
||||
import { useGroups } from '@/react/portainer/environments/environment-groups/queries';
|
||||
import { activateDeviceMutationKey } from '@/portainer/hostmanagement/open-amt/queries';
|
||||
|
||||
import { PageHeader } from '@@/PageHeader';
|
||||
import { ViewLoading } from '@@/ViewLoading';
|
||||
|
||||
import { EdgeDevicesDatatable } from './EdgeDevicesDatatable/EdgeDevicesDatatable';
|
||||
|
||||
export function ListView() {
|
||||
const isActivatingDevice = useIsActivatingDevice();
|
||||
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' }]}
|
||||
/>
|
||||
|
||||
{isActivatingDevice ? (
|
||||
<ViewLoading message="Activating Active Management Technology on selected device..." />
|
||||
) : (
|
||||
<EdgeDevicesDatatable
|
||||
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"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function useIsActivatingDevice() {
|
||||
const count = useIsMutating({ mutationKey: activateDeviceMutationKey });
|
||||
return count > 0;
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
export { ListView } from './ListView';
|
51
app/react/edge/edge-devices/open-amt/types.ts
Normal file
51
app/react/edge/edge-devices/open-amt/types.ts
Normal file
|
@ -0,0 +1,51 @@
|
|||
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 enum PowerStateCode {
|
||||
On = 2,
|
||||
SleepLight = 3,
|
||||
SleepDeep = 4,
|
||||
OffHard = 6,
|
||||
Hibernate = 7,
|
||||
OffSoft = 8,
|
||||
PowerCycle = 9,
|
||||
OffHardGraceful = 13,
|
||||
}
|
||||
|
||||
export type Device = {
|
||||
guid: string;
|
||||
hostname: string;
|
||||
powerState: PowerStateCode;
|
||||
connectionStatus: boolean;
|
||||
features?: DeviceFeatures;
|
||||
};
|
33
app/react/edge/edge-devices/open-amt/useAMTDevices.tsx
Normal file
33
app/react/edge/edge-devices/open-amt/useAMTDevices.tsx
Normal file
|
@ -0,0 +1,33 @@
|
|||
import { useQuery } from 'react-query';
|
||||
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { withError } from '@/react-tools/react-query';
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
|
||||
import { Device } from './types';
|
||||
|
||||
export function useAMTDevices(
|
||||
environmentId: EnvironmentId,
|
||||
{ enabled }: { enabled?: boolean } = {}
|
||||
) {
|
||||
return useQuery(
|
||||
['amt_devices', environmentId],
|
||||
() => getDevices(environmentId),
|
||||
{
|
||||
...withError('Failed retrieving AMT devices'),
|
||||
enabled,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function getDevices(environmentId: EnvironmentId) {
|
||||
try {
|
||||
const { data: devices } = await axios.get<Device[]>(
|
||||
`/open_amt/${environmentId}/devices`
|
||||
);
|
||||
|
||||
return devices;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error, 'Unable to retrieve device information');
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
import { useMutation } from 'react-query';
|
||||
|
||||
import { promiseSequence } from '@/portainer/helpers/promise-utils';
|
||||
import { mutationOptions, withError } from '@/react-tools/react-query';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
|
||||
export function useActivateDevicesMutation() {
|
||||
return useMutation(
|
||||
(environmentIds: EnvironmentId[]) =>
|
||||
promiseSequence(environmentIds.map((id) => () => activateDevice(id))),
|
||||
mutationOptions(withError('Unable to associate with OpenAMT'))
|
||||
);
|
||||
}
|
||||
|
||||
async function activateDevice(environmentId: EnvironmentId) {
|
||||
try {
|
||||
await axios.post(`/open_amt/${environmentId}/activate`);
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error, 'Unable to activate device');
|
||||
}
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
import { useMutation, useQueryClient } from 'react-query';
|
||||
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { withError } from '@/react-tools/react-query';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
export enum DeviceAction {
|
||||
PowerOn = 'power on',
|
||||
PowerOff = 'power off',
|
||||
Restart = 'restart',
|
||||
}
|
||||
|
||||
export function useExecuteAMTDeviceActionMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(executeDeviceAction, {
|
||||
onSuccess(_data, { environmentId }) {
|
||||
queryClient.invalidateQueries([['amt_devices', environmentId]]);
|
||||
},
|
||||
...withError('Unable to execute device action'),
|
||||
});
|
||||
}
|
||||
|
||||
async function executeDeviceAction({
|
||||
action,
|
||||
deviceGUID,
|
||||
environmentId,
|
||||
}: {
|
||||
environmentId: EnvironmentId;
|
||||
deviceGUID: string;
|
||||
action: DeviceAction;
|
||||
}) {
|
||||
try {
|
||||
await axios.post(
|
||||
`/open_amt/${environmentId}/devices/${deviceGUID}/action`,
|
||||
{ action }
|
||||
);
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error, 'Unable to execute device action');
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue