feat(tracker-web): consolidated fleet overview — breaker panel, budget guardrail, dead-letter triage
Surfaces the §2/§3 signal on the fleet overview page (read-only over existing endpoints): - Budget guardrail card: spend vs ceiling bar, projected end-of-window burn (highlighted when over ceiling), and per-engine sub-ceiling breakdown — from the new /fleet/metrics budget summary. - Engine circuit-breaker panel: lists only tripped/probing (factory, engine) pairs from metrics.engineBreakers. - Dead-letter triage table with a Re-drive button wired to the redrive operator action; filtered client-side so it is correct regardless of server filtering. All panels render only when their data is present, so the page is unchanged for fleets without budgets/breakers/dead-letters. Adds a happy-dom page test. Generated with [Devin](https://cli.devin.ai/docs) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
bcd806c6ff
commit
705d8e8eaa
178
dashboards/tracker-web/src/__tests__/fleet-overview.test.tsx
Normal file
178
dashboards/tracker-web/src/__tests__/fleet-overview.test.tsx
Normal file
@ -0,0 +1,178 @@
|
||||
// @vitest-environment happy-dom
|
||||
/**
|
||||
* Fleet overview page (§1) — the consolidated operations surface.
|
||||
*
|
||||
* Verifies the panels that surface the §2/§3 signal: the engine circuit-breaker
|
||||
* panel, the budget guardrail, and the dead-letter triage list with its Re-drive
|
||||
* button (which calls the operator action). Uses the jsdom render harness +
|
||||
* mocked fleet-client/auth-context (no network).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, beforeEach, vi } from 'vitest';
|
||||
import { act, createElement } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
const { operatorAction, listFactories, getFleetMetrics, listJobs } = vi.hoisted(() => ({
|
||||
operatorAction: vi.fn(),
|
||||
listFactories: vi.fn(),
|
||||
getFleetMetrics: vi.fn(),
|
||||
listJobs: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/auth-context', () => ({ useAuth: () => ({ token: 'tok' }) }));
|
||||
vi.mock('@/lib/fleet-client', () => ({
|
||||
listFactories,
|
||||
getFleetMetrics,
|
||||
listJobs,
|
||||
operatorAction,
|
||||
}));
|
||||
|
||||
import FleetOverviewPage from '@/app/dashboard/fleet/page';
|
||||
|
||||
beforeAll(() => {
|
||||
(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
const metricsBase = {
|
||||
productId: 'p',
|
||||
generatedAt: '2026-06-01T00:00:00.000Z',
|
||||
jobs: { total: 1, byStage: {}, queueDepth: 0, blocked: 0, active: 0, oldestQueuedAgeMs: null },
|
||||
factories: {
|
||||
total: 0,
|
||||
live: 0,
|
||||
stale: 0,
|
||||
byHealth: { ok: 0, degraded: 0, down: 0 },
|
||||
seatsUsed: 0,
|
||||
seatsTotal: 0,
|
||||
utilizationPct: 0,
|
||||
},
|
||||
alerts: [],
|
||||
};
|
||||
|
||||
const deadLetterJob = {
|
||||
id: 'j-dead',
|
||||
productId: 'p',
|
||||
stage: 'dead_letter',
|
||||
idempotencyKey: 'broken-task',
|
||||
bodyMd: '',
|
||||
priority: 'medium',
|
||||
priorityOrder: 2,
|
||||
capabilities: [],
|
||||
kind: 'leaf',
|
||||
attempts: 3,
|
||||
leaseEpoch: 3,
|
||||
engine: 'codex',
|
||||
createdAt: '2026-06-01T00:00:00.000Z',
|
||||
updatedAt: '2026-06-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
async function renderPage(): Promise<{ root: Root; container: HTMLDivElement }> {
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
let root!: Root;
|
||||
await act(async () => {
|
||||
root = createRoot(container);
|
||||
root.render(createElement(FleetOverviewPage));
|
||||
});
|
||||
// Flush the async refresh() (Promise.all of the mocked client calls).
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
return { root, container };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
listFactories.mockResolvedValue({ factories: [] });
|
||||
operatorAction.mockResolvedValue({});
|
||||
// Default: recent jobs empty, dead-letter query returns the broken job.
|
||||
listJobs.mockImplementation(async (params?: { stage?: string }) =>
|
||||
params?.stage === 'dead_letter' ? { jobs: [deadLetterJob] } : { jobs: [] }
|
||||
);
|
||||
});
|
||||
|
||||
describe('fleet overview §1 — consolidated panels', () => {
|
||||
it('renders the budget guardrail with projection and per-engine breakdown', async () => {
|
||||
getFleetMetrics.mockResolvedValue({
|
||||
...metricsBase,
|
||||
budget: {
|
||||
ceilingUsd: 100,
|
||||
spentUsd: 40,
|
||||
status: 'active',
|
||||
window: 'monthly',
|
||||
projectedUsd: 400,
|
||||
engines: [{ engine: 'codex', spentUsd: 30, ceilingUsd: 30, exhausted: true }],
|
||||
},
|
||||
});
|
||||
const { root, container } = await renderPage();
|
||||
const budget = container.querySelector('[data-testid="fleet-budget"]');
|
||||
expect(budget).not.toBeNull();
|
||||
expect(budget!.textContent).toContain('$40.00 / $100.00');
|
||||
expect(budget!.textContent).toContain('Projected monthly: $400.00');
|
||||
expect(budget!.textContent).toContain('codex');
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('renders only tripped circuit breakers', async () => {
|
||||
getFleetMetrics.mockResolvedValue({
|
||||
...metricsBase,
|
||||
engineBreakers: [
|
||||
{
|
||||
factoryId: 'fac_1',
|
||||
engine: 'codex',
|
||||
state: 'OPEN',
|
||||
failureCount: 3,
|
||||
lastFailureAt: null,
|
||||
},
|
||||
{
|
||||
factoryId: 'fac_1',
|
||||
engine: 'devin',
|
||||
state: 'CLOSED',
|
||||
failureCount: 0,
|
||||
lastFailureAt: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
const { root, container } = await renderPage();
|
||||
const panel = container.querySelector('[data-testid="fleet-breakers"]');
|
||||
expect(panel).not.toBeNull();
|
||||
expect(panel!.textContent).toContain('fac_1 · codex');
|
||||
expect(panel!.textContent).toContain('OPEN');
|
||||
expect(panel!.textContent).not.toContain('devin'); // CLOSED pairs are hidden
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('lists dead-letter jobs and re-drives one via the operator action', async () => {
|
||||
getFleetMetrics.mockResolvedValue({ ...metricsBase });
|
||||
const { root, container } = await renderPage();
|
||||
const triage = container.querySelector('[data-testid="fleet-dead-letter"]');
|
||||
expect(triage).not.toBeNull();
|
||||
expect(triage!.textContent).toContain('broken-task');
|
||||
|
||||
const btn = container.querySelector(
|
||||
'[aria-label="Re-drive job broken-task"]'
|
||||
) as HTMLButtonElement;
|
||||
expect(btn).not.toBeNull();
|
||||
await act(async () => {
|
||||
btn.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(operatorAction).toHaveBeenCalledWith('j-dead', 'redrive');
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('hides the budget and breaker panels when the metrics omit them', async () => {
|
||||
getFleetMetrics.mockResolvedValue({ ...metricsBase });
|
||||
listJobs.mockResolvedValue({ jobs: [] }); // no dead letters either
|
||||
const { root, container } = await renderPage();
|
||||
expect(container.querySelector('[data-testid="fleet-budget"]')).toBeNull();
|
||||
expect(container.querySelector('[data-testid="fleet-breakers"]')).toBeNull();
|
||||
expect(container.querySelector('[data-testid="fleet-dead-letter"]')).toBeNull();
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
});
|
||||
@ -8,6 +8,7 @@ import {
|
||||
listFactories,
|
||||
listJobs,
|
||||
getFleetMetrics,
|
||||
operatorAction,
|
||||
type FleetFactory,
|
||||
type FleetJob,
|
||||
type FleetMetrics,
|
||||
@ -56,23 +57,114 @@ function StageBadge({ stage }: { stage: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
const usd = (n: number) => `$${n.toFixed(2)}`;
|
||||
|
||||
/** Budget guardrail: spend vs ceiling + projected end-of-window burn (§3). */
|
||||
function BudgetGuardrail({ budget }: { budget: NonNullable<FleetMetrics['budget']> }) {
|
||||
const pct =
|
||||
budget.ceilingUsd > 0 ? Math.min(100, (budget.spentUsd / budget.ceilingUsd) * 100) : 0;
|
||||
const overProjected = budget.projectedUsd !== null && budget.projectedUsd > budget.ceilingUsd;
|
||||
const exhausted = budget.spentUsd >= budget.ceilingUsd;
|
||||
return (
|
||||
<section aria-label="Budget guardrail" data-testid="fleet-budget">
|
||||
<h2 className="text-lg font-semibold mb-3">Budget</h2>
|
||||
<div className="rounded-lg border p-4 space-y-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span>
|
||||
{usd(budget.spentUsd)} / {usd(budget.ceilingUsd)}{' '}
|
||||
<span className="text-muted-foreground">({budget.window})</span>
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground capitalize">{budget.status}</span>
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={`h-full ${exhausted ? 'bg-red-500' : overProjected ? 'bg-yellow-500' : 'bg-green-500'}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
{budget.projectedUsd !== null && (
|
||||
<p
|
||||
className={`text-xs ${overProjected ? 'text-yellow-700 dark:text-yellow-400' : 'text-muted-foreground'}`}
|
||||
>
|
||||
Projected {budget.window}: {usd(budget.projectedUsd)}
|
||||
{overProjected && ' — over ceiling at current burn'}
|
||||
</p>
|
||||
)}
|
||||
{budget.engines.length > 0 && (
|
||||
<ul className="text-xs text-muted-foreground space-y-1">
|
||||
{budget.engines.map(e => (
|
||||
<li key={e.engine} className="flex justify-between">
|
||||
<span className="font-mono">{e.engine}</span>
|
||||
<span className={e.exhausted ? 'text-red-600 dark:text-red-400' : ''}>
|
||||
{usd(e.spentUsd)} / {usd(e.ceilingUsd)}
|
||||
{e.exhausted && ' (capped)'}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Circuit-breaker panel: (factory, engine) pairs currently being routed around (§2). */
|
||||
function BreakerPanel({ breakers }: { breakers: NonNullable<FleetMetrics['engineBreakers']> }) {
|
||||
const tripped = breakers.filter(b => b.state !== 'CLOSED');
|
||||
if (tripped.length === 0) return null;
|
||||
return (
|
||||
<section aria-label="Engine circuit breakers" data-testid="fleet-breakers">
|
||||
<h2 className="text-lg font-semibold mb-3">Engine circuit breakers</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{tripped.map(b => (
|
||||
<div
|
||||
key={`${b.factoryId}:${b.engine}`}
|
||||
className="rounded-lg border border-red-500/30 bg-red-500/5 p-3 text-sm"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono truncate">
|
||||
{b.factoryId} · {b.engine}
|
||||
</span>
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-xs font-medium ${b.state === 'OPEN' ? 'bg-red-500/20 text-red-700 dark:text-red-400' : 'bg-yellow-500/20 text-yellow-700 dark:text-yellow-400'}`}
|
||||
>
|
||||
{b.state}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{b.failureCount} failure(s)
|
||||
{b.lastFailureAt && ` · last ${new Date(b.lastFailureAt).toLocaleTimeString()}`}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FleetOverviewPage() {
|
||||
const { token } = useAuth();
|
||||
const [factories, setFactories] = useState<FleetFactory[]>([]);
|
||||
const [jobs, setJobs] = useState<FleetJob[]>([]);
|
||||
const [metrics, setMetrics] = useState<FleetMetrics | null>(null);
|
||||
const [deadLetters, setDeadLetters] = useState<FleetJob[]>([]);
|
||||
const [redriving, setRedriving] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const [facRes, jobRes, metricsRes] = await Promise.all([
|
||||
const [facRes, jobRes, metricsRes, dlRes] = await Promise.all([
|
||||
listFactories(),
|
||||
listJobs({ limit: 10 }),
|
||||
getFleetMetrics().catch(() => null),
|
||||
listJobs({ stage: 'dead_letter', limit: 50 }).catch(() => ({ jobs: [] as FleetJob[] })),
|
||||
]);
|
||||
setFactories(facRes.factories);
|
||||
setJobs(jobRes.jobs);
|
||||
setMetrics(metricsRes);
|
||||
// Filter client-side too, so the triage list is correct even if the server
|
||||
// ignores the stage filter.
|
||||
setDeadLetters(dlRes.jobs.filter(j => j.stage === 'dead_letter'));
|
||||
} catch {
|
||||
/* degrade gracefully */
|
||||
} finally {
|
||||
@ -80,6 +172,21 @@ export default function FleetOverviewPage() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRedrive = useCallback(
|
||||
async (id: string) => {
|
||||
setRedriving(id);
|
||||
try {
|
||||
await operatorAction(id, 'redrive');
|
||||
await refresh();
|
||||
} catch {
|
||||
/* surfaced on next poll */
|
||||
} finally {
|
||||
setRedriving(null);
|
||||
}
|
||||
},
|
||||
[refresh]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
refresh();
|
||||
@ -134,6 +241,67 @@ export default function FleetOverviewPage() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Budget guardrail */}
|
||||
{metrics?.budget && <BudgetGuardrail budget={metrics.budget} />}
|
||||
|
||||
{/* Engine circuit breakers */}
|
||||
{metrics?.engineBreakers && metrics.engineBreakers.length > 0 && (
|
||||
<BreakerPanel breakers={metrics.engineBreakers} />
|
||||
)}
|
||||
|
||||
{/* Dead-letter triage */}
|
||||
{deadLetters.length > 0 && (
|
||||
<section aria-label="Dead-letter triage" data-testid="fleet-dead-letter">
|
||||
<h2 className="text-lg font-semibold mb-3">
|
||||
Dead-letter triage{' '}
|
||||
<span className="text-sm font-normal text-muted-foreground">
|
||||
({deadLetters.length})
|
||||
</span>
|
||||
</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm" aria-label="Dead-letter jobs">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="pb-2 pr-4">Key</th>
|
||||
<th className="pb-2 pr-4">Engine</th>
|
||||
<th className="pb-2 pr-4">Attempts</th>
|
||||
<th className="pb-2">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{deadLetters.map(j => (
|
||||
<tr key={j.id} className="border-b last:border-0 hover:bg-muted/50">
|
||||
<td className="py-2 pr-4">
|
||||
<Link
|
||||
href={`/dashboard/fleet/jobs/${j.id}`}
|
||||
className="hover:underline font-mono text-xs"
|
||||
>
|
||||
{j.idempotencyKey}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-2 pr-4 font-mono text-xs">
|
||||
{j.engine ?? j.engineClass ?? '—'}
|
||||
</td>
|
||||
<td className="py-2 pr-4 tabular-nums">{j.attempts}</td>
|
||||
<td className="py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRedrive(j.id)}
|
||||
disabled={redriving === j.id}
|
||||
aria-label={`Re-drive job ${j.idempotencyKey}`}
|
||||
className="rounded-md border px-2 py-1 text-xs font-medium hover:bg-muted disabled:opacity-50"
|
||||
>
|
||||
{redriving === j.id ? 'Re-driving…' : 'Re-drive'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Factory cards */}
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold mb-3">Factories</h2>
|
||||
|
||||
@ -31,6 +31,8 @@ export interface FleetJob {
|
||||
reviewDecisions?: ReviewDecision[];
|
||||
/** Concrete engine to run (overrides engineClass); falls back to factory default. */
|
||||
engine?: FleetEngine;
|
||||
/** Abstract engine class resolved on the runner when no concrete `engine` is set. */
|
||||
engineClass?: string;
|
||||
repo?: string;
|
||||
baseBranch?: string;
|
||||
/** PR mode: verify command run in the checkout before the PR opens. */
|
||||
@ -346,6 +348,25 @@ export interface FleetAlert {
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** One tripped/probing (factory, engine) circuit-breaker pair (§2). */
|
||||
export interface EngineBreakerEntry {
|
||||
factoryId: string;
|
||||
engine: string;
|
||||
state: 'CLOSED' | 'OPEN' | 'HALF_OPEN';
|
||||
failureCount: number;
|
||||
lastFailureAt: string | null;
|
||||
}
|
||||
|
||||
/** Budget guardrail summary surfaced on metrics (§3). */
|
||||
export interface FleetBudgetSummary {
|
||||
ceilingUsd: number;
|
||||
spentUsd: number;
|
||||
status: string;
|
||||
window: string;
|
||||
projectedUsd: number | null;
|
||||
engines: { engine: string; spentUsd: number; ceilingUsd: number; exhausted: boolean }[];
|
||||
}
|
||||
|
||||
export interface FleetMetrics {
|
||||
productId: string;
|
||||
generatedAt: string;
|
||||
@ -366,6 +387,10 @@ export interface FleetMetrics {
|
||||
seatsTotal: number;
|
||||
utilizationPct: number;
|
||||
};
|
||||
/** Budget guardrail summary (§3) — null when no budget is configured. */
|
||||
budget?: FleetBudgetSummary | null;
|
||||
/** Per-(factory, engine) circuit-breaker snapshot (§2) — process-wide. */
|
||||
engineBreakers?: EngineBreakerEntry[];
|
||||
alerts: FleetAlert[];
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user