Files
orchestra/web/src/screens/Dashboard.tsx
T
kami 34f3c2888f Rebuild the operator console on the ethos shell
Nine screens against orchestra-ui-spec.md and the accepted mockups, on
the ethos design system: signal violet #8F7AE5, the routing fork motif,
the 64px rail and 56px top bar, mono for every machine value and sans
for every human one.

Each screen was built by its own agent against a fixed foundation, so
the shell, tokens and primitives have one author and the screens cannot
drift into nine dialects.

Real data only. Where no endpoint exists the screen says which one it
needs instead of inventing a value. That is most of what was learned
here: Steer / Correct is disabled because nothing records a human
decision from the web, take-control is disabled because nothing
forwards keystrokes to a pane, context occupancy is missing from three
screens, and projects can show no repo, remote, quality gate or
verification policy because those live only in config.jsonc.

Three bugs the render caught that no computed value would have:

The previous stylesheet fought every shared class name and leaked
properties the new rules never mention, which is how position:fixed
survived on .topbar. It is now scoped under .legacy and applies only to
the login route, which also stops its green accent and its
backdrop-filter from reaching the console.

Go marshals a zero time.Time as "0001-01-01T00:00:00Z" and omitempty
does not omit a struct, so absent timestamps arrived populated-looking
and rendered as "739855d ago". Stripped once at the API boundary every
screen reads through, with a test.

Long machine ids overflowed their cards and painted under the next one.

Verified by rendering: chromium screenshots of the dashboard, tasks,
task detail, terminal, workers and review at 1440px, and the dashboard
at 390px. Geist is still not on disk, so both stacks fall back to the
system faces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
2026-08-29 02:44:43 +04:00

362 lines
14 KiB
TypeScript

import { Link } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { api } from '../api/client'
import type { BlockReason, Overview, Session, Task, Worker } from '../api/types'
import { Chip, Dot, Empty, EndpointGap, M, Panel, PhasePath } from '../components/Primitives'
import { Icon } from '../components/Icon'
import './Dashboard.css'
/* The ace-fca phase lives on the task as work_phase; the shared Task type has
not caught up with the Go domain yet, so it is read through a narrow cast
rather than invented or renamed. */
const phaseOf = (t: Task) => (t as Task & { work_phase?: string }).work_phase
const now = () => Date.now()
/** "18s ago" / "2m ago" / "1h ago" — a machine value, so it renders mono. */
function ago(at?: string) {
// Go marshals a zero time.Time as "0001-01-01T00:00:00Z" and omitempty does
// not omit a struct, so an absent timestamp arrives populated-looking. Left
// unguarded this rendered "739855d ago", which reads as data.
if (!at || at.startsWith('0001-01-01')) return undefined
const s = Math.max(0, Math.round((now() - Date.parse(at)) / 1000))
if (!Number.isFinite(s)) return undefined
if (s < 60) return `${s}s ago`
if (s < 3600) return `${Math.floor(s / 60)}m ago`
if (s < 86400) return `${Math.floor(s / 3600)}h ago`
return `${Math.floor(s / 86400)}d ago`
}
/** Time remaining on a lease as mm:ss, or "expired" when it already ran out. */
function until(at?: string) {
if (!at) return undefined
const s = Math.round((Date.parse(at) - now()) / 1000)
if (!Number.isFinite(s)) return undefined
if (s <= 0) return 'expired'
const m = Math.floor(s / 60)
return `${String(m).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`
}
const shortId = (id: string) => (id.length > 14 ? `${id.slice(0, 6)}${id.slice(-4)}` : id)
/* Why an item is on the operator's desk. Every line is derived from a real
block_reason or state — none of it is decorative copy. */
const attentionReason: Record<BlockReason, string> = {
trajectory_gate: 'A trajectory gate is waiting for your steering.',
human_decision: 'The agent asked for a decision it may not make itself.',
operator_required: 'Orchestra cannot proceed without an operator action.',
approval: 'A tool or edit approval is pending.',
handoff_validation: 'The handoff failed validation and was not accepted.',
lease_failure: 'The lease could not be established.',
lease_expired: 'The lease expired before the agent finished.',
worker_offline: 'The worker holding this task went offline.',
system_error: 'Orchestra hit an error it could not retry past.',
operator_block: 'You blocked this task.',
unknown: 'Blocked for a reason orchestra could not classify.',
}
const stateReason: Partial<Record<Task['state'], string>> = {
in_review: 'A pull request is waiting on review.',
failed: 'The task failed and will not retry on its own.',
needs_attention: 'Orchestra flagged this task for you.',
blocked: 'The task is blocked.',
}
function reasonFor(t: Task) {
if (t.block_reason && attentionReason[t.block_reason]) return attentionReason[t.block_reason]
return stateReason[t.state] ?? 'Waiting on you.'
}
const needsOperator = (t: Task) =>
t.state === 'needs_attention' || t.state === 'blocked' || t.state === 'failed' || t.state === 'in_review'
/** Sessions carry no task id of their own unless a capture is attached, so
* they are matched on the pane the task last held. */
function sessionFor(t: Task, sessions: Session[]) {
return sessions.find(
(s) =>
s.capture?.task_id === t.id ||
(t.last_pane_id !== undefined && s.pane_id === t.last_pane_id) ||
(t.lease !== undefined && s.harness_id === t.lease.harness_id),
)
}
function attentionIcon(t: Task) {
if (t.state === 'in_review') return 'review'
if (t.block_reason === 'trajectory_gate') return 'fork'
if (t.block_reason === 'human_decision' || t.block_reason === 'approval') return 'decision'
return 'alert'
}
function Attention({ tasks }: { tasks: Task[] }) {
return (
<Panel
title="Needs your attention"
count={tasks.length}
trailing={<Link to="/tasks">View all</Link>}
>
{tasks.length === 0 ? (
<Empty title="Nothing is waiting on you">
<p>Blocked tasks, trajectory gates and open reviews land here.</p>
</Empty>
) : (
<div className="rows">
{tasks.map((t) => (
<Link className="row" key={t.id} to={`/tasks/${t.id}`}>
<span className="att-mark" data-kind={t.state === 'in_review' ? 'review' : 'fault'}>
<Icon name={attentionIcon(t)} size={18} />
</span>
<span className="row-main">
<span className="row-title">{t.title || 'Untitled task'}</span>
<span className="row-sub">{reasonFor(t)}</span>
</span>
<span className="att-task">
<span className="row-title">{t.title ? t.project : t.id}</span>
<span className="row-sub">
<M>{shortId(t.id)}</M>
{phaseOf(t) && (
<>
{' · '}
<M>{phaseOf(t)}</M>
</>
)}
</span>
</span>
{t.blocked_at && (
<span className="mono att-when">{ago(t.blocked_at)}</span>
)}
<Chip tone={t.state === 'in_review' ? 'accent' : 'warn'}>
{t.block_reason ?? t.state}
</Chip>
<Icon name="chevron-right" size={16} />
</Link>
))}
</div>
)}
</Panel>
)
}
function Running({ tasks, sessions }: { tasks: Task[]; sessions: Session[] }) {
return (
<Panel
title="Running tasks"
count={tasks.length}
trailing={<Link to="/tasks">View all tasks</Link>}
>
{tasks.length === 0 ? (
<Empty title="No task holds a lease">
<p>Leased sessions appear here with their phase, worker and lease clock.</p>
</Empty>
) : (
<div className="table-scroll">
<table className="table">
<thead>
<tr>
<th>Task</th>
<th>Phase</th>
<th>Worker / harness</th>
<th>Last progress</th>
<th>Lease ends in</th>
<th>Attempt</th>
<th />
</tr>
</thead>
<tbody>
{tasks.map((t) => {
const s = sessionFor(t, sessions)
const left = until(t.lease?.until)
return (
<tr key={t.id}>
<td>
<Link className="cell-link" to={`/tasks/${t.id}`}>
<span className="row-title">{t.title || 'Untitled task'}</span>
<span className="row-sub">
<M>{shortId(t.id)}</M>
</span>
</Link>
</td>
<td>
<div className="phase-cell">
<Chip tone="accent">{phaseOf(t) ?? 'unknown'}</Chip>
<PhasePath current={phaseOf(t)} />
</div>
</td>
<td>
<span className="row-title mono">{t.lease?.harness_id ?? '—'}</span>
<span className="row-sub">
<M>{s?.pane_id ?? t.last_pane_id ?? 'pane unknown'}</M>
</span>
</td>
<td>
{s?.capture?.at ? (
<>
<span className="mono">{ago(s.capture.at)}</span>
<span className="row-sub">
capture <M>r{s.capture.revision}</M>
</span>
</>
) : (
<span className="row-sub">no capture yet</span>
)}
</td>
<td>
<span className="mono" data-expired={left === 'expired' ? '' : undefined}>
{left ?? '—'}
</span>
{s?.agent_status && <span className="row-sub">{s.agent_status}</span>}
</td>
<td>
<M>{t.attempt ?? 1}</M>
</td>
<td>
<Link className="cell-link" to={`/tasks/${t.id}`} aria-label="Open task">
<Icon name="chevron-right" size={16} />
</Link>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
<EndpointGap
path="GET /v1/ui/overview → session.context"
what="Context occupancy per session is measured by the herdr adapter but is not carried on the overview projection, so this table shows lease time instead of a context meter."
/>
</Panel>
)
}
function Capacity({ workers, tasks }: { workers: Worker[]; tasks: Task[] }) {
const online = workers.filter((w) => w.online).length
const reachable = workers.filter((w) => w.health.herdr_status === 'reachable').length
const unreachable = workers.filter((w) => w.health.herdr_status === 'unreachable').length
const leased = tasks.filter((t) => t.lease)
const expired = leased.filter((t) => until(t.lease?.until) === 'expired').length
const erroring = workers.filter((w) => w.health.last_error)
return (
<Panel title="System state and capacity" trailing={<Link to="/workers">View workers</Link>}>
<div className="panel-body">
<div className="grid stats">
<div className="stat">
<span className="label">Workers online</span>
<span className="stat-value">
{online} / {workers.length}
</span>
<span className="row-sub">
<M>{workers.reduce((n, w) => n + w.capacity, 0)}</M> total capacity
</span>
</div>
<div className="stat">
<span className="label">Active leases</span>
<span className="stat-value">{leased.length}</span>
<span className="row-sub">
<M>{expired}</M> past their lease end
</span>
</div>
<div className="stat">
<span className="label">Herdr backends</span>
<span className="stat-value">
{reachable} / {workers.length}
</span>
<span className="row-sub">
<M>{unreachable}</M> unreachable, from worker health
</span>
</div>
</div>
<div className="worker-lines">
{workers.length === 0 ? (
<p className="row-sub">No worker has ever registered.</p>
) : (
workers.map((w) => (
<Link className="worker-line" key={w.id} to="/workers">
<Dot
health={
!w.online
? 'error'
: w.health.herdr_status === 'reachable'
? 'healthy'
: w.health.herdr_status === 'unreachable'
? 'degraded'
: 'unknown'
}
/>
<span className="mono">{w.id}</span>
<span className="row-sub">{w.health.backend ?? 'backend unknown'}</span>
<span className="mono worker-seen">seen {ago(w.last_seen) ?? '—'}</span>
{w.health.active_task_id && (
<span className="mono">{shortId(w.health.active_task_id)}</span>
)}
{w.build?.revision && <span className="mono">{w.build.revision.slice(0, 7)}</span>}
</Link>
))
)}
</div>
{erroring.length > 0 && (
<div className="worker-errors">
{erroring.map((w) => (
<p key={w.id}>
<span className="mono">{w.id}</span>{' '}
<span className="row-sub">{w.health.last_error}</span>{' '}
<span className="mono">{ago(w.health.error_at) ?? ''}</span>
</p>
))}
</div>
)}
</div>
<EndpointGap
path="GET /v1/quota, GET /v1/router, GET /v1/sources"
what="Quota headroom, router queue depth and rejections, and source reconciliation state have no endpoint. Nothing here stands in for them."
/>
</Panel>
)
}
export function Dashboard() {
const overview = useQuery<Overview>({
queryKey: ['overview'],
queryFn: api.overview,
refetchInterval: 5000,
})
const workers = useQuery<Worker[]>({
queryKey: ['workers'],
queryFn: api.workers,
refetchInterval: 5000,
})
const tasks = overview.data?.tasks ?? []
const sessions = overview.data?.sessions ?? []
// Workers come from the federation registry; the overview snapshot is the
// fallback when that call has not landed or is failing.
const workerList = workers.data ?? overview.data?.workers ?? []
return (
<main className="page">
<div className="page-head">
<h1>Dashboard</h1>
<p>What needs you, what is running, and whether the system is healthy.</p>
<span className="head-stamp">
{overview.data ? (
<>
read <M>{new Date(overview.data.updated_at).toISOString().slice(11, 19)} UTC</M>
</>
) : overview.isError ? (
<span className="stamp-fault">overview unreachable {String(overview.error)}</span>
) : (
'reading the first snapshot'
)}
</span>
</div>
<Attention tasks={tasks.filter(needsOperator)} />
<Running tasks={tasks.filter((t) => t.state === 'leased')} sessions={sessions} />
<Capacity workers={workerList} tasks={tasks} />
</main>
)
}