feat: redesign assurance reports for production
This commit is contained in:
@@ -11,51 +11,113 @@ const number = (value: number): string => new Intl.NumberFormat('en-US').format(
|
||||
const money = (value: number): string => `$${value.toFixed(6)}`;
|
||||
|
||||
function breakdownRows(rows: H6Breakdown[]): string {
|
||||
if (rows.length === 0) return '<tr><td colspan="6" class="empty">No records in the selected scope.</td></tr>';
|
||||
if (rows.length === 0) return '<tr><td colspan="6" class="empty">No records in the selected evidence boundary.</td></tr>';
|
||||
return rows.map((row) => `<tr>
|
||||
<td>${escapeHtml(row.key)}</td><td>${number(row.runs)}</td><td>${number(row.failures)}</td>
|
||||
<td>${number(row.latency_avg_ms)} ms</td><td>${row.tokens === null ? 'Unavailable' : number(row.tokens)}</td><td>${row.cost_usd === null ? 'Unavailable' : money(row.cost_usd)}</td>
|
||||
<td><strong>${escapeHtml(row.key)}</strong></td>
|
||||
<td class="numeric">${number(row.runs)}</td>
|
||||
<td class="numeric ${row.failures > 0 ? 'negative' : ''}">${number(row.failures)}</td>
|
||||
<td class="numeric">${number(row.latency_avg_ms)} ms</td>
|
||||
<td class="numeric">${row.tokens === null ? '<span class="unavailable">Unavailable</span>' : number(row.tokens)}</td>
|
||||
<td class="numeric">${row.cost_usd === null ? '<span class="unavailable">Unavailable</span>' : money(row.cost_usd)}</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
function coverage(label: string, value: number, detail: string): string {
|
||||
const bounded = Math.min(Math.max(value, 0), 100);
|
||||
const tone = bounded >= 95 ? 'good' : bounded > 0 ? 'warn' : 'empty';
|
||||
return `<div class="coverage">
|
||||
<div class="coverage-head"><span>${escapeHtml(label)}</span><strong>${bounded}%</strong></div>
|
||||
<div class="track"><i class="${tone}" style="width:${bounded}%"></i></div>
|
||||
<small>${escapeHtml(detail)}</small>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export function renderH6ReportHtml(report: H6Report): string {
|
||||
const verdictClass = report.verdict === 'pass' ? 'pass' : report.verdict === 'fail' ? 'fail' : report.verdict === 'attention' ? 'attention' : 'neutral';
|
||||
const scope = [
|
||||
report.scope.project ? `Project: ${report.scope.project}` : 'All projects',
|
||||
report.scope.run ? `Run: ${report.scope.run}` : 'All runs',
|
||||
report.scope.from ? `From: ${report.scope.from}` : null,
|
||||
report.scope.to ? `To: ${report.scope.to}` : null,
|
||||
report.scope.project ? `Project · ${report.scope.project}` : 'All projects',
|
||||
report.scope.run ? `Run · ${report.scope.run}` : 'All runs',
|
||||
report.scope.from ? `From · ${report.scope.from}` : null,
|
||||
report.scope.to ? `To · ${report.scope.to}` : null,
|
||||
].filter((value): value is string => Boolean(value));
|
||||
const findings = report.findings.length
|
||||
? report.findings.map((finding) => `<li class="${finding.severity}"><strong>${escapeHtml(finding.code)}</strong><span>${escapeHtml(finding.message)}</span></li>`).join('')
|
||||
: '<li class="info"><strong>NO_FINDINGS</strong><span>No threshold breach was detected in this scope.</span></li>';
|
||||
const sourceRows = report.evidence_sources.map((source) => `<tr>
|
||||
<td>${escapeHtml(source.source)}</td><td>${source.present ? 'present' : 'missing'}</td><td>${source.stale ? 'stale' : 'fresh'}</td>
|
||||
<td>${source.age_s === null ? '—' : `${number(source.age_s)} s`}</td><td>${number(source.records)}</td><td><code>${escapeHtml(source.path)}</code></td>
|
||||
</tr>`).join('');
|
||||
? report.findings.map((finding, index) => `<article class="finding ${escapeHtml(finding.severity)}">
|
||||
<div class="finding-index">${String(index + 1).padStart(2, '0')}</div>
|
||||
<div><div class="finding-head"><strong>${escapeHtml(finding.code)}</strong>${finding.metric ? `<code>${escapeHtml(finding.metric)}: ${escapeHtml(finding.value)}${finding.threshold !== undefined ? ` / ${escapeHtml(finding.threshold)}` : ''}</code>` : ''}</div><p>${escapeHtml(finding.message)}</p></div>
|
||||
</article>`).join('')
|
||||
: '<article class="finding success"><div class="finding-index">✓</div><div><div class="finding-head"><strong>NO_THRESHOLD_BREACH</strong></div><p>No threshold breach was detected in this evidence boundary.</p></div></article>';
|
||||
const sourceRows = report.evidence_sources.map((source) => {
|
||||
const status = !source.present ? 'missing' : source.stale ? 'stale' : 'fresh';
|
||||
return `<tr>
|
||||
<td><strong>${escapeHtml(source.source)}</strong></td>
|
||||
<td><span class="status ${status}">${status}</span></td>
|
||||
<td class="numeric">${source.age_s === null ? 'Unavailable' : `${number(source.age_s)} s`}</td>
|
||||
<td class="numeric">${number(source.records)}</td>
|
||||
<td><code>${escapeHtml(source.path)}</code></td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
const warnings = report.data_quality.warnings.length
|
||||
? `<ul>${report.data_quality.warnings.map((warning) => `<li>${escapeHtml(warning)}</li>`).join('')}</ul>`
|
||||
: '<p>No data-quality warning.</p>';
|
||||
? report.data_quality.warnings.map((warning) => `<li>${escapeHtml(warning)}</li>`).join('')
|
||||
: '<li class="positive">All required sources are present and no estimation warning was detected.</li>';
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>${escapeHtml(report.title)} · ${escapeHtml(report.report_id)}</title>
|
||||
<style>
|
||||
:root{color-scheme:light;--ink:#172033;--muted:#64748b;--line:#dbe2ec;--paper:#fff;--wash:#f4f6fa;--indigo:#4f46e5;--teal:#0f766e}
|
||||
*{box-sizing:border-box}body{margin:0;background:var(--wash);color:var(--ink);font-family:Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;line-height:1.45}
|
||||
main{max-width:1180px;margin:0 auto;padding:42px 28px 72px}.hero{position:relative;overflow:hidden;border:1px solid #cbd5e1;border-radius:20px;background:#111827;color:#f8fafc;padding:30px;box-shadow:0 24px 50px rgba(15,23,42,.14)}
|
||||
.hero:after{content:"";position:absolute;right:-90px;top:-110px;width:300px;height:300px;border-radius:999px;background:radial-gradient(circle,#6366f1 0,transparent 68%);opacity:.5}.eyebrow{font-size:11px;text-transform:uppercase;letter-spacing:.18em;color:#a5b4fc;font-weight:800}h1{font-size:34px;line-height:1.1;margin:8px 0}.scope{display:flex;gap:8px;flex-wrap:wrap;color:#cbd5e1;font-size:12px}.scope span{border:1px solid #334155;border-radius:999px;padding:6px 10px;background:#0f172a}.verdict{position:relative;z-index:1;display:inline-flex;margin-top:22px;border-radius:999px;padding:9px 14px;font-size:12px;text-transform:uppercase;letter-spacing:.12em;font-weight:900}.verdict.pass{background:#d1fae5;color:#065f46}.verdict.fail{background:#fee2e2;color:#991b1b}.verdict.attention{background:#fef3c7;color:#92400e}.verdict.neutral{background:#e2e8f0;color:#334155}
|
||||
.meta{margin-top:10px;color:#94a3b8;font-size:12px}.grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin-top:18px}.card,.section{background:var(--paper);border:1px solid var(--line);border-radius:16px;box-shadow:0 5px 14px rgba(15,23,42,.04)}.card{padding:17px}.card span{display:block;color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.1em;font-weight:700}.card strong{display:block;margin-top:6px;font-size:26px}.section{padding:22px;margin-top:18px}h2{font-size:16px;margin:0 0 14px}table{border-collapse:collapse;width:100%;font-size:12px}th{text-align:left;color:var(--muted);font-size:10px;text-transform:uppercase;letter-spacing:.1em;padding:9px;border-bottom:1px solid var(--line)}td{padding:10px 9px;border-bottom:1px solid #edf1f5;vertical-align:top}.empty{color:var(--muted);text-align:center;padding:24px}code{font-size:11px;word-break:break-all}.findings{list-style:none;padding:0;margin:0;display:grid;gap:9px}.findings li{display:grid;grid-template-columns:190px 1fr;gap:12px;border-left:4px solid #94a3b8;background:#f8fafc;padding:11px 13px;border-radius:8px}.findings li.warning{border-color:#f59e0b}.findings li.critical{border-color:#ef4444}.findings span{color:#475569}.quality{border-left:4px solid var(--indigo);background:#eef2ff;padding:14px 16px;border-radius:10px;font-size:13px}.quality p,.quality ul{margin:0}.quality ul{padding-left:18px}.footer{margin-top:24px;color:var(--muted);font-size:11px;text-align:center}
|
||||
@media(max-width:800px){main{padding:20px 14px 50px}.grid{grid-template-columns:repeat(2,minmax(0,1fr))}.hero{padding:22px}h1{font-size:28px}.table-wrap{overflow:auto}.findings li{grid-template-columns:1fr}}
|
||||
@media print{body{background:#fff}main{max-width:none;padding:0}.hero,.card,.section{box-shadow:none}.hero{background:#111827!important;-webkit-print-color-adjust:exact;print-color-adjust:exact}.section{break-inside:avoid}.footer{margin-top:10px}}
|
||||
:root{color-scheme:light;--ink:#101828;--muted:#667085;--soft:#98a2b3;--line:#e4e7ec;--paper:#fff;--canvas:#f2f4f7;--navy:#0b1220;--cyan:#06b6d4;--green:#12b76a;--amber:#f79009;--red:#f04438}
|
||||
*{box-sizing:border-box}html{background:var(--canvas)}body{margin:0;color:var(--ink);font:13px/1.5 Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif}main{max-width:1180px;margin:0 auto;padding:38px 24px 64px}
|
||||
.cover{position:relative;overflow:hidden;border-left:7px solid var(--cyan);border-radius:22px;background:var(--navy);color:#fff;padding:30px 32px;box-shadow:0 24px 64px rgba(16,24,40,.18)}.cover:after{position:absolute;inset:0;content:"";opacity:.12;background-image:linear-gradient(rgba(255,255,255,.2) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.2) 1px,transparent 1px);background-size:32px 32px}.cover>*{position:relative;z-index:1}
|
||||
.eyebrow{color:#67e8f9;font-size:10px;font-weight:900;letter-spacing:.22em;text-transform:uppercase}.cover h1{max-width:800px;margin:12px 0 8px;font-size:36px;line-height:1.08;letter-spacing:-.035em}.cover p{max-width:720px;margin:0;color:#b3b8c3;font-size:12px}.scope{display:flex;flex-wrap:wrap;gap:7px;margin-top:22px}.scope span{border:1px solid #344054;border-radius:999px;background:#111827;padding:6px 9px;color:#d0d5dd;font:9px ui-monospace,SFMono-Regular,Menlo,monospace}
|
||||
.cover-foot{display:flex;align-items:end;justify-content:space-between;gap:24px;margin-top:26px}.verdict{display:inline-flex;align-items:center;gap:8px;border:1px solid #344054;border-radius:999px;padding:8px 13px;font-size:10px;font-weight:900;letter-spacing:.15em;text-transform:uppercase}.verdict:before{width:7px;height:7px;border-radius:99px;background:#98a2b3;content:""}.verdict.pass:before{background:var(--green)}.verdict.attention:before{background:var(--amber)}.verdict.fail:before{background:var(--red)}.document-meta{color:#98a2b3;font:9px ui-monospace,SFMono-Regular,Menlo,monospace;text-align:right}.document-meta strong{display:block;margin-top:3px;color:#e4e7ec;font-size:10px}
|
||||
.summary{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin-top:16px}.metric{min-height:112px;border:1px solid var(--line);border-radius:15px;background:var(--paper);padding:16px;box-shadow:0 8px 22px rgba(16,24,40,.045)}.metric span{display:block;color:var(--muted);font-size:9px;font-weight:900;letter-spacing:.13em;text-transform:uppercase}.metric strong{display:block;margin-top:9px;font-size:22px;line-height:1.15;letter-spacing:-.025em}.metric small{display:block;margin-top:6px;color:var(--soft);font-size:10px}.unavailable{color:#b54708;font-family:Inter,ui-sans-serif,system-ui,sans-serif;font-weight:700}
|
||||
.section{margin-top:16px;border:1px solid var(--line);border-radius:17px;background:var(--paper);box-shadow:0 8px 22px rgba(16,24,40,.04)}.section-head{display:flex;align-items:flex-start;justify-content:space-between;gap:20px;border-bottom:1px solid #f2f4f7;padding:17px 20px}.kicker{color:#087e8b;font-size:9px;font-weight:900;letter-spacing:.16em;text-transform:uppercase}.section-head h2{margin:3px 0 0;font-size:16px;letter-spacing:-.01em}.section-head p{max-width:580px;margin:3px 0 0;color:var(--muted);font-size:10px}.section-body{padding:18px 20px}
|
||||
.findings{display:grid;gap:10px}.finding{display:grid;grid-template-columns:38px minmax(0,1fr);gap:12px;border:1px solid #eaecf0;border-left:4px solid var(--cyan);border-radius:10px;padding:12px}.finding.warning{border-left-color:var(--amber);background:#fffcf5}.finding.critical{border-left-color:var(--red);background:#fffbfa}.finding.success{border-left-color:var(--green);background:#f6fef9}.finding-index{color:#98a2b3;font:15px ui-monospace,SFMono-Regular,Menlo,monospace}.finding-head{display:flex;align-items:center;justify-content:space-between;gap:12px}.finding-head strong{font:10px ui-monospace,SFMono-Regular,Menlo,monospace}.finding-head code{color:#667085;font-size:8px}.finding p{margin:5px 0 0;color:#475467;font-size:11px}
|
||||
.quality-grid{display:grid;grid-template-columns:.75fr 1.25fr;gap:20px}.coverage+.coverage{margin-top:20px}.coverage-head{display:flex;justify-content:space-between;gap:12px}.coverage-head span{color:#667085;font-size:9px;font-weight:900;letter-spacing:.12em;text-transform:uppercase}.coverage-head strong{font:13px ui-monospace,SFMono-Regular,Menlo,monospace}.track{height:6px;margin-top:7px;overflow:hidden;border-radius:99px;background:#f2f4f7}.track i{display:block;height:100%;border-radius:99px;background:#d0d5dd}.track i.good{background:var(--green)}.track i.warn{background:var(--amber)}.coverage small{display:block;margin-top:6px;color:#98a2b3}.disclosure{border:1px solid #eaecf0;border-radius:12px;background:#f9fafb;padding:14px}.disclosure strong{font-size:10px;text-transform:uppercase;letter-spacing:.1em}.disclosure ul{margin:10px 0 0;padding-left:17px;color:#475467;font-size:10px}.disclosure li+li{margin-top:6px}.disclosure .positive{color:#027a48}
|
||||
.table-wrap{overflow:auto}table{width:100%;border-collapse:collapse;font-size:10px}th{border-block:1px solid #eaecf0;background:#f9fafb;padding:9px 10px;color:#667085;font-size:8px;letter-spacing:.1em;text-align:left;text-transform:uppercase}td{border-bottom:1px solid #f2f4f7;padding:10px;vertical-align:top}tr:last-child td{border-bottom:0}.numeric{text-align:right;font:9px ui-monospace,SFMono-Regular,Menlo,monospace}.negative{color:#b42318;font-weight:700}td code{display:block;max-width:420px;color:#98a2b3;font-size:8px;word-break:break-all}.status{display:inline-flex;border:1px solid #d0d5dd;border-radius:999px;padding:3px 7px;color:#475467;font-size:8px;font-weight:900;text-transform:uppercase}.status.fresh{border-color:#a6f4c5;background:#ecfdf3;color:#027a48}.status.stale{border-color:#fedf89;background:#fffaeb;color:#b54708}.status.missing{border-color:#fecdca;background:#fef3f2;color:#b42318}.empty{padding:30px;color:#98a2b3;text-align:center}
|
||||
.footer{display:flex;justify-content:space-between;gap:20px;margin-top:20px;border-top:1px solid var(--line);padding-top:12px;color:#98a2b3;font-size:9px}
|
||||
@media(max-width:820px){main{padding:18px 12px 44px}.cover{padding:24px}.cover h1{font-size:28px}.cover-foot{display:block}.document-meta{margin-top:18px;text-align:left}.summary{grid-template-columns:repeat(2,1fr)}.quality-grid{grid-template-columns:1fr}.finding-head{display:block}.finding-head code{display:block;margin-top:4px}}
|
||||
@page{size:A4;margin:12mm}@media print{html,body{background:#fff}main{max-width:none;padding:0}.cover,.metric,.section{box-shadow:none}.cover{-webkit-print-color-adjust:exact;print-color-adjust:exact}.summary{grid-template-columns:repeat(4,1fr)}.section,.metric,.finding{break-inside:avoid}.cover h1{font-size:30px}}
|
||||
</style></head><body><main>
|
||||
<section class="hero"><div class="eyebrow">CASAN assurance dossier · contract v${report.schema_version}</div><h1>${escapeHtml(report.title)}</h1><div class="scope">${scope.map((item) => `<span>${escapeHtml(item)}</span>`).join('')}</div><div class="verdict ${verdictClass}">${escapeHtml(report.verdict)}</div><div class="meta">Generated ${escapeHtml(report.generated_at)} · ${escapeHtml(report.report_id)} · Freshness ${escapeHtml(report.freshness.status)}</div></section>
|
||||
<section class="grid"><div class="card"><span>Governed runs</span><strong>${number(report.summary.runs)}</strong></div><div class="card"><span>Failure rate</span><strong>${report.summary.failure_rate_pct}%</strong></div><div class="card"><span>P95 latency</span><strong>${number(report.summary.latency_ms.p95)} ms</strong></div><div class="card"><span>Provider tokens</span><strong>${report.summary.coverage.token_records > 0 ? number(report.summary.tokens.provider_total ?? report.summary.tokens.total ?? 0) : 'Unavailable'}</strong></div><div class="card"><span>Actual provider cost</span><strong>${report.summary.cost_usd.provider_actual !== null ? money(report.summary.cost_usd.provider_actual) : 'Unavailable'}</strong></div><div class="card"><span>Token coverage</span><strong>${report.summary.coverage.token_pct}%</strong></div><div class="card"><span>Cost coverage</span><strong>${report.summary.coverage.cost_pct}%</strong></div><div class="card"><span>Retries</span><strong>${number(report.summary.retries)}</strong></div></section>
|
||||
<section class="section"><h2>Verdict findings</h2><ul class="findings">${findings}</ul></section>
|
||||
<section class="section"><h2>Evidence freshness</h2><div class="table-wrap"><table><thead><tr><th>Source</th><th>Presence</th><th>State</th><th>Age</th><th>Records</th><th>Path</th></tr></thead><tbody>${sourceRows}</tbody></table></div></section>
|
||||
<section class="section"><h2>Step breakdown</h2><div class="table-wrap"><table><thead><tr><th>Step</th><th>Runs</th><th>Failures</th><th>Avg latency</th><th>Tokens</th><th>Cost</th></tr></thead><tbody>${breakdownRows(report.details.by_step)}</tbody></table></div></section>
|
||||
<section class="section"><h2>Provider/model breakdown</h2><div class="table-wrap"><table><thead><tr><th>Provider · model</th><th>Calls</th><th>Failures</th><th>Avg latency</th><th>Tokens</th><th>Cost</th></tr></thead><tbody>${breakdownRows(report.details.by_provider)}</tbody></table></div></section>
|
||||
<section class="section"><h2>Data quality · ${escapeHtml(report.data_quality.status)}</h2><div class="quality">${warnings}</div></section>
|
||||
<div class="footer">Generated from CASAN runtime evidence. No maturity score or telemetry value is hard-coded in this document.</div>
|
||||
<header class="cover">
|
||||
<div class="eyebrow">CASAN · H6 AgentOps assurance</div>
|
||||
<h1>Operational evidence, ready for a decision.</h1>
|
||||
<p>${escapeHtml(report.title)} · Runtime health, reliability, provider usage, cost provenance and telemetry integrity.</p>
|
||||
<div class="scope">${scope.map((item) => `<span>${escapeHtml(item)}</span>`).join('')}</div>
|
||||
<div class="cover-foot">
|
||||
<div class="verdict ${verdictClass}">${escapeHtml(report.verdict)}</div>
|
||||
<div class="document-meta">Report ID<strong>${escapeHtml(report.report_id)}</strong>Generated ${escapeHtml(report.generated_at)} · Freshness ${escapeHtml(report.freshness.status)}</div>
|
||||
</div>
|
||||
</header>
|
||||
<section class="summary">
|
||||
<div class="metric"><span>Governed runs</span><strong>${number(report.summary.runs)}</strong><small>${number(report.summary.success)} successful · ${number(report.summary.degraded)} degraded</small></div>
|
||||
<div class="metric"><span>Failure rate</span><strong>${report.summary.failure_rate_pct}%</strong><small>${number(report.summary.failed)} failed · ${number(report.summary.alerts)} alerts</small></div>
|
||||
<div class="metric"><span>P95 latency</span><strong>${number(report.summary.latency_ms.p95)} ms</strong><small>P50 ${number(report.summary.latency_ms.p50)} ms</small></div>
|
||||
<div class="metric"><span>Data quality</span><strong>${escapeHtml(report.data_quality.status.replaceAll('_', ' '))}</strong><small>Coverage disclosed below</small></div>
|
||||
<div class="metric"><span>Provider tokens</span><strong>${report.summary.coverage.token_records > 0 ? number(report.summary.tokens.provider_total ?? report.summary.tokens.total ?? 0) : '<span class="unavailable">Unavailable</span>'}</strong><small>${report.summary.coverage.token_pct}% record coverage</small></div>
|
||||
<div class="metric"><span>Actual provider cost</span><strong>${report.summary.cost_usd.provider_actual !== null ? money(report.summary.cost_usd.provider_actual) : '<span class="unavailable">Unavailable</span>'}</strong><small>${number(report.summary.provider_calls)} provider calls</small></div>
|
||||
<div class="metric"><span>Cost attribution</span><strong>${report.summary.coverage.cost_pct}%</strong><small>${report.summary.coverage.cost_records}/${report.summary.coverage.runtime_records} runtime records</small></div>
|
||||
<div class="metric"><span>Retries</span><strong>${number(report.summary.retries)}</strong><small>Observed reroute attempts</small></div>
|
||||
</section>
|
||||
<section class="section">
|
||||
<div class="section-head"><div><div class="kicker">Decision brief</div><h2>What requires attention</h2><p>Threshold findings are operational review signals, not maturity claims.</p></div><span class="status ${verdictClass}">${escapeHtml(report.verdict)}</span></div>
|
||||
<div class="section-body"><div class="findings">${findings}</div></div>
|
||||
</section>
|
||||
<section class="section">
|
||||
<div class="section-head"><div><div class="kicker">Evidence quality</div><h2>Coverage and disclosure</h2><p>Unavailable telemetry remains null and is never rendered as zero.</p></div><span class="status">${escapeHtml(report.data_quality.status)}</span></div>
|
||||
<div class="section-body"><div class="quality-grid"><div>${coverage('Token attribution', report.summary.coverage.token_pct, `${report.summary.coverage.token_records}/${report.summary.coverage.runtime_records} runtime records`)}${coverage('Cost attribution', report.summary.coverage.cost_pct, `${report.summary.coverage.cost_records}/${report.summary.coverage.runtime_records} runtime records`)}</div><div class="disclosure"><strong>Disclosure notes</strong><ul>${warnings}</ul></div></div></div>
|
||||
</section>
|
||||
<section class="section">
|
||||
<div class="section-head"><div><div class="kicker">Source integrity</div><h2>Evidence provenance</h2><p>Freshness and presence are evaluated independently for every canonical source.</p></div></div>
|
||||
<div class="table-wrap"><table><thead><tr><th>Source</th><th>State</th><th class="numeric">Age</th><th class="numeric">Records</th><th>Canonical path</th></tr></thead><tbody>${sourceRows}</tbody></table></div>
|
||||
</section>
|
||||
<section class="section">
|
||||
<div class="section-head"><div><div class="kicker">Runtime</div><h2>Execution profile by step</h2></div></div>
|
||||
<div class="table-wrap"><table><thead><tr><th>Step</th><th class="numeric">Runs</th><th class="numeric">Failures</th><th class="numeric">Avg latency</th><th class="numeric">Tokens</th><th class="numeric">Cost</th></tr></thead><tbody>${breakdownRows(report.details.by_step)}</tbody></table></div>
|
||||
</section>
|
||||
<section class="section">
|
||||
<div class="section-head"><div><div class="kicker">Provider</div><h2>Model usage and provenance</h2><p>Only provider-reported token and actual cost values are shown.</p></div></div>
|
||||
<div class="table-wrap"><table><thead><tr><th>Provider · model</th><th class="numeric">Calls</th><th class="numeric">Failures</th><th class="numeric">Avg latency</th><th class="numeric">Tokens</th><th class="numeric">Cost</th></tr></thead><tbody>${breakdownRows(report.details.by_provider)}</tbody></table></div>
|
||||
</section>
|
||||
<footer class="footer"><span>Generated from CASAN runtime evidence. No maturity score or telemetry value is hard-coded in this document.</span><span>Contract v${report.schema_version} · ${escapeHtml(report.report_id)}</span></footer>
|
||||
</main></body></html>`;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { RunAssuranceReport } from './run-report.js';
|
||||
import type { RunAssuranceReport, RunGateSnapshot } from './run-report.js';
|
||||
|
||||
const escapeHtml = (value: unknown): string => String(value ?? '')
|
||||
.replaceAll('&', '&')
|
||||
@@ -14,44 +14,91 @@ const badgeClass = (status: string): string => {
|
||||
return 'fail';
|
||||
};
|
||||
|
||||
const evidenceFields = (gate: RunGateSnapshot): string => {
|
||||
const entries = Object.entries(gate.evidence);
|
||||
if (entries.length === 0) return '<div class="empty-evidence">No safe evidence fields were emitted.</div>';
|
||||
return `<dl class="manifest">${entries.map(([key, value]) => `
|
||||
<div><dt>${escapeHtml(key)}</dt><dd>${escapeHtml(
|
||||
typeof value === 'string' ? value : JSON.stringify(value),
|
||||
)}</dd></div>`).join('')}</dl>`;
|
||||
};
|
||||
|
||||
export function renderRunAssuranceHtml(report: RunAssuranceReport): string {
|
||||
const gates = report.gates.map((gate) => `
|
||||
<article class="gate">
|
||||
<div class="gate-head"><strong>${escapeHtml(gate.title)}</strong><span class="badge ${badgeClass(gate.status)}">${escapeHtml(gate.status)}</span></div>
|
||||
<p>${escapeHtml(gate.reason)}</p>
|
||||
<small>${escapeHtml(gate.updated_at ?? 'No timestamp')}</small>
|
||||
<details><summary>Evidence fields</summary><pre>${escapeHtml(JSON.stringify(gate.evidence, null, 2))}</pre></details>
|
||||
</article>`).join('');
|
||||
const tokenValue = report.summary.token_usage_available ? 'Available' : 'Unavailable';
|
||||
const costValue = report.summary.cost_available ? 'Available' : 'Unavailable';
|
||||
const evidenceState = report.source.trace_found && report.source.graph_found ? 'Verified' : 'Partial';
|
||||
const gates = report.gates.map((gate, index) => `
|
||||
<article class="gate ${badgeClass(gate.status)}">
|
||||
<div class="gate-index">H${index + 1}</div>
|
||||
<div class="gate-copy">
|
||||
<div class="gate-heading"><h3>${escapeHtml(gate.title.split(' · ')[1] ?? gate.title)}</h3><span class="badge ${badgeClass(gate.status)}">${escapeHtml(gate.status)}</span></div>
|
||||
<p class="gate-description">${escapeHtml(gate.description)}</p>
|
||||
<div class="decision"><span>Latest decision</span><strong>${escapeHtml(gate.reason || 'No decision reason recorded.')}</strong><small>${escapeHtml(gate.updated_at ?? 'No timestamp')}</small></div>
|
||||
<div class="evidence-title">Evidence fields</div>
|
||||
${evidenceFields(gate)}
|
||||
</div>
|
||||
</article>`).join('');
|
||||
const spine = report.gates.map((gate, index) => `
|
||||
<div class="spine-node ${badgeClass(gate.status)}">
|
||||
<span class="spine-dot">${gate.status === 'pass' ? '✓' : gate.status === 'warning' ? '!' : gate.status === 'queued' ? '○' : '×'}</span>
|
||||
<strong>H${index + 1}</strong>
|
||||
<small>${escapeHtml(gate.title.split(' · ')[1] ?? gate.title)}</small>
|
||||
</div>`).join('');
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>CASAN assurance receipt · ${escapeHtml(report.trace_id)}</title>
|
||||
<title>CASAN assurance dossier · ${escapeHtml(report.trace_id)}</title>
|
||||
<style>
|
||||
:root{color-scheme:light;--ink:#0f172a;--muted:#64748b;--line:#e2e8f0;--panel:#fff;--bg:#f8fafc;--indigo:#4f46e5}
|
||||
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.55 Inter,ui-sans-serif,system-ui,-apple-system,sans-serif}
|
||||
main{max-width:1160px;margin:0 auto;padding:40px 24px 64px}.hero{position:relative;overflow:hidden;border-radius:28px;background:#111827;color:#fff;padding:32px;box-shadow:0 24px 60px rgba(15,23,42,.18)}
|
||||
.hero:after{content:"";position:absolute;right:-80px;top:-100px;width:280px;height:280px;border-radius:50%;background:rgba(99,102,241,.3);filter:blur(50px)}.eyebrow{color:#a5b4fc;font-size:11px;font-weight:800;letter-spacing:.18em;text-transform:uppercase}
|
||||
h1{position:relative;margin:8px 0 2px;font-size:30px;letter-spacing:-.035em}.trace{position:relative;color:#94a3b8;font:12px ui-monospace,SFMono-Regular,monospace;word-break:break-all}
|
||||
.summary{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin:18px 0}.metric,.section{border:1px solid var(--line);background:var(--panel);border-radius:18px;padding:18px}.metric span{display:block;color:var(--muted);font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.08em}.metric strong{display:block;margin-top:8px;font-size:22px}
|
||||
.section{margin-top:16px}.section h2{margin:0 0 14px;font-size:16px}.rail{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));gap:10px}.gate{min-width:0;border:1px solid var(--line);border-radius:15px;padding:14px;background:#fff}.gate-head{display:flex;align-items:flex-start;justify-content:space-between;gap:8px}.gate p{min-height:44px;color:#475569;font-size:12px}.gate small{color:#94a3b8;font-size:10px}.gate details{margin-top:10px;color:#64748b;font-size:10px}.gate summary{cursor:pointer;font-weight:700}.gate pre{max-height:180px;overflow:auto;white-space:pre-wrap;word-break:break-word;border-radius:8px;background:#f8fafc;padding:8px;font-size:9px}.badge{border-radius:999px;padding:3px 7px;font-size:9px;font-weight:800;text-transform:uppercase}.pass{background:#dcfce7;color:#166534}.warn{background:#fef3c7;color:#92400e}.fail{background:#ffe4e6;color:#9f1239}.muted{background:#f1f5f9;color:#64748b}
|
||||
.note{color:var(--muted);font-size:12px}.footer{margin-top:18px;color:#94a3b8;font-size:11px}
|
||||
@media(max-width:900px){.summary{grid-template-columns:repeat(2,1fr)}.rail{grid-template-columns:1fr 1fr}}@media(max-width:520px){main{padding:18px 12px}.hero{padding:22px}.rail,.summary{grid-template-columns:1fr}}
|
||||
@media print{body{background:#fff}main{max-width:none;padding:0}.hero,.metric,.section{box-shadow:none;break-inside:avoid}}
|
||||
:root{color-scheme:light;--ink:#101828;--muted:#667085;--soft:#98a2b3;--line:#e4e7ec;--paper:#fff;--canvas:#f2f4f7;--navy:#0b1220;--cyan:#06b6d4;--green:#12b76a;--amber:#f79009;--red:#f04438}
|
||||
*{box-sizing:border-box}html{background:var(--canvas)}body{margin:0;color:var(--ink);font:13px/1.5 Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif}
|
||||
main{max-width:1180px;margin:0 auto;padding:38px 24px 64px}.cover{position:relative;overflow:hidden;border-left:7px solid var(--cyan);border-radius:22px;background:var(--navy);color:#fff;padding:30px 32px;box-shadow:0 24px 64px rgba(16,24,40,.18)}
|
||||
.cover:after{position:absolute;inset:0;content:"";opacity:.12;background-image:linear-gradient(rgba(255,255,255,.2) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.2) 1px,transparent 1px);background-size:32px 32px}.cover>*{position:relative;z-index:1}.eyebrow{color:#67e8f9;font-size:10px;font-weight:900;letter-spacing:.22em;text-transform:uppercase}.cover h1{max-width:780px;margin:12px 0 6px;font-size:36px;line-height:1.08;letter-spacing:-.035em}.trace{color:#98a2b3;font:11px ui-monospace,SFMono-Regular,Menlo,monospace;word-break:break-all}
|
||||
.cover-grid{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:end;gap:24px;margin-top:26px}.verdict{display:inline-flex;align-items:center;gap:8px;border:1px solid #344054;border-radius:999px;padding:8px 13px;font-size:10px;font-weight:900;letter-spacing:.15em;text-transform:uppercase}.verdict:before{width:7px;height:7px;border-radius:99px;background:var(--cyan);content:""}.verdict.certified:before{background:var(--green)}.verdict.non_certified:before{background:var(--red)}
|
||||
.document-meta{display:grid;grid-template-columns:repeat(2,minmax(130px,1fr));gap:8px 22px;color:#98a2b3;font-size:10px}.document-meta strong{display:block;margin-top:2px;color:#e4e7ec;font:11px ui-monospace,SFMono-Regular,Menlo,monospace}
|
||||
.summary{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin-top:16px}.metric{min-height:112px;border:1px solid var(--line);border-radius:15px;background:var(--paper);padding:16px;box-shadow:0 8px 22px rgba(16,24,40,.045)}.metric span{display:block;color:var(--muted);font-size:9px;font-weight:900;letter-spacing:.13em;text-transform:uppercase}.metric strong{display:block;margin-top:9px;font-size:22px;line-height:1.15;letter-spacing:-.025em}.metric small{display:block;margin-top:6px;color:var(--soft);font-size:10px}
|
||||
.section{margin-top:16px;border:1px solid var(--line);border-radius:17px;background:var(--paper);box-shadow:0 8px 22px rgba(16,24,40,.04)}.section-head{display:flex;align-items:flex-start;justify-content:space-between;gap:20px;border-bottom:1px solid #f2f4f7;padding:17px 20px}.section-head .kicker{color:#087e8b;font-size:9px;font-weight:900;letter-spacing:.16em;text-transform:uppercase}.section-head h2{margin:3px 0 0;font-size:16px;letter-spacing:-.01em}.section-head p{max-width:540px;margin:3px 0 0;color:var(--muted);font-size:10px}
|
||||
.spine{position:relative;display:grid;grid-template-columns:repeat(7,minmax(0,1fr));padding:24px 18px 20px}.spine:before{position:absolute;left:8%;right:8%;top:43px;height:1px;background:var(--line);content:""}.spine-node{position:relative;text-align:center}.spine-dot{display:flex;width:38px;height:38px;margin:0 auto;align-items:center;justify-content:center;border:2px solid #d0d5dd;border-radius:99px;background:#fff;color:#667085;font-weight:900;box-shadow:0 0 0 5px #fff}.spine-node.pass .spine-dot{border-color:#6ce9a6;color:#027a48}.spine-node.warn .spine-dot{border-color:#fec84b;color:#b54708}.spine-node.fail .spine-dot{border-color:#fda29b;color:#b42318}.spine-node strong{display:block;margin-top:8px;color:#98a2b3;font-size:9px;letter-spacing:.13em}.spine-node small{display:block;margin-top:3px;overflow:hidden;color:#344054;font-size:9px;font-weight:700;text-overflow:ellipsis;white-space:nowrap}
|
||||
.controls{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;padding:18px}.gate{display:grid;grid-template-columns:48px minmax(0,1fr);overflow:hidden;border:1px solid var(--line);border-radius:14px;background:#fff;break-inside:avoid}.gate-index{display:flex;align-items:flex-start;justify-content:center;border-right:1px solid var(--line);padding-top:18px;color:#98a2b3;font-size:12px;font-weight:900}.gate.pass{border-top:3px solid var(--green)}.gate.warn{border-top:3px solid var(--amber)}.gate.fail{border-top:3px solid var(--red)}.gate-copy{padding:15px}.gate-heading{display:flex;align-items:center;justify-content:space-between;gap:10px}.gate-heading h3{margin:0;font-size:14px}.badge{border:1px solid #d0d5dd;border-radius:999px;padding:3px 7px;color:#475467;font-size:8px;font-weight:900;letter-spacing:.08em;text-transform:uppercase}.badge.pass{border-color:#a6f4c5;background:#ecfdf3;color:#027a48}.badge.warn{border-color:#fedf89;background:#fffaeb;color:#b54708}.badge.fail{border-color:#fecdca;background:#fef3f2;color:#b42318}.gate-description{min-height:34px;margin:6px 0;color:var(--muted);font-size:10px}.decision{margin-top:10px;border-left:3px solid #d0d5dd;background:#f9fafb;padding:9px 10px}.decision span,.evidence-title{display:block;color:#667085;font-size:8px;font-weight:900;letter-spacing:.12em;text-transform:uppercase}.decision strong{display:block;margin-top:4px;font-size:10px;font-weight:600}.decision small{display:block;margin-top:4px;color:#98a2b3;font:8px ui-monospace,SFMono-Regular,Menlo,monospace}.evidence-title{margin-top:12px}.manifest{margin:6px 0 0;border:1px solid #eaecf0;border-radius:8px}.manifest div{display:grid;grid-template-columns:110px minmax(0,1fr);border-bottom:1px solid #eaecf0}.manifest div:last-child{border-bottom:0}.manifest dt,.manifest dd{margin:0;padding:6px 7px;font:8px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;word-break:break-all}.manifest dt{background:#f9fafb;color:#667085;font-weight:700}.manifest dd{color:#344054}.empty-evidence{margin-top:6px;border:1px dashed #d0d5dd;border-radius:8px;padding:10px;color:#98a2b3;font-size:9px;text-align:center}
|
||||
.certification{display:grid;grid-template-columns:180px minmax(0,1fr);gap:20px;padding:19px 20px}.certification label{color:#667085;font-size:9px;font-weight:900;letter-spacing:.12em;text-transform:uppercase}.certification strong{display:block;margin-top:5px;font-size:15px}.certification p{margin:0;color:#475467;font-size:11px}.footer{display:flex;justify-content:space-between;gap:20px;margin-top:20px;border-top:1px solid var(--line);padding-top:12px;color:#98a2b3;font-size:9px}
|
||||
@media(max-width:820px){main{padding:18px 12px 44px}.cover{padding:24px}.cover h1{font-size:28px}.cover-grid{grid-template-columns:1fr}.summary{grid-template-columns:repeat(2,1fr)}.spine{min-width:720px}.spine-wrap{overflow:auto}.controls{grid-template-columns:1fr}.document-meta{grid-template-columns:1fr 1fr}.certification{grid-template-columns:1fr}}
|
||||
@page{size:A4;margin:12mm}@media print{html,body{background:#fff}main{max-width:none;padding:0}.cover,.metric,.section{box-shadow:none}.cover{-webkit-print-color-adjust:exact;print-color-adjust:exact}.summary{grid-template-columns:repeat(4,1fr)}.spine{padding-inline:8px}.controls{grid-template-columns:repeat(2,1fr);padding:12px}.section,.metric,.gate{break-inside:avoid}.footer{position:relative}.cover h1{font-size:30px}}
|
||||
</style></head><body><main>
|
||||
<section class="hero"><div class="eyebrow">CASAN · evidence-backed assurance</div><h1>${escapeHtml(report.verdict.replace('_', ' ').toUpperCase())}</h1><div class="trace">${escapeHtml(report.trace_id)}</div></section>
|
||||
<header class="cover">
|
||||
<div class="eyebrow">CASAN · independent assurance dossier</div>
|
||||
<h1>${escapeHtml(report.verdict.replace('_', ' ').toUpperCase())} governed run</h1>
|
||||
<div class="trace">${escapeHtml(report.trace_id)}</div>
|
||||
<div class="cover-grid">
|
||||
<div class="verdict ${escapeHtml(report.verdict)}">${escapeHtml(report.verdict.replace('_', ' '))}</div>
|
||||
<div class="document-meta">
|
||||
<div>Report ID<strong>${escapeHtml(report.report_id)}</strong></div>
|
||||
<div>Generated<strong>${escapeHtml(report.generated_at)}</strong></div>
|
||||
<div>Project<strong>${escapeHtml(report.project.id)}</strong></div>
|
||||
<div>Edition / maturity<strong>${escapeHtml(report.project.edition ?? 'unknown')} · ${escapeHtml(report.project.maturity?.status ?? 'not assessed')}</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<section class="summary">
|
||||
<div class="metric"><span>Harness gates</span><strong>${report.summary.gates_observed}/7</strong></div>
|
||||
<div class="metric"><span>H6 quality</span><strong>${escapeHtml(report.summary.telemetry_quality)}</strong></div>
|
||||
<div class="metric"><span>Token usage</span><strong>${tokenValue}</strong></div>
|
||||
<div class="metric"><span>Cost</span><strong>${costValue}</strong></div>
|
||||
<div class="metric"><span>Duration</span><strong>${report.summary.duration_ms === null ? 'Unavailable' : `${report.summary.duration_ms} ms`}</strong></div>
|
||||
<div class="metric"><span>Tool calls</span><strong>${report.summary.tool_calls}</strong></div>
|
||||
<div class="metric"><span>Failures</span><strong>${report.summary.failures}</strong></div>
|
||||
<div class="metric"><span>Evidence source</span><strong>${report.source.trace_found && report.source.graph_found ? 'Verified' : 'Partial'}</strong></div>
|
||||
<div class="metric"><span>Harness controls</span><strong>${report.summary.gates_observed}/7</strong><small>H1–H7 observed</small></div>
|
||||
<div class="metric"><span>Evidence source</span><strong>${evidenceState}</strong><small>Trace + graph reconstruction</small></div>
|
||||
<div class="metric"><span>H6 quality</span><strong>${escapeHtml(report.summary.telemetry_quality)}</strong><small>Telemetry disclosure status</small></div>
|
||||
<div class="metric"><span>Duration</span><strong>${report.summary.duration_ms === null ? 'Unavailable' : `${report.summary.duration_ms} ms`}</strong><small>End-to-end governed run</small></div>
|
||||
<div class="metric"><span>Tool calls</span><strong>${report.summary.tool_calls}</strong><small>Observed side-effect attempts</small></div>
|
||||
<div class="metric"><span>Failures</span><strong>${report.summary.failures}</strong><small>Recorded execution failures</small></div>
|
||||
<div class="metric"><span>Token usage</span><strong>${tokenValue}</strong><small>Never inferred as zero</small></div>
|
||||
<div class="metric"><span>Actual cost</span><strong>${costValue}</strong><small>Provider attribution only</small></div>
|
||||
</section>
|
||||
<section class="section"><h2>Live assurance rail · H1 → H7</h2><div class="rail">${gates}</div></section>
|
||||
<section class="section"><h2>Certification</h2><p>Strength: <strong>${escapeHtml(report.certification.strength ?? 'unknown')}</strong></p><p class="note">${escapeHtml(report.certification.reasons.join(' · ') || 'No certification reason recorded.')}</p></section>
|
||||
<div class="footer">Generated ${escapeHtml(report.generated_at)} · Project ${escapeHtml(report.project.id)} · Edition ${escapeHtml(report.project.edition ?? 'unknown')} · Maturity ${escapeHtml(report.project.maturity?.status ?? 'not assessed')}. No maturity score or unavailable telemetry value is hard-coded.</div>
|
||||
<section class="section">
|
||||
<div class="section-head"><div><div class="kicker">Control sequence</div><h2>Live assurance rail · H1 → H7</h2><p>The evidence spine shows the terminal state of every governed control.</p></div><span class="badge ${badgeClass(report.verdict)}">${escapeHtml(report.verdict)}</span></div>
|
||||
<div class="spine-wrap"><div class="spine">${spine}</div></div>
|
||||
</section>
|
||||
<section class="section">
|
||||
<div class="section-head"><div><div class="kicker">Control dossiers</div><h2>Decision and evidence detail</h2><p>Every field below is sanitized before persistence. Raw prompts and tool output are excluded.</p></div></div>
|
||||
<div class="controls">${gates}</div>
|
||||
</section>
|
||||
<section class="section">
|
||||
<div class="section-head"><div><div class="kicker">Certification</div><h2>Assurance conclusion</h2></div></div>
|
||||
<div class="certification"><div><label>Strength</label><strong>${escapeHtml(report.certification.strength ?? 'unknown')}</strong></div><p>${escapeHtml(report.certification.reasons.join(' · ') || 'No certification reason recorded.')}</p></div>
|
||||
</section>
|
||||
<footer class="footer"><span>Generated from CASAN runtime evidence. Unavailable values remain null.</span><span>Schema v${report.schema_version} · ${escapeHtml(report.trace_id)}</span></footer>
|
||||
</main></body></html>`;
|
||||
}
|
||||
|
||||
@@ -144,6 +144,9 @@ test('HTML export is standalone, escaped and contains no hard-coded maturity sco
|
||||
assert.doesNotMatch(html, /<script>alert\(1\)<\/script>/);
|
||||
assert.doesNotMatch(html, /Average\s+\d|\/100|218 core tests/i);
|
||||
assert.match(html, /No maturity score or telemetry value is hard-coded/);
|
||||
assert.match(html, /Operational evidence, ready for a decision/);
|
||||
assert.match(html, /Evidence provenance/);
|
||||
assert.match(html, /@page\{size:A4/);
|
||||
});
|
||||
|
||||
test('per-run assurance export carries H1-H7 and truthful H6 availability', () => {
|
||||
@@ -188,6 +191,8 @@ test('per-run assurance export carries H1-H7 and truthful H6 availability', () =
|
||||
const html = renderRunAssuranceHtml(report);
|
||||
assert.match(html, /Live assurance rail · H1 → H7/);
|
||||
assert.match(html, /Evidence fields/);
|
||||
assert.match(html, /Decision and evidence detail/);
|
||||
assert.match(html, /@page\{size:A4/);
|
||||
assert.match(html, /Unavailable/);
|
||||
assert.doesNotMatch(html, /\$0(?:\.0+)?/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user