Skip to content

Commit 3b1465d

Browse files
upcoming: [M3-9163] - QEMU reboot notices (#12231)
* Add new security reboot notification type * Introduce PlatformMaintenanceBanner and usePlatformMaintenance hook * Finalize QEMU banners * Added changeset: QEMU reboot notices * Added changeset: Notification type for QEMU maintenance * Add Platform maintenance notification template to MSW * Final changes to reflect devcloud data * Add unit tests * Feedback @jdamore-linode -- add gap in banner reboot button * Feedback @bnussman-akamai: remove async test queries
1 parent 9eff345 commit 3b1465d

File tree

20 files changed

+663
-17
lines changed

20 files changed

+663
-17
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@linode/api-v4": Added
3+
---
4+
5+
Notification type for QEMU maintenance ([#12231](https://github.com/linode/manager/pull/12231))

packages/api-v4/src/account/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,7 @@ export type NotificationType =
285285
| 'payment_due'
286286
| 'promotion'
287287
| 'reboot_scheduled'
288+
| 'security_reboot_maintenance_scheduled'
288289
| 'tax_id_verifying'
289290
| 'ticket_abuse'
290291
| 'ticket_important'
@@ -573,7 +574,7 @@ export interface AccountMaintenance {
573574
entity: {
574575
id: number;
575576
label: string;
576-
type: string;
577+
type: 'linode' | 'volume';
577578
url: string;
578579
};
579580
maintenance_policy_set: MaintenancePolicyType;
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@linode/manager": Upcoming Features
3+
---
4+
5+
QEMU reboot notices ([#12231](https://github.com/linode/manager/pull/12231))

packages/manager/src/components/DateTimeDisplay/DateTimeDisplay.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import * as React from 'react';
44

55
import { formatDate } from 'src/utilities/formatDate';
66

7+
import type { SxProps, Theme } from '@linode/ui';
78
import type { TimeInterval } from 'src/utilities/formatDate';
89

910
export interface DateTimeDisplayProps {
@@ -23,17 +24,21 @@ export interface DateTimeDisplayProps {
2324
* If the date and time provided is within the designated time frame then the date is displayed as a relative date
2425
*/
2526
humanizeCutoff?: TimeInterval;
27+
/**
28+
* Styles to pass through to the sx prop.
29+
*/
30+
sx?: SxProps<Theme>;
2631
/**
2732
* The date and time string to display
2833
*/
2934
value: string;
3035
}
3136

3237
const DateTimeDisplay = (props: DateTimeDisplayProps) => {
33-
const { className, displayTime, format, humanizeCutoff, value } = props;
38+
const { className, displayTime, format, humanizeCutoff, value, sx } = props;
3439
const { data: profile } = useProfile();
3540
return (
36-
<Typography className={className} component="span">
41+
<Typography className={className} component="span" sx={sx}>
3742
{formatDate(value, {
3843
displayTime,
3944
format,

packages/manager/src/components/MaintenanceBanner/MaintenanceBanner.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import * as React from 'react';
44

55
import { Link } from 'src/components/Link';
66
import { PENDING_MAINTENANCE_FILTER } from 'src/features/Account/Maintenance/utilities';
7+
import { isPlatformMaintenance } from 'src/hooks/usePlatformMaintenance';
78
import { formatDate } from 'src/utilities/formatDate';
89
import { isPast } from 'src/utilities/isPast';
910

@@ -19,11 +20,16 @@ interface Props {
1920
export const MaintenanceBanner = React.memo((props: Props) => {
2021
const { maintenanceEnd, maintenanceStart, type } = props;
2122

22-
const { data: accountMaintenanceData } = useAllAccountMaintenanceQuery(
23+
const { data: rawAccountMaintenanceData } = useAllAccountMaintenanceQuery(
2324
{},
2425
PENDING_MAINTENANCE_FILTER
2526
);
2627

28+
// Filter out platform maintenance, since that is handled separately
29+
const accountMaintenanceData = rawAccountMaintenanceData?.filter(
30+
(maintenance) => !isPlatformMaintenance(maintenance)
31+
);
32+
2733
const {
2834
data: profile,
2935
error: profileError,
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/* eslint-disable testing-library/prefer-screen-queries */
2+
import { linodeFactory } from '@linode/utilities';
3+
import React from 'react';
4+
5+
import { accountMaintenanceFactory, notificationFactory } from 'src/factories';
6+
import { renderWithTheme } from 'src/utilities/testHelpers';
7+
8+
import { LinodePlatformMaintenanceBanner } from './LinodePlatformMaintenanceBanner';
9+
10+
const queryMocks = vi.hoisted(() => ({
11+
useNotificationsQuery: vi.fn().mockReturnValue({}),
12+
useAllAccountMaintenanceQuery: vi.fn().mockReturnValue({}),
13+
useLinodeQuery: vi.fn().mockReturnValue({}),
14+
}));
15+
16+
vi.mock('@linode/queries', async () => {
17+
const actual = await vi.importActual('@linode/queries');
18+
return {
19+
...actual,
20+
...queryMocks,
21+
};
22+
});
23+
24+
beforeEach(() => {
25+
vi.stubEnv('TZ', 'UTC');
26+
});
27+
28+
describe('LinodePlatformMaintenanceBanner', () => {
29+
it("doesn't render when there is no platform maintenance", () => {
30+
queryMocks.useAllAccountMaintenanceQuery.mockReturnValue({
31+
data: accountMaintenanceFactory.buildList(3, {
32+
type: 'reboot',
33+
entity: {
34+
type: 'linode',
35+
},
36+
}),
37+
});
38+
39+
queryMocks.useNotificationsQuery.mockReturnValue({
40+
data: [],
41+
});
42+
43+
const { queryByText } = renderWithTheme(
44+
<LinodePlatformMaintenanceBanner linodeId={1} />
45+
);
46+
47+
expect(
48+
queryByText('needs to be rebooted for critical platform maintenance.')
49+
).not.toBeInTheDocument();
50+
});
51+
52+
it('does not render if there is a notification but not a maintenance item', () => {
53+
queryMocks.useAllAccountMaintenanceQuery.mockReturnValue({
54+
data: accountMaintenanceFactory.buildList(3, {
55+
type: 'reboot',
56+
entity: {
57+
type: 'linode',
58+
},
59+
reason: 'Unrelated maintenance',
60+
}),
61+
});
62+
63+
queryMocks.useNotificationsQuery.mockReturnValue({
64+
data: notificationFactory.buildList(1, {
65+
type: 'security_reboot_maintenance_scheduled',
66+
label: 'Platform Maintenance Scheduled',
67+
}),
68+
});
69+
70+
const { queryByText } = renderWithTheme(
71+
<LinodePlatformMaintenanceBanner linodeId={1} />
72+
);
73+
74+
expect(
75+
queryByText('needs to be rebooted for critical platform maintenance.')
76+
).not.toBeInTheDocument();
77+
});
78+
79+
it('renders when a maintenance item is returned', () => {
80+
const mockPlatformMaintenance = accountMaintenanceFactory.buildList(2, {
81+
type: 'reboot',
82+
entity: { type: 'linode' },
83+
reason: 'Your Linode needs a critical security update',
84+
when: '2020-01-01T00:00:00',
85+
start_time: '2020-01-01T00:00:00',
86+
});
87+
const mockMaintenance = [
88+
...mockPlatformMaintenance,
89+
accountMaintenanceFactory.build({
90+
type: 'reboot',
91+
entity: { type: 'linode' },
92+
reason: 'Unrelated maintenance item',
93+
}),
94+
];
95+
96+
queryMocks.useAllAccountMaintenanceQuery.mockReturnValue({
97+
data: mockMaintenance,
98+
});
99+
100+
queryMocks.useLinodeQuery.mockReturnValue({
101+
data: linodeFactory.build({
102+
id: mockPlatformMaintenance[0].entity.id,
103+
label: 'linode-with-platform-maintenance',
104+
}),
105+
});
106+
107+
queryMocks.useNotificationsQuery.mockReturnValue({
108+
data: notificationFactory.buildList(1, {
109+
type: 'security_reboot_maintenance_scheduled',
110+
label: 'Platform Maintenance Scheduled',
111+
}),
112+
});
113+
114+
const { getByText } = renderWithTheme(
115+
<LinodePlatformMaintenanceBanner
116+
linodeId={mockPlatformMaintenance[0].entity.id}
117+
/>
118+
);
119+
120+
expect(getByText('linode-with-platform-maintenance')).toBeVisible();
121+
expect(
122+
getByText((el) =>
123+
el.includes('needs to be rebooted for critical platform maintenance.')
124+
)
125+
).toBeVisible();
126+
});
127+
});
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { useLinodeQuery } from '@linode/queries';
2+
import { Notice } from '@linode/ui';
3+
import { Box, Button, Stack, Typography } from '@linode/ui';
4+
import React from 'react';
5+
6+
import { PowerActionsDialog } from 'src/features/Linodes/PowerActionsDialogOrDrawer';
7+
import { usePlatformMaintenance } from 'src/hooks/usePlatformMaintenance';
8+
9+
import { DateTimeDisplay } from '../DateTimeDisplay';
10+
import { Link } from '../Link';
11+
12+
import type { AccountMaintenance, Linode } from '@linode/api-v4';
13+
14+
export const LinodePlatformMaintenanceBanner = (props: {
15+
linodeId: Linode['id'];
16+
}) => {
17+
const { linodeId } = props;
18+
19+
const { linodesWithPlatformMaintenance, platformMaintenanceByLinode } =
20+
usePlatformMaintenance();
21+
22+
const { data: linode } = useLinodeQuery(
23+
linodeId,
24+
linodesWithPlatformMaintenance.has(linodeId)
25+
);
26+
27+
const [isRebootDialogOpen, setIsRebootDialogOpen] = React.useState(false);
28+
29+
if (!linodesWithPlatformMaintenance.has(linodeId)) return null;
30+
31+
const earliestMaintenance = platformMaintenanceByLinode[linodeId].reduce(
32+
(earliest, current) => {
33+
const currentMaintenanceStartTime = getMaintenanceStartTime(current);
34+
const earliestMaintenanceStartTime = getMaintenanceStartTime(earliest);
35+
36+
if (currentMaintenanceStartTime && earliestMaintenanceStartTime) {
37+
return currentMaintenanceStartTime < earliestMaintenanceStartTime
38+
? current
39+
: earliest;
40+
}
41+
42+
return earliest;
43+
},
44+
platformMaintenanceByLinode[linodeId][0]
45+
);
46+
47+
const startTime = getMaintenanceStartTime(earliestMaintenance);
48+
49+
return (
50+
<>
51+
<Notice forceImportantIconVerticalCenter variant="warning">
52+
<Stack alignItems="center" direction="row" gap={1}>
53+
<Box flex={1}>
54+
<Typography>
55+
Linode{' '}
56+
<Link to={`/linodes/${linodeId}`}>
57+
{linode?.label ?? linodeId}
58+
</Link>{' '}
59+
needs to be rebooted for critical platform maintenance.{' '}
60+
{startTime && (
61+
<>
62+
A reboot is scheduled for{' '}
63+
<strong>
64+
<DateTimeDisplay
65+
format="MM/dd/yyyy"
66+
sx={(theme) => ({
67+
fontWeight: theme.tokens.font.FontWeight.Bold,
68+
})}
69+
value={startTime}
70+
/>{' '}
71+
at{' '}
72+
<DateTimeDisplay
73+
format="HH:mm"
74+
sx={(theme) => ({
75+
fontWeight: theme.tokens.font.FontWeight.Bold,
76+
})}
77+
value={startTime}
78+
/>
79+
</strong>
80+
.
81+
</>
82+
)}
83+
</Typography>
84+
</Box>
85+
<Button
86+
buttonType="primary"
87+
disabled={linode?.status === 'rebooting'}
88+
onClick={() => setIsRebootDialogOpen(true)}
89+
tooltipText={
90+
linode?.status === 'rebooting'
91+
? 'This Linode is currently rebooting.'
92+
: undefined
93+
}
94+
>
95+
Reboot Now
96+
</Button>
97+
</Stack>
98+
</Notice>
99+
<PowerActionsDialog
100+
action="Reboot"
101+
isOpen={isRebootDialogOpen}
102+
linodeId={linodeId}
103+
onClose={() => setIsRebootDialogOpen(false)}
104+
/>
105+
</>
106+
);
107+
};
108+
109+
// The 'start_time' field might not be available, so fallback to 'when'
110+
const getMaintenanceStartTime = (
111+
maintenance: AccountMaintenance
112+
): null | string | undefined => maintenance.start_time ?? maintenance.when;

0 commit comments

Comments
 (0)