feng-x commited on
Commit
81c9c6a
·
verified ·
1 Parent(s): 68c293c

Upload folder using huggingface_hub

Browse files
web_demo/app.py CHANGED
@@ -14,10 +14,11 @@ import os
14
  import re
15
  import sys
16
  import uuid
 
17
  from concurrent.futures import ThreadPoolExecutor
18
- from datetime import datetime
19
  from pathlib import Path
20
- from typing import Any, Dict, Optional, Tuple
21
 
22
  import cv2
23
  import numpy as np
@@ -31,7 +32,18 @@ from measure_finger import measure_finger, measure_multi_finger, apply_calibrati
31
  from src.logging_config import configure_logging
32
  from src.ring_size import recommend_ring_size, RING_MODELS, VALID_RING_MODELS, DEFAULT_RING_MODEL
33
  from src.ai_recommendation import ai_explain_recommendation
34
- from web_demo.supabase_client import upload_file, save_measurement, list_measurements, update_ground_truth, delete_measurement
 
 
 
 
 
 
 
 
 
 
 
35
 
36
  configure_logging()
37
 
@@ -447,6 +459,225 @@ def api_admin_delete(measurement_id: str):
447
  return jsonify({"success": False, "error": "Delete failed"}), 400
448
 
449
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
450
  @app.route("/api/admin/export-csv")
451
  def api_admin_export_csv():
452
  if not _check_admin_token():
 
14
  import re
15
  import sys
16
  import uuid
17
+ from collections import Counter, defaultdict
18
  from concurrent.futures import ThreadPoolExecutor
19
+ from datetime import date, datetime, timedelta, timezone
20
  from pathlib import Path
21
+ from typing import Any, Dict, List, Optional, Tuple
22
 
23
  import cv2
24
  import numpy as np
 
32
  from src.logging_config import configure_logging
33
  from src.ring_size import recommend_ring_size, RING_MODELS, VALID_RING_MODELS, DEFAULT_RING_MODEL
34
  from src.ai_recommendation import ai_explain_recommendation
35
+ from web_demo.supabase_client import (
36
+ upload_file,
37
+ save_measurement,
38
+ list_measurements,
39
+ list_measurements_for_stats,
40
+ update_ground_truth,
41
+ delete_measurement,
42
+ )
43
+ from src.confidence_constants import (
44
+ CONFIDENCE_LEVEL_HIGH_THRESHOLD,
45
+ CONFIDENCE_LEVEL_MEDIUM_THRESHOLD,
46
+ )
47
 
48
  configure_logging()
49
 
 
459
  return jsonify({"success": False, "error": "Delete failed"}), 400
460
 
461
 
462
+ def _parse_iso_to_utc_date(iso_str: str) -> Optional[date]:
463
+ """Parse a Supabase `created_at` ISO string to a UTC `date`.
464
+
465
+ Supabase returns either '...+00:00' or trailing 'Z'. fromisoformat does
466
+ not accept 'Z' until 3.11, so normalize defensively.
467
+ """
468
+ if not iso_str:
469
+ return None
470
+ try:
471
+ s = iso_str.replace("Z", "+00:00")
472
+ dt = datetime.fromisoformat(s)
473
+ if dt.tzinfo is None:
474
+ dt = dt.replace(tzinfo=timezone.utc)
475
+ return dt.astimezone(timezone.utc).date()
476
+ except (ValueError, TypeError):
477
+ return None
478
+
479
+
480
+ def _kol_key(name: Optional[str]) -> Optional[str]:
481
+ """Normalize kol_name for grouping (trim + lower). Empty → None so we
482
+ don't count anonymous sample runs as a distinct KOL."""
483
+ if not name:
484
+ return None
485
+ s = name.strip().lower()
486
+ return s or None
487
+
488
+
489
+ def _compute_stats(rows: List[Dict[str, Any]], days: int = 30) -> Dict[str, Any]:
490
+ """Aggregate measurement rows into dashboard-ready buckets.
491
+
492
+ All time-bucketing is in UTC. `days` controls the per-day window; KOL,
493
+ failure, and distribution stats use the entire row set so totals reflect
494
+ everything in the table.
495
+ """
496
+ today = datetime.now(timezone.utc).date()
497
+ window_start = today - timedelta(days=days - 1)
498
+
499
+ per_day_photos: Counter = Counter()
500
+ per_day_kols: defaultdict = defaultdict(set)
501
+ per_day_fails: Counter = Counter()
502
+
503
+ kol_counts: Counter = Counter()
504
+ kol_last_seen: Dict[str, str] = {}
505
+ kol_display: Dict[str, str] = {}
506
+ fail_counter: Counter = Counter()
507
+ ring_model_counter: Counter = Counter()
508
+ mode_counter: Counter = Counter()
509
+ size_counter: Counter = Counter()
510
+
511
+ confidence_buckets = {"HIGH": 0, "MEDIUM": 0, "LOW": 0}
512
+ confidence_sum = 0.0
513
+ confidence_n = 0
514
+
515
+ success_count = 0
516
+ fail_count = 0
517
+ gt_filled_count = 0
518
+
519
+ last_7_count = 0
520
+ prev_7_count = 0
521
+ last_7_start = today - timedelta(days=6)
522
+ prev_7_start = today - timedelta(days=13)
523
+
524
+ first_at: Optional[str] = None
525
+ last_at: Optional[str] = None
526
+
527
+ for row in rows:
528
+ created_at = row.get("created_at") or ""
529
+ if created_at:
530
+ if last_at is None or created_at > last_at:
531
+ last_at = created_at
532
+ if first_at is None or created_at < first_at:
533
+ first_at = created_at
534
+
535
+ d = _parse_iso_to_utc_date(created_at)
536
+
537
+ kkey = _kol_key(row.get("kol_name"))
538
+ if kkey:
539
+ kol_counts[kkey] += 1
540
+ if kkey not in kol_display:
541
+ kol_display[kkey] = (row.get("kol_name") or "").strip()
542
+ if created_at and (kkey not in kol_last_seen or created_at > kol_last_seen[kkey]):
543
+ kol_last_seen[kkey] = created_at
544
+
545
+ fail_reason = row.get("fail_reason")
546
+ if fail_reason:
547
+ fail_count += 1
548
+ fail_counter[fail_reason] += 1
549
+ else:
550
+ success_count += 1
551
+
552
+ rm = row.get("ring_model")
553
+ if rm:
554
+ ring_model_counter[rm] += 1
555
+ m = row.get("mode")
556
+ if m:
557
+ mode_counter[m] += 1
558
+
559
+ size = row.get("overall_best_size")
560
+ if size not in (None, ""):
561
+ size_counter[str(size)] += 1
562
+
563
+ conf = row.get("confidence")
564
+ if isinstance(conf, (int, float)):
565
+ confidence_sum += float(conf)
566
+ confidence_n += 1
567
+ if conf > CONFIDENCE_LEVEL_HIGH_THRESHOLD:
568
+ confidence_buckets["HIGH"] += 1
569
+ elif conf >= CONFIDENCE_LEVEL_MEDIUM_THRESHOLD:
570
+ confidence_buckets["MEDIUM"] += 1
571
+ else:
572
+ confidence_buckets["LOW"] += 1
573
+
574
+ if any(row.get(k) not in (None, "") for k in ("gt_index_size", "gt_middle_size", "gt_ring_size")):
575
+ gt_filled_count += 1
576
+
577
+ if d is not None:
578
+ if window_start <= d <= today:
579
+ key = d.isoformat()
580
+ per_day_photos[key] += 1
581
+ if kkey:
582
+ per_day_kols[key].add(kkey)
583
+ if fail_reason:
584
+ per_day_fails[key] += 1
585
+ if last_7_start <= d <= today:
586
+ last_7_count += 1
587
+ elif prev_7_start <= d < last_7_start:
588
+ prev_7_count += 1
589
+
590
+ per_day_series = []
591
+ for i in range(days):
592
+ d = window_start + timedelta(days=i)
593
+ key = d.isoformat()
594
+ per_day_series.append({
595
+ "date": key,
596
+ "photos": per_day_photos.get(key, 0),
597
+ "unique_kols": len(per_day_kols.get(key, ())),
598
+ "fails": per_day_fails.get(key, 0),
599
+ })
600
+
601
+ top_kols = [
602
+ {
603
+ "kol_name": kol_display.get(k, k),
604
+ "count": c,
605
+ "last_at": kol_last_seen.get(k),
606
+ }
607
+ for k, c in kol_counts.most_common(10)
608
+ ]
609
+
610
+ fail_reasons = [
611
+ {"reason": r, "count": c}
612
+ for r, c in fail_counter.most_common()
613
+ ]
614
+
615
+ ring_models = [
616
+ {"model": m, "count": c}
617
+ for m, c in ring_model_counter.most_common()
618
+ ]
619
+
620
+ modes = [{"mode": m, "count": c} for m, c in mode_counter.most_common()]
621
+
622
+ def _size_sort_key(item):
623
+ s = item[0]
624
+ try:
625
+ return (0, float(s))
626
+ except (TypeError, ValueError):
627
+ return (1, s)
628
+
629
+ size_distribution = [
630
+ {"size": s, "count": c}
631
+ for s, c in sorted(size_counter.items(), key=_size_sort_key)
632
+ ]
633
+
634
+ total = len(rows)
635
+ success_rate = (success_count / total) if total else 0.0
636
+ avg_confidence = (confidence_sum / confidence_n) if confidence_n else 0.0
637
+ gt_rate = (gt_filled_count / total) if total else 0.0
638
+
639
+ return {
640
+ "totals": {
641
+ "total_measurements": total,
642
+ "unique_kols": len(kol_counts),
643
+ "success_count": success_count,
644
+ "fail_count": fail_count,
645
+ "success_rate": round(success_rate, 4),
646
+ "avg_confidence": round(avg_confidence, 4),
647
+ "gt_filled_count": gt_filled_count,
648
+ "gt_filled_rate": round(gt_rate, 4),
649
+ "last_7_days": last_7_count,
650
+ "prev_7_days": prev_7_count,
651
+ "first_measurement_at": first_at,
652
+ "last_measurement_at": last_at,
653
+ },
654
+ "window_days": days,
655
+ "per_day": per_day_series,
656
+ "top_kols": top_kols,
657
+ "fail_reasons": fail_reasons,
658
+ "ring_models": ring_models,
659
+ "modes": modes,
660
+ "size_distribution": size_distribution,
661
+ "confidence_buckets": [
662
+ {"bucket": "HIGH (>0.85)", "count": confidence_buckets["HIGH"]},
663
+ {"bucket": "MEDIUM (0.6–0.85)", "count": confidence_buckets["MEDIUM"]},
664
+ {"bucket": "LOW (<0.6)", "count": confidence_buckets["LOW"]},
665
+ ],
666
+ }
667
+
668
+
669
+ @app.route("/api/admin/stats")
670
+ def api_admin_stats():
671
+ if not _check_admin_token():
672
+ return jsonify({"error": "Unauthorized"}), 401
673
+ try:
674
+ days = max(7, min(int(request.args.get("days", 30)), 180))
675
+ except (TypeError, ValueError):
676
+ days = 30
677
+ rows = list_measurements_for_stats(limit=5000)
678
+ return jsonify(_compute_stats(rows, days=days))
679
+
680
+
681
  @app.route("/api/admin/export-csv")
682
  def api_admin_export_csv():
683
  if not _check_admin_token():
web_demo/supabase_client.py CHANGED
@@ -120,6 +120,35 @@ def list_measurements(limit: int = 200, offset: int = 0) -> List[Dict[str, Any]]
120
  return []
121
 
122
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  def delete_measurement(measurement_id: str) -> bool:
124
  """Delete a measurement record by ID."""
125
  client = _get_client()
 
120
  return []
121
 
122
 
123
+ STATS_COLUMNS = (
124
+ "id,created_at,kol_name,mode,ring_model,confidence,fail_reason,"
125
+ "overall_best_size,ring_fit,gt_index_size,gt_middle_size,gt_ring_size"
126
+ )
127
+
128
+
129
+ def list_measurements_for_stats(limit: int = 5000) -> List[Dict[str, Any]]:
130
+ """Lightweight projection used by the admin stats endpoint.
131
+
132
+ Skips heavy columns (`result_json`, `per_finger`, photo URLs) so we can
133
+ pull thousands of rows for aggregation without paying the JSON-blob cost.
134
+ """
135
+ client = _get_client()
136
+ if client is None:
137
+ return []
138
+ try:
139
+ resp = (
140
+ client.table("measurements")
141
+ .select(STATS_COLUMNS)
142
+ .order("created_at", desc=True)
143
+ .limit(limit)
144
+ .execute()
145
+ )
146
+ return resp.data or []
147
+ except Exception as e:
148
+ logger.error("Failed to list measurements for stats: %s", e)
149
+ return []
150
+
151
+
152
  def delete_measurement(measurement_id: str) -> bool:
153
  """Delete a measurement record by ID."""
154
  client = _get_client()
web_demo/templates/admin.html CHANGED
@@ -78,7 +78,75 @@
78
  .finger-cell .detail { color: var(--ink-soft); }
79
  .empty { text-align: center; padding: 48px; color: var(--ink-soft); }
80
  .scroll-wrap { overflow-x: auto; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  </style>
 
82
  </head>
83
  <body>
84
 
@@ -95,36 +163,96 @@
95
 
96
  <!-- Admin content (hidden until authenticated) -->
97
  <div class="admin-content" id="adminContent">
98
- <h1>KOL Measurement Records</h1>
99
- <div class="toolbar">
100
- <a href="/">Back to Demo</a>
101
- <a id="exportCsvLink" href="#" download>Export CSV</a>
102
- <button id="refreshBtn">Refresh</button>
103
- <button id="prevPageBtn" disabled>&lsaquo; Prev</button>
104
- <button id="nextPageBtn" disabled>Next &rsaquo;</button>
105
- <span class="count" id="countLabel">Loading...</span>
106
  </div>
107
 
108
- <div class="scroll-wrap">
109
- <table>
110
- <thead>
111
- <tr>
112
- <th>KOL</th>
113
- <th>Date</th>
114
- <th>Model</th>
115
- <th>Photo</th>
116
- <th>Index</th>
117
- <th>Middle</th>
118
- <th>Ring</th>
119
- <th>Conf</th>
120
- <th>Fail</th>
121
- <th></th>
122
- </tr>
123
- </thead>
124
- <tbody id="tableBody">
125
- <tr><td colspan="10" class="empty">Loading...</td></tr>
126
- </tbody>
127
- </table>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  </div>
129
  </div>
130
 
@@ -153,6 +281,7 @@
153
  loginGate.style.display = "none";
154
  adminContent.style.display = "block";
155
  document.getElementById("exportCsvLink").href = `/api/admin/export-csv?token=${encodeURIComponent(adminToken)}`;
 
156
  loadData();
157
  return true;
158
  };
@@ -270,6 +399,201 @@
270
  prevPageBtn.addEventListener("click", () => { currentPage--; renderPage(); });
271
  nextPageBtn.addEventListener("click", () => { currentPage++; renderPage(); });
272
  document.getElementById("refreshBtn").addEventListener("click", loadData);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
273
  </script>
274
  </body>
275
  </html>
 
78
  .finger-cell .detail { color: var(--ink-soft); }
79
  .empty { text-align: center; padding: 48px; color: var(--ink-soft); }
80
  .scroll-wrap { overflow-x: auto; }
81
+ /* --- Tabs --- */
82
+ .tabs {
83
+ display: flex; gap: 0; margin-bottom: 16px;
84
+ border-bottom: 1px solid var(--border);
85
+ }
86
+ .tab {
87
+ padding: 8px 16px; cursor: pointer; font-size: 14px;
88
+ background: none; border: none; color: var(--ink-soft);
89
+ border-bottom: 2px solid transparent;
90
+ margin-bottom: -1px;
91
+ }
92
+ .tab:hover { color: var(--ink); }
93
+ .tab.active { color: var(--ink); border-bottom-color: var(--accent); font-weight: 600; }
94
+ .pane { display: none; }
95
+ .pane.active { display: block; }
96
+ /* --- Dashboard --- */
97
+ .stat-grid {
98
+ display: grid;
99
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
100
+ gap: 12px; margin-bottom: 20px;
101
+ }
102
+ .stat-card {
103
+ background: #fff; border-radius: 8px; padding: 14px 16px;
104
+ box-shadow: 0 1px 4px var(--shadow);
105
+ }
106
+ .stat-card .label {
107
+ font-size: 11px; color: var(--ink-soft);
108
+ text-transform: uppercase; letter-spacing: 0.05em;
109
+ margin-bottom: 6px;
110
+ }
111
+ .stat-card .value {
112
+ font-size: 22px; font-weight: 600; color: var(--ink);
113
+ }
114
+ .stat-card .sub {
115
+ font-size: 12px; color: var(--ink-soft); margin-top: 4px;
116
+ }
117
+ .delta-up { color: #2f7a3d; }
118
+ .delta-down { color: var(--accent); }
119
+ .delta-flat { color: var(--ink-soft); }
120
+ .chart-grid {
121
+ display: grid;
122
+ grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
123
+ gap: 16px;
124
+ }
125
+ .chart-card {
126
+ background: #fff; border-radius: 8px; padding: 14px 16px;
127
+ box-shadow: 0 1px 4px var(--shadow);
128
+ }
129
+ .chart-card.wide { grid-column: 1 / -1; }
130
+ .chart-card h2 {
131
+ font-size: 13px; margin: 0 0 12px;
132
+ color: var(--ink-soft); font-weight: 600;
133
+ text-transform: uppercase; letter-spacing: 0.05em;
134
+ }
135
+ .chart-wrap { position: relative; height: 240px; }
136
+ .chart-wrap.tall { height: 280px; }
137
+ .top-kol-list {
138
+ list-style: none; padding: 0; margin: 0;
139
+ font-size: 13px;
140
+ }
141
+ .top-kol-list li {
142
+ display: flex; justify-content: space-between;
143
+ padding: 6px 0; border-bottom: 1px solid var(--border);
144
+ }
145
+ .top-kol-list li:last-child { border-bottom: none; }
146
+ .top-kol-list .name { font-weight: 500; }
147
+ .top-kol-list .count { color: var(--ink-soft); }
148
  </style>
149
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
150
  </head>
151
  <body>
152
 
 
163
 
164
  <!-- Admin content (hidden until authenticated) -->
165
  <div class="admin-content" id="adminContent">
166
+ <h1>Ring Sizer — Admin</h1>
167
+ <div class="tabs">
168
+ <button class="tab active" data-pane="dashboardPane">Dashboard</button>
169
+ <button class="tab" data-pane="recordsPane">Records</button>
 
 
 
 
170
  </div>
171
 
172
+ <!-- Dashboard pane -->
173
+ <div class="pane active" id="dashboardPane">
174
+ <div class="toolbar">
175
+ <a href="/">Back to Demo</a>
176
+ <button id="dashRefreshBtn">Refresh</button>
177
+ <label class="count" for="windowSelect">Window:</label>
178
+ <select id="windowSelect">
179
+ <option value="7">Last 7 days</option>
180
+ <option value="14">Last 14 days</option>
181
+ <option value="30" selected>Last 30 days</option>
182
+ <option value="60">Last 60 days</option>
183
+ <option value="90">Last 90 days</option>
184
+ </select>
185
+ <span class="count" id="dashStatusLabel">Loading...</span>
186
+ </div>
187
+
188
+ <div class="stat-grid" id="statGrid">
189
+ <!-- stat cards injected -->
190
+ </div>
191
+
192
+ <div class="chart-grid">
193
+ <div class="chart-card wide">
194
+ <h2>Uploads Per Day</h2>
195
+ <div class="chart-wrap tall"><canvas id="chartPerDay"></canvas></div>
196
+ </div>
197
+ <div class="chart-card">
198
+ <h2>Top KOLs</h2>
199
+ <ol class="top-kol-list" id="topKolList"></ol>
200
+ </div>
201
+ <div class="chart-card">
202
+ <h2>Failure Reasons</h2>
203
+ <div class="chart-wrap"><canvas id="chartFails"></canvas></div>
204
+ </div>
205
+ <div class="chart-card">
206
+ <h2>Confidence Levels</h2>
207
+ <div class="chart-wrap"><canvas id="chartConf"></canvas></div>
208
+ </div>
209
+ <div class="chart-card">
210
+ <h2>Ring Model Usage</h2>
211
+ <div class="chart-wrap"><canvas id="chartModels"></canvas></div>
212
+ </div>
213
+ <div class="chart-card">
214
+ <h2>Ring Size Distribution</h2>
215
+ <div class="chart-wrap"><canvas id="chartSizes"></canvas></div>
216
+ </div>
217
+ <div class="chart-card">
218
+ <h2>Mode (single vs multi)</h2>
219
+ <div class="chart-wrap"><canvas id="chartModes"></canvas></div>
220
+ </div>
221
+ </div>
222
+ </div>
223
+
224
+ <!-- Records pane -->
225
+ <div class="pane" id="recordsPane">
226
+ <div class="toolbar">
227
+ <a href="/">Back to Demo</a>
228
+ <a id="exportCsvLink" href="#" download>Export CSV</a>
229
+ <button id="refreshBtn">Refresh</button>
230
+ <button id="prevPageBtn" disabled>&lsaquo; Prev</button>
231
+ <button id="nextPageBtn" disabled>Next &rsaquo;</button>
232
+ <span class="count" id="countLabel">Loading...</span>
233
+ </div>
234
+
235
+ <div class="scroll-wrap">
236
+ <table>
237
+ <thead>
238
+ <tr>
239
+ <th>KOL</th>
240
+ <th>Date</th>
241
+ <th>Model</th>
242
+ <th>Photo</th>
243
+ <th>Index</th>
244
+ <th>Middle</th>
245
+ <th>Ring</th>
246
+ <th>Conf</th>
247
+ <th>Fail</th>
248
+ <th></th>
249
+ </tr>
250
+ </thead>
251
+ <tbody id="tableBody">
252
+ <tr><td colspan="10" class="empty">Loading...</td></tr>
253
+ </tbody>
254
+ </table>
255
+ </div>
256
  </div>
257
  </div>
258
 
 
281
  loginGate.style.display = "none";
282
  adminContent.style.display = "block";
283
  document.getElementById("exportCsvLink").href = `/api/admin/export-csv?token=${encodeURIComponent(adminToken)}`;
284
+ loadStats();
285
  loadData();
286
  return true;
287
  };
 
399
  prevPageBtn.addEventListener("click", () => { currentPage--; renderPage(); });
400
  nextPageBtn.addEventListener("click", () => { currentPage++; renderPage(); });
401
  document.getElementById("refreshBtn").addEventListener("click", loadData);
402
+
403
+ // ------------------------------------------------------------------
404
+ // Tab switching
405
+ // ------------------------------------------------------------------
406
+ document.querySelectorAll(".tab").forEach((btn) => {
407
+ btn.addEventListener("click", () => {
408
+ document.querySelectorAll(".tab").forEach((b) => b.classList.remove("active"));
409
+ document.querySelectorAll(".pane").forEach((p) => p.classList.remove("active"));
410
+ btn.classList.add("active");
411
+ document.getElementById(btn.dataset.pane).classList.add("active");
412
+ });
413
+ });
414
+
415
+ // ------------------------------------------------------------------
416
+ // Dashboard
417
+ // ------------------------------------------------------------------
418
+ const ACCENT = "#bf3a2b";
419
+ const PALETTE = ["#bf3a2b", "#d99c4f", "#7a8b3d", "#3d6b8b", "#8a4d8e", "#4b8a82", "#a05a3c", "#5a5a3c"];
420
+ const dashStatusLabel = document.getElementById("dashStatusLabel");
421
+ const statGrid = document.getElementById("statGrid");
422
+ const topKolList = document.getElementById("topKolList");
423
+ const windowSelect = document.getElementById("windowSelect");
424
+ const charts = {};
425
+
426
+ const fmtPct = (v) => v == null ? "-" : (v * 100).toFixed(1) + "%";
427
+ const fmtInt = (v) => v == null ? "-" : v.toLocaleString();
428
+ const fmtDay = (iso) => {
429
+ if (!iso) return "—";
430
+ // YYYY-MM-DD → MM-DD for compact axis labels
431
+ return iso.slice(5);
432
+ };
433
+ const deltaArrow = (curr, prev) => {
434
+ if (!prev && !curr) return { cls: "delta-flat", txt: "no change" };
435
+ if (!prev) return { cls: "delta-up", txt: `+${curr} vs prior 7d` };
436
+ const diff = curr - prev;
437
+ if (diff === 0) return { cls: "delta-flat", txt: "flat vs prior 7d" };
438
+ const pct = Math.round((diff / prev) * 100);
439
+ if (diff > 0) return { cls: "delta-up", txt: `+${diff} (${pct >= 0 ? "+" : ""}${pct}%) vs prior 7d` };
440
+ return { cls: "delta-down", txt: `${diff} (${pct}%) vs prior 7d` };
441
+ };
442
+
443
+ const renderStatCards = (s) => {
444
+ const t = s.totals;
445
+ const last7Delta = deltaArrow(t.last_7_days, t.prev_7_days);
446
+ const cards = [
447
+ { label: "Total measurements", value: fmtInt(t.total_measurements), sub: t.first_measurement_at ? `since ${t.first_measurement_at.slice(0, 10)}` : "" },
448
+ { label: "Unique KOLs", value: fmtInt(t.unique_kols), sub: "all-time, normalized" },
449
+ { label: "Last 7 days", value: fmtInt(t.last_7_days), sub: last7Delta.txt, subCls: last7Delta.cls },
450
+ { label: "Success rate", value: fmtPct(t.success_rate), sub: `${fmtInt(t.success_count)} ok · ${fmtInt(t.fail_count)} failed` },
451
+ { label: "Avg confidence", value: fmtPct(t.avg_confidence), sub: "successful runs only" },
452
+ { label: "Ground-truth coverage", value: fmtPct(t.gt_filled_rate), sub: `${fmtInt(t.gt_filled_count)} records labeled` },
453
+ ];
454
+ statGrid.innerHTML = cards.map((c) => `
455
+ <div class="stat-card">
456
+ <div class="label">${c.label}</div>
457
+ <div class="value">${c.value}</div>
458
+ <div class="sub ${c.subCls || ""}">${c.sub || ""}</div>
459
+ </div>
460
+ `).join("");
461
+ };
462
+
463
+ const renderTopKols = (kols) => {
464
+ if (!kols.length) {
465
+ topKolList.innerHTML = '<li><span class="name">—</span><span class="count">no KOLs yet</span></li>';
466
+ return;
467
+ }
468
+ topKolList.innerHTML = kols.map((k) => {
469
+ const last = k.last_at ? new Date(k.last_at).toISOString().slice(0, 10) : "";
470
+ return `<li><span class="name">${k.kol_name}</span><span class="count">${k.count} · ${last}</span></li>`;
471
+ }).join("");
472
+ };
473
+
474
+ const upsertChart = (key, ctx, config) => {
475
+ if (charts[key]) { charts[key].destroy(); }
476
+ charts[key] = new Chart(ctx, config);
477
+ };
478
+
479
+ const renderCharts = (s) => {
480
+ // Per-day uploads — line chart with photos, unique KOLs, fails
481
+ const labels = s.per_day.map((d) => fmtDay(d.date));
482
+ upsertChart("perDay", document.getElementById("chartPerDay"), {
483
+ type: "line",
484
+ data: {
485
+ labels,
486
+ datasets: [
487
+ { label: "Photos", data: s.per_day.map((d) => d.photos), borderColor: ACCENT, backgroundColor: ACCENT + "33", tension: 0.25, fill: true },
488
+ { label: "Unique KOLs", data: s.per_day.map((d) => d.unique_kols), borderColor: "#3d6b8b", backgroundColor: "transparent", tension: 0.25 },
489
+ { label: "Failures", data: s.per_day.map((d) => d.fails), borderColor: "#a05a3c", backgroundColor: "transparent", borderDash: [4, 4], tension: 0.25 },
490
+ ],
491
+ },
492
+ options: {
493
+ responsive: true, maintainAspectRatio: false,
494
+ interaction: { mode: "index", intersect: false },
495
+ scales: { y: { beginAtZero: true, ticks: { precision: 0 } } },
496
+ plugins: { legend: { position: "bottom" } },
497
+ },
498
+ });
499
+
500
+ // Failure reasons — doughnut. Always includes a Success slice for context.
501
+ const failLabels = ["Success", ...s.fail_reasons.map((f) => f.reason)];
502
+ const failData = [s.totals.success_count, ...s.fail_reasons.map((f) => f.count)];
503
+ const failColors = ["#7a8b3d", ...s.fail_reasons.map((_, i) => PALETTE[(i + 1) % PALETTE.length])];
504
+ upsertChart("fails", document.getElementById("chartFails"), {
505
+ type: "doughnut",
506
+ data: { labels: failLabels, datasets: [{ data: failData, backgroundColor: failColors }] },
507
+ options: {
508
+ responsive: true, maintainAspectRatio: false,
509
+ plugins: { legend: { position: "right", labels: { boxWidth: 12, font: { size: 11 } } } },
510
+ },
511
+ });
512
+
513
+ // Confidence buckets — horizontal bar
514
+ upsertChart("conf", document.getElementById("chartConf"), {
515
+ type: "bar",
516
+ data: {
517
+ labels: s.confidence_buckets.map((b) => b.bucket),
518
+ datasets: [{
519
+ data: s.confidence_buckets.map((b) => b.count),
520
+ backgroundColor: ["#7a8b3d", "#d99c4f", "#bf3a2b"],
521
+ }],
522
+ },
523
+ options: {
524
+ indexAxis: "y", responsive: true, maintainAspectRatio: false,
525
+ plugins: { legend: { display: false } },
526
+ scales: { x: { beginAtZero: true, ticks: { precision: 0 } } },
527
+ },
528
+ });
529
+
530
+ // Ring model usage
531
+ upsertChart("models", document.getElementById("chartModels"), {
532
+ type: "bar",
533
+ data: {
534
+ labels: s.ring_models.map((m) => m.model),
535
+ datasets: [{ data: s.ring_models.map((m) => m.count), backgroundColor: PALETTE }],
536
+ },
537
+ options: {
538
+ responsive: true, maintainAspectRatio: false,
539
+ plugins: { legend: { display: false } },
540
+ scales: { y: { beginAtZero: true, ticks: { precision: 0 } } },
541
+ },
542
+ });
543
+
544
+ // Ring size distribution
545
+ upsertChart("sizes", document.getElementById("chartSizes"), {
546
+ type: "bar",
547
+ data: {
548
+ labels: s.size_distribution.map((d) => d.size),
549
+ datasets: [{ data: s.size_distribution.map((d) => d.count), backgroundColor: ACCENT }],
550
+ },
551
+ options: {
552
+ responsive: true, maintainAspectRatio: false,
553
+ plugins: { legend: { display: false } },
554
+ scales: { y: { beginAtZero: true, ticks: { precision: 0 } } },
555
+ },
556
+ });
557
+
558
+ // Mode split
559
+ upsertChart("modes", document.getElementById("chartModes"), {
560
+ type: "doughnut",
561
+ data: {
562
+ labels: s.modes.map((m) => m.mode),
563
+ datasets: [{ data: s.modes.map((m) => m.count), backgroundColor: PALETTE }],
564
+ },
565
+ options: {
566
+ responsive: true, maintainAspectRatio: false,
567
+ plugins: { legend: { position: "right" } },
568
+ },
569
+ });
570
+ };
571
+
572
+ const loadStats = async () => {
573
+ dashStatusLabel.textContent = "Loading...";
574
+ const days = windowSelect.value;
575
+ try {
576
+ const resp = await fetch(`/api/admin/stats?token=${encodeURIComponent(adminToken)}&days=${days}`);
577
+ if (resp.status === 401) {
578
+ sessionStorage.removeItem("admin_token");
579
+ loginGate.style.display = "";
580
+ adminContent.style.display = "none";
581
+ loginError.textContent = "Session expired. Please log in again.";
582
+ return;
583
+ }
584
+ const stats = await resp.json();
585
+ renderStatCards(stats);
586
+ renderTopKols(stats.top_kols);
587
+ renderCharts(stats);
588
+ const updated = new Date().toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", second: "2-digit" });
589
+ dashStatusLabel.textContent = `Updated ${updated}`;
590
+ } catch (e) {
591
+ dashStatusLabel.textContent = `Error: ${e.message}`;
592
+ }
593
+ };
594
+
595
+ document.getElementById("dashRefreshBtn").addEventListener("click", loadStats);
596
+ windowSelect.addEventListener("change", loadStats);
597
  </script>
598
  </body>
599
  </html>