Compare commits
1
Commits
main
...
feat/fleet-board
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52ff7202df |
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* e2e: the per-org Fleet board (feat/fleet-board).
|
||||
*
|
||||
* Fixture render against a LOCAL server with the network mocked (the
|
||||
* provider-billing / budgets-responsive pattern): `/auth/session` → a PLAIN customer
|
||||
* (not an admin — Fleet is the customer's own compute and must render without any
|
||||
* admin claim), and `/v1/fleet` → a fixture that exercises every honest path at once:
|
||||
* a fully-reporting GPU host, an online-but-SILENT laptop (the one state that asks
|
||||
* for attention), a machine that reports NO telemetry (the em-dash rule), a draining
|
||||
* cluster, and an offline box.
|
||||
*
|
||||
* Four states are proven, because all four are real:
|
||||
* (1) the board with data — the happy path, desktop + mobile
|
||||
* (2) the empty fleet — a real org with nothing linked yet
|
||||
* (3) a 404 from /v1/fleet — the backend is not routed on this deployment YET
|
||||
* (the state this ships into until cloud deploys)
|
||||
* (4) the unit detail — the trend over /v1/fleet/samples
|
||||
*
|
||||
* Run: BASE_URL=http://localhost:4000 npx playwright test fleet
|
||||
*/
|
||||
import { test, expect, type Page, type Route } from '@playwright/test'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:4000'
|
||||
const SHOTS = join(process.cwd(), 'e2e-shots')
|
||||
|
||||
/** A PLAIN customer — no admin claim. Fleet must work for them; that is the point. */
|
||||
const ACCOUNT = {
|
||||
owner: 'maxpower',
|
||||
name: 'dave',
|
||||
type: 'normal-user',
|
||||
email: 'dave@maxpower.example',
|
||||
displayName: 'Dave',
|
||||
isGlobalAdmin: false,
|
||||
isAdmin: false,
|
||||
signupApplication: 'hanzo-cloud',
|
||||
}
|
||||
|
||||
const GB = 1024 ** 3
|
||||
const now = () => Math.floor(Date.now() / 1000)
|
||||
|
||||
/**
|
||||
* The fleet fixture — shaped EXACTLY like the documented `/v1/fleet` contract
|
||||
* (camelCase views, `omitempty` semantics: an unreported field is simply absent).
|
||||
*/
|
||||
const units = () => [
|
||||
{
|
||||
// Fully reporting GPU host — every meter has a real value.
|
||||
unit: 'spark-gb10',
|
||||
source: 'byo',
|
||||
kind: 'gpu',
|
||||
label: 'spark',
|
||||
host: 'spark.local',
|
||||
status: 'online',
|
||||
spec: { os: 'linux', arch: 'arm64', cpus: 20, memory: 128 * GB, gpus: [{ vendor: 'nvidia', model: 'GB10', memory: 120 * GB }] },
|
||||
metrics: { load1: 3.2, load5: 2.8, load15: 2.1, memUsed: 48 * GB, memFree: 80 * GB, gpuUtil: 0.86, at: now() - 8 },
|
||||
sessions: 12,
|
||||
running: 2,
|
||||
},
|
||||
{
|
||||
// ONLINE BUT SILENT — 11 minutes since its last heartbeat. The attention case.
|
||||
unit: 'dave-mbp',
|
||||
source: 'agent',
|
||||
kind: 'laptop',
|
||||
label: 'dave-mbp',
|
||||
host: 'dave-mbp.local',
|
||||
status: 'online',
|
||||
spec: { os: 'darwin', arch: 'arm64', cpus: 12, memory: 32 * GB, gpus: [{ vendor: 'apple', model: 'M3 Max' }] },
|
||||
metrics: { load1: 1.1, memUsed: 18 * GB, memFree: 14 * GB, at: now() - 660 },
|
||||
sessions: 3,
|
||||
running: 0,
|
||||
},
|
||||
{
|
||||
// Reports NO telemetry at all — every live cell must be an em-dash, never a 0.
|
||||
unit: 'web-1',
|
||||
source: 'visor',
|
||||
kind: 'machine',
|
||||
label: 'web-1',
|
||||
host: '10.0.0.4',
|
||||
status: 'online',
|
||||
spec: { os: 'linux', arch: 'amd64', cpus: 2, memory: 4 * GB },
|
||||
sessions: 0,
|
||||
running: 0,
|
||||
},
|
||||
{
|
||||
unit: 'prod-cluster',
|
||||
source: 'cloud',
|
||||
kind: 'cluster',
|
||||
label: 'prod-cluster',
|
||||
status: 'draining',
|
||||
spec: { os: 'linux', arch: 'amd64', cpus: 96, memory: 384 * GB },
|
||||
metrics: { load1: 12.0, memUsed: 190 * GB, memFree: 194 * GB, at: now() - 20 },
|
||||
sessions: 1,
|
||||
running: 0,
|
||||
},
|
||||
{
|
||||
unit: 'old-box',
|
||||
source: 'agent',
|
||||
kind: 'laptop',
|
||||
label: 'old-box',
|
||||
status: 'offline',
|
||||
spec: { os: 'linux', arch: 'amd64', cpus: 8, memory: 16 * GB },
|
||||
sessions: 0,
|
||||
running: 0,
|
||||
},
|
||||
]
|
||||
|
||||
/** A descending-then-rising GPU trend, plus a row that carried NO gpu column (a gap). */
|
||||
const samples = () => {
|
||||
const t0 = now() - 3600
|
||||
return Array.from({ length: 12 }, (_, i) => {
|
||||
const ts = t0 + i * 300
|
||||
const row: Record<string, unknown> = { ts, cpus: 20, memory: 128 * GB, load1: 2 + Math.sin(i) }
|
||||
// Row 5 deliberately carries no gpu_util — the chart must SKIP it, not plot 0.
|
||||
if (i !== 5) row.gpu_util = 0.4 + 0.05 * i
|
||||
row.mem_used = (40 + i) * GB
|
||||
return row
|
||||
})
|
||||
}
|
||||
|
||||
// The proven harness set (provider-billing / budgets-responsive). Deliberately NOT
|
||||
// widened to `/org/*`: the OrgGate resolves the org from the localStorage scope set
|
||||
// in `open()` and tolerates the real backend's 403, whereas an empty-ok fixture for
|
||||
// get-organization stalls it.
|
||||
const API_RE = /\/(v1|cloud|ai|billing|commerce|telemetry|vm|superbase|admin|paas|integrations|auth\/refresh)(\/|$|\?)/
|
||||
|
||||
type FleetMode = 'data' | 'empty' | 'notfound'
|
||||
|
||||
function makeMock(mode: FleetMode) {
|
||||
return async function mock(route: Route) {
|
||||
const req = route.request()
|
||||
if (req.resourceType() === 'document') return route.continue()
|
||||
const url = new URL(req.url())
|
||||
const path = url.pathname
|
||||
|
||||
if (path === '/auth/session') {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ account: ACCOUNT, expiresIn: 3600 }) })
|
||||
}
|
||||
if (path.startsWith('/auth/')) {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }) })
|
||||
}
|
||||
if (path === '/v1/fleet/samples') {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ samples: samples() }) })
|
||||
}
|
||||
if (path === '/v1/fleet') {
|
||||
if (mode === 'notfound') {
|
||||
return route.fulfill({ status: 404, contentType: 'application/json', body: JSON.stringify({ error: 'Not found' }) })
|
||||
}
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ units: mode === 'empty' ? [] : units() }),
|
||||
})
|
||||
}
|
||||
|
||||
const sameOrigin = url.origin === new URL(BASE_URL).origin
|
||||
if (sameOrigin && !API_RE.test(path)) return route.continue()
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 'ok', msg: '', data: [], data2: 0 }) })
|
||||
}
|
||||
}
|
||||
|
||||
async function open(page: Page, mode: FleetMode, path = '/fleet') {
|
||||
await page.addInitScript((org) => {
|
||||
try {
|
||||
localStorage.setItem('hanzo.console.org', org)
|
||||
localStorage.setItem('hanzo.console.org.selected', '1')
|
||||
localStorage.setItem('hz_onboarding_done:' + org, '1')
|
||||
localStorage.setItem('hz_admin_banner_dismissed', '1')
|
||||
} catch {
|
||||
/* private mode */
|
||||
}
|
||||
}, ACCOUNT.owner)
|
||||
await page.route('**/*', makeMock(mode))
|
||||
await page.goto(`${BASE_URL}${path}`, { waitUntil: 'domcontentloaded' })
|
||||
const content = page.locator('[data-testid="product-content"]').first()
|
||||
// Generous: against `next dev` the first paint of a route compiles + ships ~20
|
||||
// un-minified chunks, which is far slower than the built bundle this ships as.
|
||||
await content.waitFor({ state: 'attached', timeout: 60_000 })
|
||||
return content
|
||||
}
|
||||
|
||||
test.beforeAll(() => mkdirSync(SHOTS, { recursive: true }))
|
||||
|
||||
test.describe('the board renders real per-org compute', () => {
|
||||
test('desktop — summary, attention banner, and every unit', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
const content = await open(page, 'data')
|
||||
await expect(content.getByText('spark').first()).toBeVisible({ timeout: 20_000 })
|
||||
await page.waitForTimeout(600)
|
||||
|
||||
// Summary: 5 units, 3 online (spark + dave-mbp + web-1; draining/offline are not).
|
||||
await expect(content.getByText('Units', { exact: true }).first()).toBeVisible()
|
||||
await expect(content.getByText('GPU util', { exact: true }).first()).toBeVisible()
|
||||
|
||||
// The attention banner NAMES the online-but-silent unit.
|
||||
await expect(content.getByText(/online but no longer reporting/i).first()).toBeVisible()
|
||||
await expect(content.getByText(/dave-mbp/).first()).toBeVisible()
|
||||
|
||||
// Per-unit state, asserted INSIDE each card — the filter <select> also contains
|
||||
// the words "draining"/"offline"/"byo" as hidden <option>s, so a page-wide text
|
||||
// match would prove nothing about the pills.
|
||||
const card = (label: string) => page.locator(`[aria-label^="${label}"]`).first()
|
||||
await expect(card('prod-cluster')).toContainText('Draining')
|
||||
await expect(card('old-box')).toContainText('Offline')
|
||||
// (the badge is uppercased in CSS, so the DOM text is the label's own casing)
|
||||
await expect(card('spark')).toContainText('BYO')
|
||||
await expect(card('web-1')).toContainText('Visor')
|
||||
|
||||
// The fully-reporting host shows REAL live numbers.
|
||||
await expect(card('spark')).toContainText('86.0%')
|
||||
await expect(card('spark')).toContainText('linux/arm64 · 20 vCPU · 128.0 GB · 1× GB10')
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'fleet-desktop.png'), fullPage: true })
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('a unit that reports NO telemetry renders em-dashes, never a fabricated 0', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
const content = await open(page, 'data')
|
||||
await expect(content.getByText('web-1').first()).toBeVisible({ timeout: 20_000 })
|
||||
|
||||
// web-1's card: no metrics at all ⇒ Load/Mem/GPU are em-dashes and its heartbeat
|
||||
// age is an em-dash. Critically it must NOT read "0.00" / "0%".
|
||||
const card = page.locator('[aria-label^="web-1"]').first()
|
||||
await expect(card).toBeVisible()
|
||||
const text = (await card.innerText()).replace(/\s+/g, ' ')
|
||||
expect(text).toContain('—')
|
||||
expect(text).not.toMatch(/0\.00/)
|
||||
expect(text).not.toMatch(/\b0%/)
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('mobile — no horizontal body scroll (mission control on a phone)', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 390, height: 844 } })
|
||||
const page = await ctx.newPage()
|
||||
const content = await open(page, 'data')
|
||||
await expect(content.getByText('spark').first()).toBeVisible({ timeout: 20_000 })
|
||||
await page.waitForTimeout(600)
|
||||
|
||||
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
|
||||
expect(overflow).toBeLessThanOrEqual(1)
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'fleet-mobile.png'), fullPage: true })
|
||||
await ctx.close()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('honest states', () => {
|
||||
test('an empty fleet says how to link compute — it never fabricates a unit', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
const content = await open(page, 'empty')
|
||||
await expect(content.getByText('No compute linked yet').first()).toBeVisible({ timeout: 20_000 })
|
||||
await expect(content.getByText(/hanzo code --link/).first()).toBeVisible()
|
||||
await page.screenshot({ path: join(SHOTS, 'fleet-empty.png'), fullPage: true })
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('a 404 from /v1/fleet is an honest not-routed card, never a crash', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
const content = await open(page, 'notfound')
|
||||
// The shared BackendStateCard's 404 copy + the endpoint hint.
|
||||
await expect(content.getByText(/not (available|routed)/i).first()).toBeVisible({ timeout: 20_000 })
|
||||
await expect(content.getByText('GET /v1/fleet').first()).toBeVisible()
|
||||
await page.screenshot({ path: join(SHOTS, 'fleet-notrouted.png'), fullPage: true })
|
||||
await ctx.close()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('unit detail', () => {
|
||||
test('deep link renders the spec + the utilization trend', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
const content = await open(page, 'data', '/fleet/byo/spark-gb10')
|
||||
await expect(content.getByText('Spec', { exact: true }).first()).toBeVisible({ timeout: 20_000 })
|
||||
await page.waitForTimeout(900)
|
||||
|
||||
await expect(content.getByText('Utilization').first()).toBeVisible()
|
||||
await expect(content.getByText('GPU utilization').first()).toBeVisible()
|
||||
await expect(content.getByText('Load average (1m)').first()).toBeVisible()
|
||||
await expect(content.getByText('12 sessions · 2 running now').first()).toBeVisible()
|
||||
// The trend drew real SVG paths (not the "no data" note).
|
||||
expect(await content.locator('svg path').count()).toBeGreaterThan(2)
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'fleet-detail.png'), fullPage: true })
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('a deep link to an unknown unit says so instead of hanging or 404ing', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
const content = await open(page, 'data', '/fleet/byo/does-not-exist')
|
||||
await expect(content.getByText('Unit not found').first()).toBeVisible({ timeout: 20_000 })
|
||||
await ctx.close()
|
||||
})
|
||||
})
|
||||
Generated
+28
@@ -9178,6 +9178,33 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/expo/node_modules/react-native-worklets": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.8.3.tgz",
|
||||
"integrity": "sha512-oCBJROyLU7yG/1R8s0INMflygTH71bx+5XcYkH0CM938TlhSoVbiunE1WVW5FZa51vwYqfLie/IXMX2s1Kh3eg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/plugin-transform-arrow-functions": "^7.27.1",
|
||||
"@babel/plugin-transform-class-properties": "^7.27.1",
|
||||
"@babel/plugin-transform-classes": "^7.28.4",
|
||||
"@babel/plugin-transform-nullish-coalescing-operator": "^7.27.1",
|
||||
"@babel/plugin-transform-optional-chaining": "^7.27.1",
|
||||
"@babel/plugin-transform-shorthand-properties": "^7.27.1",
|
||||
"@babel/plugin-transform-template-literals": "^7.27.1",
|
||||
"@babel/plugin-transform-unicode-regex": "^7.27.1",
|
||||
"@babel/preset-typescript": "^7.27.1",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"semver": "^7.7.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "*",
|
||||
"@react-native/metro-config": "*",
|
||||
"react": "*",
|
||||
"react-native": "0.81 - 0.85"
|
||||
}
|
||||
},
|
||||
"node_modules/exponential-backoff": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz",
|
||||
@@ -11696,6 +11723,7 @@
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Fleet — the org's WHOLE compute surface on ONE board.
|
||||
*
|
||||
* Backed by the REAL `GET /v1/fleet` (units + last heartbeat) and
|
||||
* `GET /v1/fleet/samples` (a unit's utilization trend), read through the same-origin
|
||||
* `/v1` user-bearer BFF: the org is resolved SERVER-SIDE from the Bearer owner claim,
|
||||
* and this module never sends — or accepts — an org.
|
||||
*
|
||||
* Two routes, matched by segment count (the `/chat/:owner/:name` precedent):
|
||||
* '' the board
|
||||
* ':source/:unit' one unit — a unit id is unique only WITHIN a source, so the
|
||||
* identity is the PAIR, and a detail URL must carry both.
|
||||
*
|
||||
* Honest states: a 404/503 (the backend is not routed on this deployment yet) or a
|
||||
* 403 renders the shared BackendStateCard, an empty fleet renders the real first-run
|
||||
* state, and no cell is ever a fabricated 0 — see `~/lib/api/fleet` for the rule.
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button, Spinner, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { RefreshCw } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { FleetApi, findUnit, type FleetUnit } from '~/lib/api/fleet'
|
||||
import { FleetBoard } from './fleet/Board'
|
||||
import { UnitDetail } from './fleet/Detail'
|
||||
|
||||
type Async =
|
||||
| { phase: 'loading' }
|
||||
| { phase: 'error'; error: BackendState }
|
||||
| { phase: 'ready'; data: FleetUnit[] }
|
||||
|
||||
/** How often the board re-reads the fleet. Heartbeats are ~seconds old; this keeps up. */
|
||||
const POLL_MS = 20_000
|
||||
|
||||
export function FleetModule({ params }: { params: Record<string, string> }) {
|
||||
const router = useRouter()
|
||||
const [state, setState] = useState<Async>({ phase: 'loading' })
|
||||
// The staleness clock. Held in state (not read inline) so every "12s ago" on a
|
||||
// render agrees, and so the ages advance between polls.
|
||||
const [nowS, setNowS] = useState(() => Math.floor(Date.now() / 1000))
|
||||
|
||||
const load = useCallback((quiet = false) => {
|
||||
if (!quiet) setState({ phase: 'loading' })
|
||||
FleetApi.units()
|
||||
.then((data) => {
|
||||
setState({ phase: 'ready', data })
|
||||
setNowS(Math.floor(Date.now() / 1000))
|
||||
})
|
||||
// A background refresh must never replace a board that already has real data
|
||||
// with an error card; only a first load surfaces the failure.
|
||||
.catch((e) => setState((prev) => (quiet && prev.phase === 'ready' ? prev : { phase: 'error', error: classifyBackend(e) })))
|
||||
}, [])
|
||||
|
||||
useEffect(() => load(), [load])
|
||||
|
||||
// Poll while the tab is visible; a hidden tab neither fetches nor ages its clock.
|
||||
useEffect(() => {
|
||||
const tick = () => {
|
||||
if (typeof document !== 'undefined' && document.hidden) return
|
||||
setNowS(Math.floor(Date.now() / 1000))
|
||||
load(true)
|
||||
}
|
||||
const id = setInterval(tick, POLL_MS)
|
||||
return () => clearInterval(id)
|
||||
}, [load])
|
||||
|
||||
const source = params.source
|
||||
const unitId = params.unit
|
||||
const selected = state.phase === 'ready' && source && unitId ? findUnit(state.data, source, unitId) : undefined
|
||||
const open = (u: FleetUnit) => router.push(`/fleet/${encodeURIComponent(u.source ?? '')}/${encodeURIComponent(u.unit)}`)
|
||||
const back = () => router.push('/fleet')
|
||||
|
||||
// A deep link to a unit that is no longer in the fleet: say so, don't 404 or hang.
|
||||
if (source && unitId && state.phase === 'ready' && !selected) {
|
||||
return (
|
||||
<YStack gap="$4" p="$4">
|
||||
<PageHeader title="Unit not found" subtitle={`No ${source} unit "${unitId}" is linked to your organization.`} />
|
||||
<Button size="$2" self="flex-start" onPress={back}>
|
||||
Back to Fleet
|
||||
</Button>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
if (selected) {
|
||||
return (
|
||||
<YStack gap="$4" p="$4">
|
||||
<UnitDetail unit={selected} nowS={nowS} onBack={back} />
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<YStack gap="$4" p="$4">
|
||||
<PageHeader
|
||||
title="Fleet"
|
||||
subtitle="Every machine your organization owns or links — with live load."
|
||||
actions={
|
||||
<Button size="$2" icon={<RefreshCw size={14} />} onPress={() => load()} aria-label="Reload the fleet">
|
||||
Refresh
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{state.phase === 'loading' ? (
|
||||
<XStack p="$6" justify="center">
|
||||
<Spinner size="large" color="$color11" />
|
||||
</XStack>
|
||||
) : state.phase === 'error' ? (
|
||||
<BackendStateCard
|
||||
state={state.error}
|
||||
onRetry={() => load()}
|
||||
hint={
|
||||
<Text fontSize="$1" color="$color10">
|
||||
GET /v1/fleet
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<FleetBoard units={state.data} nowS={nowS} onOpen={open} />
|
||||
)}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* The Fleet board — every unit the org owns or linked, with live health.
|
||||
*
|
||||
* Scanned, not read: the summary answers "how much have I got and is it OK", the
|
||||
* attention banner names anything that needs a look, and the grid orders the flagged
|
||||
* units to the top. Every number is real or an em-dash.
|
||||
*/
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Card, Input, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Activity, Boxes, Cpu, Gauge, MemoryStick, Search, Server, TriangleAlert } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { MetricCard, SERIES } from '~/components/ui/Metric'
|
||||
import { EmptyState } from '~/components/ui/EmptyState'
|
||||
import { FieldSelect } from '~/components/ui/Field'
|
||||
import { fmtBytes, fmtInt } from '~/lib/api/agents'
|
||||
import { DASH } from '~/lib/api/visor'
|
||||
import { memRatio, memTotal, summarize, type FleetUnit } from '~/lib/api/fleet'
|
||||
import {
|
||||
capacityLine,
|
||||
filterUnits,
|
||||
fmtLoad,
|
||||
fmtMemPair,
|
||||
fmtRatio,
|
||||
isStale,
|
||||
loadRatio,
|
||||
orderUnits,
|
||||
sessionsSummary,
|
||||
sourceOptions,
|
||||
statusOptions,
|
||||
unitSubtitle,
|
||||
unitTitle,
|
||||
verdictNote,
|
||||
verdictOf,
|
||||
type FleetFilter,
|
||||
} from './logic'
|
||||
import { Dot, Heartbeat, kindIcon, MeterRow, SourceBadge, UnitKindLine, VerdictPill, verdictHex } from './parts'
|
||||
|
||||
/**
|
||||
* The summary strip.
|
||||
*
|
||||
* Each capacity tile says how many units it could actually count ("across 4 of 7"),
|
||||
* because a fleet where three hosts are silent has an UNKNOWN total, not a smaller
|
||||
* one — and a tile that hides that is quietly lying about the size of the fleet.
|
||||
*/
|
||||
/** One tile slot. `maxW` stops a tile that wraps onto its own row from stretching full-bleed. */
|
||||
function Tile({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<YStack flex={1} minW={168} maxW={360}>
|
||||
{children}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
function SummaryStrip({ units, nowS }: { units: FleetUnit[]; nowS: number }) {
|
||||
const s = useMemo(() => summarize(units, nowS), [units, nowS])
|
||||
const across = (from: number) => (from === s.total ? `across all ${s.total}` : `across ${from} of ${s.total}`)
|
||||
|
||||
return (
|
||||
<XStack gap="$3" flexWrap="wrap">
|
||||
<Tile>
|
||||
<MetricCard icon={<Boxes size={16} />} label="Units" value={String(s.total)} caption="linked to your org" />
|
||||
</Tile>
|
||||
<Tile>
|
||||
<MetricCard
|
||||
icon={<Activity size={16} />}
|
||||
label="Online"
|
||||
value={String(s.online)}
|
||||
caption={s.stale > 0 ? `${s.stale} online but silent` : `of ${s.total} units`}
|
||||
/>
|
||||
</Tile>
|
||||
<Tile>
|
||||
<MetricCard
|
||||
icon={<Cpu size={16} />}
|
||||
label="vCPU"
|
||||
value={s.cpus === undefined ? DASH : fmtInt(s.cpus)}
|
||||
caption={s.cpusFrom > 0 ? across(s.cpusFrom) : 'no unit reported a core count'}
|
||||
/>
|
||||
</Tile>
|
||||
<Tile>
|
||||
<MetricCard
|
||||
icon={<MemoryStick size={16} />}
|
||||
label="Memory"
|
||||
value={s.memory === undefined ? DASH : fmtBytes(s.memory)}
|
||||
caption={s.memoryFrom > 0 ? across(s.memoryFrom) : 'no unit reported memory'}
|
||||
/>
|
||||
</Tile>
|
||||
<Tile>
|
||||
<MetricCard
|
||||
icon={<Server size={16} />}
|
||||
label="GPUs"
|
||||
value={s.gpus === undefined ? DASH : String(s.gpus)}
|
||||
caption={s.gpusFrom > 0 ? `across ${s.gpusFrom} of ${s.total} units` : 'no GPUs reported'}
|
||||
/>
|
||||
</Tile>
|
||||
<Tile>
|
||||
<MetricCard
|
||||
icon={<Gauge size={16} />}
|
||||
label="GPU util"
|
||||
value={fmtRatio(s.gpuUtil)}
|
||||
caption={s.gpuUtilFrom > 0 ? `mean of ${s.gpuUtilFrom} reporting` : 'no unit reported utilization'}
|
||||
/>
|
||||
</Tile>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The attention banner — silence when there is nothing to attend to.
|
||||
*
|
||||
* This is the "state in form, not just a number" surface: rather than a tile reading
|
||||
* "2", it NAMES the units that claim to be online but have stopped reporting, which
|
||||
* is the only thing on this board that asks an operator to act.
|
||||
*/
|
||||
function AttentionBanner({ units, nowS }: { units: FleetUnit[]; nowS: number }) {
|
||||
const flagged = useMemo(() => units.filter((u) => verdictOf(u, nowS) === 'attention'), [units, nowS])
|
||||
if (flagged.length === 0) return null
|
||||
const names = flagged.slice(0, 4).map(unitTitle).join(', ')
|
||||
const more = flagged.length > 4 ? ` and ${flagged.length - 4} more` : ''
|
||||
return (
|
||||
<Card borderWidth={1} borderColor="$borderColor" p="$3" gap="$1" style={{ borderLeft: `3px solid ${SERIES[2]}` }}>
|
||||
<XStack items="center" gap="$2">
|
||||
<TriangleAlert size={15} color={SERIES[2]} />
|
||||
<Text fontSize="$3" fontWeight="600" color="$color12">
|
||||
{flagged.length === 1 ? '1 unit is' : `${flagged.length} units are`} online but no longer reporting
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$2" color="$color11">
|
||||
{names}
|
||||
{more} last sent a heartbeat over {Math.floor(120 / 60)} minutes ago. The unit may be asleep, offline, or its agent
|
||||
may have stopped — its live numbers below are the last ones it sent.
|
||||
</Text>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A labelled filter — the `FieldSelect` is a form control (width:100%), so it needs a
|
||||
* width-bounded slot to sit in a filter row. Mirrors MachinesModule's FilterSelect.
|
||||
*/
|
||||
function Filter({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
options: string[]
|
||||
onChange: (v: string) => void
|
||||
}) {
|
||||
return (
|
||||
<YStack gap="$1" width={148}>
|
||||
<Text fontSize="$1" color="$color10">
|
||||
{label}
|
||||
</Text>
|
||||
<FieldSelect value={value} options={options} onChange={onChange} />
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
/** One unit: identity, state, capacity, live health. */
|
||||
function UnitCard({ unit, nowS, onOpen }: { unit: FleetUnit; nowS: number; onOpen: () => void }) {
|
||||
const Icon = kindIcon(unit.kind)
|
||||
const v = verdictOf(unit, nowS)
|
||||
const stale = isStale(unit, nowS)
|
||||
const note = verdictNote(unit, nowS)
|
||||
const sub = unitSubtitle(unit)
|
||||
const load = loadRatio(unit)
|
||||
const mem = memRatio(unit.metrics)
|
||||
const total = memTotal(unit.metrics)
|
||||
|
||||
return (
|
||||
<Card
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
p="$3.5"
|
||||
gap="$2.5"
|
||||
flex={1}
|
||||
minW={286}
|
||||
maxW={480}
|
||||
hoverStyle={{ borderColor: '$color8' }}
|
||||
pressStyle={{ opacity: 0.85 }}
|
||||
onPress={onOpen}
|
||||
cursor="pointer"
|
||||
aria-label={`${unitTitle(unit)} — ${v}`}
|
||||
>
|
||||
<XStack items="flex-start" justify="space-between" gap="$2">
|
||||
<XStack items="center" gap="$2" flex={1} minW={0}>
|
||||
<YStack p="$1.5" rounded="$2" bg="$color3">
|
||||
<Icon size={14} />
|
||||
</YStack>
|
||||
<YStack flex={1} minW={0}>
|
||||
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>
|
||||
{unitTitle(unit)}
|
||||
</Text>
|
||||
{sub ? (
|
||||
<Text fontSize="$1" color="$color10" numberOfLines={1}>
|
||||
{sub}
|
||||
</Text>
|
||||
) : (
|
||||
<UnitKindLine unit={unit} />
|
||||
)}
|
||||
</YStack>
|
||||
</XStack>
|
||||
<SourceBadge source={unit.source} />
|
||||
</XStack>
|
||||
|
||||
<XStack items="center" justify="space-between" gap="$2">
|
||||
<VerdictPill unit={unit} nowS={nowS} />
|
||||
<Heartbeat at={unit.metrics.at} nowS={nowS} stale={stale} />
|
||||
</XStack>
|
||||
|
||||
{note ? (
|
||||
<Text fontSize="$1" color={SERIES[2]}>
|
||||
{note}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<Text fontSize="$1" color="$color10" numberOfLines={2}>
|
||||
{capacityLine(unit.spec)}
|
||||
</Text>
|
||||
|
||||
<YStack gap="$1.5">
|
||||
<MeterRow label="Load" value={fmtLoad(unit.metrics.load1)} ratio={load} dim={stale} />
|
||||
<MeterRow label="Mem" value={fmtMemPair(unit.metrics.memUsed, total)} ratio={mem} dim={stale} />
|
||||
<MeterRow label="GPU" value={fmtRatio(unit.metrics.gpuUtil)} ratio={unit.metrics.gpuUtil} dim={stale} />
|
||||
</YStack>
|
||||
|
||||
<XStack items="center" gap="$1.5">
|
||||
<Dot color={unit.running > 0 ? verdictHex('healthy') : SERIES[7]} size={6} />
|
||||
<Text fontSize="$1" color="$color10">
|
||||
{sessionsSummary(unit)}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
/** The honest first-run state — the fleet is genuinely empty, and here is how to fill it. */
|
||||
export function FleetEmpty() {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Boxes}
|
||||
title="No compute linked yet"
|
||||
description="Fleet shows every machine your organization owns or links — laptops and boxes an agent session registered, bring-your-own workers, in-cloud boxes, and the machines Hanzo manages for you — each with its live load."
|
||||
bullets={[
|
||||
'Run `hanzo code --link` on a machine to register it as a run-target.',
|
||||
'Attach a bring-your-own cluster or GPU host to bring your own compute.',
|
||||
'Launch a machine or a GPU box and it appears here automatically.',
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function FleetBoard({
|
||||
units,
|
||||
nowS,
|
||||
onOpen,
|
||||
}: {
|
||||
units: FleetUnit[]
|
||||
nowS: number
|
||||
onOpen: (u: FleetUnit) => void
|
||||
}) {
|
||||
const [filter, setFilter] = useState<FleetFilter>({})
|
||||
const sources = useMemo(() => sourceOptions(units), [units])
|
||||
const statuses = useMemo(() => statusOptions(units), [units])
|
||||
const rows = useMemo(() => orderUnits(filterUnits(units, filter), nowS), [units, filter, nowS])
|
||||
|
||||
if (units.length === 0) return <FleetEmpty />
|
||||
|
||||
return (
|
||||
<YStack gap="$4">
|
||||
<SummaryStrip units={units} nowS={nowS} />
|
||||
<AttentionBanner units={units} nowS={nowS} />
|
||||
|
||||
<XStack gap="$3" flexWrap="wrap" items="flex-end">
|
||||
<YStack gap="$1" flex={1} minW={190} maxW={320}>
|
||||
<Text fontSize="$1" color="$color10">
|
||||
Search
|
||||
</Text>
|
||||
<XStack items="center" gap="$2" px="$2.5" borderWidth={1} borderColor="$borderColor" rounded="$3" height={40}>
|
||||
<Search size={14} />
|
||||
<Input
|
||||
unstyled
|
||||
flex={1}
|
||||
fontSize="$3"
|
||||
color="$color12"
|
||||
placeholder="Name, host, OS, GPU…"
|
||||
value={filter.search ?? ''}
|
||||
onChangeText={(search: string) => setFilter((f) => ({ ...f, search }))}
|
||||
/>
|
||||
</XStack>
|
||||
</YStack>
|
||||
{sources.length > 2 ? (
|
||||
<Filter label="Source" value={filter.source ?? 'all'} options={sources} onChange={(source) => setFilter((f) => ({ ...f, source }))} />
|
||||
) : null}
|
||||
{statuses.length > 2 ? (
|
||||
<Filter label="Status" value={filter.status ?? 'all'} options={statuses} onChange={(status) => setFilter((f) => ({ ...f, status }))} />
|
||||
) : null}
|
||||
<Text fontSize="$1" color="$color10" className="hz-tnum" pb="$2.5">
|
||||
{rows.length === units.length ? `${units.length} units` : `${rows.length} of ${units.length}`}
|
||||
</Text>
|
||||
</XStack>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<Card borderWidth={1} borderColor="$borderColor" borderStyle="dashed" p="$5" items="center">
|
||||
<Text fontSize="$3" color="$color11">
|
||||
No units match these filters.
|
||||
</Text>
|
||||
</Card>
|
||||
) : (
|
||||
<XStack gap="$3" flexWrap="wrap">
|
||||
{rows.map((u) => (
|
||||
<UnitCard key={`${u.source ?? ''}/${u.unit}`} unit={u} nowS={nowS} onOpen={() => onOpen(u)} />
|
||||
))}
|
||||
</XStack>
|
||||
)}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* One unit in full: what it is, what it is doing now, and how it has been trending.
|
||||
*
|
||||
* The trend reads `GET /v1/fleet/samples?unit&source&range`. A chart is drawn only
|
||||
* where there are at least two REAL points — a single sample is not a trend, and an
|
||||
* absent column is a gap, never a plotted zero.
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Button, Card, Spinner, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { ChevronLeft, RefreshCw } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
|
||||
import { LineChart } from '~/components/ui/Charts'
|
||||
import { Panel, SERIES } from '~/components/ui/Metric'
|
||||
import { fmtBytes, fmtInt } from '~/lib/api/agents'
|
||||
import { DASH } from '~/lib/api/visor'
|
||||
import { FleetApi, FLEET_RANGES, memRatio, memTotal, type FleetRange, type FleetSample, type FleetUnit } from '~/lib/api/fleet'
|
||||
import {
|
||||
capacityLine,
|
||||
fmtLoad,
|
||||
fmtMemPair,
|
||||
fmtRatio,
|
||||
gpuLabel,
|
||||
hasTrend,
|
||||
isStale,
|
||||
kindLabel,
|
||||
loadRatio,
|
||||
RANGE_LABEL,
|
||||
seriesOf,
|
||||
sessionsSummary,
|
||||
sourceHint,
|
||||
sourceLabel,
|
||||
unitSubtitle,
|
||||
unitTitle,
|
||||
verdictNote,
|
||||
type SampleKey,
|
||||
} from './logic'
|
||||
import { Fact, Heartbeat, kindIcon, MeterRow, SourceBadge, VerdictPill } from './parts'
|
||||
|
||||
type Async =
|
||||
| { phase: 'loading' }
|
||||
| { phase: 'error'; error: BackendState }
|
||||
| { phase: 'ready'; data: FleetSample[] }
|
||||
|
||||
/** The range switcher — the same tab-row idiom the other boards use. */
|
||||
function RangeTabs({ value, onChange }: { value: FleetRange; onChange: (r: FleetRange) => void }) {
|
||||
return (
|
||||
<XStack gap="$1" bg="$color2" p="$1" rounded="$3">
|
||||
{FLEET_RANGES.map((r) => (
|
||||
<Button
|
||||
key={r}
|
||||
size="$2"
|
||||
chromeless
|
||||
bg={r === value ? '$color5' : 'transparent'}
|
||||
onPress={() => onChange(r)}
|
||||
aria-label={`Show the last ${RANGE_LABEL[r]}`}
|
||||
>
|
||||
<Text fontSize="$2" color={r === value ? '$color12' : '$color11'}>
|
||||
{RANGE_LABEL[r]}
|
||||
</Text>
|
||||
</Button>
|
||||
))}
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One trend. Renders the chart only with two or more real points; otherwise it says
|
||||
* WHY there is nothing to show rather than drawing a flat line through invented data.
|
||||
*/
|
||||
function Trend({
|
||||
title,
|
||||
samples,
|
||||
metric,
|
||||
range,
|
||||
color,
|
||||
format,
|
||||
}: {
|
||||
title: string
|
||||
samples: FleetSample[]
|
||||
metric: SampleKey
|
||||
range: FleetRange
|
||||
color: string
|
||||
format: (v: number) => string
|
||||
}) {
|
||||
const points = useMemo(() => seriesOf(samples, metric, range), [samples, metric, range])
|
||||
return (
|
||||
<Panel title={title}>
|
||||
{hasTrend(points) ? (
|
||||
<LineChart data={points} height={160} color={color} formatValue={format} />
|
||||
) : (
|
||||
<YStack p="$4" items="center">
|
||||
<Text fontSize="$2" color="$color10">
|
||||
{points.length === 1
|
||||
? 'Only one sample in this window — not enough for a trend.'
|
||||
: 'This unit reported no data for this metric in this window.'}
|
||||
</Text>
|
||||
</YStack>
|
||||
)}
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
export function UnitDetail({ unit, nowS, onBack }: { unit: FleetUnit; nowS: number; onBack: () => void }) {
|
||||
const [range, setRange] = useState<FleetRange>('24h')
|
||||
const [state, setState] = useState<Async>({ phase: 'loading' })
|
||||
const Icon = kindIcon(unit.kind)
|
||||
const stale = isStale(unit, nowS)
|
||||
const note = verdictNote(unit, nowS)
|
||||
const total = memTotal(unit.metrics)
|
||||
|
||||
const load = useCallback(() => {
|
||||
setState({ phase: 'loading' })
|
||||
FleetApi.samples({ unit: unit.unit, source: unit.source, range })
|
||||
.then((data) => setState({ phase: 'ready', data }))
|
||||
.catch((e) => setState({ phase: 'error', error: classifyBackend(e) }))
|
||||
}, [unit.unit, unit.source, range])
|
||||
|
||||
useEffect(() => load(), [load])
|
||||
|
||||
const samples = state.phase === 'ready' ? state.data : []
|
||||
const sub = unitSubtitle(unit)
|
||||
|
||||
return (
|
||||
<YStack gap="$4">
|
||||
<XStack items="flex-start" justify="space-between" gap="$3" flexWrap="wrap">
|
||||
<XStack items="center" gap="$3" flex={1} minW={220}>
|
||||
<Button size="$2" chromeless icon={<ChevronLeft size={16} />} onPress={onBack} aria-label="Back to the fleet">
|
||||
Fleet
|
||||
</Button>
|
||||
<XStack items="center" gap="$2.5" flex={1} minW={0}>
|
||||
<YStack p="$2" rounded="$3" bg="$color3">
|
||||
<Icon size={16} />
|
||||
</YStack>
|
||||
<YStack flex={1} minW={0}>
|
||||
<Text fontSize="$5" fontWeight="600" color="$color12" numberOfLines={1}>
|
||||
{unitTitle(unit)}
|
||||
</Text>
|
||||
<Text fontSize="$2" color="$color11" numberOfLines={1}>
|
||||
{kindLabel(unit.kind)}
|
||||
{sub ? ` · ${sub}` : ''}
|
||||
</Text>
|
||||
</YStack>
|
||||
</XStack>
|
||||
</XStack>
|
||||
<XStack gap="$2" items="center">
|
||||
<SourceBadge source={unit.source} />
|
||||
<Button size="$2" icon={<RefreshCw size={14} />} onPress={load} aria-label="Reload the trend">
|
||||
Refresh
|
||||
</Button>
|
||||
</XStack>
|
||||
</XStack>
|
||||
|
||||
<XStack gap="$3" flexWrap="wrap" items="stretch">
|
||||
{/* Live health — the last heartbeat, dimmed when it is old. */}
|
||||
<Card borderWidth={1} borderColor="$borderColor" p="$3.5" gap="$2.5" flex={1} minW={280}>
|
||||
<XStack items="center" justify="space-between">
|
||||
<Text fontSize="$4" fontWeight="600" color="$color12">
|
||||
Live
|
||||
</Text>
|
||||
<XStack items="center" gap="$2">
|
||||
<VerdictPill unit={unit} nowS={nowS} />
|
||||
<Heartbeat at={unit.metrics.at} nowS={nowS} stale={stale} />
|
||||
</XStack>
|
||||
</XStack>
|
||||
{note ? (
|
||||
<Text fontSize="$1" color={SERIES[2]}>
|
||||
{note} — the numbers below are the last ones it sent.
|
||||
</Text>
|
||||
) : null}
|
||||
<YStack gap="$2">
|
||||
<MeterRow label="Load" value={fmtLoad(unit.metrics.load1)} ratio={loadRatio(unit)} dim={stale} />
|
||||
<MeterRow label="Mem" value={fmtMemPair(unit.metrics.memUsed, total)} ratio={memRatio(unit.metrics)} dim={stale} />
|
||||
<MeterRow label="GPU" value={fmtRatio(unit.metrics.gpuUtil)} ratio={unit.metrics.gpuUtil} dim={stale} />
|
||||
</YStack>
|
||||
<YStack gap="$1" pt="$1">
|
||||
<Fact label="Load 1 / 5 / 15" value={`${fmtLoad(unit.metrics.load1)} / ${fmtLoad(unit.metrics.load5)} / ${fmtLoad(unit.metrics.load15)}`} />
|
||||
<Fact label="Memory free" value={unit.metrics.memFree === undefined ? DASH : fmtBytes(unit.metrics.memFree)} />
|
||||
</YStack>
|
||||
</Card>
|
||||
|
||||
{/* Spec — what the machine IS. */}
|
||||
<Card borderWidth={1} borderColor="$borderColor" p="$3.5" gap="$2.5" flex={1} minW={280}>
|
||||
<Text fontSize="$4" fontWeight="600" color="$color12">
|
||||
Spec
|
||||
</Text>
|
||||
<YStack gap="$1">
|
||||
<Fact label="Operating system" value={unit.spec.os ?? DASH} />
|
||||
<Fact label="Architecture" value={unit.spec.arch ?? DASH} />
|
||||
<Fact label="Cores" value={unit.spec.cpus === undefined ? DASH : `${fmtInt(unit.spec.cpus)} vCPU`} />
|
||||
<Fact label="Memory" value={unit.spec.memory === undefined ? DASH : fmtBytes(unit.spec.memory)} />
|
||||
<Fact label="GPUs" value={gpuLabel(unit.spec.gpus)} />
|
||||
{unit.spec.gpus.map((g, i) => (
|
||||
<Fact
|
||||
key={`${g.model ?? 'gpu'}-${i}`}
|
||||
label={` ${g.model ?? g.vendor ?? 'GPU'} VRAM`}
|
||||
value={g.memory === undefined ? DASH : fmtBytes(g.memory)}
|
||||
/>
|
||||
))}
|
||||
<Fact label="Host" value={unit.host ?? DASH} />
|
||||
<Fact label="Source" value={sourceLabel(unit.source)} />
|
||||
<Fact label="Unit id" value={unit.unit} />
|
||||
</YStack>
|
||||
<Text fontSize="$1" color="$color10">
|
||||
{sourceHint(unit.source) ?? capacityLine(unit.spec)}
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
{/* Sessions — the unit's OWN authoritative counts. */}
|
||||
<Card borderWidth={1} borderColor="$borderColor" p="$3.5" gap="$2.5" flex={1} minW={280}>
|
||||
<Text fontSize="$4" fontWeight="600" color="$color12">
|
||||
Sessions
|
||||
</Text>
|
||||
<Text fontSize="$6" fontWeight="500" color="$color12" className="hz-mono">
|
||||
{fmtInt(unit.sessions)}
|
||||
</Text>
|
||||
<Text fontSize="$2" color="$color11">
|
||||
{sessionsSummary(unit)}
|
||||
</Text>
|
||||
<Text fontSize="$1" color="$color10">
|
||||
Agent and CLI sessions dispatched to this unit. The per-session list is not shown here: the sessions API has no
|
||||
per-unit filter yet, and a partial list beside these counts would contradict them.
|
||||
</Text>
|
||||
</Card>
|
||||
</XStack>
|
||||
|
||||
<XStack items="center" justify="space-between" gap="$3" flexWrap="wrap">
|
||||
<Text fontSize="$4" fontWeight="600" color="$color12">
|
||||
Utilization
|
||||
</Text>
|
||||
<RangeTabs value={range} onChange={setRange} />
|
||||
</XStack>
|
||||
|
||||
{state.phase === 'loading' ? (
|
||||
<XStack p="$6" justify="center">
|
||||
<Spinner size="large" color="$color11" />
|
||||
</XStack>
|
||||
) : state.phase === 'error' ? (
|
||||
<BackendStateCard
|
||||
state={state.error}
|
||||
onRetry={load}
|
||||
hint={<Text fontSize="$1" color="$color10">{`GET /v1/fleet/samples?unit=${unit.unit}&range=${range}`}</Text>}
|
||||
/>
|
||||
) : (
|
||||
<XStack gap="$3" flexWrap="wrap">
|
||||
<YStack flex={1} minW={300}>
|
||||
<Trend title="GPU utilization" samples={samples} metric="gpuUtil" range={range} color={SERIES[3]} format={(v) => `${Math.round(v)}%`} />
|
||||
</YStack>
|
||||
<YStack flex={1} minW={300}>
|
||||
<Trend title="Load average (1m)" samples={samples} metric="load1" range={range} color={SERIES[0]} format={(v) => v.toFixed(2)} />
|
||||
</YStack>
|
||||
<YStack flex={1} minW={300}>
|
||||
<Trend title="Memory used" samples={samples} metric="memUsed" range={range} color={SERIES[1]} format={(v) => fmtBytes(v)} />
|
||||
</YStack>
|
||||
</XStack>
|
||||
)}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { FleetSample, FleetUnit } from '~/lib/api/fleet'
|
||||
import {
|
||||
capacityLine,
|
||||
filterUnits,
|
||||
fmtLoad,
|
||||
fmtMemPair,
|
||||
fmtRatio,
|
||||
gpuLabel,
|
||||
hasTrend,
|
||||
isStale,
|
||||
orderUnits,
|
||||
sampleLabel,
|
||||
seriesOf,
|
||||
sessionsSummary,
|
||||
sourceLabel,
|
||||
sourceOptions,
|
||||
statusOptions,
|
||||
unitSubtitle,
|
||||
unitTitle,
|
||||
verdictNote,
|
||||
verdictOf,
|
||||
} from './logic'
|
||||
|
||||
const GB = 1024 ** 3
|
||||
const NOW = 1_700_000_000
|
||||
|
||||
const unit = (over: Partial<FleetUnit> = {}): FleetUnit => ({
|
||||
unit: 'u1',
|
||||
source: 'agent',
|
||||
kind: 'laptop',
|
||||
status: 'online',
|
||||
spec: { gpus: [] },
|
||||
metrics: {},
|
||||
sessions: 0,
|
||||
running: 0,
|
||||
...over,
|
||||
})
|
||||
|
||||
describe('labels — an unknown backend word shows ITSELF, never a wrong guess', () => {
|
||||
it('maps the known sources', () => {
|
||||
expect(sourceLabel('agent')).toBe('Agent')
|
||||
expect(sourceLabel('byo')).toBe('BYO')
|
||||
expect(sourceLabel('visor')).toBe('Visor')
|
||||
})
|
||||
it('passes an unknown source through and dashes an absent one', () => {
|
||||
expect(sourceLabel('edge')).toBe('edge')
|
||||
expect(sourceLabel(undefined)).toBe('—')
|
||||
})
|
||||
it('titles from label → host → id, never empty', () => {
|
||||
expect(unitTitle(unit({ label: 'spark', host: 'spark.local' }))).toBe('spark')
|
||||
expect(unitTitle(unit({ label: undefined, host: 'spark.local' }))).toBe('spark.local')
|
||||
expect(unitTitle(unit({ label: undefined, host: undefined, unit: 'raw-id' }))).toBe('raw-id')
|
||||
})
|
||||
it('does not repeat the host as a subtitle when it is already the title', () => {
|
||||
expect(unitSubtitle(unit({ label: 'spark', host: 'spark.local' }))).toBe('spark.local')
|
||||
expect(unitSubtitle(unit({ label: undefined, host: 'spark.local' }))).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('gpuLabel', () => {
|
||||
it('groups identical models', () => {
|
||||
expect(gpuLabel([{ model: 'H100' }, { model: 'H100' }])).toBe('2× H100')
|
||||
})
|
||||
it('lists distinct models', () => {
|
||||
expect(gpuLabel([{ model: 'GB10' }, { model: 'A100' }])).toBe('1× GB10 · 1× A100')
|
||||
})
|
||||
it('falls back to the vendor, then a generic, and dashes an empty list', () => {
|
||||
expect(gpuLabel([{ vendor: 'amd' }])).toBe('1× amd')
|
||||
expect(gpuLabel([{}])).toBe('1× GPU')
|
||||
expect(gpuLabel([])).toBe('—')
|
||||
})
|
||||
})
|
||||
|
||||
describe('capacityLine — only what was reported appears', () => {
|
||||
it('renders the full line', () => {
|
||||
expect(capacityLine({ os: 'linux', arch: 'arm64', cpus: 20, memory: 128 * GB, gpus: [{ model: 'GB10' }] })).toBe(
|
||||
'linux/arm64 · 20 vCPU · 128.0 GB · 1× GB10',
|
||||
)
|
||||
})
|
||||
it('OMITS an unreported cpu count rather than printing "0 vCPU"', () => {
|
||||
expect(capacityLine({ os: 'linux', gpus: [] })).toBe('linux')
|
||||
expect(capacityLine({ os: 'linux', gpus: [] })).not.toContain('vCPU')
|
||||
})
|
||||
it('handles os-only / arch-only', () => {
|
||||
expect(capacityLine({ arch: 'amd64', gpus: [] })).toBe('amd64')
|
||||
expect(capacityLine({ os: 'darwin', arch: 'arm64', gpus: [] })).toBe('darwin/arm64')
|
||||
})
|
||||
it('an all-silent spec is a dash, never a fabricated line', () => {
|
||||
expect(capacityLine({ gpus: [] })).toBe('—')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatters dash the unknown, never zero it', () => {
|
||||
it('fmtLoad', () => {
|
||||
expect(fmtLoad(1.5)).toBe('1.50')
|
||||
expect(fmtLoad(undefined)).toBe('—')
|
||||
})
|
||||
it('fmtRatio', () => {
|
||||
expect(fmtRatio(0.75)).toBe('75.0%')
|
||||
expect(fmtRatio(undefined)).toBe('—')
|
||||
})
|
||||
it('fmtMemPair needs BOTH halves', () => {
|
||||
expect(fmtMemPair(4 * GB, 8 * GB)).toBe('4.0 GB / 8.0 GB')
|
||||
expect(fmtMemPair(undefined, 8 * GB)).toBe('—')
|
||||
expect(fmtMemPair(4 * GB, undefined)).toBe('—')
|
||||
})
|
||||
})
|
||||
|
||||
describe('verdict — attention is reserved for "online but silent"', () => {
|
||||
it('flags an online unit that stopped reporting, and explains why', () => {
|
||||
const u = unit({ status: 'online', metrics: { at: NOW - 600 } })
|
||||
expect(verdictOf(u, NOW)).toBe('attention')
|
||||
expect(verdictNote(u, NOW)).toBe('Online but has stopped reporting')
|
||||
})
|
||||
it('a healthy unit is healthy and carries no note', () => {
|
||||
const u = unit({ status: 'online', metrics: { at: NOW - 5 } })
|
||||
expect(verdictOf(u, NOW)).toBe('healthy')
|
||||
expect(verdictNote(u, NOW)).toBeUndefined()
|
||||
})
|
||||
it('draining is its own state, not an alarm', () => {
|
||||
expect(verdictOf(unit({ status: 'draining', metrics: { at: NOW } }), NOW)).toBe('draining')
|
||||
})
|
||||
it('offline is quiet — an expected absence is not a fault', () => {
|
||||
expect(verdictOf(unit({ status: 'offline', metrics: { at: NOW - 9999 } }), NOW)).toBe('quiet')
|
||||
})
|
||||
it('an online unit that never reported metrics is healthy, not flagged', () => {
|
||||
expect(verdictOf(unit({ status: 'online', metrics: {} }), NOW)).toBe('healthy')
|
||||
})
|
||||
it('an unknown status is quiet (fails closed)', () => {
|
||||
expect(verdictOf(unit({ status: 'suspended' }), NOW)).toBe('quiet')
|
||||
})
|
||||
it('isStale marks an old heartbeat, and never an absent one', () => {
|
||||
expect(isStale(unit({ metrics: { at: NOW - 600 } }), NOW)).toBe(true)
|
||||
expect(isStale(unit({ metrics: { at: NOW } }), NOW)).toBe(false)
|
||||
expect(isStale(unit({ metrics: {} }), NOW)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('filterUnits', () => {
|
||||
const units = [
|
||||
unit({ unit: 'a', label: 'spark', source: 'byo', status: 'online', spec: { os: 'linux', gpus: [{ model: 'GB10' }] } }),
|
||||
unit({ unit: 'b', label: 'laptop', source: 'agent', status: 'offline', spec: { os: 'darwin', gpus: [] } }),
|
||||
]
|
||||
|
||||
it('filters by source and status', () => {
|
||||
expect(filterUnits(units, { source: 'byo' }).map((u) => u.unit)).toEqual(['a'])
|
||||
expect(filterUnits(units, { status: 'offline' }).map((u) => u.unit)).toEqual(['b'])
|
||||
})
|
||||
it('`all` and an empty filter keep everything', () => {
|
||||
expect(filterUnits(units, { source: 'all', status: 'all' })).toHaveLength(2)
|
||||
expect(filterUnits(units, {})).toHaveLength(2)
|
||||
})
|
||||
it('searches name, os and GPU model, case-insensitively', () => {
|
||||
expect(filterUnits(units, { search: 'SPARK' }).map((u) => u.unit)).toEqual(['a'])
|
||||
expect(filterUnits(units, { search: 'gb10' }).map((u) => u.unit)).toEqual(['a'])
|
||||
expect(filterUnits(units, { search: 'darwin' }).map((u) => u.unit)).toEqual(['b'])
|
||||
})
|
||||
it('treats the query as a LITERAL substring — a regex metachar is not compiled (ReDoS guard)', () => {
|
||||
expect(() => filterUnits(units, { search: '(((((' })).not.toThrow()
|
||||
expect(filterUnits(units, { search: '.*' })).toHaveLength(0)
|
||||
expect(filterUnits(units, { search: 'a+' })).toHaveLength(0)
|
||||
})
|
||||
it('combines a source filter with a search', () => {
|
||||
expect(filterUnits(units, { source: 'agent', search: 'spark' })).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('orderUnits — mission control is scanned top-down, so attention leads', () => {
|
||||
it('puts flagged units first, then draining, then healthy, then quiet', () => {
|
||||
const rows = orderUnits(
|
||||
[
|
||||
unit({ unit: 'quiet', label: 'd', status: 'offline' }),
|
||||
unit({ unit: 'healthy', label: 'c', status: 'online', metrics: { at: NOW } }),
|
||||
unit({ unit: 'draining', label: 'b', status: 'draining' }),
|
||||
unit({ unit: 'attention', label: 'a', status: 'online', metrics: { at: NOW - 600 } }),
|
||||
],
|
||||
NOW,
|
||||
)
|
||||
expect(rows.map((u) => u.unit)).toEqual(['attention', 'draining', 'healthy', 'quiet'])
|
||||
})
|
||||
it('orders within a group by name', () => {
|
||||
const rows = orderUnits(
|
||||
[unit({ unit: '1', label: 'zeta', status: 'online' }), unit({ unit: '2', label: 'alpha', status: 'online' })],
|
||||
NOW,
|
||||
)
|
||||
expect(rows.map((u) => u.label)).toEqual(['alpha', 'zeta'])
|
||||
})
|
||||
it('does not mutate the input', () => {
|
||||
const input = [unit({ unit: '1', label: 'z' }), unit({ unit: '2', label: 'a' })]
|
||||
orderUnits(input, NOW)
|
||||
expect(input.map((u) => u.unit)).toEqual(['1', '2'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('filter options offer only what is actually present', () => {
|
||||
const units = [unit({ source: 'byo', status: 'online' }), unit({ unit: 'b', source: 'agent', status: 'draining' })]
|
||||
it('lists the present sources/statuses, sorted, with `all` first', () => {
|
||||
expect(sourceOptions(units)).toEqual(['all', 'agent', 'byo'])
|
||||
expect(statusOptions(units)).toEqual(['all', 'draining', 'online'])
|
||||
})
|
||||
it('an empty fleet offers only `all` — never a menu of absent options', () => {
|
||||
expect(sourceOptions([])).toEqual(['all'])
|
||||
expect(statusOptions([])).toEqual(['all'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('seriesOf — a gap is honest, a false 0 is a lie', () => {
|
||||
const samples: FleetSample[] = [
|
||||
{ ts: NOW - 120, load1: 1, gpuUtil: 0.5 },
|
||||
{ ts: NOW - 60 }, // this row carried no load/gpu column
|
||||
{ ts: NOW, load1: 2, gpuUtil: 0 },
|
||||
]
|
||||
|
||||
it('SKIPS a row missing the column rather than plotting it as 0', () => {
|
||||
expect(seriesOf(samples, 'load1', '1h').map((p) => p.value)).toEqual([1, 2])
|
||||
})
|
||||
it('KEEPS a measured 0 (the row exists because it was measured)', () => {
|
||||
expect(seriesOf(samples, 'gpuUtil', '1h').map((p) => p.value)).toEqual([50, 0])
|
||||
})
|
||||
it('scales gpuUtil 0..1 to a percentage for display', () => {
|
||||
expect(seriesOf([{ ts: NOW, gpuUtil: 0.42 }], 'gpuUtil', '1h')[0].value).toBeCloseTo(42)
|
||||
})
|
||||
it('leaves other units alone', () => {
|
||||
expect(seriesOf([{ ts: NOW, memUsed: 4 * GB }], 'memUsed', '1h')[0].value).toBe(4 * GB)
|
||||
})
|
||||
it('empty in, empty out', () => {
|
||||
expect(seriesOf([], 'load1', '1h')).toEqual([])
|
||||
})
|
||||
it('hasTrend needs two real points — one is not a trend', () => {
|
||||
expect(hasTrend([])).toBe(false)
|
||||
expect(hasTrend([{ value: 1 }])).toBe(false)
|
||||
expect(hasTrend([{ value: 1 }, { value: 2 }])).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sampleLabel', () => {
|
||||
it('uses a date for the 7d range and a clock time inside a day', () => {
|
||||
expect(sampleLabel(NOW, '7d')).toMatch(/\w+ \d+/)
|
||||
expect(sampleLabel(NOW, '1h')).toMatch(/\d{1,2}:\d{2}/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sessionsSummary — the unit\'s own counts are authoritative', () => {
|
||||
it('reads none / running / idle honestly', () => {
|
||||
expect(sessionsSummary(unit({ sessions: 0, running: 0 }))).toBe('No sessions recorded')
|
||||
expect(sessionsSummary(unit({ sessions: 3, running: 1 }))).toBe('3 sessions · 1 running now')
|
||||
expect(sessionsSummary(unit({ sessions: 3, running: 0 }))).toBe('3 sessions · none running')
|
||||
})
|
||||
it('singularizes one session', () => {
|
||||
expect(sessionsSummary(unit({ sessions: 1, running: 0 }))).toBe('1 session · none running')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* Fleet board — the pure view decisions.
|
||||
*
|
||||
* Everything here is a total function over the normalized `FleetUnit`/`FleetSample`
|
||||
* values: labels, capacity lines, filtering, ordering, and the series a chart plots.
|
||||
* No React, no icons, no `@hanzo/gui` — so it is unit-tested in the repo's node
|
||||
* vitest environment, and the logic that SHIPS is the logic that is tested.
|
||||
*
|
||||
* The honesty rules live in `~/lib/api/fleet` (a 0 on the unit wire is unknown); this
|
||||
* file only ever renders what survived them — an `undefined` becomes `DASH`, never a 0.
|
||||
*/
|
||||
import { fmtBytes, fmtPct } from '~/lib/api/agents'
|
||||
import { DASH } from '~/lib/api/visor'
|
||||
import {
|
||||
freshnessOf,
|
||||
isOnline,
|
||||
needsAttention,
|
||||
type FleetGpu,
|
||||
type FleetRange,
|
||||
type FleetSample,
|
||||
type FleetSpec,
|
||||
type FleetUnit,
|
||||
} from '~/lib/api/fleet'
|
||||
|
||||
// ── labels ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const SOURCE_LABEL: Record<string, string> = {
|
||||
agent: 'Agent',
|
||||
byo: 'BYO',
|
||||
cloud: 'Cloud',
|
||||
visor: 'Visor',
|
||||
}
|
||||
|
||||
/** The source badge text. An unknown source shows ITSELF, not a wrong guess. */
|
||||
export const sourceLabel = (source?: string): string => (source ? (SOURCE_LABEL[source] ?? source) : DASH)
|
||||
|
||||
const SOURCE_HINT: Record<string, string> = {
|
||||
agent: 'Linked by an agent or CLI session',
|
||||
byo: 'Bring-your-own worker you attached',
|
||||
cloud: 'Runs in Hanzo Cloud for your org',
|
||||
visor: 'A machine Hanzo manages for you',
|
||||
}
|
||||
export const sourceHint = (source?: string): string | undefined => (source ? SOURCE_HINT[source] : undefined)
|
||||
|
||||
const KIND_LABEL: Record<string, string> = {
|
||||
laptop: 'Laptop',
|
||||
cloud: 'Cloud box',
|
||||
gpu: 'GPU host',
|
||||
cluster: 'Cluster',
|
||||
machine: 'Machine',
|
||||
worker: 'Worker',
|
||||
}
|
||||
export const kindLabel = (kind?: string): string => (kind ? (KIND_LABEL[kind] ?? kind) : DASH)
|
||||
|
||||
/** A unit's display name: its label, else the host, else the raw id. Always something real. */
|
||||
export const unitTitle = (u: FleetUnit): string => u.label || u.host || u.unit
|
||||
|
||||
/** The secondary line under the title — the host, unless it is already the title. */
|
||||
export const unitSubtitle = (u: FleetUnit): string | undefined => (u.host && u.host !== unitTitle(u) ? u.host : undefined)
|
||||
|
||||
// ── capacity ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** `2× H100` / `1× GB10 · 1× A100`; DASH when there are none. Groups identical models. */
|
||||
export function gpuLabel(gpus: FleetGpu[]): string {
|
||||
if (!gpus.length) return DASH
|
||||
const groups = new Map<string, number>()
|
||||
for (const g of gpus) {
|
||||
const k = g.model || g.vendor || 'GPU'
|
||||
groups.set(k, (groups.get(k) ?? 0) + 1)
|
||||
}
|
||||
return [...groups].map(([model, n]) => `${n}× ${model}`).join(' · ')
|
||||
}
|
||||
|
||||
/**
|
||||
* The capacity line: `linux/arm64 · 20 vCPU · 128 GB · 1× GB10`.
|
||||
*
|
||||
* Only the parts the host actually reported appear — an unreported CPU count is
|
||||
* omitted from the line rather than printed as "0 vCPU". An all-silent spec is DASH.
|
||||
*/
|
||||
export function capacityLine(spec: FleetSpec): string {
|
||||
const parts: string[] = []
|
||||
if (spec.os && spec.arch) parts.push(`${spec.os}/${spec.arch}`)
|
||||
else if (spec.os) parts.push(spec.os)
|
||||
else if (spec.arch) parts.push(spec.arch)
|
||||
if (spec.cpus !== undefined) parts.push(`${spec.cpus} vCPU`)
|
||||
if (spec.memory !== undefined) parts.push(fmtBytes(spec.memory))
|
||||
if (spec.gpus.length) parts.push(gpuLabel(spec.gpus))
|
||||
return parts.length ? parts.join(' · ') : DASH
|
||||
}
|
||||
|
||||
/** A load average as `1.50`; DASH when the host never reported one. */
|
||||
export const fmtLoad = (n?: number): string => (n === undefined ? DASH : n.toFixed(2))
|
||||
|
||||
/** A 0..1 ratio as a percent; DASH when unknown. (Re-exported so views have one import.) */
|
||||
export const fmtRatio = (x?: number): string => (x === undefined ? DASH : fmtPct(x))
|
||||
|
||||
/** `40 GB / 128 GB`; DASH when either half is unknown. */
|
||||
export const fmtMemPair = (used?: number, total?: number): string =>
|
||||
used === undefined || total === undefined ? DASH : `${fmtBytes(used)} / ${fmtBytes(total)}`
|
||||
|
||||
/**
|
||||
* Load as a fraction of the unit's OWN cores — the only way a load average means
|
||||
* anything (1.0 is idle on 20 cores and on fire on 1). May exceed 1: that is a real
|
||||
* overload and the caller shows the true number while the bar pins at full.
|
||||
* `undefined` when either half is unknown — no denominator, no bar.
|
||||
*/
|
||||
export function loadRatio(u: FleetUnit): number | undefined {
|
||||
const load = u.metrics.load1
|
||||
const cpus = u.spec.cpus
|
||||
if (load === undefined || cpus === undefined) return undefined
|
||||
return load / cpus
|
||||
}
|
||||
|
||||
// ── health verdict ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* How a unit reads at a glance. `attention` is the only state that asks for action:
|
||||
* the unit says it is online but has stopped reporting.
|
||||
*/
|
||||
export type Verdict = 'attention' | 'healthy' | 'draining' | 'quiet'
|
||||
|
||||
export function verdictOf(u: FleetUnit, nowS: number): Verdict {
|
||||
if (needsAttention(u, nowS)) return 'attention'
|
||||
if (u.status === 'draining') return 'draining'
|
||||
if (isOnline(u)) return 'healthy'
|
||||
return 'quiet'
|
||||
}
|
||||
|
||||
/** Why a unit is flagged — shown next to the pill so the state explains itself. */
|
||||
export function verdictNote(u: FleetUnit, nowS: number): string | undefined {
|
||||
if (verdictOf(u, nowS) !== 'attention') return undefined
|
||||
return 'Online but has stopped reporting'
|
||||
}
|
||||
|
||||
/** True when a heartbeat is old enough to dim the live numbers it produced. */
|
||||
export const isStale = (u: FleetUnit, nowS: number): boolean => freshnessOf(u.metrics.at, nowS) === 'stale'
|
||||
|
||||
// ── filtering + ordering ─────────────────────────────────────────────────────
|
||||
|
||||
export type FleetFilter = { search?: string; source?: string; status?: string }
|
||||
|
||||
/**
|
||||
* Filter by source/status and a free-text match over the fields a person would
|
||||
* actually type: name, host, id, os/arch and GPU model.
|
||||
*
|
||||
* The search is a LITERAL case-insensitive substring test — never a compiled RegExp
|
||||
* of user input (the repo's ReDoS rule).
|
||||
*/
|
||||
export function filterUnits(units: FleetUnit[], f: FleetFilter): FleetUnit[] {
|
||||
const q = (f.search ?? '').trim().toLowerCase()
|
||||
return units.filter((u) => {
|
||||
if (f.source && f.source !== 'all' && (u.source ?? '') !== f.source) return false
|
||||
if (f.status && f.status !== 'all' && (u.status ?? '') !== f.status) return false
|
||||
if (!q) return true
|
||||
const hay = [unitTitle(u), u.host, u.unit, u.source, u.kind, u.spec.os, u.spec.arch, gpuLabel(u.spec.gpus)]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
return hay.includes(q)
|
||||
})
|
||||
}
|
||||
|
||||
const RANK: Record<Verdict, number> = { attention: 0, draining: 1, healthy: 2, quiet: 3 }
|
||||
|
||||
/**
|
||||
* Order the board so what needs attention is at the top: flagged units first, then
|
||||
* draining, then healthy, then quiet — each group by name. Mission control is scanned
|
||||
* from the top down, so the top must be the thing worth looking at.
|
||||
*/
|
||||
export function orderUnits(units: FleetUnit[], nowS: number): FleetUnit[] {
|
||||
return [...units].sort((a, b) => {
|
||||
const d = RANK[verdictOf(a, nowS)] - RANK[verdictOf(b, nowS)]
|
||||
if (d !== 0) return d
|
||||
return unitTitle(a).localeCompare(unitTitle(b))
|
||||
})
|
||||
}
|
||||
|
||||
/** The source options a filter should offer: only sources actually present, plus `all`. */
|
||||
export function sourceOptions(units: FleetUnit[]): string[] {
|
||||
const seen = new Set<string>()
|
||||
for (const u of units) if (u.source) seen.add(u.source)
|
||||
return ['all', ...[...seen].sort()]
|
||||
}
|
||||
|
||||
/** The status options actually present, plus `all`. */
|
||||
export function statusOptions(units: FleetUnit[]): string[] {
|
||||
const seen = new Set<string>()
|
||||
for (const u of units) if (u.status) seen.add(u.status)
|
||||
return ['all', ...[...seen].sort()]
|
||||
}
|
||||
|
||||
// ── the trend ────────────────────────────────────────────────────────────────
|
||||
|
||||
export const RANGE_LABEL: Record<FleetRange, string> = { '1h': '1H', '6h': '6H', '24h': '24H', '7d': '7D' }
|
||||
|
||||
/** A time label for a sample: clock time inside a day, date beyond it. */
|
||||
export function sampleLabel(ts: number, range: FleetRange): string {
|
||||
const d = new Date(ts * 1000)
|
||||
if (range === '7d') return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||
return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
/** The numeric fields of a sample a chart can plot. */
|
||||
export type SampleKey = 'load1' | 'load5' | 'load15' | 'gpuUtil' | 'memUsed' | 'costCents'
|
||||
|
||||
/**
|
||||
* A sample series as chart points.
|
||||
*
|
||||
* A row that did not carry this column is SKIPPED, never plotted as 0 — a gap is
|
||||
* honest, a false trough is a lie. `gpuUtil` is scaled to a percentage for display.
|
||||
*/
|
||||
export function seriesOf(samples: FleetSample[], key: SampleKey, range: FleetRange): { label: string; value: number }[] {
|
||||
const out: { label: string; value: number }[] = []
|
||||
for (const s of samples) {
|
||||
const raw = s[key]
|
||||
if (raw === undefined || s.ts === undefined) continue
|
||||
out.push({ label: sampleLabel(s.ts, range), value: key === 'gpuUtil' ? raw * 100 : raw })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** The house rule: fewer than two real points is not a trend. */
|
||||
export const hasTrend = (points: { value: number }[]): boolean => points.length >= 2
|
||||
|
||||
/**
|
||||
* The recorded-sessions note.
|
||||
*
|
||||
* `/v1/fleet` reports a unit's authoritative session counts. `/v1/agents/sessions`
|
||||
* has NO per-target filter today, so this board does not fetch a per-unit session
|
||||
* LIST — a client-side filter of the org's recent sessions could show "none" for a
|
||||
* unit whose own count says 12, and a panel that contradicts the number beside it is
|
||||
* worse than no panel. BACKEND FOLLOW-ON: add `?target=` to GET /v1/agents/sessions
|
||||
* and this becomes a real list.
|
||||
*/
|
||||
export function sessionsSummary(u: FleetUnit): string {
|
||||
if (u.sessions === 0) return 'No sessions recorded'
|
||||
const plural = u.sessions === 1 ? 'session' : 'sessions'
|
||||
return u.running > 0 ? `${u.sessions} ${plural} · ${u.running} running now` : `${u.sessions} ${plural} · none running`
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Fleet board — the shared visual atoms.
|
||||
*
|
||||
* Colour is SEMANTIC and separate from the product accent: a unit's tone says how it
|
||||
* is doing, never which brand it belongs to. Every hex is drawn from the console's
|
||||
* existing `SERIES` palette (`~/components/ui/Metric`) so the board sits inside the
|
||||
* design system rather than beside it, and the meters reuse the shared `UtilBar`
|
||||
* (which tones itself green → amber → red by value).
|
||||
*/
|
||||
import type { ReactElement } from 'react'
|
||||
import { Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Box, Cloud, Cpu, HardDrive, Laptop, Network, Server } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { SERIES, UtilBar } from '~/components/ui/Metric'
|
||||
import type { ProductIcon } from '~/lib/products/registry'
|
||||
import type { FleetUnit } from '~/lib/api/fleet'
|
||||
import { agoLabel } from '~/lib/api/fleet'
|
||||
import { kindLabel, sourceLabel, verdictOf, type Verdict } from './logic'
|
||||
|
||||
/**
|
||||
* Verdict → tone. `quiet` (offline) is GREY, not red: a laptop that closed its lid is
|
||||
* an expected absence, not a failure, and colouring it like an outage trains an
|
||||
* operator to ignore the colour. Amber is reserved for the one state that asks for
|
||||
* action — online but no longer reporting.
|
||||
*/
|
||||
const VERDICT_HEX: Record<Verdict, string> = {
|
||||
attention: SERIES[2], // amber — look at this
|
||||
healthy: SERIES[1], // green
|
||||
draining: SERIES[0], // blue — a deliberate operator action, informational
|
||||
quiet: SERIES[7], // muted — an expected absence
|
||||
}
|
||||
|
||||
const VERDICT_LABEL: Record<Verdict, string> = {
|
||||
attention: 'Not reporting',
|
||||
healthy: 'Online',
|
||||
draining: 'Draining',
|
||||
quiet: 'Offline',
|
||||
}
|
||||
|
||||
export const verdictHex = (v: Verdict): string => VERDICT_HEX[v]
|
||||
|
||||
const KIND_ICON: Record<string, ProductIcon> = {
|
||||
laptop: Laptop,
|
||||
cloud: Cloud,
|
||||
gpu: Cpu,
|
||||
cluster: Network,
|
||||
machine: Server,
|
||||
worker: HardDrive,
|
||||
}
|
||||
|
||||
/** The kind icon; an unrecognized kind gets the generic box rather than a wrong picture. */
|
||||
export const kindIcon = (kind?: string): ProductIcon => (kind ? (KIND_ICON[kind] ?? Box) : Box)
|
||||
|
||||
/** A coloured dot — inline SVG so the fill is a raw hex (the house pattern). */
|
||||
export function Dot({ color, size = 8 }: { color: string; size?: number }): ReactElement {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 10 10" aria-hidden="true">
|
||||
<circle cx="5" cy="5" r="5" fill={color} />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The status pill: the unit's DECLARED status fused with whether it is still
|
||||
* reporting. "Online" and "online but silent for 20m" are different operational
|
||||
* facts and must never render identically.
|
||||
*/
|
||||
export function VerdictPill({ unit, nowS }: { unit: FleetUnit; nowS: number }): ReactElement {
|
||||
const v = verdictOf(unit, nowS)
|
||||
// An unknown backend status shows ITSELF rather than being coerced into one of ours.
|
||||
const known = unit.status === 'online' || unit.status === 'offline' || unit.status === 'draining'
|
||||
const label = v === 'quiet' && unit.status && !known ? unit.status : VERDICT_LABEL[v]
|
||||
return (
|
||||
<XStack items="center" gap="$1.5">
|
||||
<Dot color={VERDICT_HEX[v]} />
|
||||
<Text fontSize="$2" color="$color12">
|
||||
{label}
|
||||
</Text>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
/** The source badge — which plane this unit came from. */
|
||||
export function SourceBadge({ source }: { source?: string }): ReactElement {
|
||||
return (
|
||||
<Text fontSize="$1" px="$2" py="$1" rounded="$2" bg="$color3" color="$color11" textTransform="uppercase">
|
||||
{sourceLabel(source)}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
/** "12s ago", dimmed once the heartbeat is stale so the age reads as a warning itself. */
|
||||
export function Heartbeat({ at, nowS, stale }: { at?: number; nowS: number; stale: boolean }): ReactElement {
|
||||
return (
|
||||
<Text fontSize="$1" color={stale ? SERIES[2] : '$color10'} className="hz-mono">
|
||||
{agoLabel(at, nowS)}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One live-health row: a label, the real value, and a bar.
|
||||
*
|
||||
* The bar appears ONLY when a ratio is genuinely known — an unreported metric shows
|
||||
* the em-dash and NO bar, because an empty bar reads as "0%", which is a fabrication.
|
||||
* A stale value is dimmed: it is real, but it is old.
|
||||
*/
|
||||
export function MeterRow({
|
||||
label,
|
||||
value,
|
||||
ratio,
|
||||
dim,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
ratio?: number
|
||||
dim?: boolean
|
||||
}): ReactElement {
|
||||
return (
|
||||
<XStack items="center" gap="$2">
|
||||
<Text fontSize="$1" color="$color10" width={34}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text fontSize="$2" color={dim ? '$color10' : '$color12'} className="hz-mono" flex={1} minW={0} numberOfLines={1}>
|
||||
{value}
|
||||
</Text>
|
||||
{ratio !== undefined ? <UtilBar value={ratio * 100} width={72} /> : null}
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
/** A labelled fact — the detail view's spec rows. Value is pre-formatted (so `—` is honest). */
|
||||
export function Fact({ label, value }: { label: string; value: string }): ReactElement {
|
||||
return (
|
||||
<XStack justify="space-between" gap="$3" items="baseline">
|
||||
<Text fontSize="$2" color="$color11">
|
||||
{label}
|
||||
</Text>
|
||||
<Text fontSize="$2" color="$color12" className="hz-mono" numberOfLines={1}>
|
||||
{value}
|
||||
</Text>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
/** The kind + label line used in both the card and the detail header. */
|
||||
export function UnitKindLine({ unit }: { unit: FleetUnit }): ReactElement {
|
||||
return (
|
||||
<Text fontSize="$1" color="$color10">
|
||||
{kindLabel(unit.kind)}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* The per-org / per-project AI-overview board — `GET /v1/evals/metrics` (hanzoai/cloud
|
||||
* `clients/eval` `metrics.go` → `metricsBoard`). The native "Langfuse home": which
|
||||
* models an org uses, request volume, cost, tokens (prompt/completion/total), error &
|
||||
* success rate, and latency percentiles (p50/p95/p99) over a window — aggregated from
|
||||
* the cloud_usage ledger (+ best-effort GenAI-span latency).
|
||||
*
|
||||
* Transport: the same-origin `/v1` user-bearer BFF (`cloudProxyV1Url('evals/metrics')`
|
||||
* -> `<origin>/v1/evals/metrics`); the `evals` head is allow-listed in `proxy-allow.ts`.
|
||||
* The org is pinned SERVER-SIDE from the validated bearer owner (never a client header);
|
||||
* a selected project rides `X-Project-Id` (stamped by `client.ts` when a project is in
|
||||
* scope), so a signed-in member only ever sees its OWN org, narrowed to its project.
|
||||
* The endpoint speaks PLAIN REST (raw JSON, real HTTP status), so `restGet` throws a
|
||||
* typed `ApiError` — a 503 (datastore) / 404 (not routed) / 401.403 (session/access) is
|
||||
* surfaced as `connected:false`, which the page renders as an honest notice, never a
|
||||
* fabricated dashboard.
|
||||
*
|
||||
* Honest by construction: every field is defensively normalized (missing -> 0 / [],
|
||||
* snake_case AND camelCase tolerated), and the latency percentiles are preserved as
|
||||
* `null` when the backend has no GenAI-span data (rendered "—", never a fake 0).
|
||||
*/
|
||||
import { ApiError, cloudProxyV1Url, restGet } from './client'
|
||||
|
||||
export type MetricsRange = '24h' | '7d' | '30d'
|
||||
const RANGES: MetricsRange[] = ['24h', '7d', '30d']
|
||||
|
||||
// ── defensive coercion (snake_case OR camelCase; missing/garbage -> honest zero) ──
|
||||
const rec = (v: unknown): Record<string, unknown> =>
|
||||
v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : {}
|
||||
const arr = (v: unknown): unknown[] => (Array.isArray(v) ? v : [])
|
||||
const num = (v: unknown): number =>
|
||||
typeof v === 'number' && Number.isFinite(v)
|
||||
? v
|
||||
: typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v))
|
||||
? Number(v)
|
||||
: 0
|
||||
const str = (v: unknown): string => (typeof v === 'string' ? v : '')
|
||||
const bool = (v: unknown): boolean => v === true
|
||||
/** Nullable number — a latency percentile is `null` (not 0) when there is no data. */
|
||||
const numOrNull = (v: unknown): number | null =>
|
||||
typeof v === 'number' && Number.isFinite(v)
|
||||
? v
|
||||
: typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v))
|
||||
? Number(v)
|
||||
: null
|
||||
/** First present of the given keys (camel + snake variants). */
|
||||
const g = (o: Record<string, unknown>, ...keys: string[]): unknown => {
|
||||
for (const k of keys) if (o[k] !== undefined) return o[k]
|
||||
return undefined
|
||||
}
|
||||
|
||||
export type BoardScope = { org: string; project: string; allOrgs: boolean }
|
||||
export type BoardRange = { range: string; start: string; end: string; interval: string }
|
||||
|
||||
export type BoardTotals = {
|
||||
generations: number
|
||||
promptTokens: number
|
||||
completionTokens: number
|
||||
totalTokens: number
|
||||
costCents: number
|
||||
errors: number
|
||||
successRate: number // 0..1
|
||||
models: number
|
||||
users: number
|
||||
}
|
||||
|
||||
export type BoardPoint = { t: string; generations: number; costCents: number; totalTokens: number; errors: number }
|
||||
|
||||
export type ModelStat = {
|
||||
model: string
|
||||
provider: string
|
||||
requests: number
|
||||
promptTokens: number
|
||||
completionTokens: number
|
||||
totalTokens: number
|
||||
costCents: number
|
||||
errors: number
|
||||
errorRate: number // 0..1
|
||||
costPct: number // 0..100
|
||||
p50Ms: number | null
|
||||
p95Ms: number | null
|
||||
p99Ms: number | null
|
||||
modelCount: number // >0 only on the folded "other" row
|
||||
}
|
||||
|
||||
export type LatencyStat = { available: boolean; p50Ms: number | null; p95Ms: number | null; p99Ms: number | null }
|
||||
|
||||
/** The full AI-overview board + a `connected` flag (false = the endpoint is unreachable). */
|
||||
export type Board = {
|
||||
scope: BoardScope
|
||||
range: BoardRange
|
||||
totals: BoardTotals
|
||||
series: BoardPoint[]
|
||||
byModel: ModelStat[]
|
||||
other: ModelStat | null
|
||||
latency: LatencyStat
|
||||
connected: boolean
|
||||
}
|
||||
|
||||
function normalizeTotals(v: unknown): BoardTotals {
|
||||
const t = rec(v)
|
||||
return {
|
||||
generations: num(g(t, 'generations')),
|
||||
promptTokens: num(g(t, 'promptTokens', 'prompt_tokens')),
|
||||
completionTokens: num(g(t, 'completionTokens', 'completion_tokens')),
|
||||
totalTokens: num(g(t, 'totalTokens', 'total_tokens')),
|
||||
costCents: num(g(t, 'costCents', 'cost_cents')),
|
||||
errors: num(g(t, 'errors')),
|
||||
successRate: num(g(t, 'successRate', 'success_rate')),
|
||||
models: num(g(t, 'models')),
|
||||
users: num(g(t, 'users')),
|
||||
}
|
||||
}
|
||||
|
||||
const normSeries = (v: unknown): BoardPoint[] =>
|
||||
arr(v).map((r) => {
|
||||
const o = rec(r)
|
||||
return {
|
||||
t: str(g(o, 't')),
|
||||
generations: num(g(o, 'generations')),
|
||||
costCents: num(g(o, 'costCents', 'cost_cents')),
|
||||
totalTokens: num(g(o, 'totalTokens', 'total_tokens')),
|
||||
errors: num(g(o, 'errors')),
|
||||
}
|
||||
})
|
||||
|
||||
function normModel(v: unknown): ModelStat {
|
||||
const o = rec(v)
|
||||
return {
|
||||
model: str(g(o, 'model')),
|
||||
provider: str(g(o, 'provider')),
|
||||
requests: num(g(o, 'requests')),
|
||||
promptTokens: num(g(o, 'promptTokens', 'prompt_tokens')),
|
||||
completionTokens: num(g(o, 'completionTokens', 'completion_tokens')),
|
||||
totalTokens: num(g(o, 'totalTokens', 'total_tokens')),
|
||||
costCents: num(g(o, 'costCents', 'cost_cents')),
|
||||
errors: num(g(o, 'errors')),
|
||||
errorRate: num(g(o, 'errorRate', 'error_rate')),
|
||||
costPct: num(g(o, 'costPct', 'cost_pct')),
|
||||
p50Ms: numOrNull(g(o, 'p50Ms', 'p50_ms')),
|
||||
p95Ms: numOrNull(g(o, 'p95Ms', 'p95_ms')),
|
||||
p99Ms: numOrNull(g(o, 'p99Ms', 'p99_ms')),
|
||||
modelCount: num(g(o, 'modelCount', 'model_count')),
|
||||
}
|
||||
}
|
||||
|
||||
function normLatency(v: unknown): LatencyStat {
|
||||
const o = rec(v)
|
||||
return {
|
||||
available: bool(g(o, 'available')),
|
||||
p50Ms: numOrNull(g(o, 'p50Ms', 'p50_ms')),
|
||||
p95Ms: numOrNull(g(o, 'p95Ms', 'p95_ms')),
|
||||
p99Ms: numOrNull(g(o, 'p99Ms', 'p99_ms')),
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalize the raw `/v1/evals/metrics` 200 body -> a connected Board. */
|
||||
export function normalizeBoard(raw: unknown): Board {
|
||||
const d = rec(raw)
|
||||
const scope = rec(g(d, 'scope'))
|
||||
const range = rec(g(d, 'range'))
|
||||
const other = g(d, 'other')
|
||||
return {
|
||||
scope: {
|
||||
org: str(g(scope, 'org')),
|
||||
project: str(g(scope, 'project')),
|
||||
allOrgs: bool(g(scope, 'allOrgs', 'all_orgs')),
|
||||
},
|
||||
range: {
|
||||
range: str(g(range, 'range')),
|
||||
start: str(g(range, 'start')),
|
||||
end: str(g(range, 'end')),
|
||||
interval: str(g(range, 'interval')),
|
||||
},
|
||||
totals: normalizeTotals(g(d, 'totals')),
|
||||
series: normSeries(g(d, 'series')),
|
||||
byModel: arr(g(d, 'byModel', 'by_model')).map(normModel),
|
||||
other: other != null && typeof other === 'object' ? normModel(other) : null,
|
||||
latency: normLatency(g(d, 'latency')),
|
||||
connected: true,
|
||||
}
|
||||
}
|
||||
|
||||
/** The honest not-connected board (the endpoint is unreachable), carrying the range. */
|
||||
export function emptyBoard(range: MetricsRange): Board {
|
||||
return {
|
||||
scope: { org: '', project: '', allOrgs: false },
|
||||
range: { range, start: '', end: '', interval: '' },
|
||||
totals: {
|
||||
generations: 0,
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
totalTokens: 0,
|
||||
costCents: 0,
|
||||
successRate: 0,
|
||||
errors: 0,
|
||||
models: 0,
|
||||
users: 0,
|
||||
},
|
||||
series: [],
|
||||
byModel: [],
|
||||
other: null,
|
||||
latency: { available: false, p50Ms: null, p95Ms: null, p99Ms: null },
|
||||
connected: false,
|
||||
}
|
||||
}
|
||||
|
||||
const metricsUrl = (range: MetricsRange): string =>
|
||||
cloudProxyV1Url(`evals/metrics?range=${encodeURIComponent(range)}`)
|
||||
|
||||
export const EvalsMetricsApi = {
|
||||
/**
|
||||
* The AI-overview board for the caller's org (+ selected project) over `range`.
|
||||
* Returns a normalized, connected Board on 200; on any transport error returns an
|
||||
* honest `connected:false` board (never throws) so the page renders a notice, not a
|
||||
* crash. Any non-200 is "not connected"; a signed-in 403 (project not attributed /
|
||||
* not enabled) still yields the honest empty board the page shows beside a notice.
|
||||
*/
|
||||
board: async (range: MetricsRange = '24h'): Promise<Board> => {
|
||||
const r: MetricsRange = RANGES.includes(range) ? range : '24h'
|
||||
try {
|
||||
return normalizeBoard(await restGet<unknown>(metricsUrl(r)))
|
||||
} catch (e) {
|
||||
void (e instanceof ApiError ? e.status : 0)
|
||||
return emptyBoard(r)
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { cloudProxyV1Url } from './client'
|
||||
import {
|
||||
agoLabel,
|
||||
findUnit,
|
||||
FleetApi,
|
||||
freshnessOf,
|
||||
isOnline,
|
||||
memRatio,
|
||||
memTotal,
|
||||
needsAttention,
|
||||
normalizeMetrics,
|
||||
normalizeSample,
|
||||
normalizeSamples,
|
||||
normalizeSpec,
|
||||
normalizeUnit,
|
||||
normalizeUnits,
|
||||
sampleSeconds,
|
||||
STALE_AFTER_S,
|
||||
summarize,
|
||||
unitKey,
|
||||
type FleetUnit,
|
||||
} from './fleet'
|
||||
|
||||
const GB = 1024 ** 3
|
||||
|
||||
/** A minimal real unit; override per case. */
|
||||
const unit = (over: Partial<FleetUnit> = {}): FleetUnit => ({
|
||||
unit: 'u1',
|
||||
source: 'agent',
|
||||
kind: 'laptop',
|
||||
status: 'online',
|
||||
spec: { gpus: [] },
|
||||
metrics: {},
|
||||
sessions: 0,
|
||||
running: 0,
|
||||
...over,
|
||||
})
|
||||
|
||||
describe('FleetApi routes to the /v1 bearer BFF and never sends an org', () => {
|
||||
const seen: string[] = []
|
||||
const stubFetch = (body: unknown) =>
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (url: string) => {
|
||||
seen.push(String(url))
|
||||
return { status: 200, ok: true, text: async () => JSON.stringify(body), json: async () => body } as unknown as Response
|
||||
}),
|
||||
)
|
||||
afterEach(() => {
|
||||
seen.length = 0
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('units() reads /v1/fleet (the head 403s a cookie-only call, so it must hit the BFF)', async () => {
|
||||
stubFetch({ units: [] })
|
||||
await FleetApi.units()
|
||||
expect(seen[0]).toBe(cloudProxyV1Url('fleet'))
|
||||
expect(seen[0]).toContain('/v1/fleet')
|
||||
})
|
||||
|
||||
it('samples() passes unit/source/range and defaults the range to 24h', async () => {
|
||||
stubFetch({ samples: [] })
|
||||
await FleetApi.samples({ unit: 'u1', source: 'agent' })
|
||||
const u = new URL(seen[0], 'https://console.hanzo.ai')
|
||||
expect(u.pathname).toBe('/v1/fleet/samples')
|
||||
expect(u.searchParams.get('unit')).toBe('u1')
|
||||
expect(u.searchParams.get('source')).toBe('agent')
|
||||
expect(u.searchParams.get('range')).toBe('24h')
|
||||
})
|
||||
|
||||
it('an explicit range wins; an absent source is omitted, never sent empty', async () => {
|
||||
stubFetch({ samples: [] })
|
||||
await FleetApi.samples({ unit: 'u1', range: '7d' })
|
||||
const u = new URL(seen[0], 'https://console.hanzo.ai')
|
||||
expect(u.searchParams.get('range')).toBe('7d')
|
||||
expect(u.searchParams.has('source')).toBe(false)
|
||||
})
|
||||
|
||||
it('NEVER sends an org — tenancy is the bearer, so no request may carry an org param', async () => {
|
||||
stubFetch({ units: [] })
|
||||
await FleetApi.units()
|
||||
await FleetApi.samples({ unit: 'u1', source: 'byo', range: '1h' })
|
||||
for (const url of seen) {
|
||||
expect(url).not.toMatch(/[?&](org|orgId|owner|tenant)=/i)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeUnits — wire tolerance, never throws', () => {
|
||||
it('unwraps {units} (the contract), a bare array, {items} and {data:{units}}', () => {
|
||||
expect(normalizeUnits({ units: [{ unit: 'a' }, { unit: 'b' }] })).toHaveLength(2)
|
||||
expect(normalizeUnits([{ unit: 'a' }])).toHaveLength(1)
|
||||
expect(normalizeUnits({ items: [{ unit: 'a' }] })).toHaveLength(1)
|
||||
expect(normalizeUnits({ status: 'ok', data: { units: [{ unit: 'a' }] } })).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('drops an idless row rather than inventing an unaddressable unit', () => {
|
||||
expect(normalizeUnits({ units: [{ unit: 'a' }, { label: 'no id' }] })).toHaveLength(1)
|
||||
expect(normalizeUnit({})).toBeNull()
|
||||
expect(normalizeUnit('garbage')).toBeNull()
|
||||
})
|
||||
|
||||
it('never throws on garbage', () => {
|
||||
expect(() => normalizeUnits(null)).not.toThrow()
|
||||
expect(normalizeUnits(null)).toEqual([])
|
||||
expect(normalizeUnits('nope')).toEqual([])
|
||||
expect(normalizeUnits({ units: 'not-an-array' })).toEqual([])
|
||||
})
|
||||
|
||||
it('reads a full unit', () => {
|
||||
const u = normalizeUnit({
|
||||
unit: 'gb10-1',
|
||||
source: 'byo',
|
||||
kind: 'gpu',
|
||||
status: 'online',
|
||||
label: 'spark',
|
||||
host: 'spark.local',
|
||||
spec: { os: 'linux', arch: 'arm64', cpus: 20, memory: 128 * GB, gpus: [{ vendor: 'nvidia', model: 'GB10', memory: 120 * GB }] },
|
||||
metrics: { load1: 1.5, memUsed: 40 * GB, memFree: 88 * GB, gpuUtil: 0.75, at: 1_700_000_000 },
|
||||
sessions: 3,
|
||||
running: 1,
|
||||
})
|
||||
expect(u).not.toBeNull()
|
||||
expect(u?.spec.cpus).toBe(20)
|
||||
expect(u?.spec.gpus[0].model).toBe('GB10')
|
||||
expect(u?.metrics.gpuUtil).toBe(0.75)
|
||||
expect(u?.sessions).toBe(3)
|
||||
expect(u?.running).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps an UNKNOWN source/kind/status verbatim — a new backend word must not be coerced into a lie', () => {
|
||||
const u = normalizeUnit({ unit: 'a', source: 'edge', kind: 'tpu', status: 'suspended' })
|
||||
expect(u?.source).toBe('edge')
|
||||
expect(u?.kind).toBe('tpu')
|
||||
expect(u?.status).toBe('suspended')
|
||||
// …and it is NOT counted as online (fails closed).
|
||||
expect(isOnline(u as FleetUnit)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the "unknown ⇒ —" rule on /v1/fleet (omitempty makes a real 0 indistinguishable)', () => {
|
||||
it('leaves absent telemetry undefined so the view renders — , never 0', () => {
|
||||
const m = normalizeMetrics({})
|
||||
expect(m.load1).toBeUndefined()
|
||||
expect(m.memUsed).toBeUndefined()
|
||||
expect(m.gpuUtil).toBeUndefined()
|
||||
expect(m.at).toBeUndefined()
|
||||
})
|
||||
|
||||
it('treats an explicit 0 as UNKNOWN — a silent host must not read as an idle one', () => {
|
||||
const m = normalizeMetrics({ load1: 0, memUsed: 0, gpuUtil: 0, at: 0 })
|
||||
expect(m.load1).toBeUndefined()
|
||||
expect(m.memUsed).toBeUndefined()
|
||||
expect(m.gpuUtil).toBeUndefined()
|
||||
expect(m.at).toBeUndefined()
|
||||
})
|
||||
|
||||
it('leaves absent/zero spec undefined (cpus, memory, VRAM)', () => {
|
||||
const s = normalizeSpec({ cpus: 0, memory: 0, gpus: [{ model: 'A100', memory: 0 }] })
|
||||
expect(s.cpus).toBeUndefined()
|
||||
expect(s.memory).toBeUndefined()
|
||||
expect(s.gpus[0].memory).toBeUndefined()
|
||||
expect(s.gpus[0].model).toBe('A100')
|
||||
})
|
||||
|
||||
it('spec.gpus is always an array — a missing list is [], never undefined', () => {
|
||||
expect(normalizeSpec({}).gpus).toEqual([])
|
||||
expect(normalizeSpec({ gpus: 'nope' }).gpus).toEqual([])
|
||||
expect(normalizeUnit({ unit: 'a' })?.spec.gpus).toEqual([])
|
||||
})
|
||||
|
||||
it('counts we keep (sessions/running) DO read 0 — absence means no rows, not silence', () => {
|
||||
const u = normalizeUnit({ unit: 'a' })
|
||||
expect(u?.sessions).toBe(0)
|
||||
expect(u?.running).toBe(0)
|
||||
})
|
||||
|
||||
it('rejects a non-finite or negative number rather than rendering it', () => {
|
||||
const m = normalizeMetrics({ load1: -1, memUsed: Number.NaN, gpuUtil: Number.POSITIVE_INFINITY })
|
||||
expect(m.load1).toBeUndefined()
|
||||
expect(m.memUsed).toBeUndefined()
|
||||
expect(m.gpuUtil).toBeUndefined()
|
||||
})
|
||||
|
||||
it('clamps gpuUtil to the documented 0..1', () => {
|
||||
expect(normalizeMetrics({ gpuUtil: 1.5 }).gpuUtil).toBe(1)
|
||||
expect(normalizeMetrics({ gpuUtil: 0.5 }).gpuUtil).toBe(0.5)
|
||||
})
|
||||
|
||||
it('reads memUsed/memFree in snake_case too', () => {
|
||||
const m = normalizeMetrics({ mem_used: 4 * GB, mem_free: 4 * GB, gpu_util: 0.5 })
|
||||
expect(m.memUsed).toBe(4 * GB)
|
||||
expect(m.memFree).toBe(4 * GB)
|
||||
expect(m.gpuUtil).toBe(0.5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeSamples — a warehouse row, where 0 IS a measurement', () => {
|
||||
it('keeps a measured 0 (the row exists because something was measured)', () => {
|
||||
const s = normalizeSample({ ts: 1_700_000_000, load1: 0, gpu_util: 0, mem_used: 0 })
|
||||
expect(s.load1).toBe(0)
|
||||
expect(s.gpuUtil).toBe(0)
|
||||
expect(s.memUsed).toBe(0)
|
||||
})
|
||||
|
||||
it('reads the snake_case columns and their camelCase twins', () => {
|
||||
const a = normalizeSample({ ts: 1, mem_used: 5, mem_free: 6, gpu_util: 0.4, gpu_model: 'H100', cost_cents: 120 })
|
||||
expect(a).toMatchObject({ memUsed: 5, memFree: 6, gpuUtil: 0.4, gpuModel: 'H100', costCents: 120 })
|
||||
const b = normalizeSample({ ts: 1, memUsed: 5, gpuUtil: 0.4, costCents: 120 })
|
||||
expect(b).toMatchObject({ memUsed: 5, gpuUtil: 0.4, costCents: 120 })
|
||||
})
|
||||
|
||||
it('parses a 64-bit int serialized as a STRING (the warehouse does this)', () => {
|
||||
expect(normalizeSample({ ts: 1, cost_cents: '1234', mem_used: '8589934592' })).toMatchObject({
|
||||
costCents: 1234,
|
||||
memUsed: 8589934592,
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves an absent column undefined so the chart shows a gap, not a false 0', () => {
|
||||
const s = normalizeSample({ ts: 1 })
|
||||
expect(s.load1).toBeUndefined()
|
||||
expect(s.gpuUtil).toBeUndefined()
|
||||
})
|
||||
|
||||
it('drops a row with no timestamp and sorts oldest-first', () => {
|
||||
const rows = normalizeSamples({ samples: [{ ts: 30, load1: 3 }, { load1: 9 }, { ts: 10, load1: 1 }] })
|
||||
expect(rows.map((r) => r.ts)).toEqual([10, 30])
|
||||
})
|
||||
|
||||
it('unwraps {samples}/{rows}/bare array; garbage → []', () => {
|
||||
expect(normalizeSamples({ samples: [{ ts: 1 }] })).toHaveLength(1)
|
||||
expect(normalizeSamples({ rows: [{ ts: 1 }] })).toHaveLength(1)
|
||||
expect(normalizeSamples([{ ts: 1 }])).toHaveLength(1)
|
||||
expect(normalizeSamples(null)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('sampleSeconds — a chart that plots ms as s is wrong by 50,000 years', () => {
|
||||
it('passes seconds through and collapses milliseconds', () => {
|
||||
expect(sampleSeconds(1_700_000_000)).toBe(1_700_000_000)
|
||||
expect(sampleSeconds(1_700_000_000_000)).toBe(1_700_000_000)
|
||||
})
|
||||
it('parses an ISO string', () => {
|
||||
expect(sampleSeconds('2023-11-14T22:13:20.000Z')).toBe(1_700_000_000)
|
||||
})
|
||||
it('undefined for garbage/zero', () => {
|
||||
expect(sampleSeconds(0)).toBeUndefined()
|
||||
expect(sampleSeconds('nope')).toBeUndefined()
|
||||
expect(sampleSeconds(null)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('freshness — three states, because "never reported" is not "went quiet"', () => {
|
||||
const now = 1_700_000_000
|
||||
|
||||
it('fresh inside the window, stale past it', () => {
|
||||
expect(freshnessOf(now, now)).toBe('fresh')
|
||||
expect(freshnessOf(now - STALE_AFTER_S, now)).toBe('fresh')
|
||||
expect(freshnessOf(now - STALE_AFTER_S - 1, now)).toBe('stale')
|
||||
})
|
||||
|
||||
it('unknown (NOT stale) when the unit never reported', () => {
|
||||
expect(freshnessOf(undefined, now)).toBe('unknown')
|
||||
expect(freshnessOf(0, now)).toBe('unknown')
|
||||
})
|
||||
|
||||
it('a server clock ahead of the browser reads fresh, never a negative age', () => {
|
||||
expect(freshnessOf(now + 30, now)).toBe('fresh')
|
||||
expect(agoLabel(now + 30, now)).toBe('0s ago')
|
||||
})
|
||||
|
||||
it('agoLabel scales s → m → h → d, and dashes an absent heartbeat', () => {
|
||||
expect(agoLabel(now - 12, now)).toBe('12s ago')
|
||||
expect(agoLabel(now - 240, now)).toBe('4m ago')
|
||||
expect(agoLabel(now - 3 * 3600, now)).toBe('3h ago')
|
||||
expect(agoLabel(now - 2 * 86400, now)).toBe('2d ago')
|
||||
expect(agoLabel(undefined, now)).toBe('—')
|
||||
})
|
||||
})
|
||||
|
||||
describe('needsAttention — online but silent is the one signal worth surfacing', () => {
|
||||
const now = 1_700_000_000
|
||||
const silent = now - 600
|
||||
|
||||
it('flags an online unit that stopped reporting', () => {
|
||||
expect(needsAttention(unit({ status: 'online', metrics: { at: silent } }), now)).toBe(true)
|
||||
})
|
||||
|
||||
it('does NOT flag an offline unit — an expected absence is not a fault', () => {
|
||||
expect(needsAttention(unit({ status: 'offline', metrics: { at: silent } }), now)).toBe(false)
|
||||
})
|
||||
|
||||
it('does NOT flag a unit that never reported metrics (e.g. a cluster)', () => {
|
||||
expect(needsAttention(unit({ status: 'online', metrics: {} }), now)).toBe(false)
|
||||
})
|
||||
|
||||
it('does NOT flag a healthy online unit', () => {
|
||||
expect(needsAttention(unit({ status: 'online', metrics: { at: now - 5 } }), now)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('memRatio / memTotal', () => {
|
||||
it('derives the total from used+free and the ratio from it', () => {
|
||||
expect(memTotal({ memUsed: 3 * GB, memFree: GB })).toBe(4 * GB)
|
||||
expect(memRatio({ memUsed: 3 * GB, memFree: GB })).toBe(0.75)
|
||||
})
|
||||
it('undefined when the host reported neither half', () => {
|
||||
expect(memTotal({})).toBeUndefined()
|
||||
expect(memRatio({})).toBeUndefined()
|
||||
})
|
||||
it('undefined when used is unknown — never a 0% bar for a silent host', () => {
|
||||
expect(memRatio({ memFree: 4 * GB })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('summarize — partial sums are labelled, never passed off as the whole fleet', () => {
|
||||
const now = 1_700_000_000
|
||||
|
||||
it('counts total/online and the online-but-silent set', () => {
|
||||
const s = summarize(
|
||||
[
|
||||
unit({ unit: 'a', status: 'online', metrics: { at: now } }),
|
||||
unit({ unit: 'b', status: 'online', metrics: { at: now - 600 } }),
|
||||
unit({ unit: 'c', status: 'offline' }),
|
||||
unit({ unit: 'd', status: 'draining', metrics: { at: now } }),
|
||||
],
|
||||
now,
|
||||
)
|
||||
expect(s.total).toBe(4)
|
||||
expect(s.online).toBe(2)
|
||||
expect(s.stale).toBe(1)
|
||||
})
|
||||
|
||||
it('sums only what was reported and says how many units that was', () => {
|
||||
const s = summarize([unit({ spec: { cpus: 8, memory: 16 * GB, gpus: [] } }), unit({ unit: 'b', spec: { gpus: [] } })], now)
|
||||
expect(s.cpus).toBe(8)
|
||||
expect(s.cpusFrom).toBe(1) // 1 of 2 units reported — the tile can say so
|
||||
expect(s.memory).toBe(16 * GB)
|
||||
expect(s.memoryFrom).toBe(1)
|
||||
})
|
||||
|
||||
it('averages gpuUtil over REPORTING units only — a silent unit must not be averaged in as 0', () => {
|
||||
const s = summarize(
|
||||
[
|
||||
unit({ unit: 'a', metrics: { gpuUtil: 0.8 } }),
|
||||
unit({ unit: 'b', metrics: {} }), // silent: must NOT drag the mean to 0.4
|
||||
],
|
||||
now,
|
||||
)
|
||||
expect(s.gpuUtil).toBe(0.8)
|
||||
expect(s.gpuUtilFrom).toBe(1)
|
||||
})
|
||||
|
||||
it('undefined (⇒ —) for every total when nothing reported', () => {
|
||||
const s = summarize([unit(), unit({ unit: 'b' })], now)
|
||||
expect(s.cpus).toBeUndefined()
|
||||
expect(s.memory).toBeUndefined()
|
||||
expect(s.gpus).toBeUndefined()
|
||||
expect(s.gpuUtil).toBeUndefined()
|
||||
expect(s.total).toBe(2)
|
||||
})
|
||||
|
||||
it('counts GPUs across units', () => {
|
||||
const s = summarize(
|
||||
[
|
||||
unit({ unit: 'a', spec: { gpus: [{ model: 'H100' }, { model: 'H100' }] } }),
|
||||
unit({ unit: 'b', spec: { gpus: [{ model: 'GB10' }] } }),
|
||||
unit({ unit: 'c', spec: { gpus: [] } }),
|
||||
],
|
||||
now,
|
||||
)
|
||||
expect(s.gpus).toBe(3)
|
||||
expect(s.gpusFrom).toBe(2)
|
||||
})
|
||||
|
||||
it('an empty fleet is all-zero/undefined, never a crash', () => {
|
||||
const s = summarize([], now)
|
||||
expect(s).toMatchObject({ total: 0, online: 0, stale: 0, cpusFrom: 0, gpuUtil: undefined })
|
||||
})
|
||||
})
|
||||
|
||||
describe('unit identity is the (source, unit) PAIR — an id is unique only within a source', () => {
|
||||
it('keys on the pair', () => {
|
||||
expect(unitKey({ source: 'agent', unit: 'box' })).toBe('agent/box')
|
||||
expect(unitKey({ source: 'byo', unit: 'box' })).not.toBe(unitKey({ source: 'agent', unit: 'box' }))
|
||||
})
|
||||
|
||||
it('findUnit disambiguates two sources sharing an id', () => {
|
||||
const units = [unit({ unit: 'box', source: 'agent', label: 'A' }), unit({ unit: 'box', source: 'byo', label: 'B' })]
|
||||
expect(findUnit(units, 'agent', 'box')?.label).toBe('A')
|
||||
expect(findUnit(units, 'byo', 'box')?.label).toBe('B')
|
||||
expect(findUnit(units, 'cloud', 'box')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,431 @@
|
||||
/**
|
||||
* Fleet — the org's WHOLE compute surface on ONE board.
|
||||
*
|
||||
* Every unit the org owns or linked, from four sources, unioned server-side:
|
||||
* agent — a laptop/box a `hanzo code --link` session registered as a run-target
|
||||
* byo — a bring-your-own worker / on-prem node the org attached
|
||||
* cloud — an in-cloud box the platform runs for the org
|
||||
* visor — a visor-managed machine (the Machines product's inventory)
|
||||
*
|
||||
* Transport: the same-origin `/v1` user-bearer BFF (`cloudProxyV1Url`), exactly like
|
||||
* machines/gpus/agents. The reads authorize on the Bearer OWNER claim and 403 a
|
||||
* cookie-only call, so they must address the BFF — which mints a short-lived user
|
||||
* token and resolves the org SERVER-SIDE from that token. The `fleet` head is
|
||||
* allow-listed in proxy-allow.ts.
|
||||
*
|
||||
* TENANCY: the org is NEVER a parameter. This client sends no org in a query, a body
|
||||
* or a path — a caller cannot ask for another org's fleet, because the only thing
|
||||
* that selects a tenant is the token the BFF mints for the signed-in user.
|
||||
*
|
||||
* HONEST BY CONSTRUCTION — the two wires carry DIFFERENT zero semantics, so they get
|
||||
* different rules (see `pos` vs `finite`):
|
||||
* - `/v1/fleet` marshals every spec/metrics field `omitempty`, so a real 0 and a
|
||||
* never-reported field are the SAME bytes. A 0 therefore means UNKNOWN and the
|
||||
* view renders `—` (per contract). Printing "0 load" for a host that never
|
||||
* reported is a fabrication, and this is the file that refuses to make it.
|
||||
* - `/v1/fleet/samples` returns warehouse rows: a row EXISTS because a measurement
|
||||
* happened, so a 0 in it is a real measured value and is kept. An absent column
|
||||
* is a gap the chart skips — a gap is honest, a false 0 is a lie.
|
||||
* An upstream field rename degrades a cell to `—`; it never throws and never invents.
|
||||
*/
|
||||
import { cloudProxyV1Url, restGet } from './client'
|
||||
|
||||
// ── the wire vocabulary (closed sets the backend documents) ──────────────────
|
||||
|
||||
/** Where a unit came from. Rendered as the source badge. */
|
||||
export type FleetSource = 'agent' | 'byo' | 'cloud' | 'visor'
|
||||
export const FLEET_SOURCES: readonly FleetSource[] = ['agent', 'byo', 'cloud', 'visor']
|
||||
|
||||
/** What a unit IS. Drives the kind icon. */
|
||||
export type FleetKind = 'laptop' | 'cloud' | 'gpu' | 'cluster' | 'machine' | 'worker'
|
||||
export const FLEET_KINDS: readonly FleetKind[] = ['laptop', 'cloud', 'gpu', 'cluster', 'machine', 'worker']
|
||||
|
||||
/** A unit's declared lifecycle. Rendered as the status pill. */
|
||||
export type FleetStatus = 'online' | 'offline' | 'draining'
|
||||
export const FLEET_STATUSES: readonly FleetStatus[] = ['online', 'offline', 'draining']
|
||||
|
||||
/** The utilization-trend windows `/v1/fleet/samples` accepts. */
|
||||
export type FleetRange = '1h' | '6h' | '24h' | '7d'
|
||||
export const FLEET_RANGES: readonly FleetRange[] = ['1h', '6h', '24h', '7d']
|
||||
|
||||
// ── the view types ───────────────────────────────────────────────────────────
|
||||
|
||||
/** One accelerator on a unit. `memory` is VRAM BYTES; absent = unknown. */
|
||||
export type FleetGpu = { vendor?: string; model?: string; memory?: number }
|
||||
|
||||
/** What a unit IS — static capability. `memory` is total RAM BYTES. */
|
||||
export type FleetSpec = { os?: string; arch?: string; cpus?: number; memory?: number; gpus: FleetGpu[] }
|
||||
|
||||
/** What a unit is DOING — the last heartbeat. Bytes for memory, 0..1 for gpuUtil. */
|
||||
export type FleetMetrics = {
|
||||
load1?: number
|
||||
load5?: number
|
||||
load15?: number
|
||||
memUsed?: number
|
||||
memFree?: number
|
||||
/** Aggregate utilization, 0..1. */
|
||||
gpuUtil?: number
|
||||
/** Unix SECONDS the server stamped this heartbeat. The staleness clock. */
|
||||
at?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* One unit of the org's compute.
|
||||
*
|
||||
* `source`/`kind`/`status` are typed as plain strings, not the unions above: the
|
||||
* backend owns those vocabularies, and a word it adds later must render honestly
|
||||
* (its own label, a neutral tone) rather than be silently coerced into a lie. The
|
||||
* unions + `isOnline`/`sourceOf`/`kindOf` are how a caller reads them safely.
|
||||
*/
|
||||
export type FleetUnit = {
|
||||
/** The unit id. Unique WITHIN a source — `(source, unit)` is the identity. */
|
||||
unit: string
|
||||
source?: string
|
||||
kind?: string
|
||||
status?: string
|
||||
label?: string
|
||||
host?: string
|
||||
spec: FleetSpec
|
||||
metrics: FleetMetrics
|
||||
/** Sessions recorded against this unit. Absent ⇒ none ⇒ 0 (see `count`). */
|
||||
sessions: number
|
||||
/** Of those, currently running. */
|
||||
running: number
|
||||
}
|
||||
|
||||
/** One row of a unit's utilization trend. `ts` is unix SECONDS. */
|
||||
export type FleetSample = {
|
||||
ts?: number
|
||||
cpus?: number
|
||||
memory?: number
|
||||
memUsed?: number
|
||||
memFree?: number
|
||||
load1?: number
|
||||
load5?: number
|
||||
load15?: number
|
||||
/** 0..1. */
|
||||
gpuUtil?: number
|
||||
gpus?: number
|
||||
gpuModel?: string
|
||||
costCents?: number
|
||||
}
|
||||
|
||||
// ── defensive readers ────────────────────────────────────────────────────────
|
||||
|
||||
const rec = (v: unknown): Record<string, unknown> =>
|
||||
v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : {}
|
||||
|
||||
const str = (v: unknown): string | undefined => (typeof v === 'string' && v.trim() ? v.trim() : undefined)
|
||||
|
||||
/**
|
||||
* A finite number from a JSON value — including a numeric STRING, because the
|
||||
* warehouse serializes 64-bit ints as strings. `undefined` for anything else.
|
||||
*/
|
||||
const num = (v: unknown): number | undefined => {
|
||||
if (typeof v === 'number') return Number.isFinite(v) ? v : undefined
|
||||
if (typeof v === 'string' && v.trim() !== '') {
|
||||
const n = Number(v)
|
||||
return Number.isFinite(n) ? n : undefined
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* A REPORTED capacity/telemetry value on `/v1/fleet`: finite and > 0, else undefined.
|
||||
*
|
||||
* The `omitempty` wire cannot distinguish a real 0 from a never-reported field, so 0
|
||||
* must read as UNKNOWN — the view prints `—`. This is the rule that keeps an idle-
|
||||
* looking "0.00 load" off a host that has said nothing at all.
|
||||
*/
|
||||
const pos = (v: unknown): number | undefined => {
|
||||
const n = num(v)
|
||||
return n !== undefined && n > 0 ? n : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* A measured value on a `/v1/fleet/samples` row: any finite number, 0 INCLUDED.
|
||||
* The row exists because a measurement happened, so its 0 is real data.
|
||||
*/
|
||||
const finite = (v: unknown): number | undefined => num(v)
|
||||
|
||||
/**
|
||||
* A count WE keep (sessions/running). Absent ⇒ we hold no rows ⇒ 0 is the truth,
|
||||
* not a fabrication — unlike host telemetry, whose absence means the host was silent.
|
||||
*/
|
||||
const count = (v: unknown): number => {
|
||||
const n = num(v)
|
||||
return n !== undefined && n > 0 ? Math.floor(n) : 0
|
||||
}
|
||||
|
||||
/** Read the first present key (wire tolerance: snake_case AND camelCase). */
|
||||
const pick = (r: Record<string, unknown>, ...keys: string[]): unknown => {
|
||||
for (const k of keys) if (r[k] !== undefined && r[k] !== null) return r[k]
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Pull the first array under any common envelope key (else `[]` — never throws). */
|
||||
const arrayUnder = (payload: unknown, keys: string[]): unknown[] => {
|
||||
if (Array.isArray(payload)) return payload
|
||||
const o = rec(payload)
|
||||
for (const k of keys) if (Array.isArray(o[k])) return o[k] as unknown[]
|
||||
// One level of nesting — a `{status,data:{units}}` envelope.
|
||||
const d = rec(o.data)
|
||||
for (const k of keys) if (Array.isArray(d[k])) return d[k] as unknown[]
|
||||
return []
|
||||
}
|
||||
|
||||
/** A ratio clamped to the documented 0..1. Out-of-range is a bug — bound it, don't trust it. */
|
||||
const ratio = (v: unknown): number | undefined => {
|
||||
const n = pos(v)
|
||||
return n === undefined ? undefined : Math.min(1, n)
|
||||
}
|
||||
|
||||
// ── normalizers ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function normalizeGpu(raw: unknown): FleetGpu {
|
||||
const r = rec(raw)
|
||||
return { vendor: str(r.vendor), model: str(r.model), memory: pos(r.memory) }
|
||||
}
|
||||
|
||||
export function normalizeSpec(raw: unknown): FleetSpec {
|
||||
const r = rec(raw)
|
||||
const gpus = Array.isArray(r.gpus) ? r.gpus.map(normalizeGpu) : []
|
||||
return { os: str(r.os), arch: str(r.arch), cpus: pos(r.cpus), memory: pos(r.memory), gpus }
|
||||
}
|
||||
|
||||
export function normalizeMetrics(raw: unknown): FleetMetrics {
|
||||
const r = rec(raw)
|
||||
return {
|
||||
load1: pos(r.load1),
|
||||
load5: pos(r.load5),
|
||||
load15: pos(r.load15),
|
||||
memUsed: pos(pick(r, 'memUsed', 'mem_used')),
|
||||
memFree: pos(pick(r, 'memFree', 'mem_free')),
|
||||
gpuUtil: ratio(pick(r, 'gpuUtil', 'gpu_util')),
|
||||
at: pos(r.at),
|
||||
}
|
||||
}
|
||||
|
||||
/** One unit. Returns null for a row with no id — an unaddressable unit is dropped, not faked. */
|
||||
export function normalizeUnit(raw: unknown): FleetUnit | null {
|
||||
const r = rec(raw)
|
||||
const unit = str(pick(r, 'unit', 'id', 'unitId'))
|
||||
if (!unit) return null
|
||||
return {
|
||||
unit,
|
||||
source: str(r.source),
|
||||
kind: str(r.kind),
|
||||
status: str(r.status),
|
||||
label: str(r.label),
|
||||
host: str(r.host),
|
||||
spec: normalizeSpec(r.spec),
|
||||
metrics: normalizeMetrics(r.metrics),
|
||||
sessions: count(r.sessions),
|
||||
running: count(r.running),
|
||||
}
|
||||
}
|
||||
|
||||
/** `{units:[…]}` (the contract), or a bare array / `{items}` / `{data:{units}}`. */
|
||||
export function normalizeUnits(payload: unknown): FleetUnit[] {
|
||||
return arrayUnder(payload, ['units', 'items', 'rows'])
|
||||
.map(normalizeUnit)
|
||||
.filter((u): u is FleetUnit => u !== null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Unix SECONDS from a sample timestamp. Tolerates seconds, milliseconds and an ISO
|
||||
* string, because the column's unit is the backend's choice and a chart that silently
|
||||
* plots milliseconds-as-seconds is wrong by 50,000 years.
|
||||
*/
|
||||
export function sampleSeconds(v: unknown): number | undefined {
|
||||
const n = num(v)
|
||||
if (n !== undefined && n > 0) return n > 1e11 ? Math.floor(n / 1000) : Math.floor(n)
|
||||
const s = str(v)
|
||||
if (!s) return undefined
|
||||
const t = Date.parse(s)
|
||||
return Number.isFinite(t) ? Math.floor(t / 1000) : undefined
|
||||
}
|
||||
|
||||
export function normalizeSample(raw: unknown): FleetSample {
|
||||
const r = rec(raw)
|
||||
return {
|
||||
ts: sampleSeconds(pick(r, 'ts', 'timestamp', 'time')),
|
||||
cpus: finite(r.cpus),
|
||||
memory: finite(r.memory),
|
||||
memUsed: finite(pick(r, 'mem_used', 'memUsed')),
|
||||
memFree: finite(pick(r, 'mem_free', 'memFree')),
|
||||
load1: finite(r.load1),
|
||||
load5: finite(r.load5),
|
||||
load15: finite(r.load15),
|
||||
gpuUtil: finite(pick(r, 'gpu_util', 'gpuUtil')),
|
||||
gpus: finite(r.gpus),
|
||||
gpuModel: str(pick(r, 'gpu_model', 'gpuModel')),
|
||||
costCents: finite(pick(r, 'cost_cents', 'costCents')),
|
||||
}
|
||||
}
|
||||
|
||||
/** Samples, oldest-first (a trend reads left-to-right); rows with no `ts` are dropped. */
|
||||
export function normalizeSamples(payload: unknown): FleetSample[] {
|
||||
return arrayUnder(payload, ['samples', 'rows', 'items', 'series'])
|
||||
.map(normalizeSample)
|
||||
.filter((s) => s.ts !== undefined)
|
||||
.sort((a, b) => (a.ts ?? 0) - (b.ts ?? 0))
|
||||
}
|
||||
|
||||
// ── pure value logic (the board's decisions, testable without a browser) ──────
|
||||
|
||||
/** A heartbeat older than this is stale — the unit stopped reporting. */
|
||||
export const STALE_AFTER_S = 120
|
||||
|
||||
/** Is this heartbeat current, old, or was there never one? */
|
||||
export type Freshness = 'fresh' | 'stale' | 'unknown'
|
||||
|
||||
/**
|
||||
* Freshness of a heartbeat. THREE states, deliberately: a unit that never reported
|
||||
* is `unknown`, NOT `stale` — we have no evidence it went quiet, so claiming it did
|
||||
* would be as much an invention as claiming it is fine.
|
||||
*
|
||||
* A future `at` (the server's clock ahead of the browser's) is `fresh`, never a
|
||||
* negative age.
|
||||
*/
|
||||
export function freshnessOf(at: number | undefined, nowS: number): Freshness {
|
||||
if (!at) return 'unknown'
|
||||
return nowS - at > STALE_AFTER_S ? 'stale' : 'fresh'
|
||||
}
|
||||
|
||||
/** "12s ago" / "4m ago" / "3h ago" / "2d ago"; `—` when the unit never reported. */
|
||||
export function agoLabel(at: number | undefined, nowS: number, dash = '—'): string {
|
||||
if (!at) return dash
|
||||
const age = Math.max(0, nowS - at)
|
||||
if (age < 60) return `${Math.floor(age)}s ago`
|
||||
if (age < 3600) return `${Math.floor(age / 60)}m ago`
|
||||
if (age < 86400) return `${Math.floor(age / 3600)}h ago`
|
||||
return `${Math.floor(age / 86400)}d ago`
|
||||
}
|
||||
|
||||
/** True only for a unit the backend declares online. Anything else fails closed. */
|
||||
export const isOnline = (u: FleetUnit): boolean => u.status === 'online'
|
||||
|
||||
/**
|
||||
* The one signal worth an operator's attention: a unit that CLAIMS to be online but
|
||||
* has stopped reporting. Offline is an expected absence, and a unit that never
|
||||
* reported metrics (a cluster, say) is not a fault — neither is flagged.
|
||||
*/
|
||||
export const needsAttention = (u: FleetUnit, nowS: number): boolean =>
|
||||
isOnline(u) && freshnessOf(u.metrics.at, nowS) === 'stale'
|
||||
|
||||
/** Memory in use as a 0..1 ratio, or undefined when either half is unreported. */
|
||||
export function memRatio(m: FleetMetrics): number | undefined {
|
||||
const total = memTotal(m)
|
||||
if (total === undefined || m.memUsed === undefined) return undefined
|
||||
return Math.min(1, m.memUsed / total)
|
||||
}
|
||||
|
||||
/** Total RAM the heartbeat implies (used + free), or undefined if neither is known. */
|
||||
export function memTotal(m: FleetMetrics): number | undefined {
|
||||
if (m.memUsed === undefined && m.memFree === undefined) return undefined
|
||||
return (m.memUsed ?? 0) + (m.memFree ?? 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* The summary strip.
|
||||
*
|
||||
* Every total carries the number of units that actually REPORTED it (`*From`), so the
|
||||
* tile can say "across 4 of 7" rather than pass a partial sum off as the whole fleet.
|
||||
* `gpuUtil` is the mean over REPORTING units only — averaging a silent unit in as 0
|
||||
* would invent an idle machine and drag the fleet's number toward a comfortable lie.
|
||||
*/
|
||||
export type FleetSummary = {
|
||||
total: number
|
||||
online: number
|
||||
/** Online but no longer reporting — the "needs attention" count. */
|
||||
stale: number
|
||||
cpus?: number
|
||||
cpusFrom: number
|
||||
memory?: number
|
||||
memoryFrom: number
|
||||
gpus?: number
|
||||
gpusFrom: number
|
||||
gpuUtil?: number
|
||||
gpuUtilFrom: number
|
||||
}
|
||||
|
||||
export function summarize(units: FleetUnit[], nowS: number): FleetSummary {
|
||||
let cpus = 0
|
||||
let cpusFrom = 0
|
||||
let memory = 0
|
||||
let memoryFrom = 0
|
||||
let gpus = 0
|
||||
let gpusFrom = 0
|
||||
let utilSum = 0
|
||||
let gpuUtilFrom = 0
|
||||
let online = 0
|
||||
let stale = 0
|
||||
|
||||
for (const u of units) {
|
||||
if (isOnline(u)) online += 1
|
||||
if (needsAttention(u, nowS)) stale += 1
|
||||
if (u.spec.cpus !== undefined) {
|
||||
cpus += u.spec.cpus
|
||||
cpusFrom += 1
|
||||
}
|
||||
if (u.spec.memory !== undefined) {
|
||||
memory += u.spec.memory
|
||||
memoryFrom += 1
|
||||
}
|
||||
if (u.spec.gpus.length > 0) {
|
||||
gpus += u.spec.gpus.length
|
||||
gpusFrom += 1
|
||||
}
|
||||
if (u.metrics.gpuUtil !== undefined) {
|
||||
utilSum += u.metrics.gpuUtil
|
||||
gpuUtilFrom += 1
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
total: units.length,
|
||||
online,
|
||||
stale,
|
||||
cpus: cpusFrom > 0 ? cpus : undefined,
|
||||
cpusFrom,
|
||||
memory: memoryFrom > 0 ? memory : undefined,
|
||||
memoryFrom,
|
||||
gpus: gpusFrom > 0 ? gpus : undefined,
|
||||
gpusFrom,
|
||||
gpuUtil: gpuUtilFrom > 0 ? utilSum / gpuUtilFrom : undefined,
|
||||
gpuUtilFrom,
|
||||
}
|
||||
}
|
||||
|
||||
/** One unit's `(source, unit)` identity as a stable key. */
|
||||
export const unitKey = (u: Pick<FleetUnit, 'source' | 'unit'>): string => `${u.source ?? ''}/${u.unit}`
|
||||
|
||||
/** Find a unit by its `(source, unit)` pair — the identity a detail route carries. */
|
||||
export const findUnit = (units: FleetUnit[], source: string, unit: string): FleetUnit | undefined =>
|
||||
units.find((u) => u.unit === unit && (u.source ?? '') === source)
|
||||
|
||||
// ── transport ────────────────────────────────────────────────────────────────
|
||||
|
||||
const fleetUrl = (path: string, query?: Record<string, string | undefined>): string => {
|
||||
const base = cloudProxyV1Url(path)
|
||||
if (!query) return base
|
||||
const params = new URLSearchParams()
|
||||
for (const [k, v] of Object.entries(query)) if (v !== undefined && v !== '') params.set(k, v)
|
||||
const qs = params.toString()
|
||||
return qs ? `${base}?${qs}` : base
|
||||
}
|
||||
|
||||
/** The trend query. NO org — the BFF resolves the tenant from the bearer. */
|
||||
export type SamplesQuery = { unit: string; source?: string; range?: FleetRange }
|
||||
|
||||
export const FleetApi = {
|
||||
/** GET /v1/fleet → every unit the org owns or linked. */
|
||||
units: async (): Promise<FleetUnit[]> => normalizeUnits(await restGet<unknown>(fleetUrl('fleet'))),
|
||||
|
||||
/** GET /v1/fleet/samples?unit&source&range → one unit's utilization trend. */
|
||||
samples: async (q: SamplesQuery): Promise<FleetSample[]> =>
|
||||
normalizeSamples(
|
||||
await restGet<unknown>(fleetUrl('fleet/samples', { unit: q.unit, source: q.source, range: q.range ?? '24h' })),
|
||||
),
|
||||
}
|
||||
@@ -204,6 +204,7 @@ import { EdgeModule } from '~/components/products/EdgeModule'
|
||||
import { FunctionsModule } from '~/components/products/FunctionsModule'
|
||||
import { ContainersModule } from '~/components/products/ContainersModule'
|
||||
import { MachinesModule } from '~/components/products/MachinesModule'
|
||||
import { FleetModule } from '~/components/products/FleetModule'
|
||||
import { GpusModule, GpusOverview } from '~/components/products/GpusModule'
|
||||
import { FinetuningModule } from '~/components/products/FinetuningModule'
|
||||
import { KubeflowModule } from '~/components/products/KubeflowModule'
|
||||
@@ -1151,6 +1152,29 @@ export const catalog: CatalogEntry[] = [
|
||||
kind: 'module',
|
||||
routes: [{ path: '', component: MapModule }],
|
||||
},
|
||||
{
|
||||
// Fleet — the org's WHOLE compute surface on ONE board: agent/CLI run-targets,
|
||||
// BYO workers, in-cloud boxes and visor machines, each with its last heartbeat,
|
||||
// plus a per-unit utilization trend. The customer-facing union that GPUs and
|
||||
// Machines are the per-kind lenses of, so it sits directly above them. Reads the
|
||||
// per-org GET /v1/fleet + /v1/fleet/samples through the /v1 bearer BFF (org from
|
||||
// the token owner) — NOT admin-gated: this is the customer's own compute.
|
||||
id: 'fleet',
|
||||
label: 'Fleet',
|
||||
icon: Boxes,
|
||||
description: 'Every machine you own or link — BYO, in-cloud, and agent run-targets — with live health.',
|
||||
category: 'Compute',
|
||||
status: 'enabled',
|
||||
repo: 'hanzoai/cloud',
|
||||
kind: 'module',
|
||||
// '' is the board; ':source/:unit' is one unit — a unit id is unique only WITHIN
|
||||
// a source, so the identity is the pair. Two segments, so it can never collide
|
||||
// with the 1-segment shared base slugs (status/logs/metrics/settings).
|
||||
routes: [
|
||||
{ path: '', component: FleetModule },
|
||||
{ path: ':source/:unit', component: FleetModule },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'gpus',
|
||||
label: 'GPUs',
|
||||
|
||||
@@ -201,6 +201,13 @@ export const CLOUD_HEADS: readonly string[] = [
|
||||
'machines',
|
||||
'gpus',
|
||||
'clusters',
|
||||
// Fleet (cloud clients/fleet + the visor/agents union): /v1/fleet[/samples|/workers].
|
||||
// The org's WHOLE compute surface on one board — agent run-targets, BYO workers,
|
||||
// in-cloud boxes and visor machines, each with its last heartbeat, plus the
|
||||
// per-unit utilization trend. The handler resolves the org from the Bearer owner
|
||||
// (X-Org-Id) and 403s a cookie-only call, so it routes through /v1 exactly like
|
||||
// machines/gpus/clusters — the single `fleet` head admits every sub-path.
|
||||
'fleet',
|
||||
// DO-native: virtual private clouds and managed load balancers — FULL CRUD
|
||||
// (/v1/vpcs[/:id], /v1/load-balancers[/:id]).
|
||||
'vpcs',
|
||||
|
||||
Reference in New Issue
Block a user