Live DAG: side blocks carry their GHOSTDAG colour (blue paid, red excluded, pending)
Observer: additive live_blocks.color (pending by default). Every chain block's mergeset marks its blues blue and its reds red, from the notification's verbose data or getBlock for chain blocks learned via virtualChainChanged; a reorg puts the removed chain blocks' mergesets back to pending. API serves color. Page: blue side blocks filled in the miner's hue at 70% with the ring, pending the faint outline, red a dark outline with a strike; tooltip and legend name the state. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
018f7392f7
commit
b9e8a79397
3 changed files with 52 additions and 11 deletions
|
|
@ -38,7 +38,7 @@ export default async function handler(req, res) {
|
|||
sql(`SELECT now() AS now,
|
||||
(SELECT row_to_json(s) FROM live_state s WHERE s.id = 1) AS state,
|
||||
(SELECT json_agg(e) FROM (SELECT ts, kind, text FROM live_events ORDER BY ts DESC LIMIT 30) e) AS events`),
|
||||
sql(`SELECT hash, blue_score, daa_score, timestamp_ms, parent_hashes, is_chain_block, vote_key_hash, received_at
|
||||
sql(`SELECT hash, blue_score, daa_score, timestamp_ms, parent_hashes, is_chain_block, vote_key_hash, received_at, color
|
||||
FROM live_blocks
|
||||
WHERE received_at > now() - interval '90 seconds'
|
||||
ORDER BY received_at DESC
|
||||
|
|
@ -112,6 +112,8 @@ export default async function handler(req, res) {
|
|||
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),
|
||||
|
|
|
|||
|
|
@ -180,12 +180,14 @@ footer{border-top:1px solid var(--line);padding-block:48px 32px}
|
|||
</div>
|
||||
<canvas id="dag" aria-hidden="true"></canvas>
|
||||
<div class="legend">
|
||||
<span><i class="sw" style="background:var(--ember)"></i>chain block</span>
|
||||
<span><i class="sw" style="border:2px solid var(--ash)"></i>side block (blue or red, off the selected chain)</span>
|
||||
<span><i class="sw" style="background:var(--ember);border:1.5px solid #5CB8FF"></i>chain block</span>
|
||||
<span><i class="sw" style="background:rgba(92,184,255,.7);border:1.5px solid #5CB8FF"></i>included (blue): paid</span>
|
||||
<span><i class="sw" style="border:1.5px solid var(--ash)"></i>pending</span>
|
||||
<span><i class="sw" style="border:1.5px solid rgba(154,154,158,.6);background:linear-gradient(45deg,transparent 44%,var(--ash) 44%,var(--ash) 56%,transparent 56%) center/8px 8px no-repeat"></i>excluded (red)</span>
|
||||
<span><i class="sw" style="border:2px solid var(--molten);border-radius:50%;background:transparent"></i>locked checkpoint, final</span>
|
||||
<span><i class="sw" style="border:2px solid var(--molten);box-shadow:0 0 8px rgba(255,179,92,.7)"></i>just arrived</span>
|
||||
<span><i class="sw" style="width:22px;height:0;border-top:2px solid var(--ember)"></i>selected chain</span>
|
||||
<span><i class="sw" style="width:22px;height:0;border-top:1px solid var(--ash)"></i>first parent of a side block</span>
|
||||
<span><i class="sw" style="width:22px;height:0;border-top:1px solid var(--ash)"></i>first parent</span>
|
||||
<span><span class="mono" style="color:#5CB8FF">5d3fd79c</span>one lane per miner, colour from its id</span>
|
||||
<span style="color:var(--ash)">hover or tap a block for its id, scores and parents</span>
|
||||
</div>
|
||||
|
|
@ -304,8 +306,8 @@ footer{border-top:1px solid var(--line);padding-block:48px 32px}
|
|||
function ingest(d){
|
||||
var now=performance.now();serverNow=new Date(d.now).getTime();serverAt=now;
|
||||
d.blocks.forEach(function(b){var e=byHash[b.hash];
|
||||
if(e){e.chain=b.chain;e.locked=!!b.locked;e.final=!!b.final;return;}
|
||||
e={hash:b.hash,ts:b.ts,blue_score:b.blue_score,daa:b.daa,parents:b.parents||[],chain:b.chain,miner:b.miner||'?',born:now,j:jitter(b.hash),locked:!!b.locked,final:!!b.final,px:0,py:0,on:false};
|
||||
if(e){e.chain=b.chain;e.locked=!!b.locked;e.final=!!b.final;e.color=b.color||'pending';return;}
|
||||
e={hash:b.hash,ts:b.ts,blue_score:b.blue_score,daa:b.daa,parents:b.parents||[],chain:b.chain,color:b.color||'pending',miner:b.miner||'?',born:now,j:jitter(b.hash),locked:!!b.locked,final:!!b.final,px:0,py:0,on:false};
|
||||
byHash[b.hash]=e;blocks.push(e);});
|
||||
var cut=vnow()-((W-G)/Math.max(8,pxPerSec))*1000-20000;
|
||||
blocks=blocks.filter(function(b){var keep=b.ts>cut;if(!keep)delete byHash[b.hash];return keep;});
|
||||
|
|
@ -367,7 +369,9 @@ footer{border-top:1px solid var(--line);padding-block:48px 32px}
|
|||
for(i=0;i<n;i++){b=blocks[i];if(!b.on)continue;m=miners[b.miner];var age=now-b.born,fl=age<900?1-age/900:0;
|
||||
if(fl>0){var g=x.createRadialGradient(b.px,b.py,0,b.px,b.py,S*1.6);g.addColorStop(0,'rgba(255,179,92,'+(0.5*fl)+')');g.addColorStop(1,'rgba(255,179,92,0)');x.fillStyle=g;x.beginPath();x.arc(b.px,b.py,S*1.6,0,Math.PI*2);x.fill();}
|
||||
if(b.chain){x.fillStyle='#F2541B';rrect(b.px-h,b.py-h,S,S,r);x.fill();x.strokeStyle=m?m.col:'#9A9A9E';x.lineWidth=1.5;rrect(b.px-h-1,b.py-h-1,S+2,S+2,r+1);x.stroke();tip=b;}
|
||||
else{x.fillStyle='#0C0C0E';rrect(b.px-h,b.py-h,S,S,r);x.fill();x.strokeStyle=fl>0?'rgba(255,179,92,0.95)':'rgba(154,154,158,0.85)';x.lineWidth=1.5;rrect(b.px-h,b.py-h,S,S,r);x.stroke();}
|
||||
else if(b.color==='blue'){var mc=m?m.col:'#9A9A9E';x.fillStyle=mc+'B3';rrect(b.px-h,b.py-h,S,S,r);x.fill();x.strokeStyle=fl>0?'rgba(255,179,92,0.95)':mc;x.lineWidth=1.5;rrect(b.px-h,b.py-h,S,S,r);x.stroke();}
|
||||
else{x.fillStyle='#0C0C0E';rrect(b.px-h,b.py-h,S,S,r);x.fill();x.strokeStyle=fl>0?'rgba(255,179,92,0.95)':(b.color==='red'?'rgba(154,154,158,0.6)':'rgba(154,154,158,0.85)');x.lineWidth=1.5;rrect(b.px-h,b.py-h,S,S,r);x.stroke();
|
||||
if(b.color==='red'){x.strokeStyle='rgba(154,154,158,0.9)';x.lineWidth=1.5;x.beginPath();x.moveTo(b.px-h*0.5,b.py+h*0.5);x.lineTo(b.px+h*0.5,b.py-h*0.5);x.stroke();}}
|
||||
if(fl>0&&b.chain){x.strokeStyle='rgba(255,179,92,'+fl+')';x.lineWidth=2;rrect(b.px-h-3,b.py-h-3,S+6,S+6,r+2);x.stroke();}
|
||||
if(b.locked){x.strokeStyle='#FFB35C';x.lineWidth=2.5;x.beginPath();x.arc(b.px,b.py,S*0.95,0,Math.PI*2);x.stroke();}
|
||||
if(hv===b){x.strokeStyle='#F4F1EC';x.lineWidth=1.5;rrect(b.px-h-3,b.py-h-3,S+6,S+6,r+2);x.stroke();}}
|
||||
|
|
@ -377,7 +381,7 @@ footer{border-top:1px solid var(--line);padding-block:48px 32px}
|
|||
if(fx>G){var ft=lk?'final #'+lk.hash.slice(0,8):'final',fw=x.measureText(ft).width;x.fillStyle='#FFB35C';
|
||||
if(fx-G>fw+14){x.textAlign='right';x.fillText(ft,fx-6,12);}else{x.textAlign='left';x.fillText(ft,fx+6,12);}}
|
||||
// tooltip on the canvas, no DOM
|
||||
if(hv){var l1=hv.hash.slice(0,8)+(hv.locked?' locked':(hv.chain?' chain':' side')),l2='blue '+compact(hv.blue_score)+' daa '+compact(hv.daa),l3=hv.parents.length+(hv.parents.length===1?' parent':' parents')+' '+hv.miner;
|
||||
if(hv){var l1=hv.hash.slice(0,8)+' '+(hv.locked?'locked':hv.chain?'chain':hv.color==='blue'?'included, paid':hv.color==='red'?'excluded':'pending'),l2='blue '+compact(hv.blue_score)+' daa '+compact(hv.daa),l3=hv.parents.length+(hv.parents.length===1?' parent':' parents')+' '+hv.miner;
|
||||
var tw=Math.max(x.measureText(l1).width,x.measureText(l2).width,x.measureText(l3).width)+20,th=52,tx=hv.px+h+10,tty=hv.py-th/2;
|
||||
if(tx+tw>W-4)tx=hv.px-h-10-tw;if(tx<G+4)tx=G+4;tty=Math.max(4,Math.min(H-th-4,tty));
|
||||
x.fillStyle='rgba(22,22,26,0.96)';rrect(tx,tty,tw,th,6);x.fill();x.strokeStyle=miners[hv.miner]?miners[hv.miner].col:'#3A3A42';x.lineWidth=1;rrect(tx+0.5,tty+0.5,tw-1,th-1,6);x.stroke();
|
||||
|
|
|
|||
|
|
@ -71,6 +71,8 @@ async function setupSchema() {
|
|||
`CREATE INDEX IF NOT EXISTS ${TB}_received_at ON ${TB} (received_at)`,
|
||||
`CREATE INDEX IF NOT EXISTS ${TB}_timestamp_ms ON ${TB} (timestamp_ms)`,
|
||||
`CREATE INDEX IF NOT EXISTS ${TB}_vote_key_received ON ${TB} (vote_key_hash, received_at)`,
|
||||
// GHOSTDAG colour (4 Oct 2026, additive): pending until a chain block merges it, then blue (paid) or red (excluded)
|
||||
`ALTER TABLE ${TB} ADD COLUMN IF NOT EXISTS color text NOT NULL DEFAULT 'pending'`,
|
||||
`CREATE TABLE IF NOT EXISTS ${TS} (
|
||||
id int PRIMARY KEY DEFAULT 1 CHECK (id = 1),
|
||||
block_count bigint, header_count bigint, blue_score bigint, difficulty double precision,
|
||||
|
|
@ -235,6 +237,8 @@ const short = h => (h || '').slice(0, 8);
|
|||
|
||||
const pendingBlocks = []; // rows waiting for the next flush
|
||||
const pendingChain = { add: new Set(), remove: new Set() };
|
||||
const pendingMerge = new Map(); // chain block hash -> { blues, reds } from its verbose data, or null to fetch with getBlock
|
||||
const pendingUnmerge = new Set(); // chain blocks removed by a reorg: their mergeset goes back to pending
|
||||
const miners = new Map(); // vote_key_hash -> { lastSeen ms, quiet bool }
|
||||
const minuteCounts = new Map(); // minute epoch -> count (last 60 minutes)
|
||||
const recentArrivals = []; // ms timestamps of arrivals in the last 60 s
|
||||
|
|
@ -292,6 +296,8 @@ function onBlock(block) {
|
|||
const miner = minerFromCoinbase(block, addressPrefix);
|
||||
for (const cert of certificatesIn(block)) pendingCerts.push({ cert, carrier: h.hash });
|
||||
const vk = h.voteKeyHash || null;
|
||||
const vd = block.verboseData;
|
||||
if (vd && vd.isChainBlock && Array.isArray(vd.mergeSetBluesHashes)) pendingMerge.set(h.hash, { blues: vd.mergeSetBluesHashes, reds: vd.mergeSetRedsHashes || [] });
|
||||
pendingBlocks.push({
|
||||
hash: h.hash, blue_score: h.blueScore, daa_score: h.daaScore, timestamp_ms: h.timestamp,
|
||||
parents: parents.length, parent_hashes: parents,
|
||||
|
|
@ -325,6 +331,35 @@ async function flushBlocks() {
|
|||
if (pendingBlocks.length) await flushBlocks();
|
||||
}
|
||||
|
||||
// Colour: every chain block's mergeset is blue (in the selected chain's past, paid) or red (excluded). Blocks no chain
|
||||
// block has merged yet stay pending. A reorg puts the removed chain blocks' mergesets back to pending first.
|
||||
async function mergesetOf(rpc, hash) {
|
||||
const b = (await rpc.call('getBlock', { hash, includeTransactions: false })).block;
|
||||
const vd = (b && b.verboseData) || {};
|
||||
return { blues: vd.mergeSetBluesHashes || [], reds: vd.mergeSetRedsHashes || [] };
|
||||
}
|
||||
let colorsBusy = false;
|
||||
async function flushColors(rpc) {
|
||||
if (colorsBusy || (!pendingMerge.size && !pendingUnmerge.size)) return;
|
||||
colorsBusy = true;
|
||||
try {
|
||||
if (pendingUnmerge.size) {
|
||||
const back = [];
|
||||
for (const h of [...pendingUnmerge].slice(0, 50)) { try { const m = await mergesetOf(rpc, h); back.push(...m.blues, ...m.reds); pendingUnmerge.delete(h); } catch (e) { log('unmerge fetch failed', h.slice(0, 8), e.message); pendingUnmerge.delete(h); } }
|
||||
if (back.length) await sql(`UPDATE ${TB} SET color = 'pending' WHERE hash = ANY($1::text[]) AND color <> 'pending'`, [pgArray(back)]);
|
||||
}
|
||||
const blues = [], reds = [];
|
||||
for (const [h, m] of [...pendingMerge].slice(0, 100)) {
|
||||
let ms = m;
|
||||
if (!ms) { try { ms = await mergesetOf(rpc, h); } catch (e) { log('mergeset fetch failed', h.slice(0, 8), e.message); pendingMerge.delete(h); continue; } }
|
||||
blues.push(...ms.blues); reds.push(...ms.reds); pendingMerge.delete(h);
|
||||
}
|
||||
if (blues.length) await sql(`UPDATE ${TB} SET color = 'blue' WHERE hash = ANY($1::text[]) AND color <> 'blue'`, [pgArray(blues)]);
|
||||
if (reds.length) await sql(`UPDATE ${TB} SET color = 'red' WHERE hash = ANY($1::text[]) AND color <> 'red'`, [pgArray(reds)]);
|
||||
} catch (e) { log('color update failed', e.message); }
|
||||
colorsBusy = false;
|
||||
}
|
||||
|
||||
async function flushChain() {
|
||||
if (pendingChain.add.size) { const a = [...pendingChain.add]; pendingChain.add.clear(); try { await sql(`UPDATE ${TB} SET is_chain_block = true WHERE hash = ANY($1::text[]) AND NOT is_chain_block`, [pgArray(a)]); } catch (e) { log('chain update failed', e.message); } }
|
||||
if (pendingChain.remove.size) { const r = [...pendingChain.remove]; pendingChain.remove.clear(); try { await sql(`UPDATE ${TB} SET is_chain_block = false WHERE hash = ANY($1::text[]) AND is_chain_block`, [pgArray(r)]); } catch (e) { log('chain update failed', e.message); } }
|
||||
|
|
@ -620,8 +655,8 @@ async function main() {
|
|||
}
|
||||
}
|
||||
else if (method === 'virtualChainChangedNotification' && inner) {
|
||||
for (const h of inner.addedChainBlockHashes || []) { pendingChain.remove.delete(h); pendingChain.add.add(h); }
|
||||
for (const h of inner.removedChainBlockHashes || []) { pendingChain.add.delete(h); pendingChain.remove.add(h); }
|
||||
for (const h of inner.addedChainBlockHashes || []) { pendingChain.remove.delete(h); pendingChain.add.add(h); pendingUnmerge.delete(h); if (!pendingMerge.has(h)) pendingMerge.set(h, null); }
|
||||
for (const h of inner.removedChainBlockHashes || []) { pendingChain.add.delete(h); pendingChain.remove.add(h); pendingMerge.delete(h); pendingUnmerge.add(h); }
|
||||
}
|
||||
};
|
||||
let connected = false;
|
||||
|
|
@ -644,7 +679,7 @@ async function main() {
|
|||
rpc.onClose = () => { setTimeout(connect, 2000); };
|
||||
await connect();
|
||||
|
||||
setInterval(async () => { await flushBlocks(); await flushChain(); await flushCertificates(rpc); }, FLUSH_EVERY_MS);
|
||||
setInterval(async () => { await flushBlocks(); await flushChain(); await flushColors(rpc); await flushCertificates(rpc); }, FLUSH_EVERY_MS);
|
||||
setInterval(() => tick(rpc), STATE_EVERY_MS);
|
||||
setInterval(prune, PRUNE_EVERY_MS);
|
||||
tick(rpc); prune();
|
||||
|
|
|
|||
Loading…
Reference in a new issue