update report h6

This commit is contained in:
thanhnv
2026-07-20 23:47:09 +07:00
parent f462079435
commit 4b6819f578
38 changed files with 1244 additions and 62 deletions
@@ -13,6 +13,7 @@ import { CommandCenter } from './pages/CommandCenter';
import { Chat } from './pages/Chat';
import { Goals } from './pages/Goals';
import { EvidencePacks } from './pages/EvidencePacks';
import { H6ReportPage } from './pages/H6Report';
export default function App() {
return (
@@ -31,6 +32,7 @@ export default function App() {
<Route path="/chat" element={<Chat />} />
<Route path="/goals" element={<Goals />} />
<Route path="/evidence-packs" element={<EvidencePacks />} />
<Route path="/reports/h6" element={<H6ReportPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</AppLayout>
@@ -13,6 +13,7 @@ const PAGE_COPY: Record<string, { title: string; eyebrow: string }> = {
'/incidents': { title: 'Incident response', eyebrow: 'Containment' },
'/traceability': { title: 'Traceability', eyebrow: 'Evidence graph' },
'/finops': { title: 'FinOps & SLO', eyebrow: 'Model economics' },
'/reports/h6': { title: 'H6 AgentOps report', eyebrow: 'Assurance dossier' },
'/approvals': { title: 'Approval inbox', eyebrow: 'Human-in-the-loop' },
'/settings': { title: 'Governed settings', eyebrow: 'Control plane' },
};
@@ -1,7 +1,7 @@
import type { ReactNode } from 'react';
import { NavLink } from 'react-router-dom';
type IconName = 'grid' | 'command' | 'chat' | 'goal' | 'runs' | 'shield' | 'governance' | 'incident' | 'trace' | 'evidence' | 'coins' | 'approval' | 'settings';
type IconName = 'grid' | 'command' | 'chat' | 'goal' | 'runs' | 'shield' | 'governance' | 'incident' | 'trace' | 'evidence' | 'report' | 'coins' | 'approval' | 'settings';
interface NavItem { to: string; label: string; icon: IconName; }
@@ -19,6 +19,7 @@ const NAVIGATION: Array<{ label: string; items: NavItem[] }> = [
{ to: '/incidents', label: 'Incidents', icon: 'incident' },
{ to: '/traceability', label: 'Traceability', icon: 'trace' },
{ to: '/evidence-packs', label: 'Evidence packs', icon: 'evidence' },
{ to: '/reports/h6', label: 'H6 AgentOps report', icon: 'report' },
] },
{ label: 'Control', items: [
{ to: '/finops', label: 'FinOps & SLO', icon: 'coins' },
@@ -41,6 +42,7 @@ function Icon({ name }: { name: IconName }) {
incident: <><path d="M10.3 3.3 2.7 17a2 2 0 0 0 1.7 3h15.2a2 2 0 0 0 1.7-3L13.7 3.3a2 2 0 0 0-3.4 0Z" /><path d="M12 9v4M12 17h.01" /></>,
trace: <><circle cx="6" cy="6" r="3" /><circle cx="18" cy="18" r="3" /><circle cx="18" cy="6" r="3" /><path d="m8.6 7.5 6.8 3M9 6h6" /></>,
evidence: <><path d="M7 3h7l3 3v15H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Z" /><path d="M14 3v4h4M8.5 12h7M8.5 16h5" /><path d="m9 8.5 1 1 2-2" /></>,
report: <><path d="M4 20V10M10 20V4M16 20v-7M22 20H2" /><path d="M3 10h2M9 4h2M15 13h2" /><path d="m16 7 2 2 4-5" /></>,
coins: <><ellipse cx="12" cy="5" rx="7" ry="3" /><path d="M5 5v7c0 1.7 3.1 3 7 3s7-1.3 7-3V5M5 12v7c0 1.7 3.1 3 7 3s7-1.3 7-3v-7" /></>,
approval: <><path d="M9 11 11 13l4-4" /><path d="M12 22c5-2.1 8-5.3 8-10V5l-8-3-8 3v7c0 4.7 3 7.9 8 10Z" /></>,
settings: <><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1-2 2-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.5v.2h-2.8v-.2a1.7 1.7 0 0 0-1-1.5 1.7 1.7 0 0 0-1.9.3l-.1.1-2-2 .1-.1A1.7 1.7 0 0 0 7.4 15a1.7 1.7 0 0 0-1.5-1H5.7v-2.8h.2a1.7 1.7 0 0 0 1.5-1 1.7 1.7 0 0 0-.3-1.9L7 8.2l2-2 .1.1a1.7 1.7 0 0 0 1.9.3 1.7 1.7 0 0 0 1-1.5v-.2h2.8v.2a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.9-.3l.1-.1 2 2-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.5 1h.2V14h-.2a1.7 1.7 0 0 0-1.5 1Z" /></>,
@@ -21,7 +21,92 @@ async function post<T>(path: string, body: unknown, headers: Record<string, stri
return res.data.data as T;
}
export interface Freshness { stale: boolean; age_s: number | null; stale_after_s: number }
export interface TelemetrySourceFreshness {
source: 'metrics' | 'provider' | 'alerts';
path: string;
present: boolean;
updated_at: string | null;
age_s: number | null;
stale: boolean;
required: boolean;
records?: number;
}
export interface Freshness { stale: boolean; age_s: number | null; stale_after_s: number; sources?: TelemetrySourceFreshness[] }
export type HarnessReportId = 'H1' | 'H2' | 'H3' | 'H4' | 'H5' | 'H6' | 'H7';
export type HarnessReportVerdict = 'pass' | 'attention' | 'fail' | 'no_data';
export interface H6ReportQuery {
project?: string;
from?: string;
to?: string;
run?: string;
limit?: number;
}
export interface H6ReportFinding {
severity: 'info' | 'warning' | 'critical';
code: string;
message: string;
metric?: string;
value?: number | string;
threshold?: number | string;
}
export interface H6Breakdown {
key: string;
runs: number;
failures: number;
latency_avg_ms: number;
tokens: number;
cost_usd: number;
}
export interface H6Report {
schema_version: 1;
report_id: string;
harness: 'H6';
title: string;
description: string;
generated_at: string;
scope: { project: string | null; from: string | null; to: string | null; run: string | null };
verdict: HarnessReportVerdict;
verdict_reasons: string[];
freshness: {
status: 'live' | 'stale' | 'missing';
stale_after_s: number;
primary_age_s: number | null;
sources: Array<TelemetrySourceFreshness & { records: number }>;
};
summary: {
runs: number;
success: number;
failed: number;
degraded: number;
failure_rate_pct: number;
retries: number;
latency_ms: { average: number; p50: number; p95: number; p99: number; max: number };
tokens: { input: number; output: number; total: number; provider_total: number };
cost_usd: { provider_actual: number; estimated: number };
provider_calls: number;
alerts: number;
};
thresholds: Record<string, number | string | boolean>;
findings: H6ReportFinding[];
evidence_sources: Array<TelemetrySourceFreshness & { records: number }>;
data_quality: { status: 'complete' | 'partial' | 'insufficient'; warnings: string[] };
available_filters: { projects: string[]; runs: string[] };
details: {
by_status: Array<{ status: string; count: number }>;
by_step: H6Breakdown[];
by_provider: H6Breakdown[];
by_cost_source: Array<{ source: string; records: number; cost_usd: number }>;
by_alert: Array<{ alert: string; count: number }>;
recent_runs: HarnessRunRecord[];
recent_alerts: Array<Record<string, unknown>>;
};
}
export interface HarnessRunRecord {
timestamp?: string;
@@ -478,6 +563,24 @@ function actorHeaders(actor: SettingsActor): Record<string, string> {
};
}
function reportQuery(query: H6ReportQuery): string {
const params = new URLSearchParams();
if (query.project) params.set('project', query.project);
if (query.from) params.set('from', query.from);
if (query.to) params.set('to', query.to);
if (query.run) params.set('run', query.run);
if (query.limit) params.set('limit', String(query.limit));
const serialized = params.toString();
return serialized ? `?${serialized}` : '';
}
export function h6ReportExportUrl(query: H6ReportQuery, format: 'json' | 'html'): string {
const base = import.meta.env.VITE_API_BASE_URL ?? '/api/v1';
const params = new URLSearchParams(reportQuery(query).replace(/^\?/, ''));
params.set('format', format);
return `${base}/reports/h6/export?${params.toString()}`;
}
export const api = {
session: () => get<SettingsActor>('session'),
overview: () => get<Overview>('overview'),
@@ -502,6 +605,7 @@ export const api = {
incidents: () => get<Freshness & { total: number; kill_switch_scopes: string[]; incidents: any[] }>('incidents'),
traceability: () => get<Freshness & { matrix: any }>('traceability'),
cost: () => get<Freshness & { provider_tokens: number; provider_cost: number; by_provider: any[]; business_kpi: any }>('cost'),
h6Report: (query: H6ReportQuery = {}) => get<H6Report>(`reports/h6${reportQuery(query)}`),
command: () => get<CommandCenterState>('command'),
evidencePacks: () => get<{ root: string; packs: EvidencePackSummary[] }>('evidence-packs'),
evidencePack: (id: string) => get<EvidencePackDetail>(`evidence-packs/${encodeURIComponent(id)}`),
@@ -0,0 +1,139 @@
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 text-2xl font-semibold tracking-tight text-slate-900">{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>{integer(row.tokens)}</td><td>{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={integer(report.data.summary.tokens.provider_total)} sub={`${report.data.summary.provider_calls} calls`} accent />
<MetricCard label="Actual cost" value={money(report.data.summary.cost_usd.provider_actual)} sub="provider reported" />
<MetricCard label="Estimated cost" value={money(report.data.summary.cost_usd.estimated)} sub="runtime fallback" />
<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}`}>
{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>;
}