From 17c44244fb6fad72caf83c8a064b471be696c4e0 Mon Sep 17 00:00:00 2001 From: Jared Marrz Date: Thu, 25 Jun 2026 15:30:58 -0400 Subject: [PATCH 1/7] feat: add shared breadcrumb component --- .../components/Breadcrumb/Breadcrumb.stories.tsx | 11 +++++++---- .../src/shared/components/Breadcrumb/Breadcrumb.tsx | 4 ++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/client/src/shared/components/Breadcrumb/Breadcrumb.stories.tsx b/client/src/shared/components/Breadcrumb/Breadcrumb.stories.tsx index dcddf3387..3967077d1 100644 --- a/client/src/shared/components/Breadcrumb/Breadcrumb.stories.tsx +++ b/client/src/shared/components/Breadcrumb/Breadcrumb.stories.tsx @@ -22,9 +22,10 @@ export default meta; type Story = StoryObj; export const SiteLevel: Story = { - name: "Site (single level with switcher)", + name: "Site detail", args: { segments: [ + { label: "Sites", to: "/fleet/sites" }, { label: "Denver", siblings: [ @@ -40,9 +41,10 @@ export const SiteLevel: Story = { }; export const BuildingLevel: Story = { - name: "Building (site link + building switcher)", + name: "Building detail", args: { segments: [ + { label: "Sites", to: "/fleet/sites" }, { label: "Denver", to: "/sites/3" }, { label: "Building 3", @@ -58,9 +60,10 @@ export const BuildingLevel: Story = { }; export const RackLevel: Story = { - name: "Rack (site + building links, rack switcher)", + name: "Rack detail", args: { segments: [ + { label: "Sites", to: "/fleet/sites" }, { label: "Denver", to: "/sites/3" }, { label: "Building 3", to: "/buildings/3" }, { @@ -81,6 +84,6 @@ export const RackLevel: Story = { export const NoSiblings: Story = { name: "No sibling switcher", args: { - segments: [{ label: "Denver", to: "/sites/3" }, { label: "Building 3" }], + segments: [{ label: "Sites", to: "/fleet/sites" }, { label: "Denver", to: "/sites/3" }, { label: "Building 3" }], }, }; diff --git a/client/src/shared/components/Breadcrumb/Breadcrumb.tsx b/client/src/shared/components/Breadcrumb/Breadcrumb.tsx index e895e8445..53b2af083 100644 --- a/client/src/shared/components/Breadcrumb/Breadcrumb.tsx +++ b/client/src/shared/components/Breadcrumb/Breadcrumb.tsx @@ -95,7 +95,7 @@ const Breadcrumb = ({ segments, testId = "breadcrumb" }: BreadcrumbProps) => { const hasSiblings = isLast && seg.siblings && seg.siblings.length > 0; return ( - + {i > 0 ? / : null} {!isLast && seg.to ? ( ( role="menu" data-testid={testId} onKeyDown={onKeyDown} - className="absolute top-full left-0 z-30 mt-1.5 max-h-72 min-w-44 overflow-y-auto rounded-2xl border border-border-5 bg-surface-elevated-base p-1.5 shadow-300" + className="absolute top-full left-0 z-30 mt-1.5 max-h-72 min-w-44 overflow-y-auto rounded-lg border border-border-5 bg-surface-elevated-base p-1.5 shadow-300" > {siblings.map((sib) => ( - - - - +
+ +
+
+ + + +
+
+
); }; diff --git a/client/src/protoFleet/features/buildings/pages/BuildingPage.tsx b/client/src/protoFleet/features/buildings/pages/BuildingPage.tsx index 6075cca8f..6dd73eba6 100644 --- a/client/src/protoFleet/features/buildings/pages/BuildingPage.tsx +++ b/client/src/protoFleet/features/buildings/pages/BuildingPage.tsx @@ -8,9 +8,14 @@ import BuildingPageHeader from "../components/BuildingPageHeader"; import { BuildingRackGrid } from "../components/BuildingRackGrid"; import { useBuildingModals } from "../hooks/useBuildingModals"; import { useBuildings } from "@/protoFleet/api/buildings"; -import { type Building, BuildingWithCountsSchema } from "@/protoFleet/api/generated/buildings/v1/buildings_pb"; +import { + type Building, + type BuildingWithCounts, + BuildingWithCountsSchema, +} from "@/protoFleet/api/generated/buildings/v1/buildings_pb"; +import { type SiteWithCounts } from "@/protoFleet/api/generated/sites/v1/sites_pb"; import { AggregationType, MeasurementType } from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; -import { parseBigIntId } from "@/protoFleet/api/sites"; +import { parseBigIntId, useSites } from "@/protoFleet/api/sites"; import { useBuildingStats } from "@/protoFleet/api/useBuildingStats"; import { useComponentErrors } from "@/protoFleet/api/useComponentErrors"; import { useTelemetryMetrics } from "@/protoFleet/api/useTelemetryMetrics"; @@ -55,7 +60,8 @@ type FetchOutcome = const BuildingPage = () => { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); - const { getBuilding, listBuildingRacks } = useBuildings(); + const { getBuilding, listAllBuildings, listBuildingRacks } = useBuildings(); + const { listSites } = useSites(); const activeSite = useFleetStore((state) => state.ui.activeSite); const buildingId = useMemo(() => parseBigIntId(id), [id]); @@ -67,6 +73,8 @@ const BuildingPage = () => { // Parallel-fetched rack count keyed by buildingId so it can't race a // navigation. Used to populate the cascade-delete dialog's count copy. const [rackCountResponse, setRackCountResponse] = useState<{ id: bigint; count: bigint } | undefined>(undefined); + const [sites, setSites] = useState([]); + const [allBuildings, setAllBuildings] = useState([]); const inflightControllerRef = useRef(null); const racksInflightRef = useRef(null); @@ -102,6 +110,21 @@ const BuildingPage = () => { [getBuilding, listBuildingRacks], ); + useEffect(() => { + const controller = new AbortController(); + void listSites({ + signal: controller.signal, + onSuccess: setSites, + onError: () => setSites([]), + }); + void listAllBuildings({ + signal: controller.signal, + onSuccess: setAllBuildings, + onError: () => setAllBuildings([]), + }); + return () => controller.abort(); + }, [listAllBuildings, listSites]); + useEffect(() => { if (buildingId === null) return; fetchBuilding(buildingId); @@ -182,6 +205,11 @@ const BuildingPage = () => { const effectiveOutcome: FetchOutcome | "loading" | "invalid" = buildingId === null ? "invalid" : response && response.id === buildingId ? response.outcome : "loading"; + const siteNameById = useMemo( + () => + new Map(sites.filter((row) => row.site !== undefined).map((row) => [row.site!.id.toString(), row.site!.name])), + [sites], + ); if (effectiveOutcome === "loading") { return ( @@ -227,6 +255,15 @@ const BuildingPage = () => { const label = effectiveBuilding.name || "(unnamed building)"; const idForHeader = effectiveBuilding.id.toString(); const buildingFilterParam = `building=${effectiveBuilding.id.toString()}`; + const siteIdForHeader = effectiveBuilding.siteId?.toString(); + const buildingSiblings = allBuildings + .filter((row) => row.building !== undefined) + .filter((row) => (row.building!.siteId ?? 0n) === (effectiveBuilding.siteId ?? 0n)) + .map((row) => ({ + label: row.building!.name || "(unnamed building)", + to: `/buildings/${row.building!.id.toString()}`, + isActive: row.building!.id === effectiveBuilding.id, + })); // Edit/Delete require the rack count to render an accurate cascade // warning ("deleting this building will unassign N racks"). Prefer the @@ -252,7 +289,14 @@ const BuildingPage = () => {
- +
{/* Stats fetch failure on initial load — surface it inline so the From a2efac179fc67bf3c091b0d496e8b52065098101 Mon Sep 17 00:00:00 2001 From: Jared Marrz Date: Thu, 25 Jun 2026 15:32:32 -0400 Subject: [PATCH 4/7] feat: add breadcrumb to rack detail --- .../pages/RackOverviewPage.test.tsx | 35 ++++++++-- .../pages/RackOverviewPage.tsx | 65 +++++++++++++++++-- 2 files changed, 92 insertions(+), 8 deletions(-) diff --git a/client/src/protoFleet/features/fleetManagement/pages/RackOverviewPage.test.tsx b/client/src/protoFleet/features/fleetManagement/pages/RackOverviewPage.test.tsx index 3356142a8..7d54e1541 100644 --- a/client/src/protoFleet/features/fleetManagement/pages/RackOverviewPage.test.tsx +++ b/client/src/protoFleet/features/fleetManagement/pages/RackOverviewPage.test.tsx @@ -1,3 +1,4 @@ +import { MemoryRouter } from "react-router-dom"; import { render, screen, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { create } from "@bufbuild/protobuf"; @@ -7,8 +8,10 @@ import { DeviceSetSchema } from "@/protoFleet/api/generated/device_set/v1/device const mockUseParams = vi.fn(); const mockNavigate = vi.fn(); +const mockUseBuildings = vi.fn(); const mockUseDeviceSets = vi.fn(); const mockUseDeviceSetStateCounts = vi.fn(); +const mockUseSites = vi.fn(); const mockUseTelemetryMetrics = vi.fn(); const mockUseComponentErrors = vi.fn(); @@ -27,6 +30,14 @@ vi.mock("@/protoFleet/api/useDeviceSets", () => ({ useDeviceSets: () => mockUseDeviceSets(), })); +vi.mock("@/protoFleet/api/buildings", () => ({ + useBuildings: () => mockUseBuildings(), +})); + +vi.mock("@/protoFleet/api/sites", () => ({ + useSites: () => mockUseSites(), +})); + vi.mock("@/protoFleet/api/useDeviceSetStateCounts", () => ({ useDeviceSetStateCounts: () => mockUseDeviceSetStateCounts(), })); @@ -116,6 +127,9 @@ const rack = create(DeviceSetSchema, { function mockResolvedRackPageData(deviceSet = rack): void { mockUseParams.mockReturnValue({ rackId: "7" }); + mockUseBuildings.mockReturnValue({ + listAllBuildings: ({ onSuccess }: { onSuccess: (buildings: unknown[]) => void }) => onSuccess([]), + }); mockUseDeviceSets.mockReturnValue({ getDeviceSet: ({ onSuccess }: { onSuccess: (resolvedDeviceSet: typeof rack) => void }) => onSuccess(deviceSet), listGroupMembers: ({ onSuccess }: { onSuccess: (deviceIds: string[]) => void }) => onSuccess([]), @@ -123,6 +137,9 @@ function mockResolvedRackPageData(deviceSet = rack): void { setRackSlotPosition: vi.fn(), deleteGroup: vi.fn(), }); + mockUseSites.mockReturnValue({ + listSites: ({ onSuccess }: { onSuccess: (sites: unknown[]) => void }) => onSuccess([]), + }); mockUseDeviceSetStateCounts.mockReturnValue({ stateCounts: { hashingCount: 0, @@ -149,6 +166,14 @@ function mockResolvedRackPageData(deviceSet = rack): void { }); } +function renderRackOverviewPage() { + return render( + + + , + ); +} + describe("RackOverviewPage", () => { beforeEach(() => { vi.clearAllMocks(); @@ -156,13 +181,15 @@ describe("RackOverviewPage", () => { }); it("renders the rack zone as a subtitle under the rack name", async () => { - render(); + renderRackOverviewPage(); - await waitFor(() => expect(screen.getByText(rackName)).toBeVisible()); + await waitFor(() => expect(screen.getAllByText(rackName).length).toBeGreaterThan(0)); const zone = screen.getByText(rackZone); expect(zone).toBeVisible(); expect(zone.className).toContain("text-text-primary"); + expect(screen.getByTestId("rack-page-breadcrumb")).toBeVisible(); + expect(screen.queryByTestId("header-icon-button")).not.toBeInTheDocument(); }); it("does not render a subtitle when the rack zone is empty", async () => { @@ -181,9 +208,9 @@ describe("RackOverviewPage", () => { mockResolvedRackPageData(rackWithoutZone); - render(); + renderRackOverviewPage(); - await waitFor(() => expect(screen.getByText(rackName)).toBeVisible()); + await waitFor(() => expect(screen.getAllByText(rackName).length).toBeGreaterThan(0)); expect(screen.queryByText(rackZone)).not.toBeInTheDocument(); }); diff --git a/client/src/protoFleet/features/fleetManagement/pages/RackOverviewPage.tsx b/client/src/protoFleet/features/fleetManagement/pages/RackOverviewPage.tsx index 855638bcf..72f1aedc9 100644 --- a/client/src/protoFleet/features/fleetManagement/pages/RackOverviewPage.tsx +++ b/client/src/protoFleet/features/fleetManagement/pages/RackOverviewPage.tsx @@ -3,13 +3,17 @@ import { useParams } from "react-router-dom"; import { create } from "@bufbuild/protobuf"; +import { useBuildings } from "@/protoFleet/api/buildings"; +import { type BuildingWithCounts } from "@/protoFleet/api/generated/buildings/v1/buildings_pb"; import { type DeviceSet, type RackCoolingType, type RackOrderIndex, RackSlotPositionSchema, } from "@/protoFleet/api/generated/device_set/v1/device_set_pb"; +import { type SiteWithCounts } from "@/protoFleet/api/generated/sites/v1/sites_pb"; import { AggregationType, MeasurementType } from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; +import { useSites } from "@/protoFleet/api/sites"; import { useComponentErrors } from "@/protoFleet/api/useComponentErrors"; import { useDeviceSets } from "@/protoFleet/api/useDeviceSets"; import { useDeviceSetStateCounts } from "@/protoFleet/api/useDeviceSetStateCounts"; @@ -27,7 +31,7 @@ import FleetErrors from "@/protoFleet/features/kpis/components/FleetErrors"; import { scopedPath } from "@/protoFleet/routing/siteScope"; import { useDuration, useSetDuration } from "@/protoFleet/store"; import { useFleetStore } from "@/protoFleet/store/useFleetStore"; -import { ChevronDown } from "@/shared/assets/icons"; +import Breadcrumb, { type BreadcrumbSegment } from "@/shared/components/Breadcrumb"; import Button, { variants } from "@/shared/components/Button"; import DurationSelector, { fleetDurations } from "@/shared/components/DurationSelector"; import Header from "@/shared/components/Header"; @@ -54,6 +58,8 @@ const RackOverviewPage = () => { // Rack resolution state const [rack, setRack] = useState(null); const [memberDeviceIds, setMemberDeviceIds] = useState(null); + const [sites, setSites] = useState([]); + const [allBuildings, setAllBuildings] = useState([]); const [loading, setLoading] = useState(true); const [notFound, setNotFound] = useState(false); const [resolveError, setResolveError] = useState(null); @@ -63,6 +69,8 @@ const RackOverviewPage = () => { const actionActiveRef = useRef(false); const { getDeviceSet, listGroupMembers, assignDevicesToRack, setRackSlotPosition, deleteGroup } = useDeviceSets(); + const { listAllBuildings } = useBuildings(); + const { listSites } = useSites(); // Request versioning to guard against stale resolution callbacks const resolveVersionRef = useRef(0); @@ -141,6 +149,21 @@ const RackOverviewPage = () => { [getDeviceSet, listGroupMembers], ); + useEffect(() => { + const controller = new AbortController(); + void listSites({ + signal: controller.signal, + onSuccess: setSites, + onError: () => setSites([]), + }); + void listAllBuildings({ + signal: controller.signal, + onSuccess: setAllBuildings, + onError: () => setAllBuildings([]), + }); + return () => controller.abort(); + }, [listAllBuildings, listSites]); + // Initial resolution from URL param useEffect(() => { if (!rackIdParam) { @@ -176,6 +199,20 @@ const RackOverviewPage = () => { const cols = rackInfo?.columns ?? 1; const orderIndex = rackInfo?.orderIndex; const numberingOrigin = orderIndex !== undefined ? orderIndexToOrigin(orderIndex) : "bottom-left"; + const siteNameById = useMemo( + () => + new Map(sites.filter((row) => row.site !== undefined).map((row) => [row.site!.id.toString(), row.site!.name])), + [sites], + ); + const buildingById = useMemo( + () => + new Map( + allBuildings + .filter((row) => row.building !== undefined) + .map((row) => [row.building!.id.toString(), row.building!]), + ), + [allBuildings], + ); const duration = useDuration(); const setDuration = useSetDuration(); @@ -270,11 +307,33 @@ const RackOverviewPage = () => { ); } + const rackBuildingId = rackInfo?.buildingId?.toString(); + const rackBuilding = rackBuildingId ? buildingById.get(rackBuildingId) : undefined; + const rackSiteId = rackInfo?.siteId?.toString() ?? rackBuilding?.siteId?.toString(); + const rackBreadcrumbSegments: BreadcrumbSegment[] = []; + if (rackSiteId) { + rackBreadcrumbSegments.push({ label: "Sites", to: "/fleet/sites" }); + rackBreadcrumbSegments.push({ label: siteNameById.get(rackSiteId) ?? "Site", to: `/sites/${rackSiteId}` }); + if (rackBuildingId) { + rackBreadcrumbSegments.push({ + label: rackBuilding?.name || "Building", + to: `/buildings/${rackBuildingId}`, + }); + } + } else { + rackBreadcrumbSegments.push({ + label: "Racks", + to: rackSiteId ? `/racks?site=${rackSiteId}` : "/fleet/racks", + }); + } + rackBreadcrumbSegments.push({ label: rack?.label ?? "Rack" }); + return (
{/* Header */}
+
{ subtitleSize="text-300" subtitleClassName="text-text-primary" inline - icon={} - iconAriaLabel="Back to racks" - iconOnClick={() => navigate(scopedPath("/fleet/racks", activeSite))} + className="mt-3" >