igneum/relay/ui.html

588 lines
56 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Igneum console</title>
<meta name="robots" content="noindex, nofollow">
<meta name="theme-color" content="#0C0C0E">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Crect width='1024' height='1024' fill='%230C0C0E'/%3E%3Cg transform='translate(166.95 166.95) scale(6.901)'%3E%3Cpolygon points='50,4 74,34 67,58 80,54 61,96 39,96 20,54 33,58 26,34' fill='%23F2541B'/%3E%3Cpolygon points='50,42 59,58 50,82 41,58' fill='%230C0C0E'/%3E%3C/g%3E%3C/svg%3E" type="image/svg+xml">
<link rel="icon" href="/favicon.ico" sizes="48x48">
<link rel="icon" href="/favicon-32.png" type="image/png" sizes="32x32">
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Unbounded:wght@700;900&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap">
<style>
:root{--obsidian:#0C0C0E;--graphite:#16161A;--line:#2A2A30;--line-2:#3A3A42;--ember:#F2541B;--molten:#FFB35C;--bone:#F4F1EC;--ash:#9A9A9E;--ink-2:#C9C7C2;--ember-ink:#0C0C0E;--red:#E5484D;--green:#5FBF8A;--gutter:16px;--r:14px}
*{box-sizing:border-box}
html,body{margin:0;background:var(--obsidian);color:var(--bone)}
body{font-family:'IBM Plex Sans',system-ui,-apple-system,sans-serif;font-size:15px;line-height:1.5;-webkit-font-smoothing:antialiased;padding-bottom:env(safe-area-inset-bottom)}
a{color:inherit;text-decoration:none}
button{font:inherit;color:inherit;cursor:pointer}
.mono{font-family:'IBM Plex Mono',monospace}
.wrap{max-width:960px;margin:0 auto;padding-inline:var(--gutter)}
header{position:sticky;top:0;z-index:5;background:rgba(12,12,14,.9);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border-bottom:1px solid var(--line)}
header .wrap{display:flex;align-items:center;justify-content:space-between;min-height:52px;gap:12px}
.brand{display:flex;align-items:center;gap:10px}
.brand .word{font-family:'Unbounded',sans-serif;font-weight:900;font-size:15px;letter-spacing:.06em}
.brand .sub{font-family:'IBM Plex Mono',monospace;font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--ash)}
.status{font-family:'IBM Plex Mono',monospace;font-size:11px;color:var(--ash);display:flex;align-items:center;gap:8px;white-space:nowrap}
.dot{width:8px;height:8px;border-radius:50%;background:var(--molten);display:inline-block}
.dot.off{background:var(--ash)}
.tabs{display:flex;gap:4px;overflow-x:auto;scrollbar-width:none;border-bottom:1px solid var(--line);background:var(--obsidian);position:sticky;top:52px;z-index:4}
.tabs::-webkit-scrollbar{display:none}
.tabs button{flex:0 0 auto;background:transparent;border:0;border-bottom:2px solid transparent;padding:10px 12px;font-family:'IBM Plex Mono',monospace;font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:var(--ash);min-height:42px}
.tabs button.on{color:var(--bone);border-bottom-color:var(--ember)}
.tabs button .n{display:inline-block;margin-left:6px;background:var(--line-2);color:var(--ink-2);border-radius:999px;padding:0 6px;font-size:10px;line-height:16px}
.tabs button .n.hot{background:var(--ember);color:var(--ember-ink)}
main{padding-block:14px 48px}
.panel{display:none;flex-direction:column;gap:12px}
.panel.on{display:flex}
.card{background:var(--graphite);border:1px solid var(--line);border-radius:var(--r);padding:14px;min-width:0}
.card.red{border-color:var(--red);box-shadow:inset 3px 0 0 var(--red)}
.card.live{box-shadow:inset 3px 0 0 var(--green)}
.card.stopped{box-shadow:inset 3px 0 0 var(--ash)}
.card h3{margin:0;font-family:'Unbounded',sans-serif;font-weight:700;font-size:14px;letter-spacing:.02em;display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.card h3 .id{font-family:'IBM Plex Mono',monospace;font-weight:400;font-size:11px;color:var(--ash);letter-spacing:.04em}
.card h3 .seen{margin-left:auto;font-family:'IBM Plex Mono',monospace;font-weight:400;font-size:11px;color:var(--ash)}
.card h3 .seen.bad{color:var(--red)}
.grid{display:grid;grid-template-columns:1fr;gap:12px}
.kv{display:grid;grid-template-columns:repeat(2,1fr);gap:8px 12px;margin-top:10px}
.kv div{min-width:0}
.kv .k{font-family:'IBM Plex Mono',monospace;font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--ash)}
.kv .v{font-family:'IBM Plex Mono',monospace;font-size:14px;color:var(--bone);overflow-wrap:anywhere}
.kv .v small{font-size:11px;color:var(--ash)}
.kv .v.bad{color:var(--red)}
.kv .v.good{color:var(--green)}
.kv .v.warm{color:var(--molten)}
.tiles{display:grid;grid-template-columns:repeat(2,1fr);gap:8px}
.tile{background:var(--obsidian);border:1px solid var(--line);border-radius:10px;padding:10px 12px;min-width:0}
.tile .k{font-family:'IBM Plex Mono',monospace;font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--ash)}
.tile .v{font-family:'IBM Plex Mono',monospace;font-size:20px;line-height:1.2;color:var(--bone);margin-top:4px;overflow-wrap:anywhere}
.tile .v small{font-size:11px;color:var(--ash)}
.tile.bad .v{color:var(--red)}
.tile.warm .v{color:var(--molten)}
table.t{width:100%;border-collapse:collapse;font-family:'IBM Plex Mono',monospace;font-size:12px;margin-top:10px}
.tw{overflow-x:auto;scrollbar-width:thin;margin-inline:-2px;padding-inline:2px}
.tw table.t{min-width:420px}
table.t td.nw{white-space:nowrap}
table.t th{text-align:left;color:var(--ash);font-weight:400;font-size:10px;letter-spacing:.14em;text-transform:uppercase;padding:4px 6px 4px 0;border-bottom:1px solid var(--line)}
table.t td{padding:6px 6px 6px 0;border-bottom:1px solid var(--line);vertical-align:top;overflow-wrap:anywhere}
table.t tr:last-child td{border-bottom:0}
table.t td.num,table.t th.num{text-align:right;white-space:nowrap}
.note{font-family:'IBM Plex Mono',monospace;font-size:12px;color:var(--ash);line-height:1.5}
.note.bad{color:var(--red)}
.ev{font-family:'IBM Plex Mono',monospace;font-size:12px;color:var(--ink-2);display:flex;gap:8px;padding:4px 0;border-bottom:1px solid var(--line)}
.ev:last-child{border-bottom:0}
.ev .t{color:var(--ash);white-space:nowrap}
.ev .k{color:var(--molten);white-space:nowrap}
.ev .k.error{color:var(--red)}
.ev .k.ok{color:var(--green)}
.spark{width:100%;height:72px;display:block;margin-top:8px}
.spark .l{fill:none;stroke:var(--ember);stroke-width:2;stroke-linejoin:round;stroke-linecap:round}
.spark .a{fill:rgba(242,84,27,.12)}
.spark .g{stroke:var(--line);stroke-width:1}
.spark .pt{fill:var(--bone);stroke:var(--obsidian);stroke-width:2}
.spark text{font-family:'IBM Plex Mono',monospace;font-size:10px;fill:var(--ash)}
.sparkwrap{position:relative}
.tip{position:absolute;top:-2px;right:0;font-family:'IBM Plex Mono',monospace;font-size:11px;color:var(--ink-2);background:var(--obsidian);border:1px solid var(--line-2);border-radius:6px;padding:2px 8px;pointer-events:none;display:none}
.chip{flex:0 0 auto;border:1px solid var(--line-2);background:transparent;color:var(--ink-2);border-radius:999px;padding:6px 12px;font-size:13px;min-height:34px}
.chip.on{background:var(--bone);color:var(--obsidian);border-color:var(--bone)}
.badge{font-family:'IBM Plex Mono',monospace;font-size:10px;letter-spacing:.14em;text-transform:uppercase;border-radius:6px;padding:3px 7px;border:1px solid var(--line-2);color:var(--ink-2);white-space:nowrap}
.badge.text{color:var(--ink-2)}
.badge.file{color:var(--molten);border-color:rgba(255,179,92,.5)}
.badge.task{background:var(--ember);color:var(--ember-ink);border-color:var(--ember)}
.badge.run{background:var(--molten);color:var(--ember-ink);border-color:var(--molten)}
.badge.result{color:var(--molten);border-color:var(--molten)}
.badge.log{color:var(--green);border-color:rgba(95,191,138,.5)}
.badge.build{color:var(--molten);border-color:var(--molten)}
.badge.note{color:var(--ink-2)}
.badge.done{color:var(--green);border-color:rgba(95,191,138,.5)}
.badge.running{color:var(--molten);border-color:var(--molten)}
.badge.queued{color:var(--ash)}
.badge.fail{color:var(--red);border-color:var(--red)}
.item{background:var(--graphite);border:1px solid var(--line);border-radius:var(--r);padding:12px 14px;display:flex;flex-direction:column;gap:8px;cursor:pointer;min-width:0}
.item.unread{border-color:var(--line-2);box-shadow:inset 3px 0 0 var(--ember)}
.item.done{opacity:.7}
.ihead{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.ihead .route{font-family:'IBM Plex Mono',monospace;font-size:11px;color:var(--ash)}
.ihead .route b{color:var(--ink-2);font-weight:500}
.ihead .time{margin-left:auto;font-family:'IBM Plex Mono',monospace;font-size:11px;color:var(--ash);white-space:nowrap}
.title{font-weight:600;font-size:15px;line-height:1.35;overflow-wrap:anywhere}
.preview{color:var(--ink-2);font-family:'IBM Plex Mono',monospace;font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.body{display:none;flex-direction:column;gap:8px}
.item.open .body{display:flex}
.item.open .preview{display:none}
pre{position:relative;margin:0;background:var(--obsidian);border:1px solid var(--line);border-radius:10px;padding:12px;font-family:'IBM Plex Mono',monospace;font-size:12.5px;line-height:1.5;white-space:pre-wrap;overflow-wrap:anywhere;color:var(--ink-2);max-height:60vh;overflow:auto}
pre.code{color:var(--bone)}
.block{position:relative}
.copy{position:absolute;top:6px;right:6px;z-index:1}
.copy.ok{border-color:var(--molten);color:var(--molten)}
.file{display:flex;align-items:center;gap:10px;flex-wrap:wrap;font-family:'IBM Plex Mono',monospace;font-size:12.5px}
.file .name{color:var(--molten);overflow-wrap:anywhere}
.file .size{color:var(--ash)}
.acts{display:flex;gap:8px;flex-wrap:wrap}
.flags{font-family:'IBM Plex Mono',monospace;font-size:11px;color:var(--ash)}
.empty{color:var(--ash);text-align:center;padding:32px 0;font-family:'IBM Plex Mono',monospace;font-size:12px}
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:42px;padding:9px 16px;border-radius:10px;font-weight:600;font-size:14px;border:1px solid var(--line-2);color:var(--bone);background:transparent;transition:border-color .15s,background .15s}
.btn:hover{border-color:var(--ash)}
.btn.primary{background:var(--ember);color:var(--ember-ink);border-color:var(--ember)}
.btn.primary:disabled{opacity:.5;cursor:default}
.btn.small{min-height:32px;padding:5px 11px;font-size:12px;font-weight:500}
.more{align-self:center}
/* relay tab (kept from the relay page) */
.waiting{display:flex;gap:8px;overflow-x:auto;scrollbar-width:none;padding-bottom:2px}
.waiting::-webkit-scrollbar{display:none}
.wchip{flex:0 0 auto;display:inline-flex;align-items:center;gap:8px;border:1px solid var(--line-2);border-radius:999px;padding:6px 12px;background:var(--graphite);font-family:'IBM Plex Mono',monospace;font-size:12px;color:var(--ink-2)}
.wchip b{color:var(--bone);font-weight:500}
.wchip .n{background:var(--ember);color:var(--ember-ink);border-radius:999px;min-width:20px;height:20px;display:inline-flex;align-items:center;justify-content:center;font-weight:500;padding-inline:6px}
.wchip.zero .n{background:var(--line-2);color:var(--ash)}
.wchip.on{border-color:var(--ember)}
.wchip .role{color:var(--ash)}
.unknown{border:1px solid var(--molten);border-radius:var(--r);padding:12px 14px;background:var(--graphite);display:flex;flex-wrap:wrap;gap:10px;align-items:center;font-size:14px}
.unknown .mono{color:var(--molten)}
.unknown input{width:90px}
.composer{display:flex;flex-direction:column;gap:10px}
.row{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
.row label{font-family:'IBM Plex Mono',monospace;font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--ash)}
select,input[type=text],textarea{background:var(--obsidian);border:1px solid var(--line-2);color:var(--bone);border-radius:10px;padding:10px 12px;font:inherit;min-height:42px}
select{appearance:none;-webkit-appearance:none;padding-right:30px;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%239A9A9E' stroke-width='1.6' fill='none'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 11px center}
textarea{width:100%;min-height:96px;resize:vertical;font-family:'IBM Plex Mono',monospace;font-size:14px;line-height:1.45}
input[type=text]{width:100%}
.drop{border:1px dashed var(--line-2);border-radius:10px;padding:12px;text-align:center;color:var(--ash);font-size:13px;cursor:pointer}
.drop.over{border-color:var(--ember);color:var(--bone)}
.drop.has{border-style:solid;border-color:var(--molten);color:var(--bone)}
.drop input{display:none}
.seg{display:inline-flex;border:1px solid var(--line-2);border-radius:10px;overflow:hidden}
.seg button{background:transparent;border:0;padding:8px 12px;font-size:13px;color:var(--ash);min-height:40px}
.seg button.on{background:var(--line);color:var(--bone)}
.progress{height:3px;background:var(--line);border-radius:2px;overflow:hidden;display:none}
.progress i{display:block;height:100%;width:0;background:var(--ember);transition:width .2s}
.msg{font-family:'IBM Plex Mono',monospace;font-size:12px;color:var(--ash);min-height:16px}
.msg.err{color:var(--ember)}
.filters{display:flex;gap:8px;overflow-x:auto;scrollbar-width:none}
.filters::-webkit-scrollbar{display:none}
.group{display:flex;flex-direction:column;gap:8px}
.ghead{display:flex;align-items:baseline;justify-content:space-between;gap:10px;padding:8px 2px 2px}
.ghead h2{font-family:'Unbounded',sans-serif;font-weight:700;font-size:14px;margin:0;letter-spacing:.02em}
.ghead .meta{font-family:'IBM Plex Mono',monospace;font-size:11px;color:var(--ash)}
.modal{position:fixed;inset:0;background:rgba(12,12,14,.92);z-index:20;display:none;flex-direction:column;padding:12px var(--gutter)}
.modal.on{display:flex}
.modal .mhead{display:flex;align-items:center;gap:10px;font-family:'IBM Plex Mono',monospace;font-size:12px;color:var(--ash);padding-bottom:8px}
.modal .mhead b{color:var(--bone);font-weight:500;overflow-wrap:anywhere}
.modal .mhead button{margin-left:auto}
.modal pre{max-height:none;flex:1;overflow:auto}
@media (min-width:700px){:root{--gutter:24px}.grid{grid-template-columns:repeat(2,1fr)}.tiles{grid-template-columns:repeat(4,1fr)}.kv{grid-template-columns:repeat(4,1fr)}.tabs button{font-size:12px;padding:12px 14px}}
@media (min-width:1000px){.grid.three{grid-template-columns:repeat(3,1fr)}}
</style>
</head>
<body>
<header><div class="wrap">
<div class="brand"><svg width="22" height="22" viewBox="0 0 100 100" aria-hidden="true"><polygon points="50,4 74,34 67,58 80,54 61,96 39,96 20,54 33,58 26,34" fill="#F2541B"/><polygon points="50,42 59,58 50,82 41,58" fill="#0C0C0E"/></svg><span class="word">IGNEUM</span><span class="sub">console</span></div>
<div class="status"><span class="dot" id="dot"></span><span id="stat">connecting</span></div>
</div></header>
<nav class="tabs" id="tabs"><div class="wrap" style="display:flex;gap:4px">
<button data-tab="machines" class="on">Machines<span class="n" id="n-machines"></span></button>
<button data-tab="jobs">Jobs<span class="n" id="n-jobs"></span></button>
<button data-tab="builds">Builds</button>
<button data-tab="chain">Chain</button>
<button data-tab="log">Work log<span class="n" id="n-log"></span></button>
<button data-tab="results">Results</button>
<button data-tab="relay">Relay<span class="n" id="n-relay"></span></button>
</div></nav>
<main class="wrap">
<section class="panel on" id="p-machines"><div class="empty">loading</div></section>
<section class="panel" id="p-jobs"><div class="empty">loading</div></section>
<section class="panel" id="p-builds"><div class="empty">loading</div></section>
<section class="panel" id="p-chain"><div class="empty">loading</div></section>
<section class="panel" id="p-log"><div class="empty">loading</div></section>
<section class="panel" id="p-results"><div class="empty">loading</div></section>
<section class="panel" id="p-relay">
<div id="unknown"></div>
<div class="waiting" id="waiting"></div>
<section class="card composer" id="composer">
<div class="row">
<label>from</label><select id="from"></select>
<label>to</label><select id="to"></select>
<div class="seg" id="kindseg"><button data-k="text" class="on">note</button><button data-k="task">task</button></div>
</div>
<input type="text" id="title" placeholder="title (optional for notes, the headline for tasks)">
<textarea id="text" placeholder="paste text, a command, a log, a path. Shift+Enter for a new line, Enter sends."></textarea>
<label class="drop" id="drop"><input type="file" id="file"><span id="droplabel">drop a file here or tap to pick one (up to 50 MB)</span></label>
<div class="progress" id="prog"><i></i></div>
<div class="row"><button class="btn primary" id="send">Send</button><button class="btn" id="clear">Clear</button><span class="msg" id="msg"></span></div>
</section>
<div class="filters" id="filters"></div>
<div id="feed"><div class="empty">loading</div></div>
<button class="btn more" id="more" style="display:none">older</button>
</section>
</main>
<div class="modal" id="modal"><div class="mhead"><b id="mtitle"></b><button class="btn small" id="mclose">close</button></div><pre id="mbody"></pre></div>
<script>
(() => {
const m = location.pathname.match(/^\/r\/([^/]+)/);
const TOKEN = m ? m[1] : '';
const API = `/r/${TOKEN}/api/`;
const CAPI = `/r/${TOKEN}/c/`;
const $ = s => document.querySelector(s);
const DEFAULT_MACHINES = ['Mac', 'PC1', 'PC2', 'Phone'];
const state = { tab: (location.hash || '#machines').slice(1), items: [], machines: [], filter: 'all', from: localStorage.getItem('relay.from') || guessFrom(), to: localStorage.getItem('relay.to') || 'all', kind: 'text', file: null, open: new Set(), maxId: 0, minId: 0, more: true, log: [], data: {}, openIds: new Set() };
function guessFrom() { return /iPhone|Android|iPad/i.test(navigator.userAgent) ? 'Phone' : 'Mac'; }
async function call(base, fn, opts = {}) {
const r = await fetch(base + fn + (opts.q ? '?' + new URLSearchParams(opts.q) : ''), opts.body !== undefined ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(opts.body) } : {});
const j = await r.json().catch(() => ({ ok: false, error: r.status }));
if (!r.ok || j.ok === false) throw new Error(j.error || ('http ' + r.status));
return j;
}
const api = (fn, o) => call(API, fn, o);
const capi = (fn, o) => call(CAPI, fn, o);
const fmtSize = n => n < 1024 ? n + ' B' : n < 1048576 ? (n / 1024).toFixed(1) + ' KB' : (n / 1048576).toFixed(1) + ' MB';
const ago = ts => { if (!ts) return 'never'; const s = (Date.now() - new Date(ts)) / 1000; if (s < 60) return Math.max(0, Math.floor(s)) + 's'; if (s < 3600) return Math.floor(s / 60) + 'm'; if (s < 86400) return Math.floor(s / 3600) + 'h'; return Math.floor(s / 86400) + 'd'; };
const when = ts => { if (!ts) return '-'; const d = new Date(ts); return d.toLocaleDateString(undefined, { day: 'numeric', month: 'short' }) + ' ' + d.toTimeString().slice(0, 5); };
const esc = s => String(s ?? '').replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const nf = (n, d = 0) => n === null || n === undefined || isNaN(n) ? '?' : Number(n).toLocaleString(undefined, { maximumFractionDigits: d, minimumFractionDigits: d });
const mh = n => n === null || n === undefined ? '?' : n >= 1000 ? (n / 1000).toFixed(2) + ' GH/s' : n.toFixed(1) + ' MH/s';
function setStat(txt, on) { $('#stat').textContent = txt; $('#dot').classList.toggle('off', !on); }
function tile(k, v, cls = '') { return `<div class="tile ${cls}"><div class="k">${k}</div><div class="v">${v}</div></div>`; }
function kvs(list) { return `<div class="kv">${list.map(([k, v, cls]) => `<div><div class="k">${k}</div><div class="v ${cls || ''}">${v}</div></div>`).join('')}</div>`; }
function showModal(title, text) { $('#mtitle').textContent = title; $('#mbody').textContent = text; $('#modal').classList.add('on'); }
$('#mclose').onclick = () => $('#modal').classList.remove('on');
$('#modal').addEventListener('click', e => { if (e.target === $('#modal')) $('#modal').classList.remove('on'); });
// ---- tabs ----
function setTab(t) {
state.tab = t; history.replaceState(null, '', '#' + t);
document.querySelectorAll('.tabs button').forEach(b => b.classList.toggle('on', b.dataset.tab === t));
document.querySelectorAll('.panel').forEach(p => p.classList.toggle('on', p.id === 'p-' + t));
refreshTab(t);
}
document.querySelectorAll('.tabs button').forEach(b => b.onclick = () => setTab(b.dataset.tab));
// ---- machines ----
function cardTable(cards) {
if (!cards.length) return '';
return `<div class="tw"><table class="t"><thead><tr><th>card</th><th class="num">now</th><th class="num">accepted</th><th class="num">rej/mis</th><th class="num">restarts</th><th>status</th></tr></thead><tbody>${cards.map(c => `<tr><td class="nw">${esc(c.label.replace(/-[0-9a-f]{8}-/, ' '))}${c.identities ? `<br><small style="color:var(--ash)">${c.identities} ids${c.synced === false ? ', NOT SYNCED' : ''}</small>` : ''}</td><td class="num">${c.stale ? `<span style="color:var(--ash)" title="last STATUS line ${c.status_at ? ago(c.status_at) + ' ago' : 'never'}; not in the total">last ${mh(c.hash_now)}</span>` : mh(c.hash_now)}</td><td class="num">${nf(c.accepted)}</td><td class="num">${nf(c.rejected)}/${nf(c.mismatched)}</td><td class="num">${nf(c.restarts)}</td><td>${c.status_at ? ago(c.status_at) + ' ago' : 'no STATUS'}${c.stale ? ' <span style="color:var(--red)">stale</span>' : ''}${c.fault ? `<br><span style="color:var(--red)">${esc(c.fault)}</span>` : ''}</td></tr>`).join('')}</tbody></table></div>`;
}
function machineCard(mc) {
const n = mc.node || {};
const silentTxt = mc.never ? 'never seen' : mc.stopped ? `stopped (${esc(mc.stopped.reason)}) ${ago(mc.stopped.at)} ago` : (mc.silent ? `silent ${ago(mc.last_seen)}` : `seen ${ago(mc.last_seen)} ago`);
// the job channel's ping (MF-11): a dead app polls nothing, and this line says so after 15 minutes, with the last job it ran
const p = mc.poll;
const pollTxt = !p ? '' : p.never ? `<div class="note" style="margin-top:8px;color:var(--ash)">job channel: no poll on record (an app before 0.3.21, or never started)</div>`
: p.silent ? `<div class="note" style="margin-top:8px;color:var(--red)">job channel silent since ${when(p.since)} (${ago(p.since)} ago)${p.last_job ? `, last job ${esc(p.last_job)}` : ', no job on record'}${p.version ? ` <small>(app ${esc(p.version)})</small>` : ''}: nothing reaches this machine until the app runs again</div>`
: `<div class="note" style="margin-top:8px">job channel polled ${ago(p.since)} ago${p.last_job ? `, last job ${esc(p.last_job)}` : ''}</div>`;
const tele = mc.telemetry && mc.telemetry.length ? `<div class="tw"><table class="t"><thead><tr><th>telemetry</th><th class="num">p95</th><th class="num">max</th><th class="num">cap</th><th class="num">GPU</th><th class="num">mem</th></tr></thead><tbody>${mc.telemetry.map(t => `<tr><td>${esc(t.name)}</td><td class="num">${t.p95_w} W</td><td class="num">${t.max_w} W</td><td class="num">${t.cap_w} W</td><td class="num ${t.max_gpu_c >= 85 ? 'bad' : ''}">${t.max_gpu_c} C</td><td class="num ${t.max_mem_c >= 95 ? 'bad' : ''}">${t.max_mem_c} C</td></tr>`).join('')}</tbody></table></div>` : (mc.cards.length ? `<div class="note" style="margin-top:8px">${mc.app ? 'no stability lines yet (power and temperatures come from nvidia-smi every 5 min; none on a Mac)' : 'the app log is not in the intake (an app before 0.3.3)'}</div>` : '');
const events = mc.events && mc.events.length ? `<div style="margin-top:8px">${mc.events.map(e => `<div class="ev"><span class="t">${when(e.at)}</span><span class="k ${e.kind}">${e.kind}</span><span>${esc(e.text)}</span></div>`).join('')}</div>` : '';
return `<article class="card ${mc.stopped ? 'stopped' : mc.silent ? 'red' : 'live'}">
<h3>${esc(mc.name)}<span class="id">${esc(mc.id8 || '')}${mc.host ? ' ' + esc(mc.host) : ''}${mc.legacy ? ' (old launcher)' : ''}</span><span class="seen ${mc.silent ? 'bad' : ''}">${silentTxt}</span></h3>
${pollTxt}${mc.never ? '<div class="note" style="margin-top:8px">nothing from this machine in the log intake in the last 48 h</div>' : kvs([
['app', mc.app_version ? esc(mc.app_version) : '<small>not in the intake</small>'],
['ota', mc.app && mc.app.ota ? esc(mc.app.ota.state) : (mc.app_version ? 'no update pending' : '?'), mc.app && mc.app.ota ? ((mc.app.ota.state === 'updated' || mc.app.ota.state === 'current') ? 'good' : 'warm') : ''],
['node', n.version ? esc(n.version) : '?'],
['height (daa)', n.daa != null ? nf(n.daa) : '?', ''],
['synced', n.at ? (n.synced ? 'yes' : (n.ibd ? 'IBD' : 'no')) : '?', n.at ? (n.synced ? 'good' : 'bad') : ''],
['hash rate', mh(mc.hash_total), mc.hash_total ? '' : 'warm'],
['accepted', `${nf(mc.accepted_total)}${mc.accepted_lifetime != null ? `<small> of ${nf(mc.accepted_lifetime)} lifetime</small>` : ''}`],
['peers', n.peers != null ? `${n.peers}${n.peers_wanted ? `<small>/${n.peers_wanted}</small>` : ''}` : '?'],
['blocks / 10 s', n.blocks_10s != null ? nf(n.blocks_10s) : '?'],
['faults', nf(mc.faults), mc.faults ? 'bad' : 'good'],
['node warnings', nf(n.warnings || 0), n.warnings ? 'warm' : ''],
['checkpoint', n.checkpoint != null ? nf(n.checkpoint) : '?'],
['run', `<small>${esc((n.run_id || (mc.runs && mc.runs[0]) || '').replace(/^.*-(\d{8}-\d{6})$/, '$1'))}</small>`],
])}
${cardTable(mc.cards)}${mc.gpus && mc.gpus.length ? `<div class="note" style="margin-top:8px">GPUs ${mc.gpus_at ? ago(mc.gpus_at) + ' ago' : ''}: ${mc.gpus.map(g => `${esc(g.name)} <span style="color:var(--ash)">[${esc(g.kind)}, ${/not usable|removed/.test(g.state) ? `<span style="color:var(--red)">${esc(g.state)}</span>` : esc(g.state)}]</span>`).join(' | ')}</div>` : ''}${(mc.hotplug || []).map(h => `<div class="note">hot-plug ${ago(h.at)} ago: ${esc(h.text)}</div>`).join('')}${tele}
${mc.app && (mc.app.ota || mc.app.power_cap || mc.app.jobs_file || (mc.app.jobs && mc.app.jobs.length) || mc.app.status) ? `<div style="margin-top:8px">
${mc.app.status ? `<div class="ev"><span class="t">${when(mc.app.status.at)}</span><span class="k">status</span><span>${esc(mc.app.status.miner)} | node ${esc(mc.app.status.node)} | up ${esc(mc.app.status.up)}</span></div>` : ''}
${mc.app.ota ? `<div class="ev"><span class="t">${when(mc.app.ota.at)}</span><span class="k ${(mc.app.ota.state === 'updated' || mc.app.ota.state === 'current') ? 'ok' : ''}">ota</span><span>${esc(mc.app.ota.text)}</span></div>` : ''}
${mc.app.power_cap ? `<div class="ev"><span class="t">${when(mc.app.power_cap.at)}</span><span class="k ${mc.app.power_cap.ok ? 'ok' : 'error'}">power</span><span>${esc(mc.app.power_cap.text)}</span></div>` : ''}
${mc.app.jobs_file ? `<div class="ev"><span class="t">${when(mc.app.jobs_file.at)}</span><span class="k">jobs</span><span>${esc(mc.app.jobs_file.text)}</span></div>` : ''}
${(mc.app.jobs || []).map(j => `<div class="ev"><span class="t">${when(j.at)}</span><span class="k">job</span><span>${esc(j.text)}</span></div>`).join('')}
</div>` : ''}${events}
${n.last_warning ? `<div class="note" style="margin-top:8px;color:var(--molten)">${esc(n.last_warning)}</div>` : ''}
</article>`;
}
function renderMachines(j) {
const live = j.machines.filter(mc => !mc.silent).length;
$('#n-machines').textContent = `${live}/${j.machines.length}`;
$('#n-machines').className = 'n' + (j.machines.some(mc => mc.silent && !mc.never && mc.name !== 'Mac') ? ' hot' : '');
$('#p-machines').innerHTML = `<div class="note">red after ${j.silent_after_s / 60} min without an upload; the job channel reads silent after ${(j.poll_silent_after_s || 900) / 60} min without a poll; unnamed ids drop off after ${j.hide_after_s / 60} min. Hash rate and accepted blocks come from the newest STATUS line per card, height and peers from the node log, power and temperatures from the app's stability lines. Refreshed ${when(j.now)}.</div><div class="grid">${j.machines.map(machineCard).join('')}</div>`;
}
// ---- jobs ----
function renderJobs(j) {
const running = j.jobs.reduce((a, jb) => a + jb.runs.filter(r => r.status === 'running').length, 0);
$('#n-jobs').textContent = j.jobs.length ? String(j.jobs.length) : ''; $('#n-jobs').className = 'n' + (running ? ' hot' : '');
if (!j.jobs.length) { $('#p-jobs').innerHTML = `<div class="empty">${esc(j.note || 'no jobs yet')}</div>`; return; }
$('#p-jobs').innerHTML = `${j.note ? `<div class="note bad">${esc(j.note)}</div>` : `<div class="note">igneum-jobs.json from the downloads host${j.generated ? ', generated ' + when(j.generated) : ''}. Status per machine from the intake (run_id job-&lt;id&gt;-&lt;machine&gt;). Tap a run for its full result.</div>`}
${j.jobs.map(jb => `<article class="card"><h3>${esc(jb.title)}<span class="id">${esc(jb.id)}${jb.unlisted ? ' (result only, not in the file)' : ''}</span><span class="seen">${jb.created ? when(jb.created) : ''}</span></h3>
${jb.command ? `<pre style="margin-top:8px;max-height:120px">${esc(jb.command).slice(0, 2000)}</pre>` : ''}
<div class="note" style="margin-top:6px">to ${esc(Array.isArray(jb.to) ? jb.to.join(', ') : jb.to || 'all')}</div>
${jb.runs.length ? `<div class="tw"><table class="t"><thead><tr><th>machine</th><th>status</th><th>result</th><th class="num">when</th></tr></thead><tbody>${jb.runs.map(r => `<tr class="jobrun" data-run="${esc(r.run_id)}" style="cursor:pointer"><td>${esc(r.machine)}<br><small style="color:var(--ash)">${esc(r.machine_id)}</small></td><td><span class="badge ${r.status === 'done' ? (r.exit_code ? 'fail' : 'done') : 'running'}">${r.status}${r.exit_code != null ? ' ' + r.exit_code : ''}</span></td><td>${esc(r.summary)}</td><td class="num">${ago(r.at)} ago</td></tr>`).join('')}</tbody></table></div>` : '<div class="note" style="margin-top:6px"><span class="badge queued">queued</span> no machine has reported yet</div>'}
</article>`).join('')}`;
$('#p-jobs').querySelectorAll('.jobrun').forEach(tr => tr.onclick = async () => { try { const r = await capi('job', { q: { run_id: tr.dataset.run } }); showModal(`${r.run_id} (${r.machine}, ${when(r.at)})`, r.lines); } catch (e) { showModal(tr.dataset.run, e.message); } });
}
// ---- builds ----
function renderBuilds(j) {
const mf = j.manifest;
const plat = mf && mf.platforms ? Object.entries(mf.platforms) : [];
const dl = j.dl && j.dl.meta && j.dl.meta.files ? j.dl.meta.files : [];
$('#p-builds').innerHTML = `
<article class="card"><h3>Hosted version<span class="seen">${mf ? 'published ' + when(mf.published_at) : ''}</span></h3>
${mf ? `${kvs([['version', esc(mf.version)], ['channel', esc(mf.channel)], ['min supported', esc(mf.min_supported_version || '-')], ['activation', mf.consensus && mf.consensus.activation_height ? nf(mf.consensus.activation_height) : 'none']])}
<div class="note" style="margin-top:8px">${esc(mf.notes)}</div>
<div class="tw"><table class="t"><thead><tr><th>platform</th><th>file</th><th class="num">size</th><th>sha256</th></tr></thead><tbody>${plat.map(([p, v]) => `<tr><td>${esc(p)}</td><td>${esc((v.url || '').split('/').pop())}</td><td class="num">${fmtSize(v.size || 0)}</td><td><small>${esc((v.sha256 || '').slice(0, 12))}</small></td></tr>`).join('')}</tbody></table></div>` : `<div class="note bad" style="margin-top:8px">${j.dl_set ? 'no manifest on the downloads host' : 'DL_TOKEN is not set on the project'}</div>`}
</article>
<article class="card"><h3>Last CI fetch<span class="seen">${j.ci ? when(j.ci.fetched_at) : ''}</span></h3>
${j.ci ? `${kvs([['installer', esc(j.ci.installer)], ['payload', esc(j.ci.payload)]])}<div class="note" style="margin-top:8px"><a href="${esc(j.ci.run)}" target="_blank" rel="noopener" style="color:var(--molten)">${esc(j.ci.run)}</a></div>` : '<div class="note" style="margin-top:8px">no igneum-windows-ci.json yet</div>'}
</article>
<article class="card"><h3>Build events<span class="seen">posted by the Mac scripts</span></h3>
${j.builds.length ? j.builds.map(b => `<div class="ev"><span class="t">${when(b.ts)}</span><span class="k">${esc(b.who)}</span><span>${esc(b.title)}${b.body ? ' <small style="color:var(--ash)">' + esc(b.body.split('\n')[0]).slice(0, 160) + '</small>' : ''}</span></div>`).join('') : '<div class="note" style="margin-top:8px">none yet. fetch-ci-artifacts.sh and publish-manifest.sh post here through tools/console.mjs post --kind build</div>'}
</article>
<article class="card"><h3>Downloads folder<span class="seen">${j.dl ? 'synced ' + when(j.dl.ts) : ''}</span></h3>
${dl.length ? `<div class="tw"><table class="t"><thead><tr><th>file</th><th class="num">size</th><th class="num">time</th></tr></thead><tbody>${dl.map(f => `<tr><td>${esc(f.name)}</td><td class="num">${fmtSize(f.size)}</td><td class="num">${when(f.mtime)}</td></tr>`).join('')}</tbody></table></div>` : '<div class="note" style="margin-top:8px">not synced yet: node tools/console.mjs sync-dl</div>'}
</article>`;
}
// ---- chain ----
function sparkline(series) {
if (!series || series.length < 2) return '';
const W = 600, H = 72, P = 4;
const vals = series.map(p => p[1]);
const max = Math.max(...vals, 1), min = 0;
const x = i => P + i * (W - 2 * P) / (series.length - 1);
const y = v => H - P - (v - min) / (max - min) * (H - 2 * P - 12);
const pts = series.map((p, i) => `${x(i).toFixed(1)},${y(p[1]).toFixed(1)}`);
const last = series[series.length - 1];
return `<div class="sparkwrap"><svg class="spark" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" data-n="${series.length}"><line class="g" x1="${P}" y1="${y(0)}" x2="${W - P}" y2="${y(0)}"/><path class="a" d="M${pts[0]} L${pts.join(' L')} L${x(series.length - 1).toFixed(1)},${y(0)} L${x(0).toFixed(1)},${y(0)} Z"/><path class="l" d="M${pts.join(' L')}"/><circle class="pt" cx="${x(series.length - 1).toFixed(1)}" cy="${y(last[1]).toFixed(1)}" r="4"/><text x="${P}" y="10">${series.length} min, max ${max}/min</text></svg><div class="tip" id="sptip"></div></div>`;
}
function renderChain(j) {
if (!j.live) { $('#p-chain').innerHTML = `<div class="empty">live feed unreachable: ${esc(j.error)}</div>`; return; }
const s = j.state || {}; const f = j.finality || {};
const lock = f.latest_locked_index != null ? `#${nf(f.latest_locked_index)}${f.latest_locked_blue_score != null ? `<small> blue ${nf(f.latest_locked_blue_score)}</small>` : ''}` : 'none';
$('#p-chain').innerHTML = `
<div class="note">${esc(s.network || '')} node ${esc(s.node_version || '?')}, observer ${s.stale ? '<span style="color:var(--red)">STALE ' + nf(s.age_s) + ' s</span>' : 'fresh'}, lag ${nf(s.observer_lag_s, 1)} s, queue ${nf(s.queue_depth)}. Refreshed ${when(j.now)}.</div>
<div class="tiles">
${tile('blocks', nf(s.block_count))}
${tile('identities 10 min', nf(s.miners_10m))}
${tile('estimate', s.hashes_per_second_estimate ? mh(s.hashes_per_second_estimate / 1e6) : '?')}
${tile('difficulty', nf(s.difficulty))}
${tile('last lock', lock, f.active ? '' : 'warm')}
${tile('finality', f.active ? 'active' : 'paused', f.active ? '' : 'warm')}
${tile('peers', nf(s.peers))}
${tile('blocks / s (60 s)', nf(s.blocks_per_second_60s, 2), s.blocks_per_second_60s != null && s.blocks_per_second_60s < 0.7 ? 'bad' : '')}
</div>
<article class="card"><h3>Blocks per minute<span class="seen">last ${(s.blocks_per_minute || []).length} min</span></h3>${sparkline(s.blocks_per_minute || [])}</article>
<article class="card"><h3>Finality<span class="seen">next #${nf(f.next_index)}</span></h3>${kvs([['voters', `${nf(f.voters)}<small>/${nf(f.keys)} keys</small>`], ['blue score', nf(s.blue_score)], ['headers', nf(s.header_count)], ['mempool', nf(s.mempool)]])}
${f.checkpoints && f.checkpoints.length ? `<div class="tw"><table class="t"><thead><tr><th>checkpoint</th><th>state</th><th class="num">of total</th><th class="num">locked</th></tr></thead><tbody>${f.checkpoints.slice().reverse().map(c => `<tr><td>#${c.index}</td><td>${esc(c.state)}</td><td class="num">${c.fraction_total != null ? (c.fraction_total * 100).toFixed(1) + '%' : '-'}</td><td class="num">${c.locked_at ? ago(c.locked_at) + ' ago' : '-'}</td></tr>`).join('')}</tbody></table></div>` : ''}
</article>
<article class="card"><h3>Events</h3><div style="margin-top:8px">${(j.events || []).map(e => `<div class="ev"><span class="t">${when(e.ts)}</span><span class="k">${esc(e.kind)}</span><span>${esc(e.text)}</span></div>`).join('') || '<div class="note">none</div>'}</div></article>
<article class="card"><h3>Hetzner network<span class="seen">${j.hetzner ? 'synced ' + when(j.hetzner.ts) : ''}</span></h3>${j.hetzner ? `<div class="note" style="margin-top:8px">${esc(j.hetzner.title)}, newest file ${j.hetzner.meta.newest ? when(j.hetzner.meta.newest) : '-'}</div><pre style="margin-top:8px;max-height:200px">${esc(j.hetzner.body)}</pre>` : '<div class="note" style="margin-top:8px">no results synced yet: node tools/console.mjs sync-hetzner</div>'}</article>`;
const svg = $('#p-chain svg.spark');
if (svg) {
const series = s.blocks_per_minute || [];
svg.addEventListener('mousemove', e => { const r = svg.getBoundingClientRect(); const i = Math.round((e.clientX - r.left) / r.width * (series.length - 1)); const p = series[Math.max(0, Math.min(series.length - 1, i))]; if (!p) return; const t = $('#sptip'); t.style.display = 'block'; t.textContent = `${new Date(p[0]).toTimeString().slice(0, 5)}: ${p[1]} blocks`; });
svg.addEventListener('mouseleave', () => { $('#sptip').style.display = 'none'; });
}
}
// ---- work log: console items + relay feed items, newest first ----
function renderLog() {
const relayItems = state.items.map(it => ({ id: 'r' + it.id, ts: it.ts, kind: it.kind, who: it.from, to: it.to, title: it.title, body: it.body, relay: it }));
const all = [...state.log.map(it => ({ ...it, id: 'c' + it.id })), ...relayItems].sort((a, b) => (b.ts || '').localeCompare(a.ts || ''));
$('#n-log').textContent = all.length ? String(Math.min(all.length, 999)) : '';
if (!all.length) { $('#p-log').innerHTML = '<div class="empty">nothing posted yet. node tools/console.mjs post --kind log --title "..." --body "..."</div>'; return; }
$('#p-log').innerHTML = `<div class="note">what the agents and the main session post (tools/console.mjs post) plus every relay item, newest first. Tap to expand.</div>` + all.slice(0, 300).map(it => {
const open = state.openIds.has(it.id);
const first = (it.body || '').split('\n').find(l => l.trim()) || '';
return `<article class="item ${open ? 'open' : ''}" data-lid="${it.id}"><div class="ihead"><span class="badge ${esc(it.kind)}">${esc(it.kind)}</span><span class="route"><b>${esc(it.who)}</b>${it.to && it.to !== 'all' ? ' to <b>' + esc(it.to) + '</b>' : ''}</span><span class="time">${when(it.ts)}</span></div>${it.title ? `<div class="title">${esc(it.title)}</div>` : ''}${first ? `<div class="preview">${esc(first)}</div>` : ''}${it.body ? `<div class="body"><pre>${esc(it.body)}</pre></div>` : ''}</article>`;
}).join('');
$('#p-log').querySelectorAll('.item').forEach(el => el.onclick = e => { if (e.target.closest('button,a,pre')) return; const id = el.dataset.lid; state.openIds.has(id) ? state.openIds.delete(id) : state.openIds.add(id); el.classList.toggle('open'); });
}
// ---- results ----
function renderResults(j) {
const led = j.ledger; const counts = led && led.meta && led.meta.counts ? led.meta.counts : {};
$('#p-results').innerHTML = `
<article class="card"><h3>FUD ledger<span class="seen">${led ? 'synced ' + when(led.ts) : ''}</span></h3>${led ? `<div class="tiles" style="margin-top:10px">${tile('entries', nf(led.meta.entries))}${Object.entries(counts).sort((a, b) => b[1] - a[1]).map(([k, v]) => tile(esc(k), nf(v), /^open/i.test(k) ? 'warm' : '')).join('')}</div>` : '<div class="note" style="margin-top:8px">not synced yet: node tools/console.mjs sync-bench</div>'}</article>
<div class="note">bench log, newest first (docs/bench-log.md, synced by tools/console.mjs sync-bench). Tap to read the first paragraph.</div>
${j.bench.length ? j.bench.map(b => `<article class="item ${state.openIds.has('b' + b.id) ? 'open' : ''}" data-lid="b${b.id}"><div class="ihead"><span class="badge">${esc(b.meta.date || 'bench')}</span><span class="time">#${b.meta.pos}</span></div><div class="title">${esc(b.title)}</div><div class="preview">${esc(b.body)}</div><div class="body"><pre>${esc(b.body)}</pre></div></article>`).join('') : '<div class="empty">no bench entries synced yet</div>'}`;
$('#p-results').querySelectorAll('.item').forEach(el => el.onclick = () => { const id = el.dataset.lid; state.openIds.has(id) ? state.openIds.delete(id) : state.openIds.add(id); el.classList.toggle('open'); });
}
// ---- relay tab (kept from the relay page) ----
function machineNames() { const names = new Set(DEFAULT_MACHINES); for (const mc of state.machines) names.add(mc.name); return [...names]; }
function fillSelects() {
const names = machineNames();
const from = $('#from'); from.innerHTML = names.map(n => `<option ${n === state.from ? 'selected' : ''}>${esc(n)}</option>`).join('');
const to = $('#to'); to.innerHTML = ['all', ...names].map(n => `<option value="${esc(n)}" ${n === state.to ? 'selected' : ''}>${n === 'all' ? 'everyone' : esc(n)}</option>`).join('');
}
function renderWaiting() {
const w = $('#waiting');
const list = state.machines.filter(mc => mc.named !== false || mc.unread);
w.innerHTML = list.map(mc => `<button class="wchip ${mc.unread ? 'on' : 'zero'} ${state.filter === mc.name ? 'on' : ''}" data-m="${esc(mc.name)}"><b>${esc(mc.name)}</b>${mc.role ? `<span class="role">${esc(mc.role)}</span>` : ''}<span class="n">${mc.unread || 0}</span><span class="role">${mc.last_seen ? ago(mc.last_seen) : 'never'}</span></button>`).join('');
w.querySelectorAll('.wchip').forEach(b => b.onclick = () => { state.filter = state.filter === b.dataset.m ? 'all' : b.dataset.m; renderFilters(); renderFeed(); renderWaiting(); });
const unknown = state.machines.filter(mc => mc.named === false && mc.hostname);
$('#unknown').innerHTML = unknown.map(mc => `<div class="unknown"><span>New machine <span class="mono">${esc(mc.hostname)}</span>${mc.info && mc.info.gpus ? ` (${esc([].concat(mc.info.gpus).join(', '))})` : ''}. Name it:</span><input type="text" placeholder="PC2" data-h="${esc(mc.hostname)}"><button class="btn small primary" data-h="${esc(mc.hostname)}">Save</button></div>`).join('');
$('#unknown').querySelectorAll('button').forEach(b => b.onclick = async () => {
const inp = $('#unknown').querySelector(`input[data-h="${CSS.escape(b.dataset.h)}"]`);
const name = inp.value.trim(); if (!name) return inp.focus();
try { await api('name', { body: { hostname: b.dataset.h, name } }); await refreshRelay(true); } catch (e) { flash(e.message, true); }
});
const unread = state.machines.reduce((a, mc) => a + (mc.unread || 0), 0);
$('#n-relay').textContent = unread ? String(unread) : ''; $('#n-relay').className = 'n' + (unread ? ' hot' : '');
}
function renderFilters() {
const f = $('#filters');
const names = ['all', ...machineNames()];
f.innerHTML = names.map(n => `<button class="chip ${state.filter === n ? 'on' : ''}" data-f="${esc(n)}">${n === 'all' ? 'everything' : esc(n)}</button>`).join('');
f.querySelectorAll('.chip').forEach(b => b.onclick = () => { state.filter = b.dataset.f; renderFilters(); renderFeed(); renderWaiting(); });
}
function segments(body) {
const out = []; const re = /```[^\n]*\n([\s\S]*?)```/g; let last = 0, x;
while ((x = re.exec(body))) { if (x.index > last) out.push({ code: false, t: body.slice(last, x.index).trim() }); out.push({ code: true, t: x[1].replace(/\n$/, '') }); last = x.index + x[0].length; }
if (last < body.length) out.push({ code: false, t: body.slice(last).trim() });
return out.filter(s => s.t);
}
function itemHtml(it) {
const open = state.open.has(it.id);
const route = it.to === 'all' ? `<b>${esc(it.from)}</b> to everyone` : `<b>${esc(it.from)}</b> to <b>${esc(it.to)}</b>`;
const flags = Object.entries(it.flags || {}).filter(([k, v]) => v !== false && v !== null && v !== '').map(([k, v]) => v === true ? k : `${k}=${v}`).join(' ');
const unread = !it.read && (it.kind === 'task' || it.kind === 'run');
const segs = segments(it.body || '');
const bodyHtml = segs.map(s => `<div class="block"><button class="btn small copy" data-copy>copy</button><pre class="${s.code ? 'code' : ''}">${esc(s.t)}</pre></div>`).join('');
const file = it.has_file ? `<div class="file"><span class="name">${esc(it.file_name || 'file')}</span><span class="size">${fmtSize(it.size)}</span><a class="btn small" href="${API}file?id=${it.id}&download=1" download onclick="event.stopPropagation()">download</a><a class="btn small" href="${API}file?id=${it.id}" target="_blank" rel="noopener" onclick="event.stopPropagation()">open</a></div>` : '';
const acts = `<div class="acts">${unread ? `<button class="btn small" data-act="ack">mark read</button>` : ''}${(it.kind === 'task' || it.kind === 'run') && !it.done ? `<button class="btn small" data-act="done">done</button>` : ''}<button class="btn small" data-act="copyall">copy all</button><button class="btn small" data-act="del">delete</button></div>`;
const preview = (it.body || '').split('\n').find(l => l.trim()) || (it.has_file ? it.file_name : '') || '';
return `<article class="item ${open ? 'open' : ''} ${unread ? 'unread' : ''} ${it.done ? 'done' : ''}" data-id="${it.id}">
<div class="ihead"><span class="badge ${it.kind}">${it.kind}</span><span class="route">${route}</span><span class="time" title="${esc(it.ts)}">${when(it.ts)}</span></div>
${it.title ? `<div class="title">${esc(it.title)}</div>` : ''}
<div class="preview">${esc(preview)}</div>
<div class="body">${flags ? `<div class="flags">${esc(flags)}${it.task_id ? ` task #${it.task_id}` : ''}${it.done ? ' done' : it.read ? ' read' : ''} #${it.id}</div>` : `<div class="flags">#${it.id}${it.task_id ? ` for task #${it.task_id}` : ''}${it.done ? ' done' : it.read && (it.kind === 'task' || it.kind === 'run') ? ' read' : ''}</div>`}${bodyHtml}${file}${acts}</div>
</article>`;
}
function renderFeed() {
const feed = $('#feed');
const items = state.items.filter(it => state.filter === 'all' || it.from === state.filter || it.to === state.filter);
if (!items.length) { feed.innerHTML = `<div class="empty">nothing here yet</div>`; $('#more').style.display = 'none'; return; }
const groups = new Map();
for (const it of items) { const k = it.from; if (!groups.has(k)) groups.set(k, []); groups.get(k).push(it); }
feed.innerHTML = [...groups.entries()].map(([name, list]) => `<section class="group"><div class="ghead"><h2>${esc(name)}</h2><span class="meta">${list.length} item${list.length === 1 ? '' : 's'}, latest ${ago(list[0].ts)} ago</span></div>${list.map(itemHtml).join('')}</section>`).join('');
$('#more').style.display = state.more && state.items.length >= 200 ? '' : 'none';
feed.querySelectorAll('.item').forEach(el => {
el.onclick = e => { if (e.target.closest('button,a,pre')) return; const id = Number(el.dataset.id); state.open.has(id) ? state.open.delete(id) : state.open.add(id); el.classList.toggle('open'); };
el.querySelectorAll('[data-copy]').forEach(b => b.onclick = async e => { e.stopPropagation(); await copy(b.nextElementSibling.textContent, b); });
el.querySelectorAll('[data-act]').forEach(b => b.onclick = async e => {
e.stopPropagation(); const id = Number(el.dataset.id); const it = state.items.find(i => i.id === id);
try {
if (b.dataset.act === 'ack') { await api('ack', { body: { ids: [id] } }); it.read = true; }
if (b.dataset.act === 'done') { await api('done', { body: { id } }); it.done = true; it.read = true; }
if (b.dataset.act === 'copyall') { await copy(it.body || '', b); return; }
if (b.dataset.act === 'del') { if (!confirm('Delete this item?')) return; await api('delete', { body: { id } }); state.items = state.items.filter(i => i.id !== id); }
renderFeed(); refreshRelay(true);
} catch (err) { flash(err.message, true); }
});
});
}
async function copy(text, btn) {
try { await navigator.clipboard.writeText(text); } catch { const ta = document.createElement('textarea'); ta.value = text; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); }
if (btn) { const old = btn.textContent; btn.textContent = 'copied'; btn.classList.add('ok'); setTimeout(() => { btn.textContent = old; btn.classList.remove('ok'); }, 1200); }
}
function flash(t, err) { const el = $('#msg'); el.textContent = t; el.classList.toggle('err', !!err); if (!err) setTimeout(() => { if (el.textContent === t) el.textContent = ''; }, 4000); }
async function refreshRelay(full) {
const q = full || !state.maxId ? { limit: 200 } : { since: state.maxId };
const j = await api('feed', { q });
state.machines = j.machines;
if (full || !state.maxId) { state.items = j.items; state.more = j.items.length >= 200; }
else if (j.items.length) { const ids = new Set(state.items.map(i => i.id)); state.items = [...j.items.filter(i => !ids.has(i.id)), ...state.items]; }
else if (state.items.length) { const j2 = await api('feed', { q: { limit: 60 } }); for (const n of j2.items) { const o = state.items.find(i => i.id === n.id); if (o) Object.assign(o, n); } }
state.maxId = state.items.reduce((a, i) => Math.max(a, i.id), 0);
state.minId = state.items.reduce((a, i) => Math.min(a, i.id), Infinity);
fillSelects(); renderWaiting(); renderFilters(); renderFeed();
}
async function older() {
try { const j = await api('feed', { q: { before: state.minId, limit: 200 } }); state.items = [...state.items, ...j.items]; state.more = j.items.length >= 200; state.minId = state.items.reduce((a, i) => Math.min(a, i.id), Infinity); renderFeed(); } catch (e) { flash(e.message, true); }
}
$('#from').onchange = e => { state.from = e.target.value; localStorage.setItem('relay.from', state.from); };
$('#to').onchange = e => { state.to = e.target.value; localStorage.setItem('relay.to', state.to); };
$('#kindseg').querySelectorAll('button').forEach(b => b.onclick = () => { state.kind = b.dataset.k; $('#kindseg').querySelectorAll('button').forEach(x => x.classList.toggle('on', x === b)); $('#title').placeholder = state.kind === 'task' ? 'what the machine should do (the headline)' : 'title (optional for notes, the headline for tasks)'; });
const drop = $('#drop');
const setFile = f => { state.file = f || null; drop.classList.toggle('has', !!f); $('#droplabel').textContent = f ? `${f.name} (${fmtSize(f.size)})` : 'drop a file here or tap to pick one (up to 50 MB)'; };
$('#file').onchange = e => setFile(e.target.files[0]);
['dragenter', 'dragover'].forEach(ev => document.addEventListener(ev, e => { e.preventDefault(); drop.classList.add('over'); }));
['dragleave', 'drop'].forEach(ev => document.addEventListener(ev, e => { e.preventDefault(); drop.classList.remove('over'); }));
document.addEventListener('drop', e => { if (e.dataTransfer.files[0]) { setFile(e.dataTransfer.files[0]); setTab('relay'); } });
document.addEventListener('paste', e => { const f = [...(e.clipboardData.files || [])][0]; if (f && document.activeElement !== $('#text')) { setFile(f); setTab('relay'); } });
$('#text').addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && !/Mobi|Android/i.test(navigator.userAgent)) { e.preventDefault(); send(); } });
$('#clear').onclick = () => { $('#text').value = ''; $('#title').value = ''; setFile(null); $('#file').value = ''; flash(''); };
$('#send').onclick = send;
$('#more').onclick = older;
async function uploadFile(f) {
const t = await api('upload', { body: { name: f.name, size: f.size } });
if (f.size > t.max) throw new Error(`file over ${fmtSize(t.max)}`);
const prog = $('#prog'); prog.style.display = 'block'; const bar = prog.firstElementChild;
const url = await new Promise((resolve, reject) => {
const x = new XMLHttpRequest();
x.open('PUT', t.put_url);
x.setRequestHeader('authorization', 'Bearer ' + t.token);
x.setRequestHeader('x-api-version', t.api_version);
x.setRequestHeader('content-type', f.type || 'application/octet-stream');
x.upload.onprogress = e => { if (e.lengthComputable) bar.style.width = Math.round(e.loaded / e.total * 100) + '%'; };
x.onload = () => { try { const j = JSON.parse(x.responseText); if (x.status < 300 && j.url) resolve(j.url); else reject(new Error(j.error && j.error.message || ('upload http ' + x.status))); } catch { reject(new Error('upload http ' + x.status)); } };
x.onerror = () => reject(new Error('upload failed'));
x.send(f);
});
prog.style.display = 'none'; bar.style.width = '0';
return { file_name: f.name, file_url: url, size: f.size };
}
async function send() {
const text = $('#text').value; const title = $('#title').value.trim();
if (!text.trim() && !state.file && !title) return flash('nothing to send', true);
const btn = $('#send'); btn.disabled = true; flash(state.file ? 'uploading' : 'sending');
try {
const o = { from: state.from, to: state.to, kind: state.kind, title, body: text };
if (state.file) Object.assign(o, await uploadFile(state.file));
if (!o.body && !o.file_url) o.kind = 'text';
if (o.file_url && o.kind === 'text') o.kind = 'file';
await api(state.kind === 'task' ? 'task' : 'drop', { body: o });
$('#text').value = ''; $('#title').value = ''; setFile(null); $('#file').value = '';
flash('sent'); await refreshRelay(false); renderLog();
} catch (e) { flash(e.message, true); } finally { btn.disabled = false; }
}
// ---- refresh ----
const loaders = {
machines: async () => renderMachines(await capi('machines')),
jobs: async () => renderJobs(await capi('jobs')),
builds: async () => renderBuilds(await capi('builds')),
chain: async () => renderChain(await capi('chain')),
log: async () => { const [j] = await Promise.all([capi('log', { q: { limit: 200 } }), refreshRelay(false)]); state.log = j.items; renderLog(); },
results: async () => renderResults(await capi('results')),
relay: async () => { await refreshRelay(false); renderLog(); },
};
let busy = false;
async function refreshTab(t) {
if (busy) return; busy = true;
try { await loaders[t](); setStat('live ' + new Date().toTimeString().slice(0, 8), true); }
catch (e) { setStat('offline: ' + e.message, false); const p = $('#p-' + t); if (p && p.querySelector('.empty')) p.innerHTML = `<div class="empty">${esc(e.message)}</div>`; }
finally { busy = false; }
}
async function background() {
// the badges need the relay unread count and the machine count even when another tab is open
try { await refreshRelay(false); } catch {}
if (state.tab !== 'machines') { try { const j = await capi('machines'); const live = j.machines.filter(mc => !mc.silent).length; $('#n-machines').textContent = `${live}/${j.machines.length}`; } catch {} }
}
if (!TOKEN) { setStat('no token in the URL', false); document.body.innerHTML += '<p class="empty">open the console through its /r/&lt;token&gt;/ link</p>'; return; }
if (!loaders[state.tab]) state.tab = 'machines';
setTab(state.tab);
background();
setInterval(() => { if (document.visibilityState === 'visible') { refreshTab(state.tab); background(); } }, 15000);
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') refreshTab(state.tab); });
window.addEventListener('hashchange', () => { const t = location.hash.slice(1); if (loaders[t] && t !== state.tab) setTab(t); });
})();
</script>
</body>
</html>