Skip to content

Commit 9755ea1

Browse files
authored
feat: Serial logs (#5672)
* feat: Serial logs * Co-pilot suggestions
1 parent c2b44ee commit 9755ea1

16 files changed

Lines changed: 560 additions & 40 deletions

File tree

requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ canonicalwebteam.discourse==7.2.0
66
canonicalwebteam.blog==6.8.4
77
canonicalwebteam.search==2.1.2
88
canonicalwebteam.image-template==1.9.0
9-
canonicalwebteam.store-api==7.4.10
9+
canonicalwebteam.store-api==7.7.0
1010
canonicalwebteam.launchpad==0.9.0
1111
django-openid-auth==0.17
1212
Flask-OpenID==1.3.1
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { QueryClient, QueryClientProvider } from "react-query";
2+
import { renderHook, waitFor } from "@testing-library/react";
3+
import { http, HttpResponse } from "msw";
4+
import { setupServer } from "msw/node";
5+
6+
import useSerialLogs from "../useSerialLogs";
7+
8+
import type { ReactNode } from "react";
9+
10+
const queryClient = new QueryClient({
11+
defaultOptions: {
12+
queries: {
13+
retry: false,
14+
},
15+
},
16+
});
17+
18+
const createWrapper = () => {
19+
return ({ children }: { children: ReactNode }) => (
20+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
21+
);
22+
};
23+
24+
const serialLogsResponse = {
25+
items: [
26+
{
27+
"brand-id": "test-brand-id",
28+
"created-at": "2023-09-05T11:55:53.732366",
29+
"model-name": "test-model",
30+
serial: "test-serial",
31+
"serial-assertion": "test-assertion",
32+
"serial-sign-key-sha3-384": "test-sha3",
33+
},
34+
],
35+
"next-cursor": null,
36+
};
37+
38+
const handlers = [
39+
http.get("/api/store/test-brand-id/models/test-model/serial-log", () => {
40+
return HttpResponse.json({
41+
data: serialLogsResponse,
42+
success: true,
43+
});
44+
}),
45+
http.get("/api/store/test-brand-id-fail/models/test-model/serial-log", () => {
46+
return HttpResponse.json({
47+
message: "There was a problem fetching serial logs",
48+
success: false,
49+
});
50+
}),
51+
http.get(
52+
"/api/store/test-brand-id-error/models/test-model/serial-log",
53+
() => {
54+
return HttpResponse.error();
55+
},
56+
),
57+
];
58+
59+
const server = setupServer(...handlers);
60+
61+
beforeAll(() => {
62+
server.listen();
63+
});
64+
65+
afterEach(() => {
66+
server.resetHandlers();
67+
queryClient.clear();
68+
});
69+
70+
afterAll(() => {
71+
server.close();
72+
});
73+
74+
describe("useSerialLogs", () => {
75+
test("returns serial logs data", async () => {
76+
const { result } = renderHook(
77+
() => useSerialLogs("test-brand-id", "test-model"),
78+
{
79+
wrapper: createWrapper(),
80+
},
81+
);
82+
83+
await waitFor(() => {
84+
expect(result.current.isSuccess).toBe(true);
85+
});
86+
87+
expect(result.current.data).toEqual({
88+
data: serialLogsResponse,
89+
success: true,
90+
});
91+
});
92+
93+
test("returns error if request fails", async () => {
94+
const { result } = renderHook(
95+
() => useSerialLogs("test-brand-id-fail", "test-model"),
96+
{
97+
wrapper: createWrapper(),
98+
},
99+
);
100+
101+
await waitFor(() => {
102+
expect(result.current.isSuccess).toBe(true);
103+
});
104+
105+
expect(result.current.data).toEqual({
106+
message: "There was a problem fetching serial logs",
107+
success: false,
108+
});
109+
});
110+
111+
test("returns error if network error", async () => {
112+
const { result } = renderHook(
113+
() => useSerialLogs("test-brand-id-error", "test-model"),
114+
{
115+
wrapper: createWrapper(),
116+
},
117+
);
118+
119+
await waitFor(() => {
120+
expect(result.current.isError).toBe(true);
121+
});
122+
123+
expect(result.current.data).toBeUndefined();
124+
});
125+
});

static/js/publisher/hooks/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import useFetchPublishedSnapMetrics from "./useFetchPublishedSnapMetrics";
1616
import useSortTableData from "./useSortTableData";
1717
import useAccountKeys from "./useAccountKeys";
1818
import useRemodels from "./useRemodels";
19+
import useSerialLogs from "./useSerialLogs";
1920

2021
export {
2122
useValidationSets,
@@ -36,4 +37,5 @@ export {
3637
useSortTableData,
3738
useAccountKeys,
3839
useRemodels,
40+
useSerialLogs,
3941
};
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { useQuery, UseQueryResult } from "react-query";
2+
import type { SerialLogResponse, ApiResponse } from "../types/shared";
3+
4+
const useSerialLogs = (
5+
brandId: string | undefined,
6+
modelId: string | undefined,
7+
): UseQueryResult<ApiResponse<SerialLogResponse>, Error> => {
8+
return useQuery<ApiResponse<SerialLogResponse>, Error>({
9+
queryKey: ["serials", brandId, modelId],
10+
queryFn: async () => {
11+
const response = await fetch(
12+
`/api/store/${brandId}/models/${modelId}/serial-log`,
13+
);
14+
const responseData = await response.json();
15+
16+
return responseData;
17+
},
18+
enabled: !!brandId,
19+
});
20+
};
21+
22+
export default useSerialLogs;

static/js/publisher/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ const ValidationSet = importComponent(() => import("./pages/ValidationSet"));
4141
const ValidationSets = importComponent(() => import("./pages/ValidationSets"));
4242
const AccountKeys = importComponent(() => import("./pages/AccountKeys"));
4343
const Remodel = importComponent(() => import("./pages/Remodel"));
44+
const SerialLog = importComponent(() => import("./pages/SerialLog"));
4445

4546
Sentry.init({
4647
dsn: window.SENTRY_DSN,
@@ -127,6 +128,7 @@ root.render(
127128
<Route path="policies/create" element={<Policies />} />
128129
<Route path="remodel" element={<Remodel />} />
129130
<Route path="remodel/configure" element={<Remodel />} />
131+
<Route path="serial-log" element={<SerialLog />} />
130132
</Route>
131133
</Route>
132134
<Route path="*" element={<Navigate to="../snaps" replace />} />

static/js/publisher/layouts/ModelDetailsPageLayout/ModelNav.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { brandIdState } from "../../state/brandStoreState";
77
function ModelNav({ sectionName }: { sectionName: string }): React.JSX.Element {
88
const { id, modelId } = useParams();
99
const brandId = useAtomValue(brandIdState);
10-
const { data } = useRemodels(brandId, modelId);
10+
const { data: remodelsData } = useRemodels(brandId, modelId);
1111

1212
return (
1313
<nav className="p-tabs">
@@ -32,7 +32,7 @@ function ModelNav({ sectionName }: { sectionName: string }): React.JSX.Element {
3232
Policies
3333
</Link>
3434
</li>
35-
{data?.success && (
35+
{remodelsData?.success && (
3636
<li className="p-tabs__item">
3737
<Link
3838
to={`/admin/${id}/models/${modelId}/remodel`}

static/js/publisher/layouts/ModelDetailsPageLayout/ModelPageLayout.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ function ModelDetailsPageLayout() {
88
pathname.endsWith("policies") || pathname.endsWith("policies/create");
99
const isRemodel =
1010
pathname.endsWith("remodel") || pathname.endsWith("remodel/configure");
11+
const isSerialLog = pathname.endsWith("serial-log");
1112

1213
const getSectionName = () => {
1314
if (isPolicies) {
@@ -18,6 +19,10 @@ function ModelDetailsPageLayout() {
1819
return "remodel";
1920
}
2021

22+
if (isSerialLog) {
23+
return "serial-log";
24+
}
25+
2126
return "overview";
2227
};
2328

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { useEffect } from "react";
2+
import { useAtomValue, useSetAtom } from "jotai";
3+
import { useParams, useSearchParams } from "react-router-dom";
4+
import { Notification, Icon, Row, Col } from "@canonical/react-components";
5+
6+
import { useSerialLogs } from "../../hooks";
7+
import {
8+
serialLogsListFilterState,
9+
serialLogsListState,
10+
} from "../../state/serialLogsState";
11+
import { brandIdState, brandStoreState } from "../../state/brandStoreState";
12+
import { setPageTitle } from "../../utils";
13+
14+
import Filter from "../../components/Filter";
15+
import SerialLogTable from "./SerialLogTable";
16+
17+
import type { UseQueryResult } from "react-query";
18+
import type { SerialLogResponse, ApiResponse } from "../../types/shared";
19+
20+
function SerialLog(): React.JSX.Element {
21+
const { id, modelId } = useParams();
22+
const brandId = useAtomValue(brandIdState);
23+
const {
24+
isLoading,
25+
isError,
26+
error,
27+
data,
28+
}: UseQueryResult<ApiResponse<SerialLogResponse>, Error> = useSerialLogs(
29+
brandId,
30+
modelId,
31+
);
32+
const setSerialLogs = useSetAtom(serialLogsListState);
33+
const setFilter = useSetAtom(serialLogsListFilterState);
34+
const brandStore = useAtomValue(brandStoreState(id));
35+
const [searchParams] = useSearchParams();
36+
37+
brandStore
38+
? setPageTitle(`Serial logs in ${brandStore.name}`)
39+
: setPageTitle("Serial logs");
40+
41+
useEffect(() => {
42+
if (!isLoading && !isError && data) {
43+
setSerialLogs(data.data?.items || []);
44+
setFilter(searchParams.get("filter") || "");
45+
}
46+
}, [isLoading, error, data, brandId, id]);
47+
48+
return (
49+
<>
50+
<div className="u-fixed-width u-flex-column u-flex-grow">
51+
{isError && error && (
52+
<Notification severity="negative">
53+
Error: {error.message}
54+
</Notification>
55+
)}
56+
{isLoading ? (
57+
<p>
58+
<Icon name="spinner" className="u-animation--spin" />
59+
&nbsp;Fetching serial logs...
60+
</p>
61+
) : data && data.success === false ? (
62+
<Notification severity="caution">
63+
{data.message || "Unable to fetch serial logs"}
64+
</Notification>
65+
) : (
66+
<>
67+
<Row>
68+
<Col size={6}>
69+
<Filter
70+
state={serialLogsListFilterState}
71+
label="Search serial logs"
72+
placeholder="Search serial logs"
73+
/>
74+
</Col>
75+
</Row>
76+
<div className="u-flex-column u-flex-grow">
77+
<SerialLogTable />
78+
</div>
79+
</>
80+
)}
81+
</div>
82+
</>
83+
);
84+
}
85+
86+
export default SerialLog;
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { useAtomValue } from "jotai";
2+
import { useParams } from "react-router-dom";
3+
import { MainTable, TablePagination } from "@canonical/react-components";
4+
import { format } from "date-fns";
5+
6+
import { brandStoreState } from "../../state/brandStoreState";
7+
import { filteredSerialLogsListState } from "../../state/serialLogsState";
8+
import { useSortTableData } from "../../hooks";
9+
10+
import type { SerialLog } from "../../types/shared";
11+
12+
function SerialLogTable(): React.JSX.Element {
13+
const { id } = useParams();
14+
const serialLogs = useAtomValue(filteredSerialLogsListState);
15+
const brandStore = useAtomValue(brandStoreState(id));
16+
17+
const headers = [
18+
{ content: "Brand" },
19+
{
20+
content: "Model",
21+
sortKey: "model-name",
22+
},
23+
{
24+
content: "Serial",
25+
sortKey: "serial",
26+
},
27+
{
28+
content: "Created date",
29+
className: "u-align--right",
30+
},
31+
];
32+
33+
const rows = serialLogs.map((serialLog: SerialLog) => {
34+
return {
35+
columns: [
36+
{ content: brandStore?.name },
37+
{ content: serialLog["model-name"] },
38+
{ content: serialLog.serial },
39+
{
40+
content: format(new Date(serialLog["created-at"]), "dd/MM/yyyy"),
41+
className: "u-align--right",
42+
},
43+
],
44+
};
45+
});
46+
47+
const { rows: sortedRows, updateSort } = useSortTableData({ rows });
48+
49+
return (
50+
<TablePagination
51+
data={sortedRows}
52+
pageLimits={[25, 50, 100, 200]}
53+
position="below"
54+
>
55+
<MainTable
56+
data-testid="serial-log-table"
57+
sortable
58+
emptyStateMsg="No serial logs match this filter"
59+
headers={headers}
60+
onUpdateSort={updateSort}
61+
/>
62+
</TablePagination>
63+
);
64+
}
65+
66+
export default SerialLogTable;

0 commit comments

Comments
 (0)