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:
parent
8158efacf7
commit
2253f888c7
@ -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) => {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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`);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user