igneum/relay/api/console.mjs
igneum-labs 57993f79dc Console Machines tab: app version, OTA state, power cap, jobs and status lines from the app log (0.3.3 header)
The IGNEUM-APP header is read from the newest upload's first line, from any tail, or from the first upload of
the run; the app's status: line supplies peers, lifetime accepted and synced when the node log tail has none.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-04 16:50:21 +00:00

335 lines
24 KiB
JavaScript

// 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;
}
const HEADER = /IGNEUM-APP version=(\S+) machine=(\S+) platform=(\S+) node=(\S+)/;
function parseHeader(text) {
const m = HEADER.exec(text || '');
return m ? { version: m[1], id8: m[2], platform: m[3], node: m[4].replace(/^igneumd[_\/]/, '') } : null;
}
function parseAppTail(tail) {
const lines = tail.split('\n');
const a = { telemetry: [], events: [], jobs: [] };
let m;
if ((m = /Igneum Miner (\S+) on /.exec(tail))) a.version = m[1];
const h = lastMatch(lines, HEADER); if (h) { a.version = h[1]; a.node_version = h[4].replace(/^igneumd[_\/]/, ''); }
const stampOf = l => { const t = /^(\d+)(?:\.\d+)? /.exec(l); return t ? new Date(Number(t[1]) * 1000).toISOString() : null; };
const strip = l => l.replace(/^\d+(\.\d+)? /, '');
// OTA: the newest line decides the state shown on the card
const ota = lastMatch(lines, /^\d+(?:\.\d+)? (?:\[\w+\] )?(OTA: .+)$/);
if (ota) { const t = ota[1]; a.ota = { text: t.slice(0, 220), at: stampOf(ota[0]), state: /waiting for administrator approval|approval not given/.test(t) ? 'waiting for approval' : /deferred/.test(t) ? 'deferred' : /updated to|installed|applied/.test(t) ? 'updated' : /download/.test(t) ? 'downloading' : 'note' }; }
const pc = lastMatch(lines, /^\d+(?:\.\d+)? (?:\[\w+\] )?(?:GPU )?(power cap.*)$/i);
if (pc) a.power_cap = { text: pc[1].slice(0, 220), at: stampOf(pc[0]), ok: !/NOT applied|nothing to read back/.test(pc[1]) };
const jf = lastMatch(lines, /^\d+(?:\.\d+)? jobs: (.+)$/);
if (jf) a.jobs_file = { text: jf[1].slice(0, 220), at: stampOf(jf[0]) };
a.jobs = lines.filter(l => /^\d+(?:\.\d+)? (?:\[\w+\] )?job [^:]+[:(]/.test(l) || /^\d+(?:\.\d+)? (?:\[\w+\] )?\d+ remote jobs? received/.test(l)).slice(-6).map(l => ({ at: stampOf(l), text: strip(l).replace(/^\[\w+\] /, '').slice(0, 220) }));
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, /^(\d+)(?:\.\d+)? status: (.+?) \| node (.+?) \| up (.+)$/))) {
a.status = { miner: m[2], node: m[3], up: m[4], at: new Date(Number(m[1]) * 1000).toISOString() };
const acc = /accepted (\d+) blocks \((\d+) this run\), ([\d.]+) MH\/s, (\w+)/.exec(m[2]);
if (acc) Object.assign(a.status, { accepted_total: Number(acc[1]), accepted_run: Number(acc[2]), mhs: Number(acc[3]), mining: acc[4] });
const nd = /(\d+) blocks, (\d+) peers, (\w+)/.exec(m[3]);
if (nd) Object.assign(a.status, { node_blocks: Number(nd[1]), node_peers: Number(nd[2]), node_state: nd[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, left(lines, 300) AS head,
right(lines, CASE WHEN label LIKE 'nodelog-%' THEN 80000 WHEN label ~ '^(win|mac)-[0-9a-f]{8}$' THEN 60000 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: [], header: null };
const hd = parseHeader(r.head) || parseHeader(r.tail); if (hd) g.header = hd;
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 = [...new Set([...groups.values()].flatMap(g => [...g.runs]))];
if (runIds.length) {
const heads = await sql(`SELECT DISTINCT ON (run_id, label) run_id, label, left(lines, 3000) AS head FROM miner_logs WHERE run_id = ANY($1) ORDER BY run_id, label, received_at ASC`, [runIds]);
for (const h of heads) {
const g = [...groups.values()].find(x => x.runs.has(h.run_id)); if (!g) continue;
const m = /igneumd\/(\S+)/.exec(h.head); if (m && h.label.startsWith('nodelog-') && !g.node.version) g.node.version = m[1];
const hd = parseHeader(h.head); if (hd && !g.header) g.header = hd;
}
}
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) || (g.header && g.header.version) || null,
app: g.app ? { at: g.app.at, status: g.app.status || null, ota: g.app.ota || null, power_cap: g.app.power_cap || null, jobs_file: g.app.jobs_file || null, jobs: g.app.jobs || [] } : null,
node: { version: g.node.version || (g.header && g.header.node) || (g.app && g.app.node_version) || null, daa: g.node.daa ?? null, blue_score: g.node.blue_score ?? null, checkpoint: g.node.checkpoint ?? null,
peers: g.node.peers ?? (g.app && g.app.status && g.app.status.node_peers != null ? g.app.status.node_peers : null), peers_wanted: g.node.peers_wanted ?? null, peers_from: g.node.peers != null ? 'node log' : (g.app && g.app.status ? 'app status line' : null),
blocks: g.app && g.app.status && g.app.status.node_blocks != null ? g.app.status.node_blocks : null,
blocks_10s: g.node.blocks_10s ?? null, synced: cards.some(c => c.synced === true) || (g.node.blocks_10s != null && !g.node.ibd_seen) || !!(g.app && g.app.status && g.app.status.node_state === 'synced'), 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 || (g.app && g.app.status ? g.app.status.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) || (g.app && g.app.status && g.app.status.accepted_run) || 0,
accepted_lifetime: g.app && g.app.status ? g.app.status.accepted_total ?? null : null,
faults: cards.reduce((a, c) => a + (c.faults || 0), 0),
telemetry: g.app ? g.app.telemetry : [], events: g.app ? g.app.events : [],
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, app: null, node: {}, cards: [], hash_total: 0, accepted_total: 0, faults: 0, telemetry: [], events: [], 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) });
}
}