csm-dashboard 0.2.0__py3-none-any.whl

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.
src/web/app.py ADDED
@@ -0,0 +1,576 @@
1
+ """FastAPI application factory."""
2
+
3
+ from fastapi import FastAPI
4
+ from fastapi.responses import HTMLResponse
5
+
6
+ from .routes import router
7
+
8
+
9
+ def create_app() -> FastAPI:
10
+ """Create and configure the FastAPI application."""
11
+ app = FastAPI(
12
+ title="CSM Operator Dashboard",
13
+ description="Track your Lido CSM validator earnings",
14
+ version="0.1.0",
15
+ )
16
+
17
+ app.include_router(router, prefix="/api")
18
+
19
+ @app.get("/", response_class=HTMLResponse)
20
+ async def index():
21
+ return """
22
+ <!DOCTYPE html>
23
+ <html>
24
+ <head>
25
+ <title>CSM Operator Dashboard</title>
26
+ <script src="https://cdn.tailwindcss.com"></script>
27
+ </head>
28
+ <body class="bg-gray-900 text-white min-h-screen p-8">
29
+ <div class="max-w-4xl mx-auto">
30
+ <h1 class="text-3xl font-bold mb-2">CSM Operator Dashboard</h1>
31
+ <p class="text-gray-400 mb-8">Track your Lido Community Staking Module validator earnings</p>
32
+
33
+ <form id="lookup-form" class="mb-8">
34
+ <div class="flex gap-4">
35
+ <input type="text" id="address"
36
+ placeholder="Enter Ethereum address or Operator ID"
37
+ class="flex-1 p-3 bg-gray-800 rounded text-white border border-gray-700 focus:border-blue-500 focus:outline-none" />
38
+ <button type="submit"
39
+ class="px-6 py-3 bg-blue-600 rounded hover:bg-blue-700 font-medium">
40
+ Check Rewards
41
+ </button>
42
+ </div>
43
+ </form>
44
+
45
+ <div id="loading" class="hidden">
46
+ <div class="flex items-center justify-center p-8">
47
+ <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
48
+ <span class="ml-3">Loading...</span>
49
+ </div>
50
+ </div>
51
+
52
+ <div id="error" class="hidden bg-red-900/50 border border-red-500 rounded p-4 mb-4">
53
+ <p id="error-message" class="text-red-300"></p>
54
+ </div>
55
+
56
+ <div id="results" class="hidden">
57
+ <div class="bg-gray-800 rounded-lg p-6 mb-6">
58
+ <h2 class="text-xl font-bold mb-2">
59
+ Operator #<span id="operator-id"></span>
60
+ </h2>
61
+ <div id="active-since-row" class="hidden text-sm text-green-400 mb-3">
62
+ Active Since: <span id="active-since"></span>
63
+ </div>
64
+ <div class="grid grid-cols-2 gap-4 text-sm">
65
+ <div>
66
+ <span class="text-gray-400">Manager:</span>
67
+ <span id="manager-address" class="font-mono text-xs break-all"></span>
68
+ </div>
69
+ <div>
70
+ <span class="text-gray-400">Rewards:</span>
71
+ <span id="reward-address" class="font-mono text-xs break-all"></span>
72
+ </div>
73
+ </div>
74
+ <div id="lookup-tip" class="hidden mt-3 text-sm text-gray-400 bg-gray-700/50 rounded px-3 py-2">
75
+ Tip: Use operator ID <span id="tip-operator-id" class="font-bold text-blue-400"></span> directly for faster lookups
76
+ </div>
77
+ </div>
78
+
79
+ <div class="grid grid-cols-3 gap-4 mb-6">
80
+ <div class="bg-gray-800 rounded-lg p-4 text-center">
81
+ <div class="text-2xl font-bold" id="total-validators">0</div>
82
+ <div class="text-gray-400 text-sm">Total Validators</div>
83
+ </div>
84
+ <div class="bg-gray-800 rounded-lg p-4 text-center">
85
+ <div class="text-2xl font-bold text-green-400" id="active-validators">0</div>
86
+ <div class="text-gray-400 text-sm">Active</div>
87
+ </div>
88
+ <div class="bg-gray-800 rounded-lg p-4 text-center">
89
+ <div class="text-2xl font-bold text-gray-500" id="exited-validators">0</div>
90
+ <div class="text-gray-400 text-sm">Exited</div>
91
+ </div>
92
+ </div>
93
+
94
+ <div id="validator-status" class="hidden mb-6 bg-gray-800 rounded-lg p-6">
95
+ <h3 class="text-lg font-bold mb-4">Validator Status (Beacon Chain)</h3>
96
+ <div class="grid grid-cols-3 md:grid-cols-6 gap-3 mb-4">
97
+ <div class="bg-green-900/50 rounded-lg p-3 text-center">
98
+ <div class="text-xl font-bold text-green-400" id="status-active">0</div>
99
+ <div class="text-xs text-gray-400">Active</div>
100
+ </div>
101
+ <div class="bg-yellow-900/50 rounded-lg p-3 text-center">
102
+ <div class="text-xl font-bold text-yellow-400" id="status-pending">0</div>
103
+ <div class="text-xs text-gray-400">Pending</div>
104
+ </div>
105
+ <div class="bg-yellow-900/50 rounded-lg p-3 text-center">
106
+ <div class="text-xl font-bold text-yellow-400" id="status-exiting">0</div>
107
+ <div class="text-xs text-gray-400">Exiting</div>
108
+ </div>
109
+ <div class="bg-gray-700 rounded-lg p-3 text-center">
110
+ <div class="text-xl font-bold text-gray-400" id="status-exited">0</div>
111
+ <div class="text-xs text-gray-400">Exited</div>
112
+ </div>
113
+ <div class="bg-red-900/50 rounded-lg p-3 text-center">
114
+ <div class="text-xl font-bold text-red-400" id="status-slashed">0</div>
115
+ <div class="text-xs text-gray-400">Slashed</div>
116
+ </div>
117
+ <div class="bg-gray-700 rounded-lg p-3 text-center">
118
+ <div class="text-xl font-bold text-gray-500" id="status-unknown">0</div>
119
+ <div class="text-xs text-gray-400">Unknown</div>
120
+ </div>
121
+ </div>
122
+ <div id="effectiveness-section" class="hidden border-t border-gray-700 pt-4 mt-4">
123
+ <div class="flex items-center justify-between">
124
+ <span class="text-gray-400">Average Attestation Effectiveness</span>
125
+ <span class="text-xl font-bold text-green-400"><span id="avg-effectiveness">--</span>%</span>
126
+ </div>
127
+ </div>
128
+ </div>
129
+
130
+ <div id="health-section" class="hidden mb-6 bg-gray-800 rounded-lg p-6">
131
+ <h3 class="text-lg font-bold mb-4">Health Status</h3>
132
+ <div class="space-y-3">
133
+ <div class="flex justify-between items-center">
134
+ <span class="text-gray-400">Bond</span>
135
+ <span id="health-bond" class="font-medium">--</span>
136
+ </div>
137
+ <div class="flex justify-between items-center">
138
+ <span class="text-gray-400">Stuck Validators</span>
139
+ <span id="health-stuck" class="font-medium">--</span>
140
+ </div>
141
+ <div class="flex justify-between items-center">
142
+ <span class="text-gray-400">Slashed</span>
143
+ <span id="health-slashed" class="font-medium">--</span>
144
+ </div>
145
+ <div class="flex justify-between items-center">
146
+ <span class="text-gray-400">At Risk (<32 ETH)</span>
147
+ <span id="health-at-risk" class="font-medium">--</span>
148
+ </div>
149
+ <div class="flex justify-between items-center">
150
+ <span class="text-gray-400">Performance Strikes</span>
151
+ <span id="health-strikes" class="font-medium">--</span>
152
+ </div>
153
+ <div id="strikes-detail" class="hidden">
154
+ <button id="toggle-strikes" class="text-sm text-purple-400 hover:text-purple-300 mt-1 mb-2">
155
+ Show validator details ▼
156
+ </button>
157
+ <div id="strikes-list" class="hidden pl-4 border-l-2 border-gray-600 space-y-1 text-sm font-mono max-h-64 overflow-y-auto">
158
+ <!-- Populated by JavaScript -->
159
+ </div>
160
+ </div>
161
+ <hr class="border-gray-700">
162
+ <div class="flex justify-between items-center">
163
+ <span class="font-bold">Overall</span>
164
+ <span id="health-overall" class="font-bold">--</span>
165
+ </div>
166
+ </div>
167
+ </div>
168
+
169
+ <div class="bg-gray-800 rounded-lg p-6">
170
+ <h3 class="text-lg font-bold mb-4">Earnings Summary</h3>
171
+ <div class="space-y-3">
172
+ <div class="flex justify-between">
173
+ <span class="text-gray-400">Current Bond</span>
174
+ <span><span id="current-bond">0</span> ETH</span>
175
+ </div>
176
+ <div class="flex justify-between">
177
+ <span class="text-gray-400">Required Bond</span>
178
+ <span><span id="required-bond">0</span> ETH</span>
179
+ </div>
180
+ <div class="flex justify-between">
181
+ <span class="text-gray-400">Excess Bond</span>
182
+ <span class="text-green-400"><span id="excess-bond">0</span> ETH</span>
183
+ </div>
184
+ <hr class="border-gray-700">
185
+ <div class="flex justify-between">
186
+ <span class="text-gray-400">Cumulative Rewards</span>
187
+ <span><span id="cumulative-rewards">0</span> ETH</span>
188
+ </div>
189
+ <div class="flex justify-between">
190
+ <span class="text-gray-400">Already Distributed</span>
191
+ <span><span id="distributed-rewards">0</span> ETH</span>
192
+ </div>
193
+ <div class="flex justify-between">
194
+ <span class="text-gray-400">Unclaimed Rewards</span>
195
+ <span class="text-green-400"><span id="unclaimed-rewards">0</span> ETH</span>
196
+ </div>
197
+ <hr class="border-gray-700">
198
+ <div class="flex justify-between text-xl font-bold">
199
+ <span>Total Claimable</span>
200
+ <span class="text-yellow-400"><span id="total-claimable">0</span> ETH</span>
201
+ </div>
202
+ </div>
203
+ </div>
204
+
205
+ <div class="mt-6">
206
+ <button id="load-details"
207
+ class="w-full px-4 py-3 bg-purple-600 rounded hover:bg-purple-700 font-medium transition-colors">
208
+ Load Validator Status & APY (Beacon Chain)
209
+ </button>
210
+ </div>
211
+
212
+ <div id="details-loading" class="hidden mt-6">
213
+ <div class="flex items-center justify-center p-4">
214
+ <div class="animate-spin rounded-full h-6 w-6 border-b-2 border-purple-500"></div>
215
+ <span class="ml-3 text-gray-400">Loading validator status...</span>
216
+ </div>
217
+ </div>
218
+
219
+ <div id="apy-section" class="hidden mt-6 bg-gray-800 rounded-lg p-6">
220
+ <h3 class="text-lg font-bold mb-4">APY Metrics (Historical)</h3>
221
+ <div class="overflow-x-auto">
222
+ <table class="w-full">
223
+ <thead>
224
+ <tr class="text-gray-400 text-sm">
225
+ <th class="text-left py-2">Metric</th>
226
+ <th class="text-right py-2">28-Day</th>
227
+ <th class="text-right py-2">Lifetime</th>
228
+ </tr>
229
+ </thead>
230
+ <tbody class="text-sm">
231
+ <tr>
232
+ <td class="py-2 text-gray-400">Reward APY</td>
233
+ <td class="py-2 text-right text-green-400" id="reward-apy-28d">--%</td>
234
+ <td class="py-2 text-right text-green-400" id="reward-apy-ltd">--%</td>
235
+ </tr>
236
+ <tr>
237
+ <td class="py-2 text-gray-400">Bond APY (stETH)*</td>
238
+ <td class="py-2 text-right text-green-400" id="bond-apy-28d">--%</td>
239
+ <td class="py-2 text-right text-green-400" id="bond-apy-ltd">--%</td>
240
+ </tr>
241
+ <tr class="border-t border-gray-700">
242
+ <td class="py-3 font-bold">NET APY</td>
243
+ <td class="py-3 text-right font-bold text-yellow-400" id="net-apy-28d">--%</td>
244
+ <td class="py-3 text-right font-bold text-yellow-400" id="net-apy-ltd">--%</td>
245
+ </tr>
246
+ </tbody>
247
+ </table>
248
+ </div>
249
+ <p class="mt-3 text-xs text-gray-500">*Bond APY uses current stETH rate</p>
250
+ </div>
251
+ </div>
252
+ </div>
253
+
254
+ <script>
255
+ const form = document.getElementById('lookup-form');
256
+ const loading = document.getElementById('loading');
257
+ const error = document.getElementById('error');
258
+ const errorMessage = document.getElementById('error-message');
259
+ const results = document.getElementById('results');
260
+ const loadDetailsBtn = document.getElementById('load-details');
261
+ const detailsLoading = document.getElementById('details-loading');
262
+ const validatorStatus = document.getElementById('validator-status');
263
+ const apySection = document.getElementById('apy-section');
264
+ const healthSection = document.getElementById('health-section');
265
+
266
+ function formatApy(val) {
267
+ return val !== null && val !== undefined ? val.toFixed(2) + '%' : '--%';
268
+ }
269
+
270
+ form.addEventListener('submit', async (e) => {
271
+ e.preventDefault();
272
+ const input = document.getElementById('address').value.trim();
273
+
274
+ if (!input) return;
275
+
276
+ // Reset UI
277
+ loading.classList.remove('hidden');
278
+ error.classList.add('hidden');
279
+ results.classList.add('hidden');
280
+ validatorStatus.classList.add('hidden');
281
+ apySection.classList.add('hidden');
282
+ healthSection.classList.add('hidden');
283
+ document.getElementById('active-since-row').classList.add('hidden');
284
+ loadDetailsBtn.classList.remove('hidden');
285
+ loadDetailsBtn.disabled = false;
286
+ loadDetailsBtn.textContent = 'Load Validator Status & APY (Beacon Chain)';
287
+
288
+ // Reset strikes state for new search
289
+ const strikesDetailDiv = document.getElementById('strikes-detail');
290
+ const strikesList = document.getElementById('strikes-list');
291
+ if (strikesDetailDiv) strikesDetailDiv.classList.add('hidden');
292
+ if (strikesList) {
293
+ strikesList.classList.add('hidden');
294
+ strikesList.innerHTML = '';
295
+ }
296
+
297
+ try {
298
+ const response = await fetch(`/api/operator/${input}`);
299
+ const data = await response.json();
300
+
301
+ loading.classList.add('hidden');
302
+
303
+ if (!response.ok) {
304
+ error.classList.remove('hidden');
305
+ errorMessage.textContent = data.detail || 'An error occurred';
306
+ return;
307
+ }
308
+
309
+ // Populate results
310
+ document.getElementById('operator-id').textContent = data.operator_id;
311
+ document.getElementById('manager-address').textContent = data.manager_address;
312
+ document.getElementById('reward-address').textContent = data.reward_address;
313
+
314
+ // Show Active Since if available
315
+ if (data.active_since) {
316
+ const activeSince = new Date(data.active_since);
317
+ const options = { year: 'numeric', month: 'short', day: 'numeric' };
318
+ document.getElementById('active-since').textContent = activeSince.toLocaleDateString('en-US', options);
319
+ document.getElementById('active-since-row').classList.remove('hidden');
320
+ }
321
+
322
+ // Show tip with operator ID for faster lookups
323
+ document.getElementById('tip-operator-id').textContent = data.operator_id;
324
+ document.getElementById('lookup-tip').classList.remove('hidden');
325
+
326
+ document.getElementById('total-validators').textContent = data.validators.total;
327
+ document.getElementById('active-validators').textContent = data.validators.active;
328
+ document.getElementById('exited-validators').textContent = data.validators.exited;
329
+
330
+ document.getElementById('current-bond').textContent = (data.rewards?.current_bond_eth ?? 0).toFixed(6);
331
+ document.getElementById('required-bond').textContent = (data.rewards?.required_bond_eth ?? 0).toFixed(6);
332
+ document.getElementById('excess-bond').textContent = (data.rewards?.excess_bond_eth ?? 0).toFixed(6);
333
+ document.getElementById('cumulative-rewards').textContent = (data.rewards?.cumulative_rewards_eth ?? 0).toFixed(6);
334
+ document.getElementById('distributed-rewards').textContent = (data.rewards?.distributed_eth ?? 0).toFixed(6);
335
+ document.getElementById('unclaimed-rewards').textContent = (data.rewards?.unclaimed_eth ?? 0).toFixed(6);
336
+ document.getElementById('total-claimable').textContent = (data.rewards?.total_claimable_eth ?? 0).toFixed(6);
337
+
338
+ results.classList.remove('hidden');
339
+ } catch (err) {
340
+ loading.classList.add('hidden');
341
+ error.classList.remove('hidden');
342
+ errorMessage.textContent = err.message || 'Network error';
343
+ }
344
+ });
345
+
346
+ let isLoadingDetails = false;
347
+
348
+ loadDetailsBtn.addEventListener('click', async () => {
349
+ if (isLoadingDetails) return;
350
+ isLoadingDetails = true;
351
+
352
+ const operatorId = document.getElementById('operator-id').textContent;
353
+
354
+ // Show loading, hide button
355
+ loadDetailsBtn.classList.add('hidden');
356
+ detailsLoading.classList.remove('hidden');
357
+
358
+ try {
359
+ const response = await fetch(`/api/operator/${operatorId}?detailed=true`);
360
+ const data = await response.json();
361
+
362
+ detailsLoading.classList.add('hidden');
363
+
364
+ if (!response.ok) {
365
+ loadDetailsBtn.classList.remove('hidden');
366
+ loadDetailsBtn.textContent = 'Failed - Click to Retry';
367
+ return;
368
+ }
369
+
370
+ // Populate validator status
371
+ if (data.validators.by_status) {
372
+ document.getElementById('status-active').textContent = data.validators.by_status.active || 0;
373
+ document.getElementById('status-pending').textContent = data.validators.by_status.pending || 0;
374
+ document.getElementById('status-exiting').textContent = data.validators.by_status.exiting || 0;
375
+ document.getElementById('status-exited').textContent = data.validators.by_status.exited || 0;
376
+ document.getElementById('status-slashed').textContent = data.validators.by_status.slashed || 0;
377
+ document.getElementById('status-unknown').textContent = data.validators.by_status.unknown || 0;
378
+ }
379
+
380
+ // Show effectiveness if available
381
+ if (data.performance && data.performance.avg_effectiveness !== null) {
382
+ document.getElementById('avg-effectiveness').textContent = data.performance.avg_effectiveness.toFixed(1);
383
+ document.getElementById('effectiveness-section').classList.remove('hidden');
384
+ }
385
+
386
+ validatorStatus.classList.remove('hidden');
387
+
388
+ // Populate APY metrics if available
389
+ if (data.apy) {
390
+ document.getElementById('reward-apy-28d').textContent = formatApy(data.apy.historical_reward_apy_28d);
391
+ document.getElementById('reward-apy-ltd').textContent = formatApy(data.apy.historical_reward_apy_ltd);
392
+ document.getElementById('bond-apy-28d').textContent = formatApy(data.apy.bond_apy);
393
+ document.getElementById('bond-apy-ltd').textContent = formatApy(data.apy.bond_apy);
394
+ document.getElementById('net-apy-28d').textContent = formatApy(data.apy.net_apy_28d);
395
+ document.getElementById('net-apy-ltd').textContent = formatApy(data.apy.net_apy_ltd);
396
+
397
+ apySection.classList.remove('hidden');
398
+ }
399
+
400
+ // Display Active Since date if available
401
+ if (data.active_since) {
402
+ const activeSince = new Date(data.active_since);
403
+ const options = { year: 'numeric', month: 'short', day: 'numeric' };
404
+ document.getElementById('active-since').textContent = activeSince.toLocaleDateString('en-US', options);
405
+ document.getElementById('active-since-row').classList.remove('hidden');
406
+ }
407
+
408
+ // Populate health status if available
409
+ if (data.health) {
410
+ const h = data.health;
411
+
412
+ // Bond health
413
+ if (h.bond_healthy) {
414
+ document.getElementById('health-bond').innerHTML = '<span class="text-green-400">HEALTHY</span>';
415
+ } else {
416
+ document.getElementById('health-bond').innerHTML = `<span class="text-red-400">DEFICIT -${h.bond_deficit_eth.toFixed(4)} ETH</span>`;
417
+ }
418
+
419
+ // Stuck validators
420
+ if (h.stuck_validators_count === 0) {
421
+ document.getElementById('health-stuck').innerHTML = '<span class="text-green-400">0</span>';
422
+ } else {
423
+ document.getElementById('health-stuck').innerHTML = `<span class="text-red-400">${h.stuck_validators_count} (exit within 4 days!)</span>`;
424
+ }
425
+
426
+ // Slashed
427
+ if (h.slashed_validators_count === 0) {
428
+ document.getElementById('health-slashed').innerHTML = '<span class="text-green-400">0</span>';
429
+ } else {
430
+ document.getElementById('health-slashed').innerHTML = `<span class="text-red-400">${h.slashed_validators_count}</span>`;
431
+ }
432
+
433
+ // At risk
434
+ if (h.validators_at_risk_count === 0) {
435
+ document.getElementById('health-at-risk').innerHTML = '<span class="text-green-400">0</span>';
436
+ } else {
437
+ document.getElementById('health-at-risk').innerHTML = `<span class="text-yellow-400">${h.validators_at_risk_count}</span>`;
438
+ }
439
+
440
+ // Strikes
441
+ const strikesDetailDiv = document.getElementById('strikes-detail');
442
+ const toggleStrikesBtn = document.getElementById('toggle-strikes');
443
+ const strikesList = document.getElementById('strikes-list');
444
+
445
+ if (h.strikes.total_validators_with_strikes === 0) {
446
+ document.getElementById('health-strikes').innerHTML = '<span class="text-green-400">0 validators</span>';
447
+ strikesDetailDiv.classList.add('hidden');
448
+ } else {
449
+ // Build strike status message
450
+ const strikeParts = [];
451
+ if (h.strikes.validators_at_risk > 0) {
452
+ strikeParts.push(`${h.strikes.validators_at_risk} at ejection`);
453
+ }
454
+ if (h.strikes.validators_near_ejection > 0) {
455
+ strikeParts.push(`${h.strikes.validators_near_ejection} near ejection`);
456
+ }
457
+ const strikeStatus = strikeParts.length > 0 ? strikeParts.join(', ') : 'monitoring';
458
+ const strikeColor = h.strikes.validators_at_risk > 0 ? 'text-red-400' :
459
+ (h.strikes.validators_near_ejection > 0 ? 'text-orange-400' : 'text-yellow-400');
460
+ document.getElementById('health-strikes').innerHTML =
461
+ `<span class="${strikeColor}">${h.strikes.total_validators_with_strikes} validators (${strikeStatus})</span>`;
462
+
463
+ // Show the toggle button for strikes detail
464
+ strikesDetailDiv.classList.remove('hidden');
465
+ let strikesLoaded = false;
466
+
467
+ // Function to load strikes data
468
+ const loadStrikesData = async () => {
469
+ if (strikesLoaded) return;
470
+ strikesList.innerHTML = '<div class="text-gray-400">Loading...</div>';
471
+ strikesList.classList.remove('hidden');
472
+ try {
473
+ const opId = document.getElementById('operator-id').textContent;
474
+ const strikesResp = await fetch(`/api/operator/${opId}/strikes`);
475
+ const strikesData = await strikesResp.json();
476
+ strikesList.innerHTML = strikesData.validators.map(v => {
477
+ const colorClass = v.at_ejection_risk ? 'text-red-400' :
478
+ (v.strike_count === 2 ? 'text-orange-400' : 'text-yellow-400');
479
+
480
+ // Generate 6 dots with date tooltips
481
+ const dots = v.strikes.map((strike, i) => {
482
+ const frame = strikesData.frame_dates && strikesData.frame_dates[i];
483
+ const dateRange = frame ? `${frame.start} - ${frame.end}` : `Frame ${i + 1}`;
484
+ const tooltip = `${dateRange}: ${strike ? 'Strike' : 'OK'}`;
485
+ const color = strike ? 'text-red-500' : 'text-green-500';
486
+ return `<span class="${color} cursor-help" title="${tooltip}">●</span>`;
487
+ }).join('');
488
+
489
+ // Truncated pubkey with copy + beaconcha.in link
490
+ const shortPubkey = v.pubkey.slice(0, 10) + '...' + v.pubkey.slice(-8);
491
+ const beaconUrl = `https://beaconcha.in/validator/${v.pubkey}`;
492
+
493
+ return `<div class="flex items-center gap-2 py-1.5 border-b border-gray-700 last:border-0 ${colorClass}">
494
+ <span class="font-mono text-xs">${shortPubkey}</span>
495
+ <button onclick="navigator.clipboard.writeText('${v.pubkey}'); this.textContent='✓'; setTimeout(() => this.textContent='📋', 1000)"
496
+ class="text-gray-400 hover:text-white text-sm" title="Copy full address">📋</button>
497
+ <a href="${beaconUrl}" target="_blank" rel="noopener"
498
+ class="text-blue-400 hover:text-blue-300 text-sm" title="View on beaconcha.in">↗</a>
499
+ <span class="flex gap-0.5 text-base ml-1">${dots}</span>
500
+ <span class="text-gray-400 text-xs">(${v.strike_count}/3)</span>
501
+ </div>`;
502
+ }).join('');
503
+ strikesLoaded = true;
504
+ toggleStrikesBtn.textContent = 'Hide validator details ▲';
505
+ } catch (err) {
506
+ strikesList.innerHTML = '<div class="text-red-400">Failed to load strikes</div>';
507
+ }
508
+ };
509
+
510
+ // Auto-load strikes data when there are strikes
511
+ loadStrikesData();
512
+
513
+ // Remove old listener to prevent memory leak
514
+ if (toggleStrikesBtn._clickHandler) {
515
+ toggleStrikesBtn.removeEventListener('click', toggleStrikesBtn._clickHandler);
516
+ }
517
+ toggleStrikesBtn._clickHandler = async () => {
518
+ if (strikesList.classList.contains('hidden')) {
519
+ // Expand
520
+ if (!strikesLoaded) {
521
+ await loadStrikesData();
522
+ } else {
523
+ strikesList.classList.remove('hidden');
524
+ }
525
+ toggleStrikesBtn.textContent = 'Hide validator details ▲';
526
+ } else {
527
+ // Collapse
528
+ strikesList.classList.add('hidden');
529
+ toggleStrikesBtn.textContent = 'Show validator details ▼';
530
+ }
531
+ };
532
+ toggleStrikesBtn.addEventListener('click', toggleStrikesBtn._clickHandler);
533
+ }
534
+
535
+ // Overall - color-coded by severity
536
+ if (!h.has_issues) {
537
+ document.getElementById('health-overall').innerHTML = '<span class="text-green-400">No issues detected</span>';
538
+ } else if (
539
+ !h.bond_healthy ||
540
+ h.stuck_validators_count > 0 ||
541
+ h.slashed_validators_count > 0 ||
542
+ h.validators_at_risk_count > 0 ||
543
+ h.strikes.max_strikes >= 3
544
+ ) {
545
+ // Critical issues (red)
546
+ let message = 'Issues detected - action required!';
547
+ if (h.strikes.max_strikes >= 3) {
548
+ message = `Validator ejectable (${h.strikes.validators_at_risk} at 3/3 strikes)`;
549
+ }
550
+ document.getElementById('health-overall').innerHTML = `<span class="text-red-400">${message}</span>`;
551
+ } else if (h.strikes.max_strikes === 2) {
552
+ // Warning level 2 (orange) - one more strike = ejectable
553
+ document.getElementById('health-overall').innerHTML =
554
+ `<span class="text-orange-400">Warning - ${h.strikes.validators_near_ejection} validator(s) at 2/3 strikes</span>`;
555
+ } else {
556
+ // Warning level 1 (yellow) - has strikes but not critical
557
+ document.getElementById('health-overall').innerHTML =
558
+ '<span class="text-yellow-400">Warning - validator(s) have strikes</span>';
559
+ }
560
+
561
+ healthSection.classList.remove('hidden');
562
+ }
563
+ } catch (err) {
564
+ detailsLoading.classList.add('hidden');
565
+ loadDetailsBtn.classList.remove('hidden');
566
+ loadDetailsBtn.textContent = 'Failed - Click to Retry';
567
+ } finally {
568
+ isLoadingDetails = false;
569
+ }
570
+ });
571
+ </script>
572
+ </body>
573
+ </html>
574
+ """
575
+
576
+ return app