feat: add fleet cost burndown chart
- coordinator.costBurndown() aggregates completed run cost (insights.costUsd) by UTC day over a window, returning a gap-free cumulative series + ceiling - repository.listRunsByProduct() cross-partition run query - GET /fleet/budgets/:productId/burndown?days=N route - fleet-client.getBudgetBurndown() + CostBurndown/BurndownPoint types - BurndownChart on the budget page: cumulative daily bars with a dashed ceiling overlay; bars turn red past the ceiling; degrades gracefully - Tests: +2 coordinator, +1 routes, +2 fleet-client (fleet 147, web 216) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
3f850b7b6f
commit
89860e39f9
@ -23,6 +23,7 @@ import {
|
||||
getJobExplain,
|
||||
listFactories,
|
||||
getBudget,
|
||||
getBudgetBurndown,
|
||||
upsertBudget,
|
||||
pauseBudget,
|
||||
resumeBudget,
|
||||
@ -213,5 +214,24 @@ describe('fleet-client', () => {
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
});
|
||||
|
||||
it('getBudgetBurndown fetches the series with a days query', async () => {
|
||||
fetchSpy.mockResolvedValue({
|
||||
productId: 'p1',
|
||||
ceilingUsd: 50,
|
||||
window: 'monthly',
|
||||
totalUsd: 10,
|
||||
days: [{ date: '2024-01-01', costUsd: 10, cumulativeUsd: 10 }],
|
||||
});
|
||||
const res = await getBudgetBurndown('p1', 30);
|
||||
expect(res?.totalUsd).toBe(10);
|
||||
expect(fetchSpy).toHaveBeenCalledWith('/budgets/p1/burndown?days=30', expect.anything());
|
||||
});
|
||||
|
||||
it('getBudgetBurndown returns null on 404', async () => {
|
||||
fetchSpy.mockRejectedValue(new Error('404 Not Found'));
|
||||
const res = await getBudgetBurndown('p1');
|
||||
expect(res).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -4,11 +4,19 @@ import { useEffect, useState, useCallback } from 'react';
|
||||
import { PageHeader } from '@bytelyst/dashboard-components';
|
||||
import { Button } from '@/components/ui/Primitives';
|
||||
import { useAuth } from '@/lib/auth-context';
|
||||
import { getBudget, pauseBudget, resumeBudget, type FleetBudget } from '@/lib/fleet-client';
|
||||
import {
|
||||
getBudget,
|
||||
getBudgetBurndown,
|
||||
pauseBudget,
|
||||
resumeBudget,
|
||||
type FleetBudget,
|
||||
type CostBurndown,
|
||||
} from '@/lib/fleet-client';
|
||||
|
||||
export default function FleetBudgetPage() {
|
||||
const { token } = useAuth();
|
||||
const [budget, setBudget] = useState<FleetBudget | null | undefined>(undefined);
|
||||
const [burndown, setBurndown] = useState<CostBurndown | null>(null);
|
||||
const [acting, setActing] = useState(false);
|
||||
|
||||
const productId =
|
||||
@ -20,8 +28,9 @@ export default function FleetBudgetPage() {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const b = await getBudget(productId);
|
||||
const [b, bd] = await Promise.all([getBudget(productId), getBudgetBurndown(productId, 30)]);
|
||||
setBudget(b);
|
||||
setBurndown(bd);
|
||||
} catch {
|
||||
setBudget(null);
|
||||
}
|
||||
@ -152,6 +161,62 @@ export default function FleetBudgetPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cost burndown */}
|
||||
{productId && burndown && burndown.days.length > 0 && <BurndownChart burndown={burndown} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BurndownChart({ burndown }: { burndown: CostBurndown }) {
|
||||
const { days, ceilingUsd, totalUsd } = burndown;
|
||||
const maxValue = Math.max(
|
||||
ceilingUsd ?? 0,
|
||||
...days.map(d => d.cumulativeUsd),
|
||||
1 // avoid divide-by-zero
|
||||
);
|
||||
const ceilingPct = ceilingUsd ? (ceilingUsd / maxValue) * 100 : null;
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border p-6 max-w-3xl">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<h2 className="font-semibold">Cost Burndown</h2>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
${totalUsd.toFixed(2)} over {days.length} days
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
Cumulative spend per day{ceilingUsd ? ' vs. budget ceiling (dashed)' : ''}.
|
||||
</p>
|
||||
|
||||
<div className="relative h-40 flex items-end gap-1" aria-label="Cost burndown chart">
|
||||
{ceilingPct !== null && (
|
||||
<div
|
||||
className="absolute left-0 right-0 border-t border-dashed border-red-500/70"
|
||||
style={{ bottom: `${ceilingPct}%` }}
|
||||
aria-label={`Budget ceiling $${ceilingUsd?.toFixed(2)}`}
|
||||
/>
|
||||
)}
|
||||
{days.map(d => {
|
||||
const heightPct = (d.cumulativeUsd / maxValue) * 100;
|
||||
const overCeiling = ceilingUsd !== null && d.cumulativeUsd >= ceilingUsd;
|
||||
return (
|
||||
<div key={d.date} className="flex-1 min-w-0 group relative" style={{ height: '100%' }}>
|
||||
<div className="absolute bottom-0 left-0 right-0 flex items-end h-full">
|
||||
<div
|
||||
className={`w-full rounded-t ${overCeiling ? 'bg-red-500' : 'bg-blue-500'}`}
|
||||
style={{ height: `${Math.max(heightPct, d.cumulativeUsd > 0 ? 2 : 0)}%` }}
|
||||
title={`${d.date}: $${d.cumulativeUsd.toFixed(2)} cumulative ($${d.costUsd.toFixed(2)} that day)`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-muted-foreground mt-2">
|
||||
<span>{days[0]?.date}</span>
|
||||
<span>{days[days.length - 1]?.date}</span>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@ -77,6 +77,20 @@ export interface FleetBudget {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface BurndownPoint {
|
||||
date: string;
|
||||
costUsd: number;
|
||||
cumulativeUsd: number;
|
||||
}
|
||||
|
||||
export interface CostBurndown {
|
||||
productId: string;
|
||||
ceilingUsd: number | null;
|
||||
window: string | null;
|
||||
totalUsd: number;
|
||||
days: BurndownPoint[];
|
||||
}
|
||||
|
||||
export interface DagNode {
|
||||
id: string;
|
||||
idempotencyKey: string;
|
||||
@ -232,3 +246,11 @@ export async function pauseBudget(productId: string): Promise<FleetBudget> {
|
||||
export async function resumeBudget(productId: string): Promise<FleetBudget> {
|
||||
return apiFetch(`/budgets/${productId}/resume`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function getBudgetBurndown(
|
||||
productId: string,
|
||||
days?: number
|
||||
): Promise<CostBurndown | null> {
|
||||
const qs = days ? `?days=${days}` : '';
|
||||
return apiFetchOptional(`/budgets/${productId}/burndown${qs}`);
|
||||
}
|
||||
|
||||
@ -900,4 +900,55 @@ describe('fleet coordinator — Phase 3 per-product budgets', () => {
|
||||
|
||||
expect(await coord.explainJob('missing', PID)).toBeNull();
|
||||
});
|
||||
|
||||
// ── Phase 3: COST BURNDOWN ──
|
||||
it('costBurndown: aggregates run cost by UTC day with a gap-free cumulative series', async () => {
|
||||
process.env.FLEET_BUDGETS = '1';
|
||||
await coord.upsertBudget(PID, 50, 'monthly');
|
||||
const { job } = await coord.submitJob(PID, input());
|
||||
const today = new Date().toISOString();
|
||||
const yesterday = new Date(Date.now() - 86_400_000).toISOString();
|
||||
|
||||
await repo.createRun({
|
||||
id: `${job.id}:run:1`,
|
||||
productId: PID,
|
||||
jobId: job.id,
|
||||
attempt: 1,
|
||||
engine: 'codex',
|
||||
startedAt: yesterday,
|
||||
endedAt: yesterday,
|
||||
insights: { costUsd: 4 },
|
||||
});
|
||||
await repo.createRun({
|
||||
id: `${job.id}:run:2`,
|
||||
productId: PID,
|
||||
jobId: job.id,
|
||||
attempt: 2,
|
||||
engine: 'codex',
|
||||
startedAt: today,
|
||||
endedAt: today,
|
||||
insights: { costUsd: 6 },
|
||||
});
|
||||
|
||||
const burndown = await coord.costBurndown(PID, 7);
|
||||
expect(burndown.ceilingUsd).toBe(50);
|
||||
expect(burndown.days).toHaveLength(7); // gap-free window
|
||||
expect(burndown.totalUsd).toBe(10);
|
||||
// cumulative is monotonic non-decreasing and ends at the total
|
||||
const last = burndown.days[burndown.days.length - 1];
|
||||
expect(last.cumulativeUsd).toBe(10);
|
||||
for (let i = 1; i < burndown.days.length; i++) {
|
||||
expect(burndown.days[i].cumulativeUsd).toBeGreaterThanOrEqual(
|
||||
burndown.days[i - 1].cumulativeUsd
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('costBurndown: no budget + no runs yields a zeroed series and null ceiling', async () => {
|
||||
const burndown = await coord.costBurndown('emptyproduct', 5);
|
||||
expect(burndown.ceilingUsd).toBeNull();
|
||||
expect(burndown.days).toHaveLength(5);
|
||||
expect(burndown.totalUsd).toBe(0);
|
||||
expect(burndown.days.every(d => d.costUsd === 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1087,7 +1087,69 @@ export async function accrueSpend(
|
||||
return repo.updateBudget(productId, updates);
|
||||
}
|
||||
|
||||
// ── Reaper (§25.3) ────────────────────────────────────────────────────────────
|
||||
// ── Cost burndown (§14 Phase 3 — spend-over-time vs ceiling) ──────────────────
|
||||
|
||||
/** One day of the burndown series. */
|
||||
export interface BurndownPoint {
|
||||
date: string; // YYYY-MM-DD (UTC)
|
||||
costUsd: number; // spend attributed to that day
|
||||
cumulativeUsd: number; // running total up to and including that day
|
||||
}
|
||||
|
||||
export interface CostBurndown {
|
||||
productId: string;
|
||||
ceilingUsd: number | null; // budget ceiling, if configured
|
||||
window: BudgetWindow | null;
|
||||
totalUsd: number;
|
||||
days: BurndownPoint[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate completed run cost by UTC day for the last `days` days (default 30),
|
||||
* returning a cumulative burndown series plus the budget ceiling for overlay.
|
||||
* Read-only. Costs come from each run's `insights.costUsd`; a run is attributed
|
||||
* to the day it ended (or started, if still in flight without an end time).
|
||||
*/
|
||||
export async function costBurndown(productId: string, days = 30): Promise<CostBurndown> {
|
||||
const budget = await repo.getBudget(productId);
|
||||
const runs = await repo.listRunsByProduct(productId);
|
||||
|
||||
const windowDays = Math.max(1, Math.min(days, 365));
|
||||
const dayMs = 86_400_000;
|
||||
const todayUtc = Math.floor(Date.now() / dayMs) * dayMs;
|
||||
const startUtc = todayUtc - (windowDays - 1) * dayMs;
|
||||
|
||||
// Initialise every day in the window to zero so the series has no gaps.
|
||||
const byDay = new Map<string, number>();
|
||||
for (let t = startUtc; t <= todayUtc; t += dayMs) {
|
||||
byDay.set(new Date(t).toISOString().slice(0, 10), 0);
|
||||
}
|
||||
|
||||
for (const run of runs) {
|
||||
const cost = run.insights?.costUsd;
|
||||
if (!cost || cost <= 0) continue;
|
||||
const when = Date.parse(run.endedAt ?? run.startedAt);
|
||||
if (Number.isNaN(when) || when < startUtc) continue;
|
||||
const key = new Date(when).toISOString().slice(0, 10);
|
||||
byDay.set(key, (byDay.get(key) ?? 0) + cost);
|
||||
}
|
||||
|
||||
const sortedKeys = [...byDay.keys()].sort();
|
||||
let cumulative = 0;
|
||||
const series: BurndownPoint[] = sortedKeys.map(date => {
|
||||
const costUsd = byDay.get(date) ?? 0;
|
||||
cumulative += costUsd;
|
||||
return { date, costUsd, cumulativeUsd: cumulative };
|
||||
});
|
||||
|
||||
return {
|
||||
productId,
|
||||
ceilingUsd: budget?.ceilingUsd ?? null,
|
||||
window: budget?.window ?? null,
|
||||
totalUsd: cumulative,
|
||||
days: series,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ReapResult {
|
||||
reaped: number;
|
||||
|
||||
@ -188,6 +188,11 @@ export async function listRunsByJob(jobId: string): Promise<FleetRunDoc[]> {
|
||||
return runs().findMany({ filter: { jobId }, sort: { attempt: 1 } });
|
||||
}
|
||||
|
||||
/** All runs for a product (cross-partition) — used for cost aggregation. */
|
||||
export async function listRunsByProduct(productId: string): Promise<FleetRunDoc[]> {
|
||||
return runs().findMany({ filter: { productId } });
|
||||
}
|
||||
|
||||
// ── Leases ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getLease(jobId: string): Promise<FleetLeaseDoc | null> {
|
||||
|
||||
@ -189,4 +189,17 @@ describe('fleetRoutes', () => {
|
||||
const missing = await app.inject({ method: 'GET', url: '/api/fleet/jobs/nope/explain' });
|
||||
expect(missing.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('GET /fleet/budgets/:productId/burndown returns a gap-free daily series', async () => {
|
||||
const app = await buildApp();
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/fleet/budgets/lysnrai/burndown?days=7',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.productId).toBe('lysnrai');
|
||||
expect(body.days).toHaveLength(7);
|
||||
expect(body.totalUsd).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@ -365,6 +365,18 @@ export async function fleetRoutes(app: FastifyInstance) {
|
||||
return budget;
|
||||
});
|
||||
|
||||
// ── Cost burndown — spend-over-time vs ceiling (§14) ──
|
||||
app.get('/fleet/budgets/:productId/burndown', async req => {
|
||||
await extractAuth(req);
|
||||
const { productId } = req.params as { productId: string };
|
||||
const { days } = req.query as { days?: string };
|
||||
const parsedDays = days ? Number.parseInt(days, 10) : undefined;
|
||||
return coordinator.costBurndown(
|
||||
productId,
|
||||
Number.isFinite(parsedDays) ? parsedDays : undefined
|
||||
);
|
||||
});
|
||||
|
||||
app.put('/fleet/budgets/:productId', async (req, reply) => {
|
||||
await extractAuth(req);
|
||||
const { productId } = req.params as { productId: string };
|
||||
|
||||
Loading…
Reference in New Issue
Block a user