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.
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.3.6",
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
- <h2 class="text-xl font-bold mb-2">
68
- Operator #<span id="operator-id"></span>
69
- </h2>
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
- <h3 class="text-lg font-bold mb-4">Validator Status (Beacon Chain)</h3>
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
- <span><span id="current-bond">0</span> ETH</span>
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
- <span><span id="required-bond">0</span> ETH</span>
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
- <span class="text-green-400"><span id="excess-bond">0</span> ETH</span>
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
- <span><span id="cumulative-rewards">0</span> ETH</span>
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
- <span><span id="distributed-rewards">0</span> ETH</span>
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
- <span class="text-green-400"><span id="unclaimed-rewards">0</span> ETH</span>
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
- <span class="text-yellow-400"><span id="total-claimable">0</span> ETH</span>
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">Validators</th>
296
- <th class="text-right py-2">Per Val</th>
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
- form.addEventListener('submit', async (e) => {
368
- e.preventDefault();
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
- // Populate results
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="5" class="py-4 text-center text-gray-400">No history available</td></tr>';
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 perVal = frame.validator_count > 0 ? (frame.rewards_eth / frame.validator_count).toFixed(6) : '--';
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-gray-400">${perVal}</td>
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">Total</td>
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">--</td>
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="5" class="py-4 text-center text-red-400">Failed to load history</td></tr>';
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">&#8635;</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">&#8635;</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">&#8635;</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">&#8635;</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>