Files
dchain/node/explorer/app.js
vsecoder 7e7393e4f8 chore: initial commit for v0.0.1
DChain single-node blockchain + React Native messenger client.

Core:
- PBFT consensus with multi-sig validator admission + equivocation slashing
- BadgerDB + schema migration scaffold (CurrentSchemaVersion=0)
- libp2p gossipsub (tx/v1, blocks/v1, relay/v1, version/v1)
- Native Go contracts (username_registry) alongside WASM (wazero)
- WebSocket gateway with topic-based fanout + Ed25519-nonce auth
- Relay mailbox with NaCl envelope encryption (X25519 + Ed25519)
- Prometheus /metrics, per-IP rate limit, body-size cap

Deployment:
- Single-node compose (deploy/single/) with Caddy TLS + optional Prometheus
- 3-node dev compose (docker-compose.yml) with mocked internet topology
- 3-validator prod compose (deploy/prod/) for federation
- Auto-update from Gitea via /api/update-check + systemd timer
- Build-time version injection (ldflags → node --version)
- UI / Swagger toggle flags (DCHAIN_DISABLE_UI, DCHAIN_DISABLE_SWAGGER)

Client (client-app/):
- Expo / React Native / NativeWind
- E2E NaCl encryption, typing indicator, contact requests
- Auto-discovery of canonical contracts, chain_id aware, WS reconnect on node switch

Documentation:
- README.md, CHANGELOG.md, CONTEXT.md
- deploy/single/README.md with 6 operator scenarios
- deploy/UPDATE_STRATEGY.md with 4-layer forward-compat design
- docs/contracts/*.md per contract
2026-04-17 14:16:44 +03:00

485 lines
18 KiB
JavaScript

(function() {
var C = window.ExplorerCommon;
/* ── State ──────────────────────────────────────────────────────────────── */
var state = {
txSeries: [], // tx counts per recent block (oldest → newest)
intervalSeries: [], // block intervals in seconds
supplyHistory: [], // cumulative tx count (proxy sparkline for supply)
tpsSeries: [], // same as txSeries, used for strip micro-bar
};
/* ── Address / Block links ──────────────────────────────────────────────── */
function linkAddress(value, label) {
return '<a href="/address?address=' + encodeURIComponent(value) + '">' +
C.esc(label || value) + '</a>';
}
function linkBlock(index) {
return '<a href="#" class="block-link" data-index="' + C.esc(index) + '">#' +
C.esc(index) + '</a>';
}
/* ── Micro chart helpers (strip sparklines) ─────────────────────────────── */
function resizeMicro(canvas) {
var dpr = window.devicePixelRatio || 1;
var w = canvas.offsetWidth || 80;
var h = canvas.offsetHeight || 36;
canvas.width = Math.max(1, Math.floor(w * dpr));
canvas.height = Math.max(1, Math.floor(h * dpr));
var ctx = canvas.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
return { ctx: ctx, w: w, h: h };
}
/** Tiny bar chart for the TPS strip cell */
function drawMicroBars(canvas, values, color) {
if (!canvas) return;
var r = resizeMicro(canvas);
var ctx = r.ctx; var w = r.w; var h = r.h;
ctx.clearRect(0, 0, w, h);
if (!values || !values.length) return;
var n = Math.min(values.length, 24);
var vs = values.slice(-n);
var max = Math.max.apply(null, vs.concat([1]));
var gap = 2;
var bw = Math.max(2, (w - gap * (n + 1)) / n);
ctx.fillStyle = color;
for (var i = 0; i < vs.length; i++) {
var bh = Math.max(2, (vs[i] / max) * (h - 4));
var x = gap + i * (bw + gap);
var y = h - bh;
ctx.fillRect(x, y, bw, bh);
}
}
/** Tiny sparkline with gradient fill for the Supply strip cell */
function drawSparkline(canvas, values, color) {
if (!canvas) return;
var r = resizeMicro(canvas);
var ctx = r.ctx; var w = r.w; var h = r.h;
ctx.clearRect(0, 0, w, h);
if (!values || values.length < 2) {
// draw a flat line when no data
ctx.strokeStyle = color + '55';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(2, h / 2);
ctx.lineTo(w - 2, h / 2);
ctx.stroke();
return;
}
var n = Math.min(values.length, 24);
var vs = values.slice(-n);
var mn = Math.min.apply(null, vs);
var mx = Math.max.apply(null, vs);
if (mn === mx) { mn = mn - 1; mx = mx + 1; }
var span = mx - mn;
var pad = 3;
var pts = [];
for (var i = 0; i < vs.length; i++) {
var x = pad + (i / (vs.length - 1)) * (w - pad * 2);
var y = (h - pad) - ((vs[i] - mn) / span) * (h - pad * 2);
pts.push([x, y]);
}
// gradient fill
var grad = ctx.createLinearGradient(0, 0, 0, h);
grad.addColorStop(0, color + '40');
grad.addColorStop(1, color + '05');
ctx.beginPath();
ctx.moveTo(pts[0][0], pts[0][1]);
for (var j = 1; j < pts.length; j++) ctx.lineTo(pts[j][0], pts[j][1]);
ctx.lineTo(pts[pts.length - 1][0], h);
ctx.lineTo(pts[0][0], h);
ctx.closePath();
ctx.fillStyle = grad;
ctx.fill();
// line
ctx.beginPath();
ctx.moveTo(pts[0][0], pts[0][1]);
for (var k = 1; k < pts.length; k++) ctx.lineTo(pts[k][0], pts[k][1]);
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
ctx.stroke();
}
/* ── Full-size chart helpers (main chart panels) ────────────────────────── */
function resizeCanvas(canvas) {
var dpr = window.devicePixelRatio || 1;
var w = canvas.clientWidth;
var h = canvas.clientHeight;
canvas.width = Math.max(1, Math.floor(w * dpr));
canvas.height = Math.max(1, Math.floor(h * dpr));
var ctx = canvas.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
return ctx;
}
function chartLayout(width, height) {
return {
left: 44,
right: 10,
top: 10,
bottom: 26,
plotW: Math.max(10, width - 54),
plotH: Math.max(10, height - 36)
};
}
function fmtTick(v) {
if (Math.abs(v) >= 1000) return String(Math.round(v));
if (Math.abs(v) >= 10) return (Math.round(v * 10) / 10).toFixed(1);
return (Math.round(v * 100) / 100).toFixed(2);
}
function drawAxes(ctx, w, h, b, minY, maxY) {
ctx.strokeStyle = '#334155';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(b.left, b.top); ctx.lineTo(b.left, h - b.bottom); ctx.stroke();
ctx.beginPath(); ctx.moveTo(b.left, h - b.bottom); ctx.lineTo(w - b.right, h - b.bottom); ctx.stroke();
ctx.fillStyle = '#94a3b8';
ctx.font = '11px "Inter", sans-serif';
for (var i = 0; i <= 4; i++) {
var ratio = i / 4;
var y = b.top + b.plotH * ratio;
var val = maxY - (maxY - minY) * ratio;
ctx.beginPath(); ctx.moveTo(b.left - 4, y); ctx.lineTo(b.left, y); ctx.stroke();
ctx.fillText(fmtTick(val), 4, y + 3);
}
ctx.fillText('oldest', b.left, h - 8);
ctx.fillText('newest', w - b.right - 36, h - 8);
}
function drawBars(canvas, values, color) {
if (!canvas) return;
var ctx = resizeCanvas(canvas);
var w = canvas.clientWidth; var h = canvas.clientHeight;
ctx.clearRect(0, 0, w, h);
var b = chartLayout(w, h);
var max = Math.max.apply(null, (values || []).concat([1]));
drawAxes(ctx, w, h, b, 0, max);
if (!values || !values.length) return;
var gap = 5;
var bw = Math.max(2, (b.plotW - gap * (values.length + 1)) / values.length);
ctx.fillStyle = color;
for (var i = 0; i < values.length; i++) {
var bh = max > 0 ? (values[i] / max) * b.plotH : 2;
var x = b.left + gap + i * (bw + gap);
var y = b.top + b.plotH - bh;
ctx.fillRect(x, y, bw, bh);
}
}
function drawLine(canvas, values, color) {
if (!canvas) return;
var ctx = resizeCanvas(canvas);
var w = canvas.clientWidth; var h = canvas.clientHeight;
ctx.clearRect(0, 0, w, h);
var b = chartLayout(w, h);
if (!values || !values.length) { drawAxes(ctx, w, h, b, 0, 1); return; }
var mn = Math.min.apply(null, values);
var mx = Math.max.apply(null, values);
if (mn === mx) { mn = mn - 1; mx = mx + 1; }
drawAxes(ctx, w, h, b, mn, mx);
var span = mx - mn;
ctx.beginPath();
for (var i = 0; i < values.length; i++) {
var x = values.length === 1 ? b.left + b.plotW / 2
: b.left + (i * b.plotW) / (values.length - 1);
var ratio = span === 0 ? 0.5 : (values[i] - mn) / span;
var y = b.top + b.plotH - ratio * b.plotH;
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.stroke();
}
function renderCharts() {
drawBars(document.getElementById('txChart'), state.txSeries, '#2563eb');
drawLine(document.getElementById('intervalChart'), state.intervalSeries, '#0f766e');
}
function renderStripCharts() {
drawSparkline(document.getElementById('supplyChart'), state.supplyHistory, '#7db5ff');
drawMicroBars(document.getElementById('tpsChart'), state.tpsSeries, '#41c98a');
}
/* ── renderStats ────────────────────────────────────────────────────────── */
function renderStats(net, blocks) {
// Supply strip
document.getElementById('sSupply').textContent = C.toTokenShort(net.total_supply);
// Height + last block time
document.getElementById('sHeight').textContent = Number(net.total_blocks || 0);
if (blocks.length) {
document.getElementById('sLastTime').textContent = C.timeAgo(blocks[0].time);
} else {
document.getElementById('sLastTime').textContent = '—';
}
if (!blocks.length) {
document.getElementById('sTps').textContent = '—';
document.getElementById('sBlockTime').textContent = '—';
document.getElementById('sLastBlock').textContent = '—';
document.getElementById('sLastHash').textContent = '—';
document.getElementById('sTxs').textContent = net.total_txs || 0;
document.getElementById('sTransfers').textContent = 'Transfers: ' + (net.total_transfers || 0);
document.getElementById('sRelayProofs').textContent = net.total_relay_proofs || 0;
document.getElementById('sRelayCount').textContent = (net.relay_count || 0) + ' relay nodes';
document.getElementById('sValidators').textContent = net.validator_count || 0;
document.getElementById('sTpsWindow').textContent = 'no data yet';
state.txSeries = []; state.intervalSeries = []; state.supplyHistory = []; state.tpsSeries = [];
renderCharts();
renderStripCharts();
return;
}
// Newest block
var newest = blocks[0];
document.getElementById('sLastBlock').textContent = '#' + newest.index;
document.getElementById('sLastHash').textContent = C.short(newest.hash, 24);
// TPS over window
var totalTx = 0;
for (var i = 0; i < blocks.length; i++) totalTx += Number(blocks[i].tx_count || 0);
var t0 = new Date(blocks[blocks.length - 1].time).getTime();
var t1 = new Date(blocks[0].time).getTime();
var secs = Math.max(1, (t1 - t0) / 1000);
var tps = (totalTx / secs).toFixed(2);
document.getElementById('sTps').textContent = tps;
document.getElementById('sTpsWindow').textContent = 'window: ~' + Math.round(secs) + ' sec';
// Avg block interval
var asc = blocks.slice().reverse();
var intervals = [];
for (var j = 1; j < asc.length; j++) {
var prev = new Date(asc[j - 1].time).getTime();
var cur = new Date(asc[j].time).getTime();
intervals.push(Math.max(0, (cur - prev) / 1000));
}
var avg = 0;
if (intervals.length) {
var sum = 0;
for (var k = 0; k < intervals.length; k++) sum += intervals[k];
avg = sum / intervals.length;
}
document.getElementById('sBlockTime').textContent = avg ? avg.toFixed(2) + 's' : '—';
// Card stats
document.getElementById('sTxs').textContent = net.total_txs || 0;
document.getElementById('sTransfers').textContent = 'Transfers: ' + (net.total_transfers || 0);
document.getElementById('sRelayProofs').textContent = net.total_relay_proofs || 0;
document.getElementById('sRelayCount').textContent = (net.relay_count || 0) + ' relay nodes';
document.getElementById('sValidators').textContent = net.validator_count || 0;
// Series for charts
state.txSeries = asc.map(function(b) { return Number(b.tx_count || 0); });
state.intervalSeries = intervals;
// Cumulative tx sparkline (supply proxy — shows chain activity growth)
var cum = 0;
state.supplyHistory = asc.map(function(b) { cum += Number(b.tx_count || 0); return cum; });
state.tpsSeries = state.txSeries;
renderCharts();
renderStripCharts();
}
/* ── renderRecentBlocks ─────────────────────────────────────────────────── */
function renderRecentBlocks(blocks) {
var tbody = document.getElementById('blocksBody');
if (!tbody) return;
if (!blocks || !blocks.length) {
tbody.innerHTML = '<tr><td colspan="6" class="tbl-empty">No blocks yet.</td></tr>';
return;
}
var rows = '';
var shown = blocks.slice(0, 20);
for (var i = 0; i < shown.length; i++) {
var b = shown[i];
var val = b.validator || b.proposer || '';
var valShort = val ? C.shortAddress(val) : '—';
var valCell = val
? '<a href="/address?address=' + encodeURIComponent(val) + '" title="' + C.esc(val) + '">' + C.esc(valShort) + '</a>'
: '—';
var fees = b.total_fees_ut != null ? C.toToken(Number(b.total_fees_ut)) : '—';
rows +=
'<tr>' +
'<td>' + linkBlock(b.index) + '</td>' +
'<td class="mono">' + C.esc(C.short(b.hash, 20)) + '</td>' +
'<td class="mono">' + valCell + '</td>' +
'<td>' + C.esc(b.tx_count || 0) + '</td>' +
'<td>' + C.esc(fees) + '</td>' +
'<td title="' + C.esc(C.fmtTime(b.time)) + '">' + C.esc(C.timeAgo(b.time)) + '</td>' +
'</tr>';
}
tbody.innerHTML = rows;
C.refreshIcons();
}
/* ── Block detail panel ─────────────────────────────────────────────────── */
function showBlockPanel(data) {
var panel = document.getElementById('blockDetailPanel');
var raw = document.getElementById('blockRaw');
if (!panel || !raw) return;
raw.textContent = JSON.stringify(data, null, 2);
panel.style.display = '';
panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
function hideBlockPanel() {
var panel = document.getElementById('blockDetailPanel');
if (panel) panel.style.display = 'none';
}
async function loadBlock(index) {
C.setStatus('Loading block #' + index + '…', 'warn');
try {
var b = await C.fetchJSON('/api/block/' + encodeURIComponent(index));
showBlockPanel(b);
C.setStatus('Block #' + index + ' loaded.', 'ok');
} catch (e) {
C.setStatus('Block load failed: ' + e.message, 'err');
}
}
/* ── Main refresh ───────────────────────────────────────────────────────── */
async function refreshDashboard() {
try {
var data = await Promise.all([
C.fetchJSON('/api/netstats'),
C.fetchJSON('/api/blocks?limit=36'),
]);
renderStats(data[0] || {}, data[1] || []);
renderRecentBlocks(data[1] || []);
C.setStatus('Updated at ' + new Date().toLocaleTimeString(), 'ok');
C.refreshIcons();
} catch (e) {
C.setStatus('Refresh failed: ' + e.message, 'err');
}
}
/* ── Search ─────────────────────────────────────────────────────────────── */
function handleSearch() {
var raw = (document.getElementById('searchInput').value || '').trim();
if (!raw) return;
// Pure integer → block lookup
if (/^\d+$/.test(raw)) {
loadBlock(raw);
return;
}
// Hex tx id (32 bytes = 64 chars) → tx page
if (/^[0-9a-fA-F]{64}$/.test(raw)) {
C.navAddress(raw);
return;
}
// Short hex (16 chars) or partial → try as tx id
if (/^[0-9a-fA-F]{16,63}$/.test(raw)) {
C.navTx(raw);
return;
}
// DC address or any remaining string → address page
C.navAddress(raw);
}
/* ── Event wiring ───────────────────────────────────────────────────────── */
var searchBtn = document.getElementById('searchBtn');
if (searchBtn) searchBtn.addEventListener('click', handleSearch);
var openNodeBtn = document.getElementById('openNodeBtn');
if (openNodeBtn) {
openNodeBtn.addEventListener('click', function() {
var raw = (document.getElementById('searchInput').value || '').trim();
if (raw && C.isPubKey(raw)) {
window.location.href = '/node?node=' + encodeURIComponent(raw);
} else {
window.location.href = '/validators';
}
});
}
var searchInput = document.getElementById('searchInput');
if (searchInput) {
searchInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') handleSearch();
});
}
// Block link clicks inside the blocks table
document.addEventListener('click', function(e) {
var t = e.target;
if (!t) return;
var link = t.closest ? t.closest('a.block-link') : null;
if (link) {
e.preventDefault();
loadBlock(link.dataset.index);
return;
}
});
// Redraw on resize (both full-size and strip charts)
window.addEventListener('resize', function() {
renderCharts();
renderStripCharts();
});
/* ── Boot ───────────────────────────────────────────────────────────────── */
// Polling fallback — active when SSE is unavailable or disconnected.
var pollTimer = null;
function startPolling() {
if (pollTimer) return;
pollTimer = setInterval(refreshDashboard, 10000);
}
function stopPolling() {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
}
var bootPromise = refreshDashboard();
// SSE live feed — stop polling while connected; resume on disconnect.
var sseConn = C.connectSSE({
connected: function() {
stopPolling();
C.setStatus('● LIVE', 'ok');
},
block: function(/*ev*/) {
// A new block was committed — refresh everything immediately.
refreshDashboard();
},
error: function() {
startPolling();
C.setStatus('Offline — polling every 10s', 'warn');
}
});
// If SSE is not supported by the browser, fall back to polling immediately.
if (!sseConn) startPolling();
// Handle /?block=N — navigated here from tx page block link
var blockParam = C.q('block');
if (blockParam) {
bootPromise.then(function() { loadBlock(blockParam); });
}
})();