@relay-federation/bridge 0.1.2 → 0.3.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.
@@ -0,0 +1,2212 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Federation Mesh Explorer</title>
7
+ <style>
8
+ :root {
9
+ --bg-base: #121518;
10
+ --bg-glass: rgba(255,255,255,0.06);
11
+ --bg-glass-hover: rgba(255,255,255,0.09);
12
+ --bg-surface: rgba(255,255,255,0.04);
13
+ --bg-input: rgba(0,0,0,0.3);
14
+ --border: rgba(255,255,255,0.08);
15
+ --border-light: rgba(255,255,255,0.12);
16
+ --border-accent: rgba(255,255,255,0.15);
17
+ --text-primary: #eaf0f6;
18
+ --text-secondary: #b8c0cc;
19
+ --text-muted: #6b7280;
20
+ --text-dim: #3d4450;
21
+ --accent-blue: #58a6ff;
22
+ --accent-green: #3fb950;
23
+ --accent-red: #f85149;
24
+ --accent-yellow: #d29922;
25
+ --mono: 'SF Mono', 'Cascadia Code', 'Consolas', monospace;
26
+ --font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
27
+ --glass-blur: blur(16px) saturate(120%);
28
+ --radius: 12px;
29
+ --radius-sm: 8px;
30
+ }
31
+
32
+ * { margin: 0; padding: 0; box-sizing: border-box; }
33
+ body {
34
+ background: var(--bg-base);
35
+ color: var(--text-secondary);
36
+ font-family: var(--font);
37
+ height: 100vh;
38
+ overflow: hidden;
39
+ }
40
+
41
+ /* ── Glassmorphism utility ──────────────────────── */
42
+ .glass {
43
+ background: var(--bg-glass);
44
+ backdrop-filter: var(--glass-blur);
45
+ -webkit-backdrop-filter: var(--glass-blur);
46
+ border: 1px solid var(--border);
47
+ border-radius: var(--radius);
48
+ box-shadow: 0 4px 24px rgba(0,0,0,0.2);
49
+ }
50
+
51
+ /* ── Header ──────────────────────────────────────── */
52
+ .header {
53
+ display: flex;
54
+ align-items: center;
55
+ justify-content: space-between;
56
+ padding: 0 24px;
57
+ height: 52px;
58
+ background: rgba(18,21,24,0.85);
59
+ backdrop-filter: var(--glass-blur);
60
+ border-bottom: 1px solid var(--border);
61
+ position: relative;
62
+ z-index: 10;
63
+ }
64
+ .header-left {
65
+ display: flex;
66
+ align-items: center;
67
+ gap: 16px;
68
+ }
69
+ .header h1 {
70
+ font-size: 15px;
71
+ color: var(--text-primary);
72
+ font-weight: 600;
73
+ letter-spacing: -0.3px;
74
+ white-space: nowrap;
75
+ }
76
+ .header h1 span { color: var(--accent-blue); }
77
+ .mesh-badge {
78
+ display: flex;
79
+ align-items: center;
80
+ gap: 6px;
81
+ padding: 3px 10px;
82
+ border-radius: 20px;
83
+ border: 1px solid var(--border);
84
+ background: var(--bg-glass);
85
+ font-size: 11px;
86
+ color: var(--text-muted);
87
+ }
88
+ .status-dot {
89
+ width: 7px;
90
+ height: 7px;
91
+ border-radius: 50%;
92
+ flex-shrink: 0;
93
+ }
94
+ .status-dot.green { background: var(--accent-green); box-shadow: 0 0 8px rgba(63,185,80,0.5); }
95
+ .status-dot.yellow { background: var(--accent-yellow); box-shadow: 0 0 8px rgba(210,153,34,0.5); }
96
+ .status-dot.red { background: var(--accent-red); box-shadow: 0 0 8px rgba(248,81,73,0.5); }
97
+ .status-dot.pulse { animation: pulse 2s ease-in-out infinite; }
98
+ @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
99
+
100
+ /* ── Header tabs ─────────────────────────────────── */
101
+ .header-tabs {
102
+ display: flex;
103
+ gap: 2px;
104
+ background: var(--bg-surface);
105
+ border-radius: var(--radius-sm);
106
+ padding: 3px;
107
+ border: 1px solid var(--border);
108
+ }
109
+ .header-tabs button {
110
+ padding: 5px 16px;
111
+ border: none;
112
+ background: transparent;
113
+ color: var(--text-muted);
114
+ font-size: 12px;
115
+ font-family: inherit;
116
+ cursor: pointer;
117
+ border-radius: 6px;
118
+ transition: all 0.15s;
119
+ font-weight: 500;
120
+ }
121
+ .header-tabs button.active {
122
+ background: var(--bg-glass);
123
+ color: var(--text-primary);
124
+ box-shadow: 0 1px 4px rgba(0,0,0,0.2);
125
+ }
126
+ .header-tabs button:hover:not(.active) { color: var(--text-secondary); }
127
+
128
+ .header-right {
129
+ display: flex;
130
+ align-items: center;
131
+ gap: 16px;
132
+ }
133
+ .header-stats {
134
+ display: flex;
135
+ gap: 16px;
136
+ font-size: 11px;
137
+ color: var(--text-muted);
138
+ }
139
+ .header-stats .val { color: var(--text-secondary); font-weight: 500; }
140
+
141
+ /* ── Main layout ─────────────────────────────────── */
142
+ .main {
143
+ display: flex;
144
+ height: calc(100vh - 52px);
145
+ }
146
+
147
+ /* ── Bridge rail ─────────────────────────────────── */
148
+ .bridge-rail {
149
+ width: 220px;
150
+ background: var(--bg-surface);
151
+ border-right: 1px solid var(--border);
152
+ overflow-y: auto;
153
+ flex-shrink: 0;
154
+ padding: 8px;
155
+ }
156
+ .bridge-rail::-webkit-scrollbar { width: 4px; }
157
+ .bridge-rail::-webkit-scrollbar-track { background: transparent; }
158
+ .bridge-rail::-webkit-scrollbar-thumb { background: var(--border-light); border-radius: 2px; }
159
+ .bridge-rail-header {
160
+ font-size: 10px;
161
+ text-transform: uppercase;
162
+ letter-spacing: 1px;
163
+ color: var(--text-muted);
164
+ padding: 8px 10px 6px;
165
+ font-weight: 500;
166
+ }
167
+ .rail-item {
168
+ display: flex;
169
+ align-items: center;
170
+ gap: 8px;
171
+ padding: 10px 12px;
172
+ border-radius: var(--radius-sm);
173
+ cursor: pointer;
174
+ transition: background 0.15s;
175
+ margin-bottom: 2px;
176
+ }
177
+ .rail-item:hover { background: var(--bg-glass-hover); }
178
+ .rail-item.selected { background: var(--bg-glass); border: 1px solid var(--border-light); }
179
+ .rail-item .rail-name { font-size: 13px; font-weight: 500; color: var(--text-primary); flex: 1; }
180
+ .rail-item .rail-height { font-size: 11px; color: var(--accent-green); font-family: var(--mono); font-variant-numeric: tabular-nums; }
181
+ .rail-item .rail-height.offline { color: var(--accent-red); font-family: var(--font); }
182
+ .rail-item .rail-meta { font-size: 10px; color: var(--text-muted); margin-top: 2px; }
183
+
184
+ /* ── Tab content ─────────────────────────────────── */
185
+ .tab-content {
186
+ flex: 1;
187
+ overflow-y: auto;
188
+ padding: 20px 24px;
189
+ }
190
+ .tab-content::-webkit-scrollbar { width: 6px; }
191
+ .tab-content::-webkit-scrollbar-track { background: transparent; }
192
+ .tab-content::-webkit-scrollbar-thumb { background: var(--border-light); border-radius: 3px; }
193
+
194
+ /* ── Stats hero (overview tab) ───────────────────── */
195
+ .stats-hero {
196
+ display: grid;
197
+ grid-template-columns: repeat(5, 1fr);
198
+ gap: 12px;
199
+ margin-bottom: 20px;
200
+ }
201
+ .stat-card {
202
+ padding: 18px 20px;
203
+ text-align: center;
204
+ }
205
+ .stat-card .stat-value {
206
+ font-size: 26px;
207
+ font-weight: 700;
208
+ color: var(--text-primary);
209
+ font-variant-numeric: tabular-nums;
210
+ line-height: 1.2;
211
+ }
212
+ .stat-card .stat-value.green { color: var(--accent-green); }
213
+ .stat-card .stat-label {
214
+ font-size: 10px;
215
+ color: var(--text-muted);
216
+ text-transform: uppercase;
217
+ letter-spacing: 1.2px;
218
+ margin-top: 4px;
219
+ }
220
+
221
+ /* ── Overview cards grid ─────────────────────────── */
222
+ .cards-grid {
223
+ display: grid;
224
+ grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
225
+ gap: 12px;
226
+ margin-bottom: 20px;
227
+ }
228
+ .detail-card {
229
+ padding: 16px 20px;
230
+ }
231
+ .detail-card h3 {
232
+ color: var(--text-muted);
233
+ font-size: 10px;
234
+ text-transform: uppercase;
235
+ letter-spacing: 1.2px;
236
+ margin-bottom: 10px;
237
+ font-weight: 500;
238
+ }
239
+ .panel-row {
240
+ display: flex;
241
+ justify-content: space-between;
242
+ padding: 4px 0;
243
+ font-size: 13px;
244
+ }
245
+ .panel-row .label { color: var(--text-muted); }
246
+ .panel-row .value { color: var(--text-secondary); font-family: var(--mono); font-size: 12px; }
247
+ .panel-row .value.green { color: var(--accent-green); }
248
+ .panel-row .value.yellow { color: var(--accent-yellow); }
249
+ .panel-row .value.red { color: var(--accent-red); }
250
+
251
+ /* ── Actions grid ────────────────────────────────── */
252
+ .actions-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
253
+
254
+ /* ── Buttons ─────────────────────────────────────── */
255
+ .btn {
256
+ display: inline-flex;
257
+ align-items: center;
258
+ justify-content: center;
259
+ gap: 6px;
260
+ padding: 7px 16px;
261
+ border-radius: 6px;
262
+ border: 1px solid var(--border-light);
263
+ background: var(--bg-glass);
264
+ color: var(--text-secondary);
265
+ font-size: 12px;
266
+ font-family: inherit;
267
+ cursor: pointer;
268
+ transition: all 0.15s;
269
+ }
270
+ .btn:hover { background: var(--bg-glass-hover); border-color: var(--border-accent); }
271
+ .btn.primary { background: rgba(35,134,54,0.8); border-color: rgba(35,134,54,0.6); color: #fff; }
272
+ .btn.primary:hover { background: rgba(46,160,67,0.9); }
273
+ .btn.danger { background: rgba(218,54,51,0.8); border-color: rgba(218,54,51,0.6); color: #fff; }
274
+ .btn.danger:hover { background: rgba(248,81,73,0.9); }
275
+ .btn.sm { padding: 4px 10px; font-size: 11px; }
276
+ .btn.operator-badge { background: rgba(88,166,255,0.12); border-color: rgba(88,166,255,0.3); color: var(--accent-blue); font-size: 11px; padding: 3px 10px; }
277
+ .btn.ghost { background: transparent; border-color: transparent; color: var(--text-muted); padding: 4px 8px; }
278
+ .btn.ghost:hover { color: var(--text-secondary); background: var(--bg-glass); }
279
+
280
+ /* ── Protocol badges (hyper-saturated) ──────────── */
281
+ .proto-badge {
282
+ display: inline-block;
283
+ padding: 2px 6px;
284
+ border-radius: 4px;
285
+ font-size: 9px;
286
+ font-weight: 600;
287
+ font-family: var(--mono);
288
+ letter-spacing: 0.3px;
289
+ vertical-align: middle;
290
+ }
291
+ .proto-badge.p2pkh { background: rgba(88,166,255,0.18); color: #79bfff; }
292
+ .proto-badge.op-return { background: rgba(163,113,247,0.18); color: #bc8cf5; }
293
+ .proto-badge.b { background: rgba(63,185,80,0.18); color: #4ade80; }
294
+ .proto-badge.bcat { background: rgba(57,211,185,0.18); color: #5eebd5; }
295
+ .proto-badge.bcat-part { background: rgba(57,211,185,0.12); color: #5eebd5; }
296
+ .proto-badge.map { background: rgba(210,153,34,0.2); color: #f0c040; }
297
+ .proto-badge.metanet { background: rgba(121,192,255,0.18); color: #93d0ff; }
298
+ .proto-badge.bsv-20 { background: rgba(255,197,61,0.2); color: #ffc53d; }
299
+ .proto-badge.ordinal { background: rgba(248,81,73,0.18); color: #ff6b63; }
300
+ .proto-badge.p2sh { background: rgba(139,148,158,0.15); color: #9ca3af; }
301
+ .proto-badge.multisig { background: rgba(139,148,158,0.15); color: #9ca3af; }
302
+ .proto-badge.unknown { background: rgba(107,114,128,0.15); color: var(--text-muted); }
303
+
304
+ /* ── Mempool tab ─────────────────────────────────── */
305
+ .mempool-tx {
306
+ padding: 12px 16px;
307
+ border-bottom: 1px solid var(--border);
308
+ transition: background 0.15s;
309
+ cursor: pointer;
310
+ }
311
+ .mempool-tx:hover { background: var(--bg-glass-hover); }
312
+ .mempool-tx:last-child { border-bottom: none; }
313
+ .tx-hash-link {
314
+ font-family: var(--mono);
315
+ font-size: 12px;
316
+ color: var(--accent-blue);
317
+ word-break: break-all;
318
+ line-height: 1.4;
319
+ cursor: pointer;
320
+ }
321
+ .tx-hash-link:hover { text-decoration: underline; }
322
+ .tx-summary {
323
+ font-size: 11px;
324
+ color: var(--text-muted);
325
+ margin-top: 3px;
326
+ }
327
+ .tx-output {
328
+ font-family: var(--mono);
329
+ font-size: 11px;
330
+ color: var(--text-dim);
331
+ margin-left: 8px;
332
+ margin-top: 2px;
333
+ }
334
+
335
+ /* ── Tx Explorer tab ─────────────────────────────── */
336
+ .explorer-search {
337
+ display: flex;
338
+ gap: 8px;
339
+ margin-bottom: 16px;
340
+ }
341
+ .explorer-search input {
342
+ flex: 1;
343
+ padding: 10px 14px;
344
+ background: var(--bg-input);
345
+ border: 1px solid var(--border-light);
346
+ border-radius: var(--radius-sm);
347
+ color: var(--text-primary);
348
+ font-family: var(--mono);
349
+ font-size: 13px;
350
+ transition: border-color 0.2s;
351
+ }
352
+ .explorer-search input:focus { outline: none; border-color: var(--accent-blue); }
353
+ .explorer-search input::placeholder { color: var(--text-dim); }
354
+
355
+ .tx-detail-output {
356
+ padding: 10px 14px;
357
+ border-bottom: 1px solid var(--border);
358
+ border-radius: var(--radius-sm);
359
+ margin-bottom: 4px;
360
+ background: var(--bg-surface);
361
+ }
362
+ .tx-detail-output:last-child { border-bottom: none; }
363
+ .tx-detail-row {
364
+ display: flex;
365
+ justify-content: space-between;
366
+ align-items: center;
367
+ }
368
+ .tx-parsed-data {
369
+ font-size: 11px;
370
+ margin-top: 6px;
371
+ padding: 8px 10px;
372
+ background: var(--bg-input);
373
+ border-radius: 6px;
374
+ color: var(--text-muted);
375
+ line-height: 1.6;
376
+ word-break: break-all;
377
+ border: 1px solid var(--border);
378
+ }
379
+ .tx-parsed-data .val { color: var(--text-secondary); }
380
+ .tx-parsed-data .accent { color: #ffc53d; }
381
+
382
+ /* ── Inscription cards (used by Explorer address view) ── */
383
+ .inscription-grid {
384
+ display: grid;
385
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
386
+ gap: 10px;
387
+ }
388
+ .inscription-card {
389
+ padding: 14px 16px;
390
+ overflow: hidden;
391
+ }
392
+ .inscription-card .insc-thumb {
393
+ width: 100%;
394
+ max-height: 240px;
395
+ object-fit: contain;
396
+ border-radius: 6px;
397
+ margin-bottom: 10px;
398
+ background: rgba(0,0,0,0.3);
399
+ cursor: pointer;
400
+ }
401
+ .inscription-card .insc-text {
402
+ width: 100%;
403
+ max-height: 200px;
404
+ overflow: auto;
405
+ padding: 10px;
406
+ margin-bottom: 10px;
407
+ border-radius: 6px;
408
+ background: rgba(0,0,0,0.4);
409
+ color: var(--text-secondary);
410
+ font-family: var(--mono);
411
+ font-size: 11px;
412
+ white-space: pre-wrap;
413
+ word-break: break-all;
414
+ }
415
+ .inscription-card .insc-iframe {
416
+ width: 100%;
417
+ height: 200px;
418
+ border: none;
419
+ border-radius: 6px;
420
+ margin-bottom: 10px;
421
+ background: #fff;
422
+ }
423
+ .inscription-card video,
424
+ .inscription-card audio {
425
+ width: 100%;
426
+ border-radius: 6px;
427
+ margin-bottom: 10px;
428
+ background: rgba(0,0,0,0.3);
429
+ }
430
+ .inscription-card video { max-height: 240px; }
431
+ .inscription-card .insc-txid {
432
+ font-family: var(--mono);
433
+ font-size: 11px;
434
+ color: var(--accent-blue);
435
+ cursor: pointer;
436
+ word-break: break-all;
437
+ }
438
+ .inscription-card .insc-txid:hover { text-decoration: underline; }
439
+ .inscription-card .insc-meta {
440
+ font-size: 11px;
441
+ color: var(--text-muted);
442
+ margin-top: 6px;
443
+ display: flex;
444
+ gap: 10px;
445
+ flex-wrap: wrap;
446
+ }
447
+
448
+ /* ── Inscriptions tab filters ─────────────────────── */
449
+ .inscription-filters {
450
+ display: flex;
451
+ gap: 8px;
452
+ margin-bottom: 16px;
453
+ align-items: center;
454
+ flex-wrap: wrap;
455
+ }
456
+ .inscription-filters select,
457
+ .inscription-filters input {
458
+ padding: 8px 12px;
459
+ background: var(--bg-input);
460
+ border: 1px solid var(--border-light);
461
+ border-radius: 6px;
462
+ color: var(--text-primary);
463
+ font-size: 12px;
464
+ font-family: inherit;
465
+ }
466
+ .inscription-filters select { min-width: 160px; }
467
+ .inscription-filters input { flex: 1; min-width: 200px; font-family: var(--mono); }
468
+ .inscription-filters select:focus,
469
+ .inscription-filters input:focus { outline: none; border-color: var(--accent-blue); }
470
+
471
+ /* ── Apps tab ──────────────────────────────────────── */
472
+ .apps-grid {
473
+ display: grid;
474
+ grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
475
+ gap: 12px;
476
+ }
477
+ .app-card {
478
+ padding: 16px 18px;
479
+ display: flex;
480
+ flex-direction: column;
481
+ gap: 10px;
482
+ }
483
+ .app-card-header {
484
+ display: flex;
485
+ align-items: center;
486
+ gap: 10px;
487
+ }
488
+ .app-health-dot {
489
+ width: 10px;
490
+ height: 10px;
491
+ border-radius: 50%;
492
+ flex-shrink: 0;
493
+ }
494
+ .app-health-dot.online { background: var(--accent-green); box-shadow: 0 0 8px rgba(63,185,80,0.5); }
495
+ .app-health-dot.degraded { background: var(--accent-yellow); box-shadow: 0 0 8px rgba(210,153,34,0.5); }
496
+ .app-health-dot.offline { background: var(--accent-red); box-shadow: 0 0 8px rgba(248,81,73,0.5); }
497
+ .app-health-dot.unknown { background: var(--text-dim); }
498
+ .app-card-header .app-name {
499
+ font-size: 14px;
500
+ font-weight: 600;
501
+ color: var(--text-primary);
502
+ flex: 1;
503
+ }
504
+ .app-card-header .app-name a {
505
+ color: inherit;
506
+ text-decoration: none;
507
+ }
508
+ .app-card-header .app-name a:hover { color: var(--accent-blue); text-decoration: underline; }
509
+ .ssl-badge {
510
+ font-size: 10px;
511
+ font-weight: 500;
512
+ padding: 2px 8px;
513
+ border-radius: 10px;
514
+ }
515
+ .ssl-badge.valid { background: rgba(63,185,80,0.15); color: var(--accent-green); border: 1px solid rgba(63,185,80,0.3); }
516
+ .ssl-badge.expiring { background: rgba(210,153,34,0.15); color: var(--accent-yellow); border: 1px solid rgba(210,153,34,0.3); }
517
+ .ssl-badge.expired { background: rgba(248,81,73,0.15); color: var(--accent-red); border: 1px solid rgba(248,81,73,0.3); }
518
+ .ssl-badge.unknown { background: var(--bg-surface); color: var(--text-dim); border: 1px solid var(--border); }
519
+ .app-urls {
520
+ font-size: 11px;
521
+ color: var(--text-muted);
522
+ font-family: var(--mono);
523
+ line-height: 1.6;
524
+ }
525
+ .app-stat-row {
526
+ display: flex;
527
+ align-items: center;
528
+ gap: 10px;
529
+ font-size: 12px;
530
+ }
531
+ .app-stat-row .label {
532
+ color: var(--text-muted);
533
+ min-width: 70px;
534
+ font-size: 11px;
535
+ }
536
+ .app-stat-row .value {
537
+ color: var(--text-secondary);
538
+ font-weight: 500;
539
+ font-family: var(--mono);
540
+ font-size: 12px;
541
+ }
542
+ .app-bar-bg {
543
+ width: 100%;
544
+ height: 6px;
545
+ background: rgba(0,0,0,0.3);
546
+ border-radius: 3px;
547
+ overflow: hidden;
548
+ margin-bottom: 8px;
549
+ }
550
+ .app-bar-fill {
551
+ height: 100%;
552
+ border-radius: 3px;
553
+ transition: width 0.3s;
554
+ }
555
+ .app-bar-fill.green { background: var(--accent-green); }
556
+ .app-bar-fill.yellow { background: var(--accent-yellow); }
557
+ .app-bar-fill.red { background: var(--accent-red); }
558
+ .app-bar-fill.blue { background: var(--accent-blue); }
559
+ .ep-list {
560
+ font-size: 11px;
561
+ color: var(--text-muted);
562
+ display: flex;
563
+ flex-direction: column;
564
+ gap: 3px;
565
+ }
566
+ .ep-row {
567
+ display: flex;
568
+ justify-content: space-between;
569
+ padding: 2px 0;
570
+ }
571
+ .ep-row .ep-path { font-family: var(--mono); color: var(--text-secondary); }
572
+ .ep-row .ep-count { font-family: var(--mono); color: var(--text-muted); }
573
+ .app-error-box {
574
+ padding: 8px 12px;
575
+ border-radius: 6px;
576
+ background: rgba(248,81,73,0.1);
577
+ border: 1px solid rgba(248,81,73,0.2);
578
+ font-size: 11px;
579
+ color: var(--accent-red);
580
+ font-family: var(--mono);
581
+ word-break: break-all;
582
+ }
583
+
584
+ /* ── Peers ───────────────────────────────────────── */
585
+ .peer-entry {
586
+ padding: 10px 0;
587
+ border-bottom: 1px solid var(--border);
588
+ font-size: 12px;
589
+ }
590
+ .peer-entry:last-child { border-bottom: none; }
591
+ .peer-entry .peer-name { color: var(--accent-blue); font-family: var(--mono); }
592
+ .peer-entry .peer-meta { color: var(--text-dim); margin-top: 3px; font-size: 11px; }
593
+ .peer-dot {
594
+ display: inline-block;
595
+ width: 6px;
596
+ height: 6px;
597
+ border-radius: 50%;
598
+ margin-right: 5px;
599
+ }
600
+ .peer-dot.green { background: var(--accent-green); }
601
+ .peer-dot.red { background: var(--accent-red); }
602
+
603
+ /* ── Score bars ──────────────────────────────────── */
604
+ .score-bars { margin-top: 6px; }
605
+ .score-row {
606
+ display: flex;
607
+ align-items: center;
608
+ gap: 6px;
609
+ font-size: 10px;
610
+ margin-top: 3px;
611
+ }
612
+ .score-row .score-label { color: var(--text-dim); width: 26px; text-align: right; font-family: var(--mono); }
613
+ .score-bar-bg {
614
+ flex: 1;
615
+ height: 4px;
616
+ background: var(--border);
617
+ border-radius: 2px;
618
+ overflow: hidden;
619
+ }
620
+ .score-bar-fill {
621
+ height: 100%;
622
+ border-radius: 2px;
623
+ transition: width 0.8s cubic-bezier(0.4, 0, 0.2, 1);
624
+ }
625
+ .score-bar-fill.green { background: rgba(63,185,80,0.7); }
626
+ .score-bar-fill.yellow { background: rgba(210,153,34,0.7); }
627
+ .score-bar-fill.red { background: rgba(218,54,51,0.7); }
628
+ .score-row .score-val { color: var(--text-muted); width: 30px; font-family: var(--mono); }
629
+
630
+ .error-banner {
631
+ background: rgba(248,81,73,0.1);
632
+ border: 1px solid rgba(248,81,73,0.2);
633
+ border-radius: var(--radius-sm);
634
+ padding: 10px;
635
+ color: var(--accent-red);
636
+ font-size: 12px;
637
+ text-align: center;
638
+ margin-bottom: 12px;
639
+ }
640
+
641
+ /* ── Mini peer map ───────────────────────────────── */
642
+ .mini-map svg { width: 100%; height: 180px; display: block; }
643
+ .mini-map .mm-line { stroke: rgba(88,166,255,0.5); stroke-width: 1.5; stroke-dasharray: 4 3; opacity: 0.5; }
644
+ .mini-map .mm-line.offline { stroke: var(--accent-red); opacity: 0.2; }
645
+ .mini-map .mm-ring { fill: none; stroke-width: 2; }
646
+ .mini-map .mm-bg { fill: var(--bg-base); stroke: var(--border-light); stroke-width: 1; }
647
+ .mini-map .mm-label { fill: var(--text-muted); font-size: 9px; font-family: inherit; text-anchor: middle; }
648
+ .mini-map .mm-label.name { fill: var(--accent-blue); font-size: 10px; font-weight: 600; }
649
+
650
+ /* ── Modal ───────────────────────────────────────── */
651
+ .modal-overlay {
652
+ display: none;
653
+ position: fixed;
654
+ top: 0; left: 0; right: 0; bottom: 0;
655
+ background: rgba(0,0,0,0.6);
656
+ z-index: 100;
657
+ justify-content: center;
658
+ align-items: center;
659
+ backdrop-filter: blur(4px);
660
+ }
661
+ .modal-overlay.active { display: flex; }
662
+ .modal {
663
+ background: rgba(30,35,42,0.95);
664
+ backdrop-filter: var(--glass-blur);
665
+ border: 1px solid var(--border-accent);
666
+ border-radius: var(--radius);
667
+ padding: 24px;
668
+ width: 440px;
669
+ max-height: 80vh;
670
+ overflow-y: auto;
671
+ box-shadow: 0 16px 48px rgba(0,0,0,0.4);
672
+ animation: modalIn 0.15s ease-out;
673
+ }
674
+ @keyframes modalIn { from { transform: scale(0.96); opacity: 0; } to { transform: scale(1); opacity: 1; } }
675
+ .modal h2 { color: var(--text-primary); font-size: 16px; margin-bottom: 16px; font-weight: 600; }
676
+ .modal .form-group { margin-bottom: 14px; }
677
+ .modal label { display: block; color: var(--text-muted); font-size: 12px; margin-bottom: 4px; }
678
+ .modal input, .modal textarea {
679
+ width: 100%;
680
+ padding: 8px 12px;
681
+ background: var(--bg-input);
682
+ border: 1px solid var(--border-light);
683
+ border-radius: 6px;
684
+ color: var(--text-primary);
685
+ font-family: var(--mono);
686
+ font-size: 13px;
687
+ }
688
+ .modal textarea { min-height: 80px; resize: vertical; }
689
+ .modal input:focus, .modal textarea:focus { outline: none; border-color: var(--accent-blue); }
690
+ .modal .modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
691
+ .modal .warning {
692
+ background: rgba(210,153,34,0.1);
693
+ border: 1px solid rgba(210,153,34,0.2);
694
+ border-radius: 6px;
695
+ padding: 10px;
696
+ color: var(--accent-yellow);
697
+ font-size: 12px;
698
+ margin-bottom: 12px;
699
+ }
700
+
701
+ .job-log {
702
+ background: var(--bg-input);
703
+ border: 1px solid var(--border);
704
+ border-radius: 6px;
705
+ padding: 10px;
706
+ font-family: var(--mono);
707
+ font-size: 12px;
708
+ max-height: 200px;
709
+ overflow-y: auto;
710
+ margin-top: 12px;
711
+ }
712
+ .job-log .step { color: var(--text-muted); }
713
+ .job-log .done { color: var(--accent-green); }
714
+ .job-log .error { color: var(--accent-red); }
715
+
716
+ /* ── Log viewer ──────────────────────────────────── */
717
+ .log-viewer {
718
+ display: none;
719
+ position: fixed;
720
+ bottom: 0;
721
+ left: 0;
722
+ right: 0;
723
+ height: 250px;
724
+ background: rgba(18,21,24,0.95);
725
+ backdrop-filter: var(--glass-blur);
726
+ border-top: 1px solid var(--border-light);
727
+ z-index: 50;
728
+ flex-direction: column;
729
+ animation: slideUp 0.2s ease-out;
730
+ }
731
+ @keyframes slideUp { from { transform: translateY(100%); } to { transform: translateY(0); } }
732
+ .log-viewer.active { display: flex; }
733
+ .log-viewer-header {
734
+ display: flex;
735
+ align-items: center;
736
+ justify-content: space-between;
737
+ padding: 8px 16px;
738
+ border-bottom: 1px solid var(--border);
739
+ }
740
+ .log-viewer-header h3 {
741
+ color: var(--text-muted);
742
+ font-size: 11px;
743
+ text-transform: uppercase;
744
+ letter-spacing: 1.2px;
745
+ font-weight: 500;
746
+ }
747
+ .log-viewer-body {
748
+ flex: 1;
749
+ overflow-y: auto;
750
+ padding: 8px 16px;
751
+ font-family: var(--mono);
752
+ font-size: 12px;
753
+ }
754
+ .log-line { padding: 2px 0; color: var(--text-muted); }
755
+ .log-line .log-ts { color: var(--text-dim); margin-right: 8px; }
756
+
757
+ .no-results {
758
+ text-align: center;
759
+ padding: 60px 24px;
760
+ color: var(--text-dim);
761
+ font-size: 14px;
762
+ }
763
+
764
+ .tab-title {
765
+ font-size: 18px;
766
+ font-weight: 600;
767
+ color: var(--text-primary);
768
+ margin-bottom: 16px;
769
+ display: flex;
770
+ align-items: center;
771
+ gap: 8px;
772
+ }
773
+ .tab-title .count {
774
+ font-size: 12px;
775
+ color: var(--text-muted);
776
+ font-weight: 400;
777
+ }
778
+
779
+ @keyframes dash { to { stroke-dashoffset: -7; } }
780
+ @keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
781
+ .tab-content > * { animation: fadeIn 0.2s ease-out; }
782
+ </style>
783
+ </head>
784
+ <body>
785
+
786
+ <div class="header">
787
+ <div class="header-left">
788
+ <h1><span>Federation</span> Mesh</h1>
789
+ <div class="mesh-badge">
790
+ <span class="status-dot green pulse" id="meshDot"></span>
791
+ <span id="meshStatus">loading</span>
792
+ </div>
793
+ </div>
794
+ <div class="header-tabs" id="headerTabs">
795
+ <button class="active" onclick="setTab('overview')">Overview</button>
796
+ <button onclick="setTab('mempool')">Mempool</button>
797
+ <button onclick="setTab('explorer')">Explorer</button>
798
+ <button onclick="setTab('inscriptions')">Inscriptions</button>
799
+ <button onclick="setTab('tokens')">Tokens</button>
800
+ <button onclick="setTab('apps')">Apps</button>
801
+ </div>
802
+ <div class="header-right">
803
+ <div class="header-stats">
804
+ <span><span class="val" id="hBridgeCount">-</span> bridges</span>
805
+ <span>height <span class="val" id="hBestHeight">-</span></span>
806
+ <span><span class="val" id="hTotalPeers">-</span> peers</span>
807
+ </div>
808
+ </div>
809
+ </div>
810
+
811
+ <div class="main">
812
+ <div class="bridge-rail" id="bridgeRail"></div>
813
+ <div class="tab-content" id="tabContent">
814
+ <div class="no-results">Connecting to mesh...</div>
815
+ </div>
816
+ </div>
817
+
818
+ <!-- Modal overlay -->
819
+ <div class="modal-overlay" id="modalOverlay" onclick="if(event.target===this)closeModal()">
820
+ <div class="modal" id="modalContent"></div>
821
+ </div>
822
+
823
+ <!-- Log viewer -->
824
+ <div class="log-viewer" id="logViewer">
825
+ <div class="log-viewer-header">
826
+ <h3>Live Bridge Logs</h3>
827
+ <button class="btn sm ghost" onclick="closeLogViewer()">Close</button>
828
+ </div>
829
+ <div class="log-viewer-body" id="logViewerBody"></div>
830
+ </div>
831
+
832
+ <script>
833
+ // ── QR Code generator ──────────────────────────────
834
+ function generateQR(text) {
835
+ const EC_L = [7,10,15,20,26,18,20,24,30,18,20,24,26,30,22,24,28,30,28,28,28,28,30,30,26,28,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30];
836
+ function makeQR(data) {
837
+ const bits = [];
838
+ function pushBits(val, len) { for (let i = len - 1; i >= 0; i--) bits.push((val >> i) & 1); }
839
+ pushBits(4, 4); pushBits(data.length, 8);
840
+ for (let i = 0; i < data.length; i++) pushBits(data.charCodeAt(i), 8);
841
+ pushBits(0, 4);
842
+ while (bits.length % 8) bits.push(0);
843
+ while (bits.length < 152) { pushBits(0xEC, 8); if (bits.length < 152) pushBits(0x11, 8); }
844
+ const codewords = [];
845
+ for (let i = 0; i < bits.length; i += 8) { let b = 0; for (let j = 0; j < 8; j++) b = (b << 1) | (bits[i + j] || 0); codewords.push(b); }
846
+ const ecLen = 7;
847
+ const gf = new Uint8Array(256), gl = new Uint8Array(256);
848
+ let v = 1;
849
+ for (let i = 0; i < 255; i++) { gf[i] = v; gl[v] = i; v = (v << 1) ^ (v >= 128 ? 0x11d : 0); }
850
+ function gfMul(a, b) { return a === 0 || b === 0 ? 0 : gf[(gl[a] + gl[b]) % 255]; }
851
+ const genPoly = [1];
852
+ for (let i = 0; i < ecLen; i++) {
853
+ const newGen = new Array(genPoly.length + 1).fill(0);
854
+ const factor = gf[i];
855
+ for (let j = 0; j < genPoly.length; j++) { newGen[j] ^= genPoly[j]; newGen[j + 1] ^= gfMul(genPoly[j], factor); }
856
+ genPoly.length = 0; genPoly.push(...newGen);
857
+ }
858
+ const padded = [...codewords, ...new Array(ecLen).fill(0)];
859
+ for (let i = 0; i < codewords.length; i++) { const coef = padded[i]; if (coef !== 0) { for (let j = 0; j < genPoly.length; j++) padded[i + j] ^= gfMul(genPoly[j], coef); } }
860
+ const allWords = [...codewords, ...padded.slice(codewords.length)];
861
+ const size = 25;
862
+ const matrix = Array.from({length: size}, () => new Uint8Array(size));
863
+ const reserved = Array.from({length: size}, () => new Uint8Array(size));
864
+ function finderPattern(r, c) {
865
+ for (let dr = -1; dr <= 7; dr++) for (let dc = -1; dc <= 7; dc++) {
866
+ const rr = r + dr, cc = c + dc;
867
+ if (rr < 0 || rr >= size || cc < 0 || cc >= size) continue;
868
+ const inOuter = dr >= 0 && dr <= 6 && dc >= 0 && dc <= 6;
869
+ const inInner = dr >= 2 && dr <= 4 && dc >= 2 && dc <= 4;
870
+ const onBorder = dr === 0 || dr === 6 || dc === 0 || dc === 6;
871
+ matrix[rr][cc] = (inInner || onBorder) && inOuter ? 1 : 0;
872
+ reserved[rr][cc] = 1;
873
+ }
874
+ }
875
+ finderPattern(0, 0); finderPattern(0, size - 7); finderPattern(size - 7, 0);
876
+ const ar = 18, ac = 18;
877
+ for (let dr = -2; dr <= 2; dr++) for (let dc = -2; dc <= 2; dc++) {
878
+ const ad = Math.max(Math.abs(dr), Math.abs(dc));
879
+ matrix[ar + dr][ac + dc] = (ad === 0 || ad === 2) ? 1 : 0;
880
+ reserved[ar + dr][ac + dc] = 1;
881
+ }
882
+ for (let i = 8; i < size - 8; i++) { matrix[6][i] = i % 2 === 0 ? 1 : 0; reserved[6][i] = 1; matrix[i][6] = i % 2 === 0 ? 1 : 0; reserved[i][6] = 1; }
883
+ matrix[size - 8][8] = 1; reserved[size - 8][8] = 1;
884
+ for (let i = 0; i < 8; i++) { reserved[8][i] = 1; reserved[8][size - 1 - i] = 1; reserved[i][8] = 1; reserved[size - 1 - i][8] = 1; }
885
+ reserved[8][8] = 1;
886
+ const allBits = [];
887
+ for (const w of allWords) for (let b = 7; b >= 0; b--) allBits.push((w >> b) & 1);
888
+ let bitIdx = 0;
889
+ for (let right = size - 1; right >= 1; right -= 2) {
890
+ if (right === 6) right = 5;
891
+ for (let vert = 0; vert < size; vert++) {
892
+ for (let j = 0; j < 2; j++) {
893
+ const col = right - j;
894
+ const upward = ((right + 1) / 2 | 0) % 2 === (size > 25 ? 0 : 1);
895
+ const row = upward ? size - 1 - vert : vert;
896
+ if (reserved[row][col]) continue;
897
+ if (bitIdx < allBits.length) matrix[row][col] = allBits[bitIdx++];
898
+ }
899
+ }
900
+ }
901
+ for (let r = 0; r < size; r++) for (let c = 0; c < size; c++) { if (!reserved[r][c] && (r + c) % 2 === 0) matrix[r][c] ^= 1; }
902
+ const fmt = [1,1,1,0,1,1,1,1,1,0,0,0,1,0,0];
903
+ const f1 = [[8,0],[8,1],[8,2],[8,3],[8,4],[8,5],[8,7],[8,8],[7,8],[5,8],[4,8],[3,8],[2,8],[1,8],[0,8]];
904
+ const f2 = [[size-1,8],[size-2,8],[size-3,8],[size-4,8],[size-5,8],[size-6,8],[size-7,8],[8,size-8],[8,size-7],[8,size-6],[8,size-5],[8,size-4],[8,size-3],[8,size-2],[8,size-1]];
905
+ for (let i = 0; i < 15; i++) { matrix[f1[i][0]][f1[i][1]] = fmt[i]; matrix[f2[i][0]][f2[i][1]] = fmt[i]; }
906
+ return { matrix, size };
907
+ }
908
+ const { matrix, size } = makeQR(text);
909
+ const scale = 4, border = 2, total = (size + border * 2) * scale;
910
+ const canvas = document.createElement('canvas');
911
+ canvas.width = total; canvas.height = total;
912
+ const ctx = canvas.getContext('2d');
913
+ ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, total, total);
914
+ ctx.fillStyle = '#000';
915
+ for (let r = 0; r < size; r++) for (let c = 0; c < size; c++) { if (matrix[r][c]) ctx.fillRect((c + border) * scale, (r + border) * scale, scale, scale); }
916
+ return canvas.toDataURL();
917
+ }
918
+
919
+ // ── Configuration ──────────────────────────────────
920
+ const SEED_BRIDGES = [
921
+ { name: 'bridge-alpha', url: 'http://144.202.48.217:9333' },
922
+ { name: 'bridge-beta', url: 'http://45.63.77.31:9333' }
923
+ ];
924
+ let BRIDGES = [...SEED_BRIDGES];
925
+ const POLL_INTERVAL = 5000;
926
+ let discoveryDone = false;
927
+
928
+ // ── State ──────────────────────────────────────────
929
+ let bridgeData = new Map();
930
+ let selectedBridge = null;
931
+ let operatorTokens = {};
932
+ let logSSE = null;
933
+ let jobSSE = null;
934
+ let activeTab = 'overview';
935
+ let savedExplorerInput = '';
936
+ let savedExplorerResult = '';
937
+ let appsData = null;
938
+ let latestPrice = null;
939
+
940
+ try { const saved = localStorage.getItem('relay_operator_tokens'); if (saved) operatorTokens = JSON.parse(saved); } catch {}
941
+
942
+ // ── Utilities ──────────────────────────────────────
943
+ function fmtUptime(s) {
944
+ if (!s || s <= 0) return '0m';
945
+ const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60);
946
+ if (d > 0) return d + 'd ' + h + 'h';
947
+ if (h > 0) return h + 'h ' + m + 'm';
948
+ return m + 'm';
949
+ }
950
+ function truncPubkey(pk) { return pk ? pk.slice(0, 8) + ' ... ' + pk.slice(-6) : '(none)'; }
951
+ function scoreColor(v) { return v >= 0.7 ? 'green' : v >= 0.4 ? 'yellow' : 'red'; }
952
+ function fmtSats(n) { return n === null || n === undefined ? '-' : n.toLocaleString(); }
953
+ function fmtHeight(n) { return n > 0 ? n.toLocaleString() : '-'; }
954
+ function isOperator(url) { return !!operatorTokens[url]; }
955
+ function getAuthParam(url) { const t = operatorTokens[url]; return t ? '?auth=' + encodeURIComponent(t) : ''; }
956
+ function escapeHtml(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
957
+ function hexToBase64(hex) { const bytes = new Uint8Array(hex.match(/.{1,2}/g).map(b => parseInt(b, 16))); let bin = ''; for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); return btoa(bin); }
958
+ function protocolBadge(type, protocol) {
959
+ const label = protocol || type || 'unknown';
960
+ const cls = label.replace(/_/g, '-');
961
+ return '<span class="proto-badge ' + cls + '">' + escapeHtml(label.toUpperCase()) + '</span>';
962
+ }
963
+ function tryDecodeAscii(hex) {
964
+ if (!hex || hex.length === 0 || hex.length > 2000) return null;
965
+ const bytes = [];
966
+ for (let i = 0; i < hex.length; i += 2) {
967
+ const b = parseInt(hex.slice(i, i + 2), 16);
968
+ if (b < 0x20 && b !== 0x0a && b !== 0x0d && b !== 0x09) return null;
969
+ if (b > 0x7e) return null;
970
+ bytes.push(b);
971
+ }
972
+ return String.fromCharCode(...bytes);
973
+ }
974
+
975
+ // ── Fetch ──────────────────────────────────────────
976
+ async function fetchBridge(bridge) {
977
+ try {
978
+ const r = await fetch(bridge.url + '/status' + getAuthParam(bridge.url), { signal: AbortSignal.timeout(5000) });
979
+ if (!r.ok) throw new Error('HTTP ' + r.status);
980
+ const data = await r.json();
981
+ data._name = bridge.name; data._url = bridge.url; data._error = null; data._lastSeen = Date.now();
982
+ return data;
983
+ } catch (e) {
984
+ return { _name: bridge.name, _url: bridge.url, _error: e.message,
985
+ bridge: { pubkeyHex: null, endpoint: null, meshId: null, uptimeSeconds: 0 },
986
+ peers: { connected: 0, max: 0, list: [] },
987
+ headers: { bestHeight: -1, bestHash: null, count: 0 },
988
+ txs: { mempool: 0, seen: 0 }
989
+ };
990
+ }
991
+ }
992
+ async function fetchMempool(bridge) {
993
+ try {
994
+ const r = await fetch(bridge.url + '/mempool', { signal: AbortSignal.timeout(5000) });
995
+ if (!r.ok) return { count: 0, txs: [] };
996
+ return await r.json();
997
+ } catch { return { count: 0, txs: [] }; }
998
+ }
999
+ async function discoverBridges() {
1000
+ const known = new Map();
1001
+ for (const b of SEED_BRIDGES) known.set(b.url, b.name);
1002
+ for (const seed of SEED_BRIDGES) {
1003
+ try {
1004
+ const r = await fetch(seed.url + '/discover', { signal: AbortSignal.timeout(5000) });
1005
+ if (!r.ok) continue;
1006
+ const data = await r.json();
1007
+ if (!data.bridges) continue;
1008
+ for (const b of data.bridges) {
1009
+ if (!b.statusUrl) continue;
1010
+ const base = b.statusUrl.replace(/\/status$/, '');
1011
+ if (!known.has(base)) {
1012
+ const name = 'bridge-' + (b.pubkeyHex ? b.pubkeyHex.slice(0, 8) : known.size);
1013
+ known.set(base, name);
1014
+ }
1015
+ }
1016
+ } catch {}
1017
+ }
1018
+ BRIDGES = [...known.entries()].map(([url, name]) => ({ name, url }));
1019
+ discoveryDone = true;
1020
+ }
1021
+
1022
+ // ── Poll ───────────────────────────────────────────
1023
+ async function pollAll() {
1024
+ if (!discoveryDone) await discoverBridges();
1025
+ const results = await Promise.all(BRIDGES.map(fetchBridge));
1026
+ const mempools = await Promise.all(BRIDGES.map(fetchMempool));
1027
+ for (let i = 0; i < results.length; i++) {
1028
+ results[i]._mempool = mempools[i];
1029
+ bridgeData.set(results[i]._name, results[i]);
1030
+ }
1031
+ if (!selectedBridge && bridgeData.size > 0) {
1032
+ selectedBridge = bridgeData.keys().next().value;
1033
+ }
1034
+ // Fetch price from first online bridge
1035
+ const firstOnline = results.find(b => !b._error);
1036
+ if (firstOnline) {
1037
+ fetch(firstOnline._url + '/price', { signal: AbortSignal.timeout(5000) })
1038
+ .then(r => r.ok ? r.json() : null).then(d => { if (d) latestPrice = d; }).catch(() => {});
1039
+ }
1040
+ renderHeader();
1041
+ renderBridgeRail();
1042
+ renderActiveTab(true);
1043
+ }
1044
+
1045
+ // ── Header stats ───────────────────────────────────
1046
+ function renderHeader() {
1047
+ const all = [...bridgeData.values()];
1048
+ const online = all.filter(b => !b._error);
1049
+ const totalPeers = online.reduce((s, b) => s + b.peers.connected, 0);
1050
+ const bestH = Math.max(...all.map(b => b.headers.bestHeight));
1051
+
1052
+ document.getElementById('hBridgeCount').textContent = online.length + '/' + all.length;
1053
+ document.getElementById('hTotalPeers').textContent = totalPeers;
1054
+ document.getElementById('hBestHeight').textContent = bestH > 0 ? bestH.toLocaleString() : '-';
1055
+
1056
+ const dot = document.getElementById('meshDot');
1057
+ const status = document.getElementById('meshStatus');
1058
+ if (online.length === all.length) { dot.className = 'status-dot green pulse'; status.textContent = 'healthy'; }
1059
+ else if (online.length > 0) { dot.className = 'status-dot yellow pulse'; status.textContent = 'degraded'; }
1060
+ else { dot.className = 'status-dot red pulse'; status.textContent = 'offline'; }
1061
+ }
1062
+
1063
+ // ── Bridge rail ────────────────────────────────────
1064
+ function renderBridgeRail() {
1065
+ const el = document.getElementById('bridgeRail');
1066
+ let html = '<div class="bridge-rail-header">Bridges</div>';
1067
+ for (const [name, b] of bridgeData) {
1068
+ const dotColor = b._error ? 'red' : (b.peers.connected > 0 ? 'green' : 'yellow');
1069
+ const sel = selectedBridge === name ? ' selected' : '';
1070
+ html += '<div class="rail-item' + sel + '" onclick="selectBridge(\'' + name + '\')">';
1071
+ html += '<span class="status-dot ' + dotColor + '"></span>';
1072
+ html += '<div style="display:flex;flex-direction:column;flex:1;min-width:0"><span class="rail-name">' + name + '</span>';
1073
+ const ip = (b._url || '').replace(/^https?:\/\//, '').replace(/:\d+$/, '');
1074
+ html += '<span style="font-size:10px;color:var(--text-muted);opacity:0.6">' + ip + '</span></div>';
1075
+ if (b._error) html += '<span class="rail-height offline">off</span>';
1076
+ else html += '<span class="rail-height">' + fmtHeight(b.headers.bestHeight) + '</span>';
1077
+ html += '</div>';
1078
+ }
1079
+ el.innerHTML = html;
1080
+ }
1081
+
1082
+ // ── Tab system ─────────────────────────────────────
1083
+ function setTab(tab) {
1084
+ // Save explorer state before switching
1085
+ if (activeTab === 'explorer') {
1086
+ const inp = document.getElementById('txLookupInput');
1087
+ const res = document.getElementById('txLookupResult');
1088
+ if (inp) savedExplorerInput = inp.value;
1089
+ if (res) savedExplorerResult = res.innerHTML;
1090
+ }
1091
+ activeTab = tab;
1092
+ const buttons = document.querySelectorAll('#headerTabs button');
1093
+ const tabs = ['overview', 'mempool', 'explorer', 'inscriptions', 'tokens', 'apps'];
1094
+ buttons.forEach((btn, i) => btn.classList.toggle('active', tabs[i] === tab));
1095
+ renderActiveTab();
1096
+ if (tab === 'apps') setTimeout(fetchAppsData, 50);
1097
+ if (tab === 'tokens') setTimeout(fetchTokens, 50);
1098
+ }
1099
+
1100
+ function renderActiveTab(fromPoll) {
1101
+ const el = document.getElementById('tabContent');
1102
+ // Skip apps/explorer re-render during poll (preserves state)
1103
+ if (fromPoll && (activeTab === 'apps' || activeTab === 'explorer' || activeTab === 'inscriptions' || activeTab === 'tokens')) return;
1104
+ let html;
1105
+ switch (activeTab) {
1106
+ case 'overview': html = renderOverviewTab(); break;
1107
+ case 'mempool': html = renderMempoolTab(); break;
1108
+ case 'explorer': html = renderExplorerTab(); break;
1109
+ case 'inscriptions': html = renderInscriptionsTab(); break;
1110
+ case 'tokens': html = renderTokensTab(); break;
1111
+ case 'apps': html = renderAppsTab(); break;
1112
+ default: html = renderOverviewTab();
1113
+ }
1114
+ // Only update DOM if content actually changed (prevents flash)
1115
+ if (el.innerHTML !== html) {
1116
+ el.innerHTML = html;
1117
+ if (activeTab === 'explorer') restoreExplorer();
1118
+ }
1119
+ }
1120
+
1121
+ function selectBridge(name) {
1122
+ selectedBridge = name;
1123
+ renderBridgeRail();
1124
+ renderActiveTab();
1125
+ }
1126
+
1127
+ // ── Overview tab ───────────────────────────────────
1128
+ function renderOverviewTab() {
1129
+ const all = [...bridgeData.values()];
1130
+ const online = all.filter(b => !b._error);
1131
+ const totalPeers = online.reduce((s, b) => s + b.peers.connected, 0);
1132
+ const bestH = Math.max(...all.map(b => b.headers.bestHeight));
1133
+ const totalMempool = online.reduce((s, b) => s + (b.txs ? b.txs.mempool : 0), 0);
1134
+
1135
+ let html = '<div class="stats-hero">';
1136
+ html += '<div class="stat-card glass"><div class="stat-value">' + online.length + '<span style="color:var(--text-dim);font-weight:400"> / ' + all.length + '</span></div><div class="stat-label">Bridges Online</div></div>';
1137
+ html += '<div class="stat-card glass"><div class="stat-value green">' + fmtHeight(bestH) + '</div><div class="stat-label">Best Height</div></div>';
1138
+ html += '<div class="stat-card glass"><div class="stat-value">' + totalPeers + '</div><div class="stat-label">Mesh Peers</div></div>';
1139
+ html += '<div class="stat-card glass"><div class="stat-value">' + totalMempool + '</div><div class="stat-label">Mempool Txs</div></div>';
1140
+ const priceStr = latestPrice && latestPrice.usd ? '$' + latestPrice.usd.toFixed(2) : '—';
1141
+ html += '<div class="stat-card glass"><div class="stat-value" style="color:var(--accent-blue)">' + priceStr + '</div><div class="stat-label">BSV / USD</div></div>';
1142
+ html += '</div>';
1143
+
1144
+ const b = bridgeData.get(selectedBridge);
1145
+ if (!b) return html + '<div class="no-results">Select a bridge from the rail</div>';
1146
+
1147
+ const op = b.operator === true;
1148
+ const color = b._error ? 'red' : (b.peers.connected > 0 ? 'green' : 'yellow');
1149
+ const statusText = b._error ? 'offline' : (b.peers.connected > 0 ? 'online' : 'no peers');
1150
+
1151
+ // Bridge header
1152
+ html += '<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">';
1153
+ html += '<div style="display:flex;align-items:center;gap:10px"><span class="status-dot ' + color + '"></span><span style="font-size:16px;font-weight:600;color:var(--text-primary)">' + b._name + '</span>';
1154
+ if (op) html += ' <span class="btn sm operator-badge">Operator</span>';
1155
+ html += '</div>';
1156
+ if (!op && !isOperator(b._url)) html += '<button class="btn sm" onclick="showAuthModal(\'' + b._url + '\')">Operator Login</button>';
1157
+ else if (!op && isOperator(b._url)) html += '<button class="btn sm" onclick="showAuthModal(\'' + b._url + '\')">Re-auth</button>';
1158
+ html += '</div>';
1159
+
1160
+ if (b._error) html += '<div class="error-banner">' + b._error + '</div>';
1161
+
1162
+ html += '<div class="cards-grid">';
1163
+
1164
+ // Bridge info card
1165
+ html += '<div class="detail-card glass"><h3>Bridge</h3>';
1166
+ html += '<div class="panel-row"><span class="label">Status</span><span class="value ' + color + '">' + statusText + '</span></div>';
1167
+ html += '<div class="panel-row"><span class="label">Pubkey</span><span class="value">' + truncPubkey(b.bridge.pubkeyHex) + '</span></div>';
1168
+ if (op) {
1169
+ html += '<div class="panel-row"><span class="label">Endpoint</span><span class="value">' + (b.bridge.endpoint || '-') + '</span></div>';
1170
+ if (b.bridge.domains && b.bridge.domains.length > 0) {
1171
+ html += '<div class="panel-row"><span class="label">Domains</span><span class="value">' + b.bridge.domains.join(', ') + '</span></div>';
1172
+ }
1173
+ }
1174
+ html += '<div class="panel-row"><span class="label">Mesh ID</span><span class="value">' + (b.bridge.meshId || '-') + '</span></div>';
1175
+ html += '<div class="panel-row"><span class="label">Uptime</span><span class="value">' + fmtUptime(b.bridge.uptimeSeconds) + '</span></div>';
1176
+ html += '</div>';
1177
+
1178
+ // Network card
1179
+ html += '<div class="detail-card glass"><h3>Network</h3>';
1180
+ html += '<div class="panel-row"><span class="label">Best Height</span><span class="value green">' + fmtHeight(b.headers.bestHeight) + '</span></div>';
1181
+ html += '<div class="panel-row"><span class="label">Best Hash</span><span class="value">' + (b.headers.bestHash ? b.headers.bestHash.slice(0, 16) + '...' : '-') + '</span></div>';
1182
+ html += '<div class="panel-row"><span class="label">Headers</span><span class="value">' + b.headers.count.toLocaleString() + '</span></div>';
1183
+ html += '<div class="panel-row"><span class="label">Mempool</span><span class="value">' + b.txs.mempool + ' txs</span></div>';
1184
+ html += '<div class="panel-row"><span class="label">Seen</span><span class="value">' + b.txs.seen + ' txs</span></div>';
1185
+ html += '</div>';
1186
+
1187
+ // Wallet card (operator)
1188
+ if (op && b.wallet) {
1189
+ html += '<div class="detail-card glass"><h3>Wallet</h3>';
1190
+ if (b.bridge.address) {
1191
+ html += '<div class="panel-row"><span class="label">Address</span><span class="value" style="font-size:10px;word-break:break-all">' + b.bridge.address + '</span></div>';
1192
+ html += '<div style="text-align:center;padding:10px 0"><img src="' + generateQR(b.bridge.address) + '" alt="QR" style="border-radius:6px" width="100" height="100"></div>';
1193
+ }
1194
+ html += '<div class="panel-row"><span class="label">Balance</span><span class="value green">' + fmtSats(b.wallet.balanceSats) + ' sats</span></div>';
1195
+ html += '<div class="panel-row"><span class="label">UTXOs</span><span class="value">' + (b.wallet.utxoCount || 0) + '</span></div>';
1196
+ html += '</div>';
1197
+ }
1198
+
1199
+ // BSV Node card
1200
+ if (b.bsvNode) {
1201
+ html += '<div class="detail-card glass"><h3>BSV Node</h3>';
1202
+ html += '<div class="panel-row"><span class="label">Status</span><span class="value ' + (b.bsvNode.connected ? 'green' : 'red') + '">' + (b.bsvNode.connected ? 'Connected' : 'Disconnected') + '</span></div>';
1203
+ html += '<div class="panel-row"><span class="label">Peers</span><span class="value">' + (b.bsvNode.peers || 0) + '</span></div>';
1204
+ html += '<div class="panel-row"><span class="label">Height</span><span class="value">' + (b.bsvNode.height ? b.bsvNode.height.toLocaleString() : '-') + '</span></div>';
1205
+ html += '</div>';
1206
+ }
1207
+
1208
+ // Peers card
1209
+ html += '<div class="detail-card glass"><h3>Peers (' + b.peers.connected + '/' + b.peers.max + ')</h3>';
1210
+ if (b.peers.list.length === 0) {
1211
+ html += '<div style="color:var(--text-dim);font-size:12px">No peers connected</div>';
1212
+ } else {
1213
+ for (const p of b.peers.list) {
1214
+ const dotC = p.connected ? 'green' : 'red';
1215
+ html += '<div class="peer-entry"><div><span class="peer-dot ' + dotC + '"></span><span class="peer-name">' + truncPubkey(p.pubkeyHex) + '</span> <span style="color:var(--text-dim)">score ' + (p.score !== undefined ? p.score.toFixed(2) : '?') + '</span></div>';
1216
+ html += '<div class="peer-meta">' + (p.endpoint || '') + (p.health ? ' &middot; ' + p.health : '') + '</div>';
1217
+ if (p.scoreBreakdown) html += renderScoreBars(p.scoreBreakdown);
1218
+ html += '</div>';
1219
+ }
1220
+ }
1221
+ html += '</div>';
1222
+
1223
+ // Actions card (operator)
1224
+ if (op) {
1225
+ html += '<div class="detail-card glass"><h3>Actions</h3>';
1226
+ html += '<div class="actions-grid">';
1227
+ html += '<button class="btn primary" onclick="showRegisterModal(\'' + b._url + '\')">Register</button>';
1228
+ html += '<button class="btn danger" onclick="showDeregisterModal(\'' + b._url + '\')">Deregister</button>';
1229
+ html += '<button class="btn" onclick="showSendModal(\'' + b._url + '\')">Send</button>';
1230
+ html += '<button class="btn" onclick="showConnectModal(\'' + b._url + '\')">Connect Peer</button>';
1231
+ html += '</div>';
1232
+ html += '<button class="btn ghost" onclick="openLogViewer(\'' + b._url + '\')" style="width:100%;margin-top:8px">View Live Logs</button>';
1233
+ if (isOperator(b._url)) html += '<button class="btn ghost" onclick="logoutOperator(\'' + b._url + '\')" style="width:100%;margin-top:4px;color:var(--text-dim)">Logout</button>';
1234
+ html += '<div style="margin-top:10px;padding:8px 10px;background:rgba(88,166,255,0.06);border:1px solid rgba(88,166,255,0.12);border-radius:var(--radius-sm);font-size:11px;color:var(--text-muted)">To backfill historical inscriptions and tokens, run <span style="font-family:var(--mono);color:var(--accent-blue)">relay-bridge backfill</span> from the CLI.</div>';
1235
+ html += '</div>';
1236
+ }
1237
+
1238
+ // Mini map card
1239
+ if (b.peers && b.peers.list && b.peers.list.length > 0) {
1240
+ html += '<div class="detail-card glass"><h3>Mesh Map</h3>' + renderMiniMap(b) + '</div>';
1241
+ }
1242
+
1243
+ html += '</div>'; // cards-grid
1244
+
1245
+ html += '<div style="color:var(--text-dim);font-size:10px;text-align:center;padding:12px 0">Updated ' + new Date().toLocaleTimeString() + '</div>';
1246
+ return html;
1247
+ }
1248
+
1249
+ // ── Mempool tab ────────────────────────────────────
1250
+ function renderMempoolTab() {
1251
+ const b = bridgeData.get(selectedBridge);
1252
+ if (!b) return '<div class="no-results">Select a bridge</div>';
1253
+
1254
+ let html = '<div class="tab-title">Mempool <span class="count">' + (b._mempool ? b._mempool.count : 0) + ' transactions</span></div>';
1255
+
1256
+ if (!b._mempool || b._mempool.txs.length === 0) {
1257
+ html += '<div class="glass" style="padding:40px;text-align:center"><div style="color:var(--text-dim)">No transactions in mempool</div></div>';
1258
+ return html;
1259
+ }
1260
+
1261
+ html += '<div class="glass" style="padding:0;overflow:hidden">';
1262
+ for (const tx of b._mempool.txs) {
1263
+ const totalOut = tx.outputs.reduce((s, o) => s + o.satoshis, 0);
1264
+ html += '<div class="mempool-tx" onclick="setTab(\'explorer\');setTimeout(()=>{const i=document.getElementById(\'txLookupInput\');if(i){i.value=\'' + tx.txid + '\';lookupTx(\'' + b._url + '\')}},50)">';
1265
+ html += '<div class="tx-hash-link">' + tx.txid + '</div>';
1266
+ html += '<div class="tx-summary">' + tx.size + ' bytes &middot; ' + tx.inputs.length + ' in &middot; ' + tx.outputs.length + ' out &middot; ' + fmtSats(totalOut) + ' sats</div>';
1267
+ for (const o of tx.outputs) {
1268
+ html += '<div class="tx-output">' + protocolBadge(o.type, o.protocol) + ' #' + o.vout + ': ' + fmtSats(o.satoshis) + ' sats';
1269
+ if (o.hash160) html += ' <span style="color:var(--text-dim)">' + o.hash160 + '</span>';
1270
+ html += '</div>';
1271
+ if (o.parsed) html += renderParsedData(o.type, o.protocol, o.parsed, tx.txid, o.vout);
1272
+ else if (o.type === 'op_return' && o.data && o.data.length > 0) html += renderRawOpReturn(o.data);
1273
+ }
1274
+ html += '</div>';
1275
+ }
1276
+ html += '</div>';
1277
+ return html;
1278
+ }
1279
+
1280
+ // ── Tx Explorer tab ────────────────────────────────
1281
+ function renderExplorerTab() {
1282
+ const b = bridgeData.get(selectedBridge);
1283
+ let html = '<div class="tab-title">Explorer</div>';
1284
+
1285
+ // Block ticker
1286
+ if (b && !b._error) {
1287
+ const height = b.headers ? b.headers.bestHeight : 0;
1288
+ const peers = b.peers ? b.peers.connected : 0;
1289
+ html += '<div style="display:flex;align-items:center;gap:8px;padding:8px 12px;margin-bottom:12px;background:rgba(255,255,255,0.03);border:1px solid var(--border);border-radius:var(--radius-sm);font-size:11px;color:var(--text-muted)">';
1290
+ html += '<span class="status-dot green pulse" style="display:inline-block;width:6px;height:6px;border-radius:50%"></span>';
1291
+ html += 'Latest block: <strong style="color:var(--text-secondary)">' + fmtHeight(height) + '</strong>';
1292
+ html += ' &middot; <strong style="color:var(--text-secondary)">' + peers + '</strong> peers connected';
1293
+ html += '</div>';
1294
+ }
1295
+
1296
+ html += '<div class="explorer-search">';
1297
+ html += '<input type="text" id="txLookupInput" placeholder="Paste a txid or BSV address..." onkeydown="if(event.key===\'Enter\')explorerSearch()">';
1298
+ html += '<button class="btn primary" onclick="explorerSearch()">Lookup</button>';
1299
+ html += '</div>';
1300
+ html += '<div id="txLookupResult"></div>';
1301
+
1302
+ // Live stream as empty state (only when nothing searched)
1303
+ if (!savedExplorerResult) {
1304
+ html += '<div id="explorerLiveStream" style="margin-top:16px"></div>';
1305
+ setTimeout(renderExplorerLiveStream, 50);
1306
+ }
1307
+
1308
+ return html;
1309
+ }
1310
+
1311
+ async function renderExplorerLiveStream() {
1312
+ const el = document.getElementById('explorerLiveStream');
1313
+ if (!el) return;
1314
+ const b = bridgeData.get(selectedBridge);
1315
+ if (!b) { el.innerHTML = ''; return; }
1316
+
1317
+ try {
1318
+ const r = await fetch(b._url + '/mempool', { signal: AbortSignal.timeout(8000) });
1319
+ const data = await r.json();
1320
+ const txs = data.transactions || [];
1321
+ if (txs.length === 0) {
1322
+ el.innerHTML = '<div style="color:var(--text-dim);font-size:12px;text-align:center;padding:20px">No transactions in mempool right now</div>';
1323
+ return;
1324
+ }
1325
+ let html = '<div style="font-size:11px;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:8px">Live transactions</div>';
1326
+ const show = txs.slice(0, 20);
1327
+ for (const tx of show) {
1328
+ const badges = (tx.outputs || []).filter(o => o.type !== 'p2pkh' && o.type !== 'p2sh').map(o => protocolBadge(o.type, o.protocol)).join(' ');
1329
+ html += '<div style="display:flex;justify-content:space-between;align-items:center;padding:6px 10px;margin-bottom:4px;background:rgba(255,255,255,0.03);border:1px solid var(--border);border-radius:6px;cursor:pointer" onclick="document.getElementById(\'txLookupInput\').value=\'' + tx.txid + '\';explorerSearch()">';
1330
+ html += '<div style="display:flex;align-items:center;gap:8px">';
1331
+ html += '<span style="font-family:var(--mono);font-size:11px;color:var(--accent-blue)">' + tx.txid.slice(0, 12) + '...' + tx.txid.slice(-6) + '</span>';
1332
+ if (badges) html += badges;
1333
+ html += '</div>';
1334
+ html += '<span style="font-size:11px;color:var(--text-dim)">' + (tx.size || 0) + 'B</span>';
1335
+ html += '</div>';
1336
+ }
1337
+ if (txs.length > 20) html += '<div style="font-size:11px;color:var(--text-dim);text-align:center;margin-top:4px">+ ' + (txs.length - 20) + ' more in mempool</div>';
1338
+ el.innerHTML = html;
1339
+ } catch (e) {
1340
+ el.innerHTML = '';
1341
+ }
1342
+ }
1343
+
1344
+ function detectSearchType(input) {
1345
+ if (!input) return null;
1346
+ if (/^[0-9a-fA-F]{64}$/.test(input)) return 'txid';
1347
+ if (/^[13][a-km-zA-HJ-NP-Z1-9]{24,33}$/.test(input)) return 'address';
1348
+ return null;
1349
+ }
1350
+
1351
+ function explorerSearch() {
1352
+ const input = document.getElementById('txLookupInput');
1353
+ const val = input ? input.value.trim() : '';
1354
+ const el = document.getElementById('txLookupResult');
1355
+ if (!el) return;
1356
+ const type = detectSearchType(val);
1357
+ if (type === 'txid') {
1358
+ const b = bridgeData.get(selectedBridge);
1359
+ if (b) lookupTx(b._url);
1360
+ return;
1361
+ }
1362
+ if (type === 'address') {
1363
+ savedExplorerInput = val;
1364
+ lookupAddress(val);
1365
+ return;
1366
+ }
1367
+ el.innerHTML = '<div style="color:var(--accent-red);font-size:12px;margin-top:12px">Enter a valid txid or BSV address</div>';
1368
+ }
1369
+
1370
+ async function lookupAddress(address) {
1371
+ const el = document.getElementById('txLookupResult');
1372
+ if (!el) return;
1373
+ const b = bridgeData.get(selectedBridge);
1374
+ if (!b) { el.innerHTML = '<div style="color:var(--accent-red);font-size:12px;margin-top:12px">Select a bridge first</div>'; return; }
1375
+
1376
+ el.innerHTML = '<div style="color:var(--text-muted);font-size:12px;margin-top:12px">Loading address history...</div>';
1377
+
1378
+ let historyHtml = '';
1379
+ let inscHtml = '';
1380
+
1381
+ // Fetch tx history and inscriptions in parallel
1382
+ try {
1383
+ const [histRes, inscRes] = await Promise.all([
1384
+ fetch(b._url + '/address/' + address + '/history', { signal: AbortSignal.timeout(15000) }).then(r => r.ok ? r.json() : { history: [] }).catch(() => ({ history: [] })),
1385
+ fetch(b._url + '/inscriptions?address=' + encodeURIComponent(address) + '&limit=50', { signal: AbortSignal.timeout(10000) }).then(r => r.ok ? r.json() : { inscriptions: [], total: 0 }).catch(() => ({ inscriptions: [], total: 0 }))
1386
+ ]);
1387
+
1388
+ // Transaction history section
1389
+ const txs = histRes.history || [];
1390
+ historyHtml = '<div class="glass" style="padding:16px;margin-top:12px">';
1391
+ historyHtml += '<div style="font-size:13px;color:var(--text-primary);font-weight:600;margin-bottom:10px">Transaction History</div>';
1392
+ if (txs.length === 0) {
1393
+ historyHtml += '<div style="color:var(--text-dim);font-size:12px">No transactions found for this address</div>';
1394
+ } else {
1395
+ historyHtml += '<div style="color:var(--text-muted);font-size:11px;margin-bottom:8px">' + txs.length + ' transaction' + (txs.length !== 1 ? 's' : '') + '</div>';
1396
+ const show = txs.slice(0, 50);
1397
+ for (const tx of show) {
1398
+ historyHtml += '<div style="display:flex;justify-content:space-between;align-items:center;padding:6px 0;border-bottom:1px solid var(--border)">';
1399
+ historyHtml += '<span class="addr-tx-link" style="font-family:var(--mono);font-size:11px;color:var(--accent-blue);cursor:pointer" onclick="document.getElementById(\'txLookupInput\').value=\'' + tx.tx_hash + '\';explorerSearch()">' + tx.tx_hash.slice(0, 12) + '...' + tx.tx_hash.slice(-8) + '</span>';
1400
+ historyHtml += '<span style="font-size:11px;color:var(--text-dim)">' + (tx.height > 0 ? 'block ' + tx.height.toLocaleString() : '<span style="color:var(--accent-yellow)">unconfirmed</span>') + '</span>';
1401
+ historyHtml += '</div>';
1402
+ }
1403
+ if (txs.length > 50) historyHtml += '<div style="color:var(--text-dim);font-size:11px;margin-top:6px">... and ' + (txs.length - 50) + ' more</div>';
1404
+ }
1405
+ historyHtml += '</div>';
1406
+
1407
+ // Inscriptions section
1408
+ const inscs = inscRes.inscriptions || [];
1409
+ inscHtml = '<div class="glass" style="padding:16px;margin-top:12px">';
1410
+ inscHtml += '<div style="font-size:13px;color:var(--text-primary);font-weight:600;margin-bottom:10px">Inscriptions</div>';
1411
+ if (inscs.length === 0) {
1412
+ inscHtml += '<div style="color:var(--text-dim);font-size:12px;margin-bottom:8px">No inscriptions indexed for this address</div>';
1413
+ inscHtml += '<button class="btn sm" style="background:rgba(88,166,255,0.15);color:var(--accent-blue);border:1px solid rgba(88,166,255,0.3)" onclick="startAddressScan(\'' + escapeHtml(address) + '\')">Scan for inscriptions</button>';
1414
+ } else {
1415
+ inscHtml += '<div style="color:var(--text-muted);font-size:11px;margin-bottom:8px">' + inscs.length + ' inscription' + (inscs.length !== 1 ? 's' : '') + '</div>';
1416
+ inscHtml += '<div class="inscription-grid">';
1417
+ for (const insc of inscs) {
1418
+ inscHtml += '<div class="inscription-card glass">';
1419
+ const src = '/inscription/' + insc.txid + '/' + insc.vout + '/content';
1420
+ const ct = insc.contentType || '';
1421
+ if (ct.startsWith('image/')) {
1422
+ inscHtml += '<img class="insc-thumb" src="' + src + '" alt="inscription" loading="lazy" onclick="window.open(\'' + src + '\',\'_blank\')">';
1423
+ } else if (ct === 'text/plain' || ct === 'application/json' || ct === 'application/bsv-20') {
1424
+ inscHtml += '<div class="insc-text" data-src="' + src + '">Loading...</div>';
1425
+ }
1426
+ inscHtml += '<div class="insc-txid" style="cursor:pointer;color:var(--accent-blue)" onclick="document.getElementById(\'txLookupInput\').value=\'' + insc.txid + '\';explorerSearch()">' + insc.txid.slice(0, 16) + '...:' + insc.vout + '</div>';
1427
+ inscHtml += '<div class="insc-meta">';
1428
+ inscHtml += protocolBadge('ordinal', insc.isBsv20 ? 'bsv-20' : 'ordinal');
1429
+ if (insc.contentType) inscHtml += '<span>' + escapeHtml(insc.contentType) + '</span>';
1430
+ if (insc.contentSize) inscHtml += '<span>' + insc.contentSize + ' bytes</span>';
1431
+ inscHtml += '</div>';
1432
+ inscHtml += '</div>';
1433
+ }
1434
+ inscHtml += '</div>';
1435
+ }
1436
+ inscHtml += '</div>';
1437
+ } catch (e) {
1438
+ historyHtml = '<div style="color:var(--accent-red);font-size:12px;margin-top:12px">' + escapeHtml(e.message) + '</div>';
1439
+ }
1440
+
1441
+ const headerHtml = '<div style="font-size:12px;color:var(--text-muted);margin-top:12px;margin-bottom:4px">Address: <span style="font-family:var(--mono);color:var(--text-secondary)">' + escapeHtml(address) + '</span></div>';
1442
+ el.innerHTML = headerHtml + historyHtml + inscHtml;
1443
+ savedExplorerResult = el.innerHTML;
1444
+
1445
+ // Lazy-load text content in inscription cards
1446
+ el.querySelectorAll('.insc-text[data-src]').forEach(div => {
1447
+ fetch(div.dataset.src).then(r => r.text()).then(t => {
1448
+ try { t = JSON.stringify(JSON.parse(t), null, 2); } catch {}
1449
+ div.textContent = t;
1450
+ }).catch(() => { div.textContent = '[failed to load]'; });
1451
+ });
1452
+ }
1453
+
1454
+ function startAddressScan(address) {
1455
+ const b = bridgeData.get(selectedBridge);
1456
+ if (!b) return;
1457
+ const el = document.getElementById('txLookupResult');
1458
+ if (!el) return;
1459
+ const scanDiv = document.createElement('div');
1460
+ scanDiv.id = 'explorerScanProgress';
1461
+ scanDiv.className = 'glass';
1462
+ scanDiv.style.cssText = 'padding:12px;margin-top:12px';
1463
+ scanDiv.innerHTML = '<div style="color:var(--text-secondary);font-size:13px">Scanning <span style="font-family:var(--mono);color:var(--accent-blue)">' + escapeHtml(address) + '</span>...</div><div style="margin-top:8px;height:4px;background:var(--bg-input);border-radius:2px;overflow:hidden"><div id="explorerScanBar" style="width:0%;height:100%;background:var(--accent-blue);transition:width 0.3s"></div></div><div id="explorerScanStatus" style="font-size:11px;color:var(--text-muted);margin-top:6px"></div>';
1464
+ el.appendChild(scanDiv);
1465
+
1466
+ fetch(b._url + '/scan-address', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ address }) })
1467
+ .then(res => {
1468
+ const reader = res.body.getReader();
1469
+ const decoder = new TextDecoder();
1470
+ let buffer = '';
1471
+ function pump() {
1472
+ return reader.read().then(({ done, value }) => {
1473
+ if (done) return;
1474
+ buffer += decoder.decode(value, { stream: true });
1475
+ const lines = buffer.split('\n');
1476
+ buffer = lines.pop() || '';
1477
+ for (const line of lines) {
1478
+ if (!line.startsWith('data: ')) continue;
1479
+ try {
1480
+ const ev = JSON.parse(line.slice(6));
1481
+ const bar = document.getElementById('explorerScanBar');
1482
+ const status = document.getElementById('explorerScanStatus');
1483
+ if (ev.phase === 'scanning' && ev.total > 0) {
1484
+ const pct = Math.round((ev.current / ev.total) * 100);
1485
+ if (bar) bar.style.width = pct + '%';
1486
+ if (status) status.textContent = ev.current + '/' + ev.total + (ev.found > 0 ? ' — found ' + ev.found + ' inscription(s)' : '');
1487
+ } else if (ev.phase === 'done' || ev.phase === 'complete') {
1488
+ if (bar) { bar.style.width = '100%'; bar.style.background = 'var(--accent-green)'; }
1489
+ if (status) status.textContent = ev.result ? ev.result.inscriptionsFound + ' inscriptions found' : 'Scan complete';
1490
+ setTimeout(() => lookupAddress(address), 1000);
1491
+ } else if (ev.phase === 'error') {
1492
+ if (bar) bar.style.background = 'var(--accent-red)';
1493
+ if (status) status.textContent = 'Error: ' + (ev.error || 'unknown');
1494
+ }
1495
+ } catch {}
1496
+ }
1497
+ return pump();
1498
+ });
1499
+ }
1500
+ return pump();
1501
+ })
1502
+ .catch(e => { scanDiv.innerHTML = '<div style="color:var(--accent-red)">Scan failed: ' + escapeHtml(e.message) + '</div>'; });
1503
+ }
1504
+
1505
+ function restoreExplorer() {
1506
+ const inp = document.getElementById('txLookupInput');
1507
+ const res = document.getElementById('txLookupResult');
1508
+ if (inp && savedExplorerInput) inp.value = savedExplorerInput;
1509
+ if (res && savedExplorerResult) res.innerHTML = savedExplorerResult;
1510
+ }
1511
+
1512
+ async function lookupTx(bridgeUrl) {
1513
+ const input = document.getElementById('txLookupInput');
1514
+ const txid = input ? input.value.trim() : '';
1515
+ const el = document.getElementById('txLookupResult');
1516
+ if (!el) return;
1517
+ if (!txid || txid.length !== 64 || !/^[0-9a-fA-F]+$/.test(txid)) {
1518
+ el.innerHTML = '<div style="color:var(--accent-red);font-size:12px;margin-top:12px">Enter a valid 64-character hex txid</div>';
1519
+ return;
1520
+ }
1521
+ el.innerHTML = '<div style="color:var(--text-muted);font-size:12px;margin-top:12px">Fetching from bridge...</div>';
1522
+ savedExplorerInput = txid;
1523
+ try {
1524
+ const [txRes, statusRes, proofRes] = await Promise.all([
1525
+ fetch(bridgeUrl + '/tx/' + txid, { signal: AbortSignal.timeout(15000) }),
1526
+ fetch(bridgeUrl + '/tx/' + txid + '/status', { signal: AbortSignal.timeout(5000) }).catch(() => null),
1527
+ fetch(bridgeUrl + '/proof/' + txid, { signal: AbortSignal.timeout(5000) }).catch(() => null)
1528
+ ]);
1529
+ const data = await txRes.json();
1530
+ if (data.error) {
1531
+ el.innerHTML = '<div style="color:var(--accent-red);font-size:12px;margin-top:12px">' + escapeHtml(data.error) + '</div>';
1532
+ savedExplorerResult = el.innerHTML;
1533
+ return;
1534
+ }
1535
+ const txStatus = statusRes && statusRes.ok ? await statusRes.json() : null;
1536
+ const txProof = proofRes && proofRes.ok ? await proofRes.json() : null;
1537
+ el.innerHTML = renderTxDetail(data, txStatus, txProof);
1538
+ savedExplorerResult = el.innerHTML;
1539
+ } catch (e) {
1540
+ el.innerHTML = '<div style="color:var(--accent-red);font-size:12px;margin-top:12px">' + escapeHtml(e.message) + '</div>';
1541
+ savedExplorerResult = el.innerHTML;
1542
+ }
1543
+ }
1544
+
1545
+ function renderTxDetail(tx, txStatus, txProof) {
1546
+ let html = '<div class="glass" style="padding:16px;margin-top:12px">';
1547
+ const src = (tx.source || '').toLowerCase();
1548
+ const isLocal = src === 'local' || src === 'p2p' || src === 'mempool' || src === 'store';
1549
+ const srcDot = isLocal ? 'green' : 'blue';
1550
+ const srcLabel = src === 'woc' ? 'Fetched from WhatsOnChain' : src === 'store' ? 'From your bridge\'s index' : isLocal ? 'Fetched from BSV network' : 'Source: ' + escapeHtml(tx.source || '?');
1551
+ const b = bridgeData.get(selectedBridge);
1552
+ const peerCount = b ? b.peers.connected : 0;
1553
+ html += '<div style="font-size:12px;color:var(--text-muted);margin-bottom:12px">';
1554
+ html += '<span class="status-dot ' + srcDot + '" style="display:inline-block;width:7px;height:7px;border-radius:50%;margin-right:6px;vertical-align:middle"></span>';
1555
+ html += '<span style="color:var(--text-secondary)">' + srcLabel + '</span>';
1556
+ html += ' &middot; ' + (tx.size || 0) + ' bytes';
1557
+ html += ' &middot; ' + (tx.inputs ? tx.inputs.length : 0) + ' in';
1558
+ html += ' &middot; ' + (tx.outputs ? tx.outputs.length : 0) + ' out';
1559
+ html += '</div>';
1560
+ html += '<div style="font-size:11px;color:var(--text-dim);margin-bottom:12px">Your bridge is connected to <strong style="color:var(--text-muted)">' + peerCount + ' peers</strong> on the BSV network</div>';
1561
+
1562
+ // Confirmation status
1563
+ if (txStatus) {
1564
+ const state = txStatus.state || 'unknown';
1565
+ const stateColors = { mempool: 'yellow', confirmed: 'green', orphaned: 'red', dropped: 'red' };
1566
+ const stateColor = stateColors[state] || '';
1567
+ html += '<div style="display:flex;align-items:center;gap:10px;padding:10px 14px;margin-bottom:12px;background:rgba(255,255,255,0.03);border:1px solid var(--border);border-radius:var(--radius-sm)">';
1568
+ html += '<span class="status-dot ' + stateColor + '" style="display:inline-block;width:8px;height:8px;border-radius:50%"></span>';
1569
+ html += '<span style="font-size:12px;color:var(--text-secondary);font-weight:500;text-transform:capitalize">' + escapeHtml(state) + '</span>';
1570
+ if (txStatus.height) html += '<span style="font-size:11px;color:var(--text-muted)">&middot; Block ' + txStatus.height.toLocaleString() + '</span>';
1571
+ if (txStatus.blockHash) html += '<span style="font-size:11px;color:var(--text-dim);font-family:var(--mono)">' + txStatus.blockHash.slice(0, 16) + '...</span>';
1572
+ if (txStatus.firstSeen) html += '<span style="font-size:10px;color:var(--text-dim);margin-left:auto">first seen ' + new Date(txStatus.firstSeen).toLocaleString() + '</span>';
1573
+ html += '</div>';
1574
+ }
1575
+
1576
+ // Merkle proof
1577
+ if (txProof && txProof.proof) {
1578
+ html += '<div style="padding:10px 14px;margin-bottom:12px;background:rgba(63,185,80,0.06);border:1px solid rgba(63,185,80,0.15);border-radius:var(--radius-sm)">';
1579
+ html += '<div style="display:flex;align-items:center;gap:6px;margin-bottom:6px">';
1580
+ html += '<span style="color:var(--accent-green);font-size:12px;font-weight:600">Merkle Proof Verified</span>';
1581
+ html += '</div>';
1582
+ html += '<div style="font-size:11px;color:var(--text-muted)">';
1583
+ html += 'Block: <span style="color:var(--text-secondary);font-family:var(--mono)">' + (txProof.height ? txProof.height.toLocaleString() : '-') + '</span>';
1584
+ html += ' &middot; Index: <span style="color:var(--text-secondary)">' + (txProof.proof.index !== undefined ? txProof.proof.index : '-') + '</span>';
1585
+ html += ' &middot; Path: <span style="color:var(--text-secondary)">' + (txProof.proof.nodes ? txProof.proof.nodes.length : 0) + ' nodes</span>';
1586
+ html += '</div>';
1587
+ if (txProof.blockHash) html += '<div style="font-size:10px;color:var(--text-dim);font-family:var(--mono);margin-top:4px">' + txProof.blockHash + '</div>';
1588
+ html += '</div>';
1589
+ }
1590
+
1591
+ let decodedCallout = false;
1592
+ if (tx.outputs) {
1593
+ for (const o of tx.outputs) {
1594
+ html += '<div class="tx-detail-output">';
1595
+ html += '<div class="tx-detail-row">';
1596
+ html += '<span>' + protocolBadge(o.type, o.protocol) + ' <span style="color:var(--text-muted);font-size:11px">#' + o.vout + '</span></span>';
1597
+ html += '<span style="font-family:var(--mono);font-size:12px;color:var(--text-secondary)">' + fmtSats(o.satoshis) + ' sats</span>';
1598
+ html += '</div>';
1599
+ if (o.hash160) html += '<div style="font-size:10px;color:var(--text-dim);font-family:var(--mono);margin-top:3px">hash160: ' + o.hash160 + '</div>';
1600
+ if (o.parsed) {
1601
+ html += renderParsedData(o.type, o.protocol, o.parsed, tx.txid, o.vout);
1602
+ if (!decodedCallout) { html += '<div style="font-size:13px;color:var(--text-muted);margin-top:6px;font-style:italic">Decoded by your bridge \u2014 block explorers show this as raw hex</div>'; decodedCallout = true; }
1603
+ }
1604
+ else if (o.type === 'op_return' && o.data && o.data.length > 0) {
1605
+ html += renderRawOpReturn(o.data);
1606
+ if (!decodedCallout) { html += '<div style="font-size:13px;color:var(--text-muted);margin-top:6px;font-style:italic">Decoded by your bridge \u2014 block explorers show this as raw hex</div>'; decodedCallout = true; }
1607
+ }
1608
+ html += '</div>';
1609
+ }
1610
+ }
1611
+ html += '</div>';
1612
+ return html;
1613
+ }
1614
+
1615
+ // ── Parsed data rendering ──────────────────────────
1616
+ function renderRawOpReturn(pushes) {
1617
+ let html = '<div class="tx-parsed-data">';
1618
+ html += '<div style="color:var(--text-dim);font-size:10px;margin-bottom:4px;text-transform:uppercase;letter-spacing:0.5px">Push Data</div>';
1619
+ for (let i = 0; i < pushes.length && i < 20; i++) {
1620
+ const hex = pushes[i];
1621
+ if (!hex) { html += '<span style="color:var(--text-dim)">#' + i + ': [empty]</span><br>'; continue; }
1622
+ const ascii = tryDecodeAscii(hex);
1623
+ if (ascii) {
1624
+ html += '#' + i + ': <span class="val">"' + escapeHtml(ascii) + '"</span><br>';
1625
+ } else {
1626
+ const display = hex.length > 40 ? hex.slice(0, 40) + '...' : hex;
1627
+ html += '#' + i + ': <span style="color:var(--text-dim);font-family:var(--mono)">' + display + '</span> <span style="color:var(--text-dim)">(' + (hex.length / 2) + 'B)</span><br>';
1628
+ }
1629
+ }
1630
+ if (pushes.length > 20) html += '<span style="color:var(--text-dim)">... ' + (pushes.length - 20) + ' more pushes</span>';
1631
+ html += '</div>';
1632
+ return html;
1633
+ }
1634
+
1635
+ function renderParsedData(type, protocol, parsed, txid, vout) {
1636
+ if (!parsed) return '';
1637
+ let html = '<div class="tx-parsed-data">';
1638
+
1639
+ if (protocol === 'b') {
1640
+ html += 'Mime: <span class="val">' + escapeHtml(parsed.mimeType || '-') + '</span>';
1641
+ if (parsed.filename) html += ' &middot; File: <span class="val">' + escapeHtml(parsed.filename) + '</span>';
1642
+ if (parsed.encoding) html += ' &middot; Enc: ' + escapeHtml(parsed.encoding);
1643
+ } else if (protocol === 'bcat') {
1644
+ html += 'Mime: <span class="val">' + escapeHtml(parsed.mimeType || '-') + '</span>';
1645
+ if (parsed.filename) html += ' &middot; File: <span class="val">' + escapeHtml(parsed.filename) + '</span>';
1646
+ html += ' &middot; Chunks: <span class="val">' + (parsed.chunkTxids ? parsed.chunkTxids.length : 0) + '</span>';
1647
+ } else if (protocol === 'bcat-part') {
1648
+ html += 'Chunk data: <span class="val">' + (parsed.data ? (parsed.data.length / 2) + ' bytes' : '-') + '</span>';
1649
+ } else if (protocol === 'map') {
1650
+ html += 'Action: <span class="val">' + escapeHtml(parsed.action || '-') + '</span>';
1651
+ if (parsed.pairs) {
1652
+ for (const [k, v] of Object.entries(parsed.pairs)) {
1653
+ html += '<br>' + escapeHtml(k) + ': <span class="val">' + escapeHtml(v) + '</span>';
1654
+ }
1655
+ }
1656
+ } else if (protocol === 'metanet') {
1657
+ html += 'Node: <span class="val">' + escapeHtml(parsed.nodeAddress || '-') + '</span>';
1658
+ if (parsed.parentTxid) html += '<br>Parent: <span class="val" style="font-size:10px">' + parsed.parentTxid + '</span>';
1659
+ } else if (protocol === 'bsv-20') {
1660
+ if (parsed.bsv20) {
1661
+ html += 'Op: <span class="accent">' + escapeHtml(parsed.bsv20.op || '-') + '</span>';
1662
+ if (parsed.bsv20.tick) html += ' &middot; Tick: <span class="val">' + escapeHtml(parsed.bsv20.tick) + '</span>';
1663
+ if (parsed.bsv20.id) html += ' &middot; ID: <span class="val" style="font-size:10px">' + parsed.bsv20.id.slice(0, 16) + '...</span>';
1664
+ if (parsed.bsv20.amt) html += ' &middot; Amt: <span class="val">' + escapeHtml(parsed.bsv20.amt) + '</span>';
1665
+ } else {
1666
+ html += 'Type: <span class="val">' + escapeHtml(parsed.contentType || '-') + '</span>';
1667
+ }
1668
+ } else if (protocol === 'ordinal') {
1669
+ html += 'Type: <span class="val">' + escapeHtml(parsed.contentType || '-') + '</span>';
1670
+ if (parsed.content) html += ' &middot; Size: <span class="val">' + (parsed.content.length / 2) + ' bytes</span>';
1671
+ if (parsed.content && parsed.contentType && parsed.contentType.startsWith('image/')) {
1672
+ const b64 = hexToBase64(parsed.content);
1673
+ html += '</div><div style="margin-top:8px"><img src="data:' + escapeHtml(parsed.contentType) + ';base64,' + b64 + '" style="max-width:100%;max-height:320px;border-radius:var(--radius-sm);border:1px solid var(--border)" alt="inscription">';
1674
+ }
1675
+ } else if (type === 'p2sh') {
1676
+ html += 'Script hash: <span class="val" style="font-family:var(--mono)">' + (parsed.scriptHash || '-') + '</span>';
1677
+ } else if (type === 'multisig') {
1678
+ html += '<span class="val">' + parsed.m + '-of-' + parsed.n + ' multisig</span>';
1679
+ } else {
1680
+ const s = JSON.stringify(parsed);
1681
+ html += '<span class="val">' + escapeHtml(s.length > 120 ? s.slice(0, 120) + '...' : s) + '</span>';
1682
+ }
1683
+
1684
+ html += '</div>';
1685
+ return html;
1686
+ }
1687
+
1688
+
1689
+
1690
+ // ── Mini peer map ──────────────────────────────────
1691
+ function renderMiniMap(b) {
1692
+ if (!b.peers || !b.peers.list || b.peers.list.length === 0) return '';
1693
+ const W = 340, H = 180;
1694
+ const cx = W / 2, cy = H / 2;
1695
+ const peers = b.peers.list;
1696
+ const n = peers.length;
1697
+ const mainR = 22, peerR = 16;
1698
+ const orbit = Math.min(W, H) * 0.32;
1699
+ const positions = [];
1700
+ for (let i = 0; i < n; i++) {
1701
+ const angle = -Math.PI / 2 + (2 * Math.PI * i) / n;
1702
+ positions.push({ x: cx + orbit * Math.cos(angle), y: cy + orbit * Math.sin(angle) });
1703
+ }
1704
+ let svg = '<div class="mini-map"><svg viewBox="0 0 ' + W + ' ' + H + '">';
1705
+ for (let i = 0; i < n; i++) {
1706
+ const cls = peers[i].connected ? 'mm-line' : 'mm-line offline';
1707
+ svg += '<line x1="' + cx + '" y1="' + cy + '" x2="' + positions[i].x + '" y2="' + positions[i].y + '" class="' + cls + '" style="animation:dash 1.5s linear infinite"/>';
1708
+ }
1709
+ svg += '<circle cx="' + cx + '" cy="' + cy + '" r="' + (mainR + 2) + '" class="mm-ring" stroke="' + 'var(--accent-blue)' + '" stroke-width="2"/>';
1710
+ svg += '<circle cx="' + cx + '" cy="' + cy + '" r="' + mainR + '" class="mm-bg"/>';
1711
+ svg += '<text x="' + cx + '" y="' + (cy + 4) + '" class="mm-label name" font-size="8">' + b._name + '</text>';
1712
+ for (let i = 0; i < n; i++) {
1713
+ const p = peers[i];
1714
+ const px = positions[i].x, py = positions[i].y;
1715
+ const ringColor = p.connected ? 'var(--accent-green)' : 'var(--accent-red)';
1716
+ svg += '<circle cx="' + px + '" cy="' + py + '" r="' + (peerR + 2) + '" class="mm-ring" stroke="' + ringColor + '"/>';
1717
+ svg += '<circle cx="' + px + '" cy="' + py + '" r="' + peerR + '" class="mm-bg"/>';
1718
+ svg += '<text x="' + px + '" y="' + (py + 3) + '" class="mm-label" font-size="8">' + truncPubkey(p.pubkeyHex).slice(0, 8) + '</text>';
1719
+ }
1720
+ svg += '</svg></div>';
1721
+ return svg;
1722
+ }
1723
+
1724
+ function renderScoreBars(breakdown) {
1725
+ if (!breakdown) return '';
1726
+ const labels = [['UPT', breakdown.uptime], ['RTT', breakdown.responseTime], ['ACC', breakdown.dataAccuracy], ['STK', breakdown.stakeAge]];
1727
+ let html = '<div class="score-bars">';
1728
+ for (const [label, val] of labels) {
1729
+ const pct = Math.round(val * 100);
1730
+ html += '<div class="score-row"><span class="score-label">' + label + '</span><div class="score-bar-bg"><div class="score-bar-fill ' + scoreColor(val) + '" style="width:' + pct + '%"></div></div><span class="score-val">' + val.toFixed(2) + '</span></div>';
1731
+ }
1732
+ return html + '</div>';
1733
+ }
1734
+
1735
+ // ── Modal system ───────────────────────────────────
1736
+ function openModal(html) { document.getElementById('modalContent').innerHTML = html; document.getElementById('modalOverlay').classList.add('active'); }
1737
+ function closeModal() { document.getElementById('modalOverlay').classList.remove('active'); if (jobSSE) { jobSSE.close(); jobSSE = null; } }
1738
+
1739
+ function showAuthModal(bridgeUrl) {
1740
+ const existing = operatorTokens[bridgeUrl] || '';
1741
+ const bName = BRIDGES.find(b => b.url === bridgeUrl)?.name || '';
1742
+ const bIp = bridgeUrl.replace(/^https?:\/\//, '').replace(/:\d+$/, '');
1743
+ openModal('<h2>Operator Login</h2><div style="margin-bottom:12px;font-size:13px;color:var(--text-secondary)">' + bName + ' &mdash; ' + bIp + '</div><div class="form-group"><label>statusSecret (from bridge config.json)</label><input type="password" id="authInput" value="' + existing + '" placeholder="64-char hex token"></div><div class="modal-actions"><button class="btn" onclick="closeModal()">Cancel</button><button class="btn primary" onclick="saveAuth(\'' + bridgeUrl + '\')">Login</button></div>');
1744
+ setTimeout(() => document.getElementById('authInput').focus(), 100);
1745
+ }
1746
+ function saveAuth(bridgeUrl) { const t = document.getElementById('authInput').value.trim(); if (!t) delete operatorTokens[bridgeUrl]; else operatorTokens[bridgeUrl] = t; localStorage.setItem('relay_operator_tokens', JSON.stringify(operatorTokens)); closeModal(); pollAll(); }
1747
+ function logoutOperator(bridgeUrl) { delete operatorTokens[bridgeUrl]; localStorage.setItem('relay_operator_tokens', JSON.stringify(operatorTokens)); pollAll(); }
1748
+
1749
+ function showRegisterModal(bridgeUrl) {
1750
+ openModal('<h2>Register Bridge</h2><div class="warning">This will broadcast a stake bond + registration transaction to the BSV network. Ensure your bridge wallet is funded.</div><div class="modal-actions"><button class="btn" onclick="closeModal()">Cancel</button><button class="btn primary" id="regBtn" onclick="doRegister(\'' + bridgeUrl + '\')">Register</button></div><div id="jobLog" class="job-log" style="display:none"></div>');
1751
+ }
1752
+ async function doRegister(bridgeUrl) {
1753
+ document.getElementById('regBtn').disabled = true; document.getElementById('regBtn').textContent = 'Registering...'; document.getElementById('jobLog').style.display = 'block';
1754
+ try { const r = await fetch(bridgeUrl + '/register' + getAuthParam(bridgeUrl), { method: 'POST' }); if (!r.ok) { const err = await r.json().catch(() => ({ error: 'HTTP ' + r.status })); appendJobLog('error', err.error || 'Failed'); return; } const { stream } = await r.json(); streamJob(bridgeUrl + stream); } catch (e) { appendJobLog('error', e.message); }
1755
+ }
1756
+
1757
+ function showDeregisterModal(bridgeUrl) {
1758
+ openModal('<h2>Deregister Bridge</h2><div class="form-group"><label>Reason (optional)</label><input type="text" id="deregReason" value="shutdown" placeholder="e.g. maintenance"></div><div class="modal-actions"><button class="btn" onclick="closeModal()">Cancel</button><button class="btn danger" id="deregBtn" onclick="doDeregister(\'' + bridgeUrl + '\')">Deregister</button></div><div id="jobLog" class="job-log" style="display:none"></div>');
1759
+ }
1760
+ async function doDeregister(bridgeUrl) {
1761
+ const reason = document.getElementById('deregReason').value.trim() || 'shutdown'; document.getElementById('deregBtn').disabled = true; document.getElementById('deregBtn').textContent = 'Deregistering...'; document.getElementById('jobLog').style.display = 'block';
1762
+ try { const r = await fetch(bridgeUrl + '/deregister' + getAuthParam(bridgeUrl), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reason }) }); if (!r.ok) { const err = await r.json().catch(() => ({ error: 'HTTP ' + r.status })); appendJobLog('error', err.error || 'Failed'); return; } const { stream } = await r.json(); streamJob(bridgeUrl + stream); } catch (e) { appendJobLog('error', e.message); }
1763
+ }
1764
+
1765
+ function showSendModal(bridgeUrl) {
1766
+ openModal('<h2>Send BSV</h2><div class="form-group"><label>Destination Address</label><input type="text" id="sendAddress" placeholder="1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"></div><div class="form-group"><label>Amount (satoshis)</label><input type="number" id="sendAmount" placeholder="10000" min="546"></div><div class="warning">This will broadcast a transaction from the bridge wallet.</div><div class="modal-actions"><button class="btn" onclick="closeModal()">Cancel</button><button class="btn danger" id="sendBtn" onclick="doSend(\'' + bridgeUrl + '\')">Send</button></div><div id="jobLog" class="job-log" style="display:none"></div>');
1767
+ }
1768
+ async function doSend(bridgeUrl) {
1769
+ const toAddress = document.getElementById('sendAddress').value.trim(); const amount = parseInt(document.getElementById('sendAmount').value, 10); if (!toAddress) { alert('Enter a destination address'); return; } if (!amount || amount < 546) { alert('Amount must be at least 546 sats'); return; }
1770
+ document.getElementById('sendBtn').disabled = true; document.getElementById('sendBtn').textContent = 'Sending...'; document.getElementById('jobLog').style.display = 'block';
1771
+ try { const r = await fetch(bridgeUrl + '/send' + getAuthParam(bridgeUrl), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ toAddress, amount }) }); if (!r.ok) { const err = await r.json().catch(() => ({ error: 'HTTP ' + r.status })); appendJobLog('error', err.error || 'Failed'); return; } const { stream } = await r.json(); streamJob(bridgeUrl + stream); } catch (e) { appendJobLog('error', e.message); }
1772
+ }
1773
+
1774
+ function showConnectModal(bridgeUrl) {
1775
+ openModal('<h2>Connect to Peer</h2><div class="form-group"><label>Peer WebSocket Endpoint</label><input type="text" id="connectEndpoint" placeholder="ws://host:port"></div><div class="modal-actions"><button class="btn" onclick="closeModal()">Cancel</button><button class="btn primary" id="connectBtn" onclick="doConnect(\'' + bridgeUrl + '\')">Connect</button></div><div id="connectResult" style="margin-top:12px;font-size:13px;display:none"></div>');
1776
+ }
1777
+ async function doConnect(bridgeUrl) {
1778
+ const endpoint = document.getElementById('connectEndpoint').value.trim(); if (!endpoint) return; document.getElementById('connectBtn').disabled = true; document.getElementById('connectBtn').textContent = 'Connecting...';
1779
+ try { const r = await fetch(bridgeUrl + '/connect' + getAuthParam(bridgeUrl), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ endpoint }) }); const data = await r.json(); const el = document.getElementById('connectResult'); el.style.display = 'block'; if (r.ok) { el.innerHTML = '<span style="color:var(--accent-green)">' + data.status + ': ' + data.endpoint + '</span>'; setTimeout(() => pollAll(), 2000); } else { el.innerHTML = '<span style="color:var(--accent-red)">' + (data.error || 'Failed') + '</span>'; } } catch (e) { document.getElementById('connectResult').style.display = 'block'; document.getElementById('connectResult').innerHTML = '<span style="color:var(--accent-red)">' + e.message + '</span>'; }
1780
+ }
1781
+
1782
+ // ── Job SSE streaming ──────────────────────────────
1783
+ function streamJob(streamUrl) {
1784
+ if (jobSSE) jobSSE.close();
1785
+ jobSSE = new EventSource(streamUrl);
1786
+ jobSSE.onmessage = (e) => { try { const ev = JSON.parse(e.data); if (ev.type === 'end') { jobSSE.close(); jobSSE = null; if (ev.status === 'completed') appendJobLog('done', 'Completed successfully'); setTimeout(() => pollAll(), 1000); return; } appendJobLog(ev.type || 'step', ev.message); } catch {} };
1787
+ jobSSE.onerror = () => { appendJobLog('error', 'SSE connection lost'); jobSSE.close(); jobSSE = null; };
1788
+ }
1789
+ function appendJobLog(type, message) { const log = document.getElementById('jobLog'); if (!log) return; log.style.display = 'block'; const line = document.createElement('div'); line.className = type; line.textContent = message; log.appendChild(line); log.scrollTop = log.scrollHeight; }
1790
+
1791
+ // ── Inscriptions tab ───────────────────────────────
1792
+ function renderInscriptionsTab() {
1793
+ const b = bridgeData.get(selectedBridge);
1794
+ if (!b) return '<div class="no-results">Select a bridge</div>';
1795
+
1796
+ let html = '<div class="tab-title">Inscriptions</div>';
1797
+ html += '<div class="inscription-filters">';
1798
+ html += '<select id="mimeFilter">';
1799
+ html += '<option value="">All types</option>';
1800
+ html += '<option value="image/png">image/png</option>';
1801
+ html += '<option value="image/jpeg">image/jpeg</option>';
1802
+ html += '<option value="image/webp">image/webp</option>';
1803
+ html += '<option value="image/svg+xml">image/svg+xml</option>';
1804
+ html += '<option value="text/plain">text/plain</option>';
1805
+ html += '<option value="text/html">text/html</option>';
1806
+ html += '<option value="application/json">application/json</option>';
1807
+ html += '<option value="application/bsv-20">BSV-20 tokens</option>';
1808
+ html += '</select>';
1809
+ html += '<input type="text" id="addressFilter" placeholder="Filter by address...">';
1810
+ html += '<button class="btn primary sm" onclick="fetchInscriptions()">Search</button>';
1811
+ html += '<button class="btn sm" style="margin-left:8px;background:rgba(88,166,255,0.15);color:var(--accent-blue);border:1px solid rgba(88,166,255,0.3)" onclick="promptScanAddress()">Scan Address</button>';
1812
+ html += '</div>';
1813
+ html += '<div id="scanProgress" style="display:none"></div>';
1814
+ html += '<div id="inscriptionResults"><div style="color:var(--text-dim);text-align:center;padding:30px">Click Search to load inscriptions from the bridge index, or Scan Address to import from the blockchain</div></div>';
1815
+ return html;
1816
+ }
1817
+
1818
+ async function fetchInscriptions() {
1819
+ const b = bridgeData.get(selectedBridge);
1820
+ if (!b) return;
1821
+ const mime = document.getElementById('mimeFilter')?.value || '';
1822
+ const address = document.getElementById('addressFilter')?.value.trim() || '';
1823
+ const el = document.getElementById('inscriptionResults');
1824
+ if (!el) return;
1825
+
1826
+ el.innerHTML = '<div style="color:var(--text-muted);text-align:center;padding:30px">Loading...</div>';
1827
+
1828
+ let url = b._url + '/inscriptions?limit=50';
1829
+ if (mime) url += '&mime=' + encodeURIComponent(mime);
1830
+ if (address) url += '&address=' + encodeURIComponent(address);
1831
+
1832
+ try {
1833
+ const r = await fetch(url, { signal: AbortSignal.timeout(10000) });
1834
+ const data = await r.json();
1835
+ if (data.error) { el.innerHTML = '<div style="color:var(--accent-red);padding:20px">' + escapeHtml(data.error) + '</div>'; return; }
1836
+ renderInscriptionList(el, data);
1837
+ } catch (e) {
1838
+ el.innerHTML = '<div style="color:var(--accent-red);padding:20px">' + escapeHtml(e.message) + '</div>';
1839
+ }
1840
+ }
1841
+
1842
+ function renderInscriptionList(el, data) {
1843
+ if (data.inscriptions.length === 0) {
1844
+ el.innerHTML = '<div class="glass" style="padding:30px;text-align:center"><div style="color:var(--text-dim)">No inscriptions found</div><div style="color:var(--text-dim);font-size:11px;margin-top:4px">' + data.total + ' total indexed</div></div>';
1845
+ return;
1846
+ }
1847
+
1848
+ let html = '<div style="color:var(--text-muted);font-size:11px;margin-bottom:10px">' + data.count + ' results &middot; ' + data.total + ' total indexed</div>';
1849
+ html += '<div class="inscription-grid">';
1850
+ for (const insc of data.inscriptions) {
1851
+ html += '<div class="inscription-card glass">';
1852
+ if (insc.contentType && insc.contentType.startsWith('image/')) {
1853
+ const src = '/inscription/' + insc.txid + '/' + insc.vout + '/content';
1854
+ html += '<img class="insc-thumb" src="' + src + '" alt="inscription" loading="lazy" onclick="window.open(\'' + src + '\',\'_blank\')">';
1855
+ }
1856
+ html += '<div class="insc-txid" onclick="setTab(\'explorer\');setTimeout(()=>{const i=document.getElementById(\'txLookupInput\');if(i){i.value=\'' + insc.txid + '\';explorerSearch()}},50)">' + insc.txid + ':' + insc.vout + '</div>';
1857
+ html += '<div class="insc-meta">';
1858
+ html += protocolBadge('ordinal', insc.isBsv20 ? 'bsv-20' : 'ordinal');
1859
+ if (insc.contentHash) html += '<span style="background:rgba(63,185,80,0.15);color:var(--accent-green);padding:1px 6px;border-radius:4px;font-size:10px;font-weight:600">CAS</span>';
1860
+ if (insc.contentType) html += '<span>' + escapeHtml(insc.contentType) + '</span>';
1861
+ if (insc.contentSize) html += '<span>' + insc.contentSize + ' bytes</span>';
1862
+ html += '<span>' + new Date(insc.timestamp).toLocaleString() + '</span>';
1863
+ html += '</div>';
1864
+ if (insc.isBsv20 && insc.bsv20) {
1865
+ html += '<div style="font-size:11px;margin-top:6px;color:var(--text-muted)">';
1866
+ html += 'Op: <span style="color:#ffc53d">' + escapeHtml(insc.bsv20.op || '-') + '</span>';
1867
+ if (insc.bsv20.tick) html += ' &middot; Tick: <span style="color:var(--text-secondary)">' + escapeHtml(insc.bsv20.tick) + '</span>';
1868
+ if (insc.bsv20.amt) html += ' &middot; Amt: <span style="color:var(--text-secondary)">' + escapeHtml(insc.bsv20.amt) + '</span>';
1869
+ html += '</div>';
1870
+ }
1871
+ if (insc.address) {
1872
+ html += '<div style="font-size:10px;margin-top:4px;color:var(--text-dim);font-family:var(--mono)">addr: ' + insc.address + '</div>';
1873
+ }
1874
+ html += '</div>';
1875
+ }
1876
+ html += '</div>';
1877
+ el.innerHTML = html;
1878
+ }
1879
+
1880
+ // ── Address scanner ────────────────────────────────
1881
+ function promptScanAddress() {
1882
+ const addr = document.getElementById('addressFilter')?.value.trim();
1883
+ if (addr) { startScanAddress(addr); return; }
1884
+ openModal(
1885
+ '<h3 style="margin-bottom:12px;color:var(--text-primary)">Scan Address</h3>' +
1886
+ '<p style="font-size:13px;color:var(--text-secondary);margin-bottom:12px">Enter a BSV address to scan for inscriptions. This will fetch transaction history from WhatsOnChain and index any ordinal inscriptions found.</p>' +
1887
+ '<input type="text" id="scanAddressInput" placeholder="1Abc..." style="width:100%;padding:8px 12px;background:var(--bg-input);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text-primary);font-family:var(--mono);font-size:13px;margin-bottom:12px">' +
1888
+ '<button class="btn primary" onclick="startScanAddress(document.getElementById(\'scanAddressInput\').value.trim());closeModal()">Start Scan</button>'
1889
+ );
1890
+ }
1891
+
1892
+ async function startScanAddress(address) {
1893
+ if (!address || address.length < 25 || address.length > 35) {
1894
+ const el = document.getElementById('scanProgress');
1895
+ if (el) { el.style.display = 'block'; el.innerHTML = '<div class="glass" style="padding:12px;color:var(--accent-red)">Invalid address</div>'; }
1896
+ return;
1897
+ }
1898
+ const b = bridgeData.get(selectedBridge);
1899
+ if (!b) return;
1900
+
1901
+ const el = document.getElementById('scanProgress');
1902
+ if (!el) return;
1903
+ el.style.display = 'block';
1904
+ el.innerHTML = '<div class="glass" style="padding:12px"><div style="color:var(--text-secondary);font-size:13px">Starting scan for <span style="font-family:var(--mono);color:var(--accent-blue)">' + escapeHtml(address) + '</span>...</div><div id="scanProgressBar" style="margin-top:8px;height:4px;background:var(--bg-input);border-radius:2px;overflow:hidden"><div id="scanBar" style="width:0%;height:100%;background:var(--accent-blue);transition:width 0.3s"></div></div><div id="scanStatus" style="font-size:11px;color:var(--text-muted);margin-top:6px"></div></div>';
1905
+
1906
+ try {
1907
+ const res = await fetch(b._url + '/scan-address', {
1908
+ method: 'POST',
1909
+ headers: { 'Content-Type': 'application/json' },
1910
+ body: JSON.stringify({ address })
1911
+ });
1912
+
1913
+ const reader = res.body.getReader();
1914
+ const decoder = new TextDecoder();
1915
+ let buffer = '';
1916
+
1917
+ while (true) {
1918
+ const { done, value } = await reader.read();
1919
+ if (done) break;
1920
+ buffer += decoder.decode(value, { stream: true });
1921
+ const lines = buffer.split('\n');
1922
+ buffer = lines.pop() || '';
1923
+ for (const line of lines) {
1924
+ if (!line.startsWith('data: ')) continue;
1925
+ try {
1926
+ const ev = JSON.parse(line.slice(6));
1927
+ const bar = document.getElementById('scanBar');
1928
+ const status = document.getElementById('scanStatus');
1929
+ if (ev.phase === 'scanning' && ev.total > 0) {
1930
+ const pct = Math.round((ev.current / ev.total) * 100);
1931
+ if (bar) bar.style.width = pct + '%';
1932
+ if (status) {
1933
+ let msg = ev.current + '/' + ev.total;
1934
+ if (ev.found > 0) msg += ' — found ' + ev.found + ' inscription(s)';
1935
+ if (ev.error) msg += ' — error: ' + ev.error;
1936
+ status.textContent = msg;
1937
+ }
1938
+ } else if (ev.phase === 'discovery') {
1939
+ if (status) status.textContent = ev.message || 'Discovering...';
1940
+ } else if (ev.phase === 'done' || ev.phase === 'complete') {
1941
+ if (bar) bar.style.width = '100%';
1942
+ if (bar) bar.style.background = 'var(--accent-green)';
1943
+ if (status) status.textContent = ev.message || 'Scan complete';
1944
+ if (ev.result) {
1945
+ status.textContent = 'Done: ' + ev.result.txsScanned + ' txs scanned, ' + ev.result.inscriptionsFound + ' inscriptions found';
1946
+ }
1947
+ setTimeout(() => {
1948
+ const addrInput = document.getElementById('addressFilter');
1949
+ if (addrInput) addrInput.value = address;
1950
+ fetchInscriptions();
1951
+ }, 500);
1952
+ } else if (ev.phase === 'error') {
1953
+ if (bar) bar.style.background = 'var(--accent-red)';
1954
+ if (status) status.textContent = 'Error: ' + (ev.error || 'unknown');
1955
+ }
1956
+ } catch {}
1957
+ }
1958
+ }
1959
+ } catch (e) {
1960
+ el.innerHTML = '<div class="glass" style="padding:12px;color:var(--accent-red)">Scan failed: ' + escapeHtml(e.message) + '</div>';
1961
+ }
1962
+ }
1963
+
1964
+ // ── Tokens tab ──────────────────────────────────────
1965
+ function renderTokensTab() {
1966
+ const b = bridgeData.get(selectedBridge);
1967
+ if (!b) return '<div class="no-results">Select a bridge</div>';
1968
+
1969
+ let html = '<div class="tab-title">BSV-20 Tokens</div>';
1970
+ html += '<div class="inscription-filters">';
1971
+ html += '<input type="text" id="tokenTickFilter" placeholder="Filter by tick...">';
1972
+ html += '<button class="btn primary sm" onclick="fetchTokens()">Load Tokens</button>';
1973
+ html += '</div>';
1974
+ html += '<div id="tokenResults"><div style="color:var(--text-dim);text-align:center;padding:30px">Click Load Tokens to fetch indexed BSV-20 tokens from this bridge</div></div>';
1975
+ return html;
1976
+ }
1977
+
1978
+ async function fetchTokens() {
1979
+ const b = bridgeData.get(selectedBridge);
1980
+ if (!b) return;
1981
+ const el = document.getElementById('tokenResults');
1982
+ if (!el) return;
1983
+ el.innerHTML = '<div style="color:var(--text-muted);text-align:center;padding:30px">Loading...</div>';
1984
+
1985
+ try {
1986
+ const r = await fetch(b._url + '/tokens', { signal: AbortSignal.timeout(10000) });
1987
+ const data = await r.json();
1988
+ if (data.error) { el.innerHTML = '<div style="color:var(--accent-red);padding:20px">' + escapeHtml(data.error) + '</div>'; return; }
1989
+
1990
+ const tokens = data.tokens || [];
1991
+ const filter = (document.getElementById('tokenTickFilter')?.value || '').trim().toLowerCase();
1992
+ const filtered = filter ? tokens.filter(t => (t.tick || '').toLowerCase().includes(filter)) : tokens;
1993
+
1994
+ if (filtered.length === 0) {
1995
+ el.innerHTML = '<div class="glass" style="padding:30px;text-align:center"><div style="color:var(--text-dim)">No BSV-20 tokens found</div></div>';
1996
+ return;
1997
+ }
1998
+
1999
+ let html = '<div style="color:var(--text-muted);font-size:11px;margin-bottom:10px">' + filtered.length + ' token' + (filtered.length !== 1 ? 's' : '') + ' indexed</div>';
2000
+ html += '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:10px">';
2001
+ for (const tok of filtered) {
2002
+ html += '<div class="glass" style="padding:14px;cursor:pointer" onclick="showTokenDetail(\'' + escapeHtml(tok.tick) + '\')">';
2003
+ html += '<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">';
2004
+ html += protocolBadge('op_return', 'bsv-20');
2005
+ html += '<span style="font-size:14px;font-weight:600;color:var(--text-primary)">' + escapeHtml(tok.tick) + '</span>';
2006
+ html += '</div>';
2007
+ html += '<div style="font-size:11px;color:var(--text-muted)">';
2008
+ if (tok.max) html += 'Max: <span style="color:var(--text-secondary)">' + escapeHtml(String(tok.max)) + '</span> &middot; ';
2009
+ if (tok.lim) html += 'Limit: <span style="color:var(--text-secondary)">' + escapeHtml(String(tok.lim)) + '</span> &middot; ';
2010
+ html += 'Mints: <span style="color:var(--text-secondary)">' + (tok.mintCount || 0) + '</span>';
2011
+ html += '</div>';
2012
+ if (tok.deployTxid) html += '<div style="font-size:10px;color:var(--text-dim);font-family:var(--mono);margin-top:6px">' + tok.deployTxid.slice(0, 20) + '...</div>';
2013
+ html += '</div>';
2014
+ }
2015
+ html += '</div>';
2016
+ el.innerHTML = html;
2017
+ } catch (e) {
2018
+ el.innerHTML = '<div style="color:var(--accent-red);padding:20px">' + escapeHtml(e.message) + '</div>';
2019
+ }
2020
+ }
2021
+
2022
+ async function showTokenDetail(tick) {
2023
+ const b = bridgeData.get(selectedBridge);
2024
+ if (!b) return;
2025
+ try {
2026
+ const r = await fetch(b._url + '/token/' + encodeURIComponent(tick), { signal: AbortSignal.timeout(10000) });
2027
+ const tok = await r.json();
2028
+ if (tok.error) { alert(tok.error); return; }
2029
+
2030
+ let body = '<h3 style="margin-bottom:12px;color:var(--text-primary)">' + protocolBadge('op_return', 'bsv-20') + ' ' + escapeHtml(tick) + '</h3>';
2031
+ body += '<div style="display:grid;gap:8px;font-size:13px">';
2032
+ body += '<div class="panel-row"><span class="label">Max Supply</span><span class="value">' + escapeHtml(String(tok.max || '-')) + '</span></div>';
2033
+ body += '<div class="panel-row"><span class="label">Mint Limit</span><span class="value">' + escapeHtml(String(tok.lim || '-')) + '</span></div>';
2034
+ body += '<div class="panel-row"><span class="label">Decimals</span><span class="value">' + (tok.dec != null ? tok.dec : '-') + '</span></div>';
2035
+ body += '<div class="panel-row"><span class="label">Mints</span><span class="value">' + (tok.mintCount || 0) + '</span></div>';
2036
+ if (tok.deployTxid) body += '<div class="panel-row"><span class="label">Deploy Tx</span><span class="value" style="font-size:10px;font-family:var(--mono);word-break:break-all">' + escapeHtml(tok.deployTxid) + '</span></div>';
2037
+ body += '</div>';
2038
+
2039
+ // Balance lookup
2040
+ body += '<div style="margin-top:16px;border-top:1px solid var(--border);padding-top:12px">';
2041
+ body += '<div style="font-size:12px;color:var(--text-muted);margin-bottom:8px">Check balance by address script hash:</div>';
2042
+ body += '<div style="display:flex;gap:8px"><input type="text" id="tokenBalAddr" placeholder="Script hash..." style="flex:1;padding:6px 10px;background:var(--bg-input);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text-primary);font-family:var(--mono);font-size:12px"><button class="btn sm primary" onclick="checkTokenBalance(\'' + escapeHtml(tick) + '\')">Check</button></div>';
2043
+ body += '<div id="tokenBalResult" style="margin-top:8px;font-size:12px"></div>';
2044
+ body += '</div>';
2045
+
2046
+ openModal(body);
2047
+ } catch (e) {
2048
+ alert('Failed: ' + e.message);
2049
+ }
2050
+ }
2051
+
2052
+ async function checkTokenBalance(tick) {
2053
+ const b = bridgeData.get(selectedBridge);
2054
+ if (!b) return;
2055
+ const sh = document.getElementById('tokenBalAddr')?.value.trim();
2056
+ const el = document.getElementById('tokenBalResult');
2057
+ if (!sh || !el) return;
2058
+ el.innerHTML = '<span style="color:var(--text-muted)">Checking...</span>';
2059
+ try {
2060
+ const r = await fetch(b._url + '/token/' + encodeURIComponent(tick) + '/balance/' + encodeURIComponent(sh), { signal: AbortSignal.timeout(10000) });
2061
+ const data = await r.json();
2062
+ if (data.error) { el.innerHTML = '<span style="color:var(--accent-red)">' + escapeHtml(data.error) + '</span>'; return; }
2063
+ el.innerHTML = '<span style="color:var(--accent-green);font-weight:600">' + escapeHtml(String(data.balance || 0)) + '</span> <span style="color:var(--text-muted)">' + escapeHtml(tick) + '</span>';
2064
+ } catch (e) {
2065
+ el.innerHTML = '<span style="color:var(--accent-red)">' + escapeHtml(e.message) + '</span>';
2066
+ }
2067
+ }
2068
+
2069
+ // ── Apps tab ────────────────────────────────────────
2070
+ function renderAppsTab() {
2071
+ let html = '<div style="margin-bottom:16px"><span style="font-size:15px;font-weight:600;color:var(--text-primary)">Apps</span>';
2072
+ html += '<span style="font-size:11px;color:var(--text-muted);margin-left:10px">Apps running on your bridge</span></div>';
2073
+
2074
+ if (!appsData || appsData.length === 0) {
2075
+ html += '<div class="glass" style="padding:30px;text-align:center"><div style="color:var(--text-dim)">No apps configured</div>';
2076
+ html += '<div style="color:var(--text-dim);font-size:11px;margin-top:4px">Add an "apps" array to your bridge config</div></div>';
2077
+ return html;
2078
+ }
2079
+
2080
+ html += '<div class="apps-grid">';
2081
+ for (const app of appsData) {
2082
+ html += '<div class="app-card glass">';
2083
+
2084
+ // Header: health dot + name + SSL badge
2085
+ const hStatus = app.health?.status || 'unknown';
2086
+ html += '<div class="app-card-header">';
2087
+ html += '<span class="app-health-dot ' + hStatus + '"></span>';
2088
+ html += '<span class="app-name"><a href="' + escapeHtml(app.url) + '" target="_blank">' + escapeHtml(app.name) + '</a></span>';
2089
+ if (app.ssl) {
2090
+ let sslClass = 'unknown', sslLabel = 'SSL ?';
2091
+ if (app.ssl.valid && app.ssl.daysRemaining > 30) { sslClass = 'valid'; sslLabel = 'SSL OK'; }
2092
+ else if (app.ssl.valid && app.ssl.daysRemaining <= 30) { sslClass = 'expiring'; sslLabel = app.ssl.daysRemaining + 'd left'; }
2093
+ else if (app.ssl.valid === false) { sslClass = 'expired'; sslLabel = 'SSL expired'; }
2094
+ html += '<span class="ssl-badge ' + sslClass + '">' + sslLabel + '</span>';
2095
+ }
2096
+ html += '</div>';
2097
+
2098
+ // URLs
2099
+ html += '<div class="app-urls">' + escapeHtml(app.url);
2100
+ if (app.bridgeDomain) html += '<br>bridge: ' + escapeHtml(app.bridgeDomain);
2101
+ html += '</div>';
2102
+
2103
+ // Status + response time
2104
+ if (app.health) {
2105
+ const h = app.health;
2106
+ const ms = h.responseTimeMs || 0;
2107
+ const msColor = ms < 500 ? 'green' : ms < 2000 ? 'yellow' : 'red';
2108
+ const msWidth = Math.max(8, Math.min(100, (ms / 3000) * 100));
2109
+ html += '<div class="app-stat-row">';
2110
+ html += '<span class="label">Status</span>';
2111
+ html += '<span class="value">' + (h.statusCode || '-') + '</span>';
2112
+ html += '<span class="label" style="margin-left:auto">Latency</span>';
2113
+ html += '<span class="value">' + ms + 'ms</span>';
2114
+ html += '</div>';
2115
+ html += '<div class="app-bar-bg"><div class="app-bar-fill ' + msColor + '" style="width:' + msWidth + '%"></div></div>';
2116
+
2117
+ // Uptime
2118
+ const pct = h.uptimePercent != null ? h.uptimePercent : 0;
2119
+ const upColor = pct >= 99 ? 'green' : pct >= 90 ? 'yellow' : 'red';
2120
+ html += '<div class="app-stat-row">';
2121
+ html += '<span class="label">Uptime</span>';
2122
+ html += '<span class="value">' + pct.toFixed(1) + '%</span>';
2123
+ html += '<span style="font-size:10px;color:var(--text-dim);margin-left:auto">' + (h.checksUp || 0) + '/' + (h.checksTotal || 0) + ' checks</span>';
2124
+ html += '</div>';
2125
+ html += '<div class="app-bar-bg"><div class="app-bar-fill ' + upColor + '" style="width:' + pct + '%"></div></div>';
2126
+ }
2127
+
2128
+ // SSL details
2129
+ if (app.ssl && app.ssl.valid != null) {
2130
+ html += '<div class="app-stat-row">';
2131
+ html += '<span class="label">SSL</span>';
2132
+ html += '<span class="value" style="font-size:11px">' + escapeHtml(app.ssl.issuer || '-') + '</span>';
2133
+ if (app.ssl.expiresAt) html += '<span style="font-size:10px;color:var(--text-dim);margin-left:auto">exp ' + new Date(app.ssl.expiresAt).toLocaleDateString() + '</span>';
2134
+ html += '</div>';
2135
+ }
2136
+
2137
+ // Bridge usage
2138
+ if (app.usage) {
2139
+ html += '<div class="app-stat-row">';
2140
+ html += '<span class="label">Requests</span>';
2141
+ html += '<span class="value">' + (app.usage.totalRequests || 0).toLocaleString() + '</span>';
2142
+ if (app.usage.lastSeen) html += '<span style="font-size:10px;color:var(--text-dim);margin-left:auto">last ' + new Date(app.usage.lastSeen).toLocaleTimeString() + '</span>';
2143
+ html += '</div>';
2144
+
2145
+ // Endpoint breakdown (top 8)
2146
+ const eps = app.usage.endpoints ? Object.entries(app.usage.endpoints).sort((a, b) => b[1] - a[1]).slice(0, 8) : [];
2147
+ if (eps.length > 0) {
2148
+ html += '<div class="ep-list">';
2149
+ for (const [path, count] of eps) {
2150
+ html += '<div class="ep-row"><span class="ep-path">' + escapeHtml(path) + '</span><span class="ep-count">' + count + '</span></div>';
2151
+ }
2152
+ html += '</div>';
2153
+ }
2154
+ }
2155
+
2156
+ // Last error
2157
+ if (app.health?.lastError) {
2158
+ html += '<div class="app-error-box">' + escapeHtml(typeof app.health.lastError === 'object' ? app.health.lastError.message : app.health.lastError) + '</div>';
2159
+ }
2160
+
2161
+ // Last check timestamp
2162
+ if (app.health?.lastCheck) {
2163
+ html += '<div style="font-size:10px;color:var(--text-dim);margin-top:4px">checked ' + new Date(app.health.lastCheck).toLocaleTimeString() + '</div>';
2164
+ }
2165
+
2166
+ html += '</div>';
2167
+ }
2168
+ html += '</div>';
2169
+ return html;
2170
+ }
2171
+
2172
+ async function fetchAppsData() {
2173
+ const b = bridgeData.get(selectedBridge);
2174
+ if (!b) return;
2175
+ try {
2176
+ const r = await fetch(b._url + '/apps', { signal: AbortSignal.timeout(10000) });
2177
+ if (!r.ok) { appsData = []; if (activeTab === 'apps') { const el = document.getElementById('tabContent'); if (el) el.innerHTML = renderAppsTab(); } return; }
2178
+ const data = await r.json();
2179
+ if (Array.isArray(data)) appsData = data;
2180
+ else if (data.apps) appsData = data.apps;
2181
+ else appsData = [];
2182
+ if (activeTab === 'apps') {
2183
+ const el = document.getElementById('tabContent');
2184
+ if (el) el.innerHTML = renderAppsTab();
2185
+ }
2186
+ } catch (e) {
2187
+ appsData = [];
2188
+ if (activeTab === 'apps') {
2189
+ const el = document.getElementById('tabContent');
2190
+ if (el) el.innerHTML = '<div class="glass" style="padding:20px;color:var(--accent-red)">' + escapeHtml(e.message) + '</div>';
2191
+ }
2192
+ }
2193
+ }
2194
+
2195
+ // ── Log viewer ─────────────────────────────────────
2196
+ function openLogViewer(bridgeUrl) {
2197
+ document.getElementById('logViewerBody').innerHTML = '';
2198
+ document.getElementById('logViewer').classList.add('active');
2199
+ if (logSSE) logSSE.close();
2200
+ logSSE = new EventSource(bridgeUrl + '/logs');
2201
+ logSSE.onmessage = (e) => { try { const entry = JSON.parse(e.data); const body = document.getElementById('logViewerBody'); const line = document.createElement('div'); line.className = 'log-line'; line.innerHTML = '<span class="log-ts">' + new Date(entry.timestamp).toLocaleTimeString() + '</span>' + escapeHtml(entry.message); body.appendChild(line); while (body.children.length > 200) body.removeChild(body.firstChild); body.scrollTop = body.scrollHeight; } catch {} };
2202
+ logSSE.onerror = () => { const body = document.getElementById('logViewerBody'); const line = document.createElement('div'); line.className = 'log-line'; line.style.color = 'var(--accent-red)'; line.textContent = 'SSE connection lost'; body.appendChild(line); };
2203
+ }
2204
+ function closeLogViewer() { document.getElementById('logViewer').classList.remove('active'); if (logSSE) { logSSE.close(); logSSE = null; } }
2205
+
2206
+ // ── Init ───────────────────────────────────────────
2207
+ pollAll();
2208
+ setInterval(pollAll, POLL_INTERVAL);
2209
+ setInterval(discoverBridges, 60000);
2210
+ </script>
2211
+ </body>
2212
+ </html>