miniledger 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dashboard/app.js CHANGED
@@ -1,6 +1,7 @@
1
- // MiniLedger Dashboard — vanilla JS, no build step
1
+ // MiniLedger Dashboard — vanilla JS SPA, no build step
2
2
  const API = window.location.origin;
3
3
  let refreshTimer = null;
4
+ let currentRoute = '';
4
5
 
5
6
  // ── Fetch helpers ─────────────────────────────────────────────────
6
7
 
@@ -9,9 +10,7 @@ async function api(path) {
9
10
  const res = await fetch(API + path);
10
11
  if (!res.ok) return null;
11
12
  return await res.json();
12
- } catch {
13
- return null;
14
- }
13
+ } catch { return null; }
15
14
  }
16
15
 
17
16
  async function apiPost(path, body) {
@@ -22,20 +21,18 @@ async function apiPost(path, body) {
22
21
  body: JSON.stringify(body),
23
22
  });
24
23
  return await res.json();
25
- } catch {
26
- return null;
27
- }
24
+ } catch { return null; }
28
25
  }
29
26
 
30
27
  // ── Formatters ────────────────────────────────────────────────────
31
28
 
32
29
  function shortHash(h) {
33
- if (!h) return '';
34
- return h.substring(0, 8) + '...' + h.substring(h.length - 6);
30
+ if (!h) return '\u2014';
31
+ return h.substring(0, 8) + '\u2026' + h.substring(h.length - 6);
35
32
  }
36
33
 
37
34
  function timeAgo(ts) {
38
- if (!ts) return '';
35
+ if (!ts) return '\u2014';
39
36
  const diff = Date.now() - ts;
40
37
  if (diff < 1000) return 'just now';
41
38
  if (diff < 60000) return Math.floor(diff / 1000) + 's ago';
@@ -44,219 +41,810 @@ function timeAgo(ts) {
44
41
  }
45
42
 
46
43
  function formatUptime(ms) {
47
- if (!ms) return '';
44
+ if (!ms) return '\u2014';
48
45
  const s = Math.floor(ms / 1000);
49
46
  if (s < 60) return s + 's';
50
47
  if (s < 3600) return Math.floor(s / 60) + 'm ' + (s % 60) + 's';
51
48
  return Math.floor(s / 3600) + 'h ' + Math.floor((s % 3600) / 60) + 'm';
52
49
  }
53
50
 
54
- // ── Update functions ──────────────────────────────────────────────
51
+ function formatTime(ts) {
52
+ if (!ts) return '\u2014';
53
+ return new Date(ts).toLocaleString();
54
+ }
55
55
 
56
- async function updateStatus() {
57
- const data = await api('/status');
58
- if (!data) {
59
- document.getElementById('status-dot').className = 'dot red';
60
- document.getElementById('status-text').textContent = 'Disconnected';
61
- return;
62
- }
56
+ function txTypeBadge(type) {
57
+ const colors = {
58
+ 'state:set': 'badge-green',
59
+ 'state:delete': 'badge-red',
60
+ 'contract:deploy': 'badge-purple',
61
+ 'contract:invoke': 'badge-cyan',
62
+ 'governance:propose': 'badge-orange',
63
+ 'governance:vote': 'badge-yellow',
64
+ };
65
+ return `<span class="badge ${colors[type] || ''}">${type}</span>`;
66
+ }
63
67
 
64
- document.getElementById('status-dot').className = 'dot green';
65
- document.getElementById('status-text').textContent = 'Running';
66
- document.getElementById('node-id').textContent = data.nodeId;
67
- document.getElementById('uptime').textContent = formatUptime(data.uptime);
68
- document.getElementById('stat-height').textContent = data.chainHeight;
69
- document.getElementById('stat-txpool').textContent = data.txPoolSize;
70
- document.getElementById('stat-peers').textContent = data.peerCount;
68
+ function escapeHtml(str) {
69
+ const div = document.createElement('div');
70
+ div.textContent = str;
71
+ return div.innerHTML;
71
72
  }
72
73
 
73
- async function updateBlocks() {
74
- const data = await api('/blocks');
75
- if (!data) return;
74
+ function jsonPretty(obj) {
75
+ return escapeHtml(JSON.stringify(obj, null, 2));
76
+ }
76
77
 
77
- const el = document.getElementById('blocks-list');
78
- document.getElementById('block-count').textContent = data.height + 1;
78
+ // ── Router ────────────────────────────────────────────────────────
79
79
 
80
- if (!data.blocks || data.blocks.length === 0) {
81
- el.innerHTML = '<div class="empty-state">No blocks yet</div>';
82
- return;
80
+ function navigate(hash) {
81
+ window.location.hash = hash;
82
+ }
83
+
84
+ function getRoute() {
85
+ const h = window.location.hash.slice(1) || '/';
86
+ return h;
87
+ }
88
+
89
+ function matchRoute(route) {
90
+ const patterns = [
91
+ { pattern: /^\/$/, page: 'overview' },
92
+ { pattern: /^\/blocks$/, page: 'blocks' },
93
+ { pattern: /^\/block\/(\d+)$/, page: 'blockDetail', params: m => ({ height: parseInt(m[1]) }) },
94
+ { pattern: /^\/transactions$/, page: 'transactions' },
95
+ { pattern: /^\/tx\/([a-fA-F0-9]+)$/, page: 'txDetail', params: m => ({ hash: m[1] }) },
96
+ { pattern: /^\/address\/([a-fA-F0-9]+)$/, page: 'address', params: m => ({ pubkey: m[1] }) },
97
+ { pattern: /^\/state$/, page: 'state' },
98
+ { pattern: /^\/state\/(.+)$/, page: 'stateDetail', params: m => ({ key: decodeURIComponent(m[1]) }) },
99
+ { pattern: /^\/contracts$/, page: 'contracts' },
100
+ { pattern: /^\/contract\/(.+)$/, page: 'contractDetail', params: m => ({ name: decodeURIComponent(m[1]) }) },
101
+ { pattern: /^\/governance$/, page: 'governance' },
102
+ { pattern: /^\/proposal\/(.+)$/, page: 'proposalDetail', params: m => ({ id: m[1] }) },
103
+ { pattern: /^\/network$/, page: 'network' },
104
+ ];
105
+
106
+ for (const p of patterns) {
107
+ const m = route.match(p.pattern);
108
+ if (m) return { page: p.page, params: p.params ? p.params(m) : {} };
83
109
  }
110
+ return { page: 'overview', params: {} };
111
+ }
84
112
 
85
- // Show newest first
86
- const blocks = [...data.blocks].reverse();
87
- el.innerHTML = blocks.map(b => `
88
- <div class="block-item">
89
- <span class="block-num">#${b.height}</span>
90
- <span class="block-hash">${shortHash(b.hash)}</span>
91
- <span class="block-meta">${b.transactions.length} tx · ${timeAgo(b.timestamp)}</span>
92
- </div>
93
- `).join('');
113
+ async function router() {
114
+ const route = getRoute();
115
+ if (route === currentRoute) return;
116
+ currentRoute = route;
117
+
118
+ // Update nav active state
119
+ document.querySelectorAll('.nav-link').forEach(el => {
120
+ const r = el.getAttribute('data-route');
121
+ if (r === route || (r && r !== '/' && route.startsWith(r))) {
122
+ el.classList.add('active');
123
+ } else if (r === '/' && route === '/') {
124
+ el.classList.add('active');
125
+ } else {
126
+ el.classList.remove('active');
127
+ }
128
+ });
129
+
130
+ const { page, params } = matchRoute(route);
131
+ const el = document.getElementById('app');
132
+
133
+ switch (page) {
134
+ case 'overview': await renderOverview(el); break;
135
+ case 'blocks': await renderBlocks(el); break;
136
+ case 'blockDetail': await renderBlockDetail(el, params.height); break;
137
+ case 'transactions': await renderTransactions(el); break;
138
+ case 'txDetail': await renderTxDetail(el, params.hash); break;
139
+ case 'address': await renderAddress(el, params.pubkey); break;
140
+ case 'state': await renderState(el); break;
141
+ case 'stateDetail': await renderStateDetail(el, params.key); break;
142
+ case 'contracts': await renderContracts(el); break;
143
+ case 'contractDetail': await renderContractDetail(el, params.name); break;
144
+ case 'governance': await renderGovernance(el); break;
145
+ case 'proposalDetail': await renderProposalDetail(el, params.id); break;
146
+ case 'network': await renderNetwork(el); break;
147
+ default: await renderOverview(el);
148
+ }
94
149
  }
95
150
 
96
- async function updateTransactions() {
97
- // Get transactions from recent blocks
98
- const data = await api('/blocks');
99
- if (!data || !data.blocks) return;
151
+ // ── Page: Overview ────────────────────────────────────────────────
100
152
 
101
- const el = document.getElementById('tx-list');
102
- const allTx = [];
153
+ async function renderOverview(el) {
154
+ el.innerHTML = '<div class="empty-state">Loading...</div>';
103
155
 
104
- for (const block of data.blocks) {
105
- for (const tx of block.transactions) {
106
- allTx.push({ ...tx, blockHeight: block.height });
156
+ const [status, blocksData, peers, contracts, proposals, consensus] = await Promise.all([
157
+ api('/status'),
158
+ api('/blocks?limit=20'),
159
+ api('/peers'),
160
+ api('/contracts'),
161
+ api('/proposals'),
162
+ api('/consensus'),
163
+ ]);
164
+
165
+ const height = status?.chainHeight ?? 0;
166
+ const stateCount = await apiPost('/state/query', { sql: "SELECT COUNT(*) as c FROM world_state WHERE key NOT LIKE '\\_%' ESCAPE '\\'" });
167
+ const stateKeys = stateCount?.results?.[0]?.c ?? 0;
168
+ const contractCount = contracts?.count ?? 0;
169
+ const proposalCount = proposals?.count ?? 0;
170
+
171
+ // Compute blocks-per-minute chart data from block timestamps
172
+ const blocks = blocksData?.blocks ?? [];
173
+ const chartBars = [];
174
+ if (blocks.length > 1) {
175
+ const sorted = [...blocks].sort((a, b) => a.height - b.height);
176
+ for (let i = 1; i < sorted.length; i++) {
177
+ const dt = sorted[i].timestamp - sorted[i - 1].timestamp;
178
+ chartBars.push(dt > 0 ? 60000 / dt : 0); // blocks per minute
107
179
  }
108
180
  }
181
+ const maxBpm = Math.max(...chartBars, 1);
109
182
 
183
+ // Recent 5 blocks, 5 txs
184
+ const recentBlocks = blocks.slice(0, 5);
185
+ const allTx = [];
186
+ for (const b of blocks) {
187
+ for (const tx of b.transactions) {
188
+ allTx.push({ ...tx, blockHeight: b.height });
189
+ }
190
+ }
110
191
  allTx.sort((a, b) => b.timestamp - a.timestamp);
111
- const recent = allTx.slice(0, 20);
192
+ const recentTx = allTx.slice(0, 5);
193
+
194
+ let consensusHtml = '';
195
+ if (consensus?.state) {
196
+ const s = consensus.state;
197
+ consensusHtml = `
198
+ <div class="detail-grid" style="margin-top:0">
199
+ <div class="detail-row"><div class="detail-label">Role</div><div class="detail-value text"><span class="badge ${s.role === 'leader' ? 'badge-green' : ''}">${s.role}</span></div></div>
200
+ <div class="detail-row"><div class="detail-label">Term</div><div class="detail-value">${s.term ?? '\u2014'}</div></div>
201
+ <div class="detail-row"><div class="detail-label">Leader</div><div class="detail-value">${s.leaderId || 'none'}</div></div>
202
+ </div>`;
203
+ }
112
204
 
113
- document.getElementById('tx-count').textContent = allTx.length;
205
+ el.innerHTML = `
206
+ <div class="stats">
207
+ <div class="stat"><div class="stat-value">${height}</div><div class="stat-label">Block Height</div></div>
208
+ <div class="stat"><div class="stat-value">${status?.txPoolSize ?? 0}</div><div class="stat-label">TX Pool</div></div>
209
+ <div class="stat"><div class="stat-value">${status?.peerCount ?? 0}</div><div class="stat-label">Peers</div></div>
210
+ <div class="stat"><div class="stat-value">${stateKeys}</div><div class="stat-label">State Keys</div></div>
211
+ <div class="stat"><div class="stat-value">${contractCount}</div><div class="stat-label">Contracts</div></div>
212
+ <div class="stat"><div class="stat-value">${proposalCount}</div><div class="stat-label">Proposals</div></div>
213
+ </div>
114
214
 
115
- if (recent.length === 0) {
116
- el.innerHTML = '<div class="empty-state">No transactions yet</div>';
117
- return;
118
- }
215
+ ${chartBars.length > 0 ? `
216
+ <div class="mini-chart">
217
+ <div class="mini-chart-title">Block Rate (blocks/min, last ${chartBars.length + 1} blocks)</div>
218
+ <div class="chart-bars">
219
+ ${chartBars.map(v => `<div class="chart-bar" style="height:${Math.max(4, (v / maxBpm) * 100)}%" title="${v.toFixed(1)} bpm"></div>`).join('')}
220
+ </div>
221
+ </div>` : ''}
222
+
223
+ <div class="grid">
224
+ <div class="card">
225
+ <div class="card-header">Recent Blocks <a class="link" href="#/blocks">View all &rarr;</a></div>
226
+ <div class="card-body">
227
+ ${recentBlocks.length === 0 ? '<div class="empty-state">No blocks yet</div>' :
228
+ recentBlocks.map(b => `
229
+ <div class="list-item">
230
+ <a class="num link" href="#/block/${b.height}">#${b.height}</a>
231
+ <span class="hash">${shortHash(b.hash)}</span>
232
+ <span class="meta">${b.transactions.length} tx &middot; ${timeAgo(b.timestamp)}</span>
233
+ </div>`).join('')}
234
+ </div>
235
+ </div>
119
236
 
120
- el.innerHTML = recent.map(tx => `
121
- <div class="tx-item">
122
- <span class="tx-hash">${shortHash(tx.hash)}</span>
123
- <span class="tx-type">${tx.type}</span>
124
- <div style="font-size:12px;color:var(--text-dim);margin-top:2px">
125
- Block #${tx.blockHeight} · ${timeAgo(tx.timestamp)}
237
+ <div class="card">
238
+ <div class="card-header">Recent Transactions <a class="link" href="#/transactions">View all &rarr;</a></div>
239
+ <div class="card-body">
240
+ ${recentTx.length === 0 ? '<div class="empty-state">No transactions yet</div>' :
241
+ recentTx.map(tx => `
242
+ <div class="list-item">
243
+ <a class="link mono" href="#/tx/${tx.hash}" style="font-size:12px">${shortHash(tx.hash)}</a>
244
+ ${txTypeBadge(tx.type)}
245
+ <span class="meta">Block #${tx.blockHeight} &middot; ${timeAgo(tx.timestamp)}</span>
246
+ </div>`).join('')}
247
+ </div>
126
248
  </div>
127
249
  </div>
128
- `).join('');
250
+
251
+ ${consensusHtml ? `
252
+ <div style="margin-top:20px">
253
+ <div class="page-title" style="font-size:16px">Consensus Status</div>
254
+ ${consensusHtml}
255
+ </div>` : ''}
256
+ `;
129
257
  }
130
258
 
131
- async function updatePeers() {
132
- const data = await api('/peers');
133
- if (!data) return;
259
+ // ── Page: Block List ──────────────────────────────────────────────
134
260
 
135
- const el = document.getElementById('peers-list');
136
- document.getElementById('peer-badge').textContent = data.count;
261
+ async function renderBlocks(el, page) {
262
+ const p = page || parseInt(new URLSearchParams(window.location.hash.split('?')[1]).get('page')) || 1;
263
+ el.innerHTML = '<div class="empty-state">Loading blocks...</div>';
137
264
 
138
- if (!data.peers || data.peers.length === 0) {
139
- el.innerHTML = '<div class="empty-state">No peers connected (solo mode)</div>';
140
- return;
141
- }
265
+ const data = await api(`/blocks?page=${p}&limit=20`);
266
+ if (!data) { el.innerHTML = '<div class="empty-state">Failed to load blocks</div>'; return; }
142
267
 
143
- el.innerHTML = data.peers.map(p => `
144
- <div class="peer-item">
145
- <span class="dot ${p.status === 'connected' ? 'green' : 'red'}"></span>
146
- <span class="peer-id">${p.nodeId}</span>
147
- <span style="color:var(--text-dim);font-size:12px">${p.address || '—'}</span>
148
- <span style="margin-left:auto;font-size:12px;color:var(--text-dim)">H:${p.chainHeight}</span>
268
+ el.innerHTML = `
269
+ <div class="page-title">Blocks</div>
270
+ <table class="data-table">
271
+ <thead><tr>
272
+ <th>Height</th><th>Hash</th><th>Proposer</th><th>Txs</th><th>Time</th>
273
+ </tr></thead>
274
+ <tbody>
275
+ ${data.blocks.map(b => `
276
+ <tr class="clickable" onclick="navigate('#/block/${b.height}')">
277
+ <td><a class="link" href="#/block/${b.height}">#${b.height}</a></td>
278
+ <td class="mono dim">${shortHash(b.hash)}</td>
279
+ <td class="mono dim"><a class="link" href="#/address/${b.proposer}">${shortHash(b.proposer)}</a></td>
280
+ <td>${b.transactions.length}</td>
281
+ <td class="dim">${timeAgo(b.timestamp)}</td>
282
+ </tr>`).join('')}
283
+ </tbody>
284
+ </table>
285
+ <div class="pagination">
286
+ <button onclick="renderBlocks(document.getElementById('app'), ${p - 1})" ${p <= 1 ? 'disabled' : ''}>&larr; Newer</button>
287
+ <span class="page-info">Page ${p} of ${data.totalPages}</span>
288
+ <button onclick="renderBlocks(document.getElementById('app'), ${p + 1})" ${p >= data.totalPages ? 'disabled' : ''}>Older &rarr;</button>
149
289
  </div>
150
- `).join('');
290
+ `;
151
291
  }
152
292
 
153
- async function updateContractsGov() {
154
- const [contracts, proposals] = await Promise.all([
155
- api('/contracts'),
156
- api('/proposals'),
293
+ // ── Page: Block Detail ────────────────────────────────────────────
294
+
295
+ async function renderBlockDetail(el, height) {
296
+ el.innerHTML = '<div class="empty-state">Loading block...</div>';
297
+
298
+ const block = await api(`/blocks/${height}`);
299
+ if (!block) { el.innerHTML = '<div class="empty-state">Block not found</div>'; return; }
300
+
301
+ const maxHeight = (await api('/status'))?.chainHeight ?? height;
302
+
303
+ el.innerHTML = `
304
+ <div class="block-nav">
305
+ <button onclick="navigate('#/block/${height - 1}')" ${height <= 0 ? 'disabled' : ''}>&larr; Block ${height - 1}</button>
306
+ <span class="block-nav-info">Block #${height}</span>
307
+ <button onclick="navigate('#/block/${height + 1}')" ${height >= maxHeight ? 'disabled' : ''}>Block ${height + 1} &rarr;</button>
308
+ </div>
309
+
310
+ <div class="detail-grid">
311
+ <div class="detail-row"><div class="detail-label">Height</div><div class="detail-value">${block.height}</div></div>
312
+ <div class="detail-row"><div class="detail-label">Hash</div><div class="detail-value">${block.hash}</div></div>
313
+ <div class="detail-row"><div class="detail-label">Previous Hash</div><div class="detail-value">${block.previousHash}</div></div>
314
+ <div class="detail-row"><div class="detail-label">Merkle Root</div><div class="detail-value">${block.merkleRoot}</div></div>
315
+ <div class="detail-row"><div class="detail-label">State Root</div><div class="detail-value">${block.stateRoot}</div></div>
316
+ <div class="detail-row"><div class="detail-label">Proposer</div><div class="detail-value"><a class="link" href="#/address/${block.proposer}">${block.proposer}</a></div></div>
317
+ <div class="detail-row"><div class="detail-label">Signature</div><div class="detail-value">${block.signature}</div></div>
318
+ <div class="detail-row"><div class="detail-label">Timestamp</div><div class="detail-value text">${formatTime(block.timestamp)}</div></div>
319
+ <div class="detail-row"><div class="detail-label">Transactions</div><div class="detail-value text">${block.transactions.length}</div></div>
320
+ </div>
321
+
322
+ ${block.transactions.length > 0 ? `
323
+ <div class="page-title" style="font-size:16px;margin-top:8px">Transactions in Block</div>
324
+ <table class="data-table">
325
+ <thead><tr><th>Hash</th><th>Type</th><th>Sender</th><th>Time</th></tr></thead>
326
+ <tbody>
327
+ ${block.transactions.map(tx => `
328
+ <tr class="clickable" onclick="navigate('#/tx/${tx.hash}')">
329
+ <td class="mono"><a class="link" href="#/tx/${tx.hash}">${shortHash(tx.hash)}</a></td>
330
+ <td>${txTypeBadge(tx.type)}</td>
331
+ <td class="mono dim"><a class="link" href="#/address/${tx.sender}">${shortHash(tx.sender)}</a></td>
332
+ <td class="dim">${timeAgo(tx.timestamp)}</td>
333
+ </tr>`).join('')}
334
+ </tbody>
335
+ </table>` : '<div class="empty-state">No transactions in this block</div>'}
336
+ `;
337
+ }
338
+
339
+ // ── Page: Transaction List ────────────────────────────────────────
340
+
341
+ let txFilter = '';
342
+
343
+ async function renderTransactions(el, page) {
344
+ const p = page || 1;
345
+ el.innerHTML = '<div class="empty-state">Loading transactions...</div>';
346
+
347
+ const typeParam = txFilter ? `&type=${encodeURIComponent(txFilter)}` : '';
348
+ const data = await api(`/tx/recent?page=${p}&limit=20${typeParam}`);
349
+ if (!data) { el.innerHTML = '<div class="empty-state">Failed to load transactions</div>'; return; }
350
+
351
+ el.innerHTML = `
352
+ <div class="page-title">Transactions</div>
353
+ <div class="filter-bar">
354
+ <label>Filter by type:</label>
355
+ <select id="tx-type-filter" onchange="txFilter=this.value;renderTransactions(document.getElementById('app'),1)">
356
+ <option value="">All types</option>
357
+ <option value="state:set" ${txFilter === 'state:set' ? 'selected' : ''}>state:set</option>
358
+ <option value="state:delete" ${txFilter === 'state:delete' ? 'selected' : ''}>state:delete</option>
359
+ <option value="contract:deploy" ${txFilter === 'contract:deploy' ? 'selected' : ''}>contract:deploy</option>
360
+ <option value="contract:invoke" ${txFilter === 'contract:invoke' ? 'selected' : ''}>contract:invoke</option>
361
+ <option value="governance:propose" ${txFilter === 'governance:propose' ? 'selected' : ''}>governance:propose</option>
362
+ <option value="governance:vote" ${txFilter === 'governance:vote' ? 'selected' : ''}>governance:vote</option>
363
+ </select>
364
+ <span class="dim" style="font-size:12px">${data.total} total</span>
365
+ </div>
366
+ <table class="data-table">
367
+ <thead><tr><th>Hash</th><th>Type</th><th>Sender</th><th>Block</th><th>Time</th></tr></thead>
368
+ <tbody>
369
+ ${data.transactions.map(tx => `
370
+ <tr class="clickable" onclick="navigate('#/tx/${tx.hash}')">
371
+ <td class="mono"><a class="link" href="#/tx/${tx.hash}">${shortHash(tx.hash)}</a></td>
372
+ <td>${txTypeBadge(tx.type)}</td>
373
+ <td class="mono dim"><a class="link" href="#/address/${tx.sender}">${shortHash(tx.sender)}</a></td>
374
+ <td><a class="link" href="#/block/${tx.blockHeight}">#${tx.blockHeight}</a></td>
375
+ <td class="dim">${timeAgo(tx.timestamp)}</td>
376
+ </tr>`).join('')}
377
+ </tbody>
378
+ </table>
379
+ ${data.totalPages > 1 ? `
380
+ <div class="pagination">
381
+ <button onclick="renderTransactions(document.getElementById('app'), ${p - 1})" ${p <= 1 ? 'disabled' : ''}>&larr; Prev</button>
382
+ <span class="page-info">Page ${p} of ${data.totalPages}</span>
383
+ <button onclick="renderTransactions(document.getElementById('app'), ${p + 1})" ${p >= data.totalPages ? 'disabled' : ''}>Next &rarr;</button>
384
+ </div>` : ''}
385
+ `;
386
+ }
387
+
388
+ // ── Page: Transaction Detail ──────────────────────────────────────
389
+
390
+ async function renderTxDetail(el, hash) {
391
+ el.innerHTML = '<div class="empty-state">Loading transaction...</div>';
392
+
393
+ const tx = await api(`/tx/${hash}`);
394
+ if (!tx) { el.innerHTML = '<div class="empty-state">Transaction not found</div>'; return; }
395
+
396
+ // Find the block containing this tx
397
+ let blockHeight = null;
398
+ const search = await apiPost('/state/query', { sql: `SELECT block_height FROM transactions WHERE hash = '${hash}'` });
399
+ if (search?.results?.[0]) blockHeight = search.results[0].block_height;
400
+
401
+ el.innerHTML = `
402
+ <div class="page-title">Transaction Detail</div>
403
+ <div class="detail-grid">
404
+ <div class="detail-row"><div class="detail-label">Hash</div><div class="detail-value">${tx.hash}</div></div>
405
+ <div class="detail-row"><div class="detail-label">Type</div><div class="detail-value text">${txTypeBadge(tx.type)}</div></div>
406
+ <div class="detail-row"><div class="detail-label">Sender</div><div class="detail-value"><a class="link" href="#/address/${tx.sender}">${tx.sender}</a></div></div>
407
+ <div class="detail-row"><div class="detail-label">Nonce</div><div class="detail-value">${tx.nonce}</div></div>
408
+ <div class="detail-row"><div class="detail-label">Timestamp</div><div class="detail-value text">${formatTime(tx.timestamp)}</div></div>
409
+ <div class="detail-row"><div class="detail-label">Signature</div><div class="detail-value">${tx.signature}</div></div>
410
+ ${blockHeight !== null ? `<div class="detail-row"><div class="detail-label">Block</div><div class="detail-value text"><a class="link" href="#/block/${blockHeight}">Block #${blockHeight}</a></div></div>` : ''}
411
+ </div>
412
+
413
+ <div class="page-title" style="font-size:16px;margin-top:8px">Payload</div>
414
+ <div class="json-viewer">${jsonPretty(tx.payload)}</div>
415
+ `;
416
+ }
417
+
418
+ // ── Page: Address ─────────────────────────────────────────────────
419
+
420
+ async function renderAddress(el, pubkey) {
421
+ el.innerHTML = '<div class="empty-state">Loading address...</div>';
422
+
423
+ const [txData, stateData] = await Promise.all([
424
+ api(`/tx/sender/${pubkey}`),
425
+ apiPost('/state/query', { sql: `SELECT key, value, version, updated_at, block_height FROM world_state WHERE updated_by = ? AND key NOT LIKE '\\_%' ESCAPE '\\'`, params: [pubkey] }),
157
426
  ]);
158
427
 
159
- const el = document.getElementById('contracts-gov');
160
- let html = '';
428
+ const txs = txData?.transactions ?? [];
429
+ const stateEntries = stateData?.results ?? [];
161
430
 
162
- if (contracts && contracts.contracts && contracts.contracts.length > 0) {
163
- html += '<div style="margin-bottom:12px;font-size:12px;color:var(--text-dim);font-weight:600">CONTRACTS</div>';
164
- html += contracts.contracts.map(c => `
165
- <div class="tx-item">
166
- <strong>${c.name}</strong> <span class="tx-type">v${c.version}</span>
167
- <div style="font-size:12px;color:var(--text-dim);margin-top:2px">by ${shortHash(c.deployedBy)}</div>
168
- </div>
169
- `).join('');
170
- }
431
+ el.innerHTML = `
432
+ <div class="page-title">Address</div>
433
+ <div class="detail-grid">
434
+ <div class="detail-row"><div class="detail-label">Public Key</div><div class="detail-value">${pubkey}</div></div>
435
+ <div class="detail-row"><div class="detail-label">Node ID</div><div class="detail-value">${shortHash(pubkey)}</div></div>
436
+ <div class="detail-row"><div class="detail-label">Transactions</div><div class="detail-value text">${txs.length}</div></div>
437
+ <div class="detail-row"><div class="detail-label">State Entries</div><div class="detail-value text">${stateEntries.length}</div></div>
438
+ </div>
171
439
 
172
- if (proposals && proposals.proposals && proposals.proposals.length > 0) {
173
- html += '<div style="margin-top:12px;margin-bottom:12px;font-size:12px;color:var(--text-dim);font-weight:600">PROPOSALS</div>';
174
- html += proposals.proposals.map(p => `
175
- <div class="tx-item">
176
- <strong>${p.title}</strong>
177
- <span class="tx-type">${p.status}</span>
178
- <div style="font-size:12px;color:var(--text-dim);margin-top:2px">
179
- ${Object.keys(p.votes).length} votes · by ${shortHash(p.proposer)}
180
- </div>
440
+ <div class="page-title" style="font-size:16px;margin-top:8px">Transaction History</div>
441
+ ${txs.length === 0 ? '<div class="empty-state">No transactions from this address</div>' : `
442
+ <table class="data-table">
443
+ <thead><tr><th>Hash</th><th>Type</th><th>Nonce</th><th>Time</th></tr></thead>
444
+ <tbody>
445
+ ${txs.map(tx => `
446
+ <tr class="clickable" onclick="navigate('#/tx/${tx.hash}')">
447
+ <td class="mono"><a class="link" href="#/tx/${tx.hash}">${shortHash(tx.hash)}</a></td>
448
+ <td>${txTypeBadge(tx.type)}</td>
449
+ <td>${tx.nonce}</td>
450
+ <td class="dim">${timeAgo(tx.timestamp)}</td>
451
+ </tr>`).join('')}
452
+ </tbody>
453
+ </table>`}
454
+
455
+ ${stateEntries.length > 0 ? `
456
+ <div class="page-title" style="font-size:16px;margin-top:20px">State Entries Owned</div>
457
+ <table class="data-table">
458
+ <thead><tr><th>Key</th><th>Value</th><th>Version</th><th>Block</th></tr></thead>
459
+ <tbody>
460
+ ${stateEntries.map(e => `
461
+ <tr class="clickable" onclick="navigate('#/state/${encodeURIComponent(e.key)}')">
462
+ <td class="mono"><a class="link" href="#/state/${encodeURIComponent(e.key)}">${escapeHtml(e.key)}</a></td>
463
+ <td class="dim" style="max-width:250px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escapeHtml(String(e.value).substring(0, 80))}</td>
464
+ <td>${e.version}</td>
465
+ <td><a class="link" href="#/block/${e.block_height}">#${e.block_height}</a></td>
466
+ </tr>`).join('')}
467
+ </tbody>
468
+ </table>` : ''}
469
+ `;
470
+ }
471
+
472
+ // ── Page: State Explorer ──────────────────────────────────────────
473
+
474
+ let stateTab = 'browse';
475
+
476
+ async function renderState(el, page) {
477
+ const p = page || 1;
478
+
479
+ el.innerHTML = `
480
+ <div class="page-title">State Explorer</div>
481
+ <div class="tabs">
482
+ <div class="tab ${stateTab === 'browse' ? 'active' : ''}" onclick="stateTab='browse';renderState(document.getElementById('app'))">Browse</div>
483
+ <div class="tab ${stateTab === 'sql' ? 'active' : ''}" onclick="stateTab='sql';renderState(document.getElementById('app'))">SQL Console</div>
484
+ </div>
485
+ <div id="state-content"></div>
486
+ `;
487
+
488
+ const content = document.getElementById('state-content');
489
+
490
+ if (stateTab === 'sql') {
491
+ content.innerHTML = `
492
+ <div class="query-area">
493
+ <input type="text" id="sql-input" placeholder="SELECT * FROM world_state LIMIT 20" value="SELECT * FROM world_state ORDER BY updated_at DESC LIMIT 20">
494
+ <button onclick="runSqlQuery()">Run Query</button>
181
495
  </div>
182
- `).join('');
496
+ <div id="query-results"><div class="empty-state">Run a query to see results</div></div>
497
+ `;
498
+ document.getElementById('sql-input').addEventListener('keydown', (e) => {
499
+ if (e.key === 'Enter') runSqlQuery();
500
+ });
501
+ return;
183
502
  }
184
503
 
185
- if (!html) {
186
- html = '<div class="empty-state">No contracts or proposals</div>';
187
- }
504
+ content.innerHTML = '<div class="empty-state">Loading state...</div>';
505
+ const data = await api(`/state?page=${p}&limit=20`);
506
+ if (!data) { content.innerHTML = '<div class="empty-state">Failed to load state</div>'; return; }
507
+
508
+ content.innerHTML = `
509
+ <table class="data-table">
510
+ <thead><tr><th>Key</th><th>Value</th><th>Version</th><th>Updated By</th><th>Block</th></tr></thead>
511
+ <tbody>
512
+ ${data.entries.map(e => `
513
+ <tr class="clickable" onclick="navigate('#/state/${encodeURIComponent(e.key)}')">
514
+ <td class="mono"><a class="link" href="#/state/${encodeURIComponent(e.key)}">${escapeHtml(e.key)}</a></td>
515
+ <td class="dim" style="max-width:250px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escapeHtml(JSON.stringify(e.value).substring(0, 80))}</td>
516
+ <td>${e.version}</td>
517
+ <td class="mono dim"><a class="link" href="#/address/${e.updatedBy}">${shortHash(e.updatedBy)}</a></td>
518
+ <td><a class="link" href="#/block/${e.blockHeight}">#${e.blockHeight}</a></td>
519
+ </tr>`).join('')}
520
+ </tbody>
521
+ </table>
522
+ ${data.totalPages > 1 ? `
523
+ <div class="pagination">
524
+ <button onclick="renderState(document.getElementById('app'), ${p - 1})" ${p <= 1 ? 'disabled' : ''}>&larr; Prev</button>
525
+ <span class="page-info">Page ${p} of ${data.totalPages}</span>
526
+ <button onclick="renderState(document.getElementById('app'), ${p + 1})" ${p >= data.totalPages ? 'disabled' : ''}>Next &rarr;</button>
527
+ </div>` : ''}
528
+ `;
529
+ }
188
530
 
189
- el.innerHTML = html;
531
+ // ── Page: State Detail ────────────────────────────────────────────
532
+
533
+ async function renderStateDetail(el, key) {
534
+ el.innerHTML = '<div class="empty-state">Loading...</div>';
535
+
536
+ const entry = await api(`/state/${encodeURIComponent(key)}`);
537
+ if (!entry) { el.innerHTML = '<div class="empty-state">State key not found</div>'; return; }
538
+
539
+ el.innerHTML = `
540
+ <div class="page-title">State Entry</div>
541
+ <div class="detail-grid">
542
+ <div class="detail-row"><div class="detail-label">Key</div><div class="detail-value">${escapeHtml(entry.key)}</div></div>
543
+ <div class="detail-row"><div class="detail-label">Version</div><div class="detail-value">${entry.version}</div></div>
544
+ <div class="detail-row"><div class="detail-label">Updated By</div><div class="detail-value"><a class="link" href="#/address/${entry.updatedBy}">${entry.updatedBy}</a></div></div>
545
+ <div class="detail-row"><div class="detail-label">Updated At</div><div class="detail-value text">${formatTime(entry.updatedAt)}</div></div>
546
+ <div class="detail-row"><div class="detail-label">Block Height</div><div class="detail-value text"><a class="link" href="#/block/${entry.blockHeight}">Block #${entry.blockHeight}</a></div></div>
547
+ </div>
548
+ <div class="page-title" style="font-size:16px;margin-top:8px">Value</div>
549
+ <div class="json-viewer">${jsonPretty(entry.value)}</div>
550
+ `;
190
551
  }
191
552
 
192
- async function updateStateCount() {
193
- const data = await apiPost('/state/query', {
194
- sql: 'SELECT COUNT(*) as count FROM world_state WHERE key NOT LIKE \'_%\'',
195
- });
196
- if (data && data.results && data.results.length > 0) {
197
- document.getElementById('stat-state').textContent = data.results[0].count || 0;
198
- }
553
+ // ── Page: Contracts ───────────────────────────────────────────────
554
+
555
+ async function renderContracts(el) {
556
+ el.innerHTML = '<div class="empty-state">Loading contracts...</div>';
557
+
558
+ const data = await api('/contracts');
559
+ if (!data) { el.innerHTML = '<div class="empty-state">Failed to load contracts</div>'; return; }
560
+
561
+ const contracts = data.contracts || [];
562
+
563
+ el.innerHTML = `
564
+ <div class="page-title">Contracts</div>
565
+ ${contracts.length === 0 ? '<div class="empty-state">No contracts deployed</div>' : `
566
+ <table class="data-table">
567
+ <thead><tr><th>Name</th><th>Version</th><th>Deployed By</th><th>Deployed At</th></tr></thead>
568
+ <tbody>
569
+ ${contracts.map(c => `
570
+ <tr class="clickable" onclick="navigate('#/contract/${encodeURIComponent(c.name)}')">
571
+ <td><a class="link" href="#/contract/${encodeURIComponent(c.name)}">${escapeHtml(c.name)}</a></td>
572
+ <td><span class="badge">${escapeHtml(c.version)}</span></td>
573
+ <td class="mono dim"><a class="link" href="#/address/${c.deployedBy}">${shortHash(c.deployedBy)}</a></td>
574
+ <td class="dim">${formatTime(c.deployedAt)}</td>
575
+ </tr>`).join('')}
576
+ </tbody>
577
+ </table>`}
578
+ `;
579
+ }
580
+
581
+ // ── Page: Contract Detail ─────────────────────────────────────────
582
+
583
+ async function renderContractDetail(el, name) {
584
+ el.innerHTML = '<div class="empty-state">Loading contract...</div>';
585
+
586
+ const entry = await api(`/state/_contract:${encodeURIComponent(name)}`);
587
+ const codeEntry = await api(`/state/_contract_code:${encodeURIComponent(name)}`);
588
+
589
+ if (!entry) { el.innerHTML = '<div class="empty-state">Contract not found</div>'; return; }
590
+
591
+ const meta = entry.value;
592
+
593
+ el.innerHTML = `
594
+ <div class="page-title">Contract: ${escapeHtml(name)}</div>
595
+ <div class="detail-grid">
596
+ <div class="detail-row"><div class="detail-label">Name</div><div class="detail-value text">${escapeHtml(meta.name)}</div></div>
597
+ <div class="detail-row"><div class="detail-label">Version</div><div class="detail-value text">${escapeHtml(meta.version)}</div></div>
598
+ <div class="detail-row"><div class="detail-label">Deployed By</div><div class="detail-value"><a class="link" href="#/address/${meta.deployedBy}">${meta.deployedBy}</a></div></div>
599
+ <div class="detail-row"><div class="detail-label">Deployed At</div><div class="detail-value text">${formatTime(meta.deployedAt)}</div></div>
600
+ </div>
601
+
602
+ ${codeEntry ? `
603
+ <div class="page-title" style="font-size:16px;margin-top:8px">Contract Code</div>
604
+ <div class="code-view">${escapeHtml(typeof codeEntry.value === 'string' ? codeEntry.value : JSON.stringify(codeEntry.value, null, 2))}</div>` : ''}
605
+ `;
606
+ }
607
+
608
+ // ── Page: Governance ──────────────────────────────────────────────
609
+
610
+ async function renderGovernance(el) {
611
+ el.innerHTML = '<div class="empty-state">Loading proposals...</div>';
612
+
613
+ const data = await api('/proposals');
614
+ if (!data) { el.innerHTML = '<div class="empty-state">Failed to load proposals</div>'; return; }
615
+
616
+ const proposals = data.proposals || [];
617
+
618
+ const statusBadge = (s) => {
619
+ const map = { active: 'badge-green', approved: 'badge-cyan', rejected: 'badge-red', expired: 'badge-yellow' };
620
+ return `<span class="badge ${map[s] || ''}">${s}</span>`;
621
+ };
622
+
623
+ el.innerHTML = `
624
+ <div class="page-title">Governance</div>
625
+ ${proposals.length === 0 ? '<div class="empty-state">No proposals yet</div>' : `
626
+ <table class="data-table">
627
+ <thead><tr><th>Title</th><th>Status</th><th>Votes</th><th>Proposer</th><th>Expires</th></tr></thead>
628
+ <tbody>
629
+ ${proposals.map(p => {
630
+ const voteCount = p.votes ? Object.keys(p.votes).length : 0;
631
+ const yesVotes = p.votes ? Object.values(p.votes).filter(v => v === true).length : 0;
632
+ return `
633
+ <tr class="clickable" onclick="navigate('#/proposal/${p.id}')">
634
+ <td><a class="link" href="#/proposal/${p.id}">${escapeHtml(p.title)}</a></td>
635
+ <td>${statusBadge(p.status)}</td>
636
+ <td>${yesVotes}/${voteCount}</td>
637
+ <td class="mono dim"><a class="link" href="#/address/${p.proposer}">${shortHash(p.proposer)}</a></td>
638
+ <td class="dim">${formatTime(p.expiresAt)}</td>
639
+ </tr>`;
640
+ }).join('')}
641
+ </tbody>
642
+ </table>`}
643
+ `;
644
+ }
645
+
646
+ // ── Page: Proposal Detail ─────────────────────────────────────────
647
+
648
+ async function renderProposalDetail(el, id) {
649
+ el.innerHTML = '<div class="empty-state">Loading proposal...</div>';
650
+
651
+ const proposal = await api(`/proposals/${id}`);
652
+ if (!proposal) { el.innerHTML = '<div class="empty-state">Proposal not found</div>'; return; }
653
+
654
+ const votes = proposal.votes || {};
655
+ const voters = Object.entries(votes);
656
+ const yesVotes = voters.filter(([, v]) => v === true).length;
657
+ const noVotes = voters.filter(([, v]) => v === false).length;
658
+ const total = voters.length || 1;
659
+
660
+ const statusBadge = (s) => {
661
+ const map = { active: 'badge-green', approved: 'badge-cyan', rejected: 'badge-red', expired: 'badge-yellow' };
662
+ return `<span class="badge ${map[s] || ''}">${s}</span>`;
663
+ };
664
+
665
+ el.innerHTML = `
666
+ <div class="page-title">Proposal: ${escapeHtml(proposal.title)}</div>
667
+ <div class="detail-grid">
668
+ <div class="detail-row"><div class="detail-label">ID</div><div class="detail-value">${proposal.id}</div></div>
669
+ <div class="detail-row"><div class="detail-label">Status</div><div class="detail-value text">${statusBadge(proposal.status)}</div></div>
670
+ <div class="detail-row"><div class="detail-label">Type</div><div class="detail-value text">${escapeHtml(proposal.type || '\u2014')}</div></div>
671
+ <div class="detail-row"><div class="detail-label">Proposer</div><div class="detail-value"><a class="link" href="#/address/${proposal.proposer}">${proposal.proposer}</a></div></div>
672
+ <div class="detail-row"><div class="detail-label">Description</div><div class="detail-value text">${escapeHtml(proposal.description || '\u2014')}</div></div>
673
+ <div class="detail-row"><div class="detail-label">Created</div><div class="detail-value text">${formatTime(proposal.createdAt)}</div></div>
674
+ <div class="detail-row"><div class="detail-label">Expires</div><div class="detail-value text">${formatTime(proposal.expiresAt)}</div></div>
675
+ </div>
676
+
677
+ <div class="page-title" style="font-size:16px;margin-top:8px">Vote Breakdown</div>
678
+ <div style="display:flex;gap:16px;align-items:center;margin-bottom:8px">
679
+ <span class="badge badge-green">Yes: ${yesVotes}</span>
680
+ <span class="badge badge-red">No: ${noVotes}</span>
681
+ </div>
682
+ <div class="vote-bar" style="width:100%;max-width:400px">
683
+ <div class="yes" style="width:${(yesVotes / total) * 100}%"></div>
684
+ <div class="no" style="width:${(noVotes / total) * 100}%"></div>
685
+ </div>
686
+
687
+ ${voters.length > 0 ? `
688
+ <table class="data-table" style="margin-top:12px">
689
+ <thead><tr><th>Voter</th><th>Vote</th></tr></thead>
690
+ <tbody>
691
+ ${voters.map(([voter, vote]) => `
692
+ <tr>
693
+ <td class="mono"><a class="link" href="#/address/${voter}">${shortHash(voter)}</a></td>
694
+ <td>${vote ? '<span class="badge badge-green">Yes</span>' : '<span class="badge badge-red">No</span>'}</td>
695
+ </tr>`).join('')}
696
+ </tbody>
697
+ </table>` : '<div class="empty-state">No votes yet</div>'}
698
+
699
+ ${proposal.action ? `
700
+ <div class="page-title" style="font-size:16px;margin-top:20px">Action</div>
701
+ <div class="json-viewer">${jsonPretty(proposal.action)}</div>` : ''}
702
+ `;
703
+ }
704
+
705
+ // ── Page: Network ─────────────────────────────────────────────────
706
+
707
+ async function renderNetwork(el) {
708
+ el.innerHTML = '<div class="empty-state">Loading network info...</div>';
709
+
710
+ const [peers, consensus, identity, status] = await Promise.all([
711
+ api('/peers'),
712
+ api('/consensus'),
713
+ api('/identity'),
714
+ api('/status'),
715
+ ]);
716
+
717
+ const peerList = peers?.peers ?? [];
718
+
719
+ el.innerHTML = `
720
+ <div class="page-title">Network</div>
721
+
722
+ <div class="detail-grid" style="margin-bottom:20px">
723
+ <div class="detail-row"><div class="detail-label">Node ID</div><div class="detail-value">${status?.nodeId || '\u2014'}</div></div>
724
+ <div class="detail-row"><div class="detail-label">Public Key</div><div class="detail-value">${identity?.publicKey || '\u2014'}</div></div>
725
+ <div class="detail-row"><div class="detail-label">Protocol</div><div class="detail-value text">v${status?.version || '\u2014'}</div></div>
726
+ </div>
727
+
728
+ ${consensus?.state ? `
729
+ <div class="page-title" style="font-size:16px">Consensus</div>
730
+ <div class="detail-grid" style="margin-bottom:20px">
731
+ <div class="detail-row"><div class="detail-label">Algorithm</div><div class="detail-value text">${consensus.algorithm}</div></div>
732
+ <div class="detail-row"><div class="detail-label">Role</div><div class="detail-value text"><span class="badge ${consensus.state.role === 'leader' ? 'badge-green' : ''}">${consensus.state.role}</span></div></div>
733
+ <div class="detail-row"><div class="detail-label">Term</div><div class="detail-value">${consensus.state.term ?? '\u2014'}</div></div>
734
+ <div class="detail-row"><div class="detail-label">Leader</div><div class="detail-value text">${consensus.state.leaderId || 'none'}</div></div>
735
+ </div>` : `
736
+ <div class="detail-grid" style="margin-bottom:20px">
737
+ <div class="detail-row"><div class="detail-label">Algorithm</div><div class="detail-value text">${consensus?.algorithm || 'solo'}</div></div>
738
+ </div>`}
739
+
740
+ <div class="page-title" style="font-size:16px">Peers (${peerList.length})</div>
741
+ ${peerList.length === 0 ? '<div class="empty-state">No peers connected (solo mode)</div>' :
742
+ peerList.map(p => `
743
+ <div class="peer-card">
744
+ <span class="dot ${p.status === 'connected' ? 'green' : 'red'}"></span>
745
+ <div class="peer-info">
746
+ <div class="peer-id">${p.nodeId}</div>
747
+ <div class="peer-meta">${p.address || '\u2014'} &middot; ${p.orgId || '\u2014'}</div>
748
+ </div>
749
+ <div class="peer-height">H:${p.chainHeight}</div>
750
+ <span class="badge ${p.status === 'connected' ? 'badge-green' : 'badge-red'}">${p.status}</span>
751
+ </div>`).join('')}
752
+ `;
199
753
  }
200
754
 
201
755
  // ── SQL Query ─────────────────────────────────────────────────────
202
756
 
203
- async function runQuery() {
204
- const sql = document.getElementById('sql-input').value.trim();
757
+ async function runSqlQuery() {
758
+ const input = document.getElementById('sql-input');
759
+ if (!input) return;
760
+ const sql = input.value.trim();
205
761
  if (!sql) return;
206
762
 
207
763
  const el = document.getElementById('query-results');
208
764
  el.innerHTML = '<div class="empty-state">Running...</div>';
209
765
 
210
766
  const data = await apiPost('/state/query', { sql });
211
- if (!data) {
212
- el.innerHTML = '<div class="empty-state" style="color:var(--red)">Query failed</div>';
213
- return;
214
- }
215
-
216
- if (data.error) {
217
- el.innerHTML = `<div class="empty-state" style="color:var(--red)">${data.error}</div>`;
218
- return;
219
- }
220
-
221
- if (!data.results || data.results.length === 0) {
222
- el.innerHTML = '<div class="empty-state">No results</div>';
223
- return;
224
- }
767
+ if (!data) { el.innerHTML = '<div class="empty-state" style="color:var(--red)">Query failed</div>'; return; }
768
+ if (data.error) { el.innerHTML = `<div class="empty-state" style="color:var(--red)">${escapeHtml(data.error)}</div>`; return; }
769
+ if (!data.results || data.results.length === 0) { el.innerHTML = '<div class="empty-state">No results</div>'; return; }
225
770
 
226
771
  const cols = Object.keys(data.results[0]);
227
- const headerRow = cols.map(c => `<th>${c}</th>`).join('');
228
- const rows = data.results.map(r =>
229
- '<tr>' + cols.map(c => `<td title="${String(r[c] ?? '')}">${String(r[c] ?? '')}</td>`).join('') + '</tr>'
230
- ).join('');
231
-
232
772
  el.innerHTML = `
233
773
  <table class="result-table">
234
- <thead><tr>${headerRow}</tr></thead>
235
- <tbody>${rows}</tbody>
774
+ <thead><tr>${cols.map(c => `<th>${escapeHtml(c)}</th>`).join('')}</tr></thead>
775
+ <tbody>${data.results.map(r =>
776
+ '<tr>' + cols.map(c => `<td title="${escapeHtml(String(r[c] ?? ''))}">${escapeHtml(String(r[c] ?? ''))}</td>`).join('') + '</tr>'
777
+ ).join('')}</tbody>
236
778
  </table>
237
779
  `;
238
780
  }
239
781
 
240
- // Enter key runs query
241
- document.getElementById('sql-input').addEventListener('keydown', (e) => {
242
- if (e.key === 'Enter') runQuery();
782
+ // ── Search ────────────────────────────────────────────────────────
783
+
784
+ async function handleSearch(query) {
785
+ if (!query) return;
786
+ const data = await api(`/search?q=${encodeURIComponent(query)}`);
787
+ if (!data || data.type === 'not_found') {
788
+ const el = document.getElementById('app');
789
+ el.innerHTML = `<div class="page-title">Search Results</div><div class="empty-state">No results found for "${escapeHtml(query)}"</div>`;
790
+ currentRoute = '';
791
+ return;
792
+ }
793
+
794
+ switch (data.type) {
795
+ case 'block': navigate(`#/block/${data.height}`); break;
796
+ case 'transaction': navigate(`#/tx/${data.hash}`); break;
797
+ case 'address': navigate(`#/address/${data.pubkey}`); break;
798
+ case 'state': navigate(`#/state/${encodeURIComponent(data.key)}`); break;
799
+ case 'contract': navigate(`#/contract/${encodeURIComponent(data.name)}`); break;
800
+ default:
801
+ const el = document.getElementById('app');
802
+ el.innerHTML = `<div class="page-title">Search Results</div><div class="empty-state">No results found for "${escapeHtml(query)}"</div>`;
803
+ currentRoute = '';
804
+ }
805
+ }
806
+
807
+ document.getElementById('search-input').addEventListener('keydown', (e) => {
808
+ if (e.key === 'Enter') {
809
+ handleSearch(e.target.value.trim());
810
+ e.target.blur();
811
+ }
243
812
  });
244
813
 
245
- // ── Refresh loop ──────────────────────────────────────────────────
814
+ // ── Status update (sidebar) ───────────────────────────────────────
246
815
 
247
- async function refreshAll() {
248
- await Promise.all([
249
- updateStatus(),
250
- updateBlocks(),
251
- updateTransactions(),
252
- updatePeers(),
253
- updateContractsGov(),
254
- updateStateCount(),
255
- ]);
816
+ async function updateTopBar() {
817
+ const data = await api('/status');
818
+ if (!data) {
819
+ document.getElementById('status-dot').className = 'dot red';
820
+ document.getElementById('status-text').textContent = 'Disconnected';
821
+ return;
822
+ }
823
+
824
+ document.getElementById('status-dot').className = 'dot green';
825
+ document.getElementById('status-text').textContent = 'Running';
826
+ document.getElementById('node-id-short').textContent = data.nodeId;
827
+ document.getElementById('tb-height').textContent = data.chainHeight;
828
+ document.getElementById('tb-uptime').textContent = formatUptime(data.uptime);
256
829
  }
257
830
 
831
+ // ── Init ──────────────────────────────────────────────────────────
832
+
833
+ window.addEventListener('hashchange', () => {
834
+ currentRoute = ''; // force re-render
835
+ router();
836
+ });
837
+
838
+ // Make functions available to onclick handlers
839
+ window.navigate = (hash) => { window.location.hash = hash.replace('#', ''); };
840
+ window.renderBlocks = renderBlocks;
841
+ window.renderTransactions = renderTransactions;
842
+ window.renderState = renderState;
843
+ window.runSqlQuery = runSqlQuery;
844
+
258
845
  // Initial load
259
- refreshAll();
846
+ updateTopBar();
847
+ router();
260
848
 
261
- // Auto-refresh every 2 seconds
262
- refreshTimer = setInterval(refreshAll, 2000);
849
+ // Auto-refresh top bar every 3 seconds
850
+ refreshTimer = setInterval(updateTopBar, 3000);