1587 lines
46 KiB
HTML
1587 lines
46 KiB
HTML
<!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, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """);
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|