Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,62 +2,76 @@ import { useNavigate } from "react-router-dom";

import { scopedPath } from "@/protoFleet/routing/siteScope";
import { useFleetStore } from "@/protoFleet/store/useFleetStore";
import { ChevronDown } from "@/shared/assets/icons";
import Breadcrumb, { type BreadcrumbSibling } from "@/shared/components/Breadcrumb";
import Button, { variants } from "@/shared/components/Button";
import Header from "@/shared/components/Header";

interface BuildingPageHeaderProps {
label: string;
buildingId: string;
siteId?: string;
siteName?: string;
buildingSiblings?: BreadcrumbSibling[];
// Opens ManageBuildingModal. Optional so callers that don't need the
// editing surface (e.g. error states, loading) can omit it; the button
// disables when no handler is wired.
onEditBuilding?: () => void;
}

// Mirrors RackOverviewPage's header: chevron-left back button, heading-300
// title, and a cluster of three secondary buttons. "View miners" and "View
// Mirrors RackOverviewPage's header: breadcrumb, heading-300 title, and a
// cluster of three secondary buttons. "View miners" and "View
// racks" link to their respective lists with the `building` URL filter — the
// singular key parsed by filterUrlParams.ts (mirroring the existing `group`
// and `rack` singular keys). RacksPage parses the same param to pre-select
// its building filter chip.
const BuildingPageHeader = ({ label, buildingId, onEditBuilding }: BuildingPageHeaderProps) => {
const BuildingPageHeader = ({
label,
buildingId,
siteId,
siteName,
buildingSiblings,
onEditBuilding,
}: BuildingPageHeaderProps) => {
const navigate = useNavigate();
const activeSite = useFleetStore((state) => state.ui.activeSite);
const currentSegment = {
label,
siblings: buildingSiblings && buildingSiblings.length > 1 ? buildingSiblings : undefined,
};
const breadcrumbSegments = siteId
? [{ label: "Sites", to: "/fleet/sites" }, { label: siteName ?? "Site", to: `/sites/${siteId}` }, currentSegment]
: [{ label: "Buildings", to: "/fleet/buildings" }, currentSegment];
Comment thread
jmarrxyz marked this conversation as resolved.

return (
<Header
title={label}
titleSize="text-heading-300"
inline
icon={<ChevronDown className="rotate-90" />}
iconAriaLabel="Back to sites"
iconOnClick={() => navigate(scopedPath("/fleet/sites", activeSite))}
>
<div className="ml-3 flex items-center gap-3">
<Button
variant={variants.secondary}
onClick={() => navigate(scopedPath(`/fleet/racks?building=${buildingId}`, activeSite))}
testId="building-page-view-racks"
>
View racks
</Button>
<Button
variant={variants.secondary}
onClick={() => navigate(scopedPath(`/fleet/miners?building=${buildingId}`, activeSite))}
testId="building-page-view-miners"
>
View miners
</Button>
<Button
variant={variants.secondary}
onClick={onEditBuilding ?? (() => undefined)}
disabled={!onEditBuilding}
testId="building-page-edit"
>
Edit building
</Button>
</div>
</Header>
<div className="flex flex-col gap-3">
<Breadcrumb segments={breadcrumbSegments} testId="building-page-breadcrumb" />
<Header title={label} titleSize="text-heading-300" inline>
<div className="ml-3 flex items-center gap-3">
<Button
variant={variants.secondary}
onClick={() => navigate(scopedPath(`/fleet/racks?building=${buildingId}`, activeSite))}
testId="building-page-view-racks"
>
View racks
</Button>
<Button
variant={variants.secondary}
onClick={() => navigate(scopedPath(`/fleet/miners?building=${buildingId}`, activeSite))}
testId="building-page-view-miners"
>
View miners
</Button>
<Button
variant={variants.secondary}
onClick={onEditBuilding ?? (() => undefined)}
disabled={!onEditBuilding}
testId="building-page-edit"
>
Edit building
</Button>
</div>
</Header>
</div>
);
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,40 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { create } from "@bufbuild/protobuf";

import BuildingPage from "./BuildingPage";
import { type Building, type BuildingRack, BuildingSchema } from "@/protoFleet/api/generated/buildings/v1/buildings_pb";
import {
type Building,
type BuildingRack,
BuildingSchema,
type BuildingWithCounts,
BuildingWithCountsSchema,
} from "@/protoFleet/api/generated/buildings/v1/buildings_pb";
import { SiteSchema, type SiteWithCounts, SiteWithCountsSchema } from "@/protoFleet/api/generated/sites/v1/sites_pb";
import { DEFAULT_ACTIVE_SITE } from "@/protoFleet/store/types/activeSite";
import { useFleetStore } from "@/protoFleet/store/useFleetStore";

const getBuildingMock = vi.hoisted(() => vi.fn());
const listAllBuildingsMock = vi.hoisted(() => vi.fn());
const listBuildingRacksMock = vi.hoisted(() => vi.fn());
const listSitesMock = vi.hoisted(() => vi.fn());

vi.mock("@/protoFleet/api/buildings", () => ({
useBuildings: () => ({
getBuilding: getBuildingMock,
listAllBuildings: listAllBuildingsMock,
listBuildingRacks: listBuildingRacksMock,
}),
}));

vi.mock("@/protoFleet/api/sites", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/protoFleet/api/sites")>();
return {
...actual,
useSites: () => ({
listSites: listSitesMock,
}),
};
});

vi.mock("@/protoFleet/api/useBuildingStats", () => ({
useBuildingStats: () => ({
stats: {
Expand Down Expand Up @@ -135,9 +155,30 @@ describe("BuildingPage", () => {
}),
),
);
listAllBuildingsMock.mockImplementation(({ onSuccess }: { onSuccess: (buildings: BuildingWithCounts[]) => void }) =>
onSuccess([
create(BuildingWithCountsSchema, {
building: create(BuildingSchema, {
id: 123n,
name: "Building A",
siteId: 8n,
}),
}),
]),
);
listBuildingRacksMock.mockImplementation(({ onSuccess }: { onSuccess: (racks: BuildingRack[]) => void }) =>
onSuccess([]),
);
listSitesMock.mockImplementation(({ onSuccess }: { onSuccess: (sites: SiteWithCounts[]) => void }) =>
onSuccess([
create(SiteWithCountsSchema, {
site: create(SiteSchema, {
id: 8n,
name: "Austin",
}),
}),
]),
);
});

it("preserves the selected site when leaving building detail for miners", async () => {
Expand Down
52 changes: 48 additions & 4 deletions client/src/protoFleet/features/buildings/pages/BuildingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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]);
Expand All @@ -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<SiteWithCounts[]>([]);
const [allBuildings, setAllBuildings] = useState<BuildingWithCounts[]>([]);
const inflightControllerRef = useRef<AbortController | null>(null);
const racksInflightRef = useRef<AbortController | null>(null);

Expand Down Expand Up @@ -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([]),
Comment thread
jmarrxyz marked this conversation as resolved.
});
return () => controller.abort();
}, [listAllBuildings, listSites]);

useEffect(() => {
if (buildingId === null) return;
fetchBuilding(buildingId);
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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
Expand All @@ -252,7 +289,14 @@ const BuildingPage = () => {
<div className="h-full" data-testid="building-page">
<div className="flex flex-col">
<div className="p-6 pb-0 laptop:p-10 laptop:pb-0">
<BuildingPageHeader label={label} buildingId={idForHeader} onEditBuilding={handleEditBuilding} />
<BuildingPageHeader
label={label}
buildingId={idForHeader}
siteId={siteIdForHeader}
siteName={siteIdForHeader ? siteNameById.get(siteIdForHeader) : undefined}
buildingSiblings={buildingSiblings}
onEditBuilding={handleEditBuilding}
/>
</div>

{/* Stats fetch failure on initial load — surface it inline so the
Expand Down
Loading
Loading