Skip to content

Commit 7ce5a12

Browse files
upcoming: [M3-9113] - Add Interface History table (#12321)
* new branch for changes * Added changeset: Add Linode Interface History table * add mock data * cleanup * feedback pt 1 * increase mock data amount to test pagination * fix routing, query goes through but don't see history * fix tests tanstack router update * fix some bugs * one more * fix types as per api updates * oops fix test * Added changeset: Update LinodeInterfaceHistory type as per API type changes * update loading column size * feature flag and update types * flag the drawer itself --------- Co-authored-by: Banks Nussman <[email protected]> Co-authored-by: Banks Nussman <[email protected]>
1 parent dd41397 commit 7ce5a12

File tree

16 files changed

+372
-21
lines changed

16 files changed

+372
-21
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": Upcoming Features
3+
---
4+
5+
Update LinodeInterfaceHistory type as per API type changes ([#12321](https://github.com/linode/manager/pull/12321))

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

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -342,16 +342,35 @@ export interface PublicInterfaceData {
342342
}
343343

344344
// Other Linode Interface types
345-
export type LinodeInterfaceStatus = 'active' | 'deleted' | 'inactive';
346345

347346
export interface LinodeInterfaceHistory {
347+
/**
348+
* When this version was created.
349+
*
350+
* @example 2025-09-16T15:01:32
351+
*/
348352
created: string;
349-
event_id: number;
350-
interface_data: string; // will come in as JSON string object that we'll need to parse
353+
/**
354+
* The JSON body returned in response to a successful PUT, POST, or DELETE operation on the interface.
355+
*/
356+
interface_data: LinodeInterface;
357+
/**
358+
* The unique ID for this history version.
359+
*/
351360
interface_history_id: number;
361+
/**
362+
* The network interface defined in the version.
363+
*/
352364
interface_id: number;
365+
/**
366+
* The Linode the interface_id belongs to.
367+
*/
353368
linode_id: number;
354-
status: LinodeInterfaceStatus;
369+
/**
370+
* The network interface's version.
371+
*
372+
* The first version from a POST is 1. The version number is incremented when the network interface configuration is changed.
373+
*/
355374
version: number;
356375
}
357376

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+
Add Linode Interface History table ([#12321](https://github.com/linode/manager/pull/12321))

packages/manager/src/featureFlags.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ interface LinodeInterfacesFlag extends BaseFeatureFlag {
5858
* Shows a Beta chip for UI elements related to Linode Interfaces
5959
*/
6060
beta?: boolean;
61+
/**
62+
* Enables the Interface History Table
63+
*/
64+
interface_history?: boolean;
6165
/**
6266
* Shows a New chip for UI elements related to Linode Interfaces
6367
*/

packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddresses.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ export const LinodeIPAddresses = (props: LinodeIPAddressesProps) => {
156156
from: '/linodes/$linodeId/networking',
157157
},
158158
preferenceKey: 'linode-ip-addresses',
159+
prefix: 'linode-ip-addresses',
159160
});
160161

161162
if (isLoading) {

packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/LinodeInterfaces.tsx

Lines changed: 39 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import { Box, Button, Drawer, Paper, Stack, Typography } from '@linode/ui';
2-
import { useNavigate, useParams } from '@tanstack/react-router';
2+
import { useLocation, useNavigate, useParams } from '@tanstack/react-router';
33
import React, { useState } from 'react';
44

5+
import { useFlags } from 'src/hooks/useFlags';
6+
57
import { AddInterfaceDrawer } from './AddInterfaceDrawer/AddInterfaceDrawer';
68
import { DeleteInterfaceDialog } from './DeleteInterfaceDialog';
79
import { EditInterfaceDrawerContents } from './EditInterfaceDrawer/EditInterfaceDrawerContent';
810
import { InterfaceDetailsDrawer } from './InterfaceDetailsDrawer/InterfaceDetailsDrawer';
911
import { InterfaceSettingsForm } from './InterfaceSettingsForm';
12+
import { HistoryDialog } from './LinodeInterfacesHistory/HistoryDialog';
1013
import { LinodeInterfacesTable } from './LinodeInterfacesTable';
1114

1215
interface Props {
@@ -16,9 +19,13 @@ interface Props {
1619

1720
export const LinodeInterfaces = ({ linodeId, regionId }: Props) => {
1821
const navigate = useNavigate();
19-
const { interfaceId } = useParams({
20-
strict: false,
21-
});
22+
const location = useLocation();
23+
const flags = useFlags();
24+
25+
const isHistoryTableEnabled =
26+
flags.linodeInterfaces?.interface_history ?? false;
27+
28+
const { interfaceId } = useParams({ strict: false });
2229

2330
const [isAddDrawerOpen, setIsAddDrawerOpen] = useState(false);
2431
const [isEditDrawerOpen, setIsEditDrawerOpen] = useState(false);
@@ -42,15 +49,13 @@ export const LinodeInterfaces = ({ linodeId, regionId }: Props) => {
4249
navigate({
4350
to: '/linodes/$linodeId/networking/interfaces/$interfaceId',
4451
params: { linodeId, interfaceId },
45-
search: {
46-
delete: undefined,
47-
migrate: undefined,
48-
rebuild: undefined,
49-
rescue: undefined,
50-
resize: undefined,
51-
selectedImageId: undefined,
52-
upgrade: undefined,
53-
},
52+
});
53+
};
54+
55+
const onShowHistory = () => {
56+
navigate({
57+
to: '/linodes/$linodeId/networking/history',
58+
params: { linodeId },
5459
});
5560
};
5661

@@ -68,7 +73,15 @@ export const LinodeInterfaces = ({ linodeId, regionId }: Props) => {
6873
>
6974
<Typography variant="h3">Network Interfaces</Typography>
7075
<Stack direction="row" spacing={1}>
71-
<Button onClick={() => setIsSettingsDrawerOpen(true)}>
76+
{isHistoryTableEnabled && (
77+
<Button buttonType="secondary" onClick={onShowHistory}>
78+
Interface History
79+
</Button>
80+
)}
81+
<Button
82+
buttonType={isHistoryTableEnabled ? 'outlined' : 'secondary'}
83+
onClick={() => setIsSettingsDrawerOpen(true)}
84+
>
7285
Interface Settings
7386
</Button>
7487
<Button buttonType="primary" onClick={() => setIsAddDrawerOpen(true)}>
@@ -104,6 +117,18 @@ export const LinodeInterfaces = ({ linodeId, regionId }: Props) => {
104117
}
105118
open={Boolean(interfaceId)}
106119
/>
120+
{isHistoryTableEnabled && (
121+
<HistoryDialog
122+
linodeId={linodeId}
123+
onClose={() =>
124+
navigate({
125+
to: '/linodes/$linodeId/networking',
126+
params: { linodeId },
127+
})
128+
}
129+
open={location.pathname.includes('networking/history')}
130+
/>
131+
)}
107132
<Drawer
108133
onClose={() => setIsEditDrawerOpen(false)}
109134
open={isEditDrawerOpen}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import userEvent from '@testing-library/user-event';
2+
import * as React from 'react';
3+
4+
import { renderWithTheme } from 'src/utilities/testHelpers';
5+
6+
import { HistoryDialog } from './HistoryDialog';
7+
8+
const props = {
9+
linodeId: 1,
10+
onClose: vi.fn(),
11+
open: true,
12+
};
13+
14+
describe('LinodeInterfacesHistoryDialog', () => {
15+
it('renders the LinodeInterfaceHistoryDialog', () => {
16+
const { getByText } = renderWithTheme(<HistoryDialog {...props} />);
17+
18+
expect(getByText('Network Interfaces History')).toBeVisible();
19+
expect(getByText('Created')).toBeVisible();
20+
expect(getByText('Interface ID')).toBeVisible();
21+
expect(getByText('Linode ID')).toBeVisible();
22+
expect(getByText('Version')).toBeVisible();
23+
expect(getByText('Close')).toBeVisible();
24+
});
25+
26+
it('closes the drawer', async () => {
27+
const { getByText } = renderWithTheme(<HistoryDialog {...props} />);
28+
29+
const closeButton = getByText('Close');
30+
await userEvent.click(closeButton);
31+
expect(props.onClose).toHaveBeenCalled();
32+
});
33+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { ActionsPanel, Dialog } from '@linode/ui';
2+
import * as React from 'react';
3+
4+
import { HistoryTable } from './HistoryTable';
5+
6+
interface Props {
7+
linodeId: number;
8+
onClose: () => void;
9+
open: boolean;
10+
}
11+
12+
export const HistoryDialog = (props: Props) => {
13+
const { linodeId, onClose, open } = props;
14+
15+
return (
16+
<Dialog
17+
fullWidth
18+
onClose={onClose}
19+
open={open}
20+
title="Network Interfaces History"
21+
>
22+
<HistoryTable linodeId={linodeId} />
23+
<ActionsPanel
24+
secondaryButtonProps={{
25+
label: 'Close',
26+
onClick: onClose,
27+
}}
28+
/>
29+
</Dialog>
30+
);
31+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { useLinodeInterfacesHistory } from '@linode/queries';
2+
import * as React from 'react';
3+
4+
import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter';
5+
import { Table } from 'src/components/Table';
6+
import { TableBody } from 'src/components/TableBody';
7+
import { TableCell } from 'src/components/TableCell';
8+
import { TableHead } from 'src/components/TableHead';
9+
import { TableRow } from 'src/components/TableRow';
10+
import { TableSortCell } from 'src/components/TableSortCell';
11+
import { useOrderV2 } from 'src/hooks/useOrderV2';
12+
import { usePaginationV2 } from 'src/hooks/usePaginationV2';
13+
14+
import { HistoryTableContent } from './HistoryTableContent';
15+
16+
const preferenceKey = 'linode-interface-history';
17+
18+
interface Props {
19+
linodeId: number;
20+
}
21+
22+
export const HistoryTable = (props: Props) => {
23+
const { linodeId } = props;
24+
25+
const pagination = usePaginationV2({
26+
currentRoute: '/linodes/$linodeId/networking/history',
27+
preferenceKey: `${preferenceKey}`,
28+
});
29+
30+
const { handleOrderChange, order, orderBy } = useOrderV2({
31+
initialRoute: {
32+
defaultOrder: {
33+
order: 'desc',
34+
orderBy: 'interface_id',
35+
},
36+
from: '/linodes/$linodeId/networking/history',
37+
},
38+
preferenceKey: `${preferenceKey}-order`,
39+
prefix: preferenceKey,
40+
});
41+
42+
const filter = {
43+
['+order']: order,
44+
['+order_by']: orderBy,
45+
};
46+
47+
const {
48+
data: interfaceHistory,
49+
error,
50+
isLoading,
51+
} = useLinodeInterfacesHistory(
52+
linodeId,
53+
{
54+
page: pagination.page,
55+
page_size: pagination.pageSize,
56+
},
57+
filter
58+
);
59+
60+
return (
61+
<>
62+
<Table>
63+
<TableHead>
64+
<TableRow>
65+
<TableCell>Created</TableCell>
66+
<TableSortCell
67+
active={orderBy === 'interface_id'}
68+
direction={order}
69+
handleClick={handleOrderChange}
70+
label={'interface_id'}
71+
>
72+
Interface ID
73+
</TableSortCell>
74+
<TableSortCell
75+
active={orderBy === 'linode_id'}
76+
direction={order}
77+
handleClick={handleOrderChange}
78+
label={'linode_id'}
79+
>
80+
Linode ID
81+
</TableSortCell>
82+
<TableSortCell
83+
active={orderBy === 'version'}
84+
direction={order}
85+
handleClick={handleOrderChange}
86+
label={'version'}
87+
>
88+
Version
89+
</TableSortCell>
90+
</TableRow>
91+
</TableHead>
92+
<TableBody>
93+
<HistoryTableContent
94+
data={interfaceHistory?.data}
95+
error={error}
96+
isLoading={isLoading}
97+
/>
98+
</TableBody>
99+
</Table>
100+
<PaginationFooter
101+
count={interfaceHistory?.results ?? 0}
102+
handlePageChange={pagination.handlePageChange}
103+
handleSizeChange={pagination.handlePageSizeChange}
104+
page={pagination.page}
105+
pageSize={pagination.pageSize}
106+
/>
107+
</>
108+
);
109+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import * as React from 'react';
2+
3+
import { DateTimeDisplay } from 'src/components/DateTimeDisplay';
4+
import { TableCell } from 'src/components/TableCell';
5+
import { TableRow } from 'src/components/TableRow';
6+
import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty';
7+
import { TableRowError } from 'src/components/TableRowError/TableRowError';
8+
import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading';
9+
10+
import type { APIError, LinodeInterfaceHistory } from '@linode/api-v4';
11+
12+
interface Props {
13+
data: LinodeInterfaceHistory[] | undefined;
14+
error: APIError[] | null;
15+
isLoading: boolean;
16+
}
17+
18+
export const HistoryTableContent = (props: Props) => {
19+
const { data, error, isLoading } = props;
20+
21+
const cols = 4;
22+
23+
if (isLoading) {
24+
return <TableRowLoading columns={cols} rows={1} />;
25+
}
26+
27+
if (error) {
28+
return <TableRowError colSpan={cols} message={error?.[0].reason} />;
29+
}
30+
31+
if (data?.length === 0) {
32+
return (
33+
<TableRowEmpty
34+
colSpan={cols}
35+
message="There is no network interface history for this Linode."
36+
/>
37+
);
38+
}
39+
40+
return data?.map((history) => (
41+
<TableRow key={history.interface_history_id}>
42+
<TableCell>
43+
<DateTimeDisplay value={history.created} />
44+
</TableCell>
45+
<TableCell>{history.interface_id}</TableCell>
46+
<TableCell>{history.linode_id}</TableCell>
47+
<TableCell>{history.version}</TableCell>
48+
</TableRow>
49+
));
50+
};

0 commit comments

Comments
 (0)