The "LIVE DEVNET" scene on the home page showed blocks 21, proven 0, locked 3 and grey nodes while the devnet stood past chain block 140,000 with 2,090 shards paid and a checkpoint locking every 30 s. Four causes, all in site/index.html: - "blocks" was the scene's own spawn counter, never the chain; - "proven" counted blocks of the 90 s window whose shards were all paid, and proofs land a median 380 s behind the tip (median_proof_lag_s), so the window can never hold one; - "locked" counted locked checkpoint blocks inside the same 90 s window (always about 3); - the live path skipped the colour state machine, so every real block stayed grey, and a failed first fetch left the simulation running under a label that looked live, with no further polls. Now: /api/live carries state.height (the chain block number, the same field /api/stats reports, one indexed subquery); the scene's counters read chain block (state.height), shards proven (proving.paid_shards_total, the node's count of paid shard records, with the 10-minute count and the median lag in the note) and last lock (finality.latest_locked_index); live blocks take the observer's own words (pending, included, excluded, proven, locked checkpoint, final to its left), refreshed on every poll, with a legend in those words; the hero tile shows the chain block instead of the node's held-block count; polling never stops (2 s while fresh, 10 s while off) and the off state is labelled "simulated preview · live feed unavailable" or "observer offline, last update N ago" with a note that the counters count simulated blocks. Also found while verifying: the scene advanced a fixed 16 ms per frame, so it ran at double speed on 120 Hz displays; it now uses the real frame time, capped at 50 ms. /live had the same window bug in its "proven 0/16" counter; it now reads the observer's 10-minute truth (chain blocks with every shard paid, of the chain blocks planned in 10 min) with the lag and the chain total in its title. Verified against the live API through tools/site-serve.mjs in the built-in browser and headless Chromium: chain block 140,489, shards proven 2,130, last lock #6,784; the failed-fetch state shown by blocking /api/live. Screenshots: docs/plans/site-ui-3-shots/item0/ (1440 and 390, live and failed). Checks: identity grep 0 hits, link check 708 links 0 broken, site/api tests 14 pass, site build clean. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
176 lines
10 KiB
JavaScript
176 lines
10 KiB
JavaScript
// Igneum live devnet feed. GET /api/live returns what tools/observer wrote to Neon:
|
|
// {now, state (incl. height = chain block number), blocks (last 90 s, or ?window=N up to 300), miners (last 10 min), events (last 30), finality (checkpoints, newest lock),
|
|
// proving (activation and 10-minute counts; every chain block carries shards: [{i, state, prover, lag, payout}])}.
|
|
// Five queries, all on indexed columns. Cached one second at the edge.
|
|
// LIVE_TABLE_PREFIX (default empty) reads a test observer's tables (fintest_live_*, provtest_live_*) instead of the devnet's.
|
|
// Zero dependencies: Neon's HTTP SQL endpoint over Node's built-in fetch.
|
|
|
|
const STALE_AFTER_S = 30;
|
|
const T = (process.env.LIVE_TABLE_PREFIX || '').replace(/[^a-z0-9_]/gi, '');
|
|
|
|
function neon() {
|
|
const url = process.env.DATABASE_URL;
|
|
if (!url) throw new Error('DATABASE_URL is not set');
|
|
const host = new URL(url).hostname.replace('-pooler', '');
|
|
return async (query, params = []) => {
|
|
const r = await fetch(`https://${host}/sql`, {
|
|
method: 'POST',
|
|
headers: { 'Neon-Connection-String': url, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ query, params }),
|
|
});
|
|
const j = await r.json();
|
|
if (!r.ok) throw new Error(j.message || JSON.stringify(j));
|
|
return j.rows || [];
|
|
};
|
|
}
|
|
|
|
const num = v => (v === null || v === undefined ? null : Number(v));
|
|
const short = h => (h ? String(h).slice(0, 16) : null);
|
|
const minerId = vk => (vk ? String(vk).slice(0, 8) : null);
|
|
|
|
export default async function handler(req, res) {
|
|
res.setHeader('Cache-Control', 'public, max-age=1');
|
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
if (req.method !== 'GET') {
|
|
res.setHeader('Allow', 'GET');
|
|
return res.status(405).json({ ok: false, error: 'method not allowed' });
|
|
}
|
|
try {
|
|
const sql = neon();
|
|
// ?window=N seconds of blocks (30 to 300, default 90): the page's diagnostic long view; the row cap keeps a busy DAG bounded
|
|
const q = new URL(req.url || '/', 'http://x').searchParams;
|
|
const windowS = Math.max(30, Math.min(300, Number(q.get('window')) || 90));
|
|
const [head, blockRows, minerRows, checkpointRows, proofRows] = await Promise.all([
|
|
sql(`SELECT now() AS now,
|
|
(SELECT row_to_json(s) FROM ${T}live_state s WHERE s.id = 1) AS state,
|
|
(SELECT json_agg(e) FROM (SELECT ts, kind, text FROM ${T}live_events ORDER BY ts DESC LIMIT 30) e) AS events,
|
|
(SELECT max(number) FROM ${T}live_blocks WHERE number IS NOT NULL AND received_at > now() - interval '10 minutes') AS height`),
|
|
sql(`SELECT hash, blue_score, daa_score, timestamp_ms, parent_hashes, is_chain_block, vote_key_hash, received_at, color
|
|
FROM ${T}live_blocks
|
|
WHERE received_at > now() - ($1 || ' seconds')::interval
|
|
ORDER BY received_at DESC
|
|
LIMIT 2000`, [String(windowS)]),
|
|
sql(`SELECT vote_key_hash, count(*)::int AS blocks, max(received_at) AS last_seen, max(engine) AS engine, max(miner_address) AS address
|
|
FROM ${T}live_blocks
|
|
WHERE received_at > now() - interval '10 minutes' AND vote_key_hash IS NOT NULL
|
|
GROUP BY vote_key_hash
|
|
ORDER BY blocks DESC, last_seen DESC`),
|
|
// Finality v2: the most recent checkpoints; an older observer has no table yet, so this query may fail
|
|
sql(`SELECT index, hash, blue_score, daa_score, state, signed_weight, active_weight, total_weight, fraction_active, fraction_total, votes_seen, voters, locked_at, updated_at
|
|
FROM ${T}live_checkpoints
|
|
ORDER BY index DESC
|
|
LIMIT 40`).catch(() => []),
|
|
// Proving v0: the planned shards of the chain blocks in the window and what became of them (an older observer has no table)
|
|
sql(`SELECT block_hash, shard, shards, state, prover, lag_daa, payout_wei, pgas
|
|
FROM ${T}live_proofs
|
|
WHERE received_at > now() - ($1 || ' seconds')::interval
|
|
ORDER BY block_hash, shard`, [String(windowS + 30)]).catch(() => []),
|
|
]);
|
|
|
|
const now = new Date(head[0].now).getTime();
|
|
const s = head[0].state || null;
|
|
const updated = s && s.updated_at ? new Date(s.updated_at).getTime() : null;
|
|
const age_s = updated === null ? null : Math.max(0, (now - updated) / 1000);
|
|
const stale = age_s === null || age_s > STALE_AFTER_S;
|
|
const total10m = minerRows.reduce((a, m) => a + m.blocks, 0);
|
|
|
|
const state = {
|
|
stale,
|
|
age_s: age_s === null ? null : Math.round(age_s * 10) / 10,
|
|
network: s ? s.network : null,
|
|
node_version: s ? s.node_version : null,
|
|
// 6 Oct 2026: height is the chain block number (the EVM block number of the newest chain block the observer planned),
|
|
// the same field /api/stats reports; block_count counts every DAG block the node holds (pruned nodes hold fewer)
|
|
height: num(head[0].height),
|
|
block_count: s ? num(s.block_count) : null,
|
|
header_count: s ? num(s.header_count) : null,
|
|
blue_score: s ? num(s.blue_score) : null,
|
|
difficulty: s ? num(s.difficulty) : null,
|
|
hashes_per_second_estimate: s ? num(s.hashes_per_second_estimate) : null,
|
|
peers: s ? num(s.peers) : null,
|
|
mempool: s ? num(s.mempool) : null,
|
|
blocks_60s: s ? num(s.blocks_60s) : null,
|
|
blocks_per_second_60s: s && s.blocks_60s !== null ? Math.round(num(s.blocks_60s) / 60 * 1000) / 1000 : null,
|
|
blocks_per_minute: s ? s.blocks_per_minute || [] : [],
|
|
miners_10m: minerRows.length,
|
|
observer_started_at: s ? s.observer_started_at : null,
|
|
// 4 Oct 2026: how far the observer's newest stored block is behind the clock, and what it still has queued
|
|
observer_lag_s: s ? num(s.observer_lag_s) : null,
|
|
queue_depth: s ? num(s.queue_depth) : null,
|
|
updated_at: s ? s.updated_at : null,
|
|
};
|
|
|
|
const fin = (s && s.finality) || null;
|
|
const checkpoints = checkpointRows.reverse().map(c => ({
|
|
index: num(c.index), hash: short(c.hash), blue_score: num(c.blue_score), daa: num(c.daa_score), state: c.state,
|
|
signed: num(c.signed_weight), active: num(c.active_weight), total: num(c.total_weight),
|
|
fraction_active: num(c.fraction_active), fraction_total: num(c.fraction_total), votes: num(c.votes_seen), voters: num(c.voters),
|
|
locked_at: c.locked_at, updated_at: c.updated_at,
|
|
}));
|
|
const lockedHashes = new Set(checkpoints.filter(c => c.state === 'locked').map(c => c.hash));
|
|
const newestLock = checkpoints.filter(c => c.state === 'locked').slice(-1)[0] || null;
|
|
const finality = {
|
|
supported: !!fin,
|
|
active: fin ? !!fin.finality_active : false,
|
|
next_index: fin ? num(fin.next_index) : null,
|
|
latest_locked_index: newestLock ? newestLock.index : (fin ? num(fin.latest_locked_index) || null : null),
|
|
latest_locked_hash: newestLock ? newestLock.hash : null,
|
|
latest_locked_blue_score: newestLock ? newestLock.blue_score : null,
|
|
params: fin ? fin.params || null : null,
|
|
weights: fin ? fin.weights || null : null,
|
|
checkpoints,
|
|
};
|
|
|
|
// proving v0: one cell per planned shard; payout in IGN (wei / 1e18) to four decimals, never an address (R4.6.8)
|
|
const shardsOf = new Map();
|
|
for (const r of proofRows) {
|
|
const k = short(r.block_hash);
|
|
if (!shardsOf.has(k)) shardsOf.set(k, []);
|
|
let payout = null;
|
|
if (r.payout_wei) { try { payout = Number(BigInt(r.payout_wei) / 100000000000000n) / 10000; } catch { payout = null; } }
|
|
shardsOf.get(k).push({ i: num(r.shard), n: num(r.shards), state: r.state, prover: r.prover || null, lag: num(r.lag_daa), payout, pgas: num(r.pgas) });
|
|
}
|
|
const pv = (s && s.proving) || null;
|
|
const proving = pv && pv.supported ? {
|
|
supported: true, active: !!pv.active, activation_daa: num(pv.activation_daa), tip_daa: num(pv.tip_daa), verifier: pv.verifier || null, pool: pv.pool || null,
|
|
paid_shards_total: num(pv.paid_shards_total), shard_budget_pgas: num(pv.shard_budget_pgas),
|
|
blocks_10m: num(pv.blocks_10m), blocks_fully_proven_10m: num(pv.blocks_fully_proven_10m), shards_proven_10m: num(pv.shards_proven_10m), shards_paid_10m: num(pv.shards_paid_10m),
|
|
median_proof_lag_s: num(pv.median_proof_lag_s), provers_10m: num(pv.provers_10m),
|
|
} : { supported: false, reason: pv ? (pv.reason || 'node without proving') : 'observer has not reported the proving layer' };
|
|
|
|
const blocks = blockRows.reverse().map(b => ({
|
|
hash: short(b.hash),
|
|
blue_score: num(b.blue_score),
|
|
daa: num(b.daa_score),
|
|
ts: num(b.timestamp_ms),
|
|
rx: new Date(b.received_at).getTime(),
|
|
parents: (b.parent_hashes || []).map(short),
|
|
chain: !!b.is_chain_block,
|
|
miner: minerId(b.vote_key_hash),
|
|
// GHOSTDAG colour from the observer: blue (merged by a chain block, paid), red (excluded) or pending (not merged yet)
|
|
color: b.color === 'blue' || b.color === 'red' ? b.color : 'pending',
|
|
// Finality v2: a locked checkpoint block, and whether the block sits at or before the newest lock
|
|
locked: lockedHashes.has(short(b.hash)),
|
|
final: !!(newestLock && b.is_chain_block && num(b.blue_score) <= newestLock.blue_score),
|
|
// Proving v0: the chain block's shards (planned, proving, verified, paid) and whether every shard is proven
|
|
shards: shardsOf.get(short(b.hash)) || [],
|
|
proven: (shardsOf.get(short(b.hash)) || []).length > 0 && (shardsOf.get(short(b.hash)) || []).every(x => x.state === 'verified' || x.state === 'paid'),
|
|
}));
|
|
|
|
const miners = minerRows.map(m => ({
|
|
id: minerId(m.vote_key_hash),
|
|
blocks: m.blocks,
|
|
share: total10m ? Math.round(m.blocks / total10m * 1000) / 10 : 0,
|
|
last_seen: m.last_seen,
|
|
engine: m.engine || null,
|
|
}));
|
|
|
|
// peer events never carry an address on the public API (review round 4, R4.6.2); older stored rows are redacted here too
|
|
const events = (head[0].events || []).map(e => ({ ts: e.ts, kind: e.kind, text: String(e.text).replace(/\b\d{1,3}(\.\d{1,3}){3}(:\d+)?\b/g, 'a peer') }));
|
|
|
|
return res.status(200).json({ ok: true, now: new Date(now).toISOString(), state, blocks, miners, events, finality, proving });
|
|
} catch (e) {
|
|
res.setHeader('Cache-Control', 'no-store');
|
|
return res.status(500).json({ ok: false, error: String(e.message || e) });
|
|
}
|
|
}
|