viz-js-lib 0.12.6 → 0.12.7
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/api-frontend/app.js +890 -0
- package/api-frontend/index.html +41 -0
- package/api-frontend/jsonrpc-api-spec.json +1353 -0
- package/api-frontend/style.css +600 -0
- package/config.json +1 -1
- package/dist/statistics.html +1 -1
- package/dist/viz-tests.min.js +6 -272
- package/dist/viz-tests.min.js.gz +0 -0
- package/dist/viz.min.js +6 -6
- package/dist/viz.min.js.gz +0 -0
- package/lib/api/methods.js +23 -153
- package/package.json +6 -3
- package/test/api.test.js +11 -164
- package/test/api.ws.test.js +162 -0
- package/test/methods_by_version.js +27 -35
- package/test/types_test.js +2 -2
|
@@ -0,0 +1,890 @@
|
|
|
1
|
+
/* global viz */
|
|
2
|
+
// ─── Methods tree (from src/api/methods.js) ─────────────────────────────────
|
|
3
|
+
const METHODS = [
|
|
4
|
+
{api:"validator_api",method:"get_validator_schedule"},
|
|
5
|
+
{api:"validator_api",method:"get_validators",params:["validatorIds"]},
|
|
6
|
+
{api:"validator_api",method:"get_validator_by_account",params:["accountName"]},
|
|
7
|
+
{api:"validator_api",method:"get_validators_by_vote",params:["from","limit"]},
|
|
8
|
+
{api:"validator_api",method:"get_validators_by_counted_vote",params:["from","limit"]},
|
|
9
|
+
{api:"validator_api",method:"get_validator_count"},
|
|
10
|
+
{api:"validator_api",method:"lookup_validator_accounts",params:["lowerBoundName","limit"]},
|
|
11
|
+
{api:"validator_api",method:"get_active_validators"},
|
|
12
|
+
{api:"account_history",method:"get_account_history",params:["account","from","limit"]},
|
|
13
|
+
{api:"operation_history",method:"get_ops_in_block",params:["blockNum","onlyVirtual"]},
|
|
14
|
+
{api:"operation_history",method:"get_transaction",params:["trxId"]},
|
|
15
|
+
{api:"database_api",method:"get_block_header",params:["blockNum"]},
|
|
16
|
+
{api:"database_api",method:"get_block",params:["blockNum"]},
|
|
17
|
+
{api:"database_api",method:"get_irreversible_block_header",params:["blockNum"]},
|
|
18
|
+
{api:"database_api",method:"get_irreversible_block",params:["blockNum"]},
|
|
19
|
+
{api:"database_api",method:"get_config"},
|
|
20
|
+
{api:"database_api",method:"get_dynamic_global_properties"},
|
|
21
|
+
{api:"database_api",method:"get_chain_properties"},
|
|
22
|
+
{api:"database_api",method:"get_hardfork_version"},
|
|
23
|
+
{api:"database_api",method:"get_next_scheduled_hardfork"},
|
|
24
|
+
{api:"database_api",method:"get_account_count"},
|
|
25
|
+
{api:"database_api",method:"get_master_history",params:["account"]},
|
|
26
|
+
{api:"database_api",method:"set_block_applied_callback",params:["callback"]},
|
|
27
|
+
{api:"database_api",method:"get_recovery_request",params:["account"]},
|
|
28
|
+
{api:"database_api",method:"get_escrow",params:["from","escrowId"]},
|
|
29
|
+
{api:"database_api",method:"get_withdraw_routes",params:["account","withdrawRouteType"]},
|
|
30
|
+
{api:"database_api",method:"get_transaction_hex",params:["trx"]},
|
|
31
|
+
{api:"database_api",method:"get_required_signatures",params:["trx","availableKeys"]},
|
|
32
|
+
{api:"database_api",method:"get_potential_signatures",params:["trx"]},
|
|
33
|
+
{api:"database_api",method:"verify_authority",params:["trx"]},
|
|
34
|
+
{api:"database_api",method:"verify_account_authority",params:["name","signers"]},
|
|
35
|
+
{api:"database_api",method:"get_accounts",params:["accountNames"]},
|
|
36
|
+
{api:"database_api",method:"lookup_account_names",params:["accountNames"]},
|
|
37
|
+
{api:"database_api",method:"lookup_accounts",params:["lowerBoundName","limit"]},
|
|
38
|
+
{api:"database_api",method:"get_database_info"},
|
|
39
|
+
{api:"database_api",method:"get_vesting_delegations",params:["account","from","limit","type"]},
|
|
40
|
+
{api:"database_api",method:"get_expiring_vesting_delegations",params:["account","from","limit"]},
|
|
41
|
+
{api:"database_api",method:"get_proposed_transactions",params:["account","from","limit"]},
|
|
42
|
+
{api:"database_api",method:"get_accounts_on_sale",params:["from","limit"]},
|
|
43
|
+
{api:"database_api",method:"get_accounts_on_auction",params:["from","limit"]},
|
|
44
|
+
{api:"database_api",method:"get_subaccounts_on_sale",params:["from","limit"]},
|
|
45
|
+
{api:"account_by_key",method:"get_key_references",params:["account_name_type"]},
|
|
46
|
+
{api:"network_broadcast_api",method:"broadcast_transaction",params:["trx"]},
|
|
47
|
+
{api:"network_broadcast_api",method:"broadcast_transaction_with_callback",params:["confirmationCallback","trx"]},
|
|
48
|
+
{api:"network_broadcast_api",method:"broadcast_transaction_synchronous",params:["trx"]},
|
|
49
|
+
{api:"network_broadcast_api",method:"broadcast_block",params:["block"]},
|
|
50
|
+
{api:"committee_api",method:"get_committee_request",params:["request_id","votes_count"]},
|
|
51
|
+
{api:"committee_api",method:"get_committee_request_votes",params:["request_id"]},
|
|
52
|
+
{api:"committee_api",method:"get_committee_requests_list",params:["status"]},
|
|
53
|
+
{api:"invite_api",method:"get_invites_list",params:["status"]},
|
|
54
|
+
{api:"invite_api",method:"get_invite_by_id",params:["id"]},
|
|
55
|
+
{api:"invite_api",method:"get_invite_by_key",params:["invite_key"]},
|
|
56
|
+
{api:"paid_subscription_api",method:"get_paid_subscriptions",params:["from","limit"]},
|
|
57
|
+
{api:"paid_subscription_api",method:"get_paid_subscription_options",params:["account"]},
|
|
58
|
+
{api:"paid_subscription_api",method:"get_paid_subscription_status",params:["subscriber","account"]},
|
|
59
|
+
{api:"paid_subscription_api",method:"get_active_paid_subscriptions",params:["subscriber"]},
|
|
60
|
+
{api:"paid_subscription_api",method:"get_inactive_paid_subscriptions",params:["subscriber"]},
|
|
61
|
+
{api:"custom_protocol_api",method:"get_account",params:["account","custom_protocol_id"]},
|
|
62
|
+
{api:"auth_util",method:"check_authority_signature",params:["account_name","level","signatures"]},
|
|
63
|
+
{api:"block_info",method:"get_block_info",params:["start_block_num","count"]},
|
|
64
|
+
{api:"block_info",method:"get_blocks_with_info",params:["start_block_num","count"]},
|
|
65
|
+
{api:"raw_block",method:"get_raw_block",params:["block_num"]}
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
// ─── Derived structures ─────────────────────────────────────────────────────
|
|
69
|
+
// plugins: { pluginName: [method, method, ...], ... } in insertion order
|
|
70
|
+
const plugins = {};
|
|
71
|
+
METHODS.forEach(m => {
|
|
72
|
+
if (!plugins[m.api]) plugins[m.api] = [];
|
|
73
|
+
plugins[m.api].push(m);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// ─── Node management ─────────────────────────────────────────────────────────
|
|
77
|
+
const DEFAULT_NODES = [
|
|
78
|
+
'https://api.viz.world',
|
|
79
|
+
'https://mirror.viz.world',
|
|
80
|
+
'https://node.viz.cx',
|
|
81
|
+
'https://viz.lexai.top'
|
|
82
|
+
];
|
|
83
|
+
const LS_CUSTOM = 'viz_custom_nodes';
|
|
84
|
+
const LS_SELECTED = 'viz_selected_node';
|
|
85
|
+
|
|
86
|
+
function getCustomNodes() { try { return JSON.parse(localStorage.getItem(LS_CUSTOM)) || []; } catch(e) { return []; } }
|
|
87
|
+
function saveCustomNodes(arr) { localStorage.setItem(LS_CUSTOM, JSON.stringify(arr)); }
|
|
88
|
+
function getAllNodes() { return [...DEFAULT_NODES, ...getCustomNodes()]; }
|
|
89
|
+
function getSelectedNode() { return localStorage.getItem(LS_SELECTED) || DEFAULT_NODES[0]; }
|
|
90
|
+
function setSelectedNode(url) { localStorage.setItem(LS_SELECTED, url); }
|
|
91
|
+
|
|
92
|
+
function addCustomNode(url) {
|
|
93
|
+
url = url.trim().replace(/\/+$/, '');
|
|
94
|
+
if (!url) return;
|
|
95
|
+
const customs = getCustomNodes();
|
|
96
|
+
if (DEFAULT_NODES.includes(url) || customs.includes(url)) return;
|
|
97
|
+
customs.push(url);
|
|
98
|
+
saveCustomNodes(customs);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function removeCustomNode(url) {
|
|
102
|
+
url = url.trim().replace(/\/+$/, '');
|
|
103
|
+
saveCustomNodes(getCustomNodes().filter(n => n !== url));
|
|
104
|
+
if (getSelectedNode() === url) setSelectedNode(DEFAULT_NODES[0]);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ─── DOM refs ────────────────────────────────────────────────────────────────
|
|
108
|
+
const $nodeSelect = document.getElementById('node-select');
|
|
109
|
+
const $customInput = document.getElementById('custom-node-input');
|
|
110
|
+
const $addBtn = document.getElementById('add-node-btn');
|
|
111
|
+
const $removeBtn = document.getElementById('remove-node-btn');
|
|
112
|
+
const $checkBtn = document.getElementById('check-nodes-btn');
|
|
113
|
+
const $statusPanel = document.getElementById('node-status-panel');
|
|
114
|
+
const $sidebar = document.getElementById('sidebar');
|
|
115
|
+
const $contentMain = document.getElementById('content-main');
|
|
116
|
+
const $responseArea = document.getElementById('response-area');
|
|
117
|
+
|
|
118
|
+
// ─── Header: node selector ───────────────────────────────────────────────────
|
|
119
|
+
function renderNodeSelector() {
|
|
120
|
+
const all = getAllNodes();
|
|
121
|
+
const selected = getSelectedNode();
|
|
122
|
+
$nodeSelect.innerHTML = '';
|
|
123
|
+
all.forEach(url => {
|
|
124
|
+
const opt = document.createElement('option');
|
|
125
|
+
opt.value = url;
|
|
126
|
+
opt.textContent = url;
|
|
127
|
+
if (url === selected) opt.selected = true;
|
|
128
|
+
$nodeSelect.appendChild(opt);
|
|
129
|
+
});
|
|
130
|
+
// enable/disable remove button
|
|
131
|
+
$removeBtn.disabled = DEFAULT_NODES.includes(selected);
|
|
132
|
+
// Highlight server toggle when a non-default node is active
|
|
133
|
+
const serverBtn = document.getElementById('server-toggle');
|
|
134
|
+
if (serverBtn) serverBtn.classList.toggle('node-active', !DEFAULT_NODES.includes(selected));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
$nodeSelect.addEventListener('change', () => {
|
|
138
|
+
setSelectedNode($nodeSelect.value);
|
|
139
|
+
renderNodeSelector();
|
|
140
|
+
// Update hash with new node (triggers hashchange → applyHash)
|
|
141
|
+
const { plugin, method, args } = parseHash();
|
|
142
|
+
if (plugin) {
|
|
143
|
+
navigateTo(plugin, method, args);
|
|
144
|
+
} else {
|
|
145
|
+
// No method selected — just re-render, no hash to update
|
|
146
|
+
applyHash();
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
$addBtn.addEventListener('click', () => {
|
|
151
|
+
const url = $customInput.value.trim();
|
|
152
|
+
if (!url) return;
|
|
153
|
+
addCustomNode(url);
|
|
154
|
+
setSelectedNode(url.replace(/\/+$/, ''));
|
|
155
|
+
$customInput.value = '';
|
|
156
|
+
renderNodeSelector();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
$removeBtn.addEventListener('click', () => {
|
|
160
|
+
const sel = $nodeSelect.value;
|
|
161
|
+
if (DEFAULT_NODES.includes(sel)) return;
|
|
162
|
+
removeCustomNode(sel);
|
|
163
|
+
renderNodeSelector();
|
|
164
|
+
applyHash();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// ─── Node health check ───────────────────────────────────────────────────────
|
|
168
|
+
const checkAbort = false;
|
|
169
|
+
|
|
170
|
+
function pingNode(url) {
|
|
171
|
+
return new Promise(resolve => {
|
|
172
|
+
const xhr = new XMLHttpRequest();
|
|
173
|
+
xhr.overrideMimeType('text/plain');
|
|
174
|
+
xhr.open('POST', url);
|
|
175
|
+
xhr.setRequestHeader('accept', 'application/json, text/plain, */*');
|
|
176
|
+
xhr.setRequestHeader('content-type', 'application/json');
|
|
177
|
+
const start = Date.now();
|
|
178
|
+
xhr.timeout = 8000;
|
|
179
|
+
xhr.onreadystatechange = function() {
|
|
180
|
+
if (xhr.readyState === 4) {
|
|
181
|
+
const latency = Date.now() - start;
|
|
182
|
+
if (xhr.status === 200) {
|
|
183
|
+
try {
|
|
184
|
+
const json = JSON.parse(xhr.responseText);
|
|
185
|
+
if (json.error) {
|
|
186
|
+
resolve({ url, latency: null, block: null, error: json.error.message || 'RPC error' });
|
|
187
|
+
} else {
|
|
188
|
+
const r = json.result || {};
|
|
189
|
+
resolve({ url, latency, block: r.last_irreversible_block_num || null, error: null });
|
|
190
|
+
}
|
|
191
|
+
} catch(e) {
|
|
192
|
+
resolve({ url, latency: null, block: null, error: 'Bad JSON' });
|
|
193
|
+
}
|
|
194
|
+
} else {
|
|
195
|
+
resolve({ url, latency: null, block: null, error: 'HTTP ' + (xhr.status || 'timeout') });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
xhr.ontimeout = function() {
|
|
200
|
+
resolve({ url, latency: null, block: null, error: 'Timeout' });
|
|
201
|
+
};
|
|
202
|
+
xhr.onerror = function() {
|
|
203
|
+
resolve({ url, latency: null, block: null, error: 'Network error' });
|
|
204
|
+
};
|
|
205
|
+
xhr.send('{"id":1,"method":"call","jsonrpc":"2.0","params":["database_api","get_dynamic_global_properties",[]]}');
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function renderNodeCards(results) {
|
|
210
|
+
// Find best (lowest latency among ok nodes)
|
|
211
|
+
let bestUrl = null, bestLatency = Infinity;
|
|
212
|
+
results.forEach(r => {
|
|
213
|
+
if (r.latency !== null && r.latency < bestLatency) {
|
|
214
|
+
bestLatency = r.latency;
|
|
215
|
+
bestUrl = r.url;
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
$statusPanel.querySelectorAll('.node-card').forEach(function(c) { c.remove(); });
|
|
220
|
+
results.forEach(r => {
|
|
221
|
+
const card = document.createElement('div');
|
|
222
|
+
card.className = 'node-card';
|
|
223
|
+
if (r.error) card.className += ' node-err';
|
|
224
|
+
else if (r.latency !== null) card.className += ' node-ok';
|
|
225
|
+
if (r.url === bestUrl) card.className += ' node-best';
|
|
226
|
+
|
|
227
|
+
let infoHtml = '';
|
|
228
|
+
if (r.error) {
|
|
229
|
+
infoHtml = '<span class="node-error">' + esc(r.error) + '</span>';
|
|
230
|
+
} else {
|
|
231
|
+
infoHtml = '<span class="node-latency">' + r.latency + ' ms</span>';
|
|
232
|
+
if (r.block !== null) infoHtml += '<span class="node-block">LIB #' + r.block + '</span>';
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
card.innerHTML = '<div class="node-card-url">' + esc(r.url) + '</div>'
|
|
236
|
+
+ '<div class="node-card-info">' + infoHtml + '</div>';
|
|
237
|
+
card.addEventListener('click', () => {
|
|
238
|
+
if (!r.error) {
|
|
239
|
+
setSelectedNode(r.url);
|
|
240
|
+
renderNodeSelector();
|
|
241
|
+
checkAllNodes(); // re-render panel with updated selection
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
$statusPanel.appendChild(card);
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function checkAllNodes() {
|
|
249
|
+
const all = getAllNodes();
|
|
250
|
+
$statusPanel.classList.remove('hidden');
|
|
251
|
+
// Add close button if not already present
|
|
252
|
+
if (!$statusPanel.querySelector('.panel-close-btn')) {
|
|
253
|
+
const closeBtn = document.createElement('button');
|
|
254
|
+
closeBtn.className = 'panel-close-btn';
|
|
255
|
+
closeBtn.innerHTML = '×';
|
|
256
|
+
closeBtn.title = 'Close';
|
|
257
|
+
closeBtn.addEventListener('click', function() {
|
|
258
|
+
$statusPanel.classList.add('hidden');
|
|
259
|
+
});
|
|
260
|
+
$statusPanel.insertBefore(closeBtn, $statusPanel.firstChild);
|
|
261
|
+
}
|
|
262
|
+
$checkBtn.disabled = true;
|
|
263
|
+
$checkBtn.textContent = 'Checking...';
|
|
264
|
+
|
|
265
|
+
// Show checking state for all nodes (preserve close button)
|
|
266
|
+
const existingCards = $statusPanel.querySelectorAll('.node-card');
|
|
267
|
+
existingCards.forEach(function(c) { c.remove(); });
|
|
268
|
+
all.forEach(url => {
|
|
269
|
+
const card = document.createElement('div');
|
|
270
|
+
card.className = 'node-card node-checking';
|
|
271
|
+
card.setAttribute('data-node-url', url);
|
|
272
|
+
card.innerHTML = '<div class="node-card-url">' + esc(url) + '</div>'
|
|
273
|
+
+ '<div class="node-card-info"><span class="node-checking-text">Checking...</span></div>';
|
|
274
|
+
$statusPanel.appendChild(card);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// Ping all nodes concurrently
|
|
278
|
+
const results = await Promise.all(all.map(url => pingNode(url)));
|
|
279
|
+
|
|
280
|
+
// Render final results
|
|
281
|
+
renderNodeCards(results);
|
|
282
|
+
|
|
283
|
+
$checkBtn.disabled = false;
|
|
284
|
+
$checkBtn.textContent = 'Check nodes';
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
$checkBtn.addEventListener('click', checkAllNodes);
|
|
288
|
+
|
|
289
|
+
// ─── Hash routing ────────────────────────────────────────────────────────────
|
|
290
|
+
// Format: #/plugin_name/method_name?arg1=val1&arg2=val2&api=https://...
|
|
291
|
+
function parseHash() {
|
|
292
|
+
const raw = decodeURIComponent(location.hash.replace(/^#\/?/, ''));
|
|
293
|
+
if (!raw) return { plugin: null, method: null, args: {}, apiUrl: null };
|
|
294
|
+
const [pathPart, queryPart] = raw.split('?');
|
|
295
|
+
const parts = pathPart.split('/').filter(Boolean);
|
|
296
|
+
const plugin = parts[0] || null;
|
|
297
|
+
const method = parts[1] || null;
|
|
298
|
+
const args = {};
|
|
299
|
+
let apiUrl = null;
|
|
300
|
+
if (queryPart) {
|
|
301
|
+
queryPart.split('&').forEach(pair => {
|
|
302
|
+
const [k, ...rest] = pair.split('=');
|
|
303
|
+
const v = decodeURIComponent(rest.join('='));
|
|
304
|
+
if (k === 'api') { apiUrl = v; }
|
|
305
|
+
else { args[k] = v; }
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
return { plugin, method, args, apiUrl };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function buildHash(plugin, method, args) {
|
|
312
|
+
if (!plugin) return '#/';
|
|
313
|
+
let h = '#/' + plugin;
|
|
314
|
+
if (method) h += '/' + method;
|
|
315
|
+
const pairs = [];
|
|
316
|
+
if (args) {
|
|
317
|
+
Object.entries(args).forEach(([k, v]) => {
|
|
318
|
+
if (v !== '' && v !== undefined) pairs.push(encodeURIComponent(k) + '=' + encodeURIComponent(v));
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
const node = getSelectedNode();
|
|
322
|
+
pairs.push('api=' + encodeURIComponent(node));
|
|
323
|
+
return h + '?' + pairs.join('&');
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function navigateTo(plugin, method, args) {
|
|
327
|
+
location.hash = buildHash(plugin, method, args);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
window.addEventListener('hashchange', applyHash);
|
|
331
|
+
|
|
332
|
+
// ─── Popular shortcuts ─────────────────────────────────────────────────────────
|
|
333
|
+
const POPULAR = [
|
|
334
|
+
{api:"database_api", method:"get_dynamic_global_properties"},
|
|
335
|
+
{api:"database_api", method:"get_chain_properties"},
|
|
336
|
+
{api:"validator_api", method:"get_active_validators"},
|
|
337
|
+
{api:"validator_api", method:"get_validators_by_counted_vote", preset:{limit:"21"}},
|
|
338
|
+
{api:"validator_api", method:"get_validator_by_account"},
|
|
339
|
+
{api:"database_api", method:"get_accounts"},
|
|
340
|
+
{api:"account_history", method:"get_account_history", preset:{from:"-1",limit:"100"}}
|
|
341
|
+
];
|
|
342
|
+
|
|
343
|
+
// ─── Spec lookup (from jsonrpc-api-spec.json) ──────────────────────────────────
|
|
344
|
+
// Keyed by pluginName → methodName → {d: description, p: {paramName: {c: caption, d: description, t: type}}}
|
|
345
|
+
const SPEC = {
|
|
346
|
+
validator_api: {
|
|
347
|
+
get_active_validators: {d:"Returns the list of currently active validator account names participating in block production."},
|
|
348
|
+
get_validator_schedule: {d:"Returns the current validator schedule object, including the shuffled list of validators and their timeshares."},
|
|
349
|
+
get_validators: {d:"Returns a list of validator objects by their database IDs.",p:{validatorIds:{c:"Validator IDs",d:"Array of validator object database IDs to look up.",t:"array"}}},
|
|
350
|
+
get_validator_by_account: {d:"Returns the validator object registered under a specific account name, or null if not a validator.",p:{accountName:{c:"Account Name",d:"The account name to look up as a validator."}}},
|
|
351
|
+
get_validators_by_vote: {d:"Returns validators sorted by total votes (descending). Starts from a given account name. Max 100 results.",p:{from:{c:"From Account",d:"Account name to start from. Empty string starts from the top."},limit:{c:"Limit",d:"Maximum number of results (max 100)."}}},
|
|
352
|
+
get_validators_by_counted_vote: {d:"Returns validators sorted by counted votes (descending). Max 100 results.",p:{from:{c:"From Account",d:"Account name to start from. Empty string starts from the top."},limit:{c:"Limit",d:"Maximum number of results (max 100)."}}},
|
|
353
|
+
get_validator_count: {d:"Returns the total number of registered validators on the blockchain."},
|
|
354
|
+
lookup_validator_accounts: {d:"Looks up validator account names starting from a lower bound. Returns up to 1000 results.",p:{lowerBoundName:{c:"Lower Bound Name",d:"Lower bound of the first account name. Empty string starts from the beginning."},limit:{c:"Limit",d:"Maximum number of results (max 1000)."}}}
|
|
355
|
+
},
|
|
356
|
+
account_history: {
|
|
357
|
+
get_account_history: {d:"Returns a map of operations for a given account in the sequence range [from-limit, from]. Use from=-1 for the most recent operations.",p:{account:{c:"Account Name",d:"The account name whose operation history to retrieve."},from:{c:"From Sequence",d:"Absolute sequence number. Use -1 for the most recent operation."},limit:{c:"Limit",d:"Maximum number of operations to return (1-1000)."}}}
|
|
358
|
+
},
|
|
359
|
+
operation_history: {
|
|
360
|
+
get_ops_in_block: {d:"Returns the sequence of operations included or generated within a particular block.",p:{blockNum:{c:"Block Number",d:"Height of the block whose operations should be returned."},onlyVirtual:{c:"Only Virtual",d:"Whether to only include virtual operations.",t:"boolean"}}},
|
|
361
|
+
get_transaction: {d:"Returns a transaction by its ID, including block number and transaction index.",p:{trxId:{c:"Transaction ID",d:"The hash of the transaction to retrieve."}}}
|
|
362
|
+
},
|
|
363
|
+
database_api: {
|
|
364
|
+
get_block_header: {d:"Retrieves a block header by block number.",p:{blockNum:{c:"Block Number",d:"Height of the block whose header should be returned."}}},
|
|
365
|
+
get_block: {d:"Retrieves a full, signed block by block number.",p:{blockNum:{c:"Block Number",d:"Height of the block to be returned."}}},
|
|
366
|
+
get_irreversible_block_header: {d:"Retrieves a block header only if the block is irreversible.",p:{blockNum:{c:"Block Number",d:"Height of the block whose header should be returned."}}},
|
|
367
|
+
get_irreversible_block: {d:"Retrieves a full, signed block only if it is irreversible.",p:{blockNum:{c:"Block Number",d:"Height of the block to be returned."}}},
|
|
368
|
+
get_config: {d:"Retrieves compile-time constants and configuration values of the blockchain."},
|
|
369
|
+
get_dynamic_global_properties: {d:"Retrieves the current dynamic global properties: head block number, total supply, and other real-time chain metrics."},
|
|
370
|
+
get_chain_properties: {d:"Retrieves the chain properties as set by the median validator schedule."},
|
|
371
|
+
get_hardfork_version: {d:"Returns the current hardfork version of the blockchain."},
|
|
372
|
+
get_next_scheduled_hardfork: {d:"Returns the next scheduled hardfork version and the time it is planned to go live."},
|
|
373
|
+
get_account_count: {d:"Returns the total number of accounts registered on the blockchain."},
|
|
374
|
+
get_master_history: {d:"Returns the master authority change history for a given account.",p:{account:{c:"Account Name",d:"The account name whose master authority history to retrieve."}}},
|
|
375
|
+
set_block_applied_callback: {d:"Sets a callback triggered on each newly generated block (WebSocket only).",p:{callback:{c:"Callback",d:"Callback function to invoke when a new block is applied."}}},
|
|
376
|
+
get_recovery_request: {d:"Returns the current account recovery request for an account, if one exists.",p:{account:{c:"Account Name",d:"The account name whose recovery request to check."}}},
|
|
377
|
+
get_escrow: {d:"Returns the escrow object for a given sender and escrow ID.",p:{from:{c:"From Account",d:"The account name of the escrow sender."},escrowId:{c:"Escrow ID",d:"The numeric escrow ID to look up."}}},
|
|
378
|
+
get_withdraw_routes: {d:"Returns vesting withdrawal routes for a given account.",p:{account:{c:"Account Name",d:"The account name whose withdrawal routes to retrieve."},withdrawRouteType:{c:"Route Type",d:"Filter direction: 'incoming', 'outgoing', or 'all'."}}},
|
|
379
|
+
get_transaction_hex: {d:"Returns a hexadecimal dump of the serialized binary form of a transaction.",p:{trx:{c:"Transaction",d:"The signed transaction object to serialize.",t:"object"}}},
|
|
380
|
+
get_required_signatures: {d:"Returns the minimal subset of public keys that should add signatures to authorize the transaction.",p:{trx:{c:"Transaction",d:"The signed transaction to analyze.",t:"object"},availableKeys:{c:"Available Keys",d:"Array of public keys that the caller can sign with.",t:"array"}}},
|
|
381
|
+
get_potential_signatures: {d:"Returns the set of all public keys that could possibly sign for a given transaction.",p:{trx:{c:"Transaction",d:"The signed transaction to analyze.",t:"object"}}},
|
|
382
|
+
verify_authority: {d:"Verifies that a transaction has all of the required signatures.",p:{trx:{c:"Transaction",d:"The signed transaction to verify.",t:"object"}}},
|
|
383
|
+
verify_account_authority: {d:"Verifies that a set of public keys has sufficient authority for an account.",p:{name:{c:"Account Name",d:"The account name to check authority for."},signers:{c:"Signer Keys",d:"Array of public keys to verify against the account's authority.",t:"array"}}},
|
|
384
|
+
get_accounts: {d:"Returns full account objects for a list of account names, including balances, vesting, authority, and validator votes.",p:{accountNames:{c:"Account Names",d:"Array of account names to look up.",t:"array"}}},
|
|
385
|
+
lookup_account_names: {d:"Looks up accounts by their names. Returns null for accounts that do not exist.",p:{accountNames:{c:"Account Names",d:"Array of account names to look up.",t:"array"}}},
|
|
386
|
+
lookup_accounts: {d:"Looks up account names starting from a lower bound in alphabetical order.",p:{lowerBoundName:{c:"Lower Bound Name",d:"Lower bound of the first account name."},limit:{c:"Limit",d:"Maximum number of results (max 1000)."}}},
|
|
387
|
+
get_database_info: {d:"Returns database shared memory usage information including total size, free size, and per-index record counts."},
|
|
388
|
+
get_vesting_delegations: {d:"Returns vesting delegation objects for a given account with pagination.",p:{account:{c:"Account Name",d:"The delegator or delegatee account name."},from:{c:"From",d:"Account name to start from for pagination."},limit:{c:"Limit",d:"Maximum number of results (default 100, max 1000)."},type:{c:"Delegation Type",d:"Filter: 'delegated' (sent) or 'received'."}}},
|
|
389
|
+
get_expiring_vesting_delegations: {d:"Returns expiring vesting delegation objects for a given account.",p:{account:{c:"Account Name",d:"The delegator account name."},from:{c:"From Date",d:"Start date/time for expiration lookup (ISO timestamp)."},limit:{c:"Limit",d:"Maximum number of results (default 100, max 1000)."}}},
|
|
390
|
+
get_proposed_transactions: {d:"Returns proposed transactions (proposals) associated with a given account.",p:{account:{c:"Account Name",d:"The account name whose proposals to retrieve."},from:{c:"From Offset",d:"Offset for pagination (number of results to skip)."},limit:{c:"Limit",d:"Maximum number of proposals to return (max 100)."}}},
|
|
391
|
+
get_accounts_on_sale: {d:"Returns accounts currently on sale (direct sale, not auction).",p:{from:{c:"From Offset",d:"Number of results to skip for pagination."},limit:{c:"Limit",d:"Maximum number of results (max 1000)."}}},
|
|
392
|
+
get_accounts_on_auction: {d:"Returns accounts currently on auction (no target buyer set).",p:{from:{c:"From Offset",d:"Number of results to skip for pagination."},limit:{c:"Limit",d:"Maximum number of results (max 1000)."}}},
|
|
393
|
+
get_subaccounts_on_sale: {d:"Returns subaccounts currently on sale.",p:{from:{c:"From Offset",d:"Number of results to skip for pagination."},limit:{c:"Limit",d:"Maximum number of results (max 1000)."}}}
|
|
394
|
+
},
|
|
395
|
+
account_by_key: {
|
|
396
|
+
get_key_references: {d:"Returns all account names that reference the given public keys in their authority.",p:{account_name_type:{c:"Public Keys",d:"Array of public keys to look up.",t:"array"}}}
|
|
397
|
+
},
|
|
398
|
+
network_broadcast_api: {
|
|
399
|
+
broadcast_transaction: {d:"Broadcasts a signed transaction to the network. Accepted into the pending pool and propagated to P2P peers.",p:{trx:{c:"Transaction",d:"The signed transaction to broadcast.",t:"object"}}},
|
|
400
|
+
broadcast_transaction_with_callback: {d:"Broadcasts a signed transaction with a confirmation callback.",p:{confirmationCallback:{c:"Callback",d:"Confirmation callback function."},trx:{c:"Transaction",d:"The signed transaction to broadcast.",t:"object"}}},
|
|
401
|
+
broadcast_transaction_synchronous: {d:"Broadcasts a signed transaction and waits for confirmation. Returns transaction ID, block number, and index.",p:{trx:{c:"Transaction",d:"The signed transaction to broadcast.",t:"object"}}},
|
|
402
|
+
broadcast_block: {d:"Broadcasts a signed block to the network. Typically used by validators.",p:{block:{c:"Block",d:"The signed block to broadcast.",t:"object"}}}
|
|
403
|
+
},
|
|
404
|
+
committee_api: {
|
|
405
|
+
get_committee_request: {d:"Returns a committee request by its ID, optionally including votes.",p:{request_id:{c:"Request ID",d:"The numeric ID of the committee request."},votes_count:{c:"Votes Count",d:"Number of votes to include. 0=no votes, -1=all votes."}}},
|
|
406
|
+
get_committee_request_votes: {d:"Returns all votes for a specific committee request.",p:{request_id:{c:"Request ID",d:"The numeric ID of the committee request whose votes to retrieve."}}},
|
|
407
|
+
get_committee_requests_list: {d:"Returns a list of committee request IDs filtered by status.",p:{status:{c:"Status",d:"The status code to filter by (0=pending, 1=approved, etc.)."}}}
|
|
408
|
+
},
|
|
409
|
+
invite_api: {
|
|
410
|
+
get_invites_list: {d:"Returns a list of invite IDs filtered by status.",p:{status:{c:"Status",d:"The status code to filter invites by."}}},
|
|
411
|
+
get_invite_by_id: {d:"Returns an invite object by its database ID.",p:{id:{c:"Invite ID",d:"The database ID of the invite to retrieve."}}},
|
|
412
|
+
get_invite_by_key: {d:"Returns an invite object by its public key.",p:{invite_key:{c:"Invite Key",d:"The public key associated with the invite."}}}
|
|
413
|
+
},
|
|
414
|
+
paid_subscription_api: {
|
|
415
|
+
get_paid_subscriptions: {d:"Returns a paginated list of all paid subscription objects.",p:{from:{c:"From Offset",d:"Number of results to skip for pagination."},limit:{c:"Limit",d:"Maximum number of results (max 1000)."}}},
|
|
416
|
+
get_paid_subscription_options: {d:"Returns the paid subscription settings for a given account (creator).",p:{account:{c:"Account Name",d:"The account name of the subscription creator."}}},
|
|
417
|
+
get_paid_subscription_status: {d:"Returns the subscription status of a specific subscriber for a given creator.",p:{subscriber:{c:"Subscriber",d:"The account name of the subscriber."},account:{c:"Creator Account",d:"The account name of the subscription creator."}}},
|
|
418
|
+
get_active_paid_subscriptions: {d:"Returns creator accounts that a given subscriber has active subscriptions to.",p:{subscriber:{c:"Subscriber",d:"The account name of the subscriber."}}},
|
|
419
|
+
get_inactive_paid_subscriptions: {d:"Returns creator accounts that a given subscriber has inactive subscriptions to.",p:{subscriber:{c:"Subscriber",d:"The account name of the subscriber."}}}
|
|
420
|
+
},
|
|
421
|
+
custom_protocol_api: {
|
|
422
|
+
get_account: {d:"Returns an account object enriched with custom protocol sequence data.",p:{account:{c:"Account Name",d:"The account name to look up."},custom_protocol_id:{c:"Custom Protocol ID",d:"The custom protocol ID string. Empty string to skip custom protocol lookup."}}}
|
|
423
|
+
},
|
|
424
|
+
auth_util: {
|
|
425
|
+
check_authority_signature: {d:"Verifies that signatures are valid for the given account's authority at a specified level.",p:{account_name:{c:"Account Name",d:"The account name whose authority to check."},level:{c:"Authority Level",d:"Authority level: 'master', 'active', or 'regular'. Empty defaults to 'active'."},signatures:{c:"Signatures",d:"Array of signatures to verify.",t:"array"}}}
|
|
426
|
+
},
|
|
427
|
+
block_info: {
|
|
428
|
+
get_block_info: {d:"Returns block metadata for a range of blocks starting from start_block_num.",p:{start_block_num:{c:"Start Block Number",d:"The first block number to return info for."},count:{c:"Count",d:"Number of blocks to return info for (max 10000)."}}},
|
|
429
|
+
get_blocks_with_info: {d:"Returns full signed blocks with attached metadata for a range. Limits total response to 8 MB.",p:{start_block_num:{c:"Start Block Number",d:"The first block number to return."},count:{c:"Count",d:"Maximum number of blocks (max 10000). Response capped at 8 MB."}}}
|
|
430
|
+
},
|
|
431
|
+
raw_block: {
|
|
432
|
+
get_raw_block: {d:"Returns a raw block by block number, including the base64-encoded serialized binary.",p:{block_num:{c:"Block Number",d:"Height of the block to retrieve in raw form."}}}
|
|
433
|
+
}
|
|
434
|
+
};
|
|
435
|
+
function getMethodSpec(plugin, method) {
|
|
436
|
+
return SPEC[plugin] && SPEC[plugin][method] || null;
|
|
437
|
+
}
|
|
438
|
+
function getParamSpec(plugin, method, paramName) {
|
|
439
|
+
const ms = getMethodSpec(plugin, method);
|
|
440
|
+
return ms && ms.p && ms.p[paramName] || null;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// ─── Rendering ───────────────────────────────────────────────────────────────
|
|
444
|
+
function esc(str) {
|
|
445
|
+
return str.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Params that expect JSON arrays — combine hardcoded list with spec type data
|
|
449
|
+
const ARRAY_PARAM_NAMES = ['accountNames','validatorIds','availableKeys','signers','signatures','account_name_type'];
|
|
450
|
+
function isArrayParam(name, plugin, method) {
|
|
451
|
+
if (ARRAY_PARAM_NAMES.indexOf(name) !== -1) return true;
|
|
452
|
+
const ps = (plugin && method) ? getParamSpec(plugin, method, name) : null;
|
|
453
|
+
return ps && ps.t === 'array';
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function renderSidebar(activePlugin, activeMethod) {
|
|
457
|
+
let html = '<div class="sidebar-section-title">Popular</div>';
|
|
458
|
+
POPULAR.forEach(p => {
|
|
459
|
+
const mActive = (p.api === activePlugin && p.method === activeMethod) ? ' active' : '';
|
|
460
|
+
const presetQuery = p.preset ? '?' + Object.entries(p.preset).map(([k,v]) => encodeURIComponent(k)+'='+encodeURIComponent(v)).join('&') : '';
|
|
461
|
+
html += '<a class="sidebar-item sidebar-method' + mActive + '" '
|
|
462
|
+
+ 'href="#/' + esc(p.api) + '/' + esc(p.method) + presetQuery + '">' + esc(p.method) + '</a>';
|
|
463
|
+
});
|
|
464
|
+
html += '<div class="sidebar-section-title">Plugins</div>';
|
|
465
|
+
Object.keys(plugins).forEach(pName => {
|
|
466
|
+
const isPluginActive = pName === activePlugin;
|
|
467
|
+
html += '<a class="sidebar-item sidebar-plugin' + (isPluginActive && !activeMethod ? ' active' : '') + '" '
|
|
468
|
+
+ 'href="#/' + esc(pName) + '">' + esc(pName) + '</a>';
|
|
469
|
+
if (isPluginActive) {
|
|
470
|
+
plugins[pName].forEach(m => {
|
|
471
|
+
const mActive = m.method === activeMethod ? ' active' : '';
|
|
472
|
+
html += '<a class="sidebar-item sidebar-method' + mActive + '" '
|
|
473
|
+
+ 'href="#/' + esc(pName) + '/' + esc(m.method) + '">' + esc(m.method) + '</a>';
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
$sidebar.innerHTML = html;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function renderWelcome() {
|
|
481
|
+
$contentMain.innerHTML =
|
|
482
|
+
'<div class="welcome-title">VIZ API Explorer</div>'
|
|
483
|
+
+ '<div class="welcome-sub">Select a plugin from the sidebar to browse available methods.<br>'
|
|
484
|
+
+ 'Click a method to view its parameters and execute it against the selected node.</div>';
|
|
485
|
+
$responseArea.innerHTML = '';
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function renderPluginPage(pluginName) {
|
|
489
|
+
const list = plugins[pluginName];
|
|
490
|
+
if (!list) { renderNotFound(); return; }
|
|
491
|
+
let html = '<div class="page-title">' + esc(pluginName) + '</div>'
|
|
492
|
+
+ '<div class="welcome-sub">' + list.length + ' method' + (list.length !== 1 ? 's' : '') + ' available</div>'
|
|
493
|
+
+ '<div style="margin-top:18px">';
|
|
494
|
+
list.forEach(m => {
|
|
495
|
+
const params = m.params ? m.params.join(', ') : 'no params';
|
|
496
|
+
const ms = getMethodSpec(pluginName, m.method);
|
|
497
|
+
const desc = ms ? ms.d : '';
|
|
498
|
+
html += '<a class="sidebar-item sidebar-method" style="padding-left:8px;display:block;color:var(--fg2)" '
|
|
499
|
+
+ 'href="#/' + esc(pluginName) + '/' + esc(m.method) + '">'
|
|
500
|
+
+ '<strong style="color:var(--fg);font-family:var(--mono)">' + esc(m.method) + '</strong>'
|
|
501
|
+
+ '<span style="margin-left:10px;font-size:.8rem">(' + esc(params) + ')</span>'
|
|
502
|
+
+ (desc ? '<div class="method-list-desc">' + esc(desc) + '</div>' : '')
|
|
503
|
+
+ '</a>';
|
|
504
|
+
});
|
|
505
|
+
html += '</div>';
|
|
506
|
+
$contentMain.innerHTML = html;
|
|
507
|
+
$responseArea.innerHTML = '';
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function renderMethodPage(pluginName, methodDef, prefillArgs) {
|
|
511
|
+
const params = methodDef.params || [];
|
|
512
|
+
const ms = getMethodSpec(pluginName, methodDef.method);
|
|
513
|
+
let html = '<span class="plugin-badge">' + esc(pluginName) + '</span>'
|
|
514
|
+
+ '<div class="page-title">' + esc(methodDef.method) + '</div>'
|
|
515
|
+
+ (ms && ms.d ? '<div class="method-desc">' + esc(ms.d) + '</div>' : '')
|
|
516
|
+
+ '<div class="method-form"><form id="method-form" autocomplete="off">';
|
|
517
|
+
if (params.length === 0) {
|
|
518
|
+
html += '<div class="welcome-sub">This method takes no parameters.</div>';
|
|
519
|
+
} else {
|
|
520
|
+
params.forEach(p => {
|
|
521
|
+
const ps = getParamSpec(pluginName, methodDef.method, p);
|
|
522
|
+
const label = ps ? ps.c : p;
|
|
523
|
+
const hint = ps ? ps.d : '';
|
|
524
|
+
if (isArrayParam(p, pluginName, methodDef.method)) {
|
|
525
|
+
// Parse prefilled value as array or split comma-separated
|
|
526
|
+
let vals = [];
|
|
527
|
+
if (prefillArgs[p]) {
|
|
528
|
+
try {
|
|
529
|
+
const parsed = JSON.parse(prefillArgs[p]);
|
|
530
|
+
if (Array.isArray(parsed)) vals = parsed.map(String);
|
|
531
|
+
else vals = [String(parsed)];
|
|
532
|
+
} catch(e) {
|
|
533
|
+
vals = prefillArgs[p].split(',').map(function(s) { return s.trim(); });
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
if (vals.length === 0) vals = [''];
|
|
537
|
+
html += '<div class="field-group">'
|
|
538
|
+
+ '<label class="field-label">' + esc(label) + '<span class="array-badge">[ ]</span></label>'
|
|
539
|
+
+ (hint ? '<div class="field-hint">' + esc(hint) + '</div>' : '')
|
|
540
|
+
+ '<div class="multi-input-wrap" data-param="' + esc(p) + '" data-caption="' + esc(label) + '">';
|
|
541
|
+
vals.forEach(function(v, i) {
|
|
542
|
+
html += renderMultiRow(p, label, i, v, vals.length);
|
|
543
|
+
});
|
|
544
|
+
html += '</div></div>';
|
|
545
|
+
} else {
|
|
546
|
+
const val = prefillArgs[p] || '';
|
|
547
|
+
html += '<div class="field-group">'
|
|
548
|
+
+ '<label class="field-label" for="arg-' + esc(p) + '">' + esc(label) + '</label>'
|
|
549
|
+
+ (hint ? '<div class="field-hint">' + esc(hint) + '</div>' : '')
|
|
550
|
+
+ '<input class="field-input" id="arg-' + esc(p) + '" name="' + esc(p) + '" '
|
|
551
|
+
+ 'placeholder="' + esc(label) + '" value="' + esc(val) + '" />'
|
|
552
|
+
+ '</div>';
|
|
553
|
+
}
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
html += '<button type="submit" id="execute-btn">Execute</button>'
|
|
557
|
+
+ '</form></div>';
|
|
558
|
+
$contentMain.innerHTML = html;
|
|
559
|
+
$responseArea.innerHTML = '';
|
|
560
|
+
|
|
561
|
+
// Bind multi-input events
|
|
562
|
+
document.querySelectorAll('.multi-input-wrap').forEach(function(wrap) {
|
|
563
|
+
bindMultiInputEvents(wrap);
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
document.getElementById('method-form').addEventListener('submit', function(e) {
|
|
567
|
+
e.preventDefault();
|
|
568
|
+
executeMethod(pluginName, methodDef);
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function renderMultiRow(paramName, caption, index, value, total) {
|
|
573
|
+
const removeBtn = total > 1
|
|
574
|
+
? '<button type="button" class="mi-remove" title="Remove">×</button>'
|
|
575
|
+
: '<button type="button" class="mi-remove" style="visibility:hidden">×</button>';
|
|
576
|
+
return '<div class="multi-input-row">'
|
|
577
|
+
+ '<input class="field-input mi-input" data-param="' + esc(paramName) + '" '
|
|
578
|
+
+ 'placeholder="' + esc(caption) + ' [' + index + ']" value="' + esc(value) + '" />'
|
|
579
|
+
+ removeBtn
|
|
580
|
+
+ '</div>';
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function bindMultiInputEvents(wrap) {
|
|
584
|
+
// Track whether the last input already had content (to avoid adding a row per keystroke)
|
|
585
|
+
let _lastInputHadValue = false;
|
|
586
|
+
|
|
587
|
+
wrap.addEventListener('focusin', function(e) {
|
|
588
|
+
// Reset tracking when focus enters any input in this wrap
|
|
589
|
+
if (e.target.classList.contains('mi-input')) {
|
|
590
|
+
const rows = wrap.querySelectorAll('.multi-input-row');
|
|
591
|
+
const lastRow = rows[rows.length - 1];
|
|
592
|
+
const lastInput = lastRow.querySelector('.mi-input');
|
|
593
|
+
_lastInputHadValue = (e.target === lastInput && lastInput.value !== '');
|
|
594
|
+
}
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
wrap.addEventListener('input', function(e) {
|
|
598
|
+
if (!e.target.classList.contains('mi-input')) return;
|
|
599
|
+
const rows = wrap.querySelectorAll('.multi-input-row');
|
|
600
|
+
const lastRow = rows[rows.length - 1];
|
|
601
|
+
const lastInput = lastRow.querySelector('.mi-input');
|
|
602
|
+
// Auto-add row only when the last input goes from empty → non-empty
|
|
603
|
+
if (e.target === lastInput && !_lastInputHadValue && lastInput.value !== '') {
|
|
604
|
+
_lastInputHadValue = true;
|
|
605
|
+
addMultiRow(wrap);
|
|
606
|
+
// _lastInputHadValue resets on next focusin to the new last input
|
|
607
|
+
}
|
|
608
|
+
});
|
|
609
|
+
wrap.addEventListener('click', function(e) {
|
|
610
|
+
if (!e.target.classList.contains('mi-remove')) return;
|
|
611
|
+
const row = e.target.closest('.multi-input-row');
|
|
612
|
+
const rows = wrap.querySelectorAll('.multi-input-row');
|
|
613
|
+
if (rows.length > 1) {
|
|
614
|
+
row.remove();
|
|
615
|
+
// Update visibility of remove buttons
|
|
616
|
+
updateRemoveButtons(wrap);
|
|
617
|
+
updatePlaceholders(wrap);
|
|
618
|
+
}
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function addMultiRow(wrap) {
|
|
623
|
+
const paramName = wrap.getAttribute('data-param');
|
|
624
|
+
const caption = wrap.getAttribute('data-caption') || paramName;
|
|
625
|
+
const rows = wrap.querySelectorAll('.multi-input-row');
|
|
626
|
+
const idx = rows.length;
|
|
627
|
+
const div = document.createElement('div');
|
|
628
|
+
div.innerHTML = renderMultiRow(paramName, caption, idx, '', idx + 1);
|
|
629
|
+
wrap.appendChild(div.firstElementChild);
|
|
630
|
+
updateRemoveButtons(wrap);
|
|
631
|
+
// Focus new input
|
|
632
|
+
const newInput = wrap.querySelector('.multi-input-row:last-child .mi-input');
|
|
633
|
+
if (newInput) newInput.focus();
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function updateRemoveButtons(wrap) {
|
|
637
|
+
const rows = wrap.querySelectorAll('.multi-input-row');
|
|
638
|
+
rows.forEach(function(row, i) {
|
|
639
|
+
const btn = row.querySelector('.mi-remove');
|
|
640
|
+
btn.style.visibility = rows.length > 1 ? 'visible' : 'hidden';
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function updatePlaceholders(wrap) {
|
|
645
|
+
const paramName = wrap.getAttribute('data-param');
|
|
646
|
+
const caption = wrap.getAttribute('data-caption') || paramName;
|
|
647
|
+
const rows = wrap.querySelectorAll('.multi-input-row');
|
|
648
|
+
rows.forEach(function(row, i) {
|
|
649
|
+
const input = row.querySelector('.mi-input');
|
|
650
|
+
input.placeholder = caption + ' [' + i + ']';
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function renderNotFound() {
|
|
655
|
+
$contentMain.innerHTML = '<div class="welcome-title">Not found</div>'
|
|
656
|
+
+ '<div class="welcome-sub">The requested plugin or method does not exist.</div>';
|
|
657
|
+
$responseArea.innerHTML = '';
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// ─── JSON syntax highlighting ────────────────────────────────────────────────
|
|
661
|
+
function syntaxHighlight(obj) {
|
|
662
|
+
const json = JSON.stringify(obj, null, 2);
|
|
663
|
+
// Tokenize and colorize
|
|
664
|
+
return json.replace(
|
|
665
|
+
/("(?:\\.|[^"\\])*")\s*(:)?|(\b\d+\.?\d*(?:[eE][+-]?\d+)?\b)|(\btrue\b|\bfalse\b)|(\bnull\b)|([{}\[\],])/g,
|
|
666
|
+
function (match, str, colon, num, bool, nul, brace) {
|
|
667
|
+
if (str) {
|
|
668
|
+
if (colon) return '<span class="json-key">' + esc(str) + '</span>:';
|
|
669
|
+
return '<span class="json-string">' + esc(str) + '</span>';
|
|
670
|
+
}
|
|
671
|
+
if (num) return '<span class="json-number">' + esc(num) + '</span>';
|
|
672
|
+
if (bool) return '<span class="json-bool">' + esc(bool) + '</span>';
|
|
673
|
+
if (nul) return '<span class="json-null">' + esc(nul) + '</span>';
|
|
674
|
+
if (brace) return '<span class="json-brace">' + esc(brace) + '</span>';
|
|
675
|
+
return esc(match);
|
|
676
|
+
}
|
|
677
|
+
);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function renderRequest(requestData) {
|
|
681
|
+
return '<div class="response-label request-label">Request</div>'
|
|
682
|
+
+ '<div class="response-box request-box">' + esc(JSON.stringify(requestData)) + '</div>';
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function renderResponse(data, elapsed, requestData) {
|
|
686
|
+
$responseArea.innerHTML = (requestData ? renderRequest(requestData) : '')
|
|
687
|
+
+ '<div class="response-label">Response' + (elapsed != null ? ' (' + elapsed + ' ms)' : '') + '</div>'
|
|
688
|
+
+ '<div class="response-box">' + syntaxHighlight(data) + '</div>';
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function renderError(err, requestData) {
|
|
692
|
+
const msg = err && err.message ? err.message : String(err);
|
|
693
|
+
const detail = err && err.payload ? '\n\n' + JSON.stringify(err.payload, null, 2) : '';
|
|
694
|
+
$responseArea.innerHTML = (requestData ? renderRequest(requestData) : '')
|
|
695
|
+
+ '<div class="response-label" style="color:var(--red)">Error</div>'
|
|
696
|
+
+ '<div class="response-box response-error">' + esc(msg) + (detail ? '\n' + esc(detail) : '') + '</div>';
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function renderLoading(requestData) {
|
|
700
|
+
$responseArea.innerHTML = (requestData ? renderRequest(requestData) : '')
|
|
701
|
+
+ '<div class="response-label">Response</div>'
|
|
702
|
+
+ '<div class="response-box response-loading">Loading...</div>';
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// ─── Execution ───────────────────────────────────────────────────────────────
|
|
706
|
+
function parseArgValue(raw) {
|
|
707
|
+
// Try to parse as JSON (handles arrays, objects, numbers, booleans)
|
|
708
|
+
try {
|
|
709
|
+
const parsed = JSON.parse(raw);
|
|
710
|
+
return parsed;
|
|
711
|
+
} catch(e) {
|
|
712
|
+
return raw; // fall back to plain string
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function executeMethod(pluginName, methodDef) {
|
|
717
|
+
const params = methodDef.params || [];
|
|
718
|
+
const argVals = params.map(function(p) {
|
|
719
|
+
if (isArrayParam(p)) {
|
|
720
|
+
// Collect all non-empty multi-input values into an array
|
|
721
|
+
const wrap = document.querySelector('.multi-input-wrap[data-param="' + p + '"]');
|
|
722
|
+
if (!wrap) return [];
|
|
723
|
+
const inputs = wrap.querySelectorAll('.mi-input');
|
|
724
|
+
const arr = [];
|
|
725
|
+
inputs.forEach(function(inp) {
|
|
726
|
+
if (inp.value.trim() !== '') arr.push(inp.value.trim());
|
|
727
|
+
});
|
|
728
|
+
return arr;
|
|
729
|
+
}
|
|
730
|
+
const el = document.getElementById('arg-' + p);
|
|
731
|
+
return el ? parseArgValue(el.value) : null;
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
// Update hash with current arg values
|
|
735
|
+
const argMap = {};
|
|
736
|
+
params.forEach(function(p, i) {
|
|
737
|
+
if (isArrayParam(p)) {
|
|
738
|
+
if (Array.isArray(argVals[i]) && argVals[i].length > 0) {
|
|
739
|
+
argMap[p] = JSON.stringify(argVals[i]);
|
|
740
|
+
}
|
|
741
|
+
} else {
|
|
742
|
+
const el = document.getElementById('arg-' + p);
|
|
743
|
+
if (el && el.value !== '') argMap[p] = el.value;
|
|
744
|
+
}
|
|
745
|
+
});
|
|
746
|
+
// Update hash silently (replaceState to avoid double trigger)
|
|
747
|
+
const newHash = buildHash(pluginName, methodDef.method, argMap);
|
|
748
|
+
history.replaceState(null, '', newHash);
|
|
749
|
+
|
|
750
|
+
// Configure viz library
|
|
751
|
+
const node = getSelectedNode();
|
|
752
|
+
if (typeof viz !== 'undefined') {
|
|
753
|
+
viz.config.set('websocket', node);
|
|
754
|
+
// Stop any existing transport so it reconnects to new node
|
|
755
|
+
if (viz.api && viz.api.transport) {
|
|
756
|
+
try { viz.api.stop(); } catch(e) {}
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// Build JSON-RPC request object (mirrors what the HTTP transport sends)
|
|
761
|
+
const requestData = {
|
|
762
|
+
jsonrpc: '2.0',
|
|
763
|
+
method: 'call',
|
|
764
|
+
params: [pluginName, methodDef.method, argVals],
|
|
765
|
+
id: 1
|
|
766
|
+
};
|
|
767
|
+
renderLoading(requestData);
|
|
768
|
+
|
|
769
|
+
const startTime = Date.now();
|
|
770
|
+
viz.api.send(pluginName, { method: methodDef.method, params: argVals }, function(err, result) {
|
|
771
|
+
const elapsed = Date.now() - startTime;
|
|
772
|
+
if (err) {
|
|
773
|
+
renderError(err, requestData);
|
|
774
|
+
} else {
|
|
775
|
+
renderResponse(result, elapsed, requestData);
|
|
776
|
+
}
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// ─── Apply hash to UI ────────────────────────────────────────────────────────
|
|
781
|
+
function applyHash() {
|
|
782
|
+
const { plugin, method, args, apiUrl } = parseHash();
|
|
783
|
+
|
|
784
|
+
// If hash has an api param, switch to that node
|
|
785
|
+
if (apiUrl) {
|
|
786
|
+
const all = getAllNodes();
|
|
787
|
+
if (all.includes(apiUrl)) {
|
|
788
|
+
setSelectedNode(apiUrl);
|
|
789
|
+
} else {
|
|
790
|
+
// Custom node from hash — add it if not already present
|
|
791
|
+
addCustomNode(apiUrl);
|
|
792
|
+
setSelectedNode(apiUrl.replace(/\/+$/, ''));
|
|
793
|
+
}
|
|
794
|
+
renderNodeSelector();
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
if (!plugin) {
|
|
798
|
+
renderSidebar(null, null);
|
|
799
|
+
renderWelcome();
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
if (!plugins[plugin]) {
|
|
804
|
+
renderSidebar(plugin, null);
|
|
805
|
+
renderNotFound();
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
if (!method) {
|
|
810
|
+
renderSidebar(plugin, null);
|
|
811
|
+
renderPluginPage(plugin);
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
const methodDef = plugins[plugin].find(m => m.method === method);
|
|
816
|
+
if (!methodDef) {
|
|
817
|
+
renderSidebar(plugin, null);
|
|
818
|
+
renderNotFound();
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
renderSidebar(plugin, method);
|
|
823
|
+
renderMethodPage(plugin, methodDef, args);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// ─── Init ────────────────────────────────────────────────────────────────────
|
|
827
|
+
// Theme
|
|
828
|
+
const LS_THEME = 'viz_theme';
|
|
829
|
+
function getStoredTheme() { return localStorage.getItem(LS_THEME) || 'light'; }
|
|
830
|
+
function applyTheme(theme) {
|
|
831
|
+
if (theme === 'dark') document.documentElement.setAttribute('data-theme', 'dark');
|
|
832
|
+
else document.documentElement.removeAttribute('data-theme');
|
|
833
|
+
localStorage.setItem(LS_THEME, theme);
|
|
834
|
+
document.querySelectorAll('#theme-toggle a').forEach(a => {
|
|
835
|
+
a.classList.toggle('active', a.getAttribute('data-theme') === theme);
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
applyTheme(getStoredTheme());
|
|
839
|
+
document.querySelectorAll('#theme-toggle a').forEach(a => {
|
|
840
|
+
a.addEventListener('click', e => {
|
|
841
|
+
e.preventDefault();
|
|
842
|
+
applyTheme(a.getAttribute('data-theme'));
|
|
843
|
+
});
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
renderNodeSelector();
|
|
847
|
+
applyHash();
|
|
848
|
+
|
|
849
|
+
// ─── Mobile UI toggles ───────────────────────────────────────────────────────
|
|
850
|
+
const $menuToggle = document.getElementById('menu-toggle');
|
|
851
|
+
const $serverToggle = document.getElementById('server-toggle');
|
|
852
|
+
const $sidebarEl = document.getElementById('sidebar');
|
|
853
|
+
const $backdrop = document.getElementById('sidebar-backdrop');
|
|
854
|
+
const $headerNodeEl = document.getElementById('header-node');
|
|
855
|
+
|
|
856
|
+
function isMobile() { return window.innerWidth <= 768; }
|
|
857
|
+
|
|
858
|
+
function closeMobileSidebar() {
|
|
859
|
+
$sidebarEl.classList.remove('open');
|
|
860
|
+
$backdrop.classList.remove('open');
|
|
861
|
+
document.body.classList.remove('sidebar-open');
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
$menuToggle.addEventListener('click', function() {
|
|
865
|
+
const isOpen = $sidebarEl.classList.toggle('open');
|
|
866
|
+
$backdrop.classList.toggle('open', isOpen);
|
|
867
|
+
document.body.classList.toggle('sidebar-open', isOpen);
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
$serverToggle.addEventListener('click', function() {
|
|
871
|
+
$headerNodeEl.classList.toggle('open');
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
$backdrop.addEventListener('click', closeMobileSidebar);
|
|
875
|
+
|
|
876
|
+
// Close sidebar when a method link is tapped on mobile (not plugin headers)
|
|
877
|
+
$sidebar.addEventListener('click', function(e) {
|
|
878
|
+
const item = e.target.closest('.sidebar-item');
|
|
879
|
+
if (item && !item.classList.contains('sidebar-plugin') && isMobile()) {
|
|
880
|
+
closeMobileSidebar();
|
|
881
|
+
}
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
// Clean up mobile state when resizing to desktop
|
|
885
|
+
window.addEventListener('resize', function() {
|
|
886
|
+
if (!isMobile()) {
|
|
887
|
+
closeMobileSidebar();
|
|
888
|
+
$headerNodeEl.classList.remove('open');
|
|
889
|
+
}
|
|
890
|
+
});
|