The owner, 6 October 2026: "I liked it when we could see each step of the algo working." The home page's scene is again the lane chart where a square moves through mined, shards being proven, proven and locked checkpoint, now as site/live-steps.js on the shared feed (site/scenes/feed.js, which gains onData): - every square is a real block from /api/live (a 300 s window so late proofs still reach the caption), released one every 2.5 s so a square crosses the scene in about 34 s; the chain merges a pending block in about 10 s, so its step changes while it is still on screen (at the original 10 s crossing most transitions landed after the square had left). The feed is sampled for the stream; item 0's counters read the chain. The first reply seeds the scene across its width, so nothing starts empty; - each step is drawn as before: an outline (grey while pending, ember once included), molten quarter cells filling as shards are verified or paid, an ember fill when proven, a ring on a locked checkpoint with the dashed "final" line to its left, excluded blocks dimmed; every transition glows for a second and a lock ripples; - one caption line under the scene for the newest block that changed step, e.g. "block 144,564: mined 21:16:03, on the selected chain; 1 shard planned", or with its shards "8 shards on 3 cards; proven in 48 s" and "locked at checkpoint 7,084". /api/live now carries each block's chain number for it. When no block changes step for a minute the caption says why in the ledger's words: finality paused (under two thirds of the weight signing), no proof landed in the last 10 minutes (with the median lag), or no block changed step; - the wheel is never touched: the module has no wheel handler, so the page scrolls through the scene; - light and dark from the tokens, 390 px, a still frame under reduced motion. The DAG module stays as /live's scene and the three scenes stay unlinked on /scenes. Proved headless over 40 s against the live feed: thirteen captioned transitions, no console errors at 1440 or 390, no overflow. Captures: docs/plans/site-ui-3-shots/after/index-steps-1440-dark-clip.jpg, index-steps-390-dark-clip.jpg and index-steps-1440-dark.webm (ten seconds of blocks moving through the steps). Checks: identity grep 0 hits, link check 931 links 0 broken, ledger text 43 of 43, contrast 32 pairs 0 under 4.5:1, api tests pass. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
178 lines
10 KiB
JavaScript
178 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, number
|
|
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),
|
|
// the chain block number (the EVM block number) once the observer has planned the block; null off the chain or before the plan
|
|
number: num(b.number),
|
|
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) });
|
|
}
|
|
}
|