Igneum console at the relay URL: Machines, Jobs, Builds, Chain, Work log, Results and the relay as tabs

relay/api/console.mjs reads the log intake (miner_logs) and console_items in Neon, the OTA manifest, the
CI json and the jobs file from the downloads host (DL_TOKEN in the project env, never in the client), and
igneum.network/api/live; 10 s cache per answer. tools/console.mjs: post --kind log|build|note, log, machines,
chain, jobs, builds, results, sync-bench, sync-dl, sync-hetzner, sync, url. The two Mac-side build scripts
post build events. Screenshots at 375 px and desktop in docs/design/console/.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-04 13:53:33 +00:00
parent c946034722
commit 8f08af5318
21 changed files with 851 additions and 132 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 MiB

View file

@ -151,6 +151,9 @@ if [ "$DEPLOY" = 1 ]; then
&& "$SIGNER" verify "$PUB" "$TMP/m.json" "$TMP/m.sig" && echo "live manifest verified at $BASE/igneum-app-latest.json" \
|| { echo "the live manifest is not reachable or does not verify yet; check the deploy output" >&2; rm -rf "$TMP"; exit 1; }
rm -rf "$TMP"
# the console's Builds tab (relay/): one build event and a fresh downloads listing; never fatal
node "$ROOT/tools/console.mjs" post --kind build --title "OTA manifest $VERSION ($CHANNEL) live" --body "$NOTES" >/dev/null 2>&1 || true
node "$ROOT/tools/console.mjs" sync-dl >/dev/null 2>&1 || true
else
if [ -n "$DLSITE" ]; then
echo "not deployed: cd $DLSITE && npx --yes vercel@latest --global-config ~/.config/igneum/vercel deploy --prod --yes"

View file

@ -46,6 +46,8 @@ JSON
rm -rf "$TMP"
echo "copied into $DEST:"
ls -la "$DEST/$(basename "$SETUP")" "$DEST/igneum-windows-app.zip"
# the console's Builds tab (relay/): one build event per fetched CI run; never fatal
node "$(dirname "$0")/../../tools/console.mjs" post --kind build --title "Windows CI $(basename "$SETUP") fetched (run $RUN_ID)" --body "https://github.com/$REPO/actions/runs/$RUN_ID" >/dev/null 2>&1 || true
# the over-the-air manifest (packaging/ota): the Windows entry for this installer; the Mac entry of the same version is
# carried over. OTA_NOTES= sets the changelog line; OTA_SKIP=1 leaves the manifest alone.
if [ "${OTA_SKIP:-0}" != 1 ]; then
@ -55,6 +57,7 @@ fi
if [ "$DEPLOY" = 1 ]; then
(cd "$DLSITE" && npx vercel@latest --global-config "$HOME/.config/igneum/vercel" deploy --prod --yes 2>&1 | grep -v "$TOKEN" || true)
echo "live: https://dl.igneum.network/dl/<token>/$(basename "$SETUP")"
node "$(dirname "$0")/../../tools/console.mjs" sync-dl >/dev/null 2>&1 || true
else
echo "deploy: cd $DLSITE && npx vercel@latest --global-config ~/.config/igneum/vercel deploy --prod --yes"
fi

View file

@ -1,4 +1,4 @@
# Igneum relay
# Igneum relay and console
Text, files and tasks between the project lead's devices without Gmail: the Mac, PC1, PC2 and the phone post to one feed and read from it. Vercel project `igneum-relay`, served at https://relay.igneum.network. Built 4 October 2026. Since the evening of 4 October 2026 the same private page is the Igneum console (`ui.html`): seven tabs, phone first, refreshed every 15 s. The relay feed and drop box are its last tab.
@ -68,7 +68,7 @@ An unknown hostname that registers appears in the feed with a "name this machine
```
cd relay && npx --yes vercel@latest --global-config ~/.config/igneum/vercel deploy --prod --yes --scope igneum
```
Env on the project: `DATABASE_URL`, `RELAY_KEY`, `RELAY_TOKEN`, `BLOB_READ_WRITE_TOKEN` (added by `vercel blob create-store`). DNS: `relay` CNAME `cname.vercel-dns.com` in the deSEC zone.
Env on the project: `DATABASE_URL`, `RELAY_KEY`, `RELAY_TOKEN`, `BLOB_READ_WRITE_TOKEN` (added by `vercel blob create-store`), `DL_TOKEN` (the downloads folder token, for the console; added 4 Oct 2026). DNS: `relay` CNAME `cname.vercel-dns.com` in the deSEC zone.
## Untested until a PC runs it (4 Oct 2026)

303
relay/api/console.mjs Normal file
View file

@ -0,0 +1,303 @@
// Igneum console: one function, dispatched on ?fn=, reached through the rewrite /r/<token>/c/<fn>.
// Auth: the token in the path (or x-relay-token), or the intake key in x-igneum-key. Nothing else. No secret
// reaches the client: DL_TOKEN (the downloads folder) is read here from the project env.
// Every GET answer is cached 10 s in the function instance.
// GET machines one card per machine from the log intake (miner_logs): node, cards, telemetry, faults
// GET jobs igneum-jobs.json from the downloads host + per-machine status from run_id job-<id>-<machine>
// GET job?run_id= the newest upload of that job run, in full
// GET builds OTA manifest, CI json, build posts, downloads listing (synced by tools/console.mjs sync-dl)
// GET chain igneum.network/api/live trimmed + the Hetzner results item
// GET log?limit=&since= work-log items (kinds log, build, note), newest first
// GET results bench entries (synced from docs/bench-log.md) + the FUD ledger counts
// POST post {kind,title,body,who,key?,meta?} one item; with key it upserts
// POST sync {items:[...]} bulk upsert by key
import { neon, authed, readJson, str, iso } from '../lib/relay.mjs';
const json = (res, status, obj) => { res.status(status).setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify(obj)); };
const CACHE_MS = 10_000;
const cache = new Map();
async function cached(key, fn) {
const hit = cache.get(key);
if (hit && Date.now() - hit.at < CACHE_MS) return hit.value;
const value = await fn();
cache.set(key, { at: Date.now(), value });
return value;
}
const DL_BASE = () => process.env.DL_TOKEN ? `https://dl.igneum.network/dl/${process.env.DL_TOKEN}` : '';
const LIVE_URL = process.env.LIVE_URL || 'https://igneum.network/api/live';
const NAMES = { ae432dc7: 'PC 1', '1ccfe586': 'PC 2', '3a9bf309': "Sam's Mac" };
const NAMED = new Set(Object.values(NAMES).concat('Mac'));
const HIDE_AFTER_S = 1800; // ids other than the named machines drop off 30 min after their last upload (test runs)
const SILENT_S = 180;
const num = v => (v === null || v === undefined || v === '' ? null : Number(v));
const kv = (text, k) => { const m = new RegExp(`(?:^|[\\s(])${k}=([^\\s,)]+)`).exec(text); return m ? m[1] : null; };
const kvNum = (text, k) => { const v = kv(text, k); return v === null ? null : Number(v); };
const lastMatch = (lines, re) => { for (let i = lines.length - 1; i >= 0; i--) { const m = re.exec(lines[i]); if (m) return m; } return null; };
const FAULT = /WORKER MISMATCH|worker error|worker exited|panicked|CUDA error|submit error/;
async function fetchJson(url, ms = 6000) {
const r = await fetch(url, { signal: AbortSignal.timeout(ms), headers: { accept: 'application/json' } });
if (!r.ok) return null;
return r.json();
}
// ---- machines ---------------------------------------------------------------------------------------------------
function parseLabel(label, machine) {
let m;
if ((m = /^nodelog-(win|mac)-([0-9a-f]{8})$/.exec(label))) return { id: m[2], platform: m[1], stream: 'node' };
if ((m = /^miner-(nvidia|amd|mac|metal|opencl)-([0-9a-f]{8})-(\d+)$/.exec(label))) return { id: m[2], platform: m[1] === 'mac' || m[1] === 'metal' ? 'mac' : 'win', stream: 'miner', vendor: m[1], card: Number(m[3]) };
if ((m = /^(win|mac)-([0-9a-f]{8})$/.exec(label))) return { id: m[2], platform: m[1], stream: 'app' };
// the launchers before the app: nodelog-<host>, nvidia-<host>[-n], amd-<host>[-n], igneum-<host>, launcher-<host>
if ((m = /^nodelog-(.+)$/.exec(label))) return { id: 'legacy:' + m[1], platform: 'legacy', stream: 'node' };
if ((m = /^(nvidia|amd)-(.+?)(?:-(\d+))?$/.exec(label))) return { id: 'legacy:' + m[2], platform: 'legacy', stream: 'miner', vendor: m[1], card: Number(m[3] || 1) };
if ((m = /^(igneum|launcher)-(.+)$/.exec(label))) return { id: 'legacy:' + m[2], platform: 'legacy', stream: 'app' };
return null;
}
function parseNodeTail(tail) {
const lines = tail.split('\n');
const n = {};
let m;
if ((m = lastMatch(lines, /Processed (\d+) blocks and (\d+) headers in the last/))) { n.blocks_10s = Number(m[1]); n.headers_10s = Number(m[2]); }
if ((m = lastMatch(lines, /Connection manager: has (\d+)\/(\d+) outgoing/))) { n.peers = Number(m[1]); n.peers_wanted = Number(m[2]); }
if ((m = lastMatch(lines, /\(daa (\d+)/))) n.daa = Number(m[1]);
if ((m = lastMatch(lines, /checkpoint (\d+) determined: block [0-9a-f]+ \(blue score (\d+), daa (\d+)\)/))) { n.checkpoint = Number(m[1]); n.blue_score = Number(m[2]); if (!n.daa || Number(m[3]) > n.daa) n.daa = Number(m[3]); }
if ((m = lastMatch(lines, /IBD/))) n.ibd_seen = true;
if ((m = lastMatch(lines, /^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d+[+-]\d{2}:\d{2})/))) n.last_line_at = iso(m[1].replace(' ', 'T'));
if ((m = lastMatch(lines, /igneumd\/(\S+)/))) n.version = m[1];
const errs = lines.filter(l => / \[ERROR\] | \[WARN \] /.test(l));
n.warnings = errs.length;
n.last_warning = errs.length ? errs[errs.length - 1].replace(/^\S+ \S+ /, '').slice(0, 200) : null;
return n;
}
function parseMinerTail(tail) {
const lines = tail.split('\n');
const c = { hash_now: 0, hash_avg: 0, accepted: 0, rejected: 0, mismatched: 0, restarts: 0, identities: null, synced: null, uptime_s: null, status_at: null, fault: null, faults: 0, template_age_s: null };
const st = lastMatch(lines, /^(\d+(?:\.\d+)?) STATUS '([^']+)'.*$/);
if (st) {
const t = st[0];
c.status_at = new Date(Number(st[1]) * 1000).toISOString();
c.hash_avg = kvNum(t, 'hash') ?? 0;
c.hash_now = kvNum(t, 'now') ?? c.hash_avg;
c.accepted = kvNum(t, 'accepted') ?? 0;
c.rejected = kvNum(t, 'rejected') ?? 0;
c.mismatched = kvNum(t, 'mismatched') ?? 0;
c.restarts = kvNum(t, 'restarts') ?? 0;
c.identities = kvNum(t, 'identities');
c.synced = kv(t, 'synced') === 'true';
c.template_age_s = (() => { const v = kv(t, 'template_age'); return v ? Number(v.replace('s', '')) : null; })();
const up = /STATUS '[^']+' \[[^\]]*\]: (\d+)s /.exec(t); if (up) c.uptime_s = Number(up[1]);
} else {
// no STATUS line in the tail: count the ACCEPTED lines at least
c.accepted = lines.filter(l => l.includes(' ACCEPTED block')).length;
}
const faults = lines.filter(l => FAULT.test(l));
c.faults = faults.length;
if (faults.length) c.fault = faults[faults.length - 1].replace(/^\d+(\.\d+)? /, '').slice(0, 200);
const acc = lastMatch(lines, /^(\d+(?:\.\d+)?) ACCEPTED block/);
if (acc) c.last_accepted_at = new Date(Number(acc[1]) * 1000).toISOString();
return c;
}
function parseAppTail(tail) {
const lines = tail.split('\n');
const a = { telemetry: [], events: [] };
let m;
if ((m = /Igneum Miner (\S+) on /.exec(tail))) a.version = m[1];
for (const l of lines) {
const s = /stability: (.+?): draw p95 (\d+) W, max (\d+) W \(cap (\d+) W\), max GPU (\d+) C, max memory (\d+) C, (\d+) samples/.exec(l);
if (s) { const i = a.telemetry.findIndex(t => t.name === s[1]); const t = { name: s[1], p95_w: Number(s[2]), max_w: Number(s[3]), cap_w: Number(s[4]), max_gpu_c: Number(s[5]), max_mem_c: Number(s[6]), samples: Number(s[7]) }; if (i >= 0) a.telemetry[i] = t; else a.telemetry.push(t); }
const e = /^(\d+(?:\.\d+)?) \[(error|ok|block|warn)\] (.+)$/.exec(l);
if (e && e[2] !== 'block') a.events.push({ at: new Date(Number(e[1]) * 1000).toISOString(), kind: e[2], text: e[3].slice(0, 200) });
}
a.events = a.events.slice(-6);
if ((m = lastMatch(lines, /status: (.+?) \| node (.+?) \| up (.+)$/))) a.status = { miner: m[1], node: m[2], up: m[3] };
return a;
}
async function machines(sql) {
// node logs get a longer tail: the connection-manager (peers) line is rare on a busy node
const rows = await sql(`SELECT DISTINCT ON (label) label, machine, run_id, received_at,
right(lines, CASE WHEN label LIKE 'nodelog-%' THEN 80000 ELSE 20000 END) AS tail
FROM miner_logs WHERE received_at > now() - interval '36 hours' ORDER BY label, received_at DESC`);
const groups = new Map();
for (const r of rows) {
const p = parseLabel(r.label, r.machine);
if (!p) continue;
const g = groups.get(p.id) || { id: p.id, platform: p.platform, hosts: new Set(), runs: new Set(), last_seen: null, node: {}, cards: [], app: null, labels: [] };
g.hosts.add(r.machine.replace(/-[0-9a-f]{8}$/, ''));
g.runs.add(r.run_id);
const at = iso(r.received_at);
if (!g.last_seen || at > g.last_seen) g.last_seen = at;
g.labels.push({ label: r.label, at, run_id: r.run_id });
if (p.stream === 'node') { g.node = { ...parseNodeTail(r.tail), at, run_id: r.run_id }; }
else if (p.stream === 'miner') g.cards.push({ label: r.label.replace(/^miner-/, ''), vendor: p.vendor, card: p.card, at, ...parseMinerTail(r.tail) });
else if (p.stream === 'app') g.app = { at, ...parseAppTail(r.tail) };
groups.set(p.id, g);
}
// node version lives in the first upload of a run (the tail may have lost the banner): one cheap query for the current runs
const runIds = [...groups.values()].map(g => g.node.run_id).filter(Boolean);
if (runIds.length) {
const heads = await sql(`SELECT DISTINCT ON (run_id) run_id, left(lines, 3000) AS head FROM miner_logs WHERE run_id = ANY($1) AND label LIKE 'nodelog-%' ORDER BY run_id, received_at ASC`, [runIds]);
for (const h of heads) { const m = /igneumd\/(\S+)/.exec(h.head); if (!m) continue; for (const g of groups.values()) if (g.node.run_id === h.run_id && !g.node.version) g.node.version = m[1]; }
}
const now = Date.now();
const out = [...groups.values()].map(g => {
const id8 = g.id.startsWith('legacy:') ? null : g.id;
const cards = g.cards.sort((a, b) => a.card - b.card);
const silent_s = g.last_seen ? Math.round((now - new Date(g.last_seen).getTime()) / 1000) : null;
return {
id: g.id, id8, platform: g.platform,
name: id8 ? (NAMES[id8] || (g.platform === 'mac' ? 'Mac' : 'PC ' + id8)) : g.id.slice(7),
host: [...g.hosts][0] || null, legacy: !id8,
last_seen: g.last_seen, silent_s, silent: silent_s === null || silent_s > SILENT_S,
app_version: g.app ? g.app.version || null : null,
node: { version: g.node.version || null, daa: g.node.daa ?? null, blue_score: g.node.blue_score ?? null, checkpoint: g.node.checkpoint ?? null, peers: g.node.peers ?? null, peers_wanted: g.node.peers_wanted ?? null, blocks_10s: g.node.blocks_10s ?? null, synced: cards.some(c => c.synced === true) || (g.node.blocks_10s != null && !g.node.ibd_seen), ibd: !!g.node.ibd_seen, last_line_at: g.node.last_line_at || null, warnings: g.node.warnings || 0, last_warning: g.node.last_warning || null, at: g.node.at || null },
cards, hash_total: Math.round(cards.reduce((a, c) => a + (c.hash_now || 0), 0) * 100) / 100,
accepted_total: cards.reduce((a, c) => a + (c.accepted || 0), 0),
faults: cards.reduce((a, c) => a + (c.faults || 0), 0),
telemetry: g.app ? g.app.telemetry : [], events: g.app ? g.app.events : [], status: g.app ? g.app.status || null : null,
labels: g.labels.sort((a, b) => (b.at > a.at ? 1 : -1)), runs: [...g.runs],
};
});
// the three named machines first (always shown), then anything seen in the last 24 h, then the rest
const order = m => m.name === 'Mac' ? 0 : m.name === 'PC 1' ? 1 : m.name === 'PC 2' ? 2 : m.name === "Sam's Mac" ? 3 : m.legacy ? 9 : 5;
out.sort((a, b) => order(a) - order(b) || (b.last_seen || '').localeCompare(a.last_seen || ''));
// several Mac installs (test runs): the newest is "Mac", the others keep their id
let macSeen = false;
for (const m of out) if (m.platform === 'mac' && m.name === 'Mac') { if (macSeen) m.name = `Mac ${m.id8}`; macSeen = true; }
for (const n of ['Mac', 'PC 1', 'PC 2', "Sam's Mac"]) if (!out.some(m => m.name === n)) out.push({ id: n, id8: Object.keys(NAMES).find(k => NAMES[k] === n) || null, platform: n.endsWith('Mac') ? 'mac' : 'win', name: n, host: null, legacy: false, last_seen: null, silent_s: null, silent: true, app_version: null, node: {}, cards: [], hash_total: 0, accepted_total: 0, faults: 0, telemetry: [], events: [], status: null, labels: [], runs: [], never: true });
out.sort((a, b) => order(a) - order(b) || (b.last_seen || '').localeCompare(a.last_seen || ''));
return { machines: out.filter(m => NAMED.has(m.name) || (m.silent_s !== null && m.silent_s < HIDE_AFTER_S)), silent_after_s: SILENT_S, hide_after_s: HIDE_AFTER_S };
}
// ---- jobs ---------------------------------------------------------------------------------------------------------
function normaliseJobs(j) {
const list = Array.isArray(j) ? j : Array.isArray(j.jobs) ? j.jobs : [];
return list.map((x, i) => ({
id: String(x.id ?? x.job_id ?? x.name ?? i), title: String(x.title ?? x.name ?? x.id ?? `job ${i + 1}`),
created: x.created ?? x.created_at ?? x.ts ?? null, to: x.to ?? x.machines ?? x.machine ?? 'all',
command: x.command ?? x.run ?? x.script ?? x.cmd ?? '', shell: x.shell ?? null, raw: x,
}));
}
async function jobs(sql) {
const base = DL_BASE();
let file = null; let note = '';
if (!base) note = 'DL_TOKEN is not set on the project';
else { try { file = await fetchJson(`${base}/igneum-jobs.json`); if (!file) note = 'no igneum-jobs.json on the downloads host yet'; } catch (e) { note = `jobs file: ${e.message}`; } }
const list = file ? normaliseJobs(file) : [];
const rows = await sql(`SELECT DISTINCT ON (run_id) run_id, label, machine, received_at, right(lines, 6000) AS tail
FROM miner_logs WHERE run_id LIKE 'job-%' AND received_at > now() - interval '14 days' ORDER BY run_id, received_at DESC`);
const runs = rows.map(r => {
const m = /^job-(.+)-([0-9a-f]{8,32})$/.exec(r.run_id);
const lines = r.tail.split('\n').filter(l => l.trim());
const ex = lastMatch(lines, /\b(?:exit(?:ed)?(?:[ _-]?code)?|EXIT|rc)\s*[=: ]\s*(-?\d+)\b/i);
const done = !!ex || lines.some(l => /^(DONE|FINISHED|RESULT)\b/.test(l));
const sum = lastMatch(lines, /^(?:RESULT|SUMMARY)[:\s](.+)$/i);
const machineId = m ? m[2] : r.machine;
return { run_id: r.run_id, job_id: m ? m[1] : r.run_id, machine_id: machineId, machine: NAMES[machineId.slice(0, 8)] || r.machine, host: r.machine, at: iso(r.received_at), label: r.label,
status: done ? 'done' : 'running', exit_code: ex ? Number(ex[1]) : null, summary: (sum ? sum[1] : lines[lines.length - 1] || '').slice(0, 300) };
});
const seen = new Set(runs.map(r => r.job_id));
const out = list.map(j => ({ ...j, runs: runs.filter(r => r.job_id === j.id) }));
for (const id of seen) if (!out.some(j => j.id === id)) out.push({ id, title: id, created: null, to: null, command: '', runs: runs.filter(r => r.job_id === id), unlisted: true });
out.sort((a, b) => String(b.created || b.runs[0]?.at || '').localeCompare(String(a.created || a.runs[0]?.at || '')));
return { jobs: out, note, file_seen: !!file, generated: file && !Array.isArray(file) ? file.generated ?? file.updated ?? null : null };
}
// ---- builds, chain, results, log --------------------------------------------------------------------------------
const itemOut = r => ({ id: Number(r.id), ts: iso(r.ts), kind: r.kind, key: r.key, who: r.who, title: r.title, body: r.body, meta: r.meta || {} });
async function builds(sql) {
const base = DL_BASE();
const [manifest, ci, posts, dl] = await Promise.all([
base ? fetchJson(`${base}/igneum-app-latest.json`).catch(() => null) : null,
base ? fetchJson(`${base}/igneum-windows-ci.json`).catch(() => null) : null,
sql(`SELECT * FROM console_items WHERE kind = 'build' ORDER BY ts DESC LIMIT 40`),
sql(`SELECT * FROM console_items WHERE key = 'dl' LIMIT 1`),
]);
return { manifest, ci, builds: posts.map(itemOut), dl: dl.length ? itemOut(dl[0]) : null, dl_set: !!base };
}
async function chain(sql) {
const [live, het] = await Promise.all([fetchJson(LIVE_URL, 8000).catch(e => ({ error: e.message })), sql(`SELECT * FROM console_items WHERE key = 'hetzner' LIMIT 1`)]);
if (!live || live.error || live.ok === false) return { ok: true, live: false, error: live && live.error || 'live feed unreachable', state: null, finality: {}, events: [], hetzner: het.length ? itemOut(het[0]) : null };
const f = live.finality || {};
return {
live: true, now: live.now, state: live.state,
finality: { active: !!f.active, next_index: f.next_index, latest_locked_index: f.latest_locked_index, latest_locked_blue_score: f.latest_locked_blue_score, latest_locked_hash: f.latest_locked_hash, voters: f.weights && f.weights.keys ? f.weights.keys.filter(k => k.voter).length : null, keys: f.weights && f.weights.keys ? f.weights.keys.length : null, checkpoints: (f.checkpoints || []).slice(-8).map(c => ({ index: c.index, state: c.state, fraction_total: c.fraction_total, locked_at: c.locked_at })) },
miners: (live.miners || []).slice(0, 12), events: (live.events || []).slice(0, 12),
hetzner: het.length ? itemOut(het[0]) : null,
};
}
async function results(sql) {
const [bench, ledger] = await Promise.all([
sql(`SELECT * FROM console_items WHERE kind = 'bench' ORDER BY (meta->>'date') DESC NULLS LAST, (meta->>'pos')::int DESC LIMIT 60`),
sql(`SELECT * FROM console_items WHERE key = 'ledger' LIMIT 1`),
]);
return { bench: bench.map(itemOut), ledger: ledger.length ? itemOut(ledger[0]) : null };
}
async function upsert(sql, o) {
const kind = str(o.kind, 20) || 'log';
const meta = o.meta && typeof o.meta === 'object' ? o.meta : {};
if (o.key) {
const r = await sql(`INSERT INTO console_items (kind, key, who, title, body, meta) VALUES ($1,$2,$3,$4,$5,$6::jsonb)
ON CONFLICT (key) DO UPDATE SET kind = EXCLUDED.kind, who = EXCLUDED.who, title = EXCLUDED.title, body = EXCLUDED.body, meta = EXCLUDED.meta, ts = CASE WHEN console_items.title = EXCLUDED.title AND console_items.body = EXCLUDED.body THEN console_items.ts ELSE now() END RETURNING id`,
[kind, str(o.key, 200), str(o.who, 80) || 'Mac', str(o.title, 400), str(o.body, 64000), JSON.stringify(meta)]);
return Number(r[0].id);
}
const r = await sql(`INSERT INTO console_items (kind, who, title, body, meta) VALUES ($1,$2,$3,$4,$5::jsonb) RETURNING id`,
[kind, str(o.who, 80) || 'Mac', str(o.title, 400), str(o.body, 64000), JSON.stringify(meta)]);
return Number(r[0].id);
}
export default async function handler(req, res) {
res.setHeader('Cache-Control', 'no-store');
if (!authed(req)) return json(res, 401, { ok: false, error: 'no token' });
const fn = String(req.query.fn || '');
const q = req.query;
let sql;
try { sql = neon(); } catch (e) { return json(res, 500, { ok: false, error: e.message }); }
try {
if (req.method === 'GET') {
if (fn === 'machines') return json(res, 200, { ok: true, now: new Date().toISOString(), ...(await cached('machines', () => machines(sql))) });
if (fn === 'jobs') return json(res, 200, { ok: true, now: new Date().toISOString(), ...(await cached('jobs', () => jobs(sql))) });
if (fn === 'job') {
const rows = await sql(`SELECT id, label, machine, run_id, received_at, lines FROM miner_logs WHERE run_id = $1 ORDER BY received_at DESC LIMIT 1`, [str(q.run_id, 200)]);
if (!rows.length) return json(res, 404, { ok: false, error: 'no upload for that run' });
return json(res, 200, { ok: true, run_id: rows[0].run_id, label: rows[0].label, machine: rows[0].machine, at: iso(rows[0].received_at), lines: rows[0].lines });
}
if (fn === 'builds') return json(res, 200, { ok: true, now: new Date().toISOString(), ...(await cached('builds', () => builds(sql))) });
if (fn === 'chain') return json(res, 200, { ok: true, ...(await cached('chain', () => chain(sql))) });
if (fn === 'results') return json(res, 200, { ok: true, ...(await cached('results', () => results(sql))) });
if (fn === 'log') {
const limit = Math.min(300, Math.max(1, Number(q.limit) || 100));
const params = []; let where = `kind IN ('log','build','note')`;
if (q.since) { params.push(Number(q.since)); where += ` AND id > $${params.length}`; }
const rows = await sql(`SELECT * FROM console_items WHERE ${where} ORDER BY id DESC LIMIT ${limit}`, params);
return json(res, 200, { ok: true, items: rows.map(itemOut), now: new Date().toISOString() });
}
return json(res, 404, { ok: false, error: `unknown fn ${fn}` });
}
if (req.method !== 'POST') return json(res, 405, { ok: false, error: 'method' });
let body;
try { body = await readJson(req); } catch { return json(res, 400, { ok: false, error: 'bad json' }); }
if (fn === 'post') {
if (!body.title && !body.body) return json(res, 400, { ok: false, error: 'title or body required' });
const id = await upsert(sql, body);
cache.clear();
return json(res, 200, { ok: true, id });
}
if (fn === 'sync') {
const items = Array.isArray(body.items) ? body.items : [];
let count = 0;
for (const it of items) { if (!it || !it.key) continue; await upsert(sql, it); count++; }
cache.clear();
return json(res, 200, { ok: true, count });
}
if (fn === 'delete') {
const id = Number(body.id); if (!id) return json(res, 400, { ok: false, error: 'id required' });
await sql(`DELETE FROM console_items WHERE id = $1`, [id]);
cache.clear();
return json(res, 200, { ok: true, id });
}
return json(res, 404, { ok: false, error: `unknown fn ${fn}` });
} catch (e) {
return json(res, 500, { ok: false, error: String(e.message || e) });
}
}

View file

@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Igneum relay</title>
<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">
@ -14,25 +14,123 @@
<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;--gutter:16px;--r:14px}
: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:860px;margin:0 auto;padding-inline:var(--gutter)}
header{position:sticky;top:0;z-index:5;background:rgba(12,12,14,.88);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:56px;gap:12px}
.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:16px;letter-spacing:.06em}
.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)}
main{padding-block:14px 48px;display:flex;flex-direction:column;gap:14px}
/* waiting strip */
.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 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)}
@ -44,9 +142,6 @@ main{padding-block:14px 48px;display:flex;flex-direction:column;gap:14px}
.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 */
.card{background:var(--graphite);border:1px solid var(--line);border-radius:var(--r);padding:14px}
.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)}
@ -54,13 +149,7 @@ select,input[type=text],textarea{background:var(--obsidian);border:1px solid var
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%}
.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:hover{background:#FF6A2B;border-color:#FF6A2B}
.btn.primary:disabled{opacity:.5;cursor:default}
.btn.small{min-height:32px;padding:5px 11px;font-size:12px;font-weight:500}
.drop{border:1px dashed var(--line-2);border-radius:10px;padding:12px;text-align:center;color:var(--ash);font-size:13px;cursor:pointer;transition:border-color .15s,color .15s}
.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}
@ -71,110 +160,251 @@ input[type=text]{width:100%}
.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 */
.filters{display:flex;gap:8px;overflow-x:auto;scrollbar-width:none}
.filters::-webkit-scrollbar{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)}
/* feed */
.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)}
.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}
.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)}
.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)}
.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 12px 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}
.more{align-self:center}
@media (min-width:700px){:root{--gutter:24px}body{font-size:16px}.item{padding:14px 16px}}
.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">relay</span></div>
<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">
<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 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>
<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>
</main>
<template id="tpl-item"></template>
<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 = { 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 };
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 api(fn, opts = {}) {
const r = await fetch(API + fn + (opts.q ? '?' + new URLSearchParams(opts.q) : ''), opts.body !== undefined ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(opts.body) } : {});
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 => { const s = (Date.now() - new Date(ts)) / 1000; if (s < 60) return 'now'; 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 => { 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 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'); });
function machineNames() {
const names = new Set(DEFAULT_MACHINES);
for (const mc of state.machines) names.add(mc.name);
return [...names];
// ---- 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">${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.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.silent ? `silent ${ago(mc.last_seen)}` : `seen ${ago(mc.last_seen)} ago`);
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">no power or temperature lines in the intake yet (the app log is not uploading)</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.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>
${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>'],
['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)],
['peers', n.peers != null ? `${n.peers}<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)}${tele}${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; 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);
@ -185,8 +415,10 @@ pre.code{color:var(--bone)}
$('#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 refresh(true); } catch (e) { flash(e.message, true); }
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');
@ -194,9 +426,7 @@ pre.code{color:var(--bone)}
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) {
// split ``` fences into code blocks so each gets its own copy button
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() });
@ -208,7 +438,7 @@ pre.code{color:var(--bone)}
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>${s.code ? 'copy' : 'copy'}</button><pre class="${s.code ? 'code' : ''}">${esc(s.t)}</pre></div>`).join('');
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 : '') || '';
@ -223,7 +453,6 @@ pre.code{color:var(--bone)}
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; }
// group by the machine that posted it, groups ordered by their newest item, items newest first inside
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('');
@ -238,7 +467,7 @@ pre.code{color:var(--bone)}
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(); refresh(true);
renderFeed(); refreshRelay(true);
} catch (err) { flash(err.message, true); }
});
});
@ -248,29 +477,20 @@ pre.code{color:var(--bone)}
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 refresh(full) {
try {
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]; }
if (!full && state.maxId) {
// keep read/done flags of visible items fresh without refetching everything: cheap second call only when machines report unread
if (j.items.length === 0 && 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();
setStat('live ' + new Date().toTimeString().slice(0, 8), true);
} catch (e) { setStat('offline: ' + e.message, false); }
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); }
}
// composer
$('#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)'; });
@ -279,13 +499,12 @@ pre.code{color:var(--bone)}
$('#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]); });
document.addEventListener('paste', e => { const f = [...(e.clipboardData.files || [])][0]; if (f && document.activeElement !== $('#text')) setFile(f); });
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)}`);
@ -293,7 +512,6 @@ pre.code{color:var(--bone)}
const url = await new Promise((resolve, reject) => {
const x = new XMLHttpRequest();
x.open('PUT', t.put_url);
// only headers the Blob API allows cross-origin; the client token already carries the pathname and the random suffix
x.setRequestHeader('authorization', 'Bearer ' + t.token);
x.setRequestHeader('x-api-version', t.api_version);
x.setRequestHeader('content-type', f.type || 'application/octet-stream');
@ -308,7 +526,6 @@ pre.code{color:var(--bone)}
async function send() {
const text = $('#text').value; const title = $('#title').value.trim();
if (!text.trim() && !state.file && !title) return flash('nothing to send', true);
if (state.kind === 'task' && state.to === 'all' && false) return;
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 };
@ -317,14 +534,40 @@ pre.code{color:var(--bone)}
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 refresh(false);
flash('sent'); await refreshRelay(false); renderLog();
} catch (e) { flash(e.message, true); } finally { btn.disabled = false; }
}
if (!TOKEN) { setStat('no token in the URL', false); document.body.innerHTML += '<p class="empty">open the relay through its /r/&lt;token&gt;/ link</p>'; return; }
refresh(true);
setInterval(() => { if (document.visibilityState === 'visible') refresh(false); }, 15000);
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') refresh(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>

View file

@ -2,16 +2,44 @@
"cleanUrls": true,
"trailingSlash": false,
"rewrites": [
{ "source": "/r/:token", "destination": "/ui" },
{ "source": "/r/:token/api/:fn", "destination": "/api/relay?token=:token&fn=:fn" }
{
"source": "/r/:token",
"destination": "/ui"
},
{
"source": "/r/:token/c/:fn",
"destination": "/api/console?token=:token&fn=:fn"
},
{
"source": "/r/:token/api/:fn",
"destination": "/api/relay?token=:token&fn=:fn"
}
],
"headers": [
{ "source": "/(.*)", "headers": [
{ "key": "X-Content-Type-Options", "value": "nosniff" },
{ "key": "X-Frame-Options", "value": "DENY" },
{ "key": "X-Robots-Tag", "value": "noindex, nofollow" },
{ "key": "Referrer-Policy", "value": "no-referrer" },
{ "key": "Cache-Control", "value": "no-store" }
] }
{
"source": "/(.*)",
"headers": [
{
"key": "X-Content-Type-Options",
"value": "nosniff"
},
{
"key": "X-Frame-Options",
"value": "DENY"
},
{
"key": "X-Robots-Tag",
"value": "noindex, nofollow"
},
{
"key": "Referrer-Policy",
"value": "no-referrer"
},
{
"key": "Cache-Control",
"value": "no-store"
}
]
}
]
}

139
tools/console.mjs Normal file
View file

@ -0,0 +1,139 @@
#!/usr/bin/env node
// Mac side of the Igneum console (relay/ in this repo, the same private URL as the relay: /r/<token>/).
// node tools/console.mjs post --kind log --title "..." [--body "..."] [--who Mac] one work-log item (kinds: log, build, note)
// node tools/console.mjs log [N] the work log, newest first
// node tools/console.mjs machines the machine cards as text
// node tools/console.mjs chain the chain numbers
// node tools/console.mjs jobs | builds | results the other tabs as text
// node tools/console.mjs sync-bench push docs/bench-log.md entries and the FUD ledger counts
// node tools/console.mjs sync-dl push the downloads folder listing (names, sizes, times)
// node tools/console.mjs sync-hetzner push the newest infra/cloud-devnet/results/ summary
// node tools/console.mjs sync all three syncs
// node tools/console.mjs url the console URL
// Reads ~/.config/igneum/relay-token (the URL secret), log-intake-key (x-igneum-key), relay-url (optional),
// dl-token and dlsite-dir (sync-dl). Zero dependencies.
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
import { homedir } from 'node:os';
import { join, dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
process.stdout.on('error', e => { if (e.code === 'EPIPE') process.exit(0); throw e; });
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const cfg = n => { try { return readFileSync(join(homedir(), '.config', 'igneum', n), 'utf8').trim(); } catch { return ''; } };
const TOKEN = cfg('relay-token'); const KEY = cfg('log-intake-key');
const BASE = (cfg('relay-url') || 'https://relay.igneum.network').replace(/\/$/, '');
if (!TOKEN) { console.error('no ~/.config/igneum/relay-token'); process.exit(1); }
const API = `${BASE}/r/${TOKEN}/c/`;
const WEB = `${BASE}/r/${TOKEN}/`;
const argv = process.argv.slice(2);
const flags = {}; const pos = [];
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a.startsWith('--')) { const k = a.slice(2); const next = argv[i + 1]; if (next !== undefined && !next.startsWith('--')) { flags[k] = next; i++; } else flags[k] = true; }
else pos.push(a);
}
const cmd = pos[0] || 'log';
async function api(fn, { q, body } = {}) {
const r = await fetch(API + fn + (q ? '?' + new URLSearchParams(q) : ''), body === undefined ? { headers: { 'x-igneum-key': KEY } }
: { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-igneum-key': KEY }, body: JSON.stringify(body) });
const j = await r.json().catch(() => ({ ok: false, error: `http ${r.status}` }));
if (!r.ok || j.ok === false) throw new Error(j.error || `http ${r.status}`);
return j;
}
const when = ts => ts ? new Date(ts).toISOString().replace('T', ' ').slice(5, 16) : '-';
const ago = ts => { if (!ts) return 'never'; const s = (Date.now() - new Date(ts)) / 1000; return s < 60 ? `${Math.floor(s)}s` : s < 3600 ? `${Math.floor(s / 60)}m` : s < 86400 ? `${Math.floor(s / 3600)}h` : `${Math.floor(s / 86400)}d`; };
const fmtSize = n => n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`;
// ---- docs/bench-log.md: one item per "## " heading, body = the first paragraph after it -------------------------
function benchItems() {
const text = readFileSync(join(ROOT, 'docs', 'bench-log.md'), 'utf8');
const lines = text.split('\n');
const items = []; let cur = null; let pos = 0;
for (const line of lines) {
const h = /^## (.+)$/.exec(line);
if (h) { if (cur) items.push(cur); cur = { title: h[1].trim(), body: '', pos: pos++, done: false }; continue; }
if (!cur || cur.done) continue;
if (!line.trim()) { if (cur.body) cur.done = true; continue; }
if (line.startsWith('|') || line.startsWith('#')) { if (cur.body) cur.done = true; continue; }
cur.body += (cur.body ? ' ' : '') + line.trim();
}
if (cur) items.push(cur);
const MONTHS = { january: 1, february: 2, march: 3, april: 4, may: 5, june: 6, july: 7, august: 8, september: 9, october: 10, november: 11, december: 12 };
const dateOf = t => { const a = /(\d{4})-(\d{2})-(\d{2})/.exec(t); if (a) return `${a[1]}-${a[2]}-${a[3]}`; const b = /(\d{1,2}) ([A-Za-z]+) (\d{4})/.exec(t); if (b && MONTHS[b[2].toLowerCase()]) return `${b[3]}-${String(MONTHS[b[2].toLowerCase()]).padStart(2, '0')}-${b[1].padStart(2, '0')}`; return ''; };
return items.map(it => ({ kind: 'bench', key: 'bench:' + it.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 120), who: 'Mac', title: it.title, body: it.body.slice(0, 4000), meta: { date: dateOf(it.title), pos: it.pos } }));
}
function ledgerItem() {
const text = readFileSync(join(ROOT, 'docs', 'fud-ledger.md'), 'utf8');
const statuses = [...text.matchAll(/^Status: (.+)$/gm)].map(m => m[1]);
const entries = (text.match(/^### [A-Z]+\d*\./gm) || []).length;
const fam = s => { const t = s.toLowerCase(); if (t.startsWith('fixed')) return 'Fixed'; if (t.startsWith('closed by rule')) return 'Closed by rule'; if (t.startsWith('closed by removal')) return 'Closed by removal'; if (t.startsWith('decided')) return 'Decided'; if (t.startsWith('answered with evidence')) return 'Answered with evidence'; if (t.startsWith('answered by design')) return 'Answered by design'; if (t.startsWith('answered')) return 'Answered'; if (t.startsWith('open')) return 'Open'; if (t.startsWith('conceded')) return 'Conceded'; return 'Other'; };
const counts = {};
for (const s of statuses) counts[fam(s)] = (counts[fam(s)] || 0) + 1;
return { kind: 'ledger', key: 'ledger', who: 'Mac', title: `FUD ledger: ${entries} entries`, body: Object.entries(counts).map(([k, v]) => `${k} ${v}`).join(', '), meta: { counts, entries, statuses: statuses.length } };
}
function dlItem() {
const dir = process.env.IGNEUM_DLSITE || cfg('dlsite-dir'); const tok = cfg('dl-token');
const folder = join(dir, 'dl', tok);
if (!dir || !tok || !existsSync(folder)) throw new Error('no downloads folder: ~/.config/igneum/dlsite-dir and dl-token');
const files = readdirSync(folder).filter(n => !n.startsWith('.')).map(n => { const s = statSync(join(folder, n)); return { name: n, size: s.size, mtime: s.mtime.toISOString() }; }).sort((a, b) => b.mtime.localeCompare(a.mtime));
return { kind: 'dl', key: 'dl', who: 'Mac', title: `${files.length} files in the downloads folder`, body: files.slice(0, 8).map(f => `${f.name} ${fmtSize(f.size)}`).join('\n'), meta: { files } };
}
function hetznerItem() {
const base = join(ROOT, 'infra', 'cloud-devnet', 'results');
if (!existsSync(base)) throw new Error('no infra/cloud-devnet/results');
const days = readdirSync(base).filter(n => /^\d{4}-\d{2}-\d{2}$/.test(n)).sort();
if (!days.length) throw new Error('no results yet');
const day = days[days.length - 1]; const dir = join(base, day);
const entries = readdirSync(dir).map(n => { const s = statSync(join(dir, n)); return { name: n, dir: s.isDirectory(), size: s.size, mtime: s.mtime.toISOString() }; }).sort((a, b) => b.mtime.localeCompare(a.mtime));
let tail = '';
const ev = join(dir, 'events.log');
if (existsSync(ev)) tail = readFileSync(ev, 'utf8').trim().split('\n').slice(-12).join('\n');
return { kind: 'hetzner', key: 'hetzner', who: 'Mac', title: `Hetzner results ${day}`, body: tail.slice(0, 4000), meta: { day, entries: entries.slice(0, 30), newest: entries[0] ? entries[0].mtime : null } };
}
try {
if (cmd === 'url') console.log(WEB);
else if (cmd === 'post') {
const kind = flags.kind || 'log'; const title = flags.title || pos.slice(1).join(' ');
if (!title && !flags.body) throw new Error('post --kind log --title "..." [--body "..."]');
let meta = {}; if (flags.meta) meta = JSON.parse(flags.meta);
const r = await api('post', { body: { kind, title, body: flags.body || '', who: flags.who || 'Mac', key: flags.key || undefined, meta } });
console.log(`posted #${r.id} ${kind}: ${title}`);
}
else if (cmd === 'log') {
const j = await api('log', { q: { limit: Number(pos[1]) || 40 } });
if (!j.items.length) console.log('no items yet');
for (const it of j.items) console.log(`${when(it.ts)} ${it.kind.padEnd(5)} ${it.who.padEnd(8)} ${it.title}${it.body ? ': ' + it.body.split('\n')[0].slice(0, 100) : ''}`);
}
else if (cmd === 'machines') {
const j = await api('machines');
for (const m of j.machines) {
console.log(`${m.name.padEnd(8)} ${m.id8 || '-'} ${m.silent ? 'SILENT' : 'live'} seen ${ago(m.last_seen)} ago | app ${m.app_version || '?'} node ${m.node.version || '?'} daa ${m.node.daa ?? '?'} peers ${m.node.peers ?? '?'} ${m.node.synced ? 'synced' : 'not synced'} | ${m.hash_total.toFixed(1)} MH/s, ${m.accepted_total} accepted, ${m.faults} faults`);
for (const c of m.cards) console.log(` ${c.label.padEnd(22)} ${c.hash_now.toFixed(1)} MH/s now, ${c.accepted} accepted, ${c.rejected} rejected, ${c.mismatched} mismatched, ${c.restarts} restarts, status ${ago(c.status_at)} ago${c.fault ? ' | ' + c.fault : ''}`);
for (const t of m.telemetry) console.log(` ${t.name}: p95 ${t.p95_w} W, max ${t.max_w} W (cap ${t.cap_w} W), GPU ${t.max_gpu_c} C, memory ${t.max_mem_c} C`);
}
}
else if (cmd === 'chain') {
const j = await api('chain'); const s = j.state || {};
console.log(`blocks ${s.block_count} | blue ${s.blue_score} | identities 10m ${s.miners_10m} | estimate ${s.hashes_per_second_estimate ? (s.hashes_per_second_estimate / 1e6).toFixed(1) + ' MH/s' : '?'} | difficulty ${s.difficulty ? Math.round(s.difficulty).toLocaleString() : '?'} | peers ${s.peers} | last lock #${j.finality.latest_locked_index} (blue ${j.finality.latest_locked_blue_score}) | lag ${s.observer_lag_s}s${s.stale ? ' STALE' : ''}`);
}
else if (cmd === 'jobs') { const j = await api('jobs'); if (!j.jobs.length) console.log(j.note || 'no jobs'); for (const jb of j.jobs) console.log(`${jb.id} ${jb.title} | ${jb.runs.map(r => `${r.machine} ${r.status}${r.exit_code != null ? ' exit ' + r.exit_code : ''}${r.summary ? ': ' + r.summary.slice(0, 80) : ''}`).join(' | ') || 'queued everywhere'}`); }
else if (cmd === 'builds') { const j = await api('builds'); console.log(`manifest ${j.manifest ? j.manifest.version + ' (' + j.manifest.channel + ') ' + Object.keys(j.manifest.platforms || {}).join('+') + ': ' + j.manifest.notes : 'none'}`); if (j.ci) console.log(`ci ${j.ci.installer} fetched ${j.ci.fetched_at} ${j.ci.run}`); for (const b of j.builds) console.log(`${when(b.ts)} ${b.who.padEnd(8)} ${b.title}${b.body ? ': ' + b.body.split('\n')[0].slice(0, 100) : ''}`); }
else if (cmd === 'results') { const j = await api('results'); if (j.ledger) console.log(`${j.ledger.title}: ${j.ledger.body}`); for (const b of j.bench) console.log(`${b.meta.date || '-'} ${b.title}`); }
else if (cmd === 'sync-bench' || cmd === 'sync-dl' || cmd === 'sync-hetzner' || cmd === 'sync') {
const items = [];
const want = cmd === 'sync' ? ['bench', 'dl', 'hetzner'] : [cmd.slice(5)];
for (const w of want) {
try {
if (w === 'bench') { items.push(...benchItems(), ledgerItem()); }
if (w === 'dl') items.push(dlItem());
if (w === 'hetzner') items.push(hetznerItem());
} catch (e) { if (cmd !== 'sync') throw e; console.log(`${w}: skipped (${e.message})`); }
}
const r = await api('sync', { body: { items } });
console.log(`synced ${r.count} items (${want.join(', ')})`);
}
else throw new Error(`unknown command ${cmd}`);
} catch (e) { console.error(e.message); process.exit(1); }