diff --git a/site/api/live.mjs b/site/api/live.mjs
index f37f91183..60ab8c432 100644
--- a/site/api/live.mjs
+++ b/site/api/live.mjs
@@ -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),
diff --git a/site/live.html b/site/live.html
index 746758c4f..b007f1625 100644
--- a/site/live.html
+++ b/site/live.html
@@ -180,12 +180,14 @@ footer{border-top:1px solid var(--line);padding-block:48px 32px}
- chain block
- side block (blue or red, off the selected chain)
+ chain block
+ included (blue): paid
+ pending
+ excluded (red)
locked checkpoint, final
just arrived
selected chain
- first parent of a side block
+ first parent
5d3fd79cone lane per miner, colour from its id
hover or tap a block for its id, scores and parents
@@ -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;i0){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 (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();