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

feat(edge): show correct heartbeat and sync aeec changes [EE-2876] (#6769)

This commit is contained in:
Chaim Lev-Ari 2022-04-19 21:43:36 +03:00 committed by GitHub
parent 76d1b70644
commit e217ac7121
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
44 changed files with 1099 additions and 307 deletions

View file

@ -0,0 +1,214 @@
import {
Column,
useGlobalFilter,
usePagination,
useRowSelect,
useSortBy,
useTable,
} from 'react-table';
import { useRowSelectColumn } from '@lineup-lite/hooks';
import { Button } from '@/portainer/components/Button';
import { Table } from '@/portainer/components/datatables/components';
import {
SearchBar,
useSearchBarState,
} from '@/portainer/components/datatables/components/SearchBar';
import { SelectedRowsCount } from '@/portainer/components/datatables/components/SelectedRowsCount';
import { PaginationControls } from '@/portainer/components/pagination-controls';
import { Environment } from '@/portainer/environments/types';
import { useTableSettings } from '@/portainer/components/datatables/components/useTableSettings';
import { notifySuccess } from '@/portainer/services/notifications';
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="" />
<Table.Actions>
<Button
onClick={() =>
handleAssociateDevice(selectedFlatRows.map((r) => r.original))
}
disabled={selectedFlatRows.length === 0}
>
Associate Device
</Button>
</Table.Actions>
<SearchBar onChange={handleSearchBarChange} value={searchBarValue} />
<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('Edge devices associated successfully');
},
}
);
}
}

View file

@ -0,0 +1,8 @@
import {
PaginationTableSettings,
SortableTableSettings,
} from '@/portainer/components/datatables/types';
export interface TableSettings
extends SortableTableSettings,
PaginationTableSettings {}

View file

@ -0,0 +1,47 @@
import { useRouter } from '@uirouter/react';
import { TableSettingsProvider } from '@/portainer/components/datatables/components/useTableSettings';
import { PageHeader } from '@/portainer/components/PageHeader';
import { useEnvironmentList } from '@/portainer/environments/queries';
import { r2a } from '@/react-tools/react2angular';
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({
edgeDeviceFilter: 'untrusted',
});
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' },
]}
/>
<TableSettingsProvider<TableSettings>
defaults={{ pageSize: 10, sortBy: { desc: false, id: 'name' } }}
storageKey={storageKey}
>
<DataTable
devices={environments}
totalCount={totalCount}
isLoading={isLoading}
storageKey={storageKey}
/>
</TableSettingsProvider>
</>
);
}
export const WaitingRoomViewAngular = r2a(WaitingRoomView, []);

View file

@ -0,0 +1 @@
export { WaitingRoomView, WaitingRoomViewAngular } from './WaitingRoomView';

View file

@ -0,0 +1,33 @@
import { useMutation, useQueryClient } from 'react-query';
import { EnvironmentId } from '@/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');
}
}

View file

@ -3,134 +3,150 @@ import angular from 'angular';
import edgeStackModule from './views/edge-stacks';
import edgeDevicesModule from './devices';
import { componentsModule } from './components';
import { WaitingRoomViewAngular } from './EdgeDevices/WaitingRoomView';
angular.module('portainer.edge', [edgeStackModule, edgeDevicesModule, componentsModule]).config(function config($stateRegistryProvider) {
const edge = {
name: 'edge',
url: '/edge',
parent: 'root',
abstract: true,
};
angular
.module('portainer.edge', [edgeStackModule, edgeDevicesModule, componentsModule])
.component('waitingRoomView', WaitingRoomViewAngular)
.config(function config($stateRegistryProvider) {
const edge = {
name: 'edge',
url: '/edge',
parent: 'root',
abstract: true,
};
const groups = {
name: 'edge.groups',
url: '/groups',
views: {
'content@': {
component: 'edgeGroupsView',
const groups = {
name: 'edge.groups',
url: '/groups',
views: {
'content@': {
component: 'edgeGroupsView',
},
},
},
};
};
const groupsNew = {
name: 'edge.groups.new',
url: '/new',
views: {
'content@': {
component: 'createEdgeGroupView',
const groupsNew = {
name: 'edge.groups.new',
url: '/new',
views: {
'content@': {
component: 'createEdgeGroupView',
},
},
},
};
};
const groupsEdit = {
name: 'edge.groups.edit',
url: '/:groupId',
views: {
'content@': {
component: 'editEdgeGroupView',
const groupsEdit = {
name: 'edge.groups.edit',
url: '/:groupId',
views: {
'content@': {
component: 'editEdgeGroupView',
},
},
},
};
};
const stacks = {
name: 'edge.stacks',
url: '/stacks',
views: {
'content@': {
component: 'edgeStacksView',
const stacks = {
name: 'edge.stacks',
url: '/stacks',
views: {
'content@': {
component: 'edgeStacksView',
},
},
},
};
};
const stacksNew = {
name: 'edge.stacks.new',
url: '/new',
views: {
'content@': {
component: 'createEdgeStackView',
const stacksNew = {
name: 'edge.stacks.new',
url: '/new',
views: {
'content@': {
component: 'createEdgeStackView',
},
},
},
};
};
const stacksEdit = {
name: 'edge.stacks.edit',
url: '/:stackId',
views: {
'content@': {
component: 'editEdgeStackView',
const stacksEdit = {
name: 'edge.stacks.edit',
url: '/:stackId',
views: {
'content@': {
component: 'editEdgeStackView',
},
},
},
params: {
tab: 0,
},
};
const edgeJobs = {
name: 'edge.jobs',
url: '/jobs',
views: {
'content@': {
component: 'edgeJobsView',
params: {
tab: 0,
},
},
};
};
const edgeJob = {
name: 'edge.jobs.job',
url: '/:id',
views: {
'content@': {
component: 'edgeJobView',
const edgeJobs = {
name: 'edge.jobs',
url: '/jobs',
views: {
'content@': {
component: 'edgeJobsView',
},
},
},
params: {
tab: 0,
},
};
};
const edgeJobCreation = {
name: 'edge.jobs.new',
url: '/new',
views: {
'content@': {
component: 'createEdgeJobView',
const edgeJob = {
name: 'edge.jobs.job',
url: '/:id',
views: {
'content@': {
component: 'edgeJobView',
},
},
},
};
const edgeDevices = {
name: 'edge.devices',
url: '/devices',
views: {
'content@': {
component: 'edgeDevicesView',
params: {
tab: 0,
},
},
};
};
$stateRegistryProvider.register(edge);
const edgeJobCreation = {
name: 'edge.jobs.new',
url: '/new',
views: {
'content@': {
component: 'createEdgeJobView',
},
},
};
$stateRegistryProvider.register(groups);
$stateRegistryProvider.register(groupsNew);
$stateRegistryProvider.register(groupsEdit);
const edgeDevices = {
name: 'edge.devices',
url: '/devices',
views: {
'content@': {
component: 'edgeDevicesView',
},
},
};
$stateRegistryProvider.register(stacks);
$stateRegistryProvider.register(stacksNew);
$stateRegistryProvider.register(stacksEdit);
if (process.env.PORTAINER_EDITION === 'BE') {
$stateRegistryProvider.register({
name: 'edge.devices.waiting-room',
url: '/waiting-room',
views: {
'content@': {
component: 'waitingRoomView',
},
},
});
}
$stateRegistryProvider.register(edgeJobs);
$stateRegistryProvider.register(edgeJob);
$stateRegistryProvider.register(edgeJobCreation);
$stateRegistryProvider.register(edge);
$stateRegistryProvider.register(edgeDevices);
});
$stateRegistryProvider.register(groups);
$stateRegistryProvider.register(groupsNew);
$stateRegistryProvider.register(groupsEdit);
$stateRegistryProvider.register(stacks);
$stateRegistryProvider.register(stacksNew);
$stateRegistryProvider.register(stacksEdit);
$stateRegistryProvider.register(edgeJobs);
$stateRegistryProvider.register(edgeJob);
$stateRegistryProvider.register(edgeJobCreation);
$stateRegistryProvider.register(edgeDevices);
});

View file

@ -19,7 +19,7 @@ export function EdgePropertiesForm({
}: Props) {
return (
<form className="form-horizontal">
<FormSectionTitle>Edge script settings</FormSectionTitle>
<FormSectionTitle>Edge agent deployment script</FormSectionTitle>
<OsSelector
value={values.os}

View file

@ -50,7 +50,7 @@ export interface EdgeDevicesTableProps {
isEnabled: boolean;
isFdoEnabled: boolean;
isOpenAmtEnabled: boolean;
disableTrustOnFirstConnect: boolean;
showWaitingRoomLink: boolean;
mpsServer: string;
dataset: Environment[];
groups: EnvironmentGroup[];
@ -62,7 +62,7 @@ export function EdgeDevicesDatatable({
storageKey,
isFdoEnabled,
isOpenAmtEnabled,
disableTrustOnFirstConnect,
showWaitingRoomLink,
mpsServer,
dataset,
groups,
@ -164,6 +164,7 @@ export function EdgeDevicesDatatable({
isFDOEnabled={isFdoEnabled}
isOpenAMTEnabled={isOpenAmtEnabled}
setLoadingMessage={setLoadingMessage}
showWaitingRoomLink={showWaitingRoomLink}
/>
</TableActions>
@ -216,7 +217,6 @@ export function EdgeDevicesDatatable({
return (
<RowProvider
key={key}
disableTrustOnFirstConnect={disableTrustOnFirstConnect}
isOpenAmtEnabled={isOpenAmtEnabled}
groupName={group[0]?.Name}
>

View file

@ -7,12 +7,14 @@ 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 '@/portainer/environments/environment.service';
import { Link } from '@/portainer/components/Link';
interface Props {
selectedItems: Environment[];
isFDOEnabled: boolean;
isOpenAMTEnabled: boolean;
setLoadingMessage(message: string): void;
showWaitingRoomLink: boolean;
}
export function EdgeDevicesDatatableActions({
@ -20,6 +22,7 @@ export function EdgeDevicesDatatableActions({
isOpenAMTEnabled,
isFDOEnabled,
setLoadingMessage,
showWaitingRoomLink,
}: Props) {
const router = useRouter();
@ -48,6 +51,12 @@ export function EdgeDevicesDatatableActions({
Associate with OpenAMT
</Button>
)}
{showWaitingRoomLink && (
<Link to="edge.devices.waiting-room">
<Button>Waiting Room</Button>
</Link>
)}
</div>
);

View file

@ -35,7 +35,7 @@ export const EdgeDevicesDatatableAngular = react2angular(
'onRefresh',
'setLoadingMessage',
'isFdoEnabled',
'disableTrustOnFirstConnect',
'showWaitingRoomLink',
'isOpenAmtEnabled',
'mpsServer',
]

View file

@ -1,7 +1,6 @@
import { createContext, useContext, useMemo, PropsWithChildren } from 'react';
interface RowContextState {
disableTrustOnFirstConnect: boolean;
isOpenAmtEnabled: boolean;
groupName?: string;
}
@ -9,20 +8,18 @@ interface RowContextState {
const RowContext = createContext<RowContextState | null>(null);
export interface RowProviderProps {
disableTrustOnFirstConnect: boolean;
groupName?: string;
isOpenAmtEnabled: boolean;
}
export function RowProvider({
disableTrustOnFirstConnect,
groupName,
isOpenAmtEnabled,
children,
}: PropsWithChildren<RowProviderProps>) {
const state = useMemo(
() => ({ disableTrustOnFirstConnect, groupName, isOpenAmtEnabled }),
[disableTrustOnFirstConnect, groupName, isOpenAmtEnabled]
() => ({ groupName, isOpenAmtEnabled }),
[groupName, isOpenAmtEnabled]
);
return <RowContext.Provider value={state}>{children}</RowContext.Provider>;

View file

@ -4,14 +4,9 @@ import { useRouter, useSref } from '@uirouter/react';
import { Environment } from '@/portainer/environments/types';
import { ActionsMenu } from '@/portainer/components/datatables/components/ActionsMenu';
import {
snapshotEndpoint,
trustEndpoint,
} from '@/portainer/environments/environment.service';
import { snapshotEndpoint } from '@/portainer/environments/environment.service';
import * as notifications from '@/portainer/services/notifications';
import { getRoute } from '@/portainer/environments/utils';
import { confirmAsync } from '@/portainer/services/modal.service/confirm';
import { useRowContext } from '@/edge/devices/components/EdgeDevicesDatatable/columns/RowContext';
export const actions: Column<Environment> = {
Header: 'Actions',
@ -39,8 +34,6 @@ export function ActionsCell({
const showRefreshSnapshot = false; // remove and show MenuItem when feature is available
const { disableTrustOnFirstConnect } = useRowContext();
return (
<ActionsMenu>
<MenuLink href={browseLinkProps.href} onClick={browseLinkProps.onClick}>
@ -51,9 +44,6 @@ export function ActionsCell({
Refresh Snapshot
</MenuItem>
)}
{disableTrustOnFirstConnect && !environment.UserTrusted && (
<MenuLink onClick={trustDevice}>Trust</MenuLink>
)}
</ActionsMenu>
);
@ -71,37 +61,4 @@ export function ActionsCell({
await router.stateService.reload();
}
}
async function trustDevice() {
const confirmed = await confirmAsync({
title: '',
message: `Mark ${environment.Name} as trusted?`,
buttons: {
cancel: {
label: 'Cancel',
className: 'btn-default',
},
confirm: {
label: 'Trust',
className: 'btn-primary',
},
},
});
if (!confirmed) {
return;
}
try {
await trustEndpoint(environment.Id);
} catch (err) {
notifications.error(
'Failure',
err as Error,
'An error occurred while trusting the environment'
);
} finally {
await router.stateService.reload();
}
}
}

View file

@ -1,8 +1,7 @@
import { CellProps, Column } from 'react-table';
import clsx from 'clsx';
import { Environment, EnvironmentStatus } from '@/portainer/environments/types';
import { useRowContext } from '@/edge/devices/components/EdgeDevicesDatatable/columns/RowContext';
import { Environment } from '@/portainer/environments/types';
import { EdgeIndicator } from '@/portainer/home/EnvironmentList/EnvironmentItem/EdgeIndicator';
export const heartbeat: Column<Environment> = {
Header: 'Heartbeat',
@ -16,35 +15,12 @@ export const heartbeat: Column<Environment> = {
export function StatusCell({
row: { original: environment },
}: CellProps<Environment>) {
const { disableTrustOnFirstConnect } = useRowContext();
if (disableTrustOnFirstConnect && !environment.UserTrusted) {
return <span className="label label-default">untrusted</span>;
}
if (!environment.LastCheckInDate) {
return (
<span className="label label-default">
<s>associated</s>
</span>
);
}
return (
<i
className={clsx(
'fa',
'fa-heartbeat',
environmentStatusLabel(environment.Status)
)}
aria-hidden="true"
<EdgeIndicator
checkInInterval={environment.EdgeCheckinInterval}
edgeId={environment.EdgeID}
lastCheckInDate={environment.LastCheckInDate}
queryDate={environment.QueryDate}
/>
);
function environmentStatusLabel(status: EnvironmentStatus) {
if (status === EnvironmentStatus.Up) {
return 'green-icon';
}
return 'orange-icon';
}
}

View file

@ -1,14 +1,19 @@
export interface EdgeDeviceTableSettings {
hiddenColumns: string[];
autoRefreshRate: number;
pageSize: number;
sortBy: { id: string; desc: boolean };
}
import {
PaginationTableSettings,
RefreshableTableSettings,
SettableColumnsTableSettings,
SortableTableSettings,
} from '@/portainer/components/datatables/types';
export interface FDOProfilesTableSettings {
pageSize: number;
sortBy: { id: string; desc: boolean };
}
export interface EdgeDeviceTableSettings
extends SortableTableSettings,
PaginationTableSettings,
SettableColumnsTableSettings,
RefreshableTableSettings {}
export interface FDOProfilesTableSettings
extends SortableTableSettings,
PaginationTableSettings {}
export enum DeviceAction {
PowerOn = 'power on',

View file

@ -31,7 +31,7 @@
groups="($ctrl.groups)"
is-fdo-enabled="($ctrl.isFDOEnabled)"
is-open-amt-enabled="($ctrl.isOpenAMTEnabled)"
disable-trust-on-first-connect="($ctrl.disableTrustOnFirstConnect)"
show-waiting-room-link="($ctrl.showWaitingRoomLink)"
mps-server="($ctrl.mpsServer)"
on-refresh="($ctrl.getEnvironments)"
set-loading-message="($ctrl.setLoadingMessage)"

View file

@ -1,5 +1,4 @@
import { getEndpoints } from 'Portainer/environments/environment.service';
import { EnvironmentType } from 'Portainer/environments/types';
angular.module('portainer.edge').controller('EdgeDevicesViewController', EdgeDevicesViewController);
/* @ngInject */
@ -11,7 +10,7 @@ export function EdgeDevicesViewController($q, $async, EndpointService, GroupServ
this.getEnvironments = function () {
return $async(async () => {
try {
const [endpointsResponse, groups] = await Promise.all([getEndpoints(0, 100, { types: [EnvironmentType.EdgeAgentOnDocker] }), GroupService.groups()]);
const [endpointsResponse, groups] = await Promise.all([getEndpoints(0, 100, { edgeDeviceFilter: 'trusted' }), GroupService.groups()]);
ctrl.groups = groups;
ctrl.edgeDevices = endpointsResponse.value;
} catch (err) {
@ -27,7 +26,7 @@ export function EdgeDevicesViewController($q, $async, EndpointService, GroupServ
const settings = await SettingsService.settings();
ctrl.isFDOEnabled = settings && settings.EnableEdgeComputeFeatures && settings.fdoConfiguration && settings.fdoConfiguration.enabled;
ctrl.disableTrustOnFirstConnect = settings && settings.EnableEdgeComputeFeatures && settings.DisableTrustOnFirstConnect;
ctrl.showWaitingRoomLink = process.env.PORTAINER_EDITION === 'BE' && settings && settings.EnableEdgeComputeFeatures && !settings.TrustOnFirstConnect;
ctrl.isOpenAMTEnabled = settings && settings.EnableEdgeComputeFeatures && settings.openAMTConfiguration && settings.openAMTConfiguration.enabled;
ctrl.mpsServer = ctrl.isOpenAMTEnabled ? settings.openAMTConfiguration.mpsServer : '';
} catch (err) {