Skip to content

Latest commit

 

History

History
482 lines (352 loc) · 118 KB

File metadata and controls

482 lines (352 loc) · 118 KB

Architecture

Technical patterns and decisions.


Stack

Layer Technology
Framework Next.js 16 (App Router, static export)
Language TypeScript 5.x (strict, ESM only)
Styling Tailwind CSS 4 + @aleph-front/ds tokens
Data TanStack React Query (client-side polling)
Deployment Static export (out/) for IPFS hosting (trailingSlash: true)

Project Structure

src/
├── app/
│   ├── layout.tsx         # Root layout (fonts, providers, app shell)
│   ├── page.tsx            # Overview page
│   ├── providers.tsx       # QueryClientProvider
│   ├── globals.css         # Tailwind + DS tokens import
│   ├── changelog/
│   │   └── page.tsx        # Changelog page (version history)
│   ├── credits/
│   │   └── page.tsx        # Credits page (credit flow diagram, recipient table)
│   ├── issues/
│   │   └── page.tsx        # Issues page (scheduling discrepancies, VM/Node perspectives)
│   ├── wallet/
│   │   └── page.tsx        # Wallet view (owned nodes, VMs, activity, permissions)
│   ├── nodes/
│   │   └── page.tsx        # Nodes page
│   ├── status/
│   │   └── page.tsx        # API status page (endpoint health checks)
│   └── vms/
│       └── page.tsx        # VMs page
├── api/
│   ├── types.ts            # Scheduler entity types + Aleph Message API types
│   ├── credit-types.ts     # Credit expense + distribution types (wire + app)
│   ├── client.ts           # API client (/api/v1 + api2.aleph.im) with snake→camel transform
│   └── client.url.test.ts  # Unit tests: getVMs URL construction (owner/status/scheduling_status)
├── changelog.ts             # Version history data (CURRENT_VERSION + CHANGELOG array)
├── hooks/
│   ├── use-nodes.ts        # useNodes, useNode (30s/15s polling)
│   ├── use-vms.ts          # useVMs, useVM (30s/15s polling)
│   ├── use-vm-creation-times.ts  # useVMMessageInfo (api2, 5min stale, no polling)
│   ├── use-overview-stats.ts  # useOverviewStats (30s polling)
│   ├── use-health.ts       # useHealth — /health endpoint polling (30s)
│   ├── use-issues.ts       # useIssues — derived discrepancy data from useVMs + useNodes
│   ├── use-wallet.ts       # useWalletNodes, useWalletVMs, useWalletActivity, useAuthorizations
│   ├── use-credit-expenses.ts # useCreditExpenses — credit expense messages from api2
│   ├── use-node-state.ts   # useNodeState — corechannel CCN/CRN aggregate
│   ├── use-node-locations.ts  # useNodeLocations — joins live node state with build-time location snapshot
│   ├── use-debounce.ts     # useDebounce hook (generic, configurable delay)
│   └── use-pagination.ts   # usePagination hook (client-side page/pageSize state + slice)
├── components/
│   ├── app-shell.tsx       # Composes DS ProductStrip + AppShellSidebar + PageHeader
│   ├── app-mark.tsx        # Per-app identity mark (logomark + Network wordmark)
│   ├── nav-icon.tsx        # Sidebar icon switch
│   ├── theme-toggle.tsx    # Dark/light toggle with localStorage
│   ├── stats-bar.tsx       # Overview stats grid (glass cards, noise texture, semantic colors)
│   ├── node-health-summary.tsx  # Node health bar chart + legend
│   ├── vm-allocation-summary.tsx # VM status breakdown
│   ├── top-nodes-card.tsx   # Top nodes by VM count card
│   ├── latest-vms-card.tsx  # Latest VMs by creation time (progressive loading from api2)
│   ├── card-header.tsx     # Shared card header with title + info tooltip
│   ├── collapsible-section.tsx # CSS grid-template-rows animated expand/collapse
│   ├── filter-toolbar.tsx  # Shared: DS Tabs underline status filter + optional filter toggle + search input
│   ├── filter-panel.tsx    # Shared: collapsible DS Card panel chrome + reset
│   ├── table-pagination.tsx # Shared: DS Pagination + page-size dropdown + "Showing X–Y of Z"
│   ├── node-table.tsx      # Nodes table with search, filters, count badges
│   ├── node-detail-panel.tsx # Node detail side panel (quick-peek)
│   ├── node-detail-view.tsx # Node full-width detail view (?view= param)
│   ├── vm-table.tsx        # VMs table with search, filters, count badges
│   ├── vm-table.test.tsx   # Smoke test: owner filter debounce + URL persistence + retention window (default 7d + lookup bypass + initialRetention)
│   ├── vm-detail-panel.tsx # VM detail side panel (quick-peek)
│   ├── vm-detail-view.tsx  # VM full-width detail view (?view= param)
│   ├── issues-vm-table.tsx # Issues page: VM perspective table + detail panel
│   ├── issues-node-table.tsx # Issues page: Node perspective table + detail panel
│   ├── credit-flow-diagram.tsx  # SVG flow diagram with particle animation + gradient paths
│   ├── credit-recipient-table.tsx # Credit recipient table (DS Table, FilterToolbar, sortable columns)
│   ├── credit-summary-bar.tsx # Credit summary stat cards
│   ├── world-map-card.tsx  # Mercator world map with per-node SVG dots
│   ├── resource-bar.tsx    # CPU/memory/disk usage bar
│   └── websocket-provider.tsx # App-wide WS client mount + useWebSocketStatus hook + event→queryKey invalidation map
├── lib/
│   ├── filters.ts          # Filter pipeline: textSearch, countByStatus, applyNodeAdvancedFilters, applyVmAdvancedFilters
│   ├── filters.test.ts     # Filter unit tests (32 tests)
│   ├── compute-units.ts    # Compute Unit derivation: computeNodeCuTotal (capacity = limiting resource), computeNodeCu(node, vms) → { total, used, available, isGpu } | null; available = scarcest free resource, used = total - available; standard 1vCPU/2GB/20GB, GPU 1vCPU/6GB/60GB
│   ├── credit-distribution.ts  # Credit expense distribution logic (computeDistributionSummary, distributeExpense)
│   ├── credit-distribution.test.ts # Distribution unit tests
│   ├── format.ts           # relativeTime, relativeTimeFromUnix, truncateHash, formatPercent, formatDateTime, formatCpuLabel, formatGpuLabel, formatAleph, explorerWalletUrl
│   ├── status-map.ts       # Status-to-visual maps: nodeStatusToDot(), NODE_STATUS_VARIANT, VM_STATUS_VARIANT, MESSAGE_TYPE_VARIANT
│   ├── world-map-projection.ts  # Web Mercator + equirectangular projection factories + deterministic per-hash scatter (mulberry32 + FNV-1a)
│   ├── world-map-resolution.ts  # Multiaddr/hostname parsing helpers (used by build-time snapshot)
│   ├── scheduler-ws.ts          # Non-React WebSocket client factory (lifecycle, exponential reconnect, subscribers, getWsUrl)
│   ├── route-title.ts      # routeTitle(pathname) — PageHeader fallback title derived from route
│   └── route-title.test.ts # Unit tests for routeTitle (11 cases covering all routes + edge cases)
├── config/
│   ├── apps.ts             # APPS list (Cloud / Network / Explorer / Swap) + ACTIVE_APP_ID for ProductStrip
│   └── nav.ts              # NAV_SECTIONS — sidebar accordion structure (Dashboard / Resources / Network / Operations)
└── data/                   # Build-time JSON snapshots (committed)
    ├── country-centroids.json   # ISO-2 → {lat, lng, name}, generated from world-countries
    └── node-locations.json      # node hash → { country }, generated from corechannel + ip3country
scripts/
├── build-country-centroids.ts  # One-shot: world-countries → src/data/country-centroids.json
├── build-node-locations.ts     # Pre-build: resolves CCN multiaddr IPs + CRN hostnames to country codes
├── preview.sh                  # CLI for multi-branch preview (start/stop/list)
└── preview-dashboard.mjs       # Preview dashboard server (port 3000)

Preview System

Multi-branch preview via git worktrees + concurrent dev servers.

Command Description
pnpm preview start <branch> Worktree + dev server on next available port
pnpm preview stop <branch> Kill server, remove worktree
pnpm preview stop-all Stop everything
pnpm preview list Show active previews

Dashboard on http://localhost:3000 lists all active previews with links. State tracked in .previews.json (gitignored). Worktrees in /tmp/previews/, node_modules via hard-link copy.


Patterns

API Client

Context: Dashboard fetches live data from the scheduler API. Approach: Fetches from NEXT_PUBLIC_API_URL (default: http://localhost:8081). Runtime URL override via ?api= query parameter. API endpoints are prefixed with /api/v1. Wire types (Api*Row) use snake_case matching the raw JSON; transform functions convert to camelCase app types. List endpoints return paginated responses ({items: T[], pagination: {page, page_size, total_items, total_pages}}). The fetchAllPages() helper fetches page 1 to learn total_pages, then fetches remaining pages in parallel (max 200 items/page). Public functions (getNodes, getVMs, getOverviewStats) return full arrays — pagination is encapsulated in the client layer. Detail endpoints (getNode, getVM) use fetchApi for the bare object + fetchAllPages for related VMs/history. Key files: src/api/types.ts (wire + app + pagination types), src/api/client.ts Notes: The getOverviewStats function fetches /stats + /vms + /nodes in parallel to derive per-status counts not available from /stats alone. GPU fields (gpus on nodes, gpu_requirements on VMs) are transformed via transformGpu to the app-level GpuDevice type (vendor, model, deviceName). CPU fields (cpu_architecture, cpu_vendor, cpu_features) are mapped to app types (cpuArchitecture, cpuVendor, cpuFeatures). formatCpuLabel() in format.ts maps CPUID vendor strings (AuthenticAMD→AMD, GenuineIntel→Intel) to display labels. Confidential computing fields (confidential_computing_enabled on nodes, requires_confidential on VMs) are mapped to app types and surfaced in tables, filters, and detail views.

Progressive Loading from Multiple APIs

Context: VM creation timestamps come from api2.aleph.im, not the scheduler API. Approach: The LatestVMsCard uses useVMs() for immediate scheduler data, then enriches with useVMCreationTimes(hashes) which calls api2.aleph.im/api/v0/messages.json. Before api2 responds, rows show hash + status badge with inline Skeleton for timestamps. Once timestamps arrive, rows re-sort by creation time and show relative dates. The api2 client function (getMessagesByHashes) lives alongside scheduler functions in client.ts with its own base URL (NEXT_PUBLIC_ALEPH_API_URL). Key files: src/api/client.ts, src/hooks/use-vm-creation-times.ts, src/components/latest-vms-card.tsx Notes: staleTime: 5min since creation timestamps are immutable. refetchInterval: false — no polling needed. Query key includes the hash array so it refetches when the VM list changes. Hash lookups are batched (100 per request) to stay under URL length limits. The card pre-sorts all VMs by updatedAt and only sends the top 100 candidates to api2 (avoids sending all 6000+ hashes which caused timeouts). VMs with no matching api2 message show "—" instead of an eternal Skeleton.

React Query Polling

Context: Real-time data without WebSockets. Approach: Each hook uses refetchInterval for automatic polling. Detail views poll at 15s, list views and overview stats at 30s. Key files: src/hooks/ Notes: staleTime: 10_000 and retry: 2 configured globally in providers.tsx.

Scheduler WebSocket Cache Invalidation

Context: Polling at 15–30s gives near-live data but lags behind real scheduling activity by seconds-to-tens-of-seconds; the scheduler exposes a /api/v1/ws event stream that can push state changes immediately.

Approach: A non-React module (src/lib/scheduler-ws.ts) owns the WebSocket lifecycle — createWsClient(url) returns a WsClient with status / lastEventAt / eventCount getters, a subscribe(fn) registry, an onStatusChange(fn) registry, and a close() that's final. Reconnect uses exponential backoff (1s → 2s → 4s → 8s → 16s → 30s cap), resets on successful connect, and is suppressed once close() runs. getWsUrl() derives the socket URL from getBaseUrl() (the same helper the HTTP client uses, exported from src/api/client.ts), rewriting the http / https prefix to ws / wss — so the ?api= query override and NEXT_PUBLIC_API_URL env fallback work uniformly.

A React provider (src/components/websocket-provider.tsx) mounts a single client for the whole app inside PersistQueryClientProvider in src/app/providers.tsx. It subscribes to events and dispatches each one through handleEvent(event, queryClient), which calls queryClient.invalidateQueries(...) against the existing keys:

Event Invalidated keys
VmScheduled / VmUnscheduled / VmUnschedulable ["overview-stats"], ["vms"], ["vm", vmHash]
VmMigrated the above + ["nodes"], ["node", sourceHash], ["node", targetHash]

Wallet (["wallet-vms", …], ["wallet-activity", …]) and credit-expense (["credit-expenses", …]) keys are intentionally excluded — they source from api2, not the scheduler. Polling stays in place as the fallback so disconnected periods don't lose correctness; the WS is purely an invalidation accelerant.

useWebSocketStatus() exposes { status, eventCount, lastEventAt } through React context. The Network Health page (/status) consumes it via a WebSocketRow rendered directly below the /health row of the Scheduler API list — passed in through StatusSection's extraRowAfterFirst slot, with extraHealthy / extraTotal props feeding the section's N/N healthy header and the top-level Endpoints Healthy card so the WS counts toward both. StatusDot flips green/amber/grey on status; the right column reads connected · awaiting events until the first event then switches to N events · last <relative> (formatRelativeTime). The page badge's allResolved check waits for the WS to leave connecting before flipping from "Checking…" to "All Systems Operational".

Key files: src/lib/scheduler-ws.ts, src/lib/scheduler-ws.test.ts, src/components/websocket-provider.tsx, src/components/websocket-provider.test.tsx, src/api/client.ts (exports getBaseUrl), src/app/providers.tsx, src/app/status/page.tsx

Notes: handleEvent is exported alongside the provider so the invalidation map is unit-testable without spinning up a real WS. Tests mock WebSocket via vi.stubGlobal and use vi.useFakeTimers() for backoff assertions. JSON parsing is defensive — malformed payloads and unknown type values are ignored without raising. Subscriber dispatch iterates a defensive [...statusSubs] / [...eventSubs] copy so unsubscribers can detach during dispatch without skipping callbacks.

Rewards Data Layer (Owner Revenue)

Rewards data layer (owner revenue + node earnings). Node-owner reward numbers come from two authoritative sources, not the old client-side reconstruction. src/api/rewards-client.tsgetRewardsTimeSeries(address, from, to, bucketSize?) calls credit.aleph.im /api/v0/rewards/time-series (detail=2) for per-address totals + by-source (credit_revenue / holder_tier / wage_subsidy) + per-role full; an optional bucketSize ("1h"/"1d") additionally returns the bucketed series (AddressRewards.buckets: RewardsBucket[], each bucket densified from the same sparse wire shape as the totals — zero buckets arrive as bare {start, end}); getDistributions() reads the latest FOUNDATION credit-rewards-distribution message (sender 0x3a5C…992C25, channel FOUNDATION) for the payout cycle window, per-address last-paid, and on-chain tx status. src/lib/reward-apportionment.ts (apportionOwnerRewards) anchors each address's authoritative role totals and splits them across owned nodes — CRN by each node's live vmCount (from useNodes()), CCN by score weight, wage by the same per-role weights (proxy) — so per-node figures sum to the address total. CRN weighting deliberately uses VM count rather than exact api2 execution node_id ALEPH: deriving the latter meant downloading the whole-network credit-expense feed for the cycle (~750MB), which hung the by-node breakdown (Decision #112); VM count is a free proxy already in cache. apportionOwnerRewards's Args takes crnVmCounts: Map<string, number> (not expenses); it clamps sub-1e-6 float residue in unattributedAleph so fully-attributed addresses don't render a scientific-notation row. getRewardsTimeSeries normalizes the API's sparse full breakdown to dense zeros — the API omits keys (or whole role objects, e.g. credit_revenue: {}) when a source is zero, and an undefined read there propagated NaN into every per-node figure. The card shows a count-up accrual line ("Accruing for N days") rather than a next-payment estimate — the payout cadence can't be predicted from one published distribution (Decision #113; payout-cycle.ts deleted with it). Hooks: useRewards, useDistributions, and useOwnerRewards (composes them + useNodes + useNodeStateOwnerRewards). useOwnerRewards's 5-min-rounded "now" ticks while mounted (useRoundedNowSec) so the accrual window's upper bound advances — keepPreviousData on useRewards absorbs the key change without a skeleton flash — and the wallet page's Refresh invalidation set (WALLET_QUERY_KEYS) includes rewards + distributions so the revenue card refetches with the rest of the page. WalletRevenueCard renders the cycle-centric view. Types live in src/api/rewards-types.ts. The hardcoded distribution split (computeWalletRewards) was removed for the wallet path; the Node Earnings tab and panel sparks moved onto this layer too (Decision #114 — see "Per-Node Earnings" below), so computeDistributionSummary/distributeExpense remain only for the credits page (Phase 2 migration pending). The rewards API base URL is overridable via NEXT_PUBLIC_CREDIT_API_URL. getStableHourRange(seconds) in src/hooks/use-rewards.ts derives hour-aligned windows ending at the start of the current hour — the API truncates bounds to whole hours anyway, so hour-aligned keys stay stable (and cacheable) for a full hour.

Wallet Revenue History (Monthly By-Source)

Context: WalletRevenueCard only shows "owed this cycle" — a single live window that resets on distribution — so there's no way to see whether a change in owed rewards came from a source shift (e.g. the holder-tier subsidy ramping) or from the operator's own VM changes (Decision #118). Approach: useOwnerRewardsHistory(address) (src/hooks/use-owner-rewards-history.ts) fires one 1mo-bucketed useRewards(address, DATA_START_SEC, nowSec, "1mo") query — reusing the same getRewardsTimeSeries client as the rest of the Rewards Data Layer, DATA_START_SEC shared from use-rewards.ts — and maps each RewardsBucket to a MonthlyReward (startSec, label, bySource, total, partial). The bucket whose UTC year/month matches "now" is flagged partial: true (the in-progress current month). RewardHistoryChart (src/components/reward-history-chart.tsx) is a bespoke local SVG stacked-bar chart — the only local component in this feature, legitimate under the Component Policy because every existing chart primitive (Sparkline, DualLineChart, NodeEarningsChart) is local too. Segments stack bottom→top per REWARD_SOURCE_META order (Credits → Holder → Wage subsidy), colored via DS tokens (no raw hex); the partial month renders at reduced fill opacity with an "MTD" tag. Hover shows a DS-token-styled floating card (border-edge/bg-surface/shadow-lg) — not DS Tooltip, which is trigger-anchored and can't track chart geometry, mirroring the node-earnings-chart.tsx hover pattern; below md the same data renders as an inline tap-to-read readout instead of a floating card. WalletRevenueHistoryCard (src/components/wallet-revenue-history-card.tsx) is DS-only container chrome (Card, Skeleton) plus a legend built from REWARD_SOURCE_META; it renders null when the wallet has zero rewards across all months (mirrors WalletRevenueCard's empty behavior) and keeps the last-good chart on a transient query error rather than flashing to empty. Key files: src/hooks/use-owner-rewards-history.ts, src/lib/reward-source-meta.ts (shared REWARD_SOURCE_META — key/label/CSS-var/dot-class per source, credit_revenue→success-500, holder_tier→primary-500, wage_subsidy→warning-500), src/components/reward-history-chart.tsx, src/components/wallet-revenue-history-card.tsx. Notes: No new API surface — reads only each bucket's bySource, ignoring the per-role full breakdown the current-cycle card needs. Past months are immutable and only the current month's bucket changes, so the query doesn't need aggressive polling; it shares useRewards's standard 5-minute staleTime. RewardSourceBar (used by WalletRevenueCard and the Node Earnings KPI) doesn't yet import REWARD_SOURCE_META — the two vocabularies are kept manually in sync for now (BACKLOG).

DS Component Policy

Context: Avoid duplicate UI primitives across projects. Approach: All reusable UI components live in @aleph-front/ds and are imported via subpath exports. Dashboard-specific compositions that combine DS components with domain logic live in src/components/. Key files: node_modules/@aleph-front/ds/, src/components/ Notes: DS is installed from npm (pinned version). The @ac/* path alias must be mapped in tsconfig.json (and vitest.config.ts) for DS internal imports to resolve. DS color tokens use error/success/warning naming (not Tailwind's destructive). Always verify token vars exist in DS tokens.css before use. Hash display uses CopyableText from @aleph-front/ds/copyable-text (middle-ellipsis, copy button, optional external link) — no local hash display component.

Status Mapping

Context: The DS StatusDot component accepts a fixed set of variants ("healthy" | "degraded" | "error" | "offline" | "unknown"), but the API returns different node statuses ("Healthy" | "Unreachable" | "Unknown" | "removed"). Badge variants also need consistent mapping from API statuses. Approach: src/lib/status-map.ts is the single source of truth for all status-to-visual mappings: nodeStatusToDot() for StatusDot, NODE_STATUS_VARIANT and VM_STATUS_VARIANT for Badge variants. All components import from this file — never define status variant maps locally. Key files: src/lib/status-map.ts Notes: Badge size should always be "sm" across the dashboard for consistency.

VM status set (10 values):

Status Variant Meaning
dispatched success (green) Running on the correct allocated node
scheduled default (neutral) Assigned to a node but not yet observed running
duplicated warning (amber) Running on the correct node plus extra unintended copies
misplaced warning (amber) Running on wrong nodes, not on the allocated node
missing error (red) Should be running but not found on any node
orphaned warning (amber) Running without any scheduling intent
unscheduled default (neutral) Deliberately not scheduled
unschedulable error (red) Cannot be placed — no node meets requirements
unknown default (neutral) No scheduling decision has been made yet
migrating warning (amber) Being moved to a different node — counts as active and shows as a visible tab on /vms

Node counting logic for discrepancy statuses:

  • duplicated: excludes allocatedNode from the affected-node count (the allocated node is correct; only extra copies are discrepant)
  • misplaced: counts all observedNodes (all observed locations are wrong because the VM is absent from the allocated node)

Dark Theme Default

Context: Operations dashboards are typically used in dark environments. Approach: theme-dark class on <html> element. ThemeToggle persists preference to localStorage and toggles the class. DS tokens resolve to dark variants via @custom-variant dark. Key files: src/app/layout.tsx, src/components/theme-toggle.tsx

App Shell Layout

Context: Consistent chrome across all pages, integrated with the wider Aleph product family (Cloud · Network · Explorer · Swap). Approach: AppShell composes three DS primitives from @aleph-front/[email protected]+ instead of the old AppSidebar/AppHeader pair: ProductStrip (54px top bar with cross-app tabs from src/config/apps.ts, logomark linking to https://aleph.cloud, theme toggle in the right slot — DS-default border-b suppressed via className="border-b-0" so chrome flows continuously into the rounded surface below; the three cross-app tabs are flagged external: true on their ProductApp entries so DS renders an external-link affordance, while Network stays internal), AppShellSidebar (collapsible expanded ↔ icon-rail, built-in toggle, accordion sections from src/config/nav.ts, version link in the new footer slot added in @aleph-front/[email protected] — auto-hides on collapse), and PageHeader (sticky chrome row above page content with a leading slot for the ☰ toggle and a title — DS default bg-background/95 overridden to bg-transparent so the main-glow accent gradient bleeds through; title tuned down to text-xs + text-muted-foreground via a [&_.truncate] arbitrary variant so it reads as quiet metadata, not a competing <h1>). The rounded bg-background surface (main-glow relative rounded-tl-2xl overflow-hidden) wraps both PageHeader and <main>, so the chrome reads as one continuous panel anchored under ProductStrip rather than a separate strip above a separate body (Decision #95). Sidebar collapse and per-section accordion state persist in localStorage via the DS hooks useSidebarCollapse (localStorage["sidebar.collapsed"]) and useAccordionState (localStorage["sidebar.section.<id>"]). Active route detection uses usePathname plus a small isActive helper. Scroll-to-top on route change is still owned by app-shell.tsx (effect on usePathname). routeTitle(pathname) in src/lib/route-title.ts provides the PageHeader's fallback title when no page has called usePageHeader; every page now calls the hook to register its own title (data-aware on /nodes and /vms, truncated address on /wallet) and, where useful, a quiet Refresh action — <Button variant="text" size="xs" iconLeft={<ArrowClockwise />}> (the icon goes through iconLeft, not as a child, because the DS Button wraps children in a single inline <span> and passing icon + text as siblings stacks them vertically). PageHeaderProvider wraps the app inside WebSocketProvider in src/app/providers.tsx, so both the PageHeader reader and page-level writers see the same context. NavItem is wired with asChild + Next.js <Link> so SPA navigation works; /credits keeps hover-prefetch by passing onMouseEnter / onFocus through to the cloned anchor (DS NavItem patch shipped in @aleph-front/[email protected], DS Decision #80). The AppMark component renders only the "Network" wordmark (returns null when collapsed) — the cross-product Aleph logomark lives in ProductStrip, so duplicating it in the sidebar would read as two anchors competing for the same role (Decision #95). Key files: src/components/app-shell.tsx, src/components/app-mark.tsx, src/components/nav-icon.tsx, src/config/apps.ts, src/config/nav.ts, src/lib/route-title.ts, src/app/providers.tsx

Overview Page Redesign

Context: The overview page needed more visual impact, spacing, and contextual help for users unfamiliar with Aleph Cloud terminology. Approach: Hero stat cards with text-4xl numbers in rigid-square italic font, each in its own glassmorphism card (bg-foreground/[0.03], border-foreground/[0.06]) with colored status indicators (green/amber/red), status-tinted backgrounds via CSS custom property --stat-tint at 7% opacity, SVG noise texture (feTurbulence) at 3% opacity for depth, and explanatory subtitles. Hero is a 2-column grid at lg (lg:gap-12, matching the mt-12 gap to the row below): a 2×2 stat grid (Nodes Total/Healthy + VMs Total/Dispatched) on the left, the WorldMapCard on the right; below lg the grid stacks. Each column has a small uppercase section label above its content (Nodes / Virtual Machines for the StatsBar, Network Map for the worldmap), exported via SectionLabel from stats-bar.tsx. The StatsBar uses flex h-full flex-col with flex-1 on each inner card row so the cards stretch vertically to match the worldmap card's height. The VMs "Total" counts only currently-active statuses (dispatched + duplicated + misplaced + missing + unschedulable) so the headline matches the sum of all active status cards on /vms and the donut ring proportions read correctly; long-tail statuses (Unreachable, Removed, Missing, Unschedulable, etc.) are reachable from the per-status pills on /nodes and /vms rather than dedicated hero cards. Content cards have larger text-2xl titles with ? info tooltips (DS Tooltip component) and padding="lg". Page has a text-4xl title with subtitle, and mt-12 / gap-8 spacing between sections. A shared CardHeader component provides the title + tooltip pattern for all 4 content cards. Key files: src/app/page.tsx, src/components/stats-bar.tsx, src/components/world-map-card.tsx, src/components/card-header.tsx, src/app/globals.css Notes: Stats grid uses 2 columns. The .stat-card::before pseudo-element reads --stat-tint from inline styles for dynamic color tinting. The .stat-card::after pseudo-element adds an SVG noise grain texture. The .card-glow utility adds shadow-brand on hover. Status-specific stat cards show a DonutRing SVG in the top-right corner (absolutely positioned) displaying the value/total ratio with an animated arc (1.2s CSS transition on stroke-dashoffset, triggered by requestAnimationFrame after mount). Each ring contains a centered Phosphor-style inline SVG icon matching the status semantics (check).

Worldmap Card

Context: The Overview hero needed a visual signal of geographic spread to convey scale and decentralization at a glance. Approach: WorldMapCard renders a Vemaps Web Mercator world map (public/world-map.svg, viewBox cropped to 100 140 600 333 for a centered Europe-leaning frame, dark navy #2B2B44 continents) with one green SVG <circle> per sampled active node. Live node state comes from useNodeState() (corechannel aggregate); per-node country comes from a build-time JSON snapshot src/data/node-locations.json keyed by node hash. The useNodeLocations(project) hook joins the two: it filters live nodes where inactiveSince == null, drops nodes missing from the snapshot, looks up the country centroid in src/data/country-centroids.json, applies a deterministic per-hash elliptical scatter (~2° lat × ~3.2° lng), and projects via the supplied Projection function. The card supplies a Mercator projection calibrated empirically to the Vemaps SVG (centerX: 400.8, equatorY: 395.7, R: 117.27, lngOffset: 11) — the SVG is centered on lng+11° (Europe/Africa-centered, common for world maps), and the Mercator math was fit against four reference landmarks (Greenland tip, Greenland south, Cape York, Wilsons Promontory) to within ~5–10 px each. The overlay SVG matches the map's viewBox + preserveAspectRatio="xMidYMid slice" so the map fills the card edge-to-edge (object-cover behavior) regardless of card aspect. Each dot has a hash-seeded flicker animation (4–6s, 0–5s delay) via the node-dot-flicker keyframe; reduced-motion clients see no animation. Card chrome is theme-aware via CSS variables (--map-dot-color, --map-vignette) so the dot-pattern background and inner soft vignette adapt to light/dark. The whole card is a Link to /network — rendered as a full-bleed absolute inset-0 overlay link (the header row is pointer-events-none and the Vemaps attribution <a> is lifted to z-20 so it stays independently clickable; an overlay link avoids the invalid nested-<a> a wrapping <Link> would create).

Per-country sampling: the snapshot has ~500 nodes with heavy clustering (130 in FR, 130 in US). Rendering all of them produces a single bright green blob. The hook passes sampleEvery: 10 to computeNodeDots, which groups nodes by country, sorts each group deterministically by hash, and takes Math.max(1, Math.ceil(N / 10)) per country. This guarantees every country with active nodes gets at least 1 dot (so RU/IT/CA/SE never disappear) while keeping the total around ~60 dots — readable density. Per-country sampling is pluggable via the sampleEvery parameter; tests pass 1 (no sampling). Key files: src/components/world-map-card.tsx, src/hooks/use-node-locations.ts, src/lib/world-map-projection.ts, src/data/node-locations.json, src/data/country-centroids.json, public/world-map.svg, src/app/globals.css (node-dot-flicker keyframe + --map-dot-color / --map-vignette theme vars) Notes: No interaction in v1 — the expand button is disabled with a "Coming soon" tooltip. CCNs and CRNs render the same color (the design intent is fleet visibility, not breakdown). Snapshot misses (no resolved IP, foreign country code outside world-countries) are silently dropped. The map opacity is 0.2 in light theme and 1.0 in dark — #2B2B44 continents on a light background read as too heavy at full opacity. Vemaps SVG is licensed under their attribution-required license; the Map by Vemaps.com link at the bottom-left is mandatory.

Network Graph Page

Context: Operators need to see the structural shape of the Aleph network — how CRNs cluster under CCNs, who owns what, and where stake/reward flows aggregate — without flipping between table views.

Approach: /network renders a force-directed graph with d3-force driving simulation and React owning the DOM. The split: d3 mutates SimNode objects in place every tick; React reads positions from a positionsRef: Map<id, {x,y}> (NOT from the mutated simNodes). The tick handler updates positionsRef, batches a single re-render per animation frame via setTickKey, and rebuilds a d3-quadtree for hit-testing (used by the SVG-level click handler). Render iterates graph.nodes and reads positionsRef.current.get(n.id) per node, with the early-out if (!p) return null — so the simNodes useMemo pre-populates positionsRef using d3-force's Fibonacci-spiral seed (radius = 10 * sqrt(0.5 + i), angle = i * π * (3 − √5)) for any node without a prior position, ensuring <g data-id> elements are in the DOM from the very first commit after data arrives. Sync warmup on fresh mount: when positionsRef is empty (initial load or after a reset-view key bump), the same useMemo also runs a throwaway forceSimulation synchronously for 300 ticks before returning, mutating the seed positions into a converged layout and writing them back into positionsRef. The first paint therefore shows the spread layout, not the spiral; the live sim is then created with alpha(0) so it doesn't re-shake. A justWarmedUpRef ref carries that signal from the useMemo (render phase) to the simulation effect (commit phase).

Layout: the page wrapper uses relative h-full md:-m-6 md:h-[calc(100%+3rem)] md:overflow-hidden to break out of <main>'s p-6 padding and bleed edge-to-edge. A graph layer (absolute inset-0 hidden md:block) holds the SVG and the absolute-positioned <NetworkLegend>. A chrome overlay (pointer-events-none relative z-10 hidden md:block) stacks the <header>, <NetworkLayerToggles> + reset-view + search row, and <NetworkFocusBanner> on top of the graph; each interactive child gets pointer-events-auto so blank-area clicks fall through to the graph. Detail panel is absolute right-0 top-0 bottom-0 z-20 w-[400px] bg-background so it slides in over the map without squeezing it. Mobile (md:hidden) renders a three-section portrait summary (network-mobile-summary.tsx): "↻ Rotate device for full network graph" hint at the top, then top 10 CCNs (each name + flag + CRN count + staked ALEPH + StatusDot, linking to /nodes?view=<hash>), top 10 countries (flag + name + "N nodes · M CCNs · K CRNs", informational only — no detail page exists), and top 10 reward addresses (truncated 0x… + CRN/CCN outline badges + → arrow, linking to /wallet?address=<addr>). Each section has a "See all N →" inline expand toggle and shows row skeletons while useNetworkGraph is loading. Aggregation lives in src/lib/network-mobile-aggregates.ts (pure helpers aggregateCountries + aggregateRewards, unit-tested in network-mobile-aggregates.test.ts); the shared dotStatusFor was promoted from network-detail-panel.tsx to network-graph-model.ts so portrait and desktop views agree on dot color.

Interactions:

  • Click = select. Handled by an onClick on the SVG that hit-tests the quadtree (radius HIT_RADIUS=12) and calls onNodeClick. Suppressed during/after a drag via dragInProgressRef (with a setTimeout(0) reset-defer so the synthetic post-mouseup click doesn't fire selection on a long-press release).
  • Long-press 200ms = drag. The drag.start handler sets a setTimeout that, on fire, sets dragInProgressRef, pins d.fx/fy = d.x/y, and computes dragOffsetRef = nodePos − lastDragPointRef using the most recent mouse position (lastDragPointRef is updated on every drag event, including those fired during the press window when the handler returns early). On real drag ticks: d.fx = event.x + offset.x. The captured offset preserves the press-time relationship between cursor and node, so the node doesn't jump to the cursor when the timer fires after the user has already moved.
  • Drag attachment is delegated to gRef.current (the parent <g> that exists from mount) via d3-drag's .container(() => gRef.current) + .subject(event => lookup.get(event.target.closest("g[data-id]")?.dataset.id)). One drag behavior bound to the parent resolves the dragged node by walking up from the event target. This is timing-independent: drag works regardless of whether <g data-id> children were in the DOM at the moment the effect attached.
  • Hover = no UI (the hover tooltip card was removed for visual quietness). Cursor stays default everywhere.
  • Pan/zoom via d3-zoom on the SVG. Zoom filter rejects events on g[data-id] so drag wins on nodes; everywhere else, mousedown pans.

Simulation tuning:

  • alphaDecay = 0.05 for initial layout (slow settle reads as graceful).
  • alphaTarget = 0.05 during drag so neighbors gently settle into new positions instead of bouncing — 0.3 (the d3-force example default) was too lively.
  • On drag.end: temporarily set alphaDecay(0.15) and register a namespaced sim.on("end.dragCooldown", ...) that restores alphaDecay(SIM_DECAY) and removes itself when the simulation auto-stops. Result: post-drag settles in ~0.4s without affecting the initial layout's pace.
  • Charge -180, link distance 60 with d3-force's degree-aware default link strength (1 / min(degree(source), degree(target))) so dense subgraphs (owner/staker/reward cliques) don't crush together while the structural CCN↔CRN star stays snappy. Anchoring is via weak forceX(0).strength(0.02) + forceY(0).strength(0.02) rather than forceCenterforceCenter is alpha-independent and visibly shoves nodes by (w/2, h/2) on the first tick after a warmed-up sim starts at alpha(0); forceX/forceY scale with alpha so they're a no-op when warmed up and a gentle pull at alpha(1) for fresh layouts.

Auto-refit semantics: userMovedRef flips to true on any drag/zoom/pan and resets to false only when refitKey = "<layers>|<focus>|<address>" changes — i.e., user-driven URL changes. On sim.on("end"), refitRef.current() calls fitTransform only if userMovedRef is false. So background polling refetches never disturb the user's viewport, but toggling a layer or focusing a node does fit the result. fitTransform pads the bounding box by 2× on each axis, caps zoom at 2, and floors at MIN_FIT_ZOOM = 0.3 — chosen so the full-graph reset shows the whole network with breathing room rather than cropping nodes off-screen at a higher minimum zoom. With the sync warmup in place (see Approach), the camera fits the converged extents in a single 450ms transition; the page-level "Updating…" indicator is gated to SETTLE_MS = 500 to match.

Symmetric viewBox for first-paint centering. The SVG renders with viewBox="${-w/2} ${-h/2} ${w} ${h}" and preserveAspectRatio="xMidYMid meet", so user-coord origin (0, 0) is at the screen center. Because the warmup converges nodes around (0, 0), the browser centers the converged layout natively before any JS runs — independent of the measured size. fitTransform returns { x: -cx*k, y: -cy*k, k } (no + size/2 term needed), and the label-positioning math in the React layer adds back size/2 when converting world coords to absolute container pixels (since the labels live in a sibling <div>, not inside the SVG). An initial useLayoutEffect reads getBoundingClientRect() synchronously so the first viewBox uses real dimensions instead of the useState({w:800, h:600}) placeholder; ResizeObserver handles subsequent changes. Replaces an earlier hide-until-ready opacity gate that masked the first paint but not the second refit after ResizeObserver fired.

Layers: structural (CCN↔CRN parent edges, default — the only on-by-default layer; see Decision #82 for why staker was removed from defaults), staker (stake links), owner (same-owner dashed), reward (reward-cluster dotted), geo (country pins, off by default — see Geo layer below). URL-persisted via ?layers=structural,reward. Edge styling: solid 1px for structural at 0.6 opacity (was 0.4), staker at 0.2; dashed 0.5px with strokeLinecap="round" for owner (1.5 1 dash, currentColor neutral gray at 0.2 — was a saturated --network-edge-owner blue at 0.25, which competed with the structural backbone) and reward (0 0.4 round dots). Arrowheads: structural edges whose target is a CRN end in a triangle marker via a single <marker id="arrow-end"> defined in the SVG <defs>, sized markerWidth/Height = 10 * nodeScale in userSpaceOnUse. The marker's fill="context-stroke" (SVG2) makes the arrow inherit the line's stroke color, so a highlight or dim on the line carries through to the arrow without a per-marker variant. To keep the arrow tip outside the CRN's opaque background underlay, the line endpoint is shortened by RADIUS.crn × nodeScale + 1.5 user units (math lives in the edge map in network-graph.tsx, not in NetworkEdge). When a node is selected, its incident edges are recolored to the node's kind color (ccn → primary-500, crn → success-500); non-incident edges flip to faded (existing path: OPACITY[type] × 0.2); see Selection spotlight below.

Edge type discriminator (Decision #86): edges carry an EdgeType = GraphLayer | "migration". Most edge types correspond 1:1 to a layer toggle, but "migration" is always-on — migrations are rare and operationally significant, so they don't get gated behind a toggle. useNetworkGraph pulls useVMs(), filters to status === "migrating" with both allocatedNode + migrationTarget resolved, and threads the list into buildGraph(state, layers, ownerBalances?, crnStatuses?, migrations?, geo?). The builder emits one type: "migration" edge per VM where both endpoints resolve to CRN nodes in the model (otherwise skipped silently — d3-force would crash on a dangling edge). Visual: solid amber stroke (var(--color-warning-500), opacity 0.9, width 1) ending in a separate <marker id="arrow-end-warning"> (geometry identical to arrow-end, just fillOpacity=0.9 so the brighter line doesn't dwarf its arrow tip). The same target-side back-off math used for structural arrows applies, so the tip lands just outside the target CRN's border. CRN detail panel surfaces a Migrations row with inbound/outbound counts derived from visibleGraph.edges, hidden when both counts are zero — the panel is the place to see "how is this CRN involved in current migrations" at a glance.

Geo layer: the optional geo layer groups located CCN/CRN around a per-country hub node — clustering, not geographic mapping. buildGraph runs country attribution unconditionally (sets n.country = ISO on each located CCN/CRN so detail panels can show a Location row even when the geo layer is off — see Decision #79); the layer-gated block on top of that emits one kind: "country" node per represented country (id namespace country:<ISO>, label = centroid name from src/data/country-centroids.json) plus one type: "geo" edge from each located node to its country node. Country nodes are unpinned regular sim nodes — the force simulation places them by cluster mass, so heavy clusters (FR/US/DE) drift to the edges and smaller ones nestle in between. Geo edges live in a second forceLink keyed "geo" (distance 25, strength 1) so the existing forceLink keeps d3's degree-aware default link strength for relational edges. Country charge in forceManyBody is boosted to -800 (vs -180 for other nodes) via a per-node strength function, so country clusters repel each other strongly enough to never overlap. Geo edges render as a country-tinted dashed tether (STROKE.geo = var(--network-country), DASH.geo = "1 2", OPACITY.geo = 0.35, no arrowhead) so each country reads as an explicit hub-and-spoke hub — the cross-country structural arrows still cross over but no longer drown the geo grouping. The selection-incident path treats country selection too: incidentColor returns var(--network-country) when a country is selected, so its tethers brighten to 0.9 opacity via the existing highlightColor branch in NetworkEdge. Country labels bypass LABEL_ZOOM_THRESHOLD (info-variant Badge) so the grouping is always legible. Country is the top-tier visual when the layer is on: RADIUS.country=22 (bigger than CCN's 16), full node treatment (opaque background underlay, fillOpacity=0.18 colored fill, stroke, outer ring at r+3, selection halo) using a --network-country CSS token (cyan/teal — oklch(0.70 0.13 200) light, oklch(0.78 0.13 200) dark; chroma is held at 0.13 because Lightning CSS silently drops out-of-gamut OKLCH at this hue, see Decision #77) — distinct from CCN purple, CRN green, staker amber, error red. Interaction parity: countries are clickable (open the detail panel via the existing ?selected=), focusable (onFocus builds an ego subgraph via geo edges so the country + its located nodes + their structural/owner/staker neighbors all pull in), and searchable — network-search.tsx matches country:FR by id and France by label, and a country match fires both ?focus and ?selected (focus action) instead of select-only. The detail panel adds a NetworkDetailPanelCountry body with a flag emoji header (via countryFlag() regional-indicator codepoint helper in src/lib/country-flag.ts), CCN/CRN/total/owner stat tiles aggregated from the country's incident geo edges in visibleGraph, and a faint inactive footnote when > 0. The legend gains a "Country" node swatch and a "Country tether" line swatch (also a dashed cyan line) when the geo layer is on. The search input gets an Info icon trigger (left of the field) with a tooltip listing the four supported query types (hash, name, address, country) and noting that country search requires the Geo layer. Toggle off: buildGraph skips the geo block → country nodes and geo edges vanish → the simulation settles back to relational layout in a few hundred ms. The networkMercator projection in src/lib/world-map-projection.ts is kept as library code (still tested) even though the layout no longer uses it — see Decision #74 for why we moved away from geographic pinning.

Selection spotlight: when a node is selected, relevantIds = {selectedId} ∪ 1-hop neighbors is computed once per (selectedId, highlightedIds, graph) change in the currently visible graph (so layer toggles change which neighbors count). When no node is selected but ?address= is set, the spotlight falls back to relevantIds = highlightedIds ∪ 1-hop neighbors so an address search dims everything outside the wallet's footprint — see Decision #78. Nodes outside the set render at group opacity = 0.18; their labels do too (the floating Badge layer reads the same relevantIds); edges where neither endpoint is in the incident set (selected node OR any highlighted node) go faded (OPACITY[type] × 0.2). Combined with the existing kind-colored highlight + halo on the focused node and its incident edges, the page reads as a clean spotlight on the 1-hop subgraph. With no selection and no address, all nodes/edges render at full opacity and the dim path short-circuits.

Focus mode (?focus=<id> or chained ?focus=A,B,C) replaces visibleGraph with egoSubgraph(fullGraph, focusId) — the focused node (the last element of the stack), its 1-hop neighbors, and the edges between them. Focus chain is encoded in the URL as a comma-separated list, last = active — useNetworkGraph parses it via parseFocusStack() and exposes both focusId and the full focusStack. Each "Focus" action appends to the stack and router.push()es the new URL (selection clicks use router.replace() so they don't pollute the back stack). Focus state is exposed as a compact pill (‹ Focused: <name> ×) rendered by network-focus-pill.tsx: the leading calls a page-level onStepBackFocus that pops the last entry from the URL stack and router.replace()s the trimmed URL, the trailing × deletes the focus param entirely. The pill is independent of browser historyrouter.back() would have rewound to whatever URL preceded the first Focus action (e.g., a parent app at /admin/network), so the URL stack is now the source of truth (see Decision #75). Browser back/forward still walks the same chain because each Focus is a router.push. The pill renders inside the detail panel between the header and content when the panel is open — so back-and-forth navigation feels like one cohesive flow. When the panel is closed but focus is active, the same pill renders in the toolbar row as a fallback so focus is always escapable. The earlier full-width focus banner was dropped because the panel covered it.

Address deep-link / search (?address=0x...): treated as a first-class selection signal (Decision #78). highlightedIds matches nodes where n.id === address (staker self-match), n.owner === address (CCN/CRN owner), or n.reward === address (CCN/CRN reward target) — three sources merged, all lowercased. Those nodes get a pulsing primary-color ring (network-node-pulse keyframe) and the auto-refit zooms to fit them. When no other node is ?selected=, the page renders a dedicated NetworkSearchAddressPanel (right side, 280px, same chrome as the node panel) showing the copyable address, "Linked to N nodes" count, a NetworkStakingSection listing every CCN where the address appears in c.stakers with per-position and total ALEPH, and an "Open wallet view →" link. Closing the address panel clears both ?address= and the search input. The Selection-spotlight path also fires on address (above).

Detail panel (?selected=<id>): a 280px floating card anchored to right-6 top-40, sized to its content with max-h-[calc(100%-11rem)] so tall content scrolls inside instead of overflowing the viewport. Background matches the sidebar (bg-muted/40 dark:bg-surface) so the panel reads as part of the same chrome layer rather than a separate surface; rounded-xl border shadow-md finishes the card. Positioned to clear the toolbar row above (which now also holds the focus pill — see below). The panel is composed of a shared shell (network-detail-panel.tsx) that renders the header (StatusDot + title + Focus + ×) and the optional "View full details →" footer (CCN/CRN only), plus three presentational bodies (network-detail-panel-ccn.tsx, network-detail-panel-crn.tsx, network-detail-panel-address.tsx) selected by node.kind. The shell looks up CCNInfo/CRNInfo from nodeState (now exposed by useNetworkGraph) and the parent CCN for CRNs; the CRN body keeps useNode(hash) for resource bars and VM count. Per-kind content is graph-relevant only — CCN shows score (formatted as percentage, e.g. "92.5%") + Location row (flag emoji + country name from country-centroids.json), CRN/Stakers stat tiles, total staked, owner, reward; CRN shows status, VM count, Score% row, Location row, parent CCN (clickable), CPU/Memory bars, owner; staker/reward shows the address with copy + wallet link, a degree summary against the visible graph, and a NetworkStakingSection listing CCNs the address stakes on (with per-position and total ALEPH). CCN/CRN panels also surface pending/understaked state inline: a small italic muted line below the dl reads "Registered but has no attached CRNs yet" (pending CCN), "Not yet active — activation needs 500,000 ALEPH total staked" (understaked CCN), "Owner must hold 200,000 ALEPH before others can stake on this node" (owner-locked CCN — takes priority over the other two messages, see Decision #83), or replaces the bare under "Parent CCN" with "Registered but not yet adopted by a CCN" (pending CRN). Heavier content (GPU lists, VM lists, history tables) lives only on /nodes?view=<hash>. The Focus action sets both ?focus and ?selected so the panel stays open after focusing — clicking Focus on a CRN's parent-CCN link rebinds the panel to the parent in one click.

Node visuals: base radii are CCN=16 (with a tight r+2 outer ring), CRN=11, staker=5, reward=6 — bumped from the original 13/8/3/4 so dots read on a dark background at low zoom. All nodes render an opaque var(--color-background) underlay before the translucent (fillOpacity=0.18) colored fill so edges don't bleed through. Stroke width is 0.75 for clean edges. CRN fill/stroke goes through a --network-crn CSS token (currently aliased to var(--color-success-500) green) instead of var(--color-success-500) directly, so a future hue change is a one-line token edit. Adaptive sizing: the nodeScaleForZoom(k) helper returns a multiplier applied to every radius (and to the <marker> arrow size + the label gap calculation): 1 in the comfortable band [0.6, 1.5], boosted up to ~1.9× at k=0 so dots stay visible when zoomed out, eased down to 0.7× above k=1.5 so dense clusters don't crowd. The result is quantized to 0.1 steps so smooth zoom doesn't thrash 500+ memoized NetworkNode re-renders; the scale is threaded through as a sizeScale prop on NetworkNode. Selection renders a translucent halo behind the node body — a single filled circle (rect for reward) at r + 8, in the node's own color at fillOpacity=0.25. No animation; the address-deep-link pulse (network-node-pulse) stays its own visual so the two states remain distinguishable when stacked. Dim (selection-spotlight): a dimmed prop on NetworkNode overrides the group opacity to 0.18 when set, taking precedence over inactive's 0.6 so unrelated nodes recede uniformly under any prior state. Pending / understaked / flagged states (Decisions #80, #83, #84, #85): buildGraph precomputes pending: boolean (CRN with parent=null at status "waiting", or CCN with resource_nodes=[] that's below the activation rule), understaked: boolean (CCN with attached CRNs that's below the activation rule), and flagged: boolean (linked CRN with score < CRN_SCORE_THRESHOLD = 0.8 OR scheduler reports "unreachable"; sourced from useNodes() and threaded into buildGraph as crnStatuses?: Map<hash, status>). Visual treatment differs: pending → grey body + pending ring (grey dotted ring, strokeWidth=0.75, dasharray="2 2", strokeOpacity=0.6) at body opacity 0.6. Understaked and flagged → kind color body at full opacity (no dim) + warning ring (var(--color-warning-500), same 0.75/2-2/0.6 ring geometry as the pending ring — only the stroke color changes) so the alert state reads at first glance without sacrificing topology identity. Detail panel cascade for flagged CRNs: pending > unreachable > low-score, with cause-specific italic notes ("Unreachable — scheduler health check is failing." / "Low score (X.XX) — below the 0.8 threshold."); dotStatusFor returns degraded and crnChipVariant returns warning for flagged CRNs. Activation has two gates exported from network-graph-model.ts: CCN_OWNER_BALANCE_THRESHOLD = 200_000 (the owner address must hold this much ALEPH on-chain, summed across chains) and CCN_ACTIVATION_THRESHOLD = 500_000 (total stake required for the node to go live). Owner balances come from getOwnerBalances(addresses) in src/api/client.ts — fetches /api/v0/addresses/<addr>/balance in parallel batches of 20 via Promise.allSettled — wrapped by useOwnerBalances(nodeState) (React Query, 5-min staleTime, keyed by sorted owner list). useNetworkGraph threads the resulting Map<lowercased addr, number> into buildGraph(state, layers, ownerBalances?, geo?). The combined predicate isBelowActivation(totalStaked, ownerBalance) returns true if totalStaked < 500k; otherwise enforces the owner gate only when ownerBalance != null — so the brief window before balances finish loading doesn't flash the whole network as locked. Helpers ccnOwnerBalance(balances, owner) (case-insensitive lookup) and isBelowActivation centralize the predicate so the graph code, panel logic, and Badge variant all key off the same source of truth. Pending nodes render in neutral grey (var(--color-neutral-500)) with the pending ring at r+3 (strokeDasharray="2 2", strokeLinecap="round") at opacity 0.6, and get force-clustered toward (500, 300) via dedicated forceX/forceY at strength 0.15 (set on both the warmup and live sims so the cluster is in place from the first paint). Understaked nodes keep their kind color at full opacity and get the warning ring (same geometry, amber stroke), no clustering — pulling them out would have visually severed them from their attached CRNs. Labels (CCN/CRN only) render as DS Badge (fill="outline", size="sm") with kind/status-mapped variants (ccn → default, crn → success, unreachable → error, inactive → info, pending → default — neutral grey to match the pending ring); the labelVariant() helper lives in network-graph.tsx and the gap from the node is r * nodeScale * k + 8. Per-kind label zoom (Decision #81): CCN labels appear at zoom ≥ LABEL_ZOOM_THRESHOLD = 1.5, CRN labels at zoom ≥ LABEL_ZOOM_THRESHOLD_CRN = 3 (CRNs are 5–10× more numerous per cluster, so the higher bar prevents the focused-country wall-of-text). Country labels bypass both thresholds. Dimmed nodes' labels also render at opacity: 0.18. Status-to-dot-and-badge mapping: the detail-panel dotStatusFor() returns healthy for both "active" (CCN) and "linked" (CRN), so linked CRNs get a green StatusDot in the panel header; crnChipVariant returns success for the same set, so the Status Badge renders green-outlined. Without this mapping linked CRNs would fall through to degraded/warning (amber).

Search: network-search.tsx is a controlled component (page owns q via useState, passes q + onChange + onSearchFit) so the page can clear the input from non-input handlers — onClosePanel and onCloseAddress both clear it, matching the pattern "dismissing the result of a search clears the search field too." The form width is pinned at max-w-[280px] to match the detail-card width, with gap-0.5 between the Info-icon Button (sized via !size-7 !p-0 override to a 28×28 icon-only target instead of the DS Button default px-4 lozenge) and the input. Zoom-on-result: non-country matches call onSearchFit(match.id), which bumps a fitNonce counter and sets fitTargetId on the page; network-graph.tsx listens via useEffect([fitNonce, fitTargetId]) and fits the camera to the matched node + 1-hop neighbors with the same 450ms transition used elsewhere (resetting userMovedRef so the fit fires even after a drag). Country matches still go through the focus mechanism (which refits to the ego subgraph) and skip onSearchFit. Address searches use the address-deep-link fit path. Reset-view clears fitTargetId so the next refit falls back to fit-all.

Dependencies added: d3-force, d3-zoom, d3-drag, d3-quadtree, d3-selection, d3-transition — modular d3 packages, no full d3 bundle.

Key files: src/app/network/page.tsx (chrome overlay; renders the toolbar focus-pill fallback; owns search query + fit-target state), src/components/network/network-graph.tsx (d3 integration), src/components/network/network-node.tsx, src/components/network/network-edge.tsx, src/components/network/network-detail-panel.tsx (shell + dispatcher; renders the in-panel focus pill), src/components/network/network-detail-panel-ccn.tsx, src/components/network/network-detail-panel-crn.tsx, src/components/network/network-detail-panel-address.tsx, src/components/network/network-detail-panel-country.tsx, src/components/network/network-search-address-panel.tsx (panel rendered when ?address= is set with no ?selected=), src/components/network/network-staking-section.tsx (reusable staking-positions list — used by both address panels), src/components/network/network-mobile-summary.tsx (portrait-only three-section summary rendered below md), src/components/network/network-focus-pill.tsx, src/components/network/network-layer-toggles.tsx, src/components/network/network-search.tsx, src/components/network/network-legend.tsx, src/lib/network-graph-model.ts (pure builder + types, plus shared dotStatusFor used by both desktop panel and mobile summary; reads node-locations.json + country-centroids.json — country attribution runs unconditionally, geo-layer nodes/edges are layer-gated), src/lib/network-mobile-aggregates.ts (pure country + reward-address aggregators for the mobile summary), src/lib/network-focus.ts (ego subgraph), src/lib/network-address-info.ts (countryName() + getStakingPositions() + totalStaked()), src/lib/world-map-projection.ts (Mercator helpers; networkMercator for the geo layer), src/lib/country-flag.ts (ISO → flag emoji), src/hooks/use-network-graph.ts (URL-driven layer/focus state, exposes nodeState). Tests: src/lib/network-graph-model.test.ts, src/lib/network-focus.test.ts, src/lib/network-mobile-aggregates.test.ts, src/lib/country-flag.test.ts, src/lib/world-map-projection.test.ts, src/components/network/network-detail-panel*.test.tsx.

Notes: The getNodeState() parser in src/api/client.ts previously dropped CCN→CRN parent links; this branch reinstates them via a parent field on CrnNode and a resourceNodes: string[] field on CcnNode, used by the graph builder. Static export requires the page to wrap <NetworkContent> in <Suspense> because of useSearchParams() (Next.js 16 errors otherwise).

CSS Token Guard

Context: A mobile-menu animation referenced var(--duration-default) — a token that doesn't exist in globals.css or @aleph-front/ds's tokens.css. The browser fell back to the property's initial value (0s) and the animation collapsed to instant. The bad reference came from a plan file that had copied it from earlier code; nobody verified the token existed at write time. CSS references look valid to TS and oxlint, so the bug is silent until someone notices the broken UI. Approach: scripts/check-css-tokens.ts collects every --name: declaration from src/app/globals.css and node_modules/@aleph-front/ds/src/styles/tokens.css, walks src/**/*.{css,ts,tsx} (excluding *.test.*) for var(--name) references, and reports any reference whose token isn't declared. References with a fallback (var(--x, default)) are skipped — those are robust to a missing token. Wired into pnpm check via a check:tokens script between typecheck and test; exits 1 with file:line and a remediation hint on any unresolved reference. Key files: scripts/check-css-tokens.ts, package.json (check:tokens, check). Notes: Dynamic CSS custom properties set via inline style (e.g. style={{ "--stat-tint": color }}) are exempt because consumers read them with a fallback. Stylelint has a similar plugin (stylelint-use-defined-variables) but adds a stylelint config + dependency; the focused 80-line script does the same job without new dev-dep surface (Decision #100).

Build-Time Data Preparation

Context: Per-node geolocation requires DNS resolution and an IP-to-country lookup. Doing both at runtime would add latency, network noise, and per-client cost; doing it once per build collapses that to a static JSON read. Approach: scripts/build-node-locations.ts runs as a prebuild step (chained into pnpm build via package.json's build script: tsx scripts/build-node-locations.ts && next build). It fetches the corechannel aggregate from api2.aleph.im, parses CCN /ip4/.../tcp/... multiaddrs and resolves CRN HTTPS hostnames via dns.resolve4(), runs each IP through the bundled ip3country DB, and writes src/data/node-locations.json (hash → { country }). The script is never the source of truth for failure: if api2 is unreachable, the response is non-OK, or the new dataset is < 50% of the previous (ABORT_FRACTION), it warns and keeps the existing committed JSON — production builds never silently regress to an empty map. pnpm build:locations runs the script standalone for ad-hoc refreshes. scripts/build-country-centroids.ts is a one-shot that materializes src/data/country-centroids.json from the world-countries package — re-run only when the upstream package adds new ISO codes. Key files: scripts/build-node-locations.ts, scripts/build-country-centroids.ts, src/lib/world-map-resolution.ts (pure helpers, unit-tested), package.json (build, build:locations) Notes: Both JSON outputs live under src/data/ (not public/) so they're imported as ES modules — type-aware, bundled with the route, no runtime fetch. Pure parsing helpers (parseIpv4FromMultiaddr, parseHostname) live in src/lib/world-map-resolution.ts and are shared with any future runtime consumer. The tsx import of a sibling TS file uses an explicit .ts extension, which is how tsx's ESM loader resolves it.

Cross-Page Navigation via URL Search Params

Context: Users need to drill from overview cards to filtered list pages, and between node/VM detail panels. Approach: URL search params (?status=, ?selected=, ?hasVms=, ?sort=, ?order=, ?view=) are the cross-page communication mechanism. Pages read params on mount via useSearchParams() to initialize local state (read-once, no write-back). Overview hero stat cards use <Link> to navigate to filtered list pages (e.g. /nodes?status=healthy). Overview activity cards (Top Nodes, Latest VMs) link directly to detail views via ?view=hash. Detail panels use <Link> for cross-entity references. Requires <Suspense> boundary in static exports since search params aren't known at build time. Key files: src/app/nodes/page.tsx, src/app/vms/page.tsx, src/components/node-health-summary.tsx, src/components/vm-allocation-summary.tsx, src/components/node-detail-panel.tsx, src/components/vm-detail-panel.tsx Notes: Tables accept initialStatus, initialHasVms, and initialSort props to seed filter/sort state from URL params. Validation via Set.has() prevents invalid status values from breaking the UI. DS Table activeKey prop highlights the selected row with a left border accent (inset box-shadow); the same accent appears on hover for all clickable rows. The DS Table has no initial sort API — pre-sort data before passing it to <Table>.

Detail Views (Full-Width)

Context: Side panels show truncated data (10 history rows, no owner/IPv6/payment fields). Users need a full view with all metadata and complete history. Approach: Search-param-based view switching. When ?view=hash is present on /nodes or /vms, the page renders a NodeDetailView or VMDetailView instead of the table+panel layout. Side panels remain as quick-peek with a "View full details →" link. Page titles flow through the DS PageHeader (sticky chrome row above page content): each /nodes and /vms page calls usePageHeader to register a data-aware title (Nodes · N total / VMs · N total) and a Refresh action; the shell still falls back to routeTitle(pathname) for any page that hasn't registered. Cross-links between detail views use ?view= (not ?selected=). Key files: src/components/node-detail-view.tsx, src/components/node-detail-view-ccn.tsx, src/components/vm-detail-view.tsx, src/app/nodes/page.tsx, src/app/vms/page.tsx, src/components/app-shell.tsx, src/lib/route-title.ts Notes: Uses search params instead of dynamic route segments (/nodes/[hash]) because IPFS static export can't resolve arbitrary dynamic paths. New API fields surfaced: owner, supportsIpv6, discoveredAt (nodes), allocatedAt, lastObservedAt, paymentType (VMs). VM panels/detail views cross-reference the allocated node via useNode(hash) to display the node name alongside the hash link. Both detail views show an error card (with back button and error message) instead of rendering blank when the API call fails. Secondary fetches (history, related VMs) use .catch(() => []) so the primary entity still renders even if history endpoints fail. The "← Nodes" / "← Virtual Machines" back navigation uses router.back() instead of a hardcoded <Link> so it returns to the actual previous page (e.g. Overview, Issues) rather than always navigating to the list page.

CCN dispatch. useNode(hash) hits /api/v1/nodes/<hash>, which only knows CRNs — CCN hashes used to land on "Node not found". NodeDetailView now reads useNodeState() early and dispatches to NodeDetailViewCcn when nodeState.ccns.has(hash), passing the CCNInfo from nodeState plus the owner balance from useOwnerBalances() so the activation-gate cascade (owner-locked > pending > understaked) reads consistently with the network graph CCN panel. The CCN shell renders an Overview tab built from nodeState alone (no extra API: hash, score, owner, reward, total staked, attached CRN count, italic activation notes, Linked CRNs list, Stakers table) and the Earnings tab via NodeEarningsTabCcn. The CRN branch is unchanged — useNode() still drives the existing resources/GPUs/VMs/history cards. Loading is gated on nodeLoading || (!node && stateLoading) so CRN visits don't wait for nodeState; CCN visits wait long enough to read the CCN entry. This also fixes the "View full details →" link in the network-graph CCN panel, which has always routed to /nodes?view=<ccn-hash> but couldn't resolve until now.

Overview / Earnings tabs. Both NodeDetailView (CRN) and NodeDetailViewCcn wrap their body in DS Tabs (variant="underline", size="sm") with Overview + Earnings triggers. The active tab is URL-driven via ?tab=earnings (Overview is the implicit default — the param is removed rather than set to overview so default URLs stay short); /nodes/page.tsx reads ?tab= and threads initialTab into the detail view. Range selection on the Earnings tab persists via ?earningsRange=24h|7d|30d. Inside the Earnings tab the per-role composition (NodeEarningsTab / NodeEarningsTabCcn) renders KPI row + dual-line chart + breakdown table; the underlying numbers come from useNodeEarnings(hash, range) (see "Per-Node Earnings" recipe below).

Compute Units (CU). src/lib/compute-units.ts derives CU for a CRN. Total capacity (computeNodeCuTotal(node)) = min(cpuCu, ramCu, diskCu) — the limiting resource — floored. Available CU = how many more CU the node can host = the scarcest of its remaining resources after subtracting every allocated VM's requested vCPU / RAM / disk: max(0, floor(min(freeVcpu, freeRamCu, freeDiskCu))). Used CU = total − available, so the three always reconcile (used + available = total, used ∈ [0, total]). All three dimensions are hard placement limits — a node with no free vCPU is full even if RAM/disk remain. computeNodeCu(node, vms) returns { total, used, available, isGpu } | null (null when node.resources is absent). Standard and confidential nodes use the ratio 1vCPU/2GB/20GB; a node uses the GPU ratio 1vCPU/6GB/60GB only while it has a free, unallocated GPU (gpus.available non-empty) — once every GPU is in use it reverts to the standard ratio, since only standard instances can still be placed. CU comes from VM allocation, not the node's *Available resource fields — those track live hardware utilization, not what is committed. CU is surfaced on four places: (1) Nodes table — the vCPUs column is replaced by a CU column showing total capacity only (the node-list payload has no per-VM requirements), with a per-cell hover tooltip carrying the class formula; (2) Node detail view (node-detail-view.tsx) — the Resources card adds a CU usage bar with an N CU available · standard/GPU-class caption; (3) Node quick-peek panel (node-detail-panel.tsx) — a CU dl row showing used / total CU · N free; (4) Network graph CRN panel (network-detail-panel-crn.tsx) — a CU line in the Resources section. The detail view, panel, and network panel all load the node's VM list, so they show total + used + available. Decision #107.

List Page Filter Pipeline

Context: Both Nodes and VMs pages need text search, status filters, and advanced filters (checkboxes, range sliders) — all client-side. Approach: Four-stage pipeline applied in useMemo: (1) textSearch matches query against configurable fields, (2) applyNodeAdvancedFilters / applyVmAdvancedFilters applies checkbox and range filters, (3) countByStatus computes per-status counts on the filtered set (for badge display), (4) status filter selects a single status. Status is applied last so count badges show accurate per-status breakdowns after search+advanced filters. All filters are client-side post-fetch — none go in the React Query key. State setters wrapped in useTransition for responsive UI. Search input debounced at 300ms via useDebounce. The CollapsibleSection component uses CSS grid-template-rows animation for smooth expand/collapse. Filter panel uses a 3-column layout (lg:grid-cols-3) with glassmorphism card styling. Key files: src/lib/filters.ts (pure filter functions + types), src/lib/filters.test.ts, src/hooks/use-debounce.ts, src/components/collapsible-section.tsx, src/components/filter-toolbar.tsx, src/components/filter-panel.tsx, src/components/node-table.tsx, src/components/vm-table.tsx Notes: The visual shell (status tabs, optional filter toggle button, search input, DS Card panel chrome with reset) is shared via FilterToolbar and FilterPanel — both tables compose these with their own status config, filter content, and grid layout. FilterToolbar is generic over the status type and accepts an optional leading slot (rendered before status tabs, separated by a vertical divider) for page-specific controls like the Issues perspective toggle. Mobile layout: the toolbar stacks vertically below md (flex-col gap-2 md:flex-row md:flex-wrap md:items-center) so each section gets a full-width row instead of competing for horizontal space; the divider between leading and the status tabs is hidden on mobile. Each TabsTrigger carries shrink-0 whitespace-nowrap so tab labels never wrap — without it, flex-shrink would squeeze long labels like "Has Orphaned (313)" into multi-line buttons during DS Tabs's overflow="collapse" measurement pass, locking in a too-tall min-height that survives the hide step. With shrink-0, tabs keep their natural width, DS detects overflow correctly, and hides surplus tabs into the dropdown (Decision #103). The filter toggle button only renders when onFiltersToggle is provided — pages without advanced filters (e.g. Issues) omit it. FilterPanel wraps content in a DS Card component. Status filters use DS Tabs with variant="underline" and overflow="collapse" — tabs that overflow the container automatically collapse into a dropdown. A toTabValue() helper maps the generic status type (which may be undefined for "All") to string values for Radix Tabs. Tooltips use native title attribute on TabsTrigger. Multi-select filters (VM type, payment status, CPU vendor) treat "all selected" and "none selected" identically as "no filter." Count badges show filtered/total format when non-status filters are active. The VmType values are lowercase ("microvm", "persistent_program", "instance") matching the API wire format. Boolean checkbox filters: Staked, IPv6, Has GPU, Confidential (nodes); Allocated to a node, Requires GPU, Requires Confidential (VMs). Multi-select: CPU Vendor (AMD, Intel) on nodes. Filter panel uses a 4-column layout on nodes (lg:grid-cols-4: Properties, CPU Vendor, Workload, Hardware). Range slider extents (vCPUs, memory, VM count) are computed from the loaded fleet via computeNodeFilterMaxes / computeVmFilterMaxes, rounded up to the next power of two with a floor (NODE_FILTER_MAX_FLOOR, VM_FILTER_MAX_FLOOR), so the slider always covers every visible row even as the fleet's largest node grows. The same maxes are passed to applyNodeAdvancedFilters / applyVmAdvancedFilters so the "is this filter active?" check uses the dynamic extent rather than a hardcoded constant. The Nodes Hardware group gained a CU range slider alongside the existing vCPUs and Memory sliders — CU is derived via computeNodeCu (src/lib/compute-units.ts) and filtered against the cuTotalRange field added to NodeAdvancedFilters; computeNodeFilterMaxes also returns a cu extent from the fleet.

Retention window (always-on lens). The VMs page filters by recency, not status: a selectable retention window (7d / 30d / 90d / All, default 7d) keeps a VM when its most recent activity is within the window. The pure helper applyRetentionWindow(vms, window, now) in src/lib/filters.ts keys on max(lastObservedAt, updatedAt, allocatedAt) >= now − windowlastObservedAt (a node still sees it), updatedAt (any projection change), and allocatedAt (covers a freshly scheduled VM not yet observed); window === "all" returns the input unchanged. now is injected so callers pass Date.now() and tests pin a fixed clock. The window is surfaced as a DS Tabs pill selector in the FilterToolbar leading slot, seeded from the initialRetention prop and persisted two-way via ?retention= (param omitted at the 7d default; RETENTION_WINDOWS validates the param). It runs in the pipeline only when no explicit lookup is active — a non-empty hash/name search or a valid owner address (hasLookupQuery, lifted to component scope) bypasses it so a targeted VM always surfaces regardless of age (Decision #110, carrying forward the #109 "explicit request wins over the browse-mode cull" principle and aligning VMs with the Nodes page, which has no cull). Status pills slice within the window; advanced filters (GPU, vCPU/memory ranges) are browse-refinement and apply inside it. Retention is a primary lens, not an advanced filter — the FilterPanel Reset clears advanced filters + owner but leaves the window untouched. Count badges: filteredCounts is computed post-window, so per-pill and All-tab badges are plain in-window counts; during a lookup (window bypassed) formatCount switches to a matched/all-time slash (filtered/unfiltered) so the numerator stays a subset of the denominator. The two other surfaces that count VMs apply their own windows: getOverviewStats in src/api/client.ts counts applyRetentionWindow(vms, DEFAULT_RETENTION, Date.now()) for the Overview headline, and useIssues applies ISSUES_RETENTION (30d) before deriving discrepancies. Phase 1 is client-side; the scheduler-side active_since param (aleph-vm-scheduler#179) is tracked in BACKLOG for a transparent server-side swap.

Tab visibility cap. The DS Tabs component (@aleph-front/[email protected]+) supports an optional maxVisible?: number prop that caps the visible tab count regardless of available width — used on the VMs page (via FilterToolbar's maxVisibleStatuses prop) to lock the visible set to All/Dispatched/Scheduled, with the rest in the existing overflow dropdown. When both width-based collapse and maxVisible are present, the stricter limit wins. Other list pages (Nodes, Issues) omit the prop and keep pure width-based collapse, so they remain unchanged.

Server-side filters (selective). getVMs() forwards owner?owners= and schedulingStatus?scheduling_status= to the scheduler. Other filters (vmTypes, requiresGpu, ranges, etc.) stay client-side — the dashboard fetches the whole VM list anyway and in-memory filtering is instant. Server-side is reserved for filters where the payload reduction is dramatic (owner = thousands → tens) and the user enters a deliberate query (vs. rapid toggles where a refetch + loading state would degrade UX). The owner input on /vms is debounced 500ms and validated against /^0x[0-9a-fA-F]{40}$/; only valid addresses are passed to useVMs({ owner }) so mid-typing keeps the full fleet visible. ?owner= persists the raw input; the filter-panel Reset clears it and the toolbar's active-filter dot lights up when a valid address is in the input. schedulingStatus ships as plumbing only — no UI consumer yet (see Backlog for Issues divergence detection). URL construction unit-tested in src/api/client.url.test.ts; debounce + URL persistence smoke-tested in src/components/vm-table.test.tsx. See Decision #88.

Issues Page — Derived Data Views

Context: DevOps investigating scheduling discrepancies had no dedicated view. Approach: /issues page with a VMs|Nodes perspective toggle (?perspective=vms|nodes). No new API calls — useIssues() hook combines useVMs() + useNodes() to derive discrepancy sets. VM perspective table: Status, VM Hash, Issue, Scheduled On, Observed On, Last Updated. Node perspective table: Status (StatusDot + Badge), Node Hash, Name, Orphaned, Duplicated, Misplaced, Missing, Total VMs, Last Updated. Status pills and text search, no advanced filters (data set is small). Accessible from the sidebar utility section (alongside API Status) — positioned as a dev/ops diagnostic tool, not primary navigation. Key files: src/app/issues/page.tsx, src/hooks/use-issues.ts, src/components/issues-vm-table.tsx, src/components/issues-node-table.tsx Notes: IssueVM extends VM with issueDescription. IssueNode bundles a Node with discrepancy counts and the list of discrepancy VMs associated with it. The perspective toggle uses DS Tabs with variant="pill" (@aleph-front/ds/tabs), rendered inline with status pills via FilterToolbar's leading slot. Five DiscrepancyStatus values: orphaned, duplicated, misplaced, missing, unschedulable. Node perspective filter pills: All / Has Orphaned / Has Duplicated / Has Misplaced / Has Missing. Node detail panel shows individual summary cards for each discrepancy type with the affected VM list below.

Wallet View — Cross-API Entity Page

Context: Ops needs to investigate a specific wallet's resources and activity across the scheduler and Aleph network. Approach: /wallet?address=0x... page combines data from three sources: scheduler API (nodes filtered by owner, VMs cross-referenced by hash), api2 messages endpoint (VM ownership via sender, activity timeline), and api2 authorization endpoints (granted/received permissions). useWalletNodes() filters existing useNodes() cache — no extra API call. useWalletVMs() fetches message hashes from api2 then cross-references against useVMs() for scheduler status. Activity section has a manual refresh button (invalidates React Query cache) for live troubleshooting. All wallet addresses in the dashboard (node owner, permission addresses) are clickable <Link>s to the wallet view, enabling wallet-to-wallet navigation. Key files: src/app/wallet/page.tsx, src/hooks/use-wallet.ts, src/api/client.ts Notes: VMs not found in the scheduler show "not tracked" status. Activity items link to Explorer for deep detail. Permissions show inline scope tags (types, channels, post_types, aggregate_keys). No sidebar entry — wallet view is a utility page reached via address links.

Credit Distribution — Shared 24h Cache

Context: The Credits page and Wallet page both need credit expense data. (The wallet's owner revenue view now sources reward numbers from the authoritative Rewards Data Layer — see that section / Decision #111 — not this shared cache.) Approach: useCreditExpenses(start, end) is the shared React Query hook. Both pages compute stable timestamps via the shared getStableExpenseRange(seconds) helper (rounds to 5-minute intervals) so the query key stays consistent across mounts, page navigations, and the persisted cache. RANGE_SECONDS exports the canonical 24h/7d/30d window lengths. Key files: src/hooks/use-credit-expenses.ts (hook + getStableExpenseRange + RANGE_SECONDS), src/lib/credit-distribution.ts (computeDistributionSummary) Notes: CRN rewards are computed per credit entry (each has a nodeId). CCN rewards use score-weighted pool shares. Staker rewards use stake-weighted pool shares. Node state weights are precomputed once (stable across expense messages). The credits page consumes this shared cache; the wallet's owner revenue view moved to the Rewards Data Layer (Decision #111).

Per-Node Earnings (Rewards-Layer Apportionment)

Context: Node detail view needs a per-CRN / per-CCN trailing ALEPH-accrued chart with a VM-count (CRN) or linked-CRN-count (CCN) overlay, plus a delta vs the previous same-length window. The original implementation reconstructed this client-side from the whole-network credit-expense feed (computeDistributionSummary with bucket options) — accurate per-bucket, but it missed the wage subsidy entirely, used the hardcoded 60/15/20 split, and pulled the heavy feed on every earnings surface including the panel sparks. Decision #114 re-sourced it onto the authoritative rewards layer. Approach: useNodeEarnings(hash, range, options?) keeps its name and return contract but internally composes useRewards(rewardAddr, start, end, bucketSize) (current window, bucketed 1h for 24h / 1d for 7d/30d), a second total-only useRewards for the previous window's delta, and — CRN + default weights: "exact" only — useExecutionExpenses (a bounded tags=type_execution api2 fetch, ≈10MB/24h vs ~112MB unfiltered) for per-bucket multi-node split weights and the per-VM table. Pure helpers in src/lib/reward-apportionment.ts do the math: roleTotals(full) (per-role totals incl. wage), computeExecutionBucketWeights(expenses, ownedCrnHashes, buckets) (per-bucket execution ALEPH per owned CRN, bucketed by parent expense message time — entry timeSec is a duration, not a timestamp), apportionNodeBuckets({...}) (splits each bucket's role pools to one node by per-bucket weights when available, else static vmCount/score proxy weights; wage rides the same weights since no per-node grain exists for it), and computePerVmEarnings({...}) (raw per-VM execution ALEPH scaled so the owned-set total matches the address's authoritative (credit_revenue + holder_tier).execution_crn — the realized share replaces the old hardcoded CRN_SHARE = 0.6). Accuracy is tiered by range: exact per-bucket weights at 24h/7d; at 30d the execution window is capped at the trailing 7d (EXEC_WINDOW_CAP_SEC — a 30d execution-only fetch is ~300MB), so the chart split falls back to the vmCount proxy (weightsExact: false) and the per-VM table covers the trailing 7d (with an extra total-only useRewards over that sub-window for the scaling factor). Single-node addresses are exact at every range — no weights needed. NodeEarnings gains bySource (rendered as the shared RewardSourceBar under the ALEPH-accrued KPI via KpiCard.extra) and weightsExact; the hook also returns isError, isPerVmLoading, isPerVmError so the tab renders early from rewards buckets and refines — the chart shows proxy-weighted values immediately with a "Refining node split from execution data…" hint while the exec fetch is in flight, and the per-VM card has its own skeleton / timed-out-error / data three-state render (headline numbers are unaffected by an exec failure). Panel sparks pass { weights: "proxy" }, which skips the execution fetch entirely — sparks cost two cheap rewards queries, nothing else. Key files: src/hooks/use-node-earnings.ts, src/hooks/use-rewards.ts (useRewards + getStableHourRange), src/hooks/use-execution-expenses.ts, src/api/client.ts (getExecutionExpenses, 60s timeout), src/api/rewards-client.ts (bucket parsing), src/api/rewards-types.ts (RewardsBucket), src/lib/reward-apportionment.ts, src/lib/node-vm-history.ts (replayVmCountTimeline), src/components/reward-source-bar.tsx, src/components/node-earnings-tab.tsx, src/components/node-earnings-tab-ccn.tsx, src/components/node-earnings-chart.tsx, src/components/node-earnings-spark.tsx, src/components/dual-line-chart.tsx, src/components/node-earnings-kpi-row.tsx Notes: The "execution-expenses" query key is deliberately not in providers.tsx's PERSISTED_QUERY_PREFIXES — tens of MB don't belong in localStorage; callers pass hour-stable windows (getStableHourRange) so in-memory keys dedupe across surfaces. The per-VM table's Total row sums the table (perVmTotal), not the KPI — the table is execution-only while totalAleph includes wage; at 30d a "Per-VM detail covers the last 7 days." caption marks the window mismatch. The previous-window delta apportions the prev address totals with the current window's realized share (prev execution data isn't fetched — doubling the payload for a delta isn't worth it; exact for single-node addresses regardless). replayVmCountTimeline walks events backward from the window end, decrementing on scheduled / migrated_to and incrementing on unscheduled / migrated_from, clamping at zero; the previous-window VM count flat-lines to currentVmCount (history only covers the recent period). Charts render dual smooth-path SVG lines (Catmull-Rom-to-Bezier via smoothPath() in src/lib/smooth-path.ts) with vectorEffect="non-scaling-stroke"; empty buckets show a role-specific hint ("Pending CCN attachment — earnings start once linked." / "Registered but has no attached CRNs yet." / "Earnings start once the node activates …"). KPI tone (up/down/warning) is driven by sign of the deltas plus the score-vs-0.8 threshold and the CCN's linkedCRNPenalty step (70% / 80% / 90% / 100% at 0 / 1 / 2 / 3+ linked CRNs). Both tabs show a "Rewards feed unreachable" prose error state when the rewards query fails.

Shared chart primitive + spark wrapper. DualLineChart (src/components/dual-line-chart.tsx) is the shared primitive that owns dual-line geometry — two smooth <path data-line> curves built by smoothPath() (Catmull-Rom-to-cubic-Bezier, tension 0; lives in src/lib/smooth-path.ts and is shared with the Credits-page Sparkline so every chart in the app has the same fluid feel), with success-green for the ALEPH primary line and primary-blue at 0.7 opacity for the secondary. A vertical gradient polygon under the ALEPH line (success-500, 0.3 → 0 opacity, top → bottom) gives the chart the same depth as the cumulative-revenue spark on Credits — gradient is intentionally on the primary series only so the secondary line stays a thin reference rather than another filled layer. The SVG uses preserveAspectRatio="none" + vectorEffect="non-scaling-stroke" so the geometry fills the container at any aspect ratio. The optional crosshair is a vertical dashed <line> from y=0 to y=height plus two emphasis dots rendered as HTML <span> overlays positioned by top / left percent with transform: translate(-50%, -50%) so they stay round — embedding them as SVG <circle> would have squashed them into ellipses under preserveAspectRatio="none". An optional pointer-capture <rect> calls back into the wrapper with the snapped bucket index. The whole assembly is wrapped in a position: relative div so the dots can sit on top of the SVG. Wrappers compute everything else: NodeEarningsChart is the tab-scale wrapper that holds hover state and renders a floating HoverCard (bucket time + ALEPH + secondary). Time format is bucket-duration-aware — MMM D · HH:MM for hourly buckets (24h), MMM D for daily buckets (7d / 30d). The card side-anchors relative to the crosshair (translate(8px, 0) when xPct < 0.5, translate(calc(-100% - 8px), 0) otherwise) so it never covers the line + dots it's annotating; data-side="right|left" is exposed on the card for testing. NodeEarningsSpark is the panel-scale wrapper (src/components/node-earnings-spark.tsx): no hover, fixed 24h range, fixed width=240 height=56, plus a one-line caption (X.XX ALEPH · Y.Y VMs scheduled avg for CRN, X.XX ALEPH · N CRNs linked for CCN). The spark consumes useNodeEarnings(hash, "24h", { weights: "proxy" }) directly so each surface gets its own React Query subscription — proxy mode skips the execution-expense fetch entirely, so a spark costs two lightweight rewards queries (deduped across surfaces via hour-stable keys). The spark is embedded on the network-graph CRN panel, network-graph CCN panel, and the /nodes side panel — where it replaced the truncated VMs list block (the panel's VMs count row and View full details → link still cover that signal). Loading shows a DS Skeleton at the spark's height; an all-zero-bucket window shows "No earnings · last 24h" instead of an empty chart. The Earnings tab's per-VM breakdown table (CRN role only) starts collapsed at top-5 + a + N more button; clicking the button expands inline (Show less collapses again) — state lives on NodeEarningsTab so range switches reset it via React's natural component identity.

Reward address reconciliation panel. NodeEarningsReconciliation (src/components/node-earnings-reconciliation.tsx) sits between the chart Card and the per-VM / linked-CRN Card on both NodeEarningsTab (CRN) and NodeEarningsTabCcn (CCN), stacked full-width. It renders a horizontal stacked bar that decomposes the reward address's window earnings into four buckets anchored on this node: this node (kind color — success-green for CRN, primary for CCN), other same-kind (kind color at 0.45 opacity), cross-kind (the other kind's color), and staking (warning amber). Hover is bidirectional: hovering a bar segment OR a legend row dims the other three (both segment and corresponding row drop to opacity-30) — the shared hoveredKey state governs both. The label row repeats the four totals with percentages and a swatch dot. The label grid uses container queries (@container/recon on the Card + @md/recon:grid-cols-2 @2xl/recon:grid-cols-4 on the grid) so it adapts to the Card's own width rather than the viewport — useful if the layout is ever embedded in a narrower column. Header has a View full wallet → link to /wallet?address=<rewardAddr>. The data comes from useNodeEarnings.reconciliation: Reconciliation | null — derived from roleTotals(rewards.full) on the same rewards response the chart uses, no extra API calls; sub-1e-6 float residue in the "other same-kind" segment is clamped to 0 so single-node addresses don't render a scientific-notation sliver. getRewardAddress is exported from src/lib/credit-distribution.ts for reuse. When the reward address is this node's only source (no other CRNs/CCNs/stake earning), the component collapses to a one-liner caption ("

earned only from this node in the last 24h.") rather than rendering a degenerate 100% bar — the explicit confirmation reads as info, not a load failure. Returns null when reconciliation === null (reward address has zero earnings in window), so parent tabs don't branch. Scoped loading states for range transitions. The tabs read isPlaceholderData from useNodeEarnings and thread it as loading only into the parts that actually change between ranges: KpiCard.loading is set per-card (the ALEPH accrued and VMs earning cards on CRN, the ALEPH accrued card on CCN — Score / Status / Linked CRNs keep their real values because none of them depend on range); NodeEarningsChart and NodeEarningsReconciliation take loading={isPlaceholderData}; the per-VM table in NodeEarningsTab swaps only the ALEPH column cell and the Total foot via an inline {isPlaceholderData ? <Skeleton/> : formatAleph(...)}, keeping VM hashes clickable; the linked-CRN table in NodeEarningsTabCcn has no skeleton fallback because none of its columns (CRN hash, status badge, vmCount) are range-dependent. The reconciliation loading branch keeps the rewardAddr, all four colored swatches, and all four labels rendered — only the windowAleph caption, the bar, and the per-segment aleph/percent values swap. Skeleton background uses the design-system bg-edge token (--color-edge resolves to primary-200 light / base-800 dark) so the placeholder reads as one quiet step from the Card surface in both themes — the DS Skeleton's default bg-muted is the same color as bg-surface in dark mode and was effectively invisible. The full-tab skeleton remains only for the cold-load case where data is undefined. Decision #92.

Persisted Query Cache + Prefetch

Context: The credit-expenses query against api2 takes ~20s on a throttled connection — every visit to /credits blocked on it. The wallet page hits the same endpoint for 24h windows. Approach: PersistQueryClientProvider (from @tanstack/react-query-persist-client) wraps the app with a localStorage-backed persister (@tanstack/query-sync-storage-persister). dehydrateOptions.shouldDehydrateQuery whitelists only the credit-expenses query-key prefix — fast-polling queries (nodes, vms, health) and queries containing non-JSON-serializable values stay in-memory only. maxAge: 24h, buster: CURRENT_VERSION so a version bump invalidates persisted entries. Stable 5-minute-rounded timestamps mean cache keys collide across mounts. useCreditExpenses uses placeholderData: keepPreviousData so the cache entry stays populated across range-tab switches and revisits to a previously-fetched range render instantly; the /credits page reads isPlaceholderData from the query and folds it into its isLoading flag so range transitions show skeletons / placeholder chrome on the summary cards, flow diagram, and recipient table instead of holding the previous range's numbers as if they were current (Decision #89). The Credits NavItem in the shell calls queryClient.prefetchQuery for 24h expenses + node-state on onMouseEnter/onFocus (once per mount, guarded by a ref) so the in-memory cache is warm by the time the user clicks. The handlers are passed through DS NavItem's asChild cloneElement to the underlying Next.js <Link> (DS Decision #80, @aleph-front/[email protected]). The 24h prefetch matches the credits page's default range. Key files: src/app/providers.tsx, src/hooks/use-credit-expenses.ts, src/components/app-shell.tsx Notes: Persister storage is undefined during SSR/static-export build (the package supports this). The localStorage key is scheduler-dashboard-rq. The buster field on persistOptions is the React Query mechanism for cache invalidation across deploys — pinning to CURRENT_VERSION from changelog.ts ties cache lifetime to released versions.

Two non-obvious rules for persisted queries (both enforced in shouldDehydrateQuery):

  1. Only persist status === "success" queries. React Query's dehydrateQuery includes the promise field for pending queries; JSON.stringify silently turns Promise objects into {}, and on rehydration the placeholderData: keepPreviousData path can deliver that empty object as data. The result: data is a non-array, non-empty object that bypasses if (!data || data.length === 0) checks and crashes downstream code. Persisting only success-state queries dodges this entirely.
  2. Never persist queries whose data contains Map, Set, Date, or BigInt. They don't survive JSON.stringify/JSON.parse — Maps roundtrip as {} (losing entries and methods), Dates become strings, BigInt throws. node-state is the canonical example: its ccns/crns are Maps, so it's deliberately excluded from persistence.

Credit Flow Diagram — Loading Placeholder

Context: While useCreditExpenses was in-flight the credits page showed a single grey skeleton block where the flow diagram would appear, leaving the page feeling empty during the slow api2 fetch. Approach: CreditFlowDiagram accepts summary: DistributionSummary | undefined. When undefined, it renders CreditFlowPlaceholder — the same SVG layout (source/destination box positions, bezier connector paths) but with var(--color-muted-foreground) colors at low opacity, em-dash values instead of ALEPH amounts, no animated particles or gradients, and a subtle animate-pulse on the SVG. Boxes use the same BOX_W/BOX_H constants and Y coordinates as the live diagram so there's no visual jump when data arrives. Key files: src/components/credit-flow-diagram.tsx (CreditFlowPlaceholder, PlaceholderBox)

Credit Flow Diagram — Particle Animation

Context: The credit distribution Sankey-style diagram needed engaging animation to convey flow directionality. Approach: Three-layer SVG rendering per flow path: (1) invisible measurement <path> in <defs> for getTotalLength() and <animateMotion> references, (2) gradient-stroked background path, (3) <circle> particles with <animateMotion> traveling along the path. Source→destination gradients use <linearGradient gradientUnits="userSpaceOnUse">. Particles are randomized (size, speed, opacity, spacing) via a seeded pseudo-random function (Math.sin based) to avoid hydration mismatches from Math.random(). ~20% of particles get a glow effect (feGaussianBlur filter) with larger radius for visual interest. Particles use negative begin offsets to appear pre-populated across all paths on first render (no empty lines on load). All paths from each source box originate from a single point (the box center Y), fanning out to their destinations. Hover dims unrelated paths/boxes (35% flows, 50% boxes) and expands the hovered flow's pill badge to show an ALEPH amount. Percentage labels are pill badges (rounded rect + text) positioned at parametric bezier points with staggered t values (storage paths at t=0.3–0.45, execution at t=0.55–0.7) to avoid overlap. Source boxes have a colored left accent bar. Wrapped in DS Card component. Color mapping: Storage=accent-500 (lime), Execution/CRN=success-500 (green), CCN=primary-400 (purple), Stakers=warning-400 (amber), Dev Fund=error-400 (coral). Each flow has a unique hue for readability in both light and dark modes. Key files: src/components/credit-flow-diagram.tsx, src/app/globals.css (flow-draw, fade-in keyframes) Notes: pathLength is measured via useRef + useEffect for gradient stroke rendering. Particle count scales with flow thickness (max(10, thickness * 2.5)). Base animation duration is 4.5s+ (slow, organic feel). bezierPoint() evaluates the cubic bezier at arbitrary t for label placement.

Credit Revenue Sparkline

Context: Jonathan requested a chart showing "evolution of total credits over time" inside the Total Revenue stat card on the credits page. Approach: Pure SVG sparkline (zero dependencies). buildCumulativeSeries() buckets the already-fetched CreditExpense[] into time intervals (hourly for 24h, 6-hourly for 7d, daily for 30d) and returns a cumulative {t, value}[] series. The Sparkline component renders two <path> elements driven by smoothPath() (src/lib/smooth-path.ts — Catmull-Rom-to-cubic-Bezier, tension 0) so the line is a fluid curve rather than straight segments: a filled area path (line + baseline-right + baseline-left + close) with a vertical gradient fill (0.3 → 0 opacity), and a stroke path on top tagged data-line for test selectors. The same smoothPath() helper powers DualLineChart, so every chart in the app shares one smoothing convention. It bleeds to the card edges via negative margins (-mx-6 -mb-6). Gradient IDs use useId() with colon-stripping (SSR-safe, no CSS.escape). preserveAspectRatio="none" + vectorEffect="non-scaling-stroke" for fluid width with consistent stroke. Key files: src/lib/sparkline-data.ts, src/components/sparkline.tsx, src/components/credit-summary-bar.tsx Notes: Only the Total Revenue card gets the sparkline. Returns null for <2 data points. aria-hidden="true" since the chart is decorative.

Sidebar Categories

Context: With 7+ nav items, flat navigation needed structure, and operators wanted to keep frequently-collapsed sections out of the way without losing them in a hidden overflow popover. Approach: Sidebar renders four DS AccordionSection blocks (@aleph-front/ds/app-shell-sidebar) — Dashboard (Overview), Resources (Nodes, VMs, Credits), Network (Graph), Operations (Health, Issues). Each section can be collapsed/expanded independently; per-section state persists in localStorage["sidebar.section.<id>"] via the DS useAccordionState hook. The whole sidebar can also collapse to an icon rail via the built-in DS collapse toggle anchored at the bottom of AppShellSidebar; that state persists in localStorage["sidebar.collapsed"]. The structure is data-driven from NAV_SECTIONS in src/config/nav.ts — each entry pairs a section id + label with an array of { href, label, iconKey } items resolved at render via NavIcon. The old "More" popover / UtilityMenu is gone: Network Health and Issues are grouped together inside Operations because both are operational-diagnostic surfaces — the Network section is reserved for topology views (the Graph). Key files: src/components/app-shell.tsx, src/config/nav.ts Notes: Section order is significant — Dashboard first (the overview entrypoint), Resources next (the bulk of the work), Network (topology) and Operations (diagnostics) last. Default-open state is decided per section via defaultOpen on AccordionSection; Dashboard / Resources / Network default to open, Operations defaults to closed (defaultOpen={false} passed only for that one section) so the diagnostic surface stays a deliberate click rather than always-on noise (Decision #95).

Client-Side Pagination

Context: Both list pages render hundreds of rows. Displaying all at once hurts scroll performance and makes scanning difficult. Approach: usePagination(items) hook owns page and pageSize state, returns a sliced pageItems array. Pagination is the last step in the filter pipeline: allData → search → advancedFilters → statusFilter → sort → paginate → Table. A useEffect resets to page 1 when any filter input changes. The TablePagination component composes the DS Pagination with a page-size dropdown (25/50/100) and a "Showing X–Y of Z" label. Hidden when total pages ≤ 1. Detail-view history tables (vm-detail-view.tsx, node-detail-view.tsx) also use usePagination over vm.history / node.history directly — the previous "Show N more" expand toggle didn't scale (an orphaned VM observed on 220 nodes produced 16,870 history rows; rendering them synchronously locked the browser for seconds). Pagination caps DOM at one page regardless of history size. The composite React key ${row.id}-${idx} guards against duplicate HistoryRow.id values from the API. Sort scope: Sorting is lifted out of the DS Table and runs on the full filtered dataset before pagination. Each table owns sortColumn / sortDirection state, applies it via applySort from src/lib/sort.ts (which mirrors the DS Table comparison rules so indicator and row order stay in sync), then slices to pageItems. The DS Table operates in controlled mode (sortColumn / sortDirection / onSortChange props from @aleph-front/[email protected]) so it skips its internal sort and renders the indicator from the props. If the table sorted internally on pageItems, clicks would only re-order the visible 25 rows — high-stat rows on later pages would never bubble up. Key files: src/hooks/use-pagination.ts, src/components/table-pagination.tsx, src/lib/sort.ts, src/components/node-table.tsx, src/components/vm-table.tsx, src/components/issues-vm-table.tsx, src/components/issues-node-table.tsx, src/components/credit-recipient-table.tsx Notes: Page clamping happens via setState during render (React's idiomatic pattern for derived-state corrections) to avoid an extra render cycle. Data fetching is unchanged — fetchAllPages still retrieves all records; pagination is purely a display concern.

Responsive Layout

Context: Dashboard must work on mobile, tablet, and desktop. Approach: Two breakpoints: md (768px) for sidebar visibility, lg (1024px) for detail panel layout. Mobile sidebar is a fixed overlay with backdrop. Detail panels (Nodes, VMs) render as full-width slide-in overlays below lg, inline side panels above. Tables use overflow-x-auto for horizontal scrolling on narrow screens. When a detail panel is open on desktop, lower-priority table columns are hidden to prevent the table from being squeezed — columns reappear when the panel closes. Each table defines a COMPACT_HIDDEN_HEADERS set; columns are filtered by header string when compact is true (or when the internal selection state is non-null for self-contained tables like Issues). The FilterToolbar + FilterPanel always render above the flex gap-6 container that holds the table and detail panel side-by-side — this ensures the toolbar gets full width regardless of whether the panel is open. Table components (NodeTable, VMTable) accept a sidePanel prop for the detail panel; the flex layout wrapping Table + TablePagination + sidePanel lives inside the table component. Key files: src/components/app-shell.tsx, src/app/nodes/page.tsx, src/app/vms/page.tsx, src/components/node-detail-panel.tsx, src/components/vm-detail-panel.tsx, src/components/node-table.tsx, src/components/vm-table.tsx, src/components/issues-vm-table.tsx, src/components/issues-node-table.tsx Notes: Uses bg-background token for the content area. Detail panels use glass card styling (bg-foreground/[0.03], border-foreground/[0.06], variant="ghost"), lg:sticky lg:top-0 to stay visible while scrolling, and truncate long lists (6 VMs, 5 history entries) with "+N more" indicators to keep the "View full details →" CTA reachable. Adaptive column hiding priority tiers: Nodes hides GPU/CPU/VMs; VMs hides Type/Node/Last Updated; Issues VM hides Scheduled On/Observed On; Issues Node hides Total VMs/Last Updated.

Mobile full-screen drop-down menu (below md): the DS AppShellSidebar provides expanded ↔ icon-rail collapse only, and on mobile we replace it entirely with a separate MobileMenu component (src/components/mobile-menu.tsx) rather than wrapping the sidebar in transforms. MobileMenu is a full-screen fixed inset-0 z-50 panel that drops from the top edge via the mobile-menu-panel-in keyframe (transform: translateY(-100%) → 0), with a mobile-menu-backdrop-in opacity fade on the fixed inset-0 z-40 backdrop. A prefers-reduced-motion: reduce block in globals.css resets both animation and transform on .mobile-menu-animated so the panel appears in place. The panel has three regions: a header with the current app name (left) and an autoFocus × button (right), a <nav> children slot that renders the same NAV_SECTIONS accordion as desktop, and a footer band with the cross-product tab list (sourced from APPS + ACTIVE_APP_ID, external apps marked with ), version link, and theme toggle. The panel uses role="dialog" + aria-modal="true". Menu state lives in useMobileMenu (src/hooks/use-mobile-menu.ts): auto-closes on usePathname change, on md+ matchMedia change, and on Escape keydown; locks body scroll (document.body.style.overflow = "hidden") while open and restores the previous value on close. AppShell orchestrates the responsive split via hidden md:flex on ProductStrip and the desktop sidebar wrapper, and md:hidden on the MobileMenu fixed children + the standalone mobile hamburger. The hamburger is rendered twice from the same SidebarToggle component: once inside PageHeader.leading with hidden md:inline-flex for desktop (toggles rail/expand via useSidebarCollapse), and once as a fixed right-3 top-3 z-30 md:hidden standalone for mobile (toggles the menu via toggleMenu). The viewport-aware handleSidebarToggle handler dispatches to the right action based on window.matchMedia("(min-width: 768px)"). Per-page Refresh actions are registered via usePageHeader wrapped in hidden md:inline-flex and don't render on mobile — React Query auto-polls at 15–30s so the inline mobile row added in 0.28.0 was redundant noise. The Wallet page keeps an Open in Explorer → link inline on mobile because it's a navigation affordance, not a refresh. Decisions #97, #98, #101.

Wide tables → stacked cards (below md): MobileTableCardRow (src/components/mobile-table-card-row.tsx) takes a primary slot and a fields: { label, value }[] array, renders a bordered card with the primary identifier on top and label/value pairs below, optionally wrapping in a <Link> when an href is supplied. Surfaces using it: Wallet Nodes and VMs (src/app/wallet/page.tsx), CreditRecipientTable (src/components/credit-recipient-table.tsx), Earnings per-VM breakdown (src/components/node-earnings-tab.tsx), Revenue payments (src/components/revenue-payments-table.tsx). Each surface renders both the desktop <table> and the mobile card list, gated by md:hidden / hidden md:block — both in the DOM, CSS picks one.

Credits flow mobile fallback: CreditFlowList (src/components/credit-flow-list.tsx) consumes the same DistributionSummary prop as CreditFlowDiagram and renders a vertical list with Storage and Execution sections, each with their constituent destination rows (CCN 75% / Stakers 20% / Dev fund 5% for storage; CRN 60% / Stakers 20% / CCN 15% / Dev fund 5% for execution). Empty sources collapse silently. The wrapper component (CreditFlowDiagram) renders the list inside md:hidden and the SVG inside hidden md:block.

Earnings chart tooltip: NodeEarningsChart's floating HoverCard carries hidden md:block so it only shows above md; below md, an inline read-out renders directly under the chart, showing the highlighted bucket's time / ALEPH / secondary count, with a "Tap chart to inspect" empty state. The chart's pointer-capture rect (in DualLineChart) already handles touch via onPointerMove, so no changes to the chart primitive.


Recipes

Adding a New Page

  1. Create src/app/<route>/page.tsx
  2. Add nav entry to NAV_SECTIONS in src/config/nav.ts
  3. Verify with pnpm build (static export must include the route)

Typography & Motion System

Context: Dashboard needed to feel more premium to prospective operators evaluating Aleph Cloud. Approach: Three-tier font hierarchy: Rigid Square (headings, via Typekit), Titillium Web (body, via Typekit), Source Code Pro (technical data, via Google Fonts). The --font-mono CSS variable overrides Tailwind's font-mono stack so all existing font-mono usage automatically resolves to Source Code Pro. A shared --ease-spring CSS variable (cubic-bezier(0.16, 1, 0.3, 1)) coordinates entrance animations. Card entrance uses CSS @keyframes card-entrance (opacity + translateY) with staggered animation-delay on overview stat cards. All animations respect prefers-reduced-motion. Key files: src/app/globals.css, src/components/stats-bar.tsx

Network Health Page

Context: The API Status page needed marketing-grade presentation for prospective operators. Approach: Reframed /status as "Network Health" with left-aligned title and a status Badge (success/error variant) showing "All Systems Operational" or degraded count. Glassmorphism stat cards for Endpoints Healthy, Avg Latency (computed from probe results), and Last Checked timestamp. The Recheck action lives in the page header (registered via usePageHeader) — disabled with Checking… label while a probe pass is in flight; the body-level button was removed. Endpoint sections in a side-by-side 2-column grid (lg:grid-cols-2), simplified headers with "N/N healthy" text count (removed per-section donut rings). Same endpoint probing logic — no new API calls. Key files: src/app/status/page.tsx, src/components/app-shell.tsx

API Status Page (legacy name — now Network Health)

See "Network Health Page" above. URL remains /status.

Deploying to IPFS

Context: Static export deployed to IPFS via Aleph Cloud with delegated billing. Approach: Manual workflow_dispatch trigger in GitHub Actions. The workflow builds the site and runs the aleph-rs CLI (aleph website update scheduler-dashboard out/deploy on first run): an authenticated CARv1 upload straight to the CCN (https://api.aleph.im), no public-gateway hop, no DHT-propagation race; the single command pins the content, bumps the websites aggregate, and re-points the domains entry for network.aleph.cloud. The CLI binary is pinned (ALEPH_CLI_VERSION + SHA256 check) in the workflow env. Replaced the previous Python aleph-client SDK script (scripts/deploy-ipfs.py, deleted) — same pattern as stasho-app's deploy-frontend.yml (Decision #116). The build bakes NEXT_PUBLIC_API_URL=https://scheduler.api.aleph.cloud (was the retired rust-scheduler.aleph.im until this migration). Key files: .github/workflows/deploy.yml Auth: CI delegate wallet signs (ALEPH_PRIVATE_KEY secret), owner wallet (0xB136…fCa) pays via --on-behalf-of; the delegate must be authorized in the owner's security aggregate for STORE + AGGREGATE keys [domains, websites] on the ALEPH-CLOUDSOLUTIONS channel (--channel must match or the CCN rejects the writes). Verify a deploy landed: job summary table (website version + volume) and the websites aggregate version advancing — a green job alone isn't proof (delegated writes can be dropped async).

Adding a New API Endpoint

  1. Add types to src/api/types.ts
  2. Add client function to src/api/client.ts
  3. Create hook in src/hooks/ with appropriate refetchInterval