Skip to content

feat(fleet): unified /fleet page with tabbed Sites/Buildings/Racks/Miners#368

Open
flesher wants to merge 6 commits into
mainfrom
fleet-page-multi-site-redesign
Open

feat(fleet): unified /fleet page with tabbed Sites/Buildings/Racks/Miners#368
flesher wants to merge 6 commits into
mainfrom
fleet-page-multi-site-redesign

Conversation

@flesher
Copy link
Copy Markdown
Contributor

@flesher flesher commented Jun 3, 2026

Summary

Implements the 2026-06-02 multi-site UX redesign: collapses /miners,
/racks, /sites, and /settings/sites into a single /fleet page
with 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/:id
detail page lands as a shell with header + Edit button. /miners and
/racks become permanent redirects, sidenav drops both entries in
favor of a single Fleet entry, and /settings/sites subnav entry is
removed. Power values in the new list tables route through
shared/utils/telemetryFormat so big buildings render as "50.0 MW"
instead of "50000 kW".

Test plan

  • Toggle VITE_MULTI_SITE_ENABLED=true and verify the sidenav shows a single Fleet entry
  • Visit /fleet, confirm landing on Sites tab; tab nav navigates between Sites / Buildings / Racks / Miners
  • Switch topbar SitePicker to a single site and confirm Sites tab hides (redirects to Buildings)
  • /miners and /racks redirect to /fleet/miners and /fleet/racks
  • Row click on Sites tab navigates to /sites/:id; "Edit site" opens the existing modal
  • Row click on Buildings tab navigates to /buildings/:id
  • Buildings tab filters rows when picker selects a specific site or Unassigned

flesher added 4 commits June 2, 2026 21:20
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.
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Jun 3, 2026

🔐 Codex Security Review

Note: This is an automated security-focused code review generated by Codex.
It should be used as a supplementary check alongside human review.
False positives are possible - use your judgment.

Scope summary

  • Reviewed pull request diff only (134be96c232277b22ecddc2811c2f5f085c6556b...76c237578b84647b1c9b119d9fcb858b804a726c, exact PR three-dot diff)
  • Model: gpt-5.5

💡 Click "edited" above to see previous reviews for this PR.


Review Summary

Overall Risk: MEDIUM

Findings

[MEDIUM] Feature flag no longer protects the new Fleet shell

  • Category: Frontend
  • Location: client/src/protoFleet/router.tsx:176
  • Description: The router permanently redirects /miners and /racks to /fleet/miners and /fleet/racks, while navItems.ts still shows the legacy /miners and /racks entries when MULTI_SITE_ENABLED is false. This means a flag-off production build still sends normal Miners/Racks navigation into the new /fleet layout.
  • Impact: The multi-site Fleet shell is exposed despite the feature flag being off. Operators lose access to the legacy standalone Miners/Racks routes through primary nav, and the new layout starts running its site-list fetch and tab UX in environments where the feature is meant to remain hidden.
  • Recommendation: Gate the legacy-route redirects on MULTI_SITE_ENABLED. When the flag is false, keep /miners rendering <Miners /> and /racks rendering <RacksPage />; when true, redirect to /fleet/miners and /fleet/racks.

Notes

.git/codex-review.diff contains frontend route/nav/page changes and a planning-doc update only. I did not see changed backend handlers, SQL, migrations, proto definitions, plugin code, Docker config, Rust code, shell-outs, pool configuration, wallet/stratum handling, or unsafe rendering in the authoritative diff.


Generated by Codex Security Review |
Triggered by: @flesher |
Review workflow run

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread client/src/protoFleet/router.tsx Outdated
Comment on lines +175 to +179
loader: () => redirect("/fleet/miners"),
},
{
path: "/racks",
loader: () => redirect("/fleet/racks"),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 /fleet parent route with nested tab routes and a FleetLayout tab strip (Sites/Buildings/Racks/Miners).
  • Introduces new list-table stubs for Fleet Sites/Buildings and a new /sites/:id detail 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.

Comment on lines +148 to +153
// 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 />),
Comment on lines 50 to 55
// 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,
Comment on lines +131 to +134
// /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.
Comment on lines +70 to +74
// "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).
Comment on lines +75 to +98
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"
>
&quot;Unassigned&quot; 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} />
</>
);
Comment on lines +344 to +349
**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.
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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";
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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.
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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";
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

client documentation Improvements or additions to documentation javascript Pull requests that update javascript code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants