Files
flare/flare.html
T
2026-06-09 17:31:10 -07:00

1587 lines
46 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Flare // lark.cx</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Fira+Sans+Condensed:ital,wght@0,300;0,400;0,500;0,700;1,300&family=Fira+Mono:wght@400;700&family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20,400,1,0&display=swap"
rel="stylesheet"
/>
<style>
.ms {
font-family: "Material Symbols Rounded";
font-style: normal;
font-weight: normal;
font-size: 16px;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
-webkit-font-smoothing: antialiased;
vertical-align: middle;
user-select: none;
}
:root {
--bg: #070600;
--panel: #111111;
--rule: #222222;
--text: #d9d9d9;
--white: #ffffff;
--muted: #6d6875;
--dim: #444444;
--gray: #888888;
--coral: #ff1053;
--blue: #1098f7;
--pumpkin: #fc7a1e;
--green: #34d399;
--gold: #ddb44b;
--red: #e8593c;
--mono: "Fira Mono", Consolas, monospace;
--sans: "Fira Sans Condensed", Calibri, sans-serif;
--display: "Bebas Neue", Impact, "Arial Black", sans-serif;
}
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: var(--bg);
color: var(--text);
font-family: var(--sans);
font-weight: 300;
font-size: 14px;
line-height: 1.55;
margin: 0;
overflow: hidden;
}
.app-wrapper {
max-width: 1500px;
margin: 0 auto;
padding: 0 4vw;
height: 100vh;
display: flex;
flex-direction: column;
}
button {
cursor: pointer;
border-radius: 0;
outline: none;
background: transparent;
}
/* --- BRANDING HEADER --- */
header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24px 0 16px;
background: var(--bg);
flex-shrink: 0;
z-index: 10;
}
.header-left {
display: flex;
align-items: flex-start;
gap: 16px;
}
.accent-bar {
width: 8px;
min-width: 8px;
height: 48px;
background: var(--coral);
flex-shrink: 0;
}
.mark {
font-family: var(--display);
line-height: 0.8;
letter-spacing: -0.02em;
font-size: 2.5rem;
color: var(--white);
}
.mark .dot {
color: var(--coral);
}
.premise {
font-family: var(--mono);
font-size: 11px;
font-weight: 400;
margin-top: 8px;
color: var(--coral);
letter-spacing: 0.1em;
}
.header-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
}
.conn-pill {
display: flex;
align-items: center;
gap: 6px;
}
.conn-dot {
width: 8px;
height: 8px;
background: var(--muted);
transition:
background 0.2s ease,
box-shadow 0.2s ease;
}
.conn-dot.live {
background: var(--green);
box-shadow: 0 0 6px rgba(52, 211, 153, 0.4);
}
.conn-dot.error {
background: var(--red);
box-shadow: 0 0 6px rgba(232, 89, 60, 0.4);
}
.conn-label {
font-family: var(--mono);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--gray);
}
#clock {
font-family: var(--mono);
font-size: 11px;
color: var(--dim);
}
/* --- MAIN SHELL & TABS --- */
.main-shell {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
.host-strip-wrap {
background: var(--bg);
padding: 0;
flex-shrink: 0;
}
#host-bar {
display: flex;
gap: 4px;
overflow-x: auto;
border-bottom: 1px solid var(--rule);
}
#host-bar::-webkit-scrollbar {
height: 0px;
}
.host-tab {
min-width: 140px;
padding: 12px 16px;
background: #0a0a0a;
border: 1px solid var(--rule);
border-bottom: none;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
position: relative;
top: 1px;
transition:
background 0.1s ease,
border-color 0.1s ease;
}
.host-tab:hover {
background: #111111;
}
.host-tab.active {
background: var(--panel);
border-color: var(--coral);
border-bottom-color: var(--panel);
z-index: 3;
}
.host-tab-name {
font-family: var(--display);
font-size: 20px;
letter-spacing: 0.04em;
color: var(--white);
white-space: nowrap;
}
.host-tab-pills {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
/* --- DETAIL & ACTIONS --- */
#detail {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--panel);
border-bottom: 1px solid var(--rule);
}
.detail-toolbar {
display: flex;
gap: 32px;
padding: 24px;
border-bottom: 1px solid var(--rule);
flex-shrink: 0;
overflow-x: auto;
}
.detail-toolbar::-webkit-scrollbar {
height: 0px;
}
/* Col 1: Title */
.col-left {
display: flex;
flex-direction: column;
justify-content: flex-start;
gap: 8px;
white-space: nowrap;
flex-shrink: 0;
}
#detail-title {
font-family: var(--display);
font-size: 42px;
line-height: 0.9;
letter-spacing: 0.02em;
color: var(--white);
margin: 0;
}
.detail-host-line {
font-family: var(--mono);
font-size: 12px;
color: var(--gray);
letter-spacing: 0.05em;
line-height: 1.5;
}
/* Col 2: Extra Meta (flare-summary) */
.col-mid {
display: flex;
flex-direction: column;
justify-content: center;
gap: 6px;
border-left: 1px solid var(--rule);
padding-left: 32px;
font-family: var(--mono);
font-size: 12px;
white-space: nowrap;
flex-shrink: 0;
}
.meta-line {
display: flex;
gap: 12px;
}
.meta-lbl {
color: var(--coral);
width: auto;
flex-shrink: 0;
}
.meta-val {
color: var(--white);
}
/* Col 3: Controls Area */
.col-right {
display: flex;
gap: 16px;
border-left: 1px solid var(--rule);
padding-left: 32px;
flex-shrink: 0;
margin-left: auto;
}
.filter-stack,
.action-stack {
display: flex;
flex-direction: column;
gap: 6px;
}
/* Action/Filter Buttons */
.action-btn,
.filter-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border: 1px solid var(--rule);
color: var(--gray);
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0.05em;
transition: all 0.15s ease;
justify-content: flex-start;
min-width: 140px;
}
.action-btn .ms,
.filter-btn .ms {
font-size: 14px;
}
.action-btn:hover {
color: var(--white);
border-color: var(--gray);
background: rgba(255, 255, 255, 0.03);
}
/* Filter specific behaviors */
.filter-btn:hover:not(.disabled) {
color: var(--white);
border-color: var(--gray);
}
.filter-btn.disabled {
opacity: 0.3;
cursor: default;
border-color: var(--rule) !important;
color: var(--gray) !important;
background: transparent !important;
}
.filter-btn.f-error.active {
color: var(--red);
border-color: var(--red);
background: rgba(232, 89, 60, 0.08);
}
.filter-btn.f-complete.active {
color: var(--green);
border-color: var(--green);
background: rgba(52, 211, 153, 0.08);
}
.filter-btn.f-running.active {
color: var(--coral);
border-color: var(--coral);
background: rgba(255, 16, 83, 0.08);
}
/* --- PILLS --- */
.mini-pill,
.log-pill {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 6px;
border: 1px solid currentColor;
font-family: var(--mono);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.05em;
background: transparent;
}
.mini-pill .ms,
.log-pill .ms {
font-size: 12px;
}
.pill-running,
.lp-running {
color: var(--coral);
}
.pill-complete,
.lp-complete {
color: var(--green);
}
.pill-error,
.lp-error {
color: var(--red);
}
.pill-idle {
color: var(--gray);
border-color: var(--dim);
}
/* --- LOG GRID --- */
#log {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
}
#log::-webkit-scrollbar {
width: 6px;
}
#log::-webkit-scrollbar-thumb {
background: var(--rule);
}
.log-header-row {
display: grid;
grid-template-columns: 120px 320px 1fr 120px;
gap: 20px;
padding: 8px 24px;
border-bottom: 1px solid var(--rule);
font-family: var(--mono);
font-size: 11px;
color: var(--dim);
background: #0a0a0a;
position: sticky;
top: 0;
z-index: 2;
}
.sort-toggle {
display: inline-flex;
align-items: center;
gap: 4px;
cursor: pointer;
color: var(--gray);
transition: color 0.15s ease;
}
.sort-toggle:hover {
color: var(--white);
}
.sort-toggle .ms {
font-size: 16px;
}
.log-entry {
display: grid;
grid-template-columns: 120px 320px 1fr 120px;
gap: 20px;
padding: 16px 24px;
border-bottom: 1px solid var(--rule);
align-items: flex-start;
border-left: 3px solid transparent;
}
/* Subtle Row Alternation */
.log-entry:nth-child(even) {
background-color: #000000;
}
.log-entry:nth-child(odd) {
background-color: #0d0d0d;
}
.log-entry:hover {
background-color: #141414 !important;
}
.log-entry.is-running {
border-left-color: var(--coral);
}
.log-entry.is-complete {
border-left-color: var(--green);
}
.log-entry.is-error {
border-left-color: var(--red);
}
.log-time {
font-family: var(--mono);
font-size: 11px;
color: var(--gray);
line-height: 1.5;
}
.log-seq {
color: var(--dim);
}
.log-title {
font-family: var(--mono);
font-size: 18px;
font-weight: 400;
color: var(--white);
line-height: 1.2;
}
.log-hmac-wrapper {
margin-top: 6px;
}
.hmac-badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.05em;
opacity: 0.6;
}
.hmac-badge .ms {
font-size: 12px;
}
.lp-lock-unknown {
color: var(--gray);
}
.lp-lock-invalid {
color: var(--coral);
}
.lp-lock-valid {
color: var(--green);
}
/* JSON Toggling / Truncation Elements */
.log-detail-col {
font-family: var(--mono);
font-size: 12px;
min-width: 0;
}
.log-detail-grid {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.log-kv {
display: flex;
gap: 8px;
align-items: flex-start;
min-width: 0;
}
.kv-toggle,
.kv-toggle-placeholder {
width: 14px;
flex-shrink: 0;
text-align: center;
}
.kv-toggle {
cursor: pointer;
color: var(--coral);
background: none;
border: none;
font-family: var(--mono);
font-size: 14px;
font-weight: 700;
padding: 0;
line-height: 1.3;
transition: color 0.15s;
}
.kv-toggle:hover {
color: var(--white);
}
.log-kv-key {
width: 90px;
color: var(--gray);
flex-shrink: 0;
}
.log-kv-val {
color: var(--white);
min-width: 0;
flex: 1;
}
/* Collapsed short string */
.val-short {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: block;
}
/* Expanded full pre block */
.val-full {
display: none;
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;
color: var(--white);
font-family: inherit;
}
/* Active toggle state */
.log-kv.expanded .val-short {
display: none;
}
.log-kv.expanded .val-full {
display: block;
}
/* Global Expand Button */
.detail-controls {
margin-bottom: 6px;
}
.expand-all-btn {
font-family: var(--mono);
font-size: 10px;
color: var(--gray);
border: 1px solid var(--rule);
padding: 2px 6px;
background: transparent;
transition: all 0.15s;
letter-spacing: 0.05em;
}
.expand-all-btn:hover {
color: var(--white);
border-color: var(--gray);
}
.log-status-col {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
flex: 1;
min-height: 200px;
padding: 40px;
}
.empty-icon {
font-family: var(--display);
font-size: 40px;
color: var(--rule);
}
.empty-text,
.empty-tabs {
font-family: var(--mono);
font-size: 12px;
color: var(--dim);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.empty-tabs {
padding: 12px 16px;
}
@media (max-width: 1000px) {
.detail-toolbar {
flex-direction: column;
gap: 16px;
}
.col-mid,
.col-right {
border-left: none;
padding-left: 0;
border-top: 1px solid var(--rule);
padding-top: 16px;
margin-left: 0;
}
.log-header-row {
display: none;
}
.log-entry {
grid-template-columns: 1fr;
gap: 12px;
}
.log-status-col {
align-items: flex-start;
}
}
</style>
</head>
<body>
<div class="app-wrapper">
<header>
<div class="header-left">
<div class="accent-bar"></div>
<div>
<div class="mark">LARK<span class="dot">.</span>CX</div>
<p class="premise">// FLARE DASHBOARD</p>
</div>
</div>
<div class="header-right">
<div class="conn-pill">
<div class="conn-dot" id="conn-dot"></div>
<div class="conn-label" id="conn-label">idle</div>
</div>
<div id="clock"></div>
</div>
</header>
<div class="main-shell">
<div class="host-strip-wrap">
<div id="host-bar">
<div class="empty-tabs">waiting for hosts</div>
</div>
</div>
<div id="detail">
<div class="detail-toolbar">
<div class="col-left">
<h1 id="detail-title" style="color: var(--dim)">
NO HOST SELECTED
</h1>
<div id="detail-host"></div>
</div>
<div class="col-mid" id="detail-meta-extra"></div>
<div class="col-right">
<div class="filter-stack" id="detail-filters"></div>
<div class="action-stack">
<button class="action-btn" onclick="exportActive('json')">
<span class="ms">data_object</span>EXPORT JSON
</button>
<button class="action-btn" onclick="exportActive('csv')">
<span class="ms">view_list</span>EXPORT CSV
</button>
</div>
</div>
</div>
<div id="log"></div>
</div>
</div>
</div>
<script>
// State
const topics = new Map();
let activeJob = null;
let logSortAsc = false; // false = newest first
const filters = { error: true, complete: true, running: true };
function getSessionBasePath() {
const path = window.location.pathname || "/";
if (path.endsWith("/flare.html"))
return path.slice(0, -"/flare.html".length) || "/";
return path.replace(/\/$/, "") || "/";
}
function getSessionBaseUrl() {
const basePath = getSessionBasePath();
return (
window.location.origin.replace(/\/$/, "") +
(basePath === "/" ? "" : basePath)
);
}
(function init() {
const p = new URLSearchParams(window.location.search);
const pUrl = getSessionBaseUrl();
const pTopic = p.get("topic") || "";
const pToken = p.get("token") || "";
const pSecret = p.get("secret") || "";
if (pTopic && pToken) addTopicEntry(pUrl, pTopic, pToken, pSecret);
updateClock();
setInterval(updateClock, 1000);
})();
function topicKey(url, topic) {
return url.replace(/\/$/, "") + "|" + topic;
}
function updateClock() {
document.getElementById("clock").textContent =
new Date().toISOString().slice(11, 19) + " UTC";
}
function addTopicEntry(url, topic, token, secret) {
for (const existing of topics.values())
if (existing.es) existing.es.close();
topics.clear();
const key = topicKey(url, topic);
topics.set(key, {
url,
topic,
token,
secret: secret || "",
es: null,
connState: "idle",
jobs: new Map(),
});
connectTopic(key);
renderSidebar();
}
function clearDetail() {
document.getElementById("detail-title").textContent =
"NO HOST SELECTED";
document.getElementById("detail-title").style.color = "var(--dim)";
document.getElementById("detail-host").innerHTML = "";
document.getElementById("detail-meta-extra").innerHTML = "";
document.getElementById("detail-filters").innerHTML = "";
document.getElementById("log").innerHTML =
`<div class="empty"><div class="empty-icon">◌</div><div class="empty-text">waiting for events</div></div>`;
}
function connectTopic(key) {
const t = topics.get(key);
if (!t) return;
if (t.es) t.es.close();
const authParam = btoa("Bearer " + t.token);
const sseUrl = `${t.url}/events?topic=${encodeURIComponent(t.topic)}&auth=${authParam}&since=all`;
t.connState = "connecting";
t.es = new EventSource(sseUrl);
t.es.addEventListener("open", () => {
t.connState = "live";
updateConnDot();
renderSidebar();
});
t.es.addEventListener("error", () => {
t.connState = "error";
updateConnDot();
renderSidebar();
});
t.es.addEventListener("message", (e) => {
try {
const flareMsg = JSON.parse(e.data);
if (flareMsg.event !== "message") return;
const envelope = JSON.parse(flareMsg.message);
envelope._flare_id = flareMsg.id;
ingestEvent(key, envelope);
} catch (err) {
console.warn("parse error:", err);
}
});
}
function updateConnDot() {
const first = topics.values().next().value;
const state = first?.connState || "connecting";
document.getElementById("conn-dot").className =
"conn-dot" +
(state === "live" ? " live" : state === "error" ? " error" : "");
document.getElementById("conn-label").textContent =
state === "live"
? "live"
: state === "error"
? "disconnected"
: "connecting";
}
function ingestEvent(tKey, envelope) {
const t = topics.get(tKey);
if (!t) return;
const content = envelope.content || envelope;
const jobId = content.host || "?";
if (!t.jobs.has(jobId))
t.jobs.set(jobId, {
id: jobId,
host: jobId,
events: [],
lastError: false,
lastComplete: false,
});
const job = t.jobs.get(jobId);
job.lastError = !!content.error;
job.lastComplete = !!content.complete;
if (
envelope._flare_id &&
job.events.some((e) => e._flare_id === envelope._flare_id)
)
return;
const ev = {
...content,
_flare_id: envelope._flare_id,
_ts: content.timestamp ? content.timestamp * 1000 : Date.now(),
_nonce: envelope.nonce || null,
_hmac: envelope.hmac || null,
_hmacState: null,
};
const idx = job.events.findIndex(
(e) => (e.seq || 0) > (content.seq || 0),
);
if (idx === -1) job.events.push(ev);
else job.events.splice(idx, 0, ev);
renderSidebar();
if (activeJob?.topicKey === tKey && activeJob?.jobId === jobId) {
renderLog(tKey, jobId);
updateDetailHeader(tKey, jobId);
} else if (!activeJob) {
selectJob(tKey, jobId);
}
}
function canonicalize(obj) {
if (Array.isArray(obj))
return "[" + obj.map(canonicalize).join(",") + "]";
if (obj !== null && typeof obj === "object") {
const keys = Object.keys(obj).sort();
return (
"{" +
keys
.map((k) => JSON.stringify(k) + ":" + canonicalize(obj[k]))
.join(",") +
"}"
);
}
return JSON.stringify(obj);
}
function _sha256Bytes(bytes) {
const K = [
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b,
0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01,
0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7,
0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc,
0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152,
0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147,
0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc,
0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819,
0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, 0x1e376c08,
0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f,
0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
];
let h = [
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f,
0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
];
const len = bytes.length;
const padded = new Uint8Array(((len + 72) >> 6) * 64);
padded.set(bytes);
padded[len] = 0x80;
new DataView(padded.buffer).setUint32(
padded.length - 4,
len * 8,
false,
);
const view = new DataView(padded.buffer);
const rotr = (x, n) => (x >>> n) | (x << (32 - n));
for (let i = 0; i < padded.length; i += 64) {
const w = new Array(64);
for (let j = 0; j < 16; j++) w[j] = view.getUint32(i + j * 4, false);
for (let j = 16; j < 64; j++) {
const s0 =
rotr(w[j - 15], 7) ^ rotr(w[j - 15], 18) ^ (w[j - 15] >>> 3);
const s1 =
rotr(w[j - 2], 17) ^ rotr(w[j - 2], 19) ^ (w[j - 2] >>> 10);
w[j] = (w[j - 16] + s0 + w[j - 7] + s1) >>> 0;
}
let [a, b, c, d, e, f, g, hh] = h;
for (let j = 0; j < 64; j++) {
const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25),
ch = (e & f) ^ (~e & g);
const t1 = (hh + S1 + ch + K[j] + w[j]) >>> 0;
const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22),
maj = (a & b) ^ (a & c) ^ (b & c);
const t2 = (S0 + maj) >>> 0;
[hh, g, f, e, d, c, b, a] = [
g,
f,
e,
(d + t1) >>> 0,
c,
b,
a,
(t1 + t2) >>> 0,
];
}
h = h.map((v, i) => (v + [a, b, c, d, e, f, g, hh][i]) >>> 0);
}
return new Uint8Array(
h.flatMap((v) => [
v >>> 24,
(v >>> 16) & 0xff,
(v >>> 8) & 0xff,
v & 0xff,
]),
);
}
function _hmacSha256Pure(key, msg) {
const enc = new TextEncoder();
let kb = enc.encode(key);
if (kb.length > 64) kb = _sha256Bytes(kb);
const block = new Uint8Array(64);
block.set(kb);
const ipad = block.map((b) => b ^ 0x36);
const opad = block.map((b) => b ^ 0x5c);
const msgB = enc.encode(msg);
const inner = new Uint8Array(64 + msgB.length);
inner.set(ipad);
inner.set(msgB, 64);
const innerHash = _sha256Bytes(inner);
const outer = new Uint8Array(64 + 32);
outer.set(opad);
outer.set(innerHash, 64);
return Array.from(_sha256Bytes(outer))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
async function verifyHmac(ev, secret) {
if (
ev._hmacState === "none" ||
ev._hmacState === "valid" ||
ev._hmacState === "invalid"
)
return ev._hmacState;
if (!ev._hmac || !ev._nonce) {
ev._hmacState = "none";
return "none";
}
if (!secret) return "unknown";
try {
const content = {};
for (const k of [
"topic",
"seq",
"timestamp",
"host",
"error",
"complete",
"title",
"detail",
])
if (ev[k] !== undefined) content[k] = ev[k];
const msg = canonicalize(content) + ev._nonce;
let computed;
if (crypto?.subtle && window.isSecureContext) {
const keyBytes = new TextEncoder().encode(secret);
const msgBytes = new TextEncoder().encode(msg);
const key = await crypto.subtle.importKey(
"raw",
keyBytes,
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const sig = await crypto.subtle.sign("HMAC", key, msgBytes);
computed = Array.from(new Uint8Array(sig))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
} else {
computed = _hmacSha256Pure(secret, msg);
}
ev._hmacState = computed === ev._hmac ? "valid" : "invalid";
} catch (err) {
ev._hmacState = "invalid";
}
return ev._hmacState;
}
function getFilteredEvents(job) {
return job.events.filter((ev) => {
if (ev.title === "flare-summary") return false;
const isErr = ev.error;
const isComp = ev.complete && !ev.error;
const isRun = !ev.error && !ev.complete;
return (
(filters.error && isErr) ||
(filters.complete && isComp) ||
(filters.running && isRun)
);
});
}
function toggleFilter(type) {
filters[type] = !filters[type];
if (activeJob) {
updateDetailHeader(activeJob.topicKey, activeJob.jobId);
renderLog(activeJob.topicKey, activeJob.jobId);
}
}
function getSortedJobs(t) {
return [...t.jobs.values()].sort((a, b) => {
const rank = (j) => (j.lastError ? 0 : j.lastComplete ? 2 : 1);
return (
rank(a) - rank(b) ||
(b.events.at(-1)?._ts || 0) - (a.events.at(-1)?._ts || 0) ||
String(a.host || "").localeCompare(String(b.host || ""))
);
});
}
function jobCounts(job) {
const evts = job.events.filter((e) => e.title !== "flare-summary");
return {
complete: evts.filter((e) => e.complete && !e.error).length,
running: evts.filter((e) => !e.complete && !e.error).length,
error: evts.filter((e) => e.error).length,
total: evts.length,
};
}
function buildCountPills(counts) {
return [
counts.complete
? `<span class="mini-pill pill-complete"><span class="ms">check_circle</span>${counts.complete}</span>`
: "",
counts.error
? `<span class="mini-pill pill-error"><span class="ms">error</span>${counts.error}</span>`
: "",
counts.running
? `<span class="mini-pill pill-running"><span class="ms">pending</span>${counts.running}</span>`
: "",
]
.filter(Boolean)
.join("");
}
function renderSidebar() {
const bar = document.getElementById("host-bar");
const firstEntry = topics.entries().next();
if (firstEntry.done) {
bar.innerHTML = `<div class="empty-tabs">waiting for hosts</div>`;
return;
}
const [key, t] = firstEntry.value;
const jobs = getSortedJobs(t);
if (jobs.length === 0) {
bar.innerHTML = `<div class="empty-tabs">waiting for hosts</div>`;
return;
}
if (
!activeJob ||
activeJob.topicKey !== key ||
!t.jobs.has(activeJob.jobId)
)
activeJob = { topicKey: key, jobId: jobs[0].id };
const keyE = escHtml(key);
bar.innerHTML = jobs
.map((job) => {
const active =
activeJob?.topicKey === key && activeJob?.jobId === job.id
? " active"
: "";
return `
<button class="host-tab${active}" onclick="selectJob('${keyE}','${escHtml(job.id)}')">
<div class="host-tab-name">${escHtml(job.host)}</div>
<div class="host-tab-pills">${buildCountPills(jobCounts(job)) || `<span class="mini-pill pill-idle">NO EVENTS</span>`}</div>
</button>`;
})
.join("");
}
function selectJob(tKey, jobId) {
activeJob = { topicKey: tKey, jobId };
filters.error = true;
filters.complete = true;
filters.running = true;
renderSidebar();
renderLog(tKey, jobId);
updateDetailHeader(tKey, jobId);
}
function toggleSort() {
logSortAsc = !logSortAsc;
if (activeJob) renderLog(activeJob.topicKey, activeJob.jobId);
}
function updateDetailHeader(tKey, jobId) {
const t = topics.get(tKey);
const job = t?.jobs.get(jobId);
if (!job) return;
document.getElementById("detail-title").textContent = job.host;
document.getElementById("detail-title").style.color = "";
const counts = jobCounts(job);
const filteredEvents = getFilteredEvents(job);
let startTimeStr = "—";
if (filteredEvents.length > 0) {
const minTs = Math.min(...filteredEvents.map((e) => e.timestamp));
const dateObj = new Date(minTs * 1000);
startTimeStr =
dateObj.toISOString().slice(0, 19).replace("T", " ") + " UTC";
}
document.getElementById("detail-host").innerHTML = `
<div class="detail-host-line">// SESSION ${escHtml(t.topic)}</div>
<div class="detail-host-line">// SHOWING ${filteredEvents.length} OF ${counts.total}</div>
<div class="detail-host-line">// START TIME: ${startTimeStr}</div>
`;
const summaryEv = [...job.events]
.reverse()
.find((e) => e.title === "flare-summary");
const metaContainer = document.getElementById("detail-meta-extra");
if (summaryEv && summaryEv.detail) {
const keys = Object.keys(summaryEv.detail).slice(0, 4);
metaContainer.innerHTML = keys
.map(
(k) => `
<div class="meta-line">
<span class="meta-lbl">// ${escHtml(k.toUpperCase())}:</span>
<span class="meta-val">${escHtml(summaryEv.detail[k])}</span>
</div>
`,
)
.join("");
} else {
metaContainer.innerHTML = "";
}
document.getElementById("detail-filters").innerHTML = `
<button class="filter-btn f-error ${filters.error ? "active" : ""} ${counts.error ? "" : "disabled"}" onclick="${counts.error ? "toggleFilter('error')" : "return false;"}">
<span class="ms">error</span> ${counts.error} ERROR
</button>
<button class="filter-btn f-complete ${filters.complete ? "active" : ""} ${counts.complete ? "" : "disabled"}" onclick="${counts.complete ? "toggleFilter('complete')" : "return false;"}">
<span class="ms">check_circle</span> ${counts.complete} COMPLETE
</button>
<button class="filter-btn f-running ${filters.running ? "active" : ""} ${counts.running ? "" : "disabled"}" onclick="${counts.running ? "toggleFilter('running')" : "return false;"}">
<span class="ms">pending</span> ${counts.running} RUNNING
</button>
`;
}
// Expansion Toggles
function toggleKvp(id) {
const el = document.getElementById(id);
if (!el) return;
el.classList.toggle("expanded");
const btn = el.querySelector(".kv-toggle");
if (btn)
btn.textContent = el.classList.contains("expanded") ? "" : "+";
}
function toggleAllKvp(containerId, btn) {
const container = document.getElementById(containerId);
if (!container) return;
const isExpanding = btn.textContent.includes("+");
const elements = container.querySelectorAll(".log-kv.expandable");
elements.forEach((el) => {
const toggleBtn = el.querySelector(".kv-toggle");
if (isExpanding) {
el.classList.add("expanded");
if (toggleBtn) toggleBtn.textContent = "";
} else {
el.classList.remove("expanded");
if (toggleBtn) toggleBtn.textContent = "+";
}
});
btn.textContent = isExpanding ? "[-] COLLAPSE ALL" : "[+] EXPAND ALL";
}
async function renderLog(tKey, jobId) {
const t = topics.get(tKey);
const job = t?.jobs.get(jobId);
const logContainer = document.getElementById("log");
if (
!job ||
job.events.filter((e) => e.title !== "flare-summary").length === 0
) {
logContainer.innerHTML = `<div class="empty"><div class="empty-icon">◌</div><div class="empty-text">waiting for events</div></div>`;
return;
}
const secret = t?.secret || "";
const filteredList = getFilteredEvents(job);
if (filteredList.length === 0) {
logContainer.innerHTML = `<div class="empty"><div class="empty-icon">◌</div><div class="empty-text">0 MATCHING EVENTS</div></div>`;
return;
}
const prevScrollHeight = logContainer.scrollHeight;
const prevScrollTop = logContainer.scrollTop;
const isAtBottom =
prevScrollHeight - prevScrollTop <= logContainer.clientHeight + 10;
const isAtTop = prevScrollTop === 0;
await Promise.all(filteredList.map((ev) => verifyHmac(ev, secret)));
const eventsOrdered = [...filteredList].sort((a, b) => {
if (a.timestamp !== b.timestamp)
return logSortAsc
? a.timestamp - b.timestamp
: b.timestamp - a.timestamp;
return logSortAsc
? (a.seq || 0) - (b.seq || 0)
: (b.seq || 0) - (a.seq || 0);
});
const sortIcon = logSortAsc ? "arrow_drop_up" : "arrow_drop_down";
const headerHtml = `
<div class="log-header-row">
<div class="sort-toggle" onclick="toggleSort()" title="Toggle sort order">TIME <span class="ms">${sortIcon}</span></div>
<div>EVENT</div>
<div>DETAILS</div>
<div style="text-align: right">STATUS</div>
</div>
`;
const eventsHtml = eventsOrdered
.map((ev) => {
const ts = new Date(ev.timestamp * 1000)
.toISOString()
.slice(11, 19);
const date = new Date(ev.timestamp * 1000)
.toISOString()
.slice(0, 10);
const state = ev.error
? "is-error"
: ev.complete
? "is-complete"
: "is-running";
const entryUid =
"evt-" + Math.random().toString(36).substring(2, 9);
const pills = [];
if (ev.error)
pills.push(
`<span class="log-pill lp-error"><span class="ms">error</span>ERROR</span>`,
);
if (ev.complete && !ev.error)
pills.push(
`<span class="log-pill lp-complete"><span class="ms">check_circle</span>COMPLETE</span>`,
);
if (!ev.error && !ev.complete)
pills.push(
`<span class="log-pill lp-running"><span class="ms">pending</span>RUNNING</span>`,
);
const hmacBadge = hmacPillHtml(ev._hmacState);
let expandableCount = 0;
const kvsHtmlList = Object.entries(ev.detail || {}).map(
([k, v]) => {
const uid = "kv-" + Math.random().toString(36).substring(2, 9);
let isExpandable = false;
let shortStr = "";
let fullStr = "";
if (typeof v === "object" && v !== null) {
isExpandable = true;
shortStr = JSON.stringify(v);
fullStr = JSON.stringify(v, null, 2);
} else {
shortStr = String(v);
if (shortStr.length > 50) {
isExpandable = true;
fullStr = shortStr;
}
}
if (isExpandable) {
expandableCount++;
return `
<div class="log-kv expandable" id="${uid}">
<button class="kv-toggle" onclick="toggleKvp('${uid}')">+</button>
<div class="log-kv-key">${escHtml(k)}</div>
<div class="log-kv-val">
<div class="val-short">${escHtml(shortStr)}</div>
<pre class="val-full">${escHtml(fullStr)}</pre>
</div>
</div>
`;
} else {
return `
<div class="log-kv">
<div class="kv-toggle-placeholder"></div>
<div class="log-kv-key">${escHtml(k)}</div>
<div class="log-kv-val val-short">${escHtml(shortStr)}</div>
</div>
`;
}
},
);
let detailHtml = `<span style="color:var(--dim)">—</span>`;
if (kvsHtmlList.length > 0) {
let expandAllHtml = "";
if (expandableCount > 1) {
expandAllHtml = `<div class="detail-controls"><button class="expand-all-btn" onclick="toggleAllKvp('${entryUid}', this)">[+] EXPAND ALL</button></div>`;
}
detailHtml = `<div class="log-detail-grid" id="${entryUid}">${expandAllHtml}${kvsHtmlList.join("")}</div>`;
}
return `
<div class="log-entry ${state}">
<div class="log-time">
<div>${date}</div>
<div>${ts} UTC</div>
<div class="log-seq">#${ev.seq}</div>
</div>
<div class="log-info">
<div class="log-title">${escHtml(ev.title)}</div>
${hmacBadge ? `<div class="log-hmac-wrapper">${hmacBadge}</div>` : ""}
</div>
<div class="log-detail-col">
${detailHtml}
</div>
<div class="log-status-col">
${pills.join("")}
</div>
</div>`;
})
.join("");
logContainer.innerHTML = headerHtml + eventsHtml;
if (logSortAsc) {
if (isAtBottom) logContainer.scrollTop = logContainer.scrollHeight;
else logContainer.scrollTop = prevScrollTop;
} else {
if (isAtTop) logContainer.scrollTop = 0;
else
logContainer.scrollTop =
prevScrollTop + (logContainer.scrollHeight - prevScrollHeight);
}
}
function hmacPillHtml(state) {
switch (state) {
case "none":
return "";
case "unknown":
return `<span class="hmac-badge lp-lock-unknown" title="No secret"><span class="ms">lock</span>HMAC</span>`;
case "invalid":
return `<span class="hmac-badge lp-lock-invalid" title="Failed"><span class="ms">lock_open</span>HMAC</span>`;
case "valid":
return `<span class="hmac-badge lp-lock-valid" title="Verified"><span class="ms">verified</span>HMAC</span>`;
default:
return "";
}
}
function download(filename, content, mime) {
const a = document.createElement("a");
a.href = URL.createObjectURL(new Blob([content], { type: mime }));
a.download = filename;
a.click();
URL.revokeObjectURL(a.href);
}
function safeFilename(s) {
return s
.replace(/[^a-z0-9_\-]/gi, "-")
.replace(/-+/g, "-")
.slice(0, 60);
}
function jobToJsonObj(t, job, filteredEvents) {
return {
url: t.url,
topic: t.topic,
host: job.host,
events: filteredEvents.map((ev) => ({
seq: ev.seq,
timestamp: ev.timestamp,
title: ev.title,
error: ev.error,
complete: ev.complete,
detail: ev.detail || {},
hmac_verified: ev._hmacState,
})),
};
}
function jobToCSVRows(t, job, filteredEvents, dk) {
return filteredEvents.map((ev) => {
const date = new Date(ev.timestamp * 1000)
.toISOString()
.slice(0, 19)
.replace("T", " ");
return [
t.topic,
job.host,
ev.timestamp,
date,
ev.seq,
ev.title,
ev.error ? 1 : 0,
ev.complete ? 1 : 0,
ev._hmacState || "",
...dk.map((k) => ev.detail?.[k] ?? ""),
].map(csvCell);
});
}
function exportActive(fmt) {
if (!activeJob) {
alert("No active job selected.");
return;
}
const t = topics.get(activeJob.topicKey);
const job = t?.jobs.get(activeJob.jobId);
if (!job) return;
const filteredEvents = getFilteredEvents(job);
if (filteredEvents.length === 0) {
alert("No matching events to export.");
return;
}
const ts = new Date().toISOString().slice(0, 19).replace(/[:.]/g, "-");
const fn = `flare-${safeFilename(t.topic)}-${safeFilename(job.host)}-${ts}`;
if (fmt === "json") {
download(
fn + ".json",
JSON.stringify(jobToJsonObj(t, job, filteredEvents), null, 2),
"application/json",
);
} else {
const dk = [
...new Set(
filteredEvents.flatMap((ev) => Object.keys(ev.detail || {})),
),
];
const hdr = [
"session",
"host",
"timestamp",
"date_utc",
"seq",
"title",
"error",
"complete",
"hmac_state",
...dk,
];
const rows = [hdr, ...jobToCSVRows(t, job, filteredEvents, dk)];
download(
fn + ".csv",
rows.map((r) => r.join(",")).join("\n"),
"text/csv",
);
}
}
function csvCell(v) {
const s = String(v ?? "");
return s.includes(",") || s.includes('"') || s.includes("\n")
? `"${s.replace(/"/g, '""')}"`
: s;
}
function escHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
</script>
</body>
</html>