157 lines
14 KiB
TypeScript
157 lines
14 KiB
TypeScript
import { useQuery } from '@tanstack/react-query';
|
|
import { useMemo, useState, type FormEvent, type ReactNode } from 'react';
|
|
import { useSearchParams } from 'react-router-dom';
|
|
import { api, h6ReportExportUrl, type H6Breakdown, type H6ReportQuery, type HarnessReportVerdict } from '../lib/api';
|
|
import { Card, StatusBadge } from '../components/ui/Card';
|
|
|
|
const money = (value: number): string => `$${value.toFixed(6)}`;
|
|
const integer = (value: number): string => new Intl.NumberFormat('en-US').format(value);
|
|
const verdictTone: Record<HarnessReportVerdict, string> = {
|
|
pass: 'border-emerald-300 bg-emerald-50 text-emerald-900',
|
|
attention: 'border-amber-300 bg-amber-50 text-amber-950',
|
|
fail: 'border-rose-300 bg-rose-50 text-rose-950',
|
|
no_data: 'border-slate-300 bg-slate-50 text-slate-800',
|
|
};
|
|
|
|
function queryFromSearch(search: URLSearchParams): H6ReportQuery {
|
|
return {
|
|
project: search.get('project') || undefined,
|
|
from: search.get('from') || undefined,
|
|
to: search.get('to') || undefined,
|
|
run: search.get('run') || undefined,
|
|
limit: 50,
|
|
};
|
|
}
|
|
|
|
function FilterField({ label, children }: { label: string; children: ReactNode }) {
|
|
return <label className="block"><span className="mb-1.5 block text-[10px] font-bold uppercase tracking-[0.13em] text-slate-500">{label}</span>{children}</label>;
|
|
}
|
|
|
|
function MetricCard({ label, value, sub, accent = false }: { label: string; value: string; sub?: string; accent?: boolean }) {
|
|
return <div className={`relative overflow-hidden rounded-2xl border p-4 ${accent ? 'border-indigo-200 bg-indigo-50/70' : 'border-slate-200 bg-white'}`}>
|
|
{accent && <span className="absolute -right-5 -top-5 h-16 w-16 rounded-full bg-indigo-200/50 blur-xl" />}
|
|
<div className="relative text-[10px] font-bold uppercase tracking-[0.12em] text-slate-500">{label}</div>
|
|
<div className={`relative mt-2 font-semibold tracking-tight text-slate-900 ${value.length > 9 ? 'text-base' : 'text-2xl'}`}>{value}</div>
|
|
{sub && <div className="relative mt-1 text-xs text-slate-500">{sub}</div>}
|
|
</div>;
|
|
}
|
|
|
|
function BreakdownTable({ rows, subject }: { rows: H6Breakdown[]; subject: string }) {
|
|
return <div className="overflow-x-auto"><table className="w-full min-w-[680px] text-sm">
|
|
<thead><tr className="border-b border-slate-200 text-left text-[10px] font-bold uppercase tracking-[0.1em] text-slate-400">
|
|
<th className="py-2.5 pr-3">{subject}</th><th>runs</th><th>failures</th><th>avg latency</th><th>tokens</th><th>cost</th>
|
|
</tr></thead>
|
|
<tbody>{rows.map((row) => <tr key={row.key} className="border-b border-slate-100 transition-colors hover:bg-slate-50/80">
|
|
<td className="max-w-[300px] py-3 pr-3 font-medium text-slate-800">{row.key}</td><td>{integer(row.runs)}</td><td>{integer(row.failures)}</td><td>{integer(row.latency_avg_ms)} ms</td><td>{row.tokens === null ? 'Unavailable' : integer(row.tokens)}</td><td>{row.cost_usd === null ? 'Unavailable' : money(row.cost_usd)}</td>
|
|
</tr>)}{rows.length === 0 && <tr><td colSpan={6} className="py-8 text-center text-slate-500">No matching {subject.toLowerCase()} records.</td></tr>}</tbody>
|
|
</table></div>;
|
|
}
|
|
|
|
function LoadingReport() {
|
|
return <div className="space-y-4" aria-label="Loading H6 report"><div className="h-44 animate-pulse rounded-3xl bg-slate-200" /><div className="grid grid-cols-2 gap-3 lg:grid-cols-4">{Array.from({ length: 8 }, (_, index) => <div key={index} className="h-28 animate-pulse rounded-2xl bg-slate-200" />)}</div></div>;
|
|
}
|
|
|
|
export function H6ReportPage() {
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const query = useMemo(() => queryFromSearch(searchParams), [searchParams]);
|
|
const [draft, setDraft] = useState(() => ({
|
|
project: searchParams.get('project') ?? '',
|
|
from: searchParams.get('from') ?? '',
|
|
to: searchParams.get('to') ?? '',
|
|
run: searchParams.get('run') ?? '',
|
|
}));
|
|
const report = useQuery({ queryKey: ['reports', 'h6', query], queryFn: () => api.h6Report(query), retry: false });
|
|
|
|
const applyFilters = (event: FormEvent<HTMLFormElement>) => {
|
|
event.preventDefault();
|
|
const next = new URLSearchParams();
|
|
if (draft.project) next.set('project', draft.project);
|
|
if (draft.from) next.set('from', draft.from);
|
|
if (draft.to) next.set('to', draft.to);
|
|
if (draft.run) next.set('run', draft.run);
|
|
setSearchParams(next);
|
|
};
|
|
|
|
const clearFilters = () => {
|
|
setDraft({ project: '', from: '', to: '', run: '' });
|
|
setSearchParams({});
|
|
};
|
|
|
|
const inputClass = 'w-full rounded-xl border border-slate-300 bg-white px-3 py-2.5 text-sm text-slate-800 outline-none transition focus:border-indigo-400 focus:ring-2 focus:ring-indigo-100';
|
|
|
|
return <div className="space-y-5">
|
|
<section className="relative overflow-hidden rounded-3xl border border-slate-800 bg-[#111827] px-5 py-6 text-white shadow-[0_22px_55px_rgba(15,23,42,0.16)] sm:px-7">
|
|
<div className="absolute right-[-60px] top-[-90px] h-64 w-64 rounded-full bg-indigo-500/25 blur-3xl" />
|
|
<div className="absolute bottom-[-120px] left-1/3 h-56 w-56 rounded-full bg-teal-500/10 blur-3xl" />
|
|
<div className="relative flex flex-col justify-between gap-5 lg:flex-row lg:items-end">
|
|
<div><div className="text-[10px] font-bold uppercase tracking-[0.2em] text-indigo-300">H6 · evidence-backed operations</div><h2 className="mt-2 text-2xl font-semibold tracking-tight sm:text-3xl">AgentOps assurance dossier</h2><p className="mt-2 max-w-2xl text-sm leading-6 text-slate-300">One report surface for runtime health, provider usage, cost provenance, failures, retries, alerts and source freshness.</p></div>
|
|
{report.data && <div className="flex flex-wrap items-center gap-2"><span className={`rounded-full border px-3 py-2 text-xs font-black uppercase tracking-[0.12em] ${verdictTone[report.data.verdict]}`}>{report.data.verdict}</span><span className="rounded-full border border-slate-700 bg-slate-900/70 px-3 py-2 text-xs text-slate-300">{report.data.freshness.status} · {report.data.report_id}</span></div>}
|
|
</div>
|
|
</section>
|
|
|
|
<Card title="Report scope" right={<span className="text-xs font-normal normal-case tracking-normal text-slate-400">Filters apply to API and both exports</span>}>
|
|
<form onSubmit={applyFilters} className="grid gap-3 md:grid-cols-2 xl:grid-cols-[1fr_1fr_1fr_1.4fr_auto] xl:items-end">
|
|
<FilterField label="Project"><select value={draft.project} onChange={(event) => setDraft((current) => ({ ...current, project: event.target.value }))} className={inputClass}><option value="">All projects</option>{report.data?.available_filters.projects.map((project) => <option key={project} value={project}>{project}</option>)}</select></FilterField>
|
|
<FilterField label="From"><input type="date" value={draft.from} onChange={(event) => setDraft((current) => ({ ...current, from: event.target.value }))} className={inputClass} /></FilterField>
|
|
<FilterField label="To"><input type="date" value={draft.to} onChange={(event) => setDraft((current) => ({ ...current, to: event.target.value }))} className={inputClass} /></FilterField>
|
|
<FilterField label="Run / trace"><input list="h6-run-options" value={draft.run} onChange={(event) => setDraft((current) => ({ ...current, run: event.target.value }))} placeholder="All runs" className={inputClass} /><datalist id="h6-run-options">{report.data?.available_filters.runs.map((run) => <option key={run} value={run} />)}</datalist></FilterField>
|
|
<div className="flex gap-2"><button type="submit" className="rounded-xl bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-300">Apply</button><button type="button" onClick={clearFilters} className="rounded-xl border border-slate-300 bg-white px-3 py-2.5 text-sm font-semibold text-slate-600 transition hover:bg-slate-50">Clear</button></div>
|
|
</form>
|
|
</Card>
|
|
|
|
{report.isLoading && <LoadingReport />}
|
|
{report.isError && <div role="alert" className="rounded-2xl border border-rose-200 bg-rose-50 p-5 text-sm text-rose-800"><div className="font-semibold">The H6 report could not be generated.</div><p className="mt-1 text-rose-700">Check the selected time range and confirm the Control Panel API can read the canonical telemetry sources.</p></div>}
|
|
|
|
{report.data && <>
|
|
<section className="grid grid-cols-2 gap-3 lg:grid-cols-4 xl:grid-cols-8">
|
|
<MetricCard label="Runs" value={integer(report.data.summary.runs)} sub={`${report.data.summary.success} successful`} accent />
|
|
<MetricCard label="Failures" value={integer(report.data.summary.failed)} sub={`${report.data.summary.failure_rate_pct}% rate`} />
|
|
<MetricCard label="Degraded" value={integer(report.data.summary.degraded)} />
|
|
<MetricCard label="P95 latency" value={`${integer(report.data.summary.latency_ms.p95)}ms`} sub={`P50 ${integer(report.data.summary.latency_ms.p50)}ms`} />
|
|
<MetricCard
|
|
label="Provider tokens"
|
|
value={report.data.summary.coverage.token_records > 0 ? integer(report.data.summary.tokens.provider_total ?? report.data.summary.tokens.total ?? 0) : 'Unavailable'}
|
|
sub={`${report.data.summary.coverage.token_pct}% run coverage`}
|
|
accent
|
|
/>
|
|
<MetricCard
|
|
label="Actual cost"
|
|
value={report.data.summary.cost_usd.provider_actual !== null ? money(report.data.summary.cost_usd.provider_actual) : 'Unavailable'}
|
|
sub={`${report.data.summary.provider_calls} provider calls`}
|
|
/>
|
|
<MetricCard
|
|
label="Cost coverage"
|
|
value={`${report.data.summary.coverage.cost_pct}%`}
|
|
sub={`${report.data.summary.coverage.cost_records}/${report.data.summary.coverage.runtime_records} runs`}
|
|
/>
|
|
<MetricCard label="Alerts" value={integer(report.data.summary.alerts)} sub={`${report.data.summary.retries} retries`} />
|
|
</section>
|
|
|
|
<div className="grid gap-5 xl:grid-cols-[1.15fr_0.85fr]">
|
|
<Card title="Verdict findings" right={<StatusBadge value={report.data.verdict} />}>
|
|
<div className="space-y-2.5">{report.data.findings.map((finding) => <div key={finding.code} className={`rounded-xl border-l-4 p-3.5 ${finding.severity === 'critical' ? 'border-rose-500 bg-rose-50' : finding.severity === 'warning' ? 'border-amber-500 bg-amber-50' : 'border-indigo-500 bg-indigo-50'}`}><div className="flex flex-wrap items-center justify-between gap-2"><span className="font-mono text-xs font-bold text-slate-800">{finding.code}</span>{finding.metric && <span className="text-[11px] text-slate-500">{finding.metric}: {String(finding.value)}{finding.threshold !== undefined ? ` · threshold ${String(finding.threshold)}` : ''}</span>}</div><p className="mt-1 text-sm leading-5 text-slate-600">{finding.message}</p></div>)}{report.data.findings.length === 0 && <div className="rounded-xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">No threshold breach was detected in this scope.</div>}</div>
|
|
</Card>
|
|
<Card title="Evidence freshness" right={<StatusBadge value={report.data.freshness.status} />}>
|
|
<div className="space-y-3">{report.data.evidence_sources.map((source) => <div key={source.source} className="rounded-xl border border-slate-200 bg-slate-50/70 p-3"><div className="flex items-center justify-between gap-3"><span className="text-sm font-semibold capitalize text-slate-800">{source.source}</span><StatusBadge value={!source.present ? 'missing' : source.stale ? 'stale' : 'fresh'} /></div><div className="mt-2 flex justify-between text-xs text-slate-500"><span>{source.records} records</span><span>{source.age_s === null ? 'no timestamp' : `${integer(source.age_s)}s old`}</span></div><div className="mt-2 break-all font-mono text-[10px] leading-4 text-slate-400">{source.path}</div></div>)}</div>
|
|
</Card>
|
|
</div>
|
|
|
|
<div className="grid gap-5 xl:grid-cols-2"><Card title="Runtime by step"><BreakdownTable rows={report.data.details.by_step} subject="Step" /></Card><Card title="Provider usage"><BreakdownTable rows={report.data.details.by_provider} subject="Provider · model" /></Card></div>
|
|
|
|
<div className="grid gap-5 xl:grid-cols-[0.8fr_1.2fr]">
|
|
<Card title={`Data quality · ${report.data.data_quality.status}`}>
|
|
<div className="mb-4 grid grid-cols-2 gap-3">
|
|
<div className="rounded-xl border border-slate-200 bg-slate-50 p-3"><div className="text-[10px] font-bold uppercase tracking-wider text-slate-400">Token coverage</div><div className="mt-1 text-xl font-semibold text-slate-900">{report.data.summary.coverage.token_pct}%</div></div>
|
|
<div className="rounded-xl border border-slate-200 bg-slate-50 p-3"><div className="text-[10px] font-bold uppercase tracking-wider text-slate-400">Cost coverage</div><div className="mt-1 text-xl font-semibold text-slate-900">{report.data.summary.coverage.cost_pct}%</div></div>
|
|
</div>
|
|
{report.data.data_quality.warnings.length > 0 ? <ul className="space-y-2 text-sm leading-5 text-slate-600">{report.data.data_quality.warnings.map((warning) => <li key={warning} className="flex gap-2"><span className="mt-1 h-2 w-2 shrink-0 rounded-full bg-amber-400" />{warning}</li>)}</ul> : <div className="text-sm text-emerald-700">All required sources are present and no estimation warning was detected.</div>}
|
|
</Card>
|
|
<Card title="Independent export" right={<span className="text-xs font-normal normal-case tracking-normal text-slate-400">Generated on demand · no hard-coded score</span>}>
|
|
<p className="text-sm leading-6 text-slate-600">Both exports use the same contract, filters, verdict rules and canonical sources as this screen. JSON is machine-auditable; HTML is print-ready for team review.</p>
|
|
<div className="mt-4 flex flex-wrap gap-3"><a href={h6ReportExportUrl(query, 'json')} className="rounded-xl bg-slate-900 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-700">Download JSON</a><a href={h6ReportExportUrl(query, 'html')} className="rounded-xl border border-indigo-300 bg-indigo-50 px-4 py-2.5 text-sm font-semibold text-indigo-700 transition hover:bg-indigo-100">Download HTML</a></div>
|
|
</Card>
|
|
</div>
|
|
</>}
|
|
</div>;
|
|
}
|