feat: simplify assurance reports with progressive disclosure
This commit is contained in:
@@ -77,6 +77,7 @@ o6.txt
|
|||||||
o7.txt
|
o7.txt
|
||||||
t6.txt
|
t6.txt
|
||||||
t7.txt
|
t7.txt
|
||||||
|
tmp/design-qa/
|
||||||
|
|
||||||
# TS build cache
|
# TS build cache
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# CASAN report redesign — Design QA
|
||||||
|
|
||||||
|
## Comparison target
|
||||||
|
|
||||||
|
- Source visual truth:
|
||||||
|
- `/Users/thanhnguyen/Downloads/hình ảnh (1).png` — 1844 × 862 px.
|
||||||
|
- `/Users/thanhnguyen/Downloads/hình ảnh (2).png` — 1796 × 795 px.
|
||||||
|
- Rendered implementation:
|
||||||
|
- `tmp/design-qa/h6-default-final.png` — 866 × 2165 px.
|
||||||
|
- Product state: Core Local Assurance Viewer, H6, stale/insufficient telemetry,
|
||||||
|
six successful runs, technical disclosures collapsed.
|
||||||
|
- Browser viewport: 866 × 1043 CSS px, DPR 2. The browser capture is stored at
|
||||||
|
CSS-pixel width; the reference screens are wider desktop compositions, so
|
||||||
|
comparison is by hierarchy, density and visualization pattern rather than
|
||||||
|
pixel-identical scaling.
|
||||||
|
- Responsive verification: 390 × 844 CSS px, document `scrollWidth` 390 px.
|
||||||
|
|
||||||
|
## Full-view comparison evidence
|
||||||
|
|
||||||
|
The two reference reports and the final implementation were opened together in
|
||||||
|
one comparison input. CASAN intentionally preserves the references' strongest
|
||||||
|
patterns—headline metrics, measured distributions, explicit provenance and
|
||||||
|
forensic detail—while moving from an all-dark engineering sheet to the existing
|
||||||
|
CASAN light decision workspace.
|
||||||
|
|
||||||
|
## Required fidelity surfaces
|
||||||
|
|
||||||
|
- Fonts and typography: the references use dense monospace throughout. CASAN
|
||||||
|
intentionally uses the existing system sans hierarchy for decision content
|
||||||
|
and reserves monospace for IDs, timestamps and evidence values. Headings,
|
||||||
|
labels and body copy wrap without clipping.
|
||||||
|
- Spacing and layout rhythm: four executive metrics, decision findings, outcome
|
||||||
|
distribution and evidence quality form a consistent vertical sequence.
|
||||||
|
Technical content is removed from the default reading path.
|
||||||
|
- Colors and tokens: navy, cyan, green, amber and red follow the existing CASAN
|
||||||
|
semantic palette. Every colored state also has a visible label or value.
|
||||||
|
- Image and asset fidelity: neither reference contains raster product imagery.
|
||||||
|
No placeholder image, generated asset or replacement illustration is needed.
|
||||||
|
- Copy and content: all values and warnings come from canonical report data.
|
||||||
|
Missing token/cost values remain unavailable and no maturity claim is inferred.
|
||||||
|
|
||||||
|
## Focused-region comparison
|
||||||
|
|
||||||
|
The final full-page capture is readable at original resolution, including the
|
||||||
|
executive metrics, decision findings, outcome bar, coverage bars and collapsed
|
||||||
|
technical disclosure. A separate crop was not needed.
|
||||||
|
|
||||||
|
## Interaction and accessibility checks
|
||||||
|
|
||||||
|
- Filter and technical sections are native `details`/`summary` controls.
|
||||||
|
- Technical telemetry becomes visible when expanded and hidden when collapsed.
|
||||||
|
- Responsive navigation opens from one labeled button and closes with Escape.
|
||||||
|
- At 390 px the document has no horizontal overflow.
|
||||||
|
- Reduced-motion CSS remains present.
|
||||||
|
- Browser console warning/error check returned no entries.
|
||||||
|
|
||||||
|
## Comparison history
|
||||||
|
|
||||||
|
### Iteration 1
|
||||||
|
|
||||||
|
- Finding: **P2 — tablet navigation consumed too much report width** at the
|
||||||
|
default 866 px browser viewport.
|
||||||
|
- Evidence: `tmp/design-qa/h6-default.png`.
|
||||||
|
- Fix: raised the local viewer navigation-collapse breakpoint from 820 px to
|
||||||
|
960 px.
|
||||||
|
|
||||||
|
### Iteration 2
|
||||||
|
|
||||||
|
- Post-fix evidence: `tmp/design-qa/h6-default-final.png`.
|
||||||
|
- Result: the report uses the full tablet canvas, the menu remains keyboard
|
||||||
|
operable, all content is readable, and no actionable P0/P1/P2 mismatch remains.
|
||||||
|
|
||||||
|
## Follow-up polish
|
||||||
|
|
||||||
|
- A multi-run trend chart can be added only after the report contract exposes a
|
||||||
|
truthful ordered series. It must not be inferred from incomplete snapshots.
|
||||||
|
|
||||||
|
final result: passed
|
||||||
@@ -78,6 +78,25 @@ Control Plane. `casan view` remains the predictable Core single-project viewer.
|
|||||||
The UI shell itself is non-sensitive and may load without a token. All project
|
The UI shell itself is non-sensitive and may load without a token. All project
|
||||||
metadata, evidence and exports require the session token.
|
metadata, evidence and exports require the session token.
|
||||||
|
|
||||||
|
## Presentation and disclosure policy
|
||||||
|
|
||||||
|
Reports use two information layers:
|
||||||
|
|
||||||
|
1. the executive layer shows verdict, governed volume, reliability, latency,
|
||||||
|
evidence quality, decision findings and measured distributions;
|
||||||
|
2. the technical layer preserves filters, provider counters, canonical paths,
|
||||||
|
event history, sanitized manifests and raw breakdown tables behind native
|
||||||
|
disclosure controls.
|
||||||
|
|
||||||
|
The technical layer is collapsed by default in interactive viewers. It remains
|
||||||
|
available for investigation and is included in print-ready export appendices.
|
||||||
|
This is progressive disclosure, not evidence deletion.
|
||||||
|
|
||||||
|
Every visual must be derived from the report contract. Missing token, cost,
|
||||||
|
freshness or provenance values remain unavailable; the UI must not invent a
|
||||||
|
zero, trend, governance verdict or maturity claim. Charts include visible
|
||||||
|
labels and values so meaning does not depend on color alone.
|
||||||
|
|
||||||
## Production acceptance gate
|
## Production acceptance gate
|
||||||
|
|
||||||
The Core viewer is releasable only when all of these pass:
|
The Core viewer is releasable only when all of these pass:
|
||||||
|
|||||||
@@ -32,6 +32,18 @@ function coverage(label: string, value: number, detail: string): string {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function distributionRows(report: H6Report): string {
|
||||||
|
const maximum = Math.max(...report.details.by_status.map((row) => row.count), 1);
|
||||||
|
if (report.details.by_status.length === 0) return '<p class="empty">No measured outcome distribution is available.</p>';
|
||||||
|
return `<ol class="distribution">${report.details.by_status.map((row) => {
|
||||||
|
const value = Math.max(row.count, 0);
|
||||||
|
const width = value === 0 ? 0 : Math.max((value / maximum) * 100, 3);
|
||||||
|
const tone = row.status === 'failed' ? 'fail' : row.status === 'degraded' ? 'attention' : 'pass';
|
||||||
|
const share = report.summary.runs ? Math.round((value / report.summary.runs) * 100) : 0;
|
||||||
|
return `<li><div><span><strong>${escapeHtml(row.status.replaceAll('_', ' '))}</strong><small>${share}% of selected runs</small></span><b>${number(value)}</b></div><i><em class="${tone}" style="width:${width}%"></em></i></li>`;
|
||||||
|
}).join('')}</ol>`;
|
||||||
|
}
|
||||||
|
|
||||||
export function renderH6ReportHtml(report: H6Report): string {
|
export function renderH6ReportHtml(report: H6Report): string {
|
||||||
const verdictClass = report.verdict === 'pass' ? 'pass' : report.verdict === 'fail' ? 'fail' : report.verdict === 'attention' ? 'attention' : 'neutral';
|
const verdictClass = report.verdict === 'pass' ? 'pass' : report.verdict === 'fail' ? 'fail' : report.verdict === 'attention' ? 'attention' : 'neutral';
|
||||||
const scope = [
|
const scope = [
|
||||||
@@ -59,6 +71,7 @@ export function renderH6ReportHtml(report: H6Report): string {
|
|||||||
const warnings = report.data_quality.warnings.length
|
const warnings = report.data_quality.warnings.length
|
||||||
? report.data_quality.warnings.map((warning) => `<li>${escapeHtml(warning)}</li>`).join('')
|
? 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>';
|
: '<li class="positive">All required sources are present and no estimation warning was detected.</li>';
|
||||||
|
const outcomes = distributionRows(report);
|
||||||
|
|
||||||
return `<!doctype html>
|
return `<!doctype html>
|
||||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
@@ -73,10 +86,12 @@ export function renderH6ReportHtml(report: H6Report): string {
|
|||||||
.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}
|
.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}
|
.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}
|
.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}
|
||||||
|
.distribution{display:grid;gap:15px;margin:0;padding:0;list-style:none}.distribution li>div{display:flex;align-items:end;justify-content:space-between;gap:16px}.distribution span strong,.distribution span small{display:block}.distribution span strong{font-size:11px;text-transform:capitalize}.distribution span small{margin-top:3px;color:var(--muted);font-size:9px}.distribution b{font:10px ui-monospace,SFMono-Regular,Menlo,monospace}.distribution i{display:block;overflow:hidden;height:7px;margin-top:7px;border-radius:99px;background:#eaecf0}.distribution em{display:block;height:100%;border-radius:inherit;background:var(--cyan)}.distribution em.pass{background:var(--green)}.distribution em.attention{background:var(--amber)}.distribution em.fail{background:var(--red)}
|
||||||
|
.technical{margin-top:16px;border:1px solid var(--line);border-radius:17px;background:var(--paper);box-shadow:0 8px 22px rgba(16,24,40,.04)}.technical>summary{display:flex;align-items:center;justify-content:space-between;gap:18px;padding:17px 20px;cursor:pointer;list-style:none}.technical>summary::-webkit-details-marker{display:none}.technical>summary strong,.technical>summary small{display:block}.technical>summary strong{font-size:13px}.technical>summary small{margin-top:3px;color:var(--muted);font-size:10px}.technical>summary b{color:var(--soft);font-size:18px;font-weight:400}.technical[open]>summary b{transform:rotate(45deg)}.technical-body{border-top:1px solid #f2f4f7;padding:18px 20px}.technical .summary{margin:0 0 16px}.technical .section{box-shadow:none}
|
||||||
.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}
|
.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}
|
.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}}
|
@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}}
|
@page{size:A4;margin:12mm}@media print{html,body{background:#fff}main{max-width:none;padding:0}.cover,.metric,.section,.technical{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}.technical>summary b{display:none}.technical-body{display:block!important}}
|
||||||
</style></head><body><main>
|
</style></head><body><main>
|
||||||
<header class="cover">
|
<header class="cover">
|
||||||
<div class="eyebrow">CASAN · H6 AgentOps assurance</div>
|
<div class="eyebrow">CASAN · H6 AgentOps assurance</div>
|
||||||
@@ -93,31 +108,33 @@ export function renderH6ReportHtml(report: H6Report): string {
|
|||||||
<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>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>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>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>
|
||||||
<section class="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-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>
|
<div class="section-body"><div class="findings">${findings}</div></div>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="section">
|
||||||
|
<div class="section-head"><div><div class="kicker">Outcome mix</div><h2>Run distribution</h2><p>Canonical runtime status values, normalized against the largest observed group.</p></div><span class="status">${number(report.summary.runs)} total</span></div>
|
||||||
|
<div class="section-body">${outcomes}</div>
|
||||||
|
</section>
|
||||||
<section class="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-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>
|
<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>
|
||||||
<section class="section">
|
<details class="technical">
|
||||||
<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>
|
<summary><span><strong>Technical evidence and breakdowns</strong><small>Provider counters, canonical paths and raw tables are preserved for audit.</small></span><b>+</b></summary>
|
||||||
<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>
|
<div class="technical-body">
|
||||||
</section>
|
<section class="summary">
|
||||||
<section class="section">
|
<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="section-head"><div><div class="kicker">Runtime</div><h2>Execution profile by step</h2></div></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="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>
|
<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>
|
||||||
</section>
|
<div class="metric"><span>Retries</span><strong>${number(report.summary.retries)}</strong><small>Observed reroute attempts</small></div>
|
||||||
<section class="section">
|
</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>
|
<section class="section"><div class="section-head"><div><div class="kicker">Source integrity</div><h2>Evidence provenance</h2></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>
|
||||||
<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 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>
|
<section class="section"><div class="section-head"><div><div class="kicker">Provider</div><h2>Model usage and provenance</h2></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>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
<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>
|
<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>`;
|
</main></body></html>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,8 +60,9 @@ main{max-width:1180px;margin:0 auto;padding:38px 24px 64px}.cover{position:relat
|
|||||||
.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}
|
.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}
|
.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}
|
.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}
|
||||||
|
.technical{margin-top:16px;border:1px solid var(--line);border-radius:17px;background:var(--paper);box-shadow:0 8px 22px rgba(16,24,40,.04)}.technical>summary{display:flex;align-items:center;justify-content:space-between;gap:18px;padding:17px 20px;cursor:pointer;list-style:none}.technical>summary::-webkit-details-marker{display:none}.technical>summary strong,.technical>summary small{display:block}.technical>summary strong{font-size:13px}.technical>summary small{margin-top:3px;color:var(--muted);font-size:10px}.technical>summary b{color:var(--soft);font-size:18px;font-weight:400}.technical[open]>summary b{transform:rotate(45deg)}.technical-body{border-top:1px solid #f2f4f7;padding:18px}.technical .summary{margin:0 0 16px}.technical .section{box-shadow:none}
|
||||||
@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}}
|
@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}}
|
@page{size:A4;margin:12mm}@media print{html,body{background:#fff}main{max-width:none;padding:0}.cover,.metric,.section,.technical{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}.technical>summary b{display:none}.technical-body{display:block!important}}
|
||||||
</style></head><body><main>
|
</style></head><body><main>
|
||||||
<header class="cover">
|
<header class="cover">
|
||||||
<div class="eyebrow">CASAN · independent assurance dossier</div>
|
<div class="eyebrow">CASAN · independent assurance dossier</div>
|
||||||
@@ -82,23 +83,27 @@ main{max-width:1180px;margin:0 auto;padding:38px 24px 64px}.cover{position:relat
|
|||||||
<div class="metric"><span>Evidence source</span><strong>${evidenceState}</strong><small>Trace + graph reconstruction</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>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>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>
|
||||||
<section class="section">
|
<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="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>
|
<div class="spine-wrap"><div class="spine">${spine}</div></div>
|
||||||
</section>
|
</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">
|
<section class="section">
|
||||||
<div class="section-head"><div><div class="kicker">Certification</div><h2>Assurance conclusion</h2></div></div>
|
<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>
|
<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>
|
</section>
|
||||||
|
<details class="technical">
|
||||||
|
<summary><span><strong>Technical evidence and control dossiers</strong><small>Execution counters and sanitized field manifests are preserved for audit.</small></span><b>+</b></summary>
|
||||||
|
<div class="technical-body">
|
||||||
|
<section class="summary">
|
||||||
|
<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"><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>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
<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>
|
<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>`;
|
</main></body></html>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,6 +145,8 @@ test('HTML export is standalone, escaped and contains no hard-coded maturity sco
|
|||||||
assert.doesNotMatch(html, /Average\s+\d|\/100|218 core tests/i);
|
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, /No maturity score or telemetry value is hard-coded/);
|
||||||
assert.match(html, /Operational evidence, ready for a decision/);
|
assert.match(html, /Operational evidence, ready for a decision/);
|
||||||
|
assert.match(html, /Run distribution/);
|
||||||
|
assert.match(html, /<details class="technical">/);
|
||||||
assert.match(html, /Evidence provenance/);
|
assert.match(html, /Evidence provenance/);
|
||||||
assert.match(html, /@page\{size:A4/);
|
assert.match(html, /@page\{size:A4/);
|
||||||
});
|
});
|
||||||
@@ -192,6 +194,7 @@ test('per-run assurance export carries H1-H7 and truthful H6 availability', () =
|
|||||||
assert.match(html, /Live assurance rail · H1 → H7/);
|
assert.match(html, /Live assurance rail · H1 → H7/);
|
||||||
assert.match(html, /Evidence fields/);
|
assert.match(html, /Evidence fields/);
|
||||||
assert.match(html, /Decision and evidence detail/);
|
assert.match(html, /Decision and evidence detail/);
|
||||||
|
assert.match(html, /<details class="technical">/);
|
||||||
assert.match(html, /@page\{size:A4/);
|
assert.match(html, /@page\{size:A4/);
|
||||||
assert.match(html, /Unavailable/);
|
assert.match(html, /Unavailable/);
|
||||||
assert.doesNotMatch(html, /\$0(?:\.0+)?/);
|
assert.doesNotMatch(html, /\$0(?:\.0+)?/);
|
||||||
|
|||||||
@@ -172,5 +172,80 @@ export function CoverageBar({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function DistributionBars({
|
||||||
|
label,
|
||||||
|
rows,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
rows: Array<{
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
detail?: ReactNode;
|
||||||
|
tone?: Tone;
|
||||||
|
}>;
|
||||||
|
}) {
|
||||||
|
const maximum = Math.max(...rows.map((row) => row.value), 1);
|
||||||
|
return (
|
||||||
|
<div role="img" aria-label={label}>
|
||||||
|
<ol className="space-y-4">
|
||||||
|
{rows.map((row) => {
|
||||||
|
const bounded = Math.max(row.value, 0);
|
||||||
|
const width = bounded === 0 ? 0 : Math.max((bounded / maximum) * 100, 3);
|
||||||
|
const tone = row.tone ?? 'info';
|
||||||
|
return (
|
||||||
|
<li key={row.label}>
|
||||||
|
<div className="flex items-end justify-between gap-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-xs font-semibold text-slate-800">{row.label}</p>
|
||||||
|
{row.detail && <div className="mt-1 text-[11px] text-slate-500">{row.detail}</div>}
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 font-mono text-xs font-semibold text-slate-900">{integerLabel(bounded)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 h-2 overflow-hidden rounded-full bg-slate-100" aria-hidden="true">
|
||||||
|
<div className={`h-full rounded-full ${TONE[tone].line}`} style={{ width: `${width}%` }} />
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
{rows.length === 0 && <p className="text-sm text-slate-500">No measured distribution is available.</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function integerLabel(value: number): string {
|
||||||
|
return new Intl.NumberFormat('en-US').format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DisclosurePanel({
|
||||||
|
summary,
|
||||||
|
description,
|
||||||
|
badge,
|
||||||
|
children,
|
||||||
|
open = false,
|
||||||
|
}: {
|
||||||
|
summary: string;
|
||||||
|
description: string;
|
||||||
|
badge?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
open?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<details open={open} className="group overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-[0_10px_28px_rgba(15,23,42,0.04)]">
|
||||||
|
<summary className="flex min-h-16 cursor-pointer list-none items-center justify-between gap-4 px-5 py-4 outline-none transition hover:bg-slate-50 focus-visible:ring-4 focus-visible:ring-inset focus-visible:ring-cyan-100 [&::-webkit-details-marker]:hidden sm:px-6">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-slate-900">{summary}</p>
|
||||||
|
<p className="mt-1 text-xs leading-5 text-slate-500">{description}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-3">
|
||||||
|
{badge}
|
||||||
|
<span className="select-none text-lg text-slate-400 transition-transform group-open:rotate-45" aria-hidden="true">+</span>
|
||||||
|
</div>
|
||||||
|
</summary>
|
||||||
|
<div className="border-t border-slate-100 p-5 sm:p-6">{children}</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export const reportActionPrimary = 'inline-flex min-h-11 items-center justify-center rounded-xl bg-white px-4 py-2.5 text-xs font-bold text-slate-950 shadow-sm transition hover:-translate-y-px hover:bg-cyan-50 focus:outline-none focus:ring-2 focus:ring-cyan-300';
|
export const reportActionPrimary = 'inline-flex min-h-11 items-center justify-center rounded-xl bg-white px-4 py-2.5 text-xs font-bold text-slate-950 shadow-sm transition hover:-translate-y-px hover:bg-cyan-50 focus:outline-none focus:ring-2 focus:ring-cyan-300';
|
||||||
export const reportActionSecondary = 'inline-flex min-h-11 items-center justify-center rounded-xl border border-slate-700 bg-slate-900/70 px-4 py-2.5 text-xs font-bold text-slate-200 transition hover:-translate-y-px hover:border-slate-500 hover:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-cyan-400';
|
export const reportActionSecondary = 'inline-flex min-h-11 items-center justify-center rounded-xl border border-slate-700 bg-slate-900/70 px-4 py-2.5 text-xs font-bold text-slate-200 transition hover:-translate-y-px hover:border-slate-500 hover:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-cyan-400';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { api, runReportExportUrl, type HarnessGateNode, type HarnessTraceGraph } from '../../lib/api';
|
import { api, runReportExportUrl, type HarnessGateNode, type HarnessTraceGraph } from '../../lib/api';
|
||||||
import { ReportPanel } from '../report/ReportPrimitives';
|
import { DisclosurePanel, ReportPanel } from '../report/ReportPrimitives';
|
||||||
import { StatusBadge } from '../ui/Card';
|
import { StatusBadge } from '../ui/Card';
|
||||||
import { AssuranceRail } from './AssuranceRail';
|
import { AssuranceRail } from './AssuranceRail';
|
||||||
|
|
||||||
@@ -35,8 +35,10 @@ function GateDossier({ node }: { node: HarnessGateNode }) {
|
|||||||
</div>
|
</div>
|
||||||
<p className="mt-3 text-sm font-medium leading-6 text-slate-800">{node.reason || 'No decision reason was emitted.'}</p>
|
<p className="mt-3 text-sm font-medium leading-6 text-slate-800">{node.reason || 'No decision reason was emitted.'}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl border border-slate-200 bg-white p-4">
|
<DisclosurePanel
|
||||||
<p className="text-[10px] font-black uppercase tracking-[0.15em] text-slate-500">Event history</p>
|
summary="Event history"
|
||||||
|
description={`${node.events.length} sanitized lifecycle events · hidden by default`}
|
||||||
|
>
|
||||||
<div className="mt-3 space-y-3">
|
<div className="mt-3 space-y-3">
|
||||||
{node.events.slice(-4).reverse().map((event, index) => (
|
{node.events.slice(-4).reverse().map((event, index) => (
|
||||||
<div key={`${event.timestamp}-${index}`} className="grid grid-cols-[0.65rem_minmax(0,1fr)] gap-3">
|
<div key={`${event.timestamp}-${index}`} className="grid grid-cols-[0.65rem_minmax(0,1fr)] gap-3">
|
||||||
@@ -55,33 +57,30 @@ function GateDossier({ node }: { node: HarnessGateNode }) {
|
|||||||
))}
|
))}
|
||||||
{node.events.length === 0 && <p className="text-sm text-slate-500">No individual event was emitted for this control.</p>}
|
{node.events.length === 0 && <p className="text-sm text-slate-500">No individual event was emitted for this control.</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</DisclosurePanel>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-hidden rounded-2xl border border-slate-800 bg-slate-950 text-slate-100 shadow-[0_18px_50px_rgba(15,23,42,0.18)]">
|
<DisclosurePanel
|
||||||
<div className="flex items-center justify-between gap-3 border-b border-slate-800 px-5 py-4">
|
summary="Safe evidence manifest"
|
||||||
<div>
|
description="Sanitized fields persisted by the harness · hidden by default"
|
||||||
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-cyan-300">Safe evidence manifest</p>
|
badge={<span className="rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1 font-mono text-[10px] text-slate-500">{entries.length} fields</span>}
|
||||||
<p className="mt-1 text-xs text-slate-500">Sanitized fields persisted by the harness</p>
|
>
|
||||||
</div>
|
|
||||||
<span className="rounded-full border border-slate-700 bg-slate-900 px-2.5 py-1 font-mono text-[10px] text-slate-400">{entries.length} fields</span>
|
|
||||||
</div>
|
|
||||||
{entries.length > 0 ? (
|
{entries.length > 0 ? (
|
||||||
<dl className="divide-y divide-slate-800">
|
<dl className="-m-5 divide-y divide-slate-100 sm:-m-6">
|
||||||
{entries.map(([key, value]) => (
|
{entries.map(([key, value]) => (
|
||||||
<div key={key} className="grid gap-1 px-5 py-3.5 sm:grid-cols-[10rem_minmax(0,1fr)] sm:gap-4">
|
<div key={key} className="grid gap-1 px-5 py-3.5 sm:grid-cols-[10rem_minmax(0,1fr)] sm:gap-4">
|
||||||
<dt className="font-mono text-[10px] font-semibold uppercase tracking-[0.08em] text-slate-500">{key}</dt>
|
<dt className="font-mono text-[10px] font-semibold uppercase tracking-[0.08em] text-slate-500">{key}</dt>
|
||||||
<dd className="break-all font-mono text-xs leading-5 text-slate-200"><EvidenceValue value={value} /></dd>
|
<dd className="break-all font-mono text-xs leading-5 text-slate-700"><EvidenceValue value={value} /></dd>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</dl>
|
</dl>
|
||||||
) : (
|
) : (
|
||||||
<div className="px-5 py-12 text-center">
|
<div className="px-5 py-12 text-center">
|
||||||
<div className="mx-auto flex h-10 w-10 items-center justify-center rounded-full border border-slate-700 text-slate-500">∅</div>
|
<div className="mx-auto flex h-10 w-10 items-center justify-center rounded-full border border-slate-200 text-slate-400">∅</div>
|
||||||
<p className="mt-3 text-sm text-slate-400">No safe evidence fields are available yet.</p>
|
<p className="mt-3 text-sm text-slate-500">No safe evidence fields are available yet.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</DisclosurePanel>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { useMemo, useState, type FormEvent, type ReactNode } from 'react';
|
|||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
CoverageBar,
|
CoverageBar,
|
||||||
|
DisclosurePanel,
|
||||||
|
DistributionBars,
|
||||||
ReportHero,
|
ReportHero,
|
||||||
ReportPanel,
|
ReportPanel,
|
||||||
SignalMetric,
|
SignalMetric,
|
||||||
@@ -140,11 +142,10 @@ export function H6ReportPage() {
|
|||||||
) : undefined}
|
) : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ReportPanel
|
<DisclosurePanel
|
||||||
eyebrow="Scope"
|
summary="Filter the evidence boundary"
|
||||||
title="Evidence boundary"
|
description="Project, date and trace filters stay out of the executive view until they are needed."
|
||||||
description="Filters are applied server-side to the screen, JSON contract and print-ready dossier."
|
badge={Object.values(draft).some(Boolean) ? <span className="rounded-full bg-cyan-50 px-3 py-1 text-[10px] font-black uppercase tracking-[0.12em] text-cyan-700">Filtered</span> : <span className="text-xs text-slate-400">All evidence</span>}
|
||||||
right={Object.values(draft).some(Boolean) ? <span className="rounded-full bg-cyan-50 px-3 py-1 text-[10px] font-black uppercase tracking-[0.12em] text-cyan-700">Filtered view</span> : <span className="text-xs text-slate-400">All available evidence</span>}
|
|
||||||
>
|
>
|
||||||
<form onSubmit={applyFilters} className="grid gap-4 md:grid-cols-2 xl:grid-cols-[1fr_1fr_1fr_1.45fr_auto] xl:items-end">
|
<form onSubmit={applyFilters} className="grid gap-4 md:grid-cols-2 xl:grid-cols-[1fr_1fr_1fr_1.45fr_auto] xl:items-end">
|
||||||
<FilterField label="Project">
|
<FilterField label="Project">
|
||||||
@@ -168,7 +169,7 @@ export function H6ReportPage() {
|
|||||||
<button type="button" onClick={clearFilters} className="min-h-11 rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm font-bold text-slate-600 transition hover:bg-slate-50">Clear</button>
|
<button type="button" onClick={clearFilters} className="min-h-11 rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm font-bold text-slate-600 transition hover:bg-slate-50">Clear</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</ReportPanel>
|
</DisclosurePanel>
|
||||||
|
|
||||||
{report.isLoading && <LoadingReport />}
|
{report.isLoading && <LoadingReport />}
|
||||||
{report.isError && (
|
{report.isError && (
|
||||||
@@ -187,24 +188,17 @@ export function H6ReportPage() {
|
|||||||
<SignalMetric label="Telemetry integrity" value={data.data_quality.status.replaceAll('_', ' ')} detail={`${data.summary.coverage.token_pct}% token · ${data.summary.coverage.cost_pct}% cost coverage`} tone={reportTone(data.data_quality.status)} primary />
|
<SignalMetric label="Telemetry integrity" value={data.data_quality.status.replaceAll('_', ' ')} detail={`${data.summary.coverage.token_pct}% token · ${data.summary.coverage.cost_pct}% cost coverage`} tone={reportTone(data.data_quality.status)} primary />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="grid gap-3 rounded-2xl border border-slate-200 bg-white p-4 shadow-[0_12px_32px_rgba(15,23,42,0.04)] sm:grid-cols-2 lg:grid-cols-4">
|
<DisclosurePanel
|
||||||
<div className="border-b border-slate-100 p-2 sm:border-b-0 sm:border-r">
|
summary="Provider telemetry"
|
||||||
<p className="text-[10px] font-black uppercase tracking-[0.14em] text-slate-400">Provider tokens</p>
|
description="Token, cost, call and retry counters are technical evidence, hidden by default."
|
||||||
<p className="mt-2 font-mono text-lg font-semibold text-slate-900">{data.summary.coverage.token_records > 0 ? integer(data.summary.tokens.provider_total ?? data.summary.tokens.total ?? 0) : 'Unavailable'}</p>
|
>
|
||||||
</div>
|
<section className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<div className="border-b border-slate-100 p-2 sm:border-b-0 lg:border-r">
|
<SignalMetric label="Provider tokens" value={data.summary.coverage.token_records > 0 ? integer(data.summary.tokens.provider_total ?? data.summary.tokens.total ?? 0) : 'Unavailable'} detail={`${data.summary.coverage.token_pct}% record coverage`} tone={data.summary.coverage.token_records > 0 ? 'info' : 'warning'} />
|
||||||
<p className="text-[10px] font-black uppercase tracking-[0.14em] text-slate-400">Actual cost</p>
|
<SignalMetric label="Actual cost" value={data.summary.cost_usd.provider_actual !== null ? money(data.summary.cost_usd.provider_actual) : 'Unavailable'} detail={`${data.summary.coverage.cost_pct}% record coverage`} tone={data.summary.cost_usd.provider_actual !== null ? 'info' : 'warning'} />
|
||||||
<p className="mt-2 font-mono text-lg font-semibold text-slate-900">{data.summary.cost_usd.provider_actual !== null ? money(data.summary.cost_usd.provider_actual) : 'Unavailable'}</p>
|
<SignalMetric label="Provider calls" value={integer(data.summary.provider_calls)} detail="Provider-attributed requests" />
|
||||||
</div>
|
<SignalMetric label="Retries" value={integer(data.summary.retries)} detail="Observed reroute attempts" tone={data.summary.retries > 0 ? 'warning' : 'neutral'} />
|
||||||
<div className="border-b border-slate-100 p-2 sm:border-b-0 sm:border-r">
|
</section>
|
||||||
<p className="text-[10px] font-black uppercase tracking-[0.14em] text-slate-400">Provider calls</p>
|
</DisclosurePanel>
|
||||||
<p className="mt-2 font-mono text-lg font-semibold text-slate-900">{integer(data.summary.provider_calls)}</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-2">
|
|
||||||
<p className="text-[10px] font-black uppercase tracking-[0.14em] text-slate-400">Retries</p>
|
|
||||||
<p className="mt-2 font-mono text-lg font-semibold text-slate-900">{integer(data.summary.retries)}</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div className="grid gap-5 xl:grid-cols-[1.18fr_0.82fr]">
|
<div className="grid gap-5 xl:grid-cols-[1.18fr_0.82fr]">
|
||||||
<ReportPanel
|
<ReportPanel
|
||||||
@@ -236,37 +230,20 @@ export function H6ReportPage() {
|
|||||||
</ReportPanel>
|
</ReportPanel>
|
||||||
|
|
||||||
<ReportPanel
|
<ReportPanel
|
||||||
eyebrow="Source integrity"
|
eyebrow="Outcome mix"
|
||||||
title="Evidence provenance"
|
title="Run distribution"
|
||||||
description="Freshness is evaluated independently for every canonical source."
|
description="Counts are grouped from canonical runtime status values; the longest bar is the largest observed group."
|
||||||
right={<StatusBadge value={data.freshness.status} />}
|
right={<span className="font-mono text-xs text-slate-400">{integer(data.summary.runs)} total</span>}
|
||||||
>
|
>
|
||||||
<div className="space-y-3">
|
<DistributionBars
|
||||||
{data.evidence_sources.map((source) => {
|
label="Governed run outcome distribution"
|
||||||
const state = !source.present ? 'missing' : source.stale ? 'stale' : 'fresh';
|
rows={data.details.by_status.map((row) => ({
|
||||||
return (
|
label: row.status.replaceAll('_', ' '),
|
||||||
<article key={source.source} className="rounded-xl border border-slate-200 p-4">
|
value: row.count,
|
||||||
<div className="flex items-start justify-between gap-4">
|
detail: `${data.summary.runs ? Math.round((row.count / data.summary.runs) * 100) : 0}% of selected runs`,
|
||||||
<div>
|
tone: row.status === 'failed' ? 'danger' as const : row.status === 'degraded' ? 'warning' as const : 'success' as const,
|
||||||
<h3 className="text-sm font-semibold capitalize text-slate-900">{source.source}</h3>
|
}))}
|
||||||
<p className="mt-1 font-mono text-[10px] text-slate-400">{source.records} records · {source.age_s === null ? 'no timestamp' : `${integer(source.age_s)}s old`}</p>
|
/>
|
||||||
</div>
|
|
||||||
<StatusBadge value={state} />
|
|
||||||
</div>
|
|
||||||
<p className="mt-3 break-all border-t border-slate-100 pt-3 font-mono text-[10px] leading-4 text-slate-400">{source.path}</p>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</ReportPanel>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-5 xl:grid-cols-2">
|
|
||||||
<ReportPanel eyebrow="Runtime" title="Execution profile by step" description="Latency, failure and attribution by governed lifecycle step.">
|
|
||||||
<BreakdownTable rows={data.details.by_step} subject="Step" />
|
|
||||||
</ReportPanel>
|
|
||||||
<ReportPanel eyebrow="Provider" title="Model usage and provenance" description="Only provider-reported token and actual cost values are shown.">
|
|
||||||
<BreakdownTable rows={data.details.by_provider} subject="Provider · model" />
|
|
||||||
</ReportPanel>
|
</ReportPanel>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -297,17 +274,47 @@ export function H6ReportPage() {
|
|||||||
</div>
|
</div>
|
||||||
</ReportPanel>
|
</ReportPanel>
|
||||||
|
|
||||||
<div className="rounded-2xl border border-slate-800 bg-slate-950 p-5 text-white shadow-[0_18px_48px_rgba(15,23,42,0.16)] sm:flex sm:items-center sm:justify-between sm:gap-6 sm:p-6">
|
<DisclosurePanel
|
||||||
<div>
|
summary="Technical evidence and breakdowns"
|
||||||
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-cyan-300">Independent evidence package</p>
|
description="Canonical paths, step tables and provider attribution are preserved for audit without crowding the decision view."
|
||||||
<h2 className="mt-2 text-lg font-semibold">Same contract. Two review formats.</h2>
|
badge={<StatusBadge value={data.freshness.status} />}
|
||||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-400">JSON is machine-auditable. HTML is a self-contained, print-ready assurance dossier generated only when requested.</p>
|
>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<section>
|
||||||
|
<div className="mb-4">
|
||||||
|
<p className="text-[10px] font-black uppercase tracking-[0.16em] text-cyan-700">Source integrity</p>
|
||||||
|
<h3 className="mt-1 text-base font-semibold text-slate-900">Evidence provenance</h3>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 lg:grid-cols-2">
|
||||||
|
{data.evidence_sources.map((source) => {
|
||||||
|
const state = !source.present ? 'missing' : source.stale ? 'stale' : 'fresh';
|
||||||
|
return (
|
||||||
|
<article key={source.source} className="rounded-xl border border-slate-200 p-4">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold capitalize text-slate-900">{source.source}</h4>
|
||||||
|
<p className="mt-1 font-mono text-[10px] text-slate-400">{source.records} records · {source.age_s === null ? 'no timestamp' : `${integer(source.age_s)}s old`}</p>
|
||||||
|
</div>
|
||||||
|
<StatusBadge value={state} />
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 break-all border-t border-slate-100 pt-3 font-mono text-[10px] leading-4 text-slate-400">{source.path}</p>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<div className="grid gap-5 xl:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-3 text-sm font-semibold text-slate-900">Execution profile by step</h3>
|
||||||
|
<BreakdownTable rows={data.details.by_step} subject="Step" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-3 text-sm font-semibold text-slate-900">Model usage and provenance</h3>
|
||||||
|
<BreakdownTable rows={data.details.by_provider} subject="Provider · model" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 flex shrink-0 flex-wrap gap-2 sm:mt-0">
|
</DisclosurePanel>
|
||||||
<a href={h6ReportExportUrl(query, 'json')} className={reportActionSecondary}>Download JSON</a>
|
|
||||||
<a href={h6ReportExportUrl(query, 'html')} className={reportActionPrimary}>Download HTML</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -137,6 +137,28 @@ main { max-width: 1500px; margin: 0 auto; padding: 28px clamp(20px,4vw,48px) 64p
|
|||||||
.panel h2 { margin: 6px 0 0; font-size: 19px; letter-spacing: -.025em; }
|
.panel h2 { margin: 6px 0 0; font-size: 19px; letter-spacing: -.025em; }
|
||||||
.panel-description { max-width: 720px; margin: 7px 0 0; color: var(--muted); font-size: 12px; line-height: 1.6; }
|
.panel-description { max-width: 720px; margin: 7px 0 0; color: var(--muted); font-size: 12px; line-height: 1.6; }
|
||||||
.panel-body { border-top: 1px solid #f2f4f7; padding: 22px 24px 24px; }
|
.panel-body { border-top: 1px solid #f2f4f7; padding: 22px 24px 24px; }
|
||||||
|
.disclosure-panel {
|
||||||
|
overflow: hidden; border: 1px solid var(--line); border-radius: 18px; background: white;
|
||||||
|
box-shadow: 0 10px 28px rgba(16,24,40,.04);
|
||||||
|
}
|
||||||
|
.disclosure-panel > summary {
|
||||||
|
display: flex; min-height: 66px; align-items: center; justify-content: space-between; gap: 18px;
|
||||||
|
padding: 15px 20px; list-style: none; cursor: pointer; transition: background .16s ease;
|
||||||
|
}
|
||||||
|
.disclosure-panel > summary::-webkit-details-marker { display: none; }
|
||||||
|
.disclosure-panel > summary:hover { background: #f9fafb; }
|
||||||
|
.disclosure-panel > summary strong, .disclosure-panel > summary small { display: block; }
|
||||||
|
.disclosure-panel > summary strong { color: #344054; font-size: 13px; }
|
||||||
|
.disclosure-panel > summary small { margin-top: 5px; color: var(--muted); font-size: 11px; line-height: 1.45; }
|
||||||
|
.disclosure-side { display: flex; flex-shrink: 0; align-items: center; gap: 10px; }
|
||||||
|
.disclosure-side > b { color: var(--faint); font-size: 20px; font-weight: 400; transition: transform .16s ease; }
|
||||||
|
.disclosure-panel[open] .disclosure-side > b { transform: rotate(45deg); }
|
||||||
|
.disclosure-body { border-top: 1px solid #f2f4f7; padding: 20px; }
|
||||||
|
.disclosure-body > dl { margin: -20px; }
|
||||||
|
.disclosure-panel .manifest-row { border-bottom: 1px solid #f2f4f7; }
|
||||||
|
.disclosure-panel .manifest-row:last-child { border-bottom: 0; }
|
||||||
|
.disclosure-panel .manifest-row dt { color: var(--faint); font: 750 9px ui-monospace,monospace; letter-spacing: .07em; text-transform: uppercase; }
|
||||||
|
.disclosure-panel .manifest-row dd { margin: 0; overflow-wrap: anywhere; color: #344054; font: 11px/1.5 ui-monospace,monospace; }
|
||||||
.two-col { display: grid; grid-template-columns: minmax(0,1.2fr) minmax(330px,.8fr); gap: 18px; }
|
.two-col { display: grid; grid-template-columns: minmax(0,1.2fr) minmax(330px,.8fr); gap: 18px; }
|
||||||
.rail-wrap { overflow-x: auto; padding-bottom: 4px; }
|
.rail-wrap { overflow-x: auto; padding-bottom: 4px; }
|
||||||
.evidence-rail { display: grid; grid-template-columns: repeat(7,minmax(116px,1fr)); min-width: 840px; gap: 7px; }
|
.evidence-rail { display: grid; grid-template-columns: repeat(7,minmax(116px,1fr)); min-width: 840px; gap: 7px; }
|
||||||
@@ -203,6 +225,20 @@ tbody tr:hover { background: #f0fdfa; }
|
|||||||
.source h3 { margin: 0; font-size: 12px; text-transform: capitalize; }
|
.source h3 { margin: 0; font-size: 12px; text-transform: capitalize; }
|
||||||
.source-meta, .source-path { color: var(--faint); font: 9px/1.5 ui-monospace,monospace; }
|
.source-meta, .source-path { color: var(--faint); font: 9px/1.5 ui-monospace,monospace; }
|
||||||
.source-meta { margin-top: 5px; }.source-path { overflow-wrap:anywhere; border-top: 1px solid #f2f4f7; margin-top: 12px; padding-top: 10px; }
|
.source-meta { margin-top: 5px; }.source-path { overflow-wrap:anywhere; border-top: 1px solid #f2f4f7; margin-top: 12px; padding-top: 10px; }
|
||||||
|
.distribution { display: grid; gap: 17px; margin: 0; padding: 0; list-style: none; }
|
||||||
|
.distribution-head { display: flex; align-items: end; justify-content: space-between; gap: 16px; }
|
||||||
|
.distribution-head span strong, .distribution-head span small { display: block; }
|
||||||
|
.distribution-head span strong { color: #344054; font-size: 12px; text-transform: capitalize; }
|
||||||
|
.distribution-head span small { margin-top: 4px; color: var(--muted); font-size: 10px; }
|
||||||
|
.distribution-head > b { color: var(--ink); font: 700 11px ui-monospace,monospace; }
|
||||||
|
.distribution-track { overflow: hidden; height: 8px; margin-top: 8px; border-radius: 99px; background: #eaecf0; }
|
||||||
|
.distribution-track i { display: block; height: 100%; border-radius: inherit; background: var(--cyan); }
|
||||||
|
.distribution-track i.success { background: #12b76a; }
|
||||||
|
.distribution-track i.warning { background: #f79009; }
|
||||||
|
.distribution-track i.danger { background: #f04438; }
|
||||||
|
.metric-grid.compact .metric-card { min-height: 116px; }
|
||||||
|
.technical-section { margin-top: 24px; border-top: 1px solid #f2f4f7; padding-top: 22px; }
|
||||||
|
.technical-section h3 { margin: 6px 0 14px; font-size: 16px; }
|
||||||
.coverage { display: grid; gap: 18px; }
|
.coverage { display: grid; gap: 18px; }
|
||||||
.coverage-head { display: flex; justify-content: space-between; gap: 12px; font-size: 11px; }
|
.coverage-head { display: flex; justify-content: space-between; gap: 12px; font-size: 11px; }
|
||||||
.coverage-track { overflow: hidden; height: 7px; margin-top: 8px; border-radius: 99px; background: #eaecf0; }
|
.coverage-track { overflow: hidden; height: 7px; margin-top: 8px; border-radius: 99px; background: #eaecf0; }
|
||||||
@@ -241,7 +277,7 @@ tbody tr:hover { background: #f0fdfa; }
|
|||||||
.filters { grid-template-columns: 1fr 1fr; }
|
.filters { grid-template-columns: 1fr 1fr; }
|
||||||
.filter-actions { grid-column: 1/-1; }
|
.filter-actions { grid-column: 1/-1; }
|
||||||
}
|
}
|
||||||
@media (max-width: 820px) {
|
@media (max-width: 960px) {
|
||||||
.sidebar { transform: translateX(-100%); transition: transform .2s ease; box-shadow: 18px 0 50px rgba(16,24,40,.28); }
|
.sidebar { transform: translateX(-100%); transition: transform .2s ease; box-shadow: 18px 0 50px rgba(16,24,40,.28); }
|
||||||
.sidebar.open { transform: translateX(0); }
|
.sidebar.open { transform: translateX(0); }
|
||||||
.shell { margin-left: 0; }
|
.shell { margin-left: 0; }
|
||||||
|
|||||||
@@ -69,6 +69,21 @@
|
|||||||
<div class="panel-body">${body}</div></article>`;
|
<div class="panel-body">${body}</div></article>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function disclosure(summary, description, body, badge = "") {
|
||||||
|
return `<details class="disclosure-panel"><summary><span><strong>${esc(summary)}</strong><small>${esc(description)}</small></span>
|
||||||
|
<span class="disclosure-side">${badge}<b aria-hidden="true">+</b></span></summary><div class="disclosure-body">${body}</div></details>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function distribution(label, rows) {
|
||||||
|
const maximum = Math.max(...rows.map((row) => Number(row.value) || 0), 1);
|
||||||
|
return `<ol class="distribution" role="img" aria-label="${esc(label)}">${rows.map((row) => {
|
||||||
|
const value = Math.max(Number(row.value) || 0, 0);
|
||||||
|
const width = value === 0 ? 0 : Math.max((value / maximum) * 100, 3);
|
||||||
|
return `<li><div class="distribution-head"><span><strong>${esc(row.label)}</strong><small>${esc(row.detail || "")}</small></span><b>${esc(number(value))}</b></div>
|
||||||
|
<div class="distribution-track" aria-hidden="true"><i class="${slug(row.tone || "info")}" style="width:${width}%"></i></div></li>`;
|
||||||
|
}).join("")}</ol>`;
|
||||||
|
}
|
||||||
|
|
||||||
function empty(title, detail) {
|
function empty(title, detail) {
|
||||||
return `<div class="empty"><div class="empty-mark">∅</div><strong>${esc(title)}</strong><p>${esc(detail)}</p></div>`;
|
return `<div class="empty"><div class="empty-mark">∅</div><strong>${esc(title)}</strong><p>${esc(detail)}</p></div>`;
|
||||||
}
|
}
|
||||||
@@ -130,10 +145,9 @@
|
|||||||
${metric("Observed failures", number(failures), `${recent.length ? Math.round((failures / recent.length) * 100) : 0}% of loaded records`)}
|
${metric("Observed failures", number(failures), `${recent.length ? Math.round((failures / recent.length) * 100) : 0}% of loaded records`)}
|
||||||
${metric("Evidence gates", latestTrace ? "H1 → H7" : "Waiting", latestTrace ? "Control-by-control reconstruction available" : "Evidence appears after a prompt finalizes")}
|
${metric("Evidence gates", latestTrace ? "H1 → H7" : "Waiting", latestTrace ? "Control-by-control reconstruction available" : "Evidence appears after a prompt finalizes")}
|
||||||
</section>` +
|
</section>` +
|
||||||
panel(
|
disclosure(
|
||||||
"Product boundary",
|
"About this local report",
|
||||||
"Core is visual by default",
|
"Edition boundaries and implementation notes are hidden until needed.",
|
||||||
"The local viewer covers the complete single-project review workflow; centralized operations remain a Platform capability.",
|
|
||||||
`<div class="two-col">
|
`<div class="two-col">
|
||||||
<div class="notice success"><strong>Included in Core</strong><br>Latest run, history, interactive H1–H7 evidence, H6 AgentOps, and on-demand JSON/HTML export.</div>
|
<div class="notice success"><strong>Included in Core</strong><br>Latest run, history, interactive H1–H7 evidence, H6 AgentOps, and on-demand JSON/HTML export.</div>
|
||||||
<div class="notice warning"><strong>Platform boundary</strong><br>Multi-project fleet views, centralized RBAC, shared approvals, ingestion and organization-wide retention.</div>
|
<div class="notice warning"><strong>Platform boundary</strong><br>Multi-project fleet views, centralized RBAC, shared approvals, ingestion and organization-wide retention.</div>
|
||||||
@@ -200,9 +214,9 @@
|
|||||||
<div class="dossier">
|
<div class="dossier">
|
||||||
<div class="decision-card"><div><p class="eyebrow">Selected control</p><h2>${esc(selected.title)}</h2><p class="panel-description">${esc(selected.description)}</p></div>
|
<div class="decision-card"><div><p class="eyebrow">Selected control</p><h2>${esc(selected.title)}</h2><p class="panel-description">${esc(selected.description)}</p></div>
|
||||||
<div class="inset"><span class="inset-label">Latest decision · ${esc(time(selected.updated_at))}</span><p>${esc(selected.reason)}</p></div>
|
<div class="inset"><span class="inset-label">Latest decision · ${esc(time(selected.updated_at))}</span><p>${esc(selected.reason)}</p></div>
|
||||||
<div class="inset"><span class="inset-label">Event history</span>${timeline}</div>
|
${disclosure("Event history", `${events.length} recent lifecycle events · hidden by default`, timeline)}
|
||||||
</div>
|
</div>
|
||||||
<div class="manifest"><div class="manifest-head"><div><p class="eyebrow">Safe evidence manifest</p><div class="metric-detail">Sanitized fields persisted by the harness</div></div>${pill(`${entries.length} fields`)}</div>${manifest}</div>
|
${disclosure("Safe evidence manifest", "Sanitized fields persisted by the harness · hidden by default", manifest, pill(`${entries.length} fields`))}
|
||||||
</div>`,
|
</div>`,
|
||||||
actions,
|
actions,
|
||||||
);
|
);
|
||||||
@@ -225,6 +239,12 @@
|
|||||||
}).join("");
|
}).join("");
|
||||||
const breakdown = (rows, subject) => `<div class="table-scroll"><table><thead><tr><th>${esc(subject)}</th><th class="right">Runs</th><th class="right">Failures</th><th class="right">Avg latency</th><th class="right">Tokens</th><th class="right">Cost</th></tr></thead>
|
const breakdown = (rows, subject) => `<div class="table-scroll"><table><thead><tr><th>${esc(subject)}</th><th class="right">Runs</th><th class="right">Failures</th><th class="right">Avg latency</th><th class="right">Tokens</th><th class="right">Cost</th></tr></thead>
|
||||||
<tbody>${rows.length ? rows.map((row) => `<tr><td><strong>${esc(row.key)}</strong></td><td class="right mono">${row.runs}</td><td class="right mono">${row.failures}</td><td class="right mono">${number(row.latency_avg_ms)} ms</td><td class="right mono">${number(row.tokens)}</td><td class="right mono">${money(row.cost_usd)}</td></tr>`).join("") : `<tr><td colspan="6">${empty("No matching records", `No ${subject.toLowerCase()} evidence is available.`)}</td></tr>`}</tbody></table></div>`;
|
<tbody>${rows.length ? rows.map((row) => `<tr><td><strong>${esc(row.key)}</strong></td><td class="right mono">${row.runs}</td><td class="right mono">${row.failures}</td><td class="right mono">${number(row.latency_avg_ms)} ms</td><td class="right mono">${number(row.tokens)}</td><td class="right mono">${money(row.cost_usd)}</td></tr>`).join("") : `<tr><td colspan="6">${empty("No matching records", `No ${subject.toLowerCase()} evidence is available.`)}</td></tr>`}</tbody></table></div>`;
|
||||||
|
const outcomes = distribution("Governed run outcome distribution", data.details.by_status.map((row) => ({
|
||||||
|
label: String(row.status).replaceAll("_", " "),
|
||||||
|
value: row.count,
|
||||||
|
detail: `${data.summary.runs ? Math.round((row.count / data.summary.runs) * 100) : 0}% of selected runs`,
|
||||||
|
tone: row.status === "failed" ? "danger" : row.status === "degraded" ? "warning" : "success",
|
||||||
|
})));
|
||||||
$("#h6-view").innerHTML =
|
$("#h6-view").innerHTML =
|
||||||
hero({
|
hero({
|
||||||
eyebrow: "H6 · AgentOps assurance",
|
eyebrow: "H6 · AgentOps assurance",
|
||||||
@@ -234,7 +254,7 @@
|
|||||||
meta: [data.report_id, `Generated ${time(data.generated_at)}`, `Freshness ${data.freshness.status}`],
|
meta: [data.report_id, `Generated ${time(data.generated_at)}`, `Freshness ${data.freshness.status}`],
|
||||||
actions,
|
actions,
|
||||||
}) +
|
}) +
|
||||||
panel("Scope", "Evidence boundary", "Filters apply to the screen and both export formats.", h6Filters(data)) +
|
disclosure("Filter the evidence boundary", "Project, date and trace filters stay out of the executive view until needed.", h6Filters(data)) +
|
||||||
`<section class="metric-grid">
|
`<section class="metric-grid">
|
||||||
${metric("Governed runs", number(data.summary.runs), `${data.summary.success} successful · ${data.summary.degraded} degraded`)}
|
${metric("Governed runs", number(data.summary.runs), `${data.summary.success} successful · ${data.summary.degraded} degraded`)}
|
||||||
${metric("Failure rate", `${data.summary.failure_rate_pct}%`, `${data.summary.failed} failed · ${data.summary.alerts} alerts`)}
|
${metric("Failure rate", `${data.summary.failure_rate_pct}%`, `${data.summary.failed} failed · ${data.summary.alerts} alerts`)}
|
||||||
@@ -243,17 +263,29 @@
|
|||||||
</section>` +
|
</section>` +
|
||||||
`<div class="two-col">
|
`<div class="two-col">
|
||||||
${panel("Decision brief", "What requires attention", "Threshold findings are review signals, not maturity claims.", findings, pill(data.verdict))}
|
${panel("Decision brief", "What requires attention", "Threshold findings are review signals, not maturity claims.", findings, pill(data.verdict))}
|
||||||
${panel("Source integrity", "Evidence provenance", "Freshness is evaluated independently for each canonical source.", `<div class="source-grid">${sources}</div>`, pill(data.freshness.status))}
|
${panel("Outcome mix", "Run distribution", "Canonical runtime status values, normalized against the largest observed group.", outcomes, `<span class="mono metric-detail">${number(data.summary.runs)} total</span>`)}
|
||||||
</div>` +
|
|
||||||
`<div class="breakdown-grid">
|
|
||||||
${panel("Runtime", "Execution profile by step", "Latency, failure and attribution by governed lifecycle step.", breakdown(data.details.by_step, "Step"))}
|
|
||||||
${panel("Provider", "Model usage and provenance", "Only provider-reported token and actual cost values are shown.", breakdown(data.details.by_provider, "Provider · model"))}
|
|
||||||
</div>` +
|
</div>` +
|
||||||
panel("Evidence quality", "Coverage and disclosure", "Unavailable telemetry remains null. Coverage gaps stay visible.", `<div class="two-col">
|
panel("Evidence quality", "Coverage and disclosure", "Unavailable telemetry remains null. Coverage gaps stay visible.", `<div class="two-col">
|
||||||
<div class="coverage">${coverage("Token attribution", data.summary.coverage.token_pct, `${data.summary.coverage.token_records}/${data.summary.coverage.runtime_records} records`)}
|
<div class="coverage">${coverage("Token attribution", data.summary.coverage.token_pct, `${data.summary.coverage.token_records}/${data.summary.coverage.runtime_records} records`)}
|
||||||
${coverage("Cost attribution", data.summary.coverage.cost_pct, `${data.summary.coverage.cost_records}/${data.summary.coverage.runtime_records} records`)}</div>
|
${coverage("Cost attribution", data.summary.coverage.cost_pct, `${data.summary.coverage.cost_records}/${data.summary.coverage.runtime_records} records`)}</div>
|
||||||
<div class="inset"><span class="inset-label">Disclosure notes</span>${data.data_quality.warnings.length ? `<ul>${data.data_quality.warnings.map((warning) => `<li class="metric-detail">${esc(warning)}</li>`).join("")}</ul>` : `<p class="success-copy">All required sources are present with no estimation warning.</p>`}</div>
|
<div class="inset"><span class="inset-label">Disclosure notes</span>${data.data_quality.warnings.length ? `<ul>${data.data_quality.warnings.map((warning) => `<li class="metric-detail">${esc(warning)}</li>`).join("")}</ul>` : `<p class="success-copy">All required sources are present with no estimation warning.</p>`}</div>
|
||||||
</div>`, pill(data.data_quality.status));
|
</div>`, pill(data.data_quality.status)) +
|
||||||
|
disclosure(
|
||||||
|
"Technical evidence and breakdowns",
|
||||||
|
"Provider counters, canonical paths and raw tables are preserved for audit without crowding the decision view.",
|
||||||
|
`<section class="metric-grid compact">
|
||||||
|
${metric("Provider tokens", data.summary.coverage.token_records > 0 ? number(data.summary.tokens.provider_total ?? data.summary.tokens.total) : "Unavailable", `${data.summary.coverage.token_pct}% record coverage`)}
|
||||||
|
${metric("Actual cost", money(data.summary.cost_usd.provider_actual), `${data.summary.coverage.cost_pct}% record coverage`)}
|
||||||
|
${metric("Provider calls", number(data.summary.provider_calls), "Provider-attributed requests")}
|
||||||
|
${metric("Retries", number(data.summary.retries), "Observed reroute attempts")}
|
||||||
|
</section>
|
||||||
|
<div class="technical-section"><p class="eyebrow">Source integrity</p><h3>Evidence provenance</h3><div class="source-grid">${sources}</div></div>
|
||||||
|
<div class="breakdown-grid technical-section">
|
||||||
|
${panel("Runtime", "Execution profile by step", "Latency, failure and attribution by governed lifecycle step.", breakdown(data.details.by_step, "Step"))}
|
||||||
|
${panel("Provider", "Model usage and provenance", "Only provider-reported token and actual cost values are shown.", breakdown(data.details.by_provider, "Provider · model"))}
|
||||||
|
</div>`,
|
||||||
|
pill(data.freshness.status),
|
||||||
|
);
|
||||||
bindH6Filters();
|
bindH6Filters();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -656,6 +656,31 @@ def report_html(report: dict[str, Any], kind: str) -> str:
|
|||||||
("Data quality", report.get("data_quality", {}).get("status")),
|
("Data quality", report.get("data_quality", {}).get("status")),
|
||||||
]
|
]
|
||||||
findings = report.get("findings", [])
|
findings = report.get("findings", [])
|
||||||
|
statuses = report.get("details", {}).get("by_status", [])
|
||||||
|
maximum = max(
|
||||||
|
[float(item.get("count", 0) or 0) for item in statuses] or [1]
|
||||||
|
)
|
||||||
|
total_runs = float(summary.get("runs", 0) or 0)
|
||||||
|
outcome_rows = "".join(
|
||||||
|
'<li><div><span><strong>%s</strong><small>%s%% of selected runs</small></span><b>%s</b></div>'
|
||||||
|
'<i><em class="%s" style="width:%s%%"></em></i></li>' % (
|
||||||
|
escape(_text(item.get("status"), "unknown").replace("_", " ")),
|
||||||
|
round((float(item.get("count", 0) or 0) / total_runs) * 100)
|
||||||
|
if total_runs else 0,
|
||||||
|
escape(str(int(float(item.get("count", 0) or 0)))),
|
||||||
|
"fail" if item.get("status") == "failed" else
|
||||||
|
"attention" if item.get("status") == "degraded" else "pass",
|
||||||
|
max((float(item.get("count", 0) or 0) / maximum) * 100, 3)
|
||||||
|
if float(item.get("count", 0) or 0) else 0,
|
||||||
|
)
|
||||||
|
for item in statuses
|
||||||
|
)
|
||||||
|
outcomes = (
|
||||||
|
'<section class="panel"><h2>Run distribution</h2>'
|
||||||
|
'<p class="sub">Canonical runtime status values, normalized against the largest observed group.</p>'
|
||||||
|
f'<ol class="distribution">{outcome_rows}</ol></section>'
|
||||||
|
if outcome_rows else ""
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
summary = report.get("summary", {})
|
summary = report.get("summary", {})
|
||||||
metrics = [
|
metrics = [
|
||||||
@@ -668,6 +693,7 @@ def report_html(report: dict[str, Any], kind: str) -> str:
|
|||||||
{"code": gate.get("title"), "severity": gate.get("status"), "message": gate.get("reason")}
|
{"code": gate.get("title"), "severity": gate.get("status"), "message": gate.get("reason")}
|
||||||
for gate in report.get("gates", [])
|
for gate in report.get("gates", [])
|
||||||
]
|
]
|
||||||
|
outcomes = ""
|
||||||
cards = "".join(
|
cards = "".join(
|
||||||
f'<div class="metric"><span>{escape(str(label))}</span><strong>{escape(str(value if value is not None else "Unavailable"))}</strong></div>'
|
f'<div class="metric"><span>{escape(str(label))}</span><strong>{escape(str(value if value is not None else "Unavailable"))}</strong></div>'
|
||||||
for label, value in metrics
|
for label, value in metrics
|
||||||
@@ -692,10 +718,11 @@ main{{max-width:1060px;margin:auto;padding:44px 24px 72px}}header{{border-radius
|
|||||||
.grid{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin:18px 0}}.metric,.panel{{border:1px solid var(--line);border-radius:18px;background:white;padding:20px;box-shadow:0 10px 30px #10182808}}
|
.grid{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin:18px 0}}.metric,.panel{{border:1px solid var(--line);border-radius:18px;background:white;padding:20px;box-shadow:0 10px 30px #10182808}}
|
||||||
.metric span{{display:block;color:var(--muted)}}.metric strong{{display:block;margin-top:16px;font-size:24px;letter-spacing:-.03em}}.panel{{margin-top:18px;padding:26px}}h2{{margin:0 0 6px;font-size:20px}}.sub{{margin:0 0 20px;color:var(--muted);font-size:14px}}
|
.metric span{{display:block;color:var(--muted)}}.metric strong{{display:block;margin-top:16px;font-size:24px;letter-spacing:-.03em}}.panel{{margin-top:18px;padding:26px}}h2{{margin:0 0 6px;font-size:20px}}.sub{{margin:0 0 20px;color:var(--muted);font-size:14px}}
|
||||||
.finding{{border-left:4px solid var(--cyan);border-radius:10px;background:#f8fafc;padding:14px 16px;margin-top:10px}}.finding div{{display:flex;justify-content:space-between;gap:16px}}code{{font-weight:800}}em{{color:var(--muted);font-size:11px;text-transform:uppercase}}.finding p{{margin:8px 0 0;color:#344054;font-size:14px;line-height:1.55}}.clear{{border-radius:12px;background:#ecfdf3;color:var(--green);padding:16px}}
|
.finding{{border-left:4px solid var(--cyan);border-radius:10px;background:#f8fafc;padding:14px 16px;margin-top:10px}}.finding div{{display:flex;justify-content:space-between;gap:16px}}code{{font-weight:800}}em{{color:var(--muted);font-size:11px;text-transform:uppercase}}.finding p{{margin:8px 0 0;color:#344054;font-size:14px;line-height:1.55}}.clear{{border-radius:12px;background:#ecfdf3;color:var(--green);padding:16px}}
|
||||||
|
.distribution{{display:grid;gap:16px;margin:20px 0 0;padding:0;list-style:none}}.distribution li>div{{display:flex;align-items:end;justify-content:space-between;gap:16px}}.distribution span strong,.distribution span small{{display:block}}.distribution span strong{{font-size:13px;text-transform:capitalize}}.distribution span small{{margin-top:4px;color:var(--muted);font-size:11px}}.distribution b{{font:700 12px ui-monospace,monospace}}.distribution i{{display:block;overflow:hidden;height:8px;margin-top:8px;border-radius:99px;background:#eaecf0}}.distribution em{{display:block;height:100%;border-radius:inherit;background:var(--cyan)}}.distribution em.pass{{background:#12b76a}}.distribution em.attention{{background:#f79009}}.distribution em.fail{{background:#f04438}}
|
||||||
details{{margin-top:18px}}summary{{cursor:pointer;font-weight:700}}pre{{overflow:auto;max-height:720px;border-radius:14px;background:#101828;color:#d0d5dd;padding:20px;font:11px/1.6 ui-monospace,monospace}}
|
details{{margin-top:18px}}summary{{cursor:pointer;font-weight:700}}pre{{overflow:auto;max-height:720px;border-radius:14px;background:#101828;color:#d0d5dd;padding:20px;font:11px/1.6 ui-monospace,monospace}}
|
||||||
footer{{margin-top:22px;color:var(--muted);font-size:11px}}@media(max-width:760px){{h1{{font-size:32px}}.grid{{grid-template-columns:1fr 1fr}}header{{padding:28px}}}}@media print{{body{{background:white}}main{{max-width:none;padding:0}}header,.panel,.metric{{box-shadow:none}}details{{break-before:page}}}}
|
footer{{margin-top:22px;color:var(--muted);font-size:11px}}@media(max-width:760px){{h1{{font-size:32px}}.grid{{grid-template-columns:1fr 1fr}}header{{padding:28px}}}}@media print{{body{{background:white}}main{{max-width:none;padding:0}}header,.panel,.metric{{box-shadow:none}}details{{break-before:page}}}}
|
||||||
</style></head><body><main><header><div class="eyebrow">CASAN · Independent assurance dossier</div><h1>{escape(str(title))}</h1>
|
</style></head><body><main><header><div class="eyebrow">CASAN · Independent assurance dossier</div><h1>{escape(str(title))}</h1>
|
||||||
<div class="meta"><span>{escape(str(report.get("report_id", "")))}</span><span>Generated {escape(str(report.get("generated_at", "")))}</span></div><div class="badge">Verdict · {escape(verdict)}</div></header>
|
<div class="meta"><span>{escape(str(report.get("report_id", "")))}</span><span>Generated {escape(str(report.get("generated_at", "")))}</span></div><div class="badge">Verdict · {escape(verdict)}</div></header>
|
||||||
<section class="grid">{cards}</section><section class="panel"><h2>Decision evidence</h2><p class="sub">Threshold findings and control outcomes from canonical local evidence.</p>{rows}</section>
|
<section class="grid">{cards}</section><section class="panel"><h2>Decision evidence</h2><p class="sub">Threshold findings and control outcomes from canonical local evidence.</p>{rows}</section>{outcomes}
|
||||||
<section class="panel"><h2>Machine-auditable contract</h2><p class="sub">The complete sanitized report payload used to render this dossier.</p><details><summary>Open JSON evidence</summary><pre>{contract}</pre></details></section>
|
<section class="panel"><h2>Machine-auditable contract</h2><p class="sub">The complete sanitized report payload used to render this dossier.</p><details><summary>Open JSON evidence</summary><pre>{contract}</pre></details></section>
|
||||||
<footer>Generated on demand by CASAN Core Local Assurance Viewer · read-only · offline-capable</footer></main></body></html>"""
|
<footer>Generated on demand by CASAN Core Local Assurance Viewer · read-only · offline-capable</footer></main></body></html>"""
|
||||||
|
|||||||
@@ -139,6 +139,15 @@ class LocalReportTests(unittest.TestCase):
|
|||||||
self.assertIn("<script>alert(1)</script>", html)
|
self.assertIn("<script>alert(1)</script>", html)
|
||||||
self.assertIn("Machine-auditable contract", html)
|
self.assertIn("Machine-auditable contract", html)
|
||||||
|
|
||||||
|
def test_h6_html_visualizes_outcomes_and_hides_raw_contract(self):
|
||||||
|
self.write_run()
|
||||||
|
report = REPORT.h6_report(self.root, run="trace-1")
|
||||||
|
html = REPORT.report_html(report, "h6")
|
||||||
|
self.assertIn("Run distribution", html)
|
||||||
|
self.assertIn('class="distribution"', html)
|
||||||
|
self.assertIn("<details><summary>Open JSON evidence</summary>", html)
|
||||||
|
self.assertNotIn("None</strong>", html)
|
||||||
|
|
||||||
def test_unsafe_trace_id_never_reads_outside_project(self):
|
def test_unsafe_trace_id_never_reads_outside_project(self):
|
||||||
report = REPORT.run_report(self.root, "../../etc/passwd")
|
report = REPORT.run_report(self.root, "../../etc/passwd")
|
||||||
self.assertEqual(report["verdict"], "not_found")
|
self.assertEqual(report["verdict"], "not_found")
|
||||||
|
|||||||
Reference in New Issue
Block a user