From 42551d8088dfdfbf9e69b2dd8af39bb217b87ba7 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 16 Oct 2026 11:51:35 -0700 Subject: [PATCH 1/3] Add slot column to sleds tab on inventory page --- app/pages/system/inventory/InventoryPage.tsx | 25 +++++++++++++++----- app/pages/system/inventory/SledsTab.tsx | 4 ++++ app/pages/system/inventory/sled/SledPage.tsx | 14 +++++++++++ mock-api/sled.ts | 4 ++++ test/e2e/inventory.e2e.ts | 11 +++++++++ 5 files changed, 52 insertions(+), 6 deletions(-) diff --git a/app/pages/system/inventory/InventoryPage.tsx b/app/pages/system/inventory/InventoryPage.tsx index 9eb2aea821..4cfc436e74 100644 --- a/app/pages/system/inventory/InventoryPage.tsx +++ b/app/pages/system/inventory/InventoryPage.tsx @@ -6,9 +6,11 @@ * Copyright Oxide Computer Company */ +import { queryClient } from '@oxide/api' import { Servers16Icon, Servers24Icon } from '@oxide/design-system/icons/react' import { DocsPopover } from '~/components/DocsPopover' +import { RefreshButton } from '~/components/RefreshButton' import { RouteTabs, Tab } from '~/components/RouteTabs' import { makeCrumb } from '~/hooks/use-crumbs' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' @@ -17,17 +19,28 @@ import { pb } from '~/util/path-builder' export const handle = makeCrumb('Inventory', pb.sledInventory()) +// covers both tabs. Sled slots come from Nexus's periodic inventory +// collection, so they can lag behind physical changes +const refreshData = () => + Promise.all([ + queryClient.invalidateEndpoint('sledList'), + queryClient.invalidateEndpoint('physicalDiskList'), + ]) + export default function InventoryPage() { return ( <> }>Inventory - } - summary="Information about the physical sleds and disks in the Oxide rack." - links={[docLinks.sleds, docLinks.storage]} - /> +
+ + } + summary="Information about the physical sleds and disks in the Oxide rack." + links={[docLinks.sleds, docLinks.storage]} + /> +
diff --git a/app/pages/system/inventory/SledsTab.tsx b/app/pages/system/inventory/SledsTab.tsx index 6c2df85610..843044a098 100644 --- a/app/pages/system/inventory/SledsTab.tsx +++ b/app/pages/system/inventory/SledsTab.tsx @@ -10,6 +10,7 @@ import { createColumnHelper } from '@tanstack/react-table' import { api, getListQFn, queryClient, type Sled } from '@oxide/api' import { Servers24Icon } from '@oxide/design-system/icons/react' +import { EmptyCell } from '~/table/cells/EmptyCell' import { LinkCell } from '~/table/cells/LinkCell' import { useQueryTable } from '~/table/QueryTable' import { EmptyMessage } from '~/ui/lib/EmptyMessage' @@ -36,6 +37,9 @@ const staticCols = [ ), }), + // last observed location, so null when a sled was just inserted. 0 is a + // valid slot, hence ?? instead of || + colHelper.accessor('slot', { cell: (info) => info.getValue() ?? }), // TODO: colHelper.accessor('baseboard.serviceAddress', { header: 'service address' }), colHelper.group({ id: 'baseboard', diff --git a/app/pages/system/inventory/sled/SledPage.tsx b/app/pages/system/inventory/sled/SledPage.tsx index 332424c216..4279749144 100644 --- a/app/pages/system/inventory/sled/SledPage.tsx +++ b/app/pages/system/inventory/sled/SledPage.tsx @@ -9,9 +9,11 @@ import { api, q, queryClient, usePrefetchedQuery } from '@oxide/api' import { Servers24Icon } from '@oxide/design-system/icons/react' +import { RefreshButton } from '~/components/RefreshButton' import { RouteTabs, Tab } from '~/components/RouteTabs' import { makeCrumb } from '~/hooks/use-crumbs' import { useSledParams } from '~/hooks/use-params' +import { EmptyCell } from '~/table/cells/EmptyCell' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' import { PropertiesTable } from '~/ui/lib/PropertiesTable' import { truncate } from '~/ui/lib/Truncate' @@ -28,6 +30,12 @@ export async function clientLoader({ params }: Route.ClientLoaderArgs) { await queryClient.fetchQuery(sledView({ sledId })) return null } +const refreshData = () => + Promise.all([ + queryClient.invalidateEndpoint('sledView'), + queryClient.invalidateEndpoint('sledInstanceList'), // instances tab + ]) + export const handle = makeCrumb( (p) => truncate(p.sledId!, 12, 'middle'), (p) => pb.sledInstances({ sledId: p.sledId! }) @@ -41,6 +49,7 @@ export default function SledPage() { <> }>Sled + @@ -71,6 +80,11 @@ export default function SledPage() { label="usable physical ram" bytes={sled.usablePhysicalRam} /> + + {/* 0 is a valid slot, hence ?? instead of || */} + {sled.slot ?? } + + diff --git a/mock-api/sled.ts b/mock-api/sled.ts index 0c6ecf92b5..05709e8f94 100644 --- a/mock-api/sled.ts +++ b/mock-api/sled.ts @@ -25,6 +25,7 @@ export const sleds: Json = [ serial: 'BRM02222869', revision: 0, }, + slot: 14, usable_hardware_threads: 254, usable_physical_ram: 1_099_511_627_776, }, @@ -43,6 +44,7 @@ export const sleds: Json = [ serial: 'BRM02222870', revision: 0, }, + slot: 15, usable_hardware_threads: 254, usable_physical_ram: 1_099_511_627_776, }, @@ -60,6 +62,7 @@ export const sleds: Json = [ serial: 'BRM02222868', revision: 0, }, + slot: 2, usable_hardware_threads: 254, usable_physical_ram: 1_099_511_627_776, }, @@ -77,6 +80,7 @@ export const sleds: Json = [ serial: 'BRM02222867', revision: 0, }, + slot: null, usable_hardware_threads: 254, usable_physical_ram: 1_099_511_627_776, }, diff --git a/test/e2e/inventory.e2e.ts b/test/e2e/inventory.e2e.ts index 3a77530625..9a4778fd88 100644 --- a/test/e2e/inventory.e2e.ts +++ b/test/e2e/inventory.e2e.ts @@ -24,6 +24,7 @@ test('Sled inventory page', async ({ page }) => { // grouping, hence the slightly weird column names await expectRowVisible(sledsTable, { id: expect.stringContaining(sleds[0].id), + slot: '14', 'serial number': sleds[0].baseboard.serial, Kind: 'In service', 'Provision policy': 'Provisionable', @@ -45,17 +46,27 @@ test('Sled inventory page', async ({ page }) => { }) await expectRowVisible(sledsTable, { id: expect.stringContaining(sleds[3].id), + slot: '—', 'serial number': sleds[3].baseboard.serial, Kind: 'Expunged', 'Provision policy': '—', state: 'decommissioned', }) + await page.getByRole('button', { name: 'Refresh data' }).click() + await expectRowVisible(sledsTable, { + id: expect.stringContaining(sleds[0].id), + slot: '14', + }) + // Visit the sled detail page of the first sled await sledsTable.getByRole('link').first().click() await expectVisible(page, ['role=heading[name*="Sled"]']) await expect(page.getByText('serialBRM02222869')).toBeVisible() + await expect(page.getByText('slot14')).toBeVisible() + await page.getByRole('button', { name: 'Refresh data' }).click() + await expect(page.getByText('slot14')).toBeVisible() const instancesTab = page.getByRole('tab', { name: 'Instances' }) await expect(instancesTab).toBeVisible() From 9e343eaa39306f8aec2418da83a95865c182d107 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 23 Oct 2026 17:16:37 -0700 Subject: [PATCH 2/3] remove refresh; put slot in first col --- app/pages/system/inventory/InventoryPage.tsx | 25 +++++--------------- app/pages/system/inventory/SledsTab.tsx | 12 +++++++--- app/pages/system/inventory/sled/SledPage.tsx | 8 ------- test/e2e/inventory.e2e.ts | 8 ------- 4 files changed, 15 insertions(+), 38 deletions(-) diff --git a/app/pages/system/inventory/InventoryPage.tsx b/app/pages/system/inventory/InventoryPage.tsx index 4cfc436e74..9eb2aea821 100644 --- a/app/pages/system/inventory/InventoryPage.tsx +++ b/app/pages/system/inventory/InventoryPage.tsx @@ -6,11 +6,9 @@ * Copyright Oxide Computer Company */ -import { queryClient } from '@oxide/api' import { Servers16Icon, Servers24Icon } from '@oxide/design-system/icons/react' import { DocsPopover } from '~/components/DocsPopover' -import { RefreshButton } from '~/components/RefreshButton' import { RouteTabs, Tab } from '~/components/RouteTabs' import { makeCrumb } from '~/hooks/use-crumbs' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' @@ -19,28 +17,17 @@ import { pb } from '~/util/path-builder' export const handle = makeCrumb('Inventory', pb.sledInventory()) -// covers both tabs. Sled slots come from Nexus's periodic inventory -// collection, so they can lag behind physical changes -const refreshData = () => - Promise.all([ - queryClient.invalidateEndpoint('sledList'), - queryClient.invalidateEndpoint('physicalDiskList'), - ]) - export default function InventoryPage() { return ( <> }>Inventory -
- - } - summary="Information about the physical sleds and disks in the Oxide rack." - links={[docLinks.sleds, docLinks.storage]} - /> -
+ } + summary="Information about the physical sleds and disks in the Oxide rack." + links={[docLinks.sleds, docLinks.storage]} + />
diff --git a/app/pages/system/inventory/SledsTab.tsx b/app/pages/system/inventory/SledsTab.tsx index 843044a098..b5f626ba4a 100644 --- a/app/pages/system/inventory/SledsTab.tsx +++ b/app/pages/system/inventory/SledsTab.tsx @@ -30,6 +30,15 @@ export const handle = { crumb: 'Sleds' } const colHelper = createColumnHelper() const staticCols = [ + // last observed location, so null when a sled was just inserted. + // 0 is a valid slot, hence ?? instead of ||. + colHelper.accessor('slot', { + cell: (info) => ( +
+ {info.getValue() ?? } +
+ ), + }), colHelper.accessor('id', { cell: (info) => ( @@ -37,9 +46,6 @@ const staticCols = [ ), }), - // last observed location, so null when a sled was just inserted. 0 is a - // valid slot, hence ?? instead of || - colHelper.accessor('slot', { cell: (info) => info.getValue() ?? }), // TODO: colHelper.accessor('baseboard.serviceAddress', { header: 'service address' }), colHelper.group({ id: 'baseboard', diff --git a/app/pages/system/inventory/sled/SledPage.tsx b/app/pages/system/inventory/sled/SledPage.tsx index 4279749144..c2879bdf58 100644 --- a/app/pages/system/inventory/sled/SledPage.tsx +++ b/app/pages/system/inventory/sled/SledPage.tsx @@ -9,7 +9,6 @@ import { api, q, queryClient, usePrefetchedQuery } from '@oxide/api' import { Servers24Icon } from '@oxide/design-system/icons/react' -import { RefreshButton } from '~/components/RefreshButton' import { RouteTabs, Tab } from '~/components/RouteTabs' import { makeCrumb } from '~/hooks/use-crumbs' import { useSledParams } from '~/hooks/use-params' @@ -30,12 +29,6 @@ export async function clientLoader({ params }: Route.ClientLoaderArgs) { await queryClient.fetchQuery(sledView({ sledId })) return null } -const refreshData = () => - Promise.all([ - queryClient.invalidateEndpoint('sledView'), - queryClient.invalidateEndpoint('sledInstanceList'), // instances tab - ]) - export const handle = makeCrumb( (p) => truncate(p.sledId!, 12, 'middle'), (p) => pb.sledInstances({ sledId: p.sledId! }) @@ -49,7 +42,6 @@ export default function SledPage() { <> }>Sled - diff --git a/test/e2e/inventory.e2e.ts b/test/e2e/inventory.e2e.ts index 9a4778fd88..d38aa28c3f 100644 --- a/test/e2e/inventory.e2e.ts +++ b/test/e2e/inventory.e2e.ts @@ -53,20 +53,12 @@ test('Sled inventory page', async ({ page }) => { state: 'decommissioned', }) - await page.getByRole('button', { name: 'Refresh data' }).click() - await expectRowVisible(sledsTable, { - id: expect.stringContaining(sleds[0].id), - slot: '14', - }) - // Visit the sled detail page of the first sled await sledsTable.getByRole('link').first().click() await expectVisible(page, ['role=heading[name*="Sled"]']) await expect(page.getByText('serialBRM02222869')).toBeVisible() await expect(page.getByText('slot14')).toBeVisible() - await page.getByRole('button', { name: 'Refresh data' }).click() - await expect(page.getByText('slot14')).toBeVisible() const instancesTab = page.getByRole('tab', { name: 'Instances' }) await expect(instancesTab).toBeVisible() From 8cc1a4ef5984c7b78a55e8a6c3b5efa6ba2ac619 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 12 Nov 2026 10:28:40 -0800 Subject: [PATCH 3/3] fix duplicate ID issue --- app/pages/system/inventory/SledsTab.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/pages/system/inventory/SledsTab.tsx b/app/pages/system/inventory/SledsTab.tsx index b5f626ba4a..866695b8fd 100644 --- a/app/pages/system/inventory/SledsTab.tsx +++ b/app/pages/system/inventory/SledsTab.tsx @@ -61,10 +61,12 @@ const staticCols = [ header: 'Policy', columns: [ colHelper.accessor('policy', { + id: 'policyKind', header: 'Kind', cell: (info) => , }), colHelper.accessor('policy', { + id: 'provisionPolicy', header: 'Provision policy', cell: (info) => , }),