feat(P05): evidence-ui/index.html vanilla-JS timeline (T-5.1)
---ci--- phase: 5 milestone: v1.0 status: execute persona: frontend-engineer task: T-5.1 requirements: covered: [REQ-14] ---/ci--- Wave 1, task T-5.1. Single self-contained HTML file (inline <style> + <script>, no external resources). Fetches ./audit.json (relative URL), renders a vertical timeline with stage color-coding (dev=blue, qa=yellow, prod=orange, finalize=green, genesis=gray, rejected=red), 12-char hash preview, refresh button, and fetch-failure handling.
This commit is contained in:
@@ -0,0 +1,334 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>ACDL Evidence Timeline</title>
|
||||
<style>
|
||||
:root {
|
||||
--stage-dev: #2563eb;
|
||||
--stage-qa: #ca8a04;
|
||||
--stage-prod: #ea580c;
|
||||
--stage-finalize: #16a34a;
|
||||
--stage-genesis: #6b7280;
|
||||
--stage-rejected: #dc2626;
|
||||
--bg: #f8fafc;
|
||||
--card-bg: #ffffff;
|
||||
--text: #0f172a;
|
||||
--muted: #64748b;
|
||||
--border: #e2e8f0;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
line-height: 1.5;
|
||||
}
|
||||
header {
|
||||
padding: 24px 32px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--card-bg);
|
||||
}
|
||||
header h1 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
header p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 32px;
|
||||
background: var(--card-bg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
button#refresh {
|
||||
appearance: none;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--text);
|
||||
color: #fff;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
button#refresh:hover { opacity: 0.9; }
|
||||
button#refresh:active { transform: translateY(1px); }
|
||||
.toolbar .status {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
main {
|
||||
padding: 24px 32px 48px;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.empty {
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
background: var(--card-bg);
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
ol.timeline {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
}
|
||||
ol.timeline::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 11px;
|
||||
top: 6px;
|
||||
bottom: 6px;
|
||||
width: 2px;
|
||||
background: var(--border);
|
||||
}
|
||||
li.event {
|
||||
position: relative;
|
||||
padding: 12px 0 12px 40px;
|
||||
}
|
||||
li.event::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
top: 18px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--dot, var(--muted));
|
||||
border: 2px solid var(--card-bg);
|
||||
box-shadow: 0 0 0 1px var(--border);
|
||||
}
|
||||
.card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 4px solid var(--dot, var(--muted));
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.card .row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.seq {
|
||||
display: inline-block;
|
||||
min-width: 28px;
|
||||
padding: 2px 6px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
border-radius: 4px;
|
||||
background: #eef2ff;
|
||||
color: #3730a3;
|
||||
border: 1px solid #c7d2fe;
|
||||
}
|
||||
.chip {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
border-radius: 999px;
|
||||
color: #fff;
|
||||
background: var(--dot, var(--muted));
|
||||
}
|
||||
.ts {
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.event-text {
|
||||
margin: 4px 0 6px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.hash {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
word-break: break-all;
|
||||
}
|
||||
footer {
|
||||
padding: 16px 32px 24px;
|
||||
border-top: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
footer code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
background: #f1f5f9;
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>ACDL Evidence Timeline</h1>
|
||||
<p>ACDL — Agentic Cloud Delivery Platform · Audit Timeline</p>
|
||||
</header>
|
||||
<div class="toolbar">
|
||||
<button id="refresh" type="button">Refresh</button>
|
||||
<span class="status" id="status"></span>
|
||||
</div>
|
||||
<main>
|
||||
<div id="container">
|
||||
<div class="empty">Loading…</div>
|
||||
</div>
|
||||
</main>
|
||||
<footer>
|
||||
<div id="footer"></div>
|
||||
</footer>
|
||||
<script>
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var AUDIT_URL = "./audit.json";
|
||||
var STAGE_COLORS = {
|
||||
dev: "var(--stage-dev)",
|
||||
qa: "var(--stage-qa)",
|
||||
prod: "var(--stage-prod)",
|
||||
finalize: "var(--stage-finalize)",
|
||||
genesis: "var(--stage-genesis)"
|
||||
};
|
||||
|
||||
function $(id) { return document.getElementById(id); }
|
||||
|
||||
function stageColor(stage, eventText) {
|
||||
var evt = (eventText || "").toString().toLowerCase();
|
||||
if (evt.indexOf("rejected") !== -1) {
|
||||
return "var(--stage-rejected)";
|
||||
}
|
||||
return STAGE_COLORS[stage] || "var(--stage-genesis)";
|
||||
}
|
||||
|
||||
function dash(v) {
|
||||
return (v === null || v === undefined || v === "") ? "—" : v;
|
||||
}
|
||||
|
||||
function hashPreview(hash) {
|
||||
if (hash === null || hash === undefined || hash === "") return "—";
|
||||
var s = String(hash);
|
||||
return s.slice(0, 12) + "…";
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function auditUrlDisplay() {
|
||||
try {
|
||||
var href = window.location.href;
|
||||
var slash = href.lastIndexOf("/");
|
||||
if (slash >= 0) {
|
||||
return href.slice(0, slash + 1) + "audit.json";
|
||||
}
|
||||
} catch (e) {}
|
||||
return AUDIT_URL;
|
||||
}
|
||||
|
||||
function renderEmpty(msg) {
|
||||
$("container").innerHTML =
|
||||
'<div class="empty">' + esc(msg) + "</div>";
|
||||
}
|
||||
|
||||
function renderTimeline(events) {
|
||||
if (!Array.isArray(events)) {
|
||||
renderEmpty("No audit data yet");
|
||||
return;
|
||||
}
|
||||
if (events.length === 0) {
|
||||
renderEmpty("No audit data yet");
|
||||
return;
|
||||
}
|
||||
var sorted = events.slice().sort(function (a, b) {
|
||||
var sa = (a && typeof a.seq === "number") ? a.seq : 0;
|
||||
var sb = (b && typeof b.seq === "number") ? b.seq : 0;
|
||||
return sa - sb;
|
||||
});
|
||||
var html = '<ol class="timeline">';
|
||||
for (var i = 0; i < sorted.length; i++) {
|
||||
var e = sorted[i] || {};
|
||||
var stage = dash(e.stage);
|
||||
var color = stageColor(e.stage, e.event);
|
||||
html += '<li class="event" style="--dot:' + color + ';">';
|
||||
html += '<div class="card" style="--dot:' + color + ';">';
|
||||
html += '<div class="row">';
|
||||
html += '<span class="seq">#' + esc(dash(e.seq)) + "</span>";
|
||||
html += '<span class="chip">' + esc(stage) + "</span>";
|
||||
html += '<span class="ts">' + esc(dash(e.ts)) + "</span>";
|
||||
html += "</div>";
|
||||
html += '<div class="event-text">' + esc(dash(e.event)) + "</div>";
|
||||
html += '<div class="hash">' + esc(hashPreview(e.hash)) + "</div>";
|
||||
html += "</div>";
|
||||
html += "</li>";
|
||||
}
|
||||
html += "</ol>";
|
||||
$("container").innerHTML = html;
|
||||
}
|
||||
|
||||
function renderFooter(ok) {
|
||||
var when = new Date().toISOString();
|
||||
var url = auditUrlDisplay();
|
||||
var prefix = "Fetched at " + when + " · audit.json: ";
|
||||
$("footer").innerHTML =
|
||||
esc(prefix) + '<code>' + esc(url) + "</code>" +
|
||||
(ok ? "" : " (fetch failed)");
|
||||
}
|
||||
|
||||
function setStatus(msg) {
|
||||
$("status").textContent = msg || "";
|
||||
}
|
||||
|
||||
function fetchAudit() {
|
||||
setStatus("Fetching…");
|
||||
fetch(AUDIT_URL, { cache: "no-store" })
|
||||
.then(function (res) {
|
||||
if (!res.ok) {
|
||||
throw new Error("HTTP " + res.status);
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error("not an array");
|
||||
}
|
||||
renderTimeline(data);
|
||||
renderFooter(true);
|
||||
setStatus("Loaded " + data.length + " event(s)");
|
||||
})
|
||||
.catch(function (err) {
|
||||
renderEmpty("No audit data yet");
|
||||
renderFooter(false);
|
||||
setStatus("Fetch failed: " + (err && err.message ? err.message : "error"));
|
||||
});
|
||||
}
|
||||
|
||||
$("refresh").addEventListener("click", fetchAudit);
|
||||
fetchAudit();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user