igneum/tools/console.mjs

150 lines
13 KiB
JavaScript

#!/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 tuning [--days 30] [--min 5] Ember Tune's fleet priors per card model (samples, MH/W)
// 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 (sent as the x-relay-token header, never in a URL: X24), 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 BASE = (cfg('relay-url') || 'https://relay.igneum.network').replace(/\/$/, '');
if (!TOKEN) { console.error('no ~/.config/igneum/relay-token'); process.exit(1); }
const API = `${BASE}/api/console?fn=`; // the function itself; the token travels in the header
const WEB = `${BASE}/r/${TOKEN}/`; // the phone's page: the one place the token stays in the path
const HEADERS = { 'x-relay-token': 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: HEADERS }
: { method: 'POST', headers: { 'Content-Type': 'application/json', ...HEADERS }, 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.stopped ? `STOPPED (${m.stopped.reason}) ${ago(m.stopped.at)} ago,` : 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.stale ? 'last ' + c.hash_now.toFixed(1) + ' MH/s (stale, not in the total)' : 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`);
// the engine's own card list (kind and state per card, hot-plug included: app/igneum-app/src/hotplug.rs), as of its last "cards:" line
if (m.gpus && m.gpus.length) console.log(` GPUs (${ago(m.gpus_at)} ago): ${m.gpus.map(g => `${g.name} [${g.kind}, ${g.state}]`).join(' | ')}`);
for (const h of m.hotplug || []) console.log(` hot-plug ${ago(h.at)} ago: ${h.text}`);
}
}
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 === 'tuning') {
const j = await api('tuning', { q: { days: flags.days || 30, min: flags.min || 5 } });
console.log(`${j.records} tune record(s) in ${j.days} day(s); a prior needs ${j.min_samples} sample(s)`);
if (!j.table.length) console.log('no tune records yet (the apps log one per finished tune, 0.3.10 and later)');
for (const t of j.table) console.log(`${t.card.replace(/_/g, ' ')} | driver ${t.driver_major} | ${t.class}: ${t.samples} sample(s) from ${t.machines} machine(s)${t.samples ? `, ${t.clock_mhz ? t.clock_mhz + ' MHz at ' : ''}${t.power_pct}%${t.clock_mhz ? '' : ' (clock unlocked)'}, ${t.eff} MH/W (${t.mhs} MH/s at ${t.watts} W, spread ${t.spread_pct}%)` : ''}${t.baseline_eff ? `; untuned ${t.baseline_eff} MH/W from ${t.baseline_samples} baseline(s)` : ''}${t.gain_pct != null ? `; gain ${t.gain_pct >= 0 ? '+' : ''}${t.gain_pct}%` : ''}; prior ${t.key in j.priors ? 'yes' : 'no'}`);
}
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); }