Skip to content

Commit 42557e2

Browse files
committed
EDM-2060: Display Suspended devices and allow resuming them
1 parent 380a757 commit 42557e2

35 files changed

Lines changed: 1308 additions & 89 deletions

apps/ocp-plugin/src/utils/apiCalls.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,11 @@ export const handleApiJSONResponse = async <R>(response: Response): Promise<R> =
7272
throw new Error(await getErrorMsgFromApiResponse(response));
7373
};
7474

75-
const putOrPostData = async <R>(kind: string, data: R, method: 'PUT' | 'POST'): Promise<R> => {
75+
const putOrPostData = async <TRequest, TResponse = TRequest>(
76+
kind: string,
77+
data: TRequest,
78+
method: 'PUT' | 'POST',
79+
): Promise<TResponse> => {
7680
const options: RequestInit = {
7781
headers: {
7882
'Content-Type': 'application/json',
@@ -90,9 +94,11 @@ const putOrPostData = async <R>(kind: string, data: R, method: 'PUT' | 'POST'):
9094
}
9195
};
9296

93-
export const postData = async <R>(kind: string, data: R): Promise<R> => putOrPostData(kind, data, 'POST');
97+
export const postData = async <TRequest, TResponse = TRequest>(kind: string, data: TRequest): Promise<TResponse> =>
98+
putOrPostData<TRequest, TResponse>(kind, data, 'POST');
9499

95-
export const putData = async <R>(kind: string, data: R): Promise<R> => putOrPostData(kind, data, 'PUT');
100+
export const putData = async <TRequest>(kind: string, data: TRequest): Promise<TRequest> =>
101+
putOrPostData<TRequest, TRequest>(kind, data, 'PUT');
96102

97103
export const deleteData = async <R>(kind: string, abortSignal?: AbortSignal): Promise<R> => {
98104
const options: RequestInit = {

apps/standalone/src/app/hooks/useFetch.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,16 @@ export const useFetch = () => {
88
[],
99
);
1010

11-
const post = React.useCallback(async <R>(kind: string, obj: R): Promise<R> => postData(kind, obj), []);
11+
const post = React.useCallback(
12+
async <TRequest, TResponse = TRequest>(kind: string, data: TRequest): Promise<TResponse> =>
13+
postData<TRequest, TResponse>(kind, data),
14+
[],
15+
);
1216

13-
const put = React.useCallback(async <R>(kind: string, obj: R): Promise<R> => putData(kind, obj), []);
17+
const put = React.useCallback(
18+
async <TRequest>(kind: string, data: TRequest): Promise<TRequest> => putData<TRequest>(kind, data),
19+
[],
20+
);
1421

1522
const remove = React.useCallback(
1623
async <R>(kind: string, abortSignal?: AbortSignal): Promise<R> => deleteData(kind, abortSignal),

apps/standalone/src/app/index.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as React from 'react';
22
import { AppContext } from '@flightctl/ui-components/src/hooks/useAppContext';
3+
import { SystemRestoreProvider } from '@flightctl/ui-components/src/hooks/useSystemRestoreContext';
34

45
import { AppRouter } from './routes';
56
import { useStandaloneAppContext } from './hooks/useStandaloneAppContext';
@@ -19,7 +20,9 @@ const App: React.FunctionComponent = () => {
1920
<React.Suspense fallback={<div />}>
2021
<AuthContext.Provider value={authContextValue}>
2122
<AppContext.Provider value={appContextValue}>
22-
<AppRouter />
23+
<SystemRestoreProvider>
24+
<AppRouter />
25+
</SystemRestoreProvider>
2326
</AppContext.Provider>
2427
</AuthContext.Provider>
2528
</React.Suspense>

apps/standalone/src/app/utils/apiCalls.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -105,9 +105,13 @@ export const fetchCliArtifacts = async (abortSignal?: AbortSignal): Promise<CliA
105105
}
106106
};
107107

108-
const putOrPostData = async <R>(kind: string, data: R, method: 'PUT' | 'POST'): Promise<R> => {
108+
const putOrPostData = async <TRequest, TResponse = TRequest>(
109+
kind: string,
110+
data: TRequest,
111+
method: 'PUT' | 'POST',
112+
): Promise<TResponse> => {
109113
try {
110-
return await fetchWithRetry<R>(kind, {
114+
return await fetchWithRetry<TResponse>(kind, {
111115
headers: {
112116
'Content-Type': 'application/json',
113117
},
@@ -121,9 +125,11 @@ const putOrPostData = async <R>(kind: string, data: R, method: 'PUT' | 'POST'):
121125
}
122126
};
123127

124-
export const postData = async <R>(kind: string, data: R): Promise<R> => putOrPostData(kind, data, 'POST');
128+
export const postData = async <TRequest, TResponse = TRequest>(kind: string, data: TRequest): Promise<TResponse> =>
129+
putOrPostData<TRequest, TResponse>(kind, data, 'POST');
125130

126-
export const putData = async <R>(kind: string, data: R): Promise<R> => putOrPostData(kind, data, 'PUT');
131+
export const putData = async <TRequest>(kind: string, data: TRequest): Promise<TRequest> =>
132+
putOrPostData<TRequest, TRequest>(kind, data, 'PUT');
127133

128134
export const deleteData = async <R>(kind: string, abortSignal?: AbortSignal): Promise<R> => {
129135
try {

libs/ansible/src/utils/apiCalls.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,13 @@ const handleApiJSONResponse = async <R>(response: Response): Promise<R> => {
1717
throw new Error(await getErrorMsgFromApiResponse(response));
1818
};
1919

20-
const putOrPostData = async <R>(
20+
const putOrPostData = async <TRequest, TResponse = TRequest>(
2121
kind: string,
22-
data: R,
22+
data: TRequest,
2323
serviceUrl: string,
2424
applyOptions: (options: RequestInit) => RequestInit,
2525
method: 'PUT' | 'POST',
26-
): Promise<R> => {
26+
): Promise<TResponse> => {
2727
const options: RequestInit = {
2828
headers: {
2929
'Content-Type': 'application/json',
@@ -41,19 +41,19 @@ const putOrPostData = async <R>(
4141
}
4242
};
4343

44-
export const postData = async <R>(
44+
export const postData = async <TRequest, TResponse = TRequest>(
4545
kind: string,
46-
data: R,
46+
data: TRequest,
4747
serviceUrl: string,
4848
applyOptions: (options: RequestInit) => RequestInit,
49-
) => putOrPostData(kind, data, serviceUrl, applyOptions, 'POST');
49+
) => putOrPostData<TRequest, TResponse>(kind, data, serviceUrl, applyOptions, 'POST');
5050

51-
export const putData = async <R>(
51+
export const putData = async <TRequest>(
5252
kind: string,
53-
data: R,
53+
data: TRequest,
5454
serviceUrl: string,
5555
applyOptions: (options: RequestInit) => RequestInit,
56-
) => putOrPostData(kind, data, serviceUrl, applyOptions, 'PUT');
56+
): Promise<TRequest> => putOrPostData<TRequest, TRequest>(kind, data, serviceUrl, applyOptions, 'PUT');
5757

5858
export const deleteData = async <R>(
5959
kind: string,

libs/ui-components/src/components/DetailsPage/DetailsPage.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export type DetailsPageProps = {
3131
resourceLink: Route;
3232
actions?: React.ReactNode;
3333
nav?: React.ReactNode;
34+
banner?: React.ReactNode;
3435
};
3536

3637
const DetailsPage = ({
@@ -45,6 +46,7 @@ const DetailsPage = ({
4546
resourceTypeLabel,
4647
actions,
4748
nav,
49+
banner,
4850
}: DetailsPageProps) => {
4951
const { t } = useTranslation();
5052
let content = children;
@@ -86,6 +88,7 @@ const DetailsPage = ({
8688
<SplitItem>{actions}</SplitItem>
8789
</Split>
8890
</PageSection>
91+
{banner}
8992
{nav && (
9093
<PageSection variant="light" type="nav" className="fctl-details-page__nav">
9194
{nav}

libs/ui-components/src/components/DetailsPage/DetailsPageActions.tsx

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import * as React from 'react';
22
import { Dropdown, DropdownItem, MenuToggle } from '@patternfly/react-core';
3+
import { Trans } from 'react-i18next';
34

45
import { DeviceDecommissionTargetType } from '@flightctl/types';
56

67
import { useTranslation } from '../../hooks/useTranslation';
78
import { getDisabledTooltipProps } from '../../utils/tooltip';
89
import DeleteModal from '../modals/DeleteModal/DeleteModal';
910
import DecommissionModal from '../modals/DecommissionModal/DecommissionModal';
11+
import ResumeDevicesModal from '../modals/ResumeDevicesModal/ResumeDevicesModal';
1012

1113
type DeleteActionProps = {
1214
onDelete: () => Promise<unknown>;
@@ -21,6 +23,13 @@ type DecommissionActionProps = {
2123
disabledReason?: string;
2224
};
2325

26+
type ResumeActionProps = {
27+
deviceId: string;
28+
alias?: string;
29+
disabledReason?: string;
30+
onResumeComplete?: () => void;
31+
};
32+
2433
export const useDeleteAction = ({
2534
resourceType,
2635
resourceName,
@@ -73,6 +82,42 @@ export const useDecommissionAction = ({ onDecommission, disabledReason }: Decomm
7382
return { decommissionAction, decommissionModal };
7483
};
7584

85+
export const useResumeAction = ({ disabledReason, deviceId, alias, onResumeComplete }: ResumeActionProps) => {
86+
const { t } = useTranslation();
87+
const [isResumeModalOpen, setIsResumeModalOpen] = React.useState(false);
88+
const resumeProps = getDisabledTooltipProps(disabledReason);
89+
const deviceNameOrAlias = alias || deviceId;
90+
const resumeAction = (
91+
<DropdownItem onClick={() => setIsResumeModalOpen(true)} {...resumeProps}>
92+
{t('Resume device')}
93+
</DropdownItem>
94+
);
95+
96+
const resumeSelector = {
97+
fieldSelector: `metadata.name=${deviceId}`,
98+
};
99+
const resumeModal = isResumeModalOpen && (
100+
<ResumeDevicesModal
101+
mode="device"
102+
title={
103+
<Trans t={t}>
104+
You are about to resume device <strong>{deviceNameOrAlias}</strong>
105+
</Trans>
106+
}
107+
selector={resumeSelector}
108+
expectedCount={1}
109+
onClose={(hasResumed) => {
110+
setIsResumeModalOpen(false);
111+
if (hasResumed) {
112+
onResumeComplete?.();
113+
}
114+
}}
115+
/>
116+
);
117+
118+
return { resumeAction, resumeModal };
119+
};
120+
76121
const DetailsPageActions = ({ children }: React.PropsWithChildren) => {
77122
const { t } = useTranslation();
78123
const [actionsOpen, setActionsOpen] = React.useState(false);

libs/ui-components/src/components/Device/DeviceDetails/DeviceDetailsPage.tsx

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,37 @@
11
import * as React from 'react';
2+
import { Trans } from 'react-i18next';
23
import { Button, DropdownItem, DropdownList, Nav, NavList } from '@patternfly/react-core';
34

4-
import { Device, DeviceDecommission, DeviceDecommissionTargetType, ResourceKind } from '@flightctl/types';
5+
import {
6+
Device,
7+
DeviceDecommission,
8+
DeviceDecommissionTargetType,
9+
DeviceSummaryStatusType,
10+
ResourceKind,
11+
} from '@flightctl/types';
512
import { useFetchPeriodically } from '../../../hooks/useFetchPeriodically';
613
import { useFetch } from '../../../hooks/useFetch';
714
import { getDisabledTooltipProps } from '../../../utils/tooltip';
815
import DetailsPage from '../../DetailsPage/DetailsPage';
9-
import DetailsPageActions, { useDecommissionAction, useDeleteAction } from '../../DetailsPage/DetailsPageActions';
16+
import DetailsPageActions, {
17+
useDecommissionAction,
18+
useDeleteAction,
19+
useResumeAction,
20+
} from '../../DetailsPage/DetailsPageActions';
1021
import { useTranslation } from '../../../hooks/useTranslation';
1122
import { ROUTE, useNavigate } from '../../../hooks/useNavigate';
1223
import { useAppContext } from '../../../hooks/useAppContext';
1324
import DeviceDetailsTab from './DeviceDetailsTab';
1425
import TerminalTab from './TerminalTab';
1526
import NavItem from '../../NavItem/NavItem';
16-
import { getEditDisabledReason, isDeviceEnrolled } from '../../../utils/devices';
27+
import { getEditDisabledReason, getResumeDisabledReason, isDeviceEnrolled } from '../../../utils/devices';
1728
import { RESOURCE, VERB } from '../../../types/rbac';
1829
import { useAccessReview } from '../../../hooks/useAccessReview';
1930
import EventsCard from '../../Events/EventsCard';
2031
import PageWithPermissions from '../../common/PageWithPermissions';
2132
import YamlEditor from '../../common/CodeEditor/YamlEditor';
2233
import DeviceAliasEdit from './DeviceAliasEdit';
34+
import { SystemRestoreBanners } from '../../SystemRestore/SystemRestoreBanners';
2335

2436
type DeviceDetailsPageProps = React.PropsWithChildren<{ hideTerminal?: boolean }>;
2537

@@ -35,12 +47,14 @@ const DeviceDetailsPage = ({ children, hideTerminal }: DeviceDetailsPageProps) =
3547

3648
const deviceLabels = device?.metadata.labels;
3749
const deviceAlias = deviceLabels?.alias;
50+
const deviceNameOrAlias = deviceAlias || deviceId;
3851
const isEnrolled = !device || isDeviceEnrolled(device);
3952

4053
const [hasTerminalAccess] = useAccessReview(RESOURCE.DEVICE_CONSOLE, VERB.GET);
4154
const [canDelete] = useAccessReview(RESOURCE.DEVICE, VERB.DELETE);
4255
const [canEdit] = useAccessReview(RESOURCE.DEVICE, VERB.PATCH);
4356
const [canDecommission] = useAccessReview(RESOURCE.DEVICE_DECOMMISSION, VERB.UPDATE);
57+
const [canResume] = useAccessReview(RESOURCE.DEVICE_RESUME, VERB.UPDATE);
4458

4559
const canOpenTerminal = hasTerminalAccess && isEnrolled;
4660

@@ -49,7 +63,7 @@ const DeviceDetailsPage = ({ children, hideTerminal }: DeviceDetailsPageProps) =
4963
await remove(`devices/${deviceId}`);
5064
navigate(ROUTE.DEVICES);
5165
},
52-
resourceName: deviceAlias || deviceId,
66+
resourceName: deviceNameOrAlias,
5367
resourceType: 'device',
5468
buttonLabel: isEnrolled ? undefined : t('Delete forever'),
5569
});
@@ -64,7 +78,33 @@ const DeviceDetailsPage = ({ children, hideTerminal }: DeviceDetailsPageProps) =
6478
},
6579
});
6680

81+
const { resumeAction, resumeModal } = useResumeAction({
82+
deviceId,
83+
alias: deviceAlias,
84+
disabledReason: device ? getResumeDisabledReason(device, t) : undefined,
85+
onResumeComplete: refetch,
86+
});
87+
6788
const editActionProps = device ? getDisabledTooltipProps(getEditDisabledReason(device, t)) : undefined;
89+
const resumeDevice = {
90+
actionText: t('Resume device'),
91+
title: (
92+
<Trans t={t}>
93+
You are about to resume <strong>{deviceNameOrAlias}</strong>
94+
</Trans>
95+
),
96+
requestSelector: {
97+
fieldSelector: `metadata.name=${deviceId}`,
98+
},
99+
};
100+
101+
const deviceSummaryStatus = device?.status?.summary.status;
102+
const deviceSummary = {
103+
[DeviceSummaryStatusType.DeviceSummaryStatusConflictPaused]:
104+
deviceSummaryStatus === DeviceSummaryStatusType.DeviceSummaryStatusConflictPaused ? 1 : 0,
105+
[DeviceSummaryStatusType.DeviceSummaryStatusAwaitingReconnect]:
106+
deviceSummaryStatus === DeviceSummaryStatusType.DeviceSummaryStatusAwaitingReconnect ? 1 : 0,
107+
};
68108

69109
return (
70110
<DetailsPage
@@ -86,6 +126,16 @@ const DeviceDetailsPage = ({ children, hideTerminal }: DeviceDetailsPageProps) =
86126
deviceAlias
87127
)
88128
}
129+
banner={
130+
device && (
131+
<SystemRestoreBanners
132+
mode="device"
133+
resumeAction={resumeDevice}
134+
summaryStatus={deviceSummary}
135+
onResumeComplete={refetch}
136+
/>
137+
)
138+
}
89139
resourceLink={ROUTE.DEVICES}
90140
resourceType="Devices"
91141
resourceTypeLabel={t('Devices')}
@@ -111,6 +161,7 @@ const DeviceDetailsPage = ({ children, hideTerminal }: DeviceDetailsPageProps) =
111161
{t('Edit device configurations')}
112162
</DropdownItem>
113163
)}
164+
{canResume && resumeAction}
114165
{canDecommission && decommissionAction}
115166
</DropdownList>
116167
</DetailsPageActions>
@@ -143,7 +194,7 @@ const DeviceDetailsPage = ({ children, hideTerminal }: DeviceDetailsPageProps) =
143194
</Routes>
144195
)}
145196

146-
{deleteModal || decommissionModal}
197+
{deleteModal || decommissionModal || resumeModal}
147198
</DetailsPage>
148199
);
149200
};

libs/ui-components/src/components/Device/DevicesPage/DevicesPage.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ const DevicesPage = ({ canListER }: { canListER: boolean }) => {
102102
setSelectedLabels={setSelectedLabels}
103103
isFilterUpdating={updating}
104104
pagination={pagination}
105+
refetchDevices={refetch}
105106
/>
106107
)}
107108
</ListPageBody>

0 commit comments

Comments
 (0)