feat(fleet): unified /fleet page with tabbed Sites/Buildings/Racks/Miners#368
feat(fleet): unified /fleet page with tabbed Sites/Buildings/Racks/Miners#368flesher wants to merge 6 commits into
Conversation
Implements PR 1 of the multi-site UX redesign. /fleet is the unified list home with tabs in hierarchy order: Sites > Buildings > Racks > Miners. Miners and Racks tabs reuse existing page bodies; Buildings and Sites tabs ship as placeholder shells. /sites/:id detail page lands as a shell with header + Edit button + placeholder body. - FleetLayout: tab nav + Outlet with last-active-tab persistence - Sites tab hides under a single-site picker selection (J2) - Permanent redirects: /miners -> /fleet/miners, /racks -> /fleet/racks - Sidenav: replace Miners + Racks entries with a single Fleet entry behind the existing MULTI_SITE_ENABLED flag - Remove /settings/sites subnav entry (site config now lives on /sites/:id detail page reached via the Sites tab) - Update plan doc with the redesign
Replaces the placeholder bodies on /fleet/sites and /fleet/buildings with real list tables. Sites tab: SitesListTable renders site name + location, building + miner counts, and power capacity. Row click navigates to /sites/:id. "Add site" CTA opens the existing SiteDetailsModal flow; empty state reuses SitesEmptyState. Buildings tab: BuildingsListTable joins ListBuildings + ListSites client-side so each row shows its parent site. Row click navigates to /buildings/:id. "Add building" CTA is deferred to PR 3 since the BuildingDetailsModal needs a site-picker field — empty state messages the operator to use /sites/:id in the meantime. Metric columns (hashrate, power used, temperature, issues, health) land with the rest of Phase 1b telemetry wiring. Per-row ellipsis menus + bulk action menus land alongside the J10 action matrix.
Buildings tab filters rows by the active SitePicker selection: - "All Sites" — every building - specific site — rows whose building.siteId matches - "Unassigned" — rows with no site assignment When the filter hides every row (but the org has buildings), the empty state explains the filter rather than falling back to the "No buildings yet" copy. Sites tab can't filter to a single site (the tab is already hidden when a specific site is picked — J2 redirect). The Unassigned case now renders a short note pointing the operator back to All Sites, matching the existing /sites surface.
BuildingsListTable + SitesListTable now route power capacity through formatPowerUsedCapacity from shared/utils/telemetryFormat so a 50,000 kW building renders as "— / 50.0 MW" instead of "50000 kW". Matches BuildingMetricsRow and SiteMetricsRow on the detail pages.
🔐 Codex Security Review
Review SummaryOverall Risk: MEDIUM Findings[MEDIUM] Feature flag no longer protects the new Fleet shell
Notes
Generated by Codex Security Review | |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c014e2d192
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| loader: () => redirect("/fleet/miners"), | ||
| }, | ||
| { | ||
| path: "/racks", | ||
| loader: () => redirect("/fleet/racks"), |
There was a problem hiding this comment.
Preserve query strings in legacy redirects
When a user follows an existing filtered link such as /miners?group=... or /racks?building=..., these loaders redirect to the new Fleet tab without carrying over location.search, so the Miners/Racks pages open unfiltered. I checked the existing drill-down links in GroupOverviewPage, GroupsPage, RackOverviewPage, and building cards, and they still generate /miners?... or /racks?... URLs that rely on those query params being preserved.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Pull request overview
Implements the multi-site UX consolidation by introducing a unified /fleet route with a tabbed layout and new list/detail shells, while updating navigation and redirect behavior to steer users away from legacy entry points.
Changes:
- Adds a new
/fleetparent route with nested tab routes and aFleetLayouttab strip (Sites/Buildings/Racks/Miners). - Introduces new list-table stubs for Fleet Sites/Buildings and a new
/sites/:iddetail shell. - Updates primary/settings nav to prefer Fleet and removes settings subnav for Sites; adds client-side redirects for legacy routes.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| docs/plans/2026-05-05-multi-site-support-plan.md | Updates the multi-site plan to describe the Fleet-page redesign and related routing changes. |
| client/src/protoFleet/router.tsx | Adds /fleet nested routes and redirects /miners + /racks; wires /sites/:id detail route. |
| client/src/protoFleet/routePrefetch.ts | Prefetches Fleet chunks (layout + sites/buildings tabs) in the global prefetch set. |
| client/src/protoFleet/features/sites/pages/SiteDetailPage.tsx | New /sites/:id shell with header + edit/add-building buttons and placeholder body. |
| client/src/protoFleet/features/fleet/pages/FleetSitesPage.tsx | New Sites tab content for /fleet/sites with empty/error states and modals. |
| client/src/protoFleet/features/fleet/pages/FleetBuildingsPage.tsx | New Buildings tab content for /fleet/buildings with SitePicker-based filtering and empty/error states. |
| client/src/protoFleet/features/fleet/components/SitesListTable/SitesListTable.tsx | New Sites list table that navigates rows to /sites/:id and formats power via telemetryFormat. |
| client/src/protoFleet/features/fleet/components/SitesListTable/index.ts | Barrel export for SitesListTable. |
| client/src/protoFleet/features/fleet/components/FleetLayout/FleetLayout.tsx | New Fleet tab-strip layout with last-tab persistence and Sites-tab hiding/redirect behavior. |
| client/src/protoFleet/features/fleet/components/FleetLayout/index.ts | Barrel export for FleetLayout. |
| client/src/protoFleet/features/fleet/components/BuildingsListTable/BuildingsListTable.tsx | New Buildings list table with site-name join and power formatting in MW. |
| client/src/protoFleet/features/fleet/components/BuildingsListTable/index.ts | Barrel export for BuildingsListTable. |
| client/src/protoFleet/config/navItems.ts | Replaces Miners/Racks nav with Fleet when multi-site flag is enabled; removes Settings → Sites entry. |
| // The FleetLayout child route renders an <Outlet />; child paths supply | ||
| // the tab body. `/fleet` (bare) redirects to the last active tab inside | ||
| // FleetLayout, so we mount FleetLayout as the parent and rely on its | ||
| // useEffect to navigate. Nested routes need their own App wrapper to | ||
| // pick up backgrounds and the global PageHeader, so each child is | ||
| // declared as its own top-level route alongside the parent layout. |
|
|
||
| // Sites + buildings (multi-site; nav entries flag-gated, routes | ||
| // unguarded so direct URL access works during dogfood) | ||
| createRoute("/sites", <SitesPage />), |
| // Sidebar destinations + the default settings sub-route. App.tsx | ||
| // triggers this at idle so the first nav click has no Suspense flash. | ||
| export const globalRoutePrefetch: readonly RouteImporter[] = [ | ||
| importDashboard, | ||
| importFleetLayout, | ||
| importMiners, |
| // /settings/sites was removed by the 2026-06-02 multi-site redesign — | ||
| // site config now lives on /sites/:id detail pages reached via the | ||
| // Sites tab on /fleet. The /settings/sites route itself stays as an | ||
| // unguarded redirect path until traffic dies down. |
| // "Unassigned" filters miners with no site, not sites. The Sites tab | ||
| // can't meaningfully scope itself to an Unassigned bucket, so we render | ||
| // a note redirecting the operator. The "site" picker case is impossible | ||
| // here — FleetLayout hides this tab + redirects away when a single site | ||
| // is selected (J2). |
| const body = | ||
| sites.length === 0 ? ( | ||
| <SitesEmptyState onAddSite={modals.openCreate} /> | ||
| ) : activeSite.kind === "unassigned" ? ( | ||
| <div | ||
| className="rounded-xl border border-dashed border-border-5 p-6 text-center text-300 text-text-primary-70" | ||
| data-testid="fleet-sites-unassigned-note" | ||
| > | ||
| "Unassigned" filters miners, not sites. Switch the picker to All Sites to see every site. | ||
| </div> | ||
| ) : ( | ||
| <> | ||
| <div className="flex items-center justify-end"> | ||
| <Button | ||
| variant={variants.primary} | ||
| size={sizes.compact} | ||
| text="Add site" | ||
| onClick={modals.openCreate} | ||
| testId="fleet-sites-add" | ||
| /> | ||
| </div> | ||
| <SitesListTable sites={sites} /> | ||
| </> | ||
| ); |
| **Routing.** Each tab is its own route: `/fleet/miners`, | ||
| `/fleet/racks`, `/fleet/buildings`, `/fleet/sites`. Bare `/fleet` | ||
| redirects to the operator's last active tab (persisted in | ||
| localStorage per username, same shape as the SitePicker selection) | ||
| or to `/fleet/miners` on first visit. Filter state per tab lives in | ||
| the URL query string, same as `/miners` and `/racks` today. |
The /miners → /fleet/miners and /racks → /fleet/racks loaders were calling redirect() with a bare path, dropping the query string. That broke any deep-link with filter state — dashboard issue cards (`/miners?filter=control-board-issue`), rack-overview "view miners" links (`/miners?rack=A-01`), and similar entry points. Take the incoming request URL and append both search and hash to the target path so filter state survives the redirect. Fixes the navigation, dashboard, and racks E2E specs that landed on the redirected page expecting an active-filter chip.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a5bbd3f73d
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| // Default lands on the leftmost tab — Sites — to mirror the fleet | ||
| // hierarchy (site → building → rack → device). Falls back to the next | ||
| // visible tab when Sites is hidden under a single-site picker. | ||
| const DEFAULT_TAB: FleetTabId = "sites"; |
There was a problem hiding this comment.
Default Fleet to a tab all fleet viewers can load
When the new ungated Fleet nav item is clicked by a role that can use the existing Miners/Racks pages but lacks site:read, the bare /fleet route immediately redirects here to /fleet/sites. That tab calls ListSites and renders a permission/error state, so the primary nav now lands those users on an error instead of one of the still-accessible fleet views. Make the default/last-tab fallback permission-aware, or default to an ungated tab such as miners/racks for users who cannot load sites.
Useful? React with 👍 / 👎.
Applies eight of nine review findings from the PR #368 multi-agent code review. One finding (#4 — Buildings tab filter logic test) is deferred per the policy: large new test file + confidence below 80. Finding #1 — Validate lastTab from localStorage before navigating (FleetLayout.tsx). A corrupted or older-schema value would otherwise navigate to `/fleet/<garbage>`. Added an isFleetTabId guard on the read path; the guard already existed inline a few lines down. Finding #5 — Fix the Retry button AbortController leak. The fetchAll useCallback returned a cleanup function intended for useEffect, but the Retry button onClick discarded the return value. Retry-initiated controllers were never aborted on unmount. Switched all three pages to a retryCounter that re-runs the effect, putting the controller back under useEffect cleanup ownership. Applies to FleetBuildingsPage, FleetSitesPage (now consuming outlet context), and SiteDetailPage. Finding #6 — Gate the bare-/fleet redirect on `sites !== undefined`. A stale single-site picker selection pointing at a now-deleted site would briefly hide the Sites tab and redirect away before useActiveSite's known-id validation effect could reset to "all". Layout now waits for the first sites resolution. Finding #7 — Lift listSites into FleetLayout outlet context. The previous layout + tab pages each issued their own listSites RPC, producing 2-3 concurrent identical fetches on /fleet/sites and /fleet/buildings. FleetLayout now owns the fetch and exposes `{ sites, sitesError, refetchSites }` via Outlet context. Tab pages consume; the layout's Sites-tab-visibility check shares the same state. Also preserves last-good data on transient errors instead of collapsing to [] (matches the existing sitesLoadedRef pattern in the legacy /sites page). Finding #8 — Surface listSites errors on the Buildings tab. The Site column previously degraded silently to "—" for every row when the sites fetch failed. Inline retry banner now explains the degradation. Finding #10 — Tighten useParams shape in SiteDetailPage. The runtime type is `id?: string`, not `id: string`; the generic now matches. Finding #9 — safe_auto fix applied during the review pass: comment in BuildingsListTable describing what formatPowerUsedCapacity does. New tests (15): - FleetLayout.test.tsx — 9 tests covering bare /fleet redirect with all picker modes, lastTab persistence + corrupted-value handling, Sites-tab hiding under single-site picker, and the sites-load redirect gate. - redirectLoaders.test.ts — 6 tests covering /miners and /racks permanent redirects preserving search + hash (the contract the recent E2E fix depended on). Refactor: redirect loaders extracted to features/fleet/redirectLoaders.ts so they're testable independent of the route tree; FleetLayout outlet context type extracted to outletContext.ts so the component file stays fast-refresh clean. Deferred (filed as follow-up): - #4 (P1, conf 75) — SitePicker filter unit tests on /fleet/buildings. Filter logic itself is covered indirectly via FleetLayout tests for the picker mode dispatch; dedicated coverage of the bigint coercion branches lands separately.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 76c237578b
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| // Hide the Sites tab once a specific site is picked — J2. "All Sites" and | ||
| // "Unassigned" both keep the tab visible since both modes treat the list | ||
| // as more than one row. | ||
| const sitesTabHidden = activeSite.kind === "site"; |
There was a problem hiding this comment.
Keep Sites tab visible when no known sites remain
When the persisted SitePicker selection points at a site that no longer exists and ListSites returns an empty list (for example the last site was deleted), useActiveSite does not reset the selection because its known-id set has size 0. This leaves activeSite.kind === "site", so this line hides the Sites tab and the redirect effect sends /fleet or /fleet/sites to Buildings, preventing operators from reaching the empty Sites tab to create a new site.
Useful? React with 👍 / 👎.
Summary
Implements the 2026-06-02 multi-site UX redesign: collapses
/miners,/racks,/sites, and/settings/sitesinto a single/fleetpagewith a tab nav (Sites > Buildings > Racks > Miners). Sites + Buildings
tabs ship as real list tables (filtered by the topbar SitePicker);
Miners + Racks tabs reuse their existing page bodies. New
/sites/:iddetail page lands as a shell with header + Edit button.
/minersand/racksbecome permanent redirects, sidenav drops both entries infavor of a single Fleet entry, and
/settings/sitessubnav entry isremoved. Power values in the new list tables route through
shared/utils/telemetryFormatso big buildings render as "50.0 MW"instead of "50000 kW".
Test plan
VITE_MULTI_SITE_ENABLED=trueand verify the sidenav shows a single Fleet entry/fleet, confirm landing on Sites tab; tab nav navigates between Sites / Buildings / Racks / Miners/minersand/racksredirect to/fleet/minersand/fleet/racks/sites/:id; "Edit site" opens the existing modal/buildings/:id