From efcc65810c375e72e2a7ccedc31be2a390c7ece4 Mon Sep 17 00:00:00 2001 From: nehjoshi5 Date: Thu, 23 Jul 2026 14:31:41 +0100 Subject: [PATCH 1/5] feat: add switch details view --- .../apiclient/@tanstack/react-query.gen.ts | 123 +++++++++ src/app/apiclient/sdk.gen.ts | 80 ++++++ src/app/apiclient/types.gen.ts | 255 ++++++++++++++++++ .../SwitchDetailsHeader.test.tsx | 34 +++ .../SwitchDetailsHeader.tsx | 41 +++ .../components/SwitchDetailsHeader/index.ts | 1 + .../SwitchDetailsSummary.test.tsx | 15 ++ .../SwitchDetailsSummary.tsx | 33 +++ .../SwitchDetailsSummary/_index.scss | 3 + .../components/SwitchDetailsSummary/index.ts | 1 + .../SwitchesTable/SwitchesTable.test.tsx | 16 +- .../SwitchesTable/SwitchesTable.tsx | 8 +- .../components/SwitchesTable/index.ts | 0 .../useSwitchesTableColumns.tsx | 52 ++-- src/app/switches/types.ts | 9 - src/app/switches/urls.ts | 2 + .../SwitchDetails/SwitchDetails.test.tsx | 36 +++ .../views/SwitchDetails/SwitchDetails.tsx | 58 ++++ src/app/switches/views/SwitchDetails/index.ts | 1 + .../views/SwitchesList/SwitchesList.tsx | 3 +- .../components/SwitchesTable/_index.scss | 7 - src/router.tsx | 9 + 22 files changed, 724 insertions(+), 63 deletions(-) create mode 100644 src/app/switches/components/SwitchDetailsHeader/SwitchDetailsHeader.test.tsx create mode 100644 src/app/switches/components/SwitchDetailsHeader/SwitchDetailsHeader.tsx create mode 100644 src/app/switches/components/SwitchDetailsHeader/index.ts create mode 100644 src/app/switches/components/SwitchDetailsSummary/SwitchDetailsSummary.test.tsx create mode 100644 src/app/switches/components/SwitchDetailsSummary/SwitchDetailsSummary.tsx create mode 100644 src/app/switches/components/SwitchDetailsSummary/_index.scss create mode 100644 src/app/switches/components/SwitchDetailsSummary/index.ts rename src/app/switches/{views/SwitchesList => }/components/SwitchesTable/SwitchesTable.test.tsx (81%) rename src/app/switches/{views/SwitchesList => }/components/SwitchesTable/SwitchesTable.tsx (82%) rename src/app/switches/{views/SwitchesList => }/components/SwitchesTable/index.ts (100%) rename src/app/switches/{views/SwitchesList => }/components/SwitchesTable/useSwitchesTableColumns/useSwitchesTableColumns.tsx (63%) delete mode 100644 src/app/switches/types.ts create mode 100644 src/app/switches/views/SwitchDetails/SwitchDetails.test.tsx create mode 100644 src/app/switches/views/SwitchDetails/SwitchDetails.tsx create mode 100644 src/app/switches/views/SwitchDetails/index.ts delete mode 100644 src/app/switches/views/SwitchesList/components/SwitchesTable/_index.scss diff --git a/src/app/apiclient/@tanstack/react-query.gen.ts b/src/app/apiclient/@tanstack/react-query.gen.ts index 3ab1bacb79..2c33d9d77b 100644 --- a/src/app/apiclient/@tanstack/react-query.gen.ts +++ b/src/app/apiclient/@tanstack/react-query.gen.ts @@ -38,6 +38,7 @@ import { createResourcePool, createSession, createSpace, + createSshHostKey, createSwitch, createTag, createUser, @@ -63,6 +64,7 @@ import { deleteRacks, deleteResourcePool, deleteSpace, + deleteSshHostKey, deleteSwitch, deleteTag, deleteUser, @@ -114,6 +116,7 @@ import { getSelectionStatistic, getSelectionStatus, getSpace, + getSshHostKey, getSubnet, getSwitch, getTag, @@ -165,6 +168,7 @@ import { listSelectionStatistic, listSelectionStatus, listSpaces, + listSshHostKeys, listSubnets, listSwitches, listTags, @@ -307,6 +311,9 @@ import type { CreateSpaceData, CreateSpaceError, CreateSpaceResponse, + CreateSshHostKeyData, + CreateSshHostKeyError, + CreateSshHostKeyResponse, CreateSwitchData, CreateSwitchError, CreateSwitchResponse, @@ -382,6 +389,9 @@ import type { DeleteSpaceData, DeleteSpaceError, DeleteSpaceResponse, + DeleteSshHostKeyData, + DeleteSshHostKeyError, + DeleteSshHostKeyResponse, DeleteSwitchData, DeleteSwitchError, DeleteSwitchResponse, @@ -533,6 +543,9 @@ import type { GetSpaceData, GetSpaceError, GetSpaceResponse, + GetSshHostKeyData, + GetSshHostKeyError, + GetSshHostKeyResponse, GetSubnetData, GetSubnetError, GetSubnetResponse, @@ -686,6 +699,9 @@ import type { ListSpacesData, ListSpacesError, ListSpacesResponse, + ListSshHostKeysData, + ListSshHostKeysError, + ListSshHostKeysResponse, ListSubnetsData, ListSubnetsError, ListSubnetsResponse, @@ -4704,6 +4720,113 @@ export const listUserSslkeysStatisticsOptions = ( queryKey: listUserSslkeysStatisticsQueryKey(options), }); +export const listSshHostKeysQueryKey = ( + options?: Options +) => createQueryKey("listSshHostKeys", options); + +/** + * List Ssh Host Keys + */ +export const listSshHostKeysOptions = ( + options?: Options +) => + queryOptions< + ListSshHostKeysResponse, + ListSshHostKeysError, + ListSshHostKeysResponse, + ReturnType + >({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await listSshHostKeys({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: listSshHostKeysQueryKey(options), + }); + +/** + * Create Ssh Host Key + */ +export const createSshHostKeyMutation = ( + options?: Partial> +): UseMutationOptions< + CreateSshHostKeyResponse, + CreateSshHostKeyError, + Options +> => { + const mutationOptions: UseMutationOptions< + CreateSshHostKeyResponse, + CreateSshHostKeyError, + Options + > = { + mutationFn: async (fnOptions) => { + const { data } = await createSshHostKey({ + ...options, + ...fnOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; + +/** + * Delete Ssh Host Key + */ +export const deleteSshHostKeyMutation = ( + options?: Partial> +): UseMutationOptions< + DeleteSshHostKeyResponse, + DeleteSshHostKeyError, + Options +> => { + const mutationOptions: UseMutationOptions< + DeleteSshHostKeyResponse, + DeleteSshHostKeyError, + Options + > = { + mutationFn: async (fnOptions) => { + const { data } = await deleteSshHostKey({ + ...options, + ...fnOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; + +export const getSshHostKeyQueryKey = (options: Options) => + createQueryKey("getSshHostKey", options); + +/** + * Get Ssh Host Key + */ +export const getSshHostKeyOptions = (options: Options) => + queryOptions< + GetSshHostKeyResponse, + GetSshHostKeyError, + GetSshHostKeyResponse, + ReturnType + >({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getSshHostKey({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getSshHostKeyQueryKey(options), + }); + export const listFabricVlanSubnetsQueryKey = ( options: Options ) => createQueryKey("listFabricVlanSubnets", options); diff --git a/src/app/apiclient/sdk.gen.ts b/src/app/apiclient/sdk.gen.ts index 8c8d93fb76..62e1d11b99 100644 --- a/src/app/apiclient/sdk.gen.ts +++ b/src/app/apiclient/sdk.gen.ts @@ -111,6 +111,9 @@ import type { CreateSpaceData, CreateSpaceErrors, CreateSpaceResponses, + CreateSshHostKeyData, + CreateSshHostKeyErrors, + CreateSshHostKeyResponses, CreateSwitchData, CreateSwitchErrors, CreateSwitchResponses, @@ -186,6 +189,9 @@ import type { DeleteSpaceData, DeleteSpaceErrors, DeleteSpaceResponses, + DeleteSshHostKeyData, + DeleteSshHostKeyErrors, + DeleteSshHostKeyResponses, DeleteSwitchData, DeleteSwitchErrors, DeleteSwitchResponses, @@ -339,6 +345,9 @@ import type { GetSpaceData, GetSpaceErrors, GetSpaceResponses, + GetSshHostKeyData, + GetSshHostKeyErrors, + GetSshHostKeyResponses, GetSubnetData, GetSubnetErrors, GetSubnetResponses, @@ -492,6 +501,9 @@ import type { ListSpacesData, ListSpacesErrors, ListSpacesResponses, + ListSshHostKeysData, + ListSshHostKeysErrors, + ListSshHostKeysResponses, ListSubnetsData, ListSubnetsErrors, ListSubnetsResponses, @@ -3113,6 +3125,74 @@ export const listUserSslkeysStatistics = ( ...options, }); +/** + * List Ssh Host Keys + */ +export const listSshHostKeys = ( + options?: Options +) => + (options?.client ?? client).get< + ListSshHostKeysResponses, + ListSshHostKeysErrors, + ThrowOnError + >({ + security: [{ scheme: "bearer", type: "http" }], + url: "/MAAS/a/v3/ssh-host-keys", + ...options, + }); + +/** + * Create Ssh Host Key + */ +export const createSshHostKey = ( + options: Options +) => + (options.client ?? client).post< + CreateSshHostKeyResponses, + CreateSshHostKeyErrors, + ThrowOnError + >({ + security: [{ scheme: "bearer", type: "http" }], + url: "/MAAS/a/v3/ssh-host-keys", + ...options, + headers: { + "Content-Type": "application/json", + ...options.headers, + }, + }); + +/** + * Delete Ssh Host Key + */ +export const deleteSshHostKey = ( + options: Options +) => + (options.client ?? client).delete< + DeleteSshHostKeyResponses, + DeleteSshHostKeyErrors, + ThrowOnError + >({ + security: [{ scheme: "bearer", type: "http" }], + url: "/MAAS/a/v3/ssh-host-keys/{ssh_host_key_id}", + ...options, + }); + +/** + * Get Ssh Host Key + */ +export const getSshHostKey = ( + options: Options +) => + (options.client ?? client).get< + GetSshHostKeyResponses, + GetSshHostKeyErrors, + ThrowOnError + >({ + security: [{ scheme: "bearer", type: "http" }], + url: "/MAAS/a/v3/ssh-host-keys/{ssh_host_key_id}", + ...options, + }); + /** * List Fabric Vlan Subnets */ diff --git a/src/app/apiclient/types.gen.ts b/src/app/apiclient/types.gen.ts index 1c707b94cb..9901764db6 100644 --- a/src/app/apiclient/types.gen.ts +++ b/src/app/apiclient/types.gen.ts @@ -3830,6 +3830,101 @@ export type SpacesListResponse = { kind?: string; }; +/** + * SshHostKeyRequest + */ +export type SshHostKeyRequest = { + /** + * Host + * + * The hostname or IP address. + */ + host: string; + /** + * Key Type + * + * The SSH key type (e.g. ssh-rsa). + */ + key_type: string; + /** + * Public Key + * + * The Base64-encoded public key. + */ + public_key: string; + /** + * Label + * + * An optional human-readable label. + */ + label?: string; +}; + +/** + * SshHostKeyResponse + */ +export type SshHostKeyResponse = { + _links?: BaseHal; + /** + * Embedded + */ + _embedded?: Record; + /** + * Kind + */ + kind?: string; + /** + * Id + */ + id: number; + /** + * Created + */ + created: string; + /** + * Updated + */ + updated: string; + /** + * Host + */ + host: string; + /** + * Key Type + */ + key_type: string; + /** + * Public Key + */ + public_key: string; + /** + * Label + */ + label?: string; +}; + +/** + * SshHostKeysListResponse + */ +export type SshHostKeysListResponse = { + /** + * Items + */ + items: SshHostKeyResponse[]; + /** + * Total + */ + total: number; + /** + * Next + */ + next?: string; + /** + * Kind + */ + kind?: string; +}; + /** * SshKeyImportFromSourceRequest */ @@ -4146,6 +4241,10 @@ export type SwitchRequest = { * Mac Address */ mac_address: string; + /** + * Name + */ + name?: string; /** * Image * @@ -4175,6 +4274,14 @@ export type SwitchResponse = { * Id */ id: number; + /** + * Name + */ + name?: string; + /** + * Management Mac + */ + management_mac?: string; /** * Target Image Id */ @@ -4191,6 +4298,10 @@ export type SwitchResponse = { * Request model for updating a switch. */ export type SwitchUpdateRequest = { + /** + * Name + */ + name?: string; /** * Image * @@ -11000,6 +11111,150 @@ export type ListUserSslkeysStatisticsResponses = { export type ListUserSslkeysStatisticsResponse = ListUserSslkeysStatisticsResponses[keyof ListUserSslkeysStatisticsResponses]; +export type ListSshHostKeysData = { + body?: never; + path?: never; + query?: { + /** + * Page + */ + page?: number; + /** + * Size + */ + size?: number; + }; + url: "/MAAS/a/v3/ssh-host-keys"; +}; + +export type ListSshHostKeysErrors = { + /** + * Unprocessable Content + */ + 422: ValidationErrorBodyResponse; +}; + +export type ListSshHostKeysError = + ListSshHostKeysErrors[keyof ListSshHostKeysErrors]; + +export type ListSshHostKeysResponses = { + /** + * Successful Response + */ + 200: SshHostKeysListResponse; +}; + +export type ListSshHostKeysResponse = + ListSshHostKeysResponses[keyof ListSshHostKeysResponses]; + +export type CreateSshHostKeyData = { + body: SshHostKeyRequest; + path?: never; + query?: never; + url: "/MAAS/a/v3/ssh-host-keys"; +}; + +export type CreateSshHostKeyErrors = { + /** + * Unprocessable Content + */ + 422: ValidationErrorBodyResponse; +}; + +export type CreateSshHostKeyError = + CreateSshHostKeyErrors[keyof CreateSshHostKeyErrors]; + +export type CreateSshHostKeyResponses = { + /** + * Successful Response + */ + 201: SshHostKeyResponse; +}; + +export type CreateSshHostKeyResponse = + CreateSshHostKeyResponses[keyof CreateSshHostKeyResponses]; + +export type DeleteSshHostKeyData = { + body?: never; + headers?: { + /** + * If-Match + */ + "if-match"?: string; + }; + path: { + /** + * Ssh Host Key Id + */ + ssh_host_key_id: number; + }; + query?: never; + url: "/MAAS/a/v3/ssh-host-keys/{ssh_host_key_id}"; +}; + +export type DeleteSshHostKeyErrors = { + /** + * Not Found + */ + 404: NotFoundBodyResponse; + /** + * Precondition Failed + */ + 412: PreconditionFailedBodyResponse; + /** + * Unprocessable Content + */ + 422: ValidationErrorBodyResponse; +}; + +export type DeleteSshHostKeyError = + DeleteSshHostKeyErrors[keyof DeleteSshHostKeyErrors]; + +export type DeleteSshHostKeyResponses = { + /** + * Successful Response + */ + 204: void; +}; + +export type DeleteSshHostKeyResponse = + DeleteSshHostKeyResponses[keyof DeleteSshHostKeyResponses]; + +export type GetSshHostKeyData = { + body?: never; + path: { + /** + * Ssh Host Key Id + */ + ssh_host_key_id: number; + }; + query?: never; + url: "/MAAS/a/v3/ssh-host-keys/{ssh_host_key_id}"; +}; + +export type GetSshHostKeyErrors = { + /** + * Not Found + */ + 404: NotFoundBodyResponse; + /** + * Unprocessable Content + */ + 422: ValidationErrorBodyResponse; +}; + +export type GetSshHostKeyError = GetSshHostKeyErrors[keyof GetSshHostKeyErrors]; + +export type GetSshHostKeyResponses = { + /** + * Successful Response + */ + 200: SshHostKeyResponse; +}; + +export type GetSshHostKeyResponse = + GetSshHostKeyResponses[keyof GetSshHostKeyResponses]; + export type ListFabricVlanSubnetsData = { body?: never; path: { diff --git a/src/app/switches/components/SwitchDetailsHeader/SwitchDetailsHeader.test.tsx b/src/app/switches/components/SwitchDetailsHeader/SwitchDetailsHeader.test.tsx new file mode 100644 index 0000000000..a6ce57c259 --- /dev/null +++ b/src/app/switches/components/SwitchDetailsHeader/SwitchDetailsHeader.test.tsx @@ -0,0 +1,34 @@ +import SwitchDetailsHeader from "./SwitchDetailsHeader"; + +import type { SwitchResponse } from "@/app/apiclient"; +import { renderWithProviders, screen } from "@/testing/utils"; + +const switchItem: SwitchResponse = { + id: 1, + name: "switch-1", +}; + +describe("SwitchDetailsHeader", () => { + it("displays the switch name", () => { + renderWithProviders(, { + initialEntries: ["/switches/1/summary"], + pattern: "/switches/:id/*", + }); + + expect( + screen.getByRole("heading", { name: "switch-1" }) + ).toBeInTheDocument(); + }); + + it("renders a summary tab link", () => { + renderWithProviders(, { + initialEntries: ["/switches/1/summary"], + pattern: "/switches/:id/*", + }); + + expect(screen.getByRole("link", { name: "Summary" })).toHaveAttribute( + "href", + "/switches/1/summary" + ); + }); +}); diff --git a/src/app/switches/components/SwitchDetailsHeader/SwitchDetailsHeader.tsx b/src/app/switches/components/SwitchDetailsHeader/SwitchDetailsHeader.tsx new file mode 100644 index 0000000000..7ad162b388 --- /dev/null +++ b/src/app/switches/components/SwitchDetailsHeader/SwitchDetailsHeader.tsx @@ -0,0 +1,41 @@ +import { Link, useLocation } from "react-router"; + +import type { SwitchResponse } from "@/app/apiclient"; +import SectionHeader from "@/app/base/components/SectionHeader"; + +type Props = { + switchItem: SwitchResponse; +}; + +const SwitchDetailsHeader = ({ switchItem }: Props) => { + const { pathname } = useLocation(); + const urlBase = `/switches/${switchItem.id}`; + return ( + + ); +}; + +export default SwitchDetailsHeader; diff --git a/src/app/switches/components/SwitchDetailsHeader/index.ts b/src/app/switches/components/SwitchDetailsHeader/index.ts new file mode 100644 index 0000000000..e26d651cda --- /dev/null +++ b/src/app/switches/components/SwitchDetailsHeader/index.ts @@ -0,0 +1 @@ +export { default } from "./SwitchDetailsHeader"; diff --git a/src/app/switches/components/SwitchDetailsSummary/SwitchDetailsSummary.test.tsx b/src/app/switches/components/SwitchDetailsSummary/SwitchDetailsSummary.test.tsx new file mode 100644 index 0000000000..657ddc6b60 --- /dev/null +++ b/src/app/switches/components/SwitchDetailsSummary/SwitchDetailsSummary.test.tsx @@ -0,0 +1,15 @@ +import SwitchDetailsSummary from "./SwitchDetailsSummary"; + +import { switchResolvers } from "@/testing/resolvers/switches"; +import { renderWithProviders, screen, setupMockServer } from "@/testing/utils"; + +setupMockServer(switchResolvers.getSwitch.handler()); + +describe("SwitchDetailsSummary", () => { + it("renders the image and mac address cards", () => { + renderWithProviders(); + + expect(screen.getByText("Image")).toBeInTheDocument(); + expect(screen.getByText("Mac Address")).toBeInTheDocument(); + }); +}); diff --git a/src/app/switches/components/SwitchDetailsSummary/SwitchDetailsSummary.tsx b/src/app/switches/components/SwitchDetailsSummary/SwitchDetailsSummary.tsx new file mode 100644 index 0000000000..4c93ef0a17 --- /dev/null +++ b/src/app/switches/components/SwitchDetailsSummary/SwitchDetailsSummary.tsx @@ -0,0 +1,33 @@ +import { Card, Col, Row } from "@canonical/react-components"; + +import "./_index.scss"; +import { useGetSwitch } from "@/app/api/query/switches"; + +type Props = { + id: number; +}; + +const SwitchDetailsSummary = ({ id }: Props) => { + const { data: details } = useGetSwitch({ path: { switch_id: id } }); + + return ( + + + + Image +

{details?.target_image}

+
+ + + + + Mac Address + +

{details?.management_mac}

+
+ +
+ ); +}; + +export default SwitchDetailsSummary; diff --git a/src/app/switches/components/SwitchDetailsSummary/_index.scss b/src/app/switches/components/SwitchDetailsSummary/_index.scss new file mode 100644 index 0000000000..7b91a329b4 --- /dev/null +++ b/src/app/switches/components/SwitchDetailsSummary/_index.scss @@ -0,0 +1,3 @@ +.switch-details-summary__card { + height: 100%; +} diff --git a/src/app/switches/components/SwitchDetailsSummary/index.ts b/src/app/switches/components/SwitchDetailsSummary/index.ts new file mode 100644 index 0000000000..4a931cfc44 --- /dev/null +++ b/src/app/switches/components/SwitchDetailsSummary/index.ts @@ -0,0 +1 @@ +export { default } from "./SwitchDetailsSummary"; diff --git a/src/app/switches/views/SwitchesList/components/SwitchesTable/SwitchesTable.test.tsx b/src/app/switches/components/SwitchesTable/SwitchesTable.test.tsx similarity index 81% rename from src/app/switches/views/SwitchesList/components/SwitchesTable/SwitchesTable.test.tsx rename to src/app/switches/components/SwitchesTable/SwitchesTable.test.tsx index 77ab790e90..0f678ace74 100644 --- a/src/app/switches/views/SwitchesList/components/SwitchesTable/SwitchesTable.test.tsx +++ b/src/app/switches/components/SwitchesTable/SwitchesTable.test.tsx @@ -36,15 +36,13 @@ describe("SwitchesTable", () => { it("displays the columns correctly", () => { renderWithProviders(); - ["Name", "Status", "Image", "ZTP enabled", "Actions"].forEach( - (column) => { - expect( - screen.getByRole("columnheader", { - name: new RegExp(`^${column}`, "i"), - }) - ).toBeInTheDocument(); - } - ); + ["Name", "Image", "Actions"].forEach((column) => { + expect( + screen.getByRole("columnheader", { + name: new RegExp(`^${column}`, "i"), + }) + ).toBeInTheDocument(); + }); }); }); diff --git a/src/app/switches/views/SwitchesList/components/SwitchesTable/SwitchesTable.tsx b/src/app/switches/components/SwitchesTable/SwitchesTable.tsx similarity index 82% rename from src/app/switches/views/SwitchesList/components/SwitchesTable/SwitchesTable.tsx rename to src/app/switches/components/SwitchesTable/SwitchesTable.tsx index c1b924f8c8..25c2ac7698 100644 --- a/src/app/switches/views/SwitchesList/components/SwitchesTable/SwitchesTable.tsx +++ b/src/app/switches/components/SwitchesTable/SwitchesTable.tsx @@ -4,15 +4,11 @@ import useSwitchesTableColumns from "./useSwitchesTableColumns/useSwitchesTableC import { useSwitches } from "@/app/api/query/switches"; import usePagination from "@/app/base/hooks/usePagination/usePagination"; -import "./_index.scss"; const SwitchesTable = () => { - const { page, debouncedPage, size, handlePageSizeChange, setPage } = - usePagination(); + const { page, size, handlePageSizeChange, setPage } = usePagination(); - const switches = useSwitches({ - query: { page: debouncedPage, size }, - }); + const switches = useSwitches({ query: { page, size } }); const columns = useSwitchesTableColumns(); diff --git a/src/app/switches/views/SwitchesList/components/SwitchesTable/index.ts b/src/app/switches/components/SwitchesTable/index.ts similarity index 100% rename from src/app/switches/views/SwitchesList/components/SwitchesTable/index.ts rename to src/app/switches/components/SwitchesTable/index.ts diff --git a/src/app/switches/views/SwitchesList/components/SwitchesTable/useSwitchesTableColumns/useSwitchesTableColumns.tsx b/src/app/switches/components/SwitchesTable/useSwitchesTableColumns/useSwitchesTableColumns.tsx similarity index 63% rename from src/app/switches/views/SwitchesList/components/SwitchesTable/useSwitchesTableColumns/useSwitchesTableColumns.tsx rename to src/app/switches/components/SwitchesTable/useSwitchesTableColumns/useSwitchesTableColumns.tsx index ae0bea8252..ded0791d79 100644 --- a/src/app/switches/views/SwitchesList/components/SwitchesTable/useSwitchesTableColumns/useSwitchesTableColumns.tsx +++ b/src/app/switches/components/SwitchesTable/useSwitchesTableColumns/useSwitchesTableColumns.tsx @@ -2,12 +2,14 @@ import { useMemo } from "react"; import { Button, Icon } from "@canonical/react-components"; import type { ColumnDef } from "@tanstack/react-table"; +import { Link } from "react-router"; +import type { SwitchResponse } from "@/app/apiclient"; import DoubleRow from "@/app/base/components/DoubleRow"; import TableActions from "@/app/base/components/TableActions"; -import type { SwitchItem } from "@/app/switches/types"; +// import type { SwitchItem } from "@/app/switches/types"; -type SwitchColumnDef = ColumnDef; +type SwitchColumnDef = ColumnDef; const useSwitchesTableColumns = (): SwitchColumnDef[] => { return useMemo( @@ -39,22 +41,27 @@ const useSwitchesTableColumns = (): SwitchColumnDef[] => { MAC address ), - cell: ({ row }) => ( + cell: ({ + row: { + original: { name, management_mac, id }, + }, + }) => ( {name ?? "—"}} + primaryTitle={name} + secondary={management_mac ?? "—"} + secondaryTitle={management_mac} /> ), }, - { - id: "status", - accessorKey: "status", - enableSorting: false, - header: "Status", - cell: ({ row }) => {row.original.status ?? "—"}, - }, + // Add when we have the status field in the API response + // { + // id: "status", + // accessorKey: "status", + // enableSorting: false, + // header: "Status", + // cell: ({ row }) => {row.original.status ?? "—"}, + // }, { id: "target_image", accessorKey: "target_image", @@ -62,23 +69,6 @@ const useSwitchesTableColumns = (): SwitchColumnDef[] => { header: "Image", cell: ({ row }) => {row.original.target_image ?? "—"}, }, - { - id: "ztp_enabled", - accessorKey: "ztp_enabled", - enableSorting: false, - header: "ZTP enabled", - cell: ({ row }) => { - const enabled = row.original.ztp_enabled; - if (enabled === undefined || enabled === null) { - return ; - } - return ( - - {enabled ? "Yes" : "No"} - - ); - }, - }, { id: "actions", accessorKey: "id", diff --git a/src/app/switches/types.ts b/src/app/switches/types.ts deleted file mode 100644 index cb99c38e93..0000000000 --- a/src/app/switches/types.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { SwitchResponse } from "@/app/apiclient"; - -// TODO: Remove SwitchItem and use SwitchResponse directly once the API includes these fields. -export type SwitchItem = SwitchResponse & { - name?: string; - mac_address?: string; - status?: string; - ztp_enabled?: boolean; -}; diff --git a/src/app/switches/urls.ts b/src/app/switches/urls.ts index 07e5d43886..8cb9a1f00f 100644 --- a/src/app/switches/urls.ts +++ b/src/app/switches/urls.ts @@ -1,5 +1,7 @@ const urls = { index: "/switches", + details: (id: string) => `/switches/${id}`, + detailsSummary: (id: string) => `/switches/${id}/summary`, }; export default urls; diff --git a/src/app/switches/views/SwitchDetails/SwitchDetails.test.tsx b/src/app/switches/views/SwitchDetails/SwitchDetails.test.tsx new file mode 100644 index 0000000000..ef9ee92336 --- /dev/null +++ b/src/app/switches/views/SwitchDetails/SwitchDetails.test.tsx @@ -0,0 +1,36 @@ +import SwitchDetails from "./SwitchDetails"; + +import { mockSwitches, switchResolvers } from "@/testing/resolvers/switches"; +import { + renderWithProviders, + screen, + setupMockServer, + waitFor, +} from "@/testing/utils"; + +setupMockServer(switchResolvers.getSwitch.handler()); + +describe("SwitchDetails", () => { + it("displays the switch name once loaded", async () => { + const switchItem = mockSwitches.items[0]; + renderWithProviders(, { + initialEntries: [`/switches/${switchItem.id}/summary`], + pattern: "/switches/:id/*", + }); + + await waitFor(() => { + expect( + screen.getByRole("heading", { name: switchItem.name }) + ).toBeInTheDocument(); + }); + }); + + it("displays a not found message for an invalid id", () => { + renderWithProviders(, { + initialEntries: ["/switches/invalid/summary"], + pattern: "/switches/:id/*", + }); + + expect(screen.getByText(/switch not found/i)).toBeInTheDocument(); + }); +}); diff --git a/src/app/switches/views/SwitchDetails/SwitchDetails.tsx b/src/app/switches/views/SwitchDetails/SwitchDetails.tsx new file mode 100644 index 0000000000..a874e6c7ea --- /dev/null +++ b/src/app/switches/views/SwitchDetails/SwitchDetails.tsx @@ -0,0 +1,58 @@ +import { Navigate, Route, Routes } from "react-router"; + +import SwitchDetailsHeader from "../../components/SwitchDetailsHeader"; +import SwitchDetailsSummary from "../../components/SwitchDetailsSummary"; +import urls from "../../urls"; + +import { useGetSwitch } from "@/app/api/query/switches"; +import ModelNotFound from "@/app/base/components/ModelNotFound"; +import PageContent from "@/app/base/components/PageContent"; +import { useGetURLId, useWindowTitle } from "@/app/base/hooks"; +import { isId } from "@/app/utils"; + +const SwitchDetails = () => { + const switchId = useGetURLId("id"); + + const isValidId = isId(switchId); + + useWindowTitle("Switch Details"); + + const { data: switchDetails } = useGetSwitch({ + path: { switch_id: switchId! }, + }); + + if (!isValidId || !switchDetails) { + return ( + + ); + } + + return ( + + } + > + + } + path="summary" + /> + + } + index + /> + + + ); +}; + +export default SwitchDetails; diff --git a/src/app/switches/views/SwitchDetails/index.ts b/src/app/switches/views/SwitchDetails/index.ts new file mode 100644 index 0000000000..84e611f44b --- /dev/null +++ b/src/app/switches/views/SwitchDetails/index.ts @@ -0,0 +1 @@ +export { default } from "./SwitchDetails"; diff --git a/src/app/switches/views/SwitchesList/SwitchesList.tsx b/src/app/switches/views/SwitchesList/SwitchesList.tsx index 197bc37cd9..06634c59d2 100644 --- a/src/app/switches/views/SwitchesList/SwitchesList.tsx +++ b/src/app/switches/views/SwitchesList/SwitchesList.tsx @@ -1,7 +1,8 @@ import { useState, type ReactElement } from "react"; +import SwitchesTable from "../../components/SwitchesTable"; + import SwitchesListHeader from "./SwitchesListHeader"; -import SwitchesTable from "./components/SwitchesTable"; import PageContent from "@/app/base/components/PageContent"; import { useWindowTitle } from "@/app/base/hooks"; diff --git a/src/app/switches/views/SwitchesList/components/SwitchesTable/_index.scss b/src/app/switches/views/SwitchesList/components/SwitchesTable/_index.scss deleted file mode 100644 index e4ba14052d..0000000000 --- a/src/app/switches/views/SwitchesList/components/SwitchesTable/_index.scss +++ /dev/null @@ -1,7 +0,0 @@ -.p-generic-table.switches-table { - td { - &.ztp_enabled { - text-align: center; - } - } -} diff --git a/src/router.tsx b/src/router.tsx index 344e2e6732..85af02baa1 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -120,6 +120,7 @@ const Networks = lazy(() => import("@/app/networks")); const PoolsList = lazy(() => import("@/app/pools/views/PoolsList")); const RacksList = lazy(() => import("@/app/racks/views/RacksList")); const SwitchesList = lazy(() => import("@/app/switches/views/SwitchesList")); +const SwitchDetails = lazy(() => import("@/app/switches/views/SwitchDetails")); const Settings = lazy(() => import("@/app/settings/views/Settings")); const FabricDetails = lazy( () => import("@/app/networks/views/Fabrics/views/FabricDetails") @@ -647,6 +648,14 @@ export const router = createBrowserRouter( ), }, + { + path: `${urls.switches.details(":id")}/*`, + element: ( + + + + ), + }, { path: urls.settings.index, element: , From 7ed3b82f04d30da8db82b345227cc654d7f460b8 Mon Sep 17 00:00:00 2001 From: nehjoshi5 Date: Thu, 23 Jul 2026 14:43:28 +0100 Subject: [PATCH 2/5] fix: update resolver --- src/testing/factories/switches.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/testing/factories/switches.ts b/src/testing/factories/switches.ts index 9afb4a96e3..115d722d18 100644 --- a/src/testing/factories/switches.ts +++ b/src/testing/factories/switches.ts @@ -5,9 +5,9 @@ import { uniqueNamesGenerator, } from "unique-names-generator"; -import type { SwitchItem } from "@/app/switches/types"; +import { SwitchResponse } from "@/app/apiclient"; -export const switchFactory = Factory.define(({ sequence }) => { +export const switchFactory = Factory.define(({ sequence }) => { const name = uniqueNamesGenerator({ dictionaries: [adjectives, animals], separator: "-", From 98702459c2e475704899d40e9ca1a559d492267b Mon Sep 17 00:00:00 2001 From: nehjoshi5 Date: Thu, 23 Jul 2026 14:48:09 +0100 Subject: [PATCH 3/5] fix: lint --- src/testing/factories/switches.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/testing/factories/switches.ts b/src/testing/factories/switches.ts index 115d722d18..22b1b302d8 100644 --- a/src/testing/factories/switches.ts +++ b/src/testing/factories/switches.ts @@ -5,7 +5,7 @@ import { uniqueNamesGenerator, } from "unique-names-generator"; -import { SwitchResponse } from "@/app/apiclient"; +import type { SwitchResponse } from "@/app/apiclient"; export const switchFactory = Factory.define(({ sequence }) => { const name = uniqueNamesGenerator({ From 90afa771fc13c3695b1309b57fffb4c3f2b1ba83 Mon Sep 17 00:00:00 2001 From: nehjoshi5 Date: Thu, 23 Jul 2026 14:50:18 +0100 Subject: [PATCH 4/5] fix: remove unused import --- .../useSwitchesTableColumns/useSwitchesTableColumns.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app/switches/components/SwitchesTable/useSwitchesTableColumns/useSwitchesTableColumns.tsx b/src/app/switches/components/SwitchesTable/useSwitchesTableColumns/useSwitchesTableColumns.tsx index ded0791d79..6982298ce3 100644 --- a/src/app/switches/components/SwitchesTable/useSwitchesTableColumns/useSwitchesTableColumns.tsx +++ b/src/app/switches/components/SwitchesTable/useSwitchesTableColumns/useSwitchesTableColumns.tsx @@ -7,7 +7,6 @@ import { Link } from "react-router"; import type { SwitchResponse } from "@/app/apiclient"; import DoubleRow from "@/app/base/components/DoubleRow"; import TableActions from "@/app/base/components/TableActions"; -// import type { SwitchItem } from "@/app/switches/types"; type SwitchColumnDef = ColumnDef; From 3c04465126fa358aa52cfa0b36fbc7ed55df1211 Mon Sep 17 00:00:00 2001 From: nehjoshi5 Date: Thu, 23 Jul 2026 14:54:16 +0100 Subject: [PATCH 5/5] fix: fix pagination --- src/app/switches/components/SwitchesTable/SwitchesTable.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/app/switches/components/SwitchesTable/SwitchesTable.tsx b/src/app/switches/components/SwitchesTable/SwitchesTable.tsx index 25c2ac7698..ea52936163 100644 --- a/src/app/switches/components/SwitchesTable/SwitchesTable.tsx +++ b/src/app/switches/components/SwitchesTable/SwitchesTable.tsx @@ -6,9 +6,10 @@ import { useSwitches } from "@/app/api/query/switches"; import usePagination from "@/app/base/hooks/usePagination/usePagination"; const SwitchesTable = () => { - const { page, size, handlePageSizeChange, setPage } = usePagination(); + const { page, debouncedPage, size, handlePageSizeChange, setPage } = + usePagination(); - const switches = useSwitches({ query: { page, size } }); + const switches = useSwitches({ query: { page: debouncedPage, size } }); const columns = useSwitchesTableColumns();