feat: ship production local assurance viewer in core
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
:root {
|
||||
--ink: #101828;
|
||||
--muted: #667085;
|
||||
--faint: #98a2b3;
|
||||
--line: #e4e7ec;
|
||||
--paper: #f6f7f9;
|
||||
--white: #fff;
|
||||
--navy: #101828;
|
||||
--navy-2: #1d2939;
|
||||
--cyan: #0891b2;
|
||||
--cyan-soft: #ecfeff;
|
||||
--green: #067647;
|
||||
--green-soft: #ecfdf3;
|
||||
--amber: #b54708;
|
||||
--amber-soft: #fffaeb;
|
||||
--rose: #b42318;
|
||||
--rose-soft: #fef3f2;
|
||||
--shadow: 0 12px 36px rgba(16, 24, 40, 0.06);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
button, input, select { font: inherit; }
|
||||
button, a { -webkit-tap-highlight-color: transparent; }
|
||||
button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible, summary:focus-visible {
|
||||
outline: 3px solid rgba(6, 182, 212, 0.28);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.hidden { display: none !important; }
|
||||
.skip-link {
|
||||
position: fixed; z-index: 100; left: 16px; top: -60px; padding: 10px 14px;
|
||||
border-radius: 10px; background: white; color: var(--ink); font-weight: 700;
|
||||
}
|
||||
.skip-link:focus { top: 16px; }
|
||||
.sidebar {
|
||||
position: fixed; inset: 0 auto 0 0; z-index: 30; width: 244px; display: flex;
|
||||
flex-direction: column; border-right: 1px solid #253044; background: var(--navy);
|
||||
color: white; padding: 22px 16px;
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 12px; color: white; text-decoration: none; padding: 4px 8px 24px; }
|
||||
.brand-mark {
|
||||
display: grid; place-items: center; width: 38px; height: 38px; border: 1px solid #344054;
|
||||
border-radius: 12px; background: #172033; color: #67e8f9; font: 800 16px ui-monospace, monospace;
|
||||
}
|
||||
.brand strong, .brand small { display: block; }
|
||||
.brand strong { font-size: 14px; letter-spacing: .08em; }
|
||||
.brand small { margin-top: 3px; color: #98a2b3; font-size: 10px; letter-spacing: .06em; text-transform: uppercase; }
|
||||
.sidebar nav { display: grid; gap: 5px; }
|
||||
.nav-item {
|
||||
display: grid; grid-template-columns: 30px 1fr; align-items: center; min-height: 46px; width: 100%;
|
||||
border: 1px solid transparent; border-radius: 12px; background: transparent; color: #98a2b3;
|
||||
padding: 8px 12px; text-align: left; font-size: 13px; font-weight: 650; cursor: pointer; transition: .18s ease;
|
||||
}
|
||||
.nav-item span { color: #667085; font: 700 11px ui-monospace, monospace; }
|
||||
.nav-item:hover { background: #172033; color: white; }
|
||||
.nav-item.active { border-color: #344054; background: #1d2939; color: white; }
|
||||
.nav-item.active span { color: #67e8f9; }
|
||||
.local-seal {
|
||||
display: grid; grid-template-columns: 8px 1fr; gap: 10px; align-items: start; margin-top: auto;
|
||||
border: 1px solid #344054; border-radius: 14px; background: #172033; padding: 13px;
|
||||
}
|
||||
.local-seal strong, .local-seal small { display: block; }
|
||||
.local-seal strong { font-size: 11px; }
|
||||
.local-seal small { margin-top: 4px; color: #667085; font: 10px ui-monospace, monospace; }
|
||||
.live-dot { width: 7px; height: 7px; margin-top: 3px; border-radius: 50%; background: #32d583; box-shadow: 0 0 0 4px rgba(50,213,131,.12); }
|
||||
.shell { margin-left: 244px; min-height: 100vh; }
|
||||
.topbar {
|
||||
position: sticky; top: 0; z-index: 20; display: flex; align-items: center; min-height: 76px;
|
||||
border-bottom: 1px solid var(--line); background: rgba(255,255,255,.92); padding: 12px clamp(20px,4vw,48px);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
.topbar h1 { margin: 2px 0 0; font-size: 18px; letter-spacing: -.02em; }
|
||||
.top-eyebrow { margin: 0; color: var(--faint); font-size: 9px; font-weight: 800; letter-spacing: .16em; text-transform: uppercase; }
|
||||
.top-actions { display: flex; align-items: center; gap: 9px; margin-left: auto; }
|
||||
.icon-button {
|
||||
display: grid; place-items: center; min-width: 44px; min-height: 44px; border: 1px solid var(--line);
|
||||
border-radius: 12px; background: white; color: #475467; cursor: pointer; transition: .18s ease;
|
||||
}
|
||||
.icon-button:hover { border-color: #98a2b3; transform: translateY(-1px); }
|
||||
.menu-button { display: none; margin-right: 12px; }
|
||||
main { max-width: 1500px; margin: 0 auto; padding: 28px clamp(20px,4vw,48px) 64px; }
|
||||
.view { display: grid; gap: 18px; }
|
||||
.hero {
|
||||
position: relative; overflow: hidden; border-radius: 28px; background: var(--navy); color: white;
|
||||
padding: clamp(28px,5vw,48px); box-shadow: 0 24px 70px rgba(16,24,40,.16);
|
||||
}
|
||||
.hero::after {
|
||||
content: ""; position: absolute; width: 360px; height: 360px; right: -160px; top: -230px;
|
||||
border: 1px solid rgba(103,232,249,.15); border-radius: 50%; box-shadow: 0 0 0 60px rgba(103,232,249,.025), 0 0 0 120px rgba(103,232,249,.02);
|
||||
}
|
||||
.hero-grid { position: relative; z-index: 1; display: grid; grid-template-columns: minmax(0,1fr) auto; gap: 28px; align-items: end; }
|
||||
.eyebrow { margin: 0; color: var(--cyan); font-size: 10px; font-weight: 850; letter-spacing: .18em; text-transform: uppercase; }
|
||||
.hero .eyebrow { color: #67e8f9; }
|
||||
.hero h2 { max-width: 760px; margin: 13px 0 12px; font-size: clamp(30px,4vw,48px); line-height: 1.02; letter-spacing: -.045em; }
|
||||
.hero-description { max-width: 760px; margin: 0; color: #98a2b3; font-size: 14px; line-height: 1.7; }
|
||||
.hero-meta { display: flex; flex-wrap: wrap; gap: 8px 18px; margin-top: 24px; color: #667085; font: 10px ui-monospace, monospace; }
|
||||
.hero-actions { display: flex; flex-wrap: wrap; gap: 9px; justify-content: flex-end; }
|
||||
.trace-hero { border-radius: 18px; padding: 20px 22px; box-shadow: none; }
|
||||
.trace-title { margin: 8px 0 !important; font-size: 18px !important; }
|
||||
.trace-meta { margin-top: 8px; }
|
||||
.rail-spaced { margin-top: 20px; }
|
||||
.button {
|
||||
display: inline-flex; align-items: center; justify-content: center; min-height: 44px; border: 1px solid transparent;
|
||||
border-radius: 12px; padding: 9px 15px; text-decoration: none; font-size: 12px; font-weight: 750; cursor: pointer; transition: .18s ease;
|
||||
}
|
||||
.button:hover { transform: translateY(-1px); }
|
||||
.button.primary { background: #67e8f9; color: var(--navy); }
|
||||
.button.primary:hover { background: #a5f3fc; }
|
||||
.button.secondary { border-color: #344054; background: #1d2939; color: #e4e7ec; }
|
||||
.button.light { border-color: #d0d5dd; background: white; color: #344054; }
|
||||
.pill {
|
||||
display: inline-flex; align-items: center; min-height: 28px; border-radius: 999px; padding: 5px 10px;
|
||||
font-size: 9px; font-weight: 850; letter-spacing: .1em; text-transform: uppercase; white-space: nowrap;
|
||||
}
|
||||
.pill.pass, .pill.certified, .pill.success, .pill.fresh, .pill.operational { background: var(--green-soft); color: var(--green); }
|
||||
.pill.warning, .pill.attention, .pill.partial, .pill.stale, .pill.in-progress, .pill.in_progress { background: var(--amber-soft); color: var(--amber); }
|
||||
.pill.error, .pill.failed, .pill.fail, .pill.blocked, .pill.non-certified, .pill.non_certified { background: var(--rose-soft); color: var(--rose); }
|
||||
.pill.neutral, .pill.queued, .pill.unknown, .pill.no-data, .pill.no_data { background: #f2f4f7; color: #475467; }
|
||||
.hero .pill { border: 1px solid #344054; background: #1d2939; color: #d0d5dd; }
|
||||
.metric-grid { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 14px; }
|
||||
.metric-card, .panel {
|
||||
border: 1px solid var(--line); border-radius: 18px; background: white; box-shadow: var(--shadow);
|
||||
}
|
||||
.metric-card { min-height: 136px; padding: 19px; }
|
||||
.metric-label { color: var(--faint); font-size: 9px; font-weight: 850; letter-spacing: .14em; text-transform: uppercase; }
|
||||
.metric-value { margin-top: 18px; font-size: 27px; font-weight: 720; letter-spacing: -.04em; }
|
||||
.metric-detail { margin-top: 7px; color: var(--muted); font-size: 11px; line-height: 1.45; }
|
||||
.panel { overflow: hidden; }
|
||||
.panel-head { display: flex; justify-content: space-between; gap: 20px; align-items: start; padding: 23px 24px 18px; }
|
||||
.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-body { border-top: 1px solid #f2f4f7; padding: 22px 24px 24px; }
|
||||
.two-col { display: grid; grid-template-columns: minmax(0,1.2fr) minmax(330px,.8fr); gap: 18px; }
|
||||
.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; }
|
||||
.gate-button {
|
||||
position: relative; min-height: 92px; border: 1px solid var(--line); border-radius: 14px; background: #fafafa;
|
||||
padding: 13px; color: #344054; text-align: left; cursor: pointer; transition: .18s ease;
|
||||
}
|
||||
.gate-button::after { content: ""; position: absolute; top: 27px; right: -8px; width: 8px; height: 1px; background: #d0d5dd; }
|
||||
.gate-button:last-child::after { display: none; }
|
||||
.gate-button:hover { border-color: #98a2b3; transform: translateY(-1px); }
|
||||
.gate-button.selected { border-color: var(--cyan); background: var(--cyan-soft); box-shadow: 0 0 0 3px rgba(6,182,212,.09); }
|
||||
.gate-index { color: var(--faint); font: 800 9px ui-monospace, monospace; letter-spacing: .1em; }
|
||||
.gate-name { display: block; margin-top: 14px; font-size: 11px; font-weight: 750; }
|
||||
.gate-state { display: flex; align-items: center; gap: 6px; margin-top: 8px; color: var(--muted); font-size: 9px; text-transform: uppercase; }
|
||||
.state-dot { width: 7px; height: 7px; border-radius: 50%; background: #98a2b3; }
|
||||
.state-dot.pass { background: #12b76a; }.state-dot.warning { background: #f79009; }.state-dot.error,.state-dot.blocked { background: #f04438; }.state-dot.running { background: #06b6d4; }
|
||||
.dossier { display: grid; grid-template-columns: minmax(0,.9fr) minmax(360px,1.1fr); gap: 18px; margin-top: 20px; }
|
||||
.decision-card { display: grid; gap: 12px; }
|
||||
.inset { border: 1px solid var(--line); border-radius: 14px; background: #f9fafb; padding: 15px; }
|
||||
.inset-label { color: var(--faint); font-size: 9px; font-weight: 850; letter-spacing: .13em; text-transform: uppercase; }
|
||||
.inset p { margin: 9px 0 0; color: #344054; font-size: 12px; line-height: 1.6; }
|
||||
.manifest { overflow: hidden; border: 1px solid #1d2939; border-radius: 16px; background: var(--navy); color: #e4e7ec; }
|
||||
.manifest-head { display: flex; justify-content: space-between; gap: 16px; padding: 16px 18px; border-bottom: 1px solid #1d2939; }
|
||||
.manifest-head .eyebrow { color: #67e8f9; }
|
||||
.manifest dl { margin: 0; }
|
||||
.manifest-row { display: grid; grid-template-columns: 140px minmax(0,1fr); gap: 16px; border-bottom: 1px solid #1d2939; padding: 12px 18px; }
|
||||
.manifest-row:last-child { border-bottom: 0; }
|
||||
.manifest dt { color: #667085; font: 750 9px ui-monospace,monospace; letter-spacing: .07em; text-transform: uppercase; }
|
||||
.manifest dd { margin: 0; overflow-wrap: anywhere; color: #d0d5dd; font: 11px/1.5 ui-monospace,monospace; }
|
||||
.timeline { display: grid; gap: 10px; margin-top: 12px; }
|
||||
.timeline-item { display: grid; grid-template-columns: 8px minmax(0,1fr); gap: 10px; }
|
||||
.timeline-line { display: flex; flex-direction: column; align-items: center; }
|
||||
.timeline-line .state-dot { margin-top: 5px; }
|
||||
.timeline-line::after { content:""; width: 1px; flex: 1; margin-top: 4px; background: var(--line); }
|
||||
.timeline-item:last-child .timeline-line::after { display: none; }
|
||||
.timeline-copy { padding-bottom: 7px; }
|
||||
.timeline-copy strong { font-size: 11px; text-transform: capitalize; }
|
||||
.timeline-copy time { float: right; color: var(--faint); font: 9px ui-monospace,monospace; }
|
||||
.timeline-copy p { margin: 4px 0 0; color: var(--muted); font-size: 11px; line-height: 1.5; }
|
||||
.table-scroll { overflow-x: auto; }
|
||||
table { width: 100%; min-width: 780px; border-collapse: collapse; font-size: 12px; }
|
||||
th { background: #f9fafb; color: var(--faint); padding: 11px 15px; text-align: left; font-size: 9px; letter-spacing: .12em; text-transform: uppercase; }
|
||||
td { border-top: 1px solid #f2f4f7; padding: 14px 15px; color: #344054; }
|
||||
tbody tr { transition: background .15s ease; }
|
||||
tbody tr:hover { background: #f0fdfa; }
|
||||
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 10px; }
|
||||
.right { text-align: right; }
|
||||
.empty { padding: 52px 20px; color: var(--muted); text-align: center; }
|
||||
.empty-mark { display: grid; place-items: center; width: 42px; height: 42px; margin: 0 auto 13px; border: 1px solid var(--line); border-radius: 50%; color: var(--faint); }
|
||||
.notice { border: 1px solid; border-radius: 14px; padding: 15px 18px; font-size: 12px; line-height: 1.55; }
|
||||
.notice.warning { border-color: #fedf89; background: var(--amber-soft); color: var(--amber); }
|
||||
.notice.error { border-color: #fecdca; background: var(--rose-soft); color: var(--rose); }
|
||||
.notice.success { border-color: #abefc6; background: var(--green-soft); color: var(--green); }
|
||||
.findings { display: grid; gap: 10px; }
|
||||
.finding { display: grid; grid-template-columns: 30px minmax(0,1fr); gap: 12px; border-left: 4px solid var(--cyan); border-radius: 11px; background: #f9fafb; padding: 14px; }
|
||||
.finding.critical { border-color: #f04438; background: var(--rose-soft); }
|
||||
.finding.warning { border-color: #f79009; background: var(--amber-soft); }
|
||||
.finding-index { color: var(--faint); font: 700 14px ui-monospace,monospace; }
|
||||
.finding code { font-size: 10px; font-weight: 800; color: #344054; }
|
||||
.finding p { margin: 6px 0 0; color: var(--muted); font-size: 11px; line-height: 1.55; }
|
||||
.source-grid { display: grid; gap: 10px; }
|
||||
.source { border: 1px solid var(--line); border-radius: 13px; padding: 14px; }
|
||||
.source-top { display: flex; justify-content: space-between; gap: 12px; }
|
||||
.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 { margin-top: 5px; }.source-path { overflow-wrap:anywhere; border-top: 1px solid #f2f4f7; margin-top: 12px; padding-top: 10px; }
|
||||
.coverage { display: grid; gap: 18px; }
|
||||
.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-fill { height: 100%; border-radius: inherit; background: var(--cyan); }
|
||||
.coverage-fill.width-0 { width: 0; }.coverage-fill.width-5 { width: 5%; }
|
||||
.coverage-fill.width-10 { width: 10%; }.coverage-fill.width-15 { width: 15%; }
|
||||
.coverage-fill.width-20 { width: 20%; }.coverage-fill.width-25 { width: 25%; }
|
||||
.coverage-fill.width-30 { width: 30%; }.coverage-fill.width-35 { width: 35%; }
|
||||
.coverage-fill.width-40 { width: 40%; }.coverage-fill.width-45 { width: 45%; }
|
||||
.coverage-fill.width-50 { width: 50%; }.coverage-fill.width-55 { width: 55%; }
|
||||
.coverage-fill.width-60 { width: 60%; }.coverage-fill.width-65 { width: 65%; }
|
||||
.coverage-fill.width-70 { width: 70%; }.coverage-fill.width-75 { width: 75%; }
|
||||
.coverage-fill.width-80 { width: 80%; }.coverage-fill.width-85 { width: 85%; }
|
||||
.coverage-fill.width-90 { width: 90%; }.coverage-fill.width-95 { width: 95%; }
|
||||
.coverage-fill.width-100 { width: 100%; }
|
||||
.success-copy { color: var(--green); }
|
||||
.filters { display: grid; grid-template-columns: 1fr 1fr 1fr 1.5fr auto; gap: 12px; align-items: end; }
|
||||
.field span { display: block; margin-bottom: 7px; color: var(--faint); font-size: 9px; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; }
|
||||
.field input, .field select { width: 100%; min-height: 44px; border: 1px solid #d0d5dd; border-radius: 11px; background: white; padding: 8px 11px; color: var(--ink); font-size: 12px; }
|
||||
.field input:hover, .field select:hover { border-color: #98a2b3; }
|
||||
.filter-actions { display: flex; gap: 7px; }
|
||||
.breakdown-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
|
||||
.loading-state { display: grid; gap: 18px; }
|
||||
.skeleton { position: relative; overflow: hidden; border-radius: 20px; background: #eaecf0; }
|
||||
.skeleton::after { content:""; position:absolute; inset:0; transform:translateX(-100%); background:linear-gradient(90deg,transparent,#ffffff80,transparent); animation: shimmer 1.4s infinite; }
|
||||
.hero-skeleton { height: 280px; }.metric-skeleton { height: 136px; }
|
||||
@keyframes shimmer { 100% { transform: translateX(100%); } }
|
||||
.nav-scrim { position: fixed; z-index: 25; inset: 0; background: rgba(16,24,40,.5); }
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
.metric-grid { grid-template-columns: repeat(2,1fr); }
|
||||
.two-col, .breakdown-grid, .dossier { grid-template-columns: 1fr; }
|
||||
.hero-grid { grid-template-columns: 1fr; }
|
||||
.hero-actions { justify-content: flex-start; }
|
||||
.panel-head { flex-wrap: wrap; }
|
||||
.filters { grid-template-columns: 1fr 1fr; }
|
||||
.filter-actions { grid-column: 1/-1; }
|
||||
}
|
||||
@media (max-width: 820px) {
|
||||
.sidebar { transform: translateX(-100%); transition: transform .2s ease; box-shadow: 18px 0 50px rgba(16,24,40,.28); }
|
||||
.sidebar.open { transform: translateX(0); }
|
||||
.shell { margin-left: 0; }
|
||||
.menu-button { display: grid; }
|
||||
.hero-grid { grid-template-columns: 1fr; }
|
||||
.hero-actions { justify-content: flex-start; }
|
||||
.dossier { grid-template-columns: 1fr; }
|
||||
.top-actions .pill { display: none; }
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
main { padding: 18px 14px 52px; }
|
||||
.topbar { padding: 10px 14px; }
|
||||
.metric-grid, .filters { grid-template-columns: 1fr; }
|
||||
.hero { border-radius: 20px; padding: 25px 20px; }
|
||||
.hero h2 { font-size: 31px; }
|
||||
.panel-head, .panel-body { padding-left: 17px; padding-right: 17px; }
|
||||
.manifest-row { grid-template-columns: 1fr; gap: 5px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
|
||||
}
|
||||
@media print {
|
||||
.sidebar, .topbar { display: none; }
|
||||
.shell { margin: 0; }
|
||||
main { max-width: none; padding: 0; }
|
||||
.hero, .metric-card, .panel { box-shadow: none; break-inside: avoid; }
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
const suppliedToken = params.get("token");
|
||||
if (suppliedToken) sessionStorage.setItem("casan_viewer_token", suppliedToken);
|
||||
const token = suppliedToken || sessionStorage.getItem("casan_viewer_token") || "";
|
||||
params.delete("token");
|
||||
history.replaceState({}, "", `${location.pathname}${params.size ? `?${params}` : ""}${location.hash}`);
|
||||
|
||||
const state = {
|
||||
view: params.get("view") || (params.get("trace") ? "runs" : "overview"),
|
||||
trace: params.get("trace") || "",
|
||||
project: null,
|
||||
registry: null,
|
||||
h6: null,
|
||||
run: null,
|
||||
selectedGate: "H1-context",
|
||||
};
|
||||
const $ = (selector) => document.querySelector(selector);
|
||||
const esc = (value) => String(value ?? "")
|
||||
.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">")
|
||||
.replaceAll('"', """).replaceAll("'", "'");
|
||||
const slug = (value) => String(value || "unknown").toLowerCase().replaceAll("_", "-").replace(/[^a-z0-9-]/g, "");
|
||||
const text = (value, fallback = "Unavailable") => value === null || value === undefined || value === "" ? fallback : String(value);
|
||||
const number = (value) => value === null || value === undefined || !Number.isFinite(Number(value))
|
||||
? "Unavailable" : new Intl.NumberFormat("en-US").format(Number(value));
|
||||
const time = (value) => value ? String(value).replace("T", " ").replace("Z", " UTC") : "No timestamp";
|
||||
const money = (value) => value === null || value === undefined ? "Unavailable" : `$${Number(value).toFixed(6)}`;
|
||||
const pill = (value) => `<span class="pill ${slug(value)}">${esc(String(value || "unknown").replaceAll("_", " "))}</span>`;
|
||||
|
||||
async function api(path) {
|
||||
const response = await fetch(path, {
|
||||
headers: { "X-CASAN-Viewer-Token": token },
|
||||
cache: "no-store",
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload.error || `HTTP ${response.status}`);
|
||||
return payload;
|
||||
}
|
||||
|
||||
function downloadUrl(path, query = {}) {
|
||||
const next = new URLSearchParams({ ...query, token });
|
||||
return `${path}?${next}`;
|
||||
}
|
||||
|
||||
function hero({ eyebrow, title, description, verdict, meta = [], actions = "" }) {
|
||||
return `<article class="hero">
|
||||
<div class="hero-grid">
|
||||
<div>
|
||||
<p class="eyebrow">${esc(eyebrow)}</p>
|
||||
<h2>${esc(title)}</h2>
|
||||
<p class="hero-description">${esc(description)}</p>
|
||||
<div class="hero-meta">${meta.map((item) => `<span>${esc(item)}</span>`).join("")}</div>
|
||||
</div>
|
||||
<div class="hero-actions">${verdict ? pill(verdict) : ""}${actions}</div>
|
||||
</div>
|
||||
</article>`;
|
||||
}
|
||||
|
||||
function metric(label, value, detail) {
|
||||
return `<article class="metric-card"><div class="metric-label">${esc(label)}</div>
|
||||
<div class="metric-value">${esc(text(value))}</div><div class="metric-detail">${esc(detail)}</div></article>`;
|
||||
}
|
||||
|
||||
function panel(eyebrow, title, description, body, right = "") {
|
||||
return `<article class="panel"><header class="panel-head"><div><p class="eyebrow">${esc(eyebrow)}</p>
|
||||
<h2>${esc(title)}</h2><p class="panel-description">${esc(description)}</p></div>${right}</header>
|
||||
<div class="panel-body">${body}</div></article>`;
|
||||
}
|
||||
|
||||
function empty(title, detail) {
|
||||
return `<div class="empty"><div class="empty-mark">∅</div><strong>${esc(title)}</strong><p>${esc(detail)}</p></div>`;
|
||||
}
|
||||
|
||||
function setView(view, push = true) {
|
||||
state.view = ["overview", "runs", "h6"].includes(view) ? view : "overview";
|
||||
document.querySelectorAll(".nav-item").forEach((item) => {
|
||||
const active = item.dataset.view === state.view;
|
||||
item.classList.toggle("active", active);
|
||||
if (active) item.setAttribute("aria-current", "page");
|
||||
else item.removeAttribute("aria-current");
|
||||
});
|
||||
document.querySelectorAll(".view").forEach((item) => item.classList.add("hidden"));
|
||||
$(`#${state.view}-view`).classList.remove("hidden");
|
||||
$("#page-title").textContent = {
|
||||
overview: "Assurance overview", runs: "Run assurance registry", h6: "H6 AgentOps dossier",
|
||||
}[state.view];
|
||||
if (push) {
|
||||
const next = new URL(location.href);
|
||||
next.searchParams.set("view", state.view);
|
||||
if (state.trace) next.searchParams.set("trace", state.trace);
|
||||
else next.searchParams.delete("trace");
|
||||
history.pushState({}, "", `${next.pathname}?${next.searchParams}`);
|
||||
}
|
||||
if (state.view === "runs") renderRuns();
|
||||
if (state.view === "h6") renderH6();
|
||||
closeNav();
|
||||
}
|
||||
|
||||
function renderOverview() {
|
||||
const profile = state.project;
|
||||
const registry = state.registry;
|
||||
const latest = registry.latest || {};
|
||||
const recent = registry.runs || [];
|
||||
const failures = recent.filter((row) => row.status === "failed").length;
|
||||
const certified = latest.certified === true;
|
||||
const latestTrace = latest.trace_id || recent[0]?.trace_id;
|
||||
const maturity = profile.maturity || {};
|
||||
const actions = latestTrace
|
||||
? `<button class="button primary" type="button" data-open-trace="${esc(latestTrace)}">Inspect latest run</button>
|
||||
<button class="button secondary" type="button" data-open-h6="${esc(latestTrace)}">Open H6</button>`
|
||||
: `<button class="button secondary" type="button" data-view-target="runs">View registry</button>`;
|
||||
$("#overview-view").innerHTML =
|
||||
hero({
|
||||
eyebrow: "CASAN Core · Local assurance",
|
||||
title: latestTrace ? "One prompt. One evidence trail." : "Ready for the first governed run.",
|
||||
description: "A read-only, offline-capable assurance workspace reconstructed from canonical project evidence. No Platform service or HTML generation on the prompt hot path.",
|
||||
verdict: latestTrace ? (certified ? "certified" : "review required") : "ready",
|
||||
meta: [
|
||||
`${profile.project_name} · ${profile.edition}`,
|
||||
latestTrace ? `Trace ${latestTrace}` : "No finalized trace yet",
|
||||
`Maturity ${maturity.level == null ? "not assessed" : `L${maturity.level}`} · ${maturity.status}`,
|
||||
],
|
||||
actions,
|
||||
}) +
|
||||
`<section class="metric-grid">
|
||||
${metric("Governed records", number(registry.count), `${recent.length} recent records loaded`)}
|
||||
${metric("Latest H6 quality", latest.telemetry_quality || "Unavailable", "Missing values are disclosed, never coerced to zero")}
|
||||
${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")}
|
||||
</section>` +
|
||||
panel(
|
||||
"Product boundary",
|
||||
"Core is visual by default",
|
||||
"The local viewer covers the complete single-project review workflow; centralized operations remain a Platform capability.",
|
||||
`<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 warning"><strong>Platform boundary</strong><br>Multi-project fleet views, centralized RBAC, shared approvals, ingestion and organization-wide retention.</div>
|
||||
</div>`
|
||||
) +
|
||||
recentRunsPanel(recent.slice(0, 6), "Latest governed evidence");
|
||||
bindActions();
|
||||
}
|
||||
|
||||
function recentRunsPanel(rows, title = "Recent governed runs") {
|
||||
const body = rows.length ? `<div class="table-scroll"><table>
|
||||
<thead><tr><th>Observed at</th><th>Lifecycle step</th><th>Verdict</th><th class="right">Latency</th><th class="right">Tokens</th><th class="right">Evidence</th></tr></thead>
|
||||
<tbody>${rows.map((row) => `<tr>
|
||||
<td class="mono">${esc(time(row.timestamp))}</td><td><strong>${esc(row.step || row.harness || "Agentic turn")}</strong></td>
|
||||
<td>${pill(row.status)}</td><td class="right mono">${esc(row.latency_ms == null ? "Unavailable" : `${number(row.latency_ms)} ms`)}</td>
|
||||
<td class="right mono">${esc(number(row.total_tokens))}</td>
|
||||
<td class="right">${row.trace_id ? `<button class="button light" type="button" data-open-trace="${esc(row.trace_id)}">Inspect H1–H7</button>` : "—"}</td>
|
||||
</tr>`).join("")}</tbody></table></div>` : empty("No governed run yet", "Complete a prompt in a configured client, then refresh this viewer.");
|
||||
return panel("Evidence index", title, "Operational values are shown only when present in canonical telemetry.", body);
|
||||
}
|
||||
|
||||
function renderRuns() {
|
||||
const rows = state.registry.runs || [];
|
||||
const selected = state.run;
|
||||
const body = hero({
|
||||
eyebrow: "Run assurance registry",
|
||||
title: "Every governed run, one evidence trail.",
|
||||
description: "Select a lifecycle record to reconstruct H1–H7 decisions, inspect sanitized evidence and export an independent dossier.",
|
||||
verdict: rows.some((row) => row.status === "failed") ? "attention" : "operational",
|
||||
meta: [`${state.registry.count} records`, `${rows.filter((row) => row.status === "failed").length} failures`, "Local canonical evidence"],
|
||||
actions: `<button class="button primary" type="button" data-view-target="h6">Open H6 dossier</button>`,
|
||||
}) + (selected ? renderTrace(selected) : "") + recentRunsPanel(rows);
|
||||
$("#runs-view").innerHTML = body;
|
||||
bindActions();
|
||||
bindGates();
|
||||
}
|
||||
|
||||
function renderTrace(report) {
|
||||
const gates = report.gates || [];
|
||||
let selected = gates.find((gate) => gate.id === state.selectedGate) || gates[0];
|
||||
if (!selected) return "";
|
||||
const rail = gates.map((gate, index) => `<button type="button" aria-pressed="${gate.id === selected.id}" class="gate-button ${gate.id === selected.id ? "selected" : ""}" data-gate="${esc(gate.id)}">
|
||||
<span class="gate-index">0${index + 1}</span><span class="gate-name">${esc(gate.title)}</span>
|
||||
<span class="gate-state"><span class="state-dot ${slug(gate.status)}"></span>${esc(gate.status)}</span></button>`).join("");
|
||||
const events = (selected.events || []).slice(-4).reverse();
|
||||
const timeline = events.length ? `<div class="timeline">${events.map((event) => `<div class="timeline-item">
|
||||
<div class="timeline-line"><span class="state-dot ${slug(event.status)}"></span></div>
|
||||
<div class="timeline-copy"><strong>${esc(event.status)}</strong><time>${esc(time(event.timestamp))}</time><p>${esc(event.reason)}</p></div>
|
||||
</div>`).join("")}</div>` : `<p class="metric-detail">No individual event was emitted for this control.</p>`;
|
||||
const entries = Object.entries(selected.evidence || {});
|
||||
const manifest = entries.length ? `<dl>${entries.map(([key, value]) => `<div class="manifest-row"><dt>${esc(key)}</dt><dd>${esc(typeof value === "string" ? value : JSON.stringify(value))}</dd></div>`).join("")}</dl>`
|
||||
: empty("No safe evidence fields", "This control has not emitted a sanitized evidence manifest.");
|
||||
const actions = `<a class="button secondary" href="${downloadUrl(`/api/v1/reports/run/${encodeURIComponent(report.trace_id)}/export`, { format: "json" })}">JSON evidence</a>
|
||||
<a class="button primary" href="${downloadUrl(`/api/v1/reports/run/${encodeURIComponent(report.trace_id)}/export`, { format: "html" })}">Export dossier</a>`;
|
||||
return panel(
|
||||
"Run assurance",
|
||||
"Governed execution dossier",
|
||||
"A control-by-control reconstruction backed by sanitized lifecycle evidence.",
|
||||
`<div class="hero trace-hero">
|
||||
<div class="hero-grid"><div><p class="eyebrow">Trace provenance</p><h2 class="trace-title">${esc(report.trace_id)}</h2>
|
||||
<div class="hero-meta trace-meta"><span>${report.summary.gates_observed}/7 controls observed</span><span>${report.project.edition} edition</span></div></div>
|
||||
<div>${pill(report.verdict)}</div></div></div>
|
||||
<div class="rail-wrap rail-spaced"><div class="evidence-rail">${rail}</div></div>
|
||||
<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="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>
|
||||
</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>
|
||||
</div>`,
|
||||
actions,
|
||||
);
|
||||
}
|
||||
|
||||
function renderH6() {
|
||||
const data = state.h6;
|
||||
if (!data) return;
|
||||
const query = data.scope || {};
|
||||
const queryObject = Object.fromEntries(Object.entries(query).filter(([, value]) => value));
|
||||
const actions = `<a class="button secondary" href="${downloadUrl("/api/v1/reports/h6/export", { ...queryObject, format: "json" })}">JSON evidence</a>
|
||||
<a class="button primary" href="${downloadUrl("/api/v1/reports/h6/export", { ...queryObject, format: "html" })}">Export dossier</a>`;
|
||||
const findings = data.findings.length ? `<div class="findings">${data.findings.map((item, index) => `<article class="finding ${slug(item.severity)}">
|
||||
<span class="finding-index">${String(index + 1).padStart(2, "0")}</span><div><code>${esc(item.code)}</code><p>${esc(item.message)}</p></div></article>`).join("")}</div>`
|
||||
: `<div class="notice success">No threshold breach was detected in the selected evidence boundary.</div>`;
|
||||
const sources = data.evidence_sources.map((source) => {
|
||||
const status = !source.present ? "missing" : source.stale ? "stale" : "fresh";
|
||||
return `<article class="source"><div class="source-top"><div><h3>${esc(source.source)}</h3><div class="source-meta">${source.records} records · ${source.age_s == null ? "no timestamp" : `${number(source.age_s)}s old`}</div></div>${pill(status)}</div>
|
||||
<div class="source-path">${esc(source.path)}</div></article>`;
|
||||
}).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>
|
||||
<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>`;
|
||||
$("#h6-view").innerHTML =
|
||||
hero({
|
||||
eyebrow: "H6 · AgentOps assurance",
|
||||
title: "Operational evidence, ready for a decision.",
|
||||
description: "Runtime health, reliability, cost provenance and telemetry integrity—designed to answer what happened, why it matters and where the evidence lives.",
|
||||
verdict: data.verdict,
|
||||
meta: [data.report_id, `Generated ${time(data.generated_at)}`, `Freshness ${data.freshness.status}`],
|
||||
actions,
|
||||
}) +
|
||||
panel("Scope", "Evidence boundary", "Filters apply to the screen and both export formats.", h6Filters(data)) +
|
||||
`<section class="metric-grid">
|
||||
${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("P95 latency", `${number(data.summary.latency_ms.p95)} ms`, `P50 ${number(data.summary.latency_ms.p50)} ms`)}
|
||||
${metric("Telemetry integrity", data.data_quality.status.replaceAll("_", " "), `${data.summary.coverage.token_pct}% token · ${data.summary.coverage.cost_pct}% cost`)}
|
||||
</section>` +
|
||||
`<div class="two-col">
|
||||
${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))}
|
||||
</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>` +
|
||||
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`)}
|
||||
${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>`, pill(data.data_quality.status));
|
||||
bindH6Filters();
|
||||
}
|
||||
|
||||
function coverage(label, value, detail) {
|
||||
const width = Math.round(Math.max(0, Math.min(100, Number(value) || 0)) / 5) * 5;
|
||||
return `<div><div class="coverage-head"><strong>${esc(label)}</strong><span>${esc(value)}%</span></div>
|
||||
<div class="coverage-track"><div class="coverage-fill width-${width}"></div></div>
|
||||
<div class="metric-detail">${esc(detail)}</div></div>`;
|
||||
}
|
||||
|
||||
function h6Filters(data) {
|
||||
const scope = data.scope || {};
|
||||
const options = data.available_filters || { projects: [], runs: [] };
|
||||
const date = (value) => value ? value.slice(0, 10) : "";
|
||||
return `<form id="h6-filters" class="filters">
|
||||
<label class="field"><span>Project</span><select name="project"><option value="">All projects</option>${options.projects.map((value) => `<option value="${esc(value)}" ${scope.project === value ? "selected" : ""}>${esc(value)}</option>`).join("")}</select></label>
|
||||
<label class="field"><span>From</span><input type="date" name="from" value="${esc(date(scope.from))}"></label>
|
||||
<label class="field"><span>To</span><input type="date" name="to" value="${esc(date(scope.to))}"></label>
|
||||
<label class="field"><span>Run / trace</span><input name="run" list="run-options" value="${esc(scope.run || "")}" placeholder="All governed runs"><datalist id="run-options">${options.runs.map((value) => `<option value="${esc(value)}"></option>`).join("")}</datalist></label>
|
||||
<div class="filter-actions"><button class="button primary" type="submit">Apply</button><button class="button light" type="button" id="clear-h6">Clear</button></div>
|
||||
</form>`;
|
||||
}
|
||||
|
||||
async function loadTrace(traceId) {
|
||||
if (!traceId) return;
|
||||
state.trace = traceId;
|
||||
state.selectedGate = "H1-context";
|
||||
$("#loading").classList.remove("hidden");
|
||||
try {
|
||||
state.run = await api(`/api/v1/reports/run/${encodeURIComponent(traceId)}`);
|
||||
setView("runs");
|
||||
} catch (error) {
|
||||
showError(`Run report could not be reconstructed: ${error.message}`);
|
||||
} finally {
|
||||
$("#loading").classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
async function loadH6(query = {}) {
|
||||
const next = new URLSearchParams(Object.fromEntries(Object.entries(query).filter(([, value]) => value)));
|
||||
$("#loading").classList.remove("hidden");
|
||||
try {
|
||||
state.h6 = await api(`/api/v1/reports/h6${next.size ? `?${next}` : ""}`);
|
||||
renderH6();
|
||||
} catch (error) {
|
||||
showError(`H6 report could not be generated: ${error.message}`);
|
||||
} finally {
|
||||
$("#loading").classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
function bindActions() {
|
||||
document.querySelectorAll("[data-view-target]").forEach((button) => button.addEventListener("click", () => setView(button.dataset.viewTarget)));
|
||||
document.querySelectorAll("[data-open-trace]").forEach((button) => button.addEventListener("click", () => loadTrace(button.dataset.openTrace)));
|
||||
document.querySelectorAll("[data-open-h6]").forEach((button) => button.addEventListener("click", async () => {
|
||||
await loadH6({ run: button.dataset.openH6 });
|
||||
setView("h6");
|
||||
}));
|
||||
}
|
||||
|
||||
function bindGates() {
|
||||
document.querySelectorAll("[data-gate]").forEach((button) => button.addEventListener("click", () => {
|
||||
state.selectedGate = button.dataset.gate;
|
||||
renderRuns();
|
||||
}));
|
||||
}
|
||||
|
||||
function bindH6Filters() {
|
||||
$("#h6-filters")?.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const values = Object.fromEntries(new FormData(event.currentTarget));
|
||||
await loadH6(values);
|
||||
});
|
||||
$("#clear-h6")?.addEventListener("click", () => loadH6());
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
$("#error").textContent = message;
|
||||
$("#error").classList.remove("hidden");
|
||||
}
|
||||
|
||||
function closeNav() {
|
||||
$(".sidebar").classList.remove("open");
|
||||
$("#nav-scrim").classList.add("hidden");
|
||||
$("#menu-button").setAttribute("aria-expanded", "false");
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
$("#error").classList.add("hidden");
|
||||
$("#loading").classList.remove("hidden");
|
||||
try {
|
||||
[state.project, state.registry, state.h6] = await Promise.all([
|
||||
api("/api/v1/project"),
|
||||
api("/api/v1/runs?limit=100"),
|
||||
api("/api/v1/reports/h6?limit=50"),
|
||||
]);
|
||||
$("#freshness-pill").textContent = `${state.h6.freshness.status} evidence`;
|
||||
$("#freshness-pill").className = `pill ${slug(state.h6.freshness.status)}`;
|
||||
renderOverview();
|
||||
renderH6();
|
||||
if (state.trace) {
|
||||
await loadTrace(state.trace);
|
||||
} else {
|
||||
setView(state.view, false);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(`Local evidence could not be loaded: ${error.message}`);
|
||||
} finally {
|
||||
$("#loading").classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll(".nav-item").forEach((button) => button.addEventListener("click", () => setView(button.dataset.view)));
|
||||
$("#refresh-button").addEventListener("click", loadAll);
|
||||
$("#menu-button").addEventListener("click", () => {
|
||||
const open = !$(".sidebar").classList.contains("open");
|
||||
$(".sidebar").classList.toggle("open", open);
|
||||
$("#nav-scrim").classList.toggle("hidden", !open);
|
||||
$("#menu-button").setAttribute("aria-expanded", String(open));
|
||||
});
|
||||
$("#nav-scrim").addEventListener("click", closeNav);
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") closeNav();
|
||||
});
|
||||
window.addEventListener("popstate", () => setView(new URLSearchParams(location.search).get("view") || "overview", false));
|
||||
loadAll();
|
||||
})();
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" rx="16" fill="#101828"/>
|
||||
<rect x="10" y="10" width="44" height="44" rx="12" fill="#172033" stroke="#344054"/>
|
||||
<path d="M39.5 22.5a14 14 0 1 0 0 19" fill="none" stroke="#67e8f9" stroke-width="5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 322 B |
@@ -0,0 +1,62 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="light" />
|
||||
<meta name="theme-color" content="#101828" />
|
||||
<title>CASAN · Local Assurance Viewer</title>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="stylesheet" href="/app.css" />
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#main">Skip to report</a>
|
||||
<div id="app">
|
||||
<aside class="sidebar" aria-label="Primary navigation">
|
||||
<a class="brand" href="#overview" aria-label="CASAN overview">
|
||||
<span class="brand-mark" aria-hidden="true">C</span>
|
||||
<span><strong>CASAN</strong><small>Local Assurance</small></span>
|
||||
</a>
|
||||
<nav>
|
||||
<button class="nav-item active" data-view="overview" type="button"><span aria-hidden="true">◫</span>Overview</button>
|
||||
<button class="nav-item" data-view="runs" type="button"><span aria-hidden="true">⌁</span>Run registry</button>
|
||||
<button class="nav-item" data-view="h6" type="button"><span aria-hidden="true">H6</span>AgentOps</button>
|
||||
</nav>
|
||||
<div class="local-seal">
|
||||
<span class="live-dot"></span>
|
||||
<div><strong>Local Core</strong><small>Loopback · read-only</small></div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="shell">
|
||||
<header class="topbar">
|
||||
<button id="menu-button" class="icon-button menu-button" type="button" aria-label="Open navigation" aria-expanded="false">☰</button>
|
||||
<div>
|
||||
<p class="top-eyebrow">Independent evidence workspace</p>
|
||||
<h1 id="page-title">Assurance overview</h1>
|
||||
</div>
|
||||
<div class="top-actions">
|
||||
<span id="freshness-pill" class="pill neutral">Evidence loading</span>
|
||||
<button id="refresh-button" class="icon-button" type="button" aria-label="Refresh evidence">↻</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main" tabindex="-1">
|
||||
<div id="loading" class="loading-state" aria-live="polite">
|
||||
<div class="skeleton hero-skeleton"></div>
|
||||
<div class="metric-grid">
|
||||
<div class="skeleton metric-skeleton"></div><div class="skeleton metric-skeleton"></div>
|
||||
<div class="skeleton metric-skeleton"></div><div class="skeleton metric-skeleton"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="error" class="notice error hidden" role="alert"></div>
|
||||
<section id="overview-view" class="view hidden" aria-labelledby="page-title"></section>
|
||||
<section id="runs-view" class="view hidden" aria-labelledby="page-title"></section>
|
||||
<section id="h6-view" class="view hidden" aria-labelledby="page-title"></section>
|
||||
</main>
|
||||
</div>
|
||||
<div id="nav-scrim" class="nav-scrim hidden"></div>
|
||||
</div>
|
||||
<script src="/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user