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/README.md +165 -35
- package/dashboard/app.js +744 -156
- package/dashboard/index.html +33 -84
- package/dashboard/style.css +500 -75
- package/dist/bin/miniledger.cjs +135 -23
- package/dist/bin/miniledger.cjs.map +1 -1
- package/package.json +33 -9
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) + '
|
|
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
|
-
|
|
51
|
+
function formatTime(ts) {
|
|
52
|
+
if (!ts) return '\u2014';
|
|
53
|
+
return new Date(ts).toLocaleString();
|
|
54
|
+
}
|
|
55
55
|
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
65
|
-
document.
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
74
|
+
function jsonPretty(obj) {
|
|
75
|
+
return escapeHtml(JSON.stringify(obj, null, 2));
|
|
76
|
+
}
|
|
76
77
|
|
|
77
|
-
|
|
78
|
-
document.getElementById('block-count').textContent = data.height + 1;
|
|
78
|
+
// ── Router ────────────────────────────────────────────────────────
|
|
79
79
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
97
|
-
// Get transactions from recent blocks
|
|
98
|
-
const data = await api('/blocks');
|
|
99
|
-
if (!data || !data.blocks) return;
|
|
151
|
+
// ── Page: Overview ────────────────────────────────────────────────
|
|
100
152
|
|
|
101
|
-
|
|
102
|
-
|
|
153
|
+
async function renderOverview(el) {
|
|
154
|
+
el.innerHTML = '<div class="empty-state">Loading...</div>';
|
|
103
155
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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 →</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 · ${timeAgo(b.timestamp)}</span>
|
|
233
|
+
</div>`).join('')}
|
|
234
|
+
</div>
|
|
235
|
+
</div>
|
|
119
236
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
237
|
+
<div class="card">
|
|
238
|
+
<div class="card-header">Recent Transactions <a class="link" href="#/transactions">View all →</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} · ${timeAgo(tx.timestamp)}</span>
|
|
246
|
+
</div>`).join('')}
|
|
247
|
+
</div>
|
|
126
248
|
</div>
|
|
127
249
|
</div>
|
|
128
|
-
|
|
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
|
-
|
|
132
|
-
const data = await api('/peers');
|
|
133
|
-
if (!data) return;
|
|
259
|
+
// ── Page: Block List ──────────────────────────────────────────────
|
|
134
260
|
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
139
|
-
|
|
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 =
|
|
144
|
-
<div class="
|
|
145
|
-
|
|
146
|
-
<
|
|
147
|
-
|
|
148
|
-
|
|
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' : ''}>← 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 →</button>
|
|
149
289
|
</div>
|
|
150
|
-
|
|
290
|
+
`;
|
|
151
291
|
}
|
|
152
292
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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' : ''}>← 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} →</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' : ''}>← 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 →</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
|
|
160
|
-
|
|
428
|
+
const txs = txData?.transactions ?? [];
|
|
429
|
+
const stateEntries = stateData?.results ?? [];
|
|
161
430
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
<div class="
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
</div>
|
|
169
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
<
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
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
|
-
|
|
186
|
-
|
|
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' : ''}>← 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 →</button>
|
|
527
|
+
</div>` : ''}
|
|
528
|
+
`;
|
|
529
|
+
}
|
|
188
530
|
|
|
189
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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'} · ${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
|
|
204
|
-
const
|
|
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
|
-
|
|
213
|
-
|
|
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>${
|
|
235
|
-
<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
|
-
//
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
// ──
|
|
814
|
+
// ── Status update (sidebar) ───────────────────────────────────────
|
|
246
815
|
|
|
247
|
-
async function
|
|
248
|
-
await
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
846
|
+
updateTopBar();
|
|
847
|
+
router();
|
|
260
848
|
|
|
261
|
-
// Auto-refresh every
|
|
262
|
-
refreshTimer = setInterval(
|
|
849
|
+
// Auto-refresh top bar every 3 seconds
|
|
850
|
+
refreshTimer = setInterval(updateTopBar, 3000);
|