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[] }> }) {
|
async function proxy(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
|
||||||
const { path } = await params;
|
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);
|
const url = new URL(targetPath, PLATFORM_API);
|
||||||
|
|
||||||
req.nextUrl.searchParams.forEach((value, key) => {
|
req.nextUrl.searchParams.forEach((value, key) => {
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import {
|
|||||||
getJobRuns,
|
getJobRuns,
|
||||||
getJobEvents,
|
getJobEvents,
|
||||||
getJobArtifacts,
|
getJobArtifacts,
|
||||||
|
getArtifactDownloadUrl,
|
||||||
getJobDag,
|
getJobDag,
|
||||||
getJobExplain,
|
getJobExplain,
|
||||||
patchJob,
|
patchJob,
|
||||||
@ -317,34 +318,95 @@ export default function FleetJobDetailPage() {
|
|||||||
|
|
||||||
{/* Runs */}
|
{/* Runs */}
|
||||||
<section>
|
<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 ? (
|
{runs.length === 0 ? (
|
||||||
<p className="text-muted-foreground text-sm">No runs yet.</p>
|
<p className="text-muted-foreground text-sm">No runs yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<table className="w-full text-sm" aria-label="Job runs">
|
<div className="overflow-x-auto">
|
||||||
<thead>
|
<table className="w-full text-sm" aria-label="Job runs">
|
||||||
<tr className="border-b text-left text-muted-foreground">
|
<thead>
|
||||||
<th className="pb-2 pr-4">Attempt</th>
|
<tr className="border-b text-left text-muted-foreground">
|
||||||
<th className="pb-2 pr-4">Engine</th>
|
<th className="pb-2 pr-4">Attempt</th>
|
||||||
<th className="pb-2 pr-4">Factory</th>
|
<th className="pb-2 pr-4">Engine</th>
|
||||||
<th className="pb-2 pr-4">Result</th>
|
<th className="pb-2 pr-4">Factory</th>
|
||||||
<th className="pb-2">Started</th>
|
<th className="pb-2 pr-4">Result</th>
|
||||||
</tr>
|
<th className="pb-2 pr-4">Started</th>
|
||||||
</thead>
|
<th className="pb-2 pr-4">Duration</th>
|
||||||
<tbody>
|
<th className="pb-2 pr-4">Model</th>
|
||||||
{runs.map(r => (
|
<th className="pb-2 pr-4 text-right">Tokens (in/out)</th>
|
||||||
<tr key={r.id} className="border-b last:border-0">
|
<th className="pb-2 text-right">Cost</th>
|
||||||
<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>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
{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>
|
</section>
|
||||||
|
|
||||||
@ -362,6 +424,16 @@ export default function FleetJobDetailPage() {
|
|||||||
<span className="text-muted-foreground text-xs">
|
<span className="text-muted-foreground text-xs">
|
||||||
({(a.sizeBytes / 1024).toFixed(1)} KB)
|
({(a.sizeBytes / 1024).toFixed(1)} KB)
|
||||||
</span>
|
</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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</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({
|
function ReviewGateCard({
|
||||||
job,
|
job,
|
||||||
reviewing,
|
reviewing,
|
||||||
|
|||||||
@ -38,6 +38,21 @@ export interface FleetFactory {
|
|||||||
lastHeartbeatAt: string;
|
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 {
|
export interface FleetRun {
|
||||||
id: string;
|
id: string;
|
||||||
jobId: string;
|
jobId: string;
|
||||||
@ -47,7 +62,7 @@ export interface FleetRun {
|
|||||||
startedAt: string;
|
startedAt: string;
|
||||||
endedAt?: string;
|
endedAt?: string;
|
||||||
result?: string;
|
result?: string;
|
||||||
insights: Record<string, unknown>;
|
insights: FleetRunInsights;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FleetEvent {
|
export interface FleetEvent {
|
||||||
@ -417,6 +432,12 @@ export async function getJobArtifacts(jobId: string): Promise<{ artifacts: Fleet
|
|||||||
return apiFetch(`/jobs/${jobId}/artifacts`);
|
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> {
|
export async function getJobDag(jobId: string): Promise<{ dag: DagNode } | null> {
|
||||||
return apiFetchOptional(`/jobs/${jobId}/dag`);
|
return apiFetchOptional(`/jobs/${jobId}/dag`);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user