diff --git a/docs/design/console/builds-desktop.png b/docs/design/console/builds-desktop.png new file mode 100644 index 000000000..8c5218130 Binary files /dev/null and b/docs/design/console/builds-desktop.png differ diff --git a/docs/design/console/builds-mobile.png b/docs/design/console/builds-mobile.png new file mode 100644 index 000000000..ace3ccc58 Binary files /dev/null and b/docs/design/console/builds-mobile.png differ diff --git a/docs/design/console/chain-desktop.png b/docs/design/console/chain-desktop.png new file mode 100644 index 000000000..d145588ee Binary files /dev/null and b/docs/design/console/chain-desktop.png differ diff --git a/docs/design/console/chain-mobile.png b/docs/design/console/chain-mobile.png new file mode 100644 index 000000000..14b068f2b Binary files /dev/null and b/docs/design/console/chain-mobile.png differ diff --git a/docs/design/console/jobs-desktop.png b/docs/design/console/jobs-desktop.png new file mode 100644 index 000000000..00184f897 Binary files /dev/null and b/docs/design/console/jobs-desktop.png differ diff --git a/docs/design/console/jobs-mobile.png b/docs/design/console/jobs-mobile.png new file mode 100644 index 000000000..5ac18c5b0 Binary files /dev/null and b/docs/design/console/jobs-mobile.png differ diff --git a/docs/design/console/log-desktop.png b/docs/design/console/log-desktop.png new file mode 100644 index 000000000..fc1d3e5c5 Binary files /dev/null and b/docs/design/console/log-desktop.png differ diff --git a/docs/design/console/log-mobile.png b/docs/design/console/log-mobile.png new file mode 100644 index 000000000..6b0153b87 Binary files /dev/null and b/docs/design/console/log-mobile.png differ diff --git a/docs/design/console/machines-desktop.png b/docs/design/console/machines-desktop.png new file mode 100644 index 000000000..a57a4d6f1 Binary files /dev/null and b/docs/design/console/machines-desktop.png differ diff --git a/docs/design/console/machines-mobile.png b/docs/design/console/machines-mobile.png new file mode 100644 index 000000000..1e9742345 Binary files /dev/null and b/docs/design/console/machines-mobile.png differ diff --git a/docs/design/console/relay-desktop.png b/docs/design/console/relay-desktop.png new file mode 100644 index 000000000..5f5a3aa87 Binary files /dev/null and b/docs/design/console/relay-desktop.png differ diff --git a/docs/design/console/relay-mobile.png b/docs/design/console/relay-mobile.png new file mode 100644 index 000000000..30647b252 Binary files /dev/null and b/docs/design/console/relay-mobile.png differ diff --git a/docs/design/console/results-desktop.png b/docs/design/console/results-desktop.png new file mode 100644 index 000000000..c62621352 Binary files /dev/null and b/docs/design/console/results-desktop.png differ diff --git a/docs/design/console/results-mobile.png b/docs/design/console/results-mobile.png new file mode 100644 index 000000000..af175d312 Binary files /dev/null and b/docs/design/console/results-mobile.png differ diff --git a/packaging/ota/publish-manifest.sh b/packaging/ota/publish-manifest.sh index 00e037e83..f9e75a788 100755 --- a/packaging/ota/publish-manifest.sh +++ b/packaging/ota/publish-manifest.sh @@ -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" diff --git a/packaging/windows/fetch-ci-artifacts.sh b/packaging/windows/fetch-ci-artifacts.sh index 3583521aa..d0cf10a3b 100755 --- a/packaging/windows/fetch-ci-artifacts.sh +++ b/packaging/windows/fetch-ci-artifacts.sh @@ -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//$(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 diff --git a/relay/README.md b/relay/README.md index 187035de8..75390e9d8 100644 --- a/relay/README.md +++ b/relay/README.md @@ -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) diff --git a/relay/api/console.mjs b/relay/api/console.mjs new file mode 100644 index 000000000..dd11ef91b --- /dev/null +++ b/relay/api/console.mjs @@ -0,0 +1,303 @@ +// Igneum console: one function, dispatched on ?fn=, reached through the rewrite /r//c/. +// 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-- +// 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-, nvidia-[-n], amd-[-n], igneum-, launcher- + 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) }); + } +} diff --git a/relay/ui.html b/relay/ui.html index c2957c2b5..1154148bf 100644 --- a/relay/ui.html +++ b/relay/ui.html @@ -3,7 +3,7 @@ -Igneum relay +Igneum console @@ -14,25 +14,123 @@
-
IGNEUMrelay
+
IGNEUMconsole
connecting
+
-
-
-
-
- - -
-
- - - -
-
+
loading
+
loading
+
loading
+
loading
+
loading
+
loading
+
+
+
+
+
+ + +
+
+ + + +
+
+
+
+
loading
+
-
-
loading
-
- + diff --git a/relay/vercel.json b/relay/vercel.json index d9cea77fb..68090ef07 100644 --- a/relay/vercel.json +++ b/relay/vercel.json @@ -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" + } + ] + } ] } diff --git a/tools/console.mjs b/tools/console.mjs new file mode 100644 index 000000000..0ff089cf8 --- /dev/null +++ b/tools/console.mjs @@ -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//). +// 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); }