csm-dashboard 0.3.6__py3-none-any.whl → 0.4.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.
- {csm_dashboard-0.3.6.dist-info → csm_dashboard-0.4.0.dist-info}/METADATA +2 -1
- csm_dashboard-0.4.0.dist-info/RECORD +35 -0
- src/cli/commands.py +8 -0
- src/core/config.py +7 -0
- src/core/types.py +3 -6
- src/data/beacon.py +23 -9
- src/data/cache.py +53 -8
- src/data/database.py +189 -0
- src/data/etherscan.py +33 -7
- src/data/ipfs_logs.py +29 -20
- src/data/lido_api.py +38 -12
- src/data/onchain.py +111 -58
- src/data/price.py +46 -0
- src/data/rewards_tree.py +18 -3
- src/data/strikes.py +35 -13
- src/main.py +12 -0
- src/services/operator_service.py +76 -52
- src/web/app.py +794 -72
- src/web/routes.py +372 -0
- csm_dashboard-0.3.6.dist-info/RECORD +0 -33
- {csm_dashboard-0.3.6.dist-info → csm_dashboard-0.4.0.dist-info}/WHEEL +0 -0
- {csm_dashboard-0.3.6.dist-info → csm_dashboard-0.4.0.dist-info}/entry_points.txt +0 -0
src/web/app.py
CHANGED
|
@@ -1,22 +1,42 @@
|
|
|
1
1
|
"""FastAPI application factory."""
|
|
2
2
|
|
|
3
|
+
import logging
|
|
3
4
|
from pathlib import Path
|
|
4
5
|
|
|
5
|
-
from fastapi import FastAPI
|
|
6
|
+
from fastapi import FastAPI, Request
|
|
6
7
|
from fastapi.responses import HTMLResponse
|
|
7
8
|
from fastapi.staticfiles import StaticFiles
|
|
8
9
|
|
|
9
10
|
from .routes import router
|
|
10
11
|
|
|
12
|
+
# Configure logging
|
|
13
|
+
logging.basicConfig(
|
|
14
|
+
level=logging.INFO,
|
|
15
|
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
|
16
|
+
)
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
11
19
|
|
|
12
20
|
def create_app() -> FastAPI:
|
|
13
21
|
"""Create and configure the FastAPI application."""
|
|
14
22
|
app = FastAPI(
|
|
15
23
|
title="CSM Operator Dashboard",
|
|
16
24
|
description="Track your Lido CSM validator earnings",
|
|
17
|
-
version="0.
|
|
25
|
+
version="0.4.0",
|
|
18
26
|
)
|
|
19
27
|
|
|
28
|
+
# Add request logging middleware
|
|
29
|
+
@app.middleware("http")
|
|
30
|
+
async def log_requests(request: Request, call_next):
|
|
31
|
+
logger.info(f"Request: {request.method} {request.url.path}")
|
|
32
|
+
try:
|
|
33
|
+
response = await call_next(request)
|
|
34
|
+
logger.info(f"Response: {request.method} {request.url.path} -> {response.status_code}")
|
|
35
|
+
return response
|
|
36
|
+
except Exception as e:
|
|
37
|
+
logger.error(f"Request failed: {request.method} {request.url.path} -> {e}")
|
|
38
|
+
raise
|
|
39
|
+
|
|
20
40
|
app.include_router(router, prefix="/api")
|
|
21
41
|
|
|
22
42
|
# Mount static files for favicon and images
|
|
@@ -24,8 +44,17 @@ def create_app() -> FastAPI:
|
|
|
24
44
|
if img_dir.exists():
|
|
25
45
|
app.mount("/img", StaticFiles(directory=str(img_dir)), name="img")
|
|
26
46
|
|
|
47
|
+
@app.on_event("startup")
|
|
48
|
+
async def startup_event():
|
|
49
|
+
logger.info("CSM Dashboard starting up")
|
|
50
|
+
|
|
51
|
+
@app.on_event("shutdown")
|
|
52
|
+
async def shutdown_event():
|
|
53
|
+
logger.info("CSM Dashboard shutting down")
|
|
54
|
+
|
|
27
55
|
@app.get("/", response_class=HTMLResponse)
|
|
28
56
|
async def index():
|
|
57
|
+
logger.debug("Serving index page")
|
|
29
58
|
return """
|
|
30
59
|
<!DOCTYPE html>
|
|
31
60
|
<html>
|
|
@@ -51,6 +80,25 @@ def create_app() -> FastAPI:
|
|
|
51
80
|
</div>
|
|
52
81
|
</form>
|
|
53
82
|
|
|
83
|
+
<!-- Saved Operators Section -->
|
|
84
|
+
<div id="saved-operators-section" class="mb-8 hidden">
|
|
85
|
+
<div class="flex justify-between items-center mb-4">
|
|
86
|
+
<h2 class="text-xl font-bold">Saved Operators</h2>
|
|
87
|
+
<button id="refresh-all-btn" class="px-4 py-2 bg-green-600 rounded hover:bg-green-700 text-sm font-medium">
|
|
88
|
+
Refresh All
|
|
89
|
+
</button>
|
|
90
|
+
</div>
|
|
91
|
+
<div id="saved-operators-loading" class="hidden">
|
|
92
|
+
<div class="flex items-center justify-center p-4">
|
|
93
|
+
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-green-500"></div>
|
|
94
|
+
<span class="ml-3 text-gray-400">Loading saved operators...</span>
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
<div id="saved-operators-list" class="grid gap-4">
|
|
98
|
+
<!-- Populated by JavaScript -->
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
|
|
54
102
|
<div id="loading" class="hidden">
|
|
55
103
|
<div class="flex items-center justify-center p-8">
|
|
56
104
|
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
|
|
@@ -64,9 +112,15 @@ def create_app() -> FastAPI:
|
|
|
64
112
|
|
|
65
113
|
<div id="results" class="hidden">
|
|
66
114
|
<div class="bg-gray-800 rounded-lg p-6 mb-6">
|
|
67
|
-
<
|
|
68
|
-
|
|
69
|
-
|
|
115
|
+
<div class="flex justify-between items-start">
|
|
116
|
+
<h2 class="text-xl font-bold mb-2">
|
|
117
|
+
Operator #<span id="operator-id"></span>
|
|
118
|
+
</h2>
|
|
119
|
+
<button id="save-operator-btn"
|
|
120
|
+
class="px-4 py-2 bg-yellow-600 rounded hover:bg-yellow-700 text-sm font-medium transition-colors">
|
|
121
|
+
Save
|
|
122
|
+
</button>
|
|
123
|
+
</div>
|
|
70
124
|
<div id="active-since-row" class="hidden text-sm text-green-400 mb-3">
|
|
71
125
|
Active Since: <span id="active-since"></span>
|
|
72
126
|
</div>
|
|
@@ -101,7 +155,13 @@ def create_app() -> FastAPI:
|
|
|
101
155
|
</div>
|
|
102
156
|
|
|
103
157
|
<div id="validator-status" class="hidden mb-6 bg-gray-800 rounded-lg p-6">
|
|
104
|
-
<
|
|
158
|
+
<div class="flex justify-between items-center mb-4">
|
|
159
|
+
<h3 class="text-lg font-bold">Validator Status (Beacon Chain)</h3>
|
|
160
|
+
<a id="beaconchain-link" href="#" target="_blank" rel="noopener"
|
|
161
|
+
class="hidden px-3 py-1.5 bg-blue-600/80 hover:bg-blue-600 rounded text-sm font-medium inline-flex items-center gap-1">
|
|
162
|
+
View on beaconcha.in <span class="text-xs">↗</span>
|
|
163
|
+
</a>
|
|
164
|
+
</div>
|
|
105
165
|
<div class="grid grid-cols-3 md:grid-cols-6 gap-3 mb-4">
|
|
106
166
|
<div class="bg-green-900/50 rounded-lg p-3 text-center">
|
|
107
167
|
<div class="text-xl font-bold text-green-400" id="status-active">0</div>
|
|
@@ -177,36 +237,60 @@ def create_app() -> FastAPI:
|
|
|
177
237
|
|
|
178
238
|
<div class="bg-gray-800 rounded-lg p-6">
|
|
179
239
|
<h3 class="text-lg font-bold mb-4">Earnings Summary</h3>
|
|
240
|
+
<div id="eth-price-display" class="hidden text-xs text-gray-500 mb-3">
|
|
241
|
+
ETH: $<span id="eth-price-value">0</span> USD
|
|
242
|
+
</div>
|
|
180
243
|
<div class="space-y-3">
|
|
181
244
|
<div class="flex justify-between">
|
|
182
245
|
<span class="text-gray-400">Current Bond</span>
|
|
183
|
-
<
|
|
246
|
+
<div class="text-right">
|
|
247
|
+
<span><span id="current-bond">0</span> ETH</span>
|
|
248
|
+
<span id="current-bond-usd" class="text-gray-500 text-sm ml-2"></span>
|
|
249
|
+
</div>
|
|
184
250
|
</div>
|
|
185
251
|
<div class="flex justify-between">
|
|
186
252
|
<span class="text-gray-400">Required Bond</span>
|
|
187
|
-
<
|
|
253
|
+
<div class="text-right">
|
|
254
|
+
<span><span id="required-bond">0</span> ETH</span>
|
|
255
|
+
<span id="required-bond-usd" class="text-gray-500 text-sm ml-2"></span>
|
|
256
|
+
</div>
|
|
188
257
|
</div>
|
|
189
258
|
<div class="flex justify-between">
|
|
190
259
|
<span class="text-gray-400">Excess Bond</span>
|
|
191
|
-
<
|
|
260
|
+
<div class="text-right">
|
|
261
|
+
<span class="text-green-400"><span id="excess-bond">0</span> ETH</span>
|
|
262
|
+
<span id="excess-bond-usd" class="text-gray-500 text-sm ml-2"></span>
|
|
263
|
+
</div>
|
|
192
264
|
</div>
|
|
193
265
|
<hr class="border-gray-700">
|
|
194
266
|
<div class="flex justify-between">
|
|
195
267
|
<span class="text-gray-400">Cumulative Rewards</span>
|
|
196
|
-
<
|
|
268
|
+
<div class="text-right">
|
|
269
|
+
<span><span id="cumulative-rewards">0</span> ETH</span>
|
|
270
|
+
<span id="cumulative-rewards-usd" class="text-gray-500 text-sm ml-2"></span>
|
|
271
|
+
</div>
|
|
197
272
|
</div>
|
|
198
273
|
<div class="flex justify-between">
|
|
199
274
|
<span class="text-gray-400">Already Distributed</span>
|
|
200
|
-
<
|
|
275
|
+
<div class="text-right">
|
|
276
|
+
<span><span id="distributed-rewards">0</span> ETH</span>
|
|
277
|
+
<span id="distributed-rewards-usd" class="text-gray-500 text-sm ml-2"></span>
|
|
278
|
+
</div>
|
|
201
279
|
</div>
|
|
202
280
|
<div class="flex justify-between">
|
|
203
281
|
<span class="text-gray-400">Unclaimed Rewards</span>
|
|
204
|
-
<
|
|
282
|
+
<div class="text-right">
|
|
283
|
+
<span class="text-green-400"><span id="unclaimed-rewards">0</span> ETH</span>
|
|
284
|
+
<span id="unclaimed-rewards-usd" class="text-gray-500 text-sm ml-2"></span>
|
|
285
|
+
</div>
|
|
205
286
|
</div>
|
|
206
287
|
<hr class="border-gray-700">
|
|
207
288
|
<div class="flex justify-between text-xl font-bold">
|
|
208
289
|
<span>Total Claimable</span>
|
|
209
|
-
<
|
|
290
|
+
<div class="text-right">
|
|
291
|
+
<span class="text-yellow-400"><span id="total-claimable">0</span> ETH</span>
|
|
292
|
+
<div id="total-claimable-usd" class="text-gray-400 text-sm font-normal"></div>
|
|
293
|
+
</div>
|
|
210
294
|
</div>
|
|
211
295
|
</div>
|
|
212
296
|
</div>
|
|
@@ -233,24 +317,24 @@ def create_app() -> FastAPI:
|
|
|
233
317
|
<tr class="text-gray-400 text-sm">
|
|
234
318
|
<th class="text-left py-2">Metric</th>
|
|
235
319
|
<th class="text-right py-2">28-Day</th>
|
|
236
|
-
<th class="text-right py-2">Lifetime</th>
|
|
320
|
+
<th id="apy-lifetime-header" class="hidden text-right py-2">Lifetime</th>
|
|
237
321
|
</tr>
|
|
238
322
|
</thead>
|
|
239
323
|
<tbody class="text-sm">
|
|
240
324
|
<tr>
|
|
241
325
|
<td class="py-2 text-gray-400">Reward APY</td>
|
|
242
326
|
<td class="py-2 text-right text-green-400" id="reward-apy-28d">--%</td>
|
|
243
|
-
<td class="py-2 text-right text-green-400" id="reward-apy-ltd">--%</td>
|
|
327
|
+
<td class="hidden py-2 text-right text-green-400" id="reward-apy-ltd">--%</td>
|
|
244
328
|
</tr>
|
|
245
329
|
<tr>
|
|
246
330
|
<td class="py-2 text-gray-400">Bond APY (stETH)*</td>
|
|
247
331
|
<td class="py-2 text-right text-green-400" id="bond-apy-28d">--%</td>
|
|
248
|
-
<td class="py-2 text-right text-green-400" id="bond-apy-ltd">--%</td>
|
|
332
|
+
<td class="hidden py-2 text-right text-green-400" id="bond-apy-ltd">--%</td>
|
|
249
333
|
</tr>
|
|
250
334
|
<tr class="border-t border-gray-700">
|
|
251
335
|
<td class="py-3 font-bold">NET APY</td>
|
|
252
336
|
<td class="py-3 text-right font-bold text-yellow-400" id="net-apy-28d">--%</td>
|
|
253
|
-
<td class="py-3 text-right font-bold text-yellow-400" id="net-apy-ltd">--%</td>
|
|
337
|
+
<td class="hidden py-3 text-right font-bold text-yellow-400" id="net-apy-ltd">--%</td>
|
|
254
338
|
</tr>
|
|
255
339
|
</tbody>
|
|
256
340
|
</table>
|
|
@@ -292,8 +376,10 @@ def create_app() -> FastAPI:
|
|
|
292
376
|
<th class="text-left py-2">#</th>
|
|
293
377
|
<th class="text-left py-2">Date Range</th>
|
|
294
378
|
<th class="text-right py-2">Rewards</th>
|
|
295
|
-
<th class="text-right py-2">
|
|
296
|
-
<th class="text-right py-2">
|
|
379
|
+
<th class="text-right py-2">Vals</th>
|
|
380
|
+
<th class="text-right py-2">Reward APY</th>
|
|
381
|
+
<th class="text-right py-2">Bond APY</th>
|
|
382
|
+
<th class="text-right py-2">Net APY</th>
|
|
297
383
|
</tr>
|
|
298
384
|
</thead>
|
|
299
385
|
<tbody id="history-tbody">
|
|
@@ -346,9 +432,82 @@ def create_app() -> FastAPI:
|
|
|
346
432
|
const loadDetailsBtn = document.getElementById('load-details');
|
|
347
433
|
const detailsLoading = document.getElementById('details-loading');
|
|
348
434
|
const validatorStatus = document.getElementById('validator-status');
|
|
435
|
+
const beaconchainLink = document.getElementById('beaconchain-link');
|
|
349
436
|
const apySection = document.getElementById('apy-section');
|
|
350
437
|
const healthSection = document.getElementById('health-section');
|
|
351
438
|
const historySection = document.getElementById('history-section');
|
|
439
|
+
|
|
440
|
+
// Global abort controller for canceling requests on page unload
|
|
441
|
+
let pageAbortController = new AbortController();
|
|
442
|
+
window.addEventListener('beforeunload', () => {
|
|
443
|
+
pageAbortController.abort();
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
// Helper to check if error is from abort (page unload)
|
|
447
|
+
function isAbortError(err) {
|
|
448
|
+
return err.name === 'AbortError';
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// ETH price state
|
|
452
|
+
let ethPriceUsd = null;
|
|
453
|
+
|
|
454
|
+
// Fetch ETH price from CoinGecko via our API
|
|
455
|
+
async function fetchEthPrice() {
|
|
456
|
+
try {
|
|
457
|
+
const response = await fetch('/api/price/eth', { signal: pageAbortController.signal });
|
|
458
|
+
const data = await response.json();
|
|
459
|
+
if (data.price) {
|
|
460
|
+
ethPriceUsd = data.price;
|
|
461
|
+
document.getElementById('eth-price-value').textContent = ethPriceUsd.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
462
|
+
document.getElementById('eth-price-display').classList.remove('hidden');
|
|
463
|
+
// Update any displayed USD values
|
|
464
|
+
updateUsdDisplays();
|
|
465
|
+
// Re-render saved operator cards to show USD
|
|
466
|
+
if (typeof rerenderSavedOperators === 'function') {
|
|
467
|
+
rerenderSavedOperators();
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
} catch (err) {
|
|
471
|
+
if (!isAbortError(err)) {
|
|
472
|
+
console.error('Failed to fetch ETH price:', err);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Format USD value
|
|
478
|
+
function formatUsd(ethAmount) {
|
|
479
|
+
if (ethPriceUsd === null || ethAmount === null || ethAmount === undefined) return '';
|
|
480
|
+
const usd = parseFloat(ethAmount) * ethPriceUsd;
|
|
481
|
+
if (usd < 0.01) return '';
|
|
482
|
+
return '$' + usd.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Update all USD displays based on current ETH values
|
|
486
|
+
function updateUsdDisplays() {
|
|
487
|
+
if (ethPriceUsd === null) return;
|
|
488
|
+
|
|
489
|
+
const fields = [
|
|
490
|
+
{ eth: 'current-bond', usd: 'current-bond-usd' },
|
|
491
|
+
{ eth: 'required-bond', usd: 'required-bond-usd' },
|
|
492
|
+
{ eth: 'excess-bond', usd: 'excess-bond-usd' },
|
|
493
|
+
{ eth: 'cumulative-rewards', usd: 'cumulative-rewards-usd' },
|
|
494
|
+
{ eth: 'distributed-rewards', usd: 'distributed-rewards-usd' },
|
|
495
|
+
{ eth: 'unclaimed-rewards', usd: 'unclaimed-rewards-usd' },
|
|
496
|
+
{ eth: 'total-claimable', usd: 'total-claimable-usd' },
|
|
497
|
+
];
|
|
498
|
+
|
|
499
|
+
fields.forEach(({ eth, usd }) => {
|
|
500
|
+
const ethEl = document.getElementById(eth);
|
|
501
|
+
const usdEl = document.getElementById(usd);
|
|
502
|
+
if (ethEl && usdEl) {
|
|
503
|
+
const ethVal = parseFloat(ethEl.textContent);
|
|
504
|
+
usdEl.textContent = formatUsd(ethVal);
|
|
505
|
+
}
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Fetch ETH price on page load
|
|
510
|
+
fetchEthPrice();
|
|
352
511
|
const loadHistoryBtn = document.getElementById('load-history-btn');
|
|
353
512
|
const historyLoading = document.getElementById('history-loading');
|
|
354
513
|
const historyTable = document.getElementById('history-table');
|
|
@@ -360,18 +519,16 @@ def create_app() -> FastAPI:
|
|
|
360
519
|
const withdrawalTable = document.getElementById('withdrawal-table');
|
|
361
520
|
const withdrawalTbody = document.getElementById('withdrawal-tbody');
|
|
362
521
|
|
|
522
|
+
// State variables for history/withdrawal loading
|
|
523
|
+
let historyLoaded = false;
|
|
524
|
+
let withdrawalsLoaded = false;
|
|
525
|
+
|
|
363
526
|
function formatApy(val) {
|
|
364
527
|
return val !== null && val !== undefined ? val.toFixed(2) + '%' : '--%';
|
|
365
528
|
}
|
|
366
529
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
const input = document.getElementById('address').value.trim();
|
|
370
|
-
|
|
371
|
-
if (!input) return;
|
|
372
|
-
|
|
373
|
-
// Reset UI
|
|
374
|
-
loading.classList.remove('hidden');
|
|
530
|
+
// Reset UI to initial state
|
|
531
|
+
function resetUI() {
|
|
375
532
|
error.classList.add('hidden');
|
|
376
533
|
results.classList.add('hidden');
|
|
377
534
|
validatorStatus.classList.add('hidden');
|
|
@@ -388,12 +545,18 @@ def create_app() -> FastAPI:
|
|
|
388
545
|
withdrawalTbody.innerHTML = '';
|
|
389
546
|
withdrawalsLoaded = false;
|
|
390
547
|
loadWithdrawalsBtn.textContent = 'Load Withdrawals';
|
|
548
|
+
beaconchainLink.classList.add('hidden');
|
|
549
|
+
beaconchainLink.href = '#';
|
|
550
|
+
document.getElementById('apy-lifetime-header').classList.add('hidden');
|
|
551
|
+
document.getElementById('reward-apy-ltd').classList.add('hidden');
|
|
552
|
+
document.getElementById('bond-apy-ltd').classList.add('hidden');
|
|
553
|
+
document.getElementById('net-apy-ltd').classList.add('hidden');
|
|
391
554
|
document.getElementById('active-since-row').classList.add('hidden');
|
|
555
|
+
document.getElementById('effectiveness-section').classList.add('hidden');
|
|
392
556
|
loadDetailsBtn.classList.remove('hidden');
|
|
393
557
|
loadDetailsBtn.disabled = false;
|
|
394
558
|
loadDetailsBtn.textContent = 'Load Validator Status & APY (Beacon Chain)';
|
|
395
559
|
|
|
396
|
-
// Reset strikes state for new search
|
|
397
560
|
const strikesDetailDiv = document.getElementById('strikes-detail');
|
|
398
561
|
const strikesList = document.getElementById('strikes-list');
|
|
399
562
|
if (strikesDetailDiv) strikesDetailDiv.classList.add('hidden');
|
|
@@ -402,8 +565,248 @@ def create_app() -> FastAPI:
|
|
|
402
565
|
strikesList.innerHTML = '';
|
|
403
566
|
}
|
|
404
567
|
|
|
568
|
+
// Reset save button state
|
|
569
|
+
currentOperatorSaved = false;
|
|
570
|
+
updateSaveButton();
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Display operator data in UI (handles both basic and detailed data)
|
|
574
|
+
function displayOperatorData(data) {
|
|
575
|
+
// Basic info
|
|
576
|
+
document.getElementById('operator-id').textContent = data.operator_id;
|
|
577
|
+
document.getElementById('manager-address').textContent = data.manager_address;
|
|
578
|
+
document.getElementById('reward-address').textContent = data.reward_address;
|
|
579
|
+
|
|
580
|
+
// Active Since
|
|
581
|
+
if (data.active_since) {
|
|
582
|
+
const activeSince = new Date(data.active_since);
|
|
583
|
+
const options = { year: 'numeric', month: 'short', day: 'numeric' };
|
|
584
|
+
document.getElementById('active-since').textContent = activeSince.toLocaleDateString('en-US', options);
|
|
585
|
+
document.getElementById('active-since-row').classList.remove('hidden');
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Tip
|
|
589
|
+
document.getElementById('tip-operator-id').textContent = data.operator_id;
|
|
590
|
+
document.getElementById('lookup-tip').classList.remove('hidden');
|
|
591
|
+
|
|
592
|
+
// Validators
|
|
593
|
+
document.getElementById('total-validators').textContent = data.validators?.total ?? 0;
|
|
594
|
+
document.getElementById('active-validators').textContent = data.validators?.active ?? 0;
|
|
595
|
+
document.getElementById('exited-validators').textContent = data.validators?.exited ?? 0;
|
|
596
|
+
|
|
597
|
+
// Rewards
|
|
598
|
+
document.getElementById('current-bond').textContent = parseFloat(data.rewards?.current_bond_eth ?? 0).toFixed(6);
|
|
599
|
+
document.getElementById('required-bond').textContent = parseFloat(data.rewards?.required_bond_eth ?? 0).toFixed(6);
|
|
600
|
+
document.getElementById('excess-bond').textContent = parseFloat(data.rewards?.excess_bond_eth ?? 0).toFixed(6);
|
|
601
|
+
document.getElementById('cumulative-rewards').textContent = parseFloat(data.rewards?.cumulative_rewards_eth ?? 0).toFixed(6);
|
|
602
|
+
document.getElementById('distributed-rewards').textContent = parseFloat(data.rewards?.distributed_eth ?? 0).toFixed(6);
|
|
603
|
+
document.getElementById('unclaimed-rewards').textContent = parseFloat(data.rewards?.unclaimed_eth ?? 0).toFixed(6);
|
|
604
|
+
document.getElementById('total-claimable').textContent = parseFloat(data.rewards?.total_claimable_eth ?? 0).toFixed(6);
|
|
605
|
+
|
|
606
|
+
// Update USD equivalents
|
|
607
|
+
updateUsdDisplays();
|
|
608
|
+
|
|
609
|
+
results.classList.remove('hidden');
|
|
610
|
+
|
|
611
|
+
// Detailed data (if available)
|
|
612
|
+
if (data.validators?.by_status) {
|
|
613
|
+
document.getElementById('status-active').textContent = data.validators.by_status.active || 0;
|
|
614
|
+
document.getElementById('status-pending').textContent = data.validators.by_status.pending || 0;
|
|
615
|
+
document.getElementById('status-exiting').textContent = data.validators.by_status.exiting || 0;
|
|
616
|
+
document.getElementById('status-exited').textContent = data.validators.by_status.exited || 0;
|
|
617
|
+
document.getElementById('status-slashed').textContent = data.validators.by_status.slashed || 0;
|
|
618
|
+
document.getElementById('status-unknown').textContent = data.validators.by_status.unknown || 0;
|
|
619
|
+
validatorStatus.classList.remove('hidden');
|
|
620
|
+
// Hide the load button since we have detailed data
|
|
621
|
+
loadDetailsBtn.classList.add('hidden');
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// Performance/effectiveness
|
|
625
|
+
if (data.performance && data.performance.avg_effectiveness !== null) {
|
|
626
|
+
document.getElementById('avg-effectiveness').textContent = data.performance.avg_effectiveness.toFixed(1);
|
|
627
|
+
document.getElementById('effectiveness-section').classList.remove('hidden');
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// APY
|
|
631
|
+
if (data.apy) {
|
|
632
|
+
document.getElementById('reward-apy-28d').textContent = formatApy(data.apy.historical_reward_apy_28d);
|
|
633
|
+
document.getElementById('reward-apy-ltd').textContent = formatApy(data.apy.historical_reward_apy_ltd);
|
|
634
|
+
document.getElementById('bond-apy-28d').textContent = formatApy(data.apy.bond_apy);
|
|
635
|
+
document.getElementById('bond-apy-ltd').textContent = formatApy(data.apy.bond_apy);
|
|
636
|
+
document.getElementById('net-apy-28d').textContent = formatApy(data.apy.net_apy_28d);
|
|
637
|
+
document.getElementById('net-apy-ltd').textContent = formatApy(data.apy.net_apy_ltd);
|
|
638
|
+
|
|
639
|
+
if (data.apy.next_distribution_date || data.apy.next_distribution_est_eth) {
|
|
640
|
+
if (data.apy.next_distribution_date) {
|
|
641
|
+
const nextDate = new Date(data.apy.next_distribution_date);
|
|
642
|
+
const options = { year: 'numeric', month: 'short', day: 'numeric' };
|
|
643
|
+
document.getElementById('next-dist-date').textContent = nextDate.toLocaleDateString('en-US', options);
|
|
644
|
+
}
|
|
645
|
+
if (data.apy.next_distribution_est_eth) {
|
|
646
|
+
document.getElementById('next-dist-eth').textContent = data.apy.next_distribution_est_eth.toFixed(4);
|
|
647
|
+
}
|
|
648
|
+
nextDistribution.classList.remove('hidden');
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
apySection.classList.remove('hidden');
|
|
652
|
+
historySection.classList.remove('hidden');
|
|
653
|
+
withdrawalSection.classList.remove('hidden');
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// Health
|
|
657
|
+
if (data.health) {
|
|
658
|
+
const h = data.health;
|
|
659
|
+
|
|
660
|
+
if (h.bond_healthy) {
|
|
661
|
+
document.getElementById('health-bond').innerHTML = '<span class="text-green-400">HEALTHY</span>';
|
|
662
|
+
} else {
|
|
663
|
+
document.getElementById('health-bond').innerHTML = `<span class="text-red-400">DEFICIT -${parseFloat(h.bond_deficit_eth).toFixed(4)} ETH</span>`;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
if (h.stuck_validators_count === 0) {
|
|
667
|
+
document.getElementById('health-stuck').innerHTML = '<span class="text-green-400">0</span>';
|
|
668
|
+
} else {
|
|
669
|
+
document.getElementById('health-stuck').innerHTML = `<span class="text-red-400">${h.stuck_validators_count} (exit within 4 days!)</span>`;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
if (h.slashed_validators_count === 0) {
|
|
673
|
+
document.getElementById('health-slashed').innerHTML = '<span class="text-green-400">0</span>';
|
|
674
|
+
} else {
|
|
675
|
+
document.getElementById('health-slashed').innerHTML = `<span class="text-red-400">${h.slashed_validators_count}</span>`;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
if (h.validators_at_risk_count === 0) {
|
|
679
|
+
document.getElementById('health-at-risk').innerHTML = '<span class="text-green-400">0</span>';
|
|
680
|
+
} else {
|
|
681
|
+
document.getElementById('health-at-risk').innerHTML = `<span class="text-yellow-400">${h.validators_at_risk_count}</span>`;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// Strikes
|
|
685
|
+
const strikesDetailDiv = document.getElementById('strikes-detail');
|
|
686
|
+
if (h.strikes && h.strikes.total_validators_with_strikes === 0) {
|
|
687
|
+
document.getElementById('health-strikes').innerHTML = '<span class="text-green-400">0 validators</span>';
|
|
688
|
+
strikesDetailDiv.classList.add('hidden');
|
|
689
|
+
} else if (h.strikes) {
|
|
690
|
+
const strikeParts = [];
|
|
691
|
+
if (h.strikes.validators_at_risk > 0) {
|
|
692
|
+
strikeParts.push(`${h.strikes.validators_at_risk} at ejection`);
|
|
693
|
+
}
|
|
694
|
+
if (h.strikes.validators_near_ejection > 0) {
|
|
695
|
+
strikeParts.push(`${h.strikes.validators_near_ejection} near ejection`);
|
|
696
|
+
}
|
|
697
|
+
const strikeStatus = strikeParts.length > 0 ? strikeParts.join(', ') : 'monitoring';
|
|
698
|
+
const strikeColor = h.strikes.validators_at_risk > 0 ? 'text-red-400' :
|
|
699
|
+
(h.strikes.validators_near_ejection > 0 ? 'text-orange-400' : 'text-yellow-400');
|
|
700
|
+
document.getElementById('health-strikes').innerHTML =
|
|
701
|
+
`<span class="${strikeColor}">${h.strikes.total_validators_with_strikes} validators (${strikeStatus})</span>`;
|
|
702
|
+
strikesDetailDiv.classList.remove('hidden');
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
healthSection.classList.remove('hidden');
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// Distribution History (if frames available in cached data)
|
|
709
|
+
if (data.apy?.frames && data.apy.frames.length > 0) {
|
|
710
|
+
historyTbody.innerHTML = data.apy.frames.map(frame => {
|
|
711
|
+
const startDate = new Date(frame.start_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
|
712
|
+
const endDate = new Date(frame.end_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
|
713
|
+
const rewardApy = frame.apy !== null && frame.apy !== undefined ? frame.apy.toFixed(2) + '%' : '--';
|
|
714
|
+
const bondApy = frame.bond_apy !== null && frame.bond_apy !== undefined ? frame.bond_apy.toFixed(2) + '%' : '--';
|
|
715
|
+
const netApy = frame.net_apy !== null && frame.net_apy !== undefined ? frame.net_apy.toFixed(2) + '%' : '--';
|
|
716
|
+
return `<tr class="border-t border-gray-700">
|
|
717
|
+
<td class="py-2">${frame.frame_number}</td>
|
|
718
|
+
<td class="py-2">${startDate} - ${endDate}</td>
|
|
719
|
+
<td class="py-2 text-right text-green-400">${frame.rewards_eth.toFixed(4)}</td>
|
|
720
|
+
<td class="py-2 text-right">${frame.validator_count}</td>
|
|
721
|
+
<td class="py-2 text-right text-green-400">${rewardApy}</td>
|
|
722
|
+
<td class="py-2 text-right text-green-400">${bondApy}</td>
|
|
723
|
+
<td class="py-2 text-right text-yellow-400 font-bold">${netApy}</td>
|
|
724
|
+
</tr>`;
|
|
725
|
+
}).join('');
|
|
726
|
+
|
|
727
|
+
// Add total row with lifetime APYs
|
|
728
|
+
const totalEth = data.apy.frames.reduce((sum, f) => sum + f.rewards_eth, 0);
|
|
729
|
+
const lifetimeRewardApy = data.apy.lifetime_reward_apy !== null && data.apy.lifetime_reward_apy !== undefined ? data.apy.lifetime_reward_apy.toFixed(2) + '%' : '--';
|
|
730
|
+
const lifetimeBondApy = data.apy.lifetime_bond_apy !== null && data.apy.lifetime_bond_apy !== undefined ? data.apy.lifetime_bond_apy.toFixed(2) + '%' : '--';
|
|
731
|
+
const lifetimeNetApy = data.apy.lifetime_net_apy !== null && data.apy.lifetime_net_apy !== undefined ? data.apy.lifetime_net_apy.toFixed(2) + '%' : '--';
|
|
732
|
+
historyTbody.innerHTML += `<tr class="border-t-2 border-gray-600 font-bold">
|
|
733
|
+
<td class="py-2" colspan="2">Lifetime</td>
|
|
734
|
+
<td class="py-2 text-right text-yellow-400">${totalEth.toFixed(4)}</td>
|
|
735
|
+
<td class="py-2 text-right">--</td>
|
|
736
|
+
<td class="py-2 text-right text-green-400">${lifetimeRewardApy}</td>
|
|
737
|
+
<td class="py-2 text-right text-green-400">${lifetimeBondApy}</td>
|
|
738
|
+
<td class="py-2 text-right text-yellow-400">${lifetimeNetApy}</td>
|
|
739
|
+
</tr>`;
|
|
740
|
+
|
|
741
|
+
historyTable.classList.remove('hidden');
|
|
742
|
+
historyLoaded = true;
|
|
743
|
+
loadHistoryBtn.textContent = 'Hide History';
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// Withdrawal History (if withdrawals available in cached data)
|
|
747
|
+
if (data.withdrawals && data.withdrawals.length > 0) {
|
|
748
|
+
withdrawalTbody.innerHTML = data.withdrawals.map((w, i) => {
|
|
749
|
+
const date = new Date(w.timestamp).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
|
750
|
+
const wType = w.withdrawal_type || 'stETH';
|
|
751
|
+
let amount, amountClass;
|
|
752
|
+
if (wType === 'unstETH' && w.claimed_eth !== null) {
|
|
753
|
+
amount = w.claimed_eth.toFixed(4) + ' ETH';
|
|
754
|
+
amountClass = 'text-green-400';
|
|
755
|
+
} else {
|
|
756
|
+
amount = w.eth_value.toFixed(4) + ' stETH';
|
|
757
|
+
amountClass = 'text-green-400';
|
|
758
|
+
}
|
|
759
|
+
let status;
|
|
760
|
+
if (wType === 'unstETH' && w.status) {
|
|
761
|
+
const statusColors = {
|
|
762
|
+
'pending': 'text-yellow-400',
|
|
763
|
+
'finalized': 'text-blue-400',
|
|
764
|
+
'claimed': 'text-green-400',
|
|
765
|
+
};
|
|
766
|
+
const statusLabels = {
|
|
767
|
+
'pending': 'Pending',
|
|
768
|
+
'finalized': 'Ready',
|
|
769
|
+
'claimed': 'Claimed',
|
|
770
|
+
};
|
|
771
|
+
status = `<span class="${statusColors[w.status] || 'text-gray-400'}">${statusLabels[w.status] || w.status}</span>`;
|
|
772
|
+
} else if (wType !== 'unstETH') {
|
|
773
|
+
status = '<span class="text-green-400">Claimed</span>';
|
|
774
|
+
} else {
|
|
775
|
+
status = '--';
|
|
776
|
+
}
|
|
777
|
+
return `<tr class="border-t border-gray-700">
|
|
778
|
+
<td class="py-2">${i + 1}</td>
|
|
779
|
+
<td class="py-2">${date}</td>
|
|
780
|
+
<td class="py-2">${wType}</td>
|
|
781
|
+
<td class="py-2 text-right ${amountClass}">${amount}</td>
|
|
782
|
+
<td class="py-2">${status}</td>
|
|
783
|
+
</tr>`;
|
|
784
|
+
}).join('');
|
|
785
|
+
|
|
786
|
+
withdrawalTable.classList.remove('hidden');
|
|
787
|
+
withdrawalsLoaded = true;
|
|
788
|
+
loadWithdrawalsBtn.textContent = 'Hide Withdrawals';
|
|
789
|
+
} else if (data.withdrawals && data.withdrawals.length === 0) {
|
|
790
|
+
// Explicit empty withdrawals
|
|
791
|
+
withdrawalTbody.innerHTML = '<tr><td colspan="5" class="py-4 text-center text-gray-400">No withdrawals found</td></tr>';
|
|
792
|
+
withdrawalTable.classList.remove('hidden');
|
|
793
|
+
withdrawalsLoaded = true;
|
|
794
|
+
loadWithdrawalsBtn.textContent = 'Hide Withdrawals';
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
form.addEventListener('submit', async (e) => {
|
|
799
|
+
e.preventDefault();
|
|
800
|
+
const input = document.getElementById('address').value.trim();
|
|
801
|
+
|
|
802
|
+
if (!input) return;
|
|
803
|
+
|
|
804
|
+
// Reset UI and show loading
|
|
805
|
+
loading.classList.remove('hidden');
|
|
806
|
+
resetUI();
|
|
807
|
+
|
|
405
808
|
try {
|
|
406
|
-
const response = await fetch(`/api/operator/${input}
|
|
809
|
+
const response = await fetch(`/api/operator/${input}`, { signal: pageAbortController.signal });
|
|
407
810
|
const data = await response.json();
|
|
408
811
|
|
|
409
812
|
loading.classList.add('hidden');
|
|
@@ -414,37 +817,9 @@ def create_app() -> FastAPI:
|
|
|
414
817
|
return;
|
|
415
818
|
}
|
|
416
819
|
|
|
417
|
-
|
|
418
|
-
document.getElementById('operator-id').textContent = data.operator_id;
|
|
419
|
-
document.getElementById('manager-address').textContent = data.manager_address;
|
|
420
|
-
document.getElementById('reward-address').textContent = data.reward_address;
|
|
421
|
-
|
|
422
|
-
// Show Active Since if available
|
|
423
|
-
if (data.active_since) {
|
|
424
|
-
const activeSince = new Date(data.active_since);
|
|
425
|
-
const options = { year: 'numeric', month: 'short', day: 'numeric' };
|
|
426
|
-
document.getElementById('active-since').textContent = activeSince.toLocaleDateString('en-US', options);
|
|
427
|
-
document.getElementById('active-since-row').classList.remove('hidden');
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
// Show tip with operator ID for faster lookups
|
|
431
|
-
document.getElementById('tip-operator-id').textContent = data.operator_id;
|
|
432
|
-
document.getElementById('lookup-tip').classList.remove('hidden');
|
|
433
|
-
|
|
434
|
-
document.getElementById('total-validators').textContent = data.validators.total;
|
|
435
|
-
document.getElementById('active-validators').textContent = data.validators.active;
|
|
436
|
-
document.getElementById('exited-validators').textContent = data.validators.exited;
|
|
437
|
-
|
|
438
|
-
document.getElementById('current-bond').textContent = parseFloat(data.rewards?.current_bond_eth ?? 0).toFixed(6);
|
|
439
|
-
document.getElementById('required-bond').textContent = parseFloat(data.rewards?.required_bond_eth ?? 0).toFixed(6);
|
|
440
|
-
document.getElementById('excess-bond').textContent = parseFloat(data.rewards?.excess_bond_eth ?? 0).toFixed(6);
|
|
441
|
-
document.getElementById('cumulative-rewards').textContent = parseFloat(data.rewards?.cumulative_rewards_eth ?? 0).toFixed(6);
|
|
442
|
-
document.getElementById('distributed-rewards').textContent = parseFloat(data.rewards?.distributed_eth ?? 0).toFixed(6);
|
|
443
|
-
document.getElementById('unclaimed-rewards').textContent = parseFloat(data.rewards?.unclaimed_eth ?? 0).toFixed(6);
|
|
444
|
-
document.getElementById('total-claimable').textContent = parseFloat(data.rewards?.total_claimable_eth ?? 0).toFixed(6);
|
|
445
|
-
|
|
446
|
-
results.classList.remove('hidden');
|
|
820
|
+
displayOperatorData(data);
|
|
447
821
|
} catch (err) {
|
|
822
|
+
if (isAbortError(err)) return; // Page is unloading, ignore
|
|
448
823
|
loading.classList.add('hidden');
|
|
449
824
|
error.classList.remove('hidden');
|
|
450
825
|
errorMessage.textContent = err.message || 'Network error';
|
|
@@ -464,7 +839,7 @@ def create_app() -> FastAPI:
|
|
|
464
839
|
detailsLoading.classList.remove('hidden');
|
|
465
840
|
|
|
466
841
|
try {
|
|
467
|
-
const response = await fetch(`/api/operator/${operatorId}?detailed=true
|
|
842
|
+
const response = await fetch(`/api/operator/${operatorId}?detailed=true`, { signal: pageAbortController.signal });
|
|
468
843
|
const data = await response.json();
|
|
469
844
|
|
|
470
845
|
detailsLoading.classList.add('hidden');
|
|
@@ -493,6 +868,16 @@ def create_app() -> FastAPI:
|
|
|
493
868
|
|
|
494
869
|
validatorStatus.classList.remove('hidden');
|
|
495
870
|
|
|
871
|
+
// Build beaconcha.in dashboard URL with validator indices
|
|
872
|
+
if (data.validator_details && data.validator_details.length > 0) {
|
|
873
|
+
const validatorIds = data.validator_details
|
|
874
|
+
.map(v => v.index !== null && v.index !== undefined ? v.index : v.pubkey)
|
|
875
|
+
.slice(0, 100)
|
|
876
|
+
.join(',');
|
|
877
|
+
beaconchainLink.href = `https://beaconcha.in/dashboard?validators=${validatorIds}`;
|
|
878
|
+
beaconchainLink.classList.remove('hidden');
|
|
879
|
+
}
|
|
880
|
+
|
|
496
881
|
// Populate APY metrics if available
|
|
497
882
|
if (data.apy) {
|
|
498
883
|
document.getElementById('reward-apy-28d').textContent = formatApy(data.apy.historical_reward_apy_28d);
|
|
@@ -595,7 +980,7 @@ def create_app() -> FastAPI:
|
|
|
595
980
|
strikesList.classList.remove('hidden');
|
|
596
981
|
try {
|
|
597
982
|
const opId = document.getElementById('operator-id').textContent;
|
|
598
|
-
const strikesResp = await fetch(`/api/operator/${opId}/strikes
|
|
983
|
+
const strikesResp = await fetch(`/api/operator/${opId}/strikes`, { signal: pageAbortController.signal });
|
|
599
984
|
const strikesData = await strikesResp.json();
|
|
600
985
|
const threshold = strikesData.strike_threshold || 3;
|
|
601
986
|
strikesList.innerHTML = strikesData.validators.map(v => {
|
|
@@ -629,6 +1014,7 @@ def create_app() -> FastAPI:
|
|
|
629
1014
|
strikesLoaded = true;
|
|
630
1015
|
toggleStrikesBtn.textContent = 'Hide validator details ▲';
|
|
631
1016
|
} catch (err) {
|
|
1017
|
+
if (isAbortError(err)) return; // Page is unloading, ignore
|
|
632
1018
|
strikesList.innerHTML = '<div class="text-red-400">Failed to load strikes</div>';
|
|
633
1019
|
}
|
|
634
1020
|
};
|
|
@@ -688,6 +1074,7 @@ def create_app() -> FastAPI:
|
|
|
688
1074
|
healthSection.classList.remove('hidden');
|
|
689
1075
|
}
|
|
690
1076
|
} catch (err) {
|
|
1077
|
+
if (isAbortError(err)) return; // Page is unloading, ignore
|
|
691
1078
|
detailsLoading.classList.add('hidden');
|
|
692
1079
|
loadDetailsBtn.classList.remove('hidden');
|
|
693
1080
|
loadDetailsBtn.textContent = 'Failed - Click to Retry';
|
|
@@ -697,7 +1084,6 @@ def create_app() -> FastAPI:
|
|
|
697
1084
|
});
|
|
698
1085
|
|
|
699
1086
|
// History button handler
|
|
700
|
-
let historyLoaded = false;
|
|
701
1087
|
loadHistoryBtn.addEventListener('click', async () => {
|
|
702
1088
|
if (historyLoaded) {
|
|
703
1089
|
// Toggle visibility
|
|
@@ -712,13 +1098,13 @@ def create_app() -> FastAPI:
|
|
|
712
1098
|
historyTable.classList.add('hidden');
|
|
713
1099
|
|
|
714
1100
|
try {
|
|
715
|
-
const response = await fetch(`/api/operator/${operatorId}?detailed=true&history=true
|
|
1101
|
+
const response = await fetch(`/api/operator/${operatorId}?detailed=true&history=true`, { signal: pageAbortController.signal });
|
|
716
1102
|
const data = await response.json();
|
|
717
1103
|
|
|
718
1104
|
historyLoading.classList.add('hidden');
|
|
719
1105
|
|
|
720
1106
|
if (!response.ok || !data.apy || !data.apy.frames) {
|
|
721
|
-
historyTbody.innerHTML = '<tr><td colspan="
|
|
1107
|
+
historyTbody.innerHTML = '<tr><td colspan="7" class="py-4 text-center text-gray-400">No history available</td></tr>';
|
|
722
1108
|
historyTable.classList.remove('hidden');
|
|
723
1109
|
return;
|
|
724
1110
|
}
|
|
@@ -727,37 +1113,57 @@ def create_app() -> FastAPI:
|
|
|
727
1113
|
historyTbody.innerHTML = data.apy.frames.map(frame => {
|
|
728
1114
|
const startDate = new Date(frame.start_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
|
729
1115
|
const endDate = new Date(frame.end_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
|
730
|
-
const
|
|
1116
|
+
const rewardApy = frame.apy !== null && frame.apy !== undefined ? frame.apy.toFixed(2) + '%' : '--';
|
|
1117
|
+
const bondApy = frame.bond_apy !== null && frame.bond_apy !== undefined ? frame.bond_apy.toFixed(2) + '%' : '--';
|
|
1118
|
+
const netApy = frame.net_apy !== null && frame.net_apy !== undefined ? frame.net_apy.toFixed(2) + '%' : '--';
|
|
731
1119
|
return `<tr class="border-t border-gray-700">
|
|
732
1120
|
<td class="py-2">${frame.frame_number}</td>
|
|
733
1121
|
<td class="py-2">${startDate} - ${endDate}</td>
|
|
734
1122
|
<td class="py-2 text-right text-green-400">${frame.rewards_eth.toFixed(4)}</td>
|
|
735
1123
|
<td class="py-2 text-right">${frame.validator_count}</td>
|
|
736
|
-
<td class="py-2 text-right text-
|
|
1124
|
+
<td class="py-2 text-right text-green-400">${rewardApy}</td>
|
|
1125
|
+
<td class="py-2 text-right text-green-400">${bondApy}</td>
|
|
1126
|
+
<td class="py-2 text-right text-yellow-400 font-bold">${netApy}</td>
|
|
737
1127
|
</tr>`;
|
|
738
1128
|
}).join('');
|
|
739
1129
|
|
|
740
|
-
// Add total row
|
|
1130
|
+
// Add total row with lifetime APYs
|
|
741
1131
|
const totalEth = data.apy.frames.reduce((sum, f) => sum + f.rewards_eth, 0);
|
|
1132
|
+
const lifetimeRewardApy = data.apy.lifetime_reward_apy !== null && data.apy.lifetime_reward_apy !== undefined ? data.apy.lifetime_reward_apy.toFixed(2) + '%' : '--';
|
|
1133
|
+
const lifetimeBondApy = data.apy.lifetime_bond_apy !== null && data.apy.lifetime_bond_apy !== undefined ? data.apy.lifetime_bond_apy.toFixed(2) + '%' : '--';
|
|
1134
|
+
const lifetimeNetApy = data.apy.lifetime_net_apy !== null && data.apy.lifetime_net_apy !== undefined ? data.apy.lifetime_net_apy.toFixed(2) + '%' : '--';
|
|
742
1135
|
historyTbody.innerHTML += `<tr class="border-t-2 border-gray-600 font-bold">
|
|
743
|
-
<td class="py-2" colspan="2">
|
|
1136
|
+
<td class="py-2" colspan="2">Lifetime</td>
|
|
744
1137
|
<td class="py-2 text-right text-yellow-400">${totalEth.toFixed(4)}</td>
|
|
745
1138
|
<td class="py-2 text-right">--</td>
|
|
746
|
-
<td class="py-2 text-right"
|
|
1139
|
+
<td class="py-2 text-right text-green-400">${lifetimeRewardApy}</td>
|
|
1140
|
+
<td class="py-2 text-right text-green-400">${lifetimeBondApy}</td>
|
|
1141
|
+
<td class="py-2 text-right text-yellow-400">${lifetimeNetApy}</td>
|
|
747
1142
|
</tr>`;
|
|
748
1143
|
|
|
1144
|
+
// Reveal and populate lifetime APY columns
|
|
1145
|
+
if (data.apy) {
|
|
1146
|
+
document.getElementById('apy-lifetime-header').classList.remove('hidden');
|
|
1147
|
+
document.getElementById('reward-apy-ltd').textContent = formatApy(data.apy.lifetime_reward_apy);
|
|
1148
|
+
document.getElementById('reward-apy-ltd').classList.remove('hidden');
|
|
1149
|
+
document.getElementById('bond-apy-ltd').textContent = formatApy(data.apy.lifetime_bond_apy);
|
|
1150
|
+
document.getElementById('bond-apy-ltd').classList.remove('hidden');
|
|
1151
|
+
document.getElementById('net-apy-ltd').textContent = formatApy(data.apy.lifetime_net_apy);
|
|
1152
|
+
document.getElementById('net-apy-ltd').classList.remove('hidden');
|
|
1153
|
+
}
|
|
1154
|
+
|
|
749
1155
|
historyTable.classList.remove('hidden');
|
|
750
1156
|
historyLoaded = true;
|
|
751
1157
|
loadHistoryBtn.textContent = 'Hide History';
|
|
752
1158
|
} catch (err) {
|
|
1159
|
+
if (isAbortError(err)) return; // Page is unloading, ignore
|
|
753
1160
|
historyLoading.classList.add('hidden');
|
|
754
|
-
historyTbody.innerHTML = '<tr><td colspan="
|
|
1161
|
+
historyTbody.innerHTML = '<tr><td colspan="7" class="py-4 text-center text-red-400">Failed to load history</td></tr>';
|
|
755
1162
|
historyTable.classList.remove('hidden');
|
|
756
1163
|
}
|
|
757
1164
|
});
|
|
758
1165
|
|
|
759
1166
|
// Withdrawal button handler
|
|
760
|
-
let withdrawalsLoaded = false;
|
|
761
1167
|
loadWithdrawalsBtn.addEventListener('click', async () => {
|
|
762
1168
|
if (withdrawalsLoaded) {
|
|
763
1169
|
// Toggle visibility
|
|
@@ -772,7 +1178,7 @@ def create_app() -> FastAPI:
|
|
|
772
1178
|
withdrawalTable.classList.add('hidden');
|
|
773
1179
|
|
|
774
1180
|
try {
|
|
775
|
-
const response = await fetch(`/api/operator/${operatorId}?withdrawals=true
|
|
1181
|
+
const response = await fetch(`/api/operator/${operatorId}?withdrawals=true`, { signal: pageAbortController.signal });
|
|
776
1182
|
const data = await response.json();
|
|
777
1183
|
|
|
778
1184
|
withdrawalLoading.classList.add('hidden');
|
|
@@ -847,11 +1253,327 @@ def create_app() -> FastAPI:
|
|
|
847
1253
|
withdrawalsLoaded = true;
|
|
848
1254
|
loadWithdrawalsBtn.textContent = 'Hide Withdrawals';
|
|
849
1255
|
} catch (err) {
|
|
1256
|
+
if (isAbortError(err)) return; // Page is unloading, ignore
|
|
850
1257
|
withdrawalLoading.classList.add('hidden');
|
|
851
1258
|
withdrawalTbody.innerHTML = '<tr><td colspan="5" class="py-4 text-center text-red-400">Failed to load withdrawals</td></tr>';
|
|
852
1259
|
withdrawalTable.classList.remove('hidden');
|
|
853
1260
|
}
|
|
854
1261
|
});
|
|
1262
|
+
|
|
1263
|
+
// ===== SAVED OPERATORS FUNCTIONALITY =====
|
|
1264
|
+
const savedOperatorsSection = document.getElementById('saved-operators-section');
|
|
1265
|
+
const savedOperatorsList = document.getElementById('saved-operators-list');
|
|
1266
|
+
const savedOperatorsLoading = document.getElementById('saved-operators-loading');
|
|
1267
|
+
const refreshAllBtn = document.getElementById('refresh-all-btn');
|
|
1268
|
+
const saveOperatorBtn = document.getElementById('save-operator-btn');
|
|
1269
|
+
|
|
1270
|
+
let currentOperatorSaved = false;
|
|
1271
|
+
let savedOperatorsData = {}; // Store operator data by ID for quick lookup
|
|
1272
|
+
|
|
1273
|
+
// Format relative time with precision
|
|
1274
|
+
function formatRelativeTime(isoString) {
|
|
1275
|
+
const date = new Date(isoString);
|
|
1276
|
+
const now = new Date();
|
|
1277
|
+
const diffMs = now - date;
|
|
1278
|
+
const diffSecs = Math.floor(diffMs / 1000);
|
|
1279
|
+
const diffMins = Math.floor(diffMs / 60000);
|
|
1280
|
+
const diffHours = Math.floor(diffMs / 3600000);
|
|
1281
|
+
const diffDays = Math.floor(diffMs / 86400000);
|
|
1282
|
+
|
|
1283
|
+
if (diffSecs < 60) return 'just now';
|
|
1284
|
+
if (diffMins < 60) return `${diffMins} min ago`;
|
|
1285
|
+
if (diffHours < 24) {
|
|
1286
|
+
const mins = diffMins % 60;
|
|
1287
|
+
if (mins === 0) return `${diffHours}h ago`;
|
|
1288
|
+
return `${diffHours}h ${mins}m ago`;
|
|
1289
|
+
}
|
|
1290
|
+
if (diffDays < 7) {
|
|
1291
|
+
const hours = diffHours % 24;
|
|
1292
|
+
if (hours === 0) return `${diffDays}d ago`;
|
|
1293
|
+
return `${diffDays}d ${hours}h ago`;
|
|
1294
|
+
}
|
|
1295
|
+
return `${diffDays}d ago`;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
// Render a single saved operator card
|
|
1299
|
+
function renderSavedOperatorCard(op) {
|
|
1300
|
+
const claimable = parseFloat(op.rewards?.total_claimable_eth ?? 0).toFixed(4);
|
|
1301
|
+
const claimableUsd = formatUsd(claimable);
|
|
1302
|
+
const validators = op.validators?.active ?? 0;
|
|
1303
|
+
const updatedAt = op._updated_at ? formatRelativeTime(op._updated_at) : 'unknown';
|
|
1304
|
+
|
|
1305
|
+
// Health indicator
|
|
1306
|
+
let healthDot = '<span class="w-2 h-2 rounded-full bg-green-500 inline-block"></span>';
|
|
1307
|
+
if (op.health?.has_issues) {
|
|
1308
|
+
if (!op.health.bond_healthy || op.health.slashed_validators_count > 0 || op.health.stuck_validators_count > 0) {
|
|
1309
|
+
healthDot = '<span class="w-2 h-2 rounded-full bg-red-500 inline-block"></span>';
|
|
1310
|
+
} else {
|
|
1311
|
+
healthDot = '<span class="w-2 h-2 rounded-full bg-yellow-500 inline-block"></span>';
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
return `
|
|
1316
|
+
<div class="bg-gray-800 rounded-lg p-4 flex justify-between items-center" data-operator-id="${op.operator_id}">
|
|
1317
|
+
<div class="flex-1">
|
|
1318
|
+
<div class="flex items-center gap-2 mb-1">
|
|
1319
|
+
${healthDot}
|
|
1320
|
+
<span class="font-bold">Operator #${op.operator_id}</span>
|
|
1321
|
+
<span class="text-gray-500 text-xs">Updated ${updatedAt}</span>
|
|
1322
|
+
</div>
|
|
1323
|
+
<div class="text-sm text-gray-400">
|
|
1324
|
+
<span class="text-green-400">${validators}</span> active validators |
|
|
1325
|
+
<span class="text-yellow-400">${claimable} ETH</span>${claimableUsd ? ` <span class="text-gray-500">(${claimableUsd})</span>` : ''} claimable
|
|
1326
|
+
</div>
|
|
1327
|
+
</div>
|
|
1328
|
+
<div class="flex gap-2">
|
|
1329
|
+
<button onclick="viewSavedOperator(${op.operator_id})"
|
|
1330
|
+
class="px-3 py-1 bg-blue-600 rounded hover:bg-blue-700 text-sm">
|
|
1331
|
+
View
|
|
1332
|
+
</button>
|
|
1333
|
+
<button onclick="refreshSavedOperator(${op.operator_id}, this)"
|
|
1334
|
+
class="px-3 py-1 bg-green-600 rounded hover:bg-green-700 text-sm">
|
|
1335
|
+
Refresh
|
|
1336
|
+
</button>
|
|
1337
|
+
<button onclick="removeSavedOperator(${op.operator_id}, this)"
|
|
1338
|
+
class="px-3 py-1 bg-red-600 rounded hover:bg-red-700 text-sm">
|
|
1339
|
+
Remove
|
|
1340
|
+
</button>
|
|
1341
|
+
</div>
|
|
1342
|
+
</div>
|
|
1343
|
+
`;
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
// Re-render saved operator cards (called when ETH price updates)
|
|
1347
|
+
function rerenderSavedOperators() {
|
|
1348
|
+
if (Object.keys(savedOperatorsData).length > 0) {
|
|
1349
|
+
const operators = Object.values(savedOperatorsData);
|
|
1350
|
+
savedOperatorsList.innerHTML = operators.map(renderSavedOperatorCard).join('');
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
// Load saved operators on page load
|
|
1355
|
+
async function loadSavedOperators() {
|
|
1356
|
+
try {
|
|
1357
|
+
const response = await fetch('/api/saved-operators', { signal: pageAbortController.signal });
|
|
1358
|
+
const data = await response.json();
|
|
1359
|
+
|
|
1360
|
+
if (data.operators && data.operators.length > 0) {
|
|
1361
|
+
// Store data for quick lookup
|
|
1362
|
+
savedOperatorsData = {};
|
|
1363
|
+
data.operators.forEach(op => {
|
|
1364
|
+
savedOperatorsData[op.operator_id] = op;
|
|
1365
|
+
});
|
|
1366
|
+
savedOperatorsList.innerHTML = data.operators.map(renderSavedOperatorCard).join('');
|
|
1367
|
+
savedOperatorsSection.classList.remove('hidden');
|
|
1368
|
+
} else {
|
|
1369
|
+
savedOperatorsData = {};
|
|
1370
|
+
savedOperatorsSection.classList.add('hidden');
|
|
1371
|
+
}
|
|
1372
|
+
} catch (err) {
|
|
1373
|
+
if (!isAbortError(err)) {
|
|
1374
|
+
console.error('Failed to load saved operators:', err);
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
// View a saved operator (display cached data directly)
|
|
1380
|
+
window.viewSavedOperator = function(operatorId) {
|
|
1381
|
+
const opData = savedOperatorsData[operatorId];
|
|
1382
|
+
if (!opData) {
|
|
1383
|
+
// Fallback to API fetch if data not in cache
|
|
1384
|
+
document.getElementById('address').value = operatorId;
|
|
1385
|
+
form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
|
1386
|
+
return;
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
// Display cached data directly
|
|
1390
|
+
document.getElementById('address').value = operatorId;
|
|
1391
|
+
resetUI();
|
|
1392
|
+
displayOperatorData(opData);
|
|
1393
|
+
|
|
1394
|
+
// Update save button state
|
|
1395
|
+
currentOperatorSaved = true;
|
|
1396
|
+
updateSaveButton();
|
|
1397
|
+
};
|
|
1398
|
+
|
|
1399
|
+
// Refresh a saved operator
|
|
1400
|
+
window.refreshSavedOperator = async function(operatorId, btn) {
|
|
1401
|
+
const originalText = btn.textContent;
|
|
1402
|
+
btn.innerHTML = '<span class="inline-block animate-spin">↻</span>';
|
|
1403
|
+
btn.disabled = true;
|
|
1404
|
+
|
|
1405
|
+
try {
|
|
1406
|
+
const response = await fetch(`/api/operator/${operatorId}/refresh`, { method: 'POST', signal: pageAbortController.signal });
|
|
1407
|
+
if (response.ok) {
|
|
1408
|
+
const data = await response.json();
|
|
1409
|
+
// Update the card in the list and stored data
|
|
1410
|
+
const card = document.querySelector(`[data-operator-id="${operatorId}"]`);
|
|
1411
|
+
if (card && data.data) {
|
|
1412
|
+
data.data._updated_at = new Date().toISOString();
|
|
1413
|
+
savedOperatorsData[operatorId] = data.data; // Update stored data
|
|
1414
|
+
card.outerHTML = renderSavedOperatorCard(data.data);
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
} catch (err) {
|
|
1418
|
+
if (!isAbortError(err)) {
|
|
1419
|
+
console.error('Failed to refresh operator:', err);
|
|
1420
|
+
}
|
|
1421
|
+
} finally {
|
|
1422
|
+
btn.innerHTML = originalText;
|
|
1423
|
+
btn.disabled = false;
|
|
1424
|
+
}
|
|
1425
|
+
};
|
|
1426
|
+
|
|
1427
|
+
// Remove a saved operator
|
|
1428
|
+
window.removeSavedOperator = async function(operatorId, btn) {
|
|
1429
|
+
const originalText = btn.textContent;
|
|
1430
|
+
btn.innerHTML = '<span class="inline-block animate-spin">↻</span>';
|
|
1431
|
+
btn.disabled = true;
|
|
1432
|
+
|
|
1433
|
+
try {
|
|
1434
|
+
const response = await fetch(`/api/operator/${operatorId}/save`, { method: 'DELETE', signal: pageAbortController.signal });
|
|
1435
|
+
if (response.ok) {
|
|
1436
|
+
delete savedOperatorsData[operatorId]; // Remove from stored data
|
|
1437
|
+
const card = document.querySelector(`[data-operator-id="${operatorId}"]`);
|
|
1438
|
+
if (card) card.remove();
|
|
1439
|
+
|
|
1440
|
+
// Hide section if no more operators
|
|
1441
|
+
if (savedOperatorsList.children.length === 0) {
|
|
1442
|
+
savedOperatorsSection.classList.add('hidden');
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
// Update save button if viewing this operator
|
|
1446
|
+
const currentOpId = document.getElementById('operator-id').textContent;
|
|
1447
|
+
if (currentOpId == operatorId) {
|
|
1448
|
+
currentOperatorSaved = false;
|
|
1449
|
+
updateSaveButton();
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
} catch (err) {
|
|
1453
|
+
if (!isAbortError(err)) {
|
|
1454
|
+
console.error('Failed to remove operator:', err);
|
|
1455
|
+
btn.innerHTML = originalText;
|
|
1456
|
+
btn.disabled = false;
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
};
|
|
1460
|
+
|
|
1461
|
+
// Refresh all saved operators
|
|
1462
|
+
refreshAllBtn.addEventListener('click', async () => {
|
|
1463
|
+
const originalText = refreshAllBtn.textContent;
|
|
1464
|
+
refreshAllBtn.disabled = true;
|
|
1465
|
+
|
|
1466
|
+
const cards = savedOperatorsList.querySelectorAll('[data-operator-id]');
|
|
1467
|
+
const total = cards.length;
|
|
1468
|
+
let current = 0;
|
|
1469
|
+
|
|
1470
|
+
for (const card of cards) {
|
|
1471
|
+
current++;
|
|
1472
|
+
refreshAllBtn.innerHTML = `<span class="inline-block animate-spin mr-1">↻</span> ${current}/${total}`;
|
|
1473
|
+
|
|
1474
|
+
const operatorId = card.dataset.operatorId;
|
|
1475
|
+
try {
|
|
1476
|
+
const response = await fetch(`/api/operator/${operatorId}/refresh`, { method: 'POST', signal: pageAbortController.signal });
|
|
1477
|
+
if (response.ok) {
|
|
1478
|
+
const data = await response.json();
|
|
1479
|
+
if (data.data) {
|
|
1480
|
+
data.data._updated_at = new Date().toISOString();
|
|
1481
|
+
savedOperatorsData[operatorId] = data.data; // Update stored data
|
|
1482
|
+
card.outerHTML = renderSavedOperatorCard(data.data);
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
} catch (err) {
|
|
1486
|
+
if (isAbortError(err)) break; // Page is unloading, stop loop
|
|
1487
|
+
console.error(`Failed to refresh operator ${operatorId}:`, err);
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
refreshAllBtn.innerHTML = originalText;
|
|
1492
|
+
refreshAllBtn.disabled = false;
|
|
1493
|
+
});
|
|
1494
|
+
|
|
1495
|
+
// Update save button state
|
|
1496
|
+
function updateSaveButton() {
|
|
1497
|
+
if (currentOperatorSaved) {
|
|
1498
|
+
saveOperatorBtn.textContent = 'Saved';
|
|
1499
|
+
saveOperatorBtn.classList.remove('bg-yellow-600', 'hover:bg-yellow-700');
|
|
1500
|
+
saveOperatorBtn.classList.add('bg-gray-600', 'hover:bg-gray-700');
|
|
1501
|
+
} else {
|
|
1502
|
+
saveOperatorBtn.textContent = 'Save';
|
|
1503
|
+
saveOperatorBtn.classList.remove('bg-gray-600', 'hover:bg-gray-700');
|
|
1504
|
+
saveOperatorBtn.classList.add('bg-yellow-600', 'hover:bg-yellow-700');
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
// Check if current operator is saved
|
|
1509
|
+
async function checkIfOperatorSaved(operatorId) {
|
|
1510
|
+
try {
|
|
1511
|
+
const response = await fetch(`/api/operator/${operatorId}/saved`, { signal: pageAbortController.signal });
|
|
1512
|
+
const data = await response.json();
|
|
1513
|
+
currentOperatorSaved = data.saved;
|
|
1514
|
+
updateSaveButton();
|
|
1515
|
+
} catch (err) {
|
|
1516
|
+
if (!isAbortError(err)) {
|
|
1517
|
+
console.error('Failed to check if operator is saved:', err);
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
// Save/unsave operator button handler
|
|
1523
|
+
saveOperatorBtn.addEventListener('click', async () => {
|
|
1524
|
+
const operatorId = document.getElementById('operator-id').textContent;
|
|
1525
|
+
if (!operatorId) return;
|
|
1526
|
+
|
|
1527
|
+
saveOperatorBtn.disabled = true;
|
|
1528
|
+
saveOperatorBtn.innerHTML = '<span class="inline-block animate-spin">↻</span>';
|
|
1529
|
+
|
|
1530
|
+
try {
|
|
1531
|
+
if (currentOperatorSaved) {
|
|
1532
|
+
// Unsave
|
|
1533
|
+
const response = await fetch(`/api/operator/${operatorId}/save`, { method: 'DELETE', signal: pageAbortController.signal });
|
|
1534
|
+
if (response.ok) {
|
|
1535
|
+
currentOperatorSaved = false;
|
|
1536
|
+
delete savedOperatorsData[operatorId]; // Remove from stored data
|
|
1537
|
+
// Remove from saved list
|
|
1538
|
+
const card = document.querySelector(`[data-operator-id="${operatorId}"]`);
|
|
1539
|
+
if (card) card.remove();
|
|
1540
|
+
if (savedOperatorsList.children.length === 0) {
|
|
1541
|
+
savedOperatorsSection.classList.add('hidden');
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
} else {
|
|
1545
|
+
// Save
|
|
1546
|
+
const response = await fetch(`/api/operator/${operatorId}/save`, { method: 'POST', signal: pageAbortController.signal });
|
|
1547
|
+
if (response.ok) {
|
|
1548
|
+
currentOperatorSaved = true;
|
|
1549
|
+
// Reload saved operators to show the new one
|
|
1550
|
+
await loadSavedOperators();
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
} catch (err) {
|
|
1554
|
+
if (!isAbortError(err)) {
|
|
1555
|
+
console.error('Failed to save/unsave operator:', err);
|
|
1556
|
+
}
|
|
1557
|
+
} finally {
|
|
1558
|
+
saveOperatorBtn.disabled = false;
|
|
1559
|
+
updateSaveButton();
|
|
1560
|
+
}
|
|
1561
|
+
});
|
|
1562
|
+
|
|
1563
|
+
// Modify form submit to check if operator is saved
|
|
1564
|
+
const originalFormSubmit = form.onsubmit;
|
|
1565
|
+
form.addEventListener('submit', async (e) => {
|
|
1566
|
+
// Wait a bit for the results to load, then check if saved
|
|
1567
|
+
setTimeout(async () => {
|
|
1568
|
+
const operatorId = document.getElementById('operator-id').textContent;
|
|
1569
|
+
if (operatorId) {
|
|
1570
|
+
await checkIfOperatorSaved(operatorId);
|
|
1571
|
+
}
|
|
1572
|
+
}, 100);
|
|
1573
|
+
});
|
|
1574
|
+
|
|
1575
|
+
// Load saved operators on page load
|
|
1576
|
+
loadSavedOperators();
|
|
855
1577
|
</script>
|
|
856
1578
|
</body>
|
|
857
1579
|
</html>
|