feat(tracker-web): per-run cost/token/time metrics + log download; fix fleet proxy

Job detail Runs table now shows Duration, Model, Tokens (in/out + cached) and
Cost per run, plus a per-job totals header (cost / tokens / wall-time). Artifacts
get a view/download button via a fresh signed URL. Also fix the fleet API proxy
to forward to /api/fleet/* (backend mounts fleet under /api) so a live backend
resolves; previously it returned 404 and only the mocked e2e passed.

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:
saravanakumardb1 2026-05-31 01:19:57 -07:00
parent 8158efacf7
commit 2253f888c7
3 changed files with 168 additions and 26 deletions

View File

@ -9,7 +9,8 @@ const PLATFORM_API = process.env.PLATFORM_API_URL || 'http://localhost:4003';
async function proxy(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
const { path } = await params;
const targetPath = `/fleet/${path.join('/')}`;
// platform-service mounts fleet routes under the /api prefix.
const targetPath = `/api/fleet/${path.join('/')}`;
const url = new URL(targetPath, PLATFORM_API);
req.nextUrl.searchParams.forEach((value, key) => {

View File

@ -11,6 +11,7 @@ import {
getJobRuns,
getJobEvents,
getJobArtifacts,
getArtifactDownloadUrl,
getJobDag,
getJobExplain,
patchJob,
@ -317,34 +318,95 @@ export default function FleetJobDetailPage() {
{/* Runs */}
<section>
<h2 className="text-lg font-semibold mb-2">Runs</h2>
<div className="flex items-center justify-between mb-2">
<h2 className="text-lg font-semibold">Runs</h2>
{runs.length > 0 &&
(() => {
const t = runTotals(runs);
return (
<div className="flex flex-wrap gap-2" aria-label="Job totals">
<Stat label="Total cost" value={t.costUsd > 0 ? fmtUsd(t.costUsd) : '—'} />
<Stat label="Tokens in" value={t.tokensIn > 0 ? fmtNum(t.tokensIn) : '—'} />
<Stat label="Tokens out" value={t.tokensOut > 0 ? fmtNum(t.tokensOut) : '—'} />
<Stat
label="Total time"
value={t.durationMs > 0 ? fmtDuration(t.durationMs) : '—'}
/>
</div>
);
})()}
</div>
{runs.length === 0 ? (
<p className="text-muted-foreground text-sm">No runs yet.</p>
) : (
<table className="w-full text-sm" aria-label="Job runs">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2 pr-4">Attempt</th>
<th className="pb-2 pr-4">Engine</th>
<th className="pb-2 pr-4">Factory</th>
<th className="pb-2 pr-4">Result</th>
<th className="pb-2">Started</th>
</tr>
</thead>
<tbody>
{runs.map(r => (
<tr key={r.id} className="border-b last:border-0">
<td className="py-2 pr-4">#{r.attempt}</td>
<td className="py-2 pr-4 font-mono text-xs">{r.engine}</td>
<td className="py-2 pr-4 font-mono text-xs">{r.factoryId ?? '—'}</td>
<td className="py-2 pr-4">{r.result ?? 'running'}</td>
<td className="py-2 text-xs text-muted-foreground">
{new Date(r.startedAt).toLocaleString()}
</td>
<div className="overflow-x-auto">
<table className="w-full text-sm" aria-label="Job runs">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2 pr-4">Attempt</th>
<th className="pb-2 pr-4">Engine</th>
<th className="pb-2 pr-4">Factory</th>
<th className="pb-2 pr-4">Result</th>
<th className="pb-2 pr-4">Started</th>
<th className="pb-2 pr-4">Duration</th>
<th className="pb-2 pr-4">Model</th>
<th className="pb-2 pr-4 text-right">Tokens (in/out)</th>
<th className="pb-2 text-right">Cost</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{runs.map(r => {
const ins = r.insights ?? {};
const dur = r.endedAt ? Date.parse(r.endedAt) - Date.parse(r.startedAt) : null;
const tin = ins.tokensIn;
const tout = ins.tokensOut;
const tcached = ins.tokensCached;
return (
<tr key={r.id} className="border-b last:border-0">
<td className="py-2 pr-4">#{r.attempt}</td>
<td className="py-2 pr-4 font-mono text-xs">{r.engine}</td>
<td className="py-2 pr-4 font-mono text-xs">{r.factoryId ?? '—'}</td>
<td className="py-2 pr-4">{r.result ?? 'running'}</td>
<td className="py-2 pr-4 text-xs text-muted-foreground">
{new Date(r.startedAt).toLocaleString()}
</td>
<td className="py-2 pr-4 text-xs">
{dur != null && dur >= 0 ? fmtDuration(dur) : '—'}
</td>
<td className="py-2 pr-4 font-mono text-xs">{ins.model ?? '—'}</td>
<td className="py-2 pr-4 text-right font-mono text-xs">
{tin != null || tout != null ? (
<>
{fmtNum(tin ?? 0)} / {fmtNum(tout ?? 0)}
{tcached ? (
<span className="text-muted-foreground">
{' '}
(+{fmtNum(tcached)} cached)
</span>
) : null}
</>
) : (
'—'
)}
</td>
<td className="py-2 text-right font-mono text-xs">
{ins.costUsd != null ? (
<>
{fmtUsd(ins.costUsd)}
{ins.estimated ? (
<span className="text-muted-foreground"> est.</span>
) : null}
</>
) : (
'—'
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</section>
@ -362,6 +424,16 @@ export default function FleetJobDetailPage() {
<span className="text-muted-foreground text-xs">
({(a.sizeBytes / 1024).toFixed(1)} KB)
</span>
<button
type="button"
className="text-xs underline text-muted-foreground hover:text-foreground"
onClick={async () => {
const url = await getArtifactDownloadUrl(a.id);
if (url) window.open(url, '_blank', 'noopener');
}}
>
{a.kind === 'log' ? 'view log' : 'download'}
</button>
</li>
))}
</ul>
@ -384,6 +456,54 @@ function MetaCard({ label, value }: { label: string; value: string }) {
);
}
/** Compact stat chip for the per-job cost/token/time totals. */
function Stat({ label, value }: { label: string; value: string }) {
return (
<span className="rounded-md border px-2 py-1 text-xs">
<span className="text-muted-foreground">{label}: </span>
<span className="font-medium font-mono">{value}</span>
</span>
);
}
/** Sum cost / tokens / wall-time across a job's runs. */
function runTotals(runs: FleetRun[]) {
let costUsd = 0;
let tokensIn = 0;
let tokensOut = 0;
let durationMs = 0;
for (const r of runs) {
const ins = r.insights ?? {};
costUsd += ins.costUsd ?? 0;
tokensIn += ins.tokensIn ?? 0;
tokensOut += ins.tokensOut ?? 0;
if (r.endedAt) {
const d = Date.parse(r.endedAt) - Date.parse(r.startedAt);
if (Number.isFinite(d) && d > 0) durationMs += d;
}
}
return { costUsd, tokensIn, tokensOut, durationMs };
}
function fmtUsd(n: number): string {
return n < 0.01 && n > 0 ? `$${n.toFixed(4)}` : `$${n.toFixed(2)}`;
}
function fmtNum(n: number): string {
return n.toLocaleString();
}
function fmtDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
const s = Math.round(ms / 1000);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
const rem = s % 60;
if (m < 60) return rem ? `${m}m ${rem}s` : `${m}m`;
const h = Math.floor(m / 60);
return `${h}h ${m % 60}m`;
}
function ReviewGateCard({
job,
reviewing,

View File

@ -38,6 +38,21 @@ export interface FleetFactory {
lastHeartbeatAt: string;
}
/** Per-run cost / token / effort metrics reported by a factory. */
export interface FleetRunInsights {
model?: string;
tokensIn?: number;
tokensOut?: number;
tokensCached?: number;
costUsd?: number;
estimated?: boolean;
turns?: number;
toolCalls?: number;
filesChanged?: number;
linesAdded?: number;
linesDeleted?: number;
}
export interface FleetRun {
id: string;
jobId: string;
@ -47,7 +62,7 @@ export interface FleetRun {
startedAt: string;
endedAt?: string;
result?: string;
insights: Record<string, unknown>;
insights: FleetRunInsights;
}
export interface FleetEvent {
@ -417,6 +432,12 @@ export async function getJobArtifacts(jobId: string): Promise<{ artifacts: Fleet
return apiFetch(`/jobs/${jobId}/artifacts`);
}
/** Resolve a short-lived signed download URL for an artifact (e.g. a `log`). */
export async function getArtifactDownloadUrl(artifactId: string): Promise<string | null> {
const res = await apiFetchOptional<{ downloadUrl?: string }>(`/artifacts/${artifactId}`);
return res?.downloadUrl ?? null;
}
export async function getJobDag(jobId: string): Promise<{ dag: DagNode } | null> {
return apiFetchOptional(`/jobs/${jobId}/dag`);
}