coderuday21 commited on
Commit
0bf1136
·
1 Parent(s): f29f921

Full codebase audit: fix critical perf bug, security, error handling, dead code

Browse files
app/auth.py CHANGED
@@ -14,7 +14,13 @@ from .models import User
14
 
15
  logger = logging.getLogger(__name__)
16
 
17
- SECRET_KEY = os.environ.get("SECRET_KEY", "dev-fallback-key-change-in-production")
 
 
 
 
 
 
18
  ALGORITHM = "HS256"
19
  ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days
20
  COOKIE_NAME = "satellite_token"
 
14
 
15
  logger = logging.getLogger(__name__)
16
 
17
+ _FALLBACK_KEY = "dev-fallback-key-change-in-production"
18
+ SECRET_KEY = os.environ.get("SECRET_KEY", _FALLBACK_KEY)
19
+ if SECRET_KEY == _FALLBACK_KEY:
20
+ logger.warning(
21
+ "SECRET_KEY env var not set — using insecure fallback. "
22
+ "Set SECRET_KEY to a random string in production!"
23
+ )
24
  ALGORITHM = "HS256"
25
  ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days
26
  COOKIE_NAME = "satellite_token"
app/detection_engine.py CHANGED
@@ -3,7 +3,6 @@ Satellite Change Detection Engine v2
3
  High-accuracy detection with multi-channel analysis, SSIM, texture features,
4
  adaptive thresholding, and improved object classification.
5
  """
6
- import io
7
  import numpy as np
8
  import cv2
9
  from PIL import Image
@@ -409,9 +408,9 @@ def _clean_mask(mask, sensitivity=0.5, border_margin=12):
409
  5. Fill holes inside regions
410
  6. Erode-then-dilate to break thin noise bridges between separate changes
411
  """
 
412
  h, w = mask.shape[:2]
413
 
414
- # 1. Remove false positives along image border (common with registration)
415
  if border_margin > 0:
416
  mask[:border_margin, :] = 0
417
  mask[-border_margin:, :] = 0
@@ -456,18 +455,18 @@ def _severity_from_region(region, total_pixels):
456
  """
457
  Classify change severity from area and confidence.
458
  Green = minor, Yellow = moderate, Red = major.
459
- Used for color-coded bounding boxes and table summary.
460
  """
461
  area = region.get("area", 0)
462
  confidence = region.get("confidence", 0.0)
463
  if total_pixels <= 0:
464
  return "minor"
465
  area_ratio = area / total_pixels
466
- # Combined score: larger area and higher confidence -> more severe
467
- score = area_ratio * 500 + confidence * 2
468
- if score < 0.5:
469
  return "minor"
470
- if score < 1.2:
471
  return "moderate"
472
  return "major"
473
 
@@ -506,7 +505,6 @@ def visualize_changes(img1, img2, change_mask, regions=None, total_pixels=None):
506
  total_px = total_pixels if total_pixels is not None else (img2.shape[0] * img2.shape[1])
507
 
508
  if regions:
509
- # Scale line thickness to image size so boxes are visible at any resolution
510
  diag = np.sqrt(img2.shape[0]**2 + img2.shape[1]**2)
511
  line_thickness = max(2, int(diag / 400))
512
 
@@ -515,15 +513,17 @@ def visualize_changes(img1, img2, change_mask, regions=None, total_pixels=None):
515
  severity = r.get("severity") or _severity_from_region(r, total_px)
516
  color = _SEVERITY_COLORS.get(severity, (255, 255, 255))
517
 
518
- # Semi-transparent filled rect behind the box for contrast
519
- box_overlay = overlay_uint8.copy()
520
- cv2.rectangle(box_overlay, (x, y), (x + w, y + h), color, cv2.FILLED)
521
- cv2.addWeighted(box_overlay, 0.12, overlay_uint8, 0.88, 0, overlay_uint8)
 
 
 
 
522
 
523
- # Color-coded bounding box
524
  cv2.rectangle(overlay_uint8, (x, y), (x + w, y + h), color, line_thickness)
525
 
526
- # Numbered label
527
  rid = r.get("id", 0)
528
  label = str(rid)
529
  font = cv2.FONT_HERSHEY_SIMPLEX
@@ -629,17 +629,6 @@ def _is_transient_object(area, w, h, features):
629
  return False
630
 
631
 
632
- # Ground-level change categories only
633
- GROUND_CHANGE_TYPES = [
634
- "New Construction/Building",
635
- "Demolition/Clearing",
636
- "Vegetation Change",
637
- "Water Body Change",
638
- "Road/Pavement Change",
639
- "Bare Land/Soil Change",
640
- ]
641
-
642
-
643
  def classify_object_type(image_region, bbox):
644
  """
645
  Classify GROUND-LEVEL structural changes only.
@@ -800,7 +789,7 @@ def classify_object_type(image_region, bbox):
800
  return best, min(conf, 1.0)
801
 
802
 
803
- def classify_with_ensemble(image_region, bbox, num_sub=4):
804
  """Ensemble: classify full region + sub-regions, vote with confidence weighting."""
805
  x, y, w, h = bbox
806
  sub_boxes = [(x, y, w, h)] # full region
 
3
  High-accuracy detection with multi-channel analysis, SSIM, texture features,
4
  adaptive thresholding, and improved object classification.
5
  """
 
6
  import numpy as np
7
  import cv2
8
  from PIL import Image
 
408
  5. Fill holes inside regions
409
  6. Erode-then-dilate to break thin noise bridges between separate changes
410
  """
411
+ mask = mask.copy()
412
  h, w = mask.shape[:2]
413
 
 
414
  if border_margin > 0:
415
  mask[:border_margin, :] = 0
416
  mask[-border_margin:, :] = 0
 
455
  """
456
  Classify change severity from area and confidence.
457
  Green = minor, Yellow = moderate, Red = major.
458
+ Area is the primary signal; confidence acts as a small bonus.
459
  """
460
  area = region.get("area", 0)
461
  confidence = region.get("confidence", 0.0)
462
  if total_pixels <= 0:
463
  return "minor"
464
  area_ratio = area / total_pixels
465
+ # Area-dominant score: area ratio (0-1) mapped to 0-10, confidence adds 0-0.3
466
+ score = area_ratio * 1000 + confidence * 0.3
467
+ if score < 1.0:
468
  return "minor"
469
+ if score < 4.0:
470
  return "moderate"
471
  return "major"
472
 
 
505
  total_px = total_pixels if total_pixels is not None else (img2.shape[0] * img2.shape[1])
506
 
507
  if regions:
 
508
  diag = np.sqrt(img2.shape[0]**2 + img2.shape[1]**2)
509
  line_thickness = max(2, int(diag / 400))
510
 
 
513
  severity = r.get("severity") or _severity_from_region(r, total_px)
514
  color = _SEVERITY_COLORS.get(severity, (255, 255, 255))
515
 
516
+ # Semi-transparent fill using only the ROI (avoids full-image copy)
517
+ x1c = max(0, x)
518
+ y1c = max(0, y)
519
+ x2c = min(overlay_uint8.shape[1], x + w)
520
+ y2c = min(overlay_uint8.shape[0], y + h)
521
+ roi = overlay_uint8[y1c:y2c, x1c:x2c]
522
+ fill = np.full_like(roi, color, dtype=np.uint8)
523
+ cv2.addWeighted(fill, 0.12, roi, 0.88, 0, roi)
524
 
 
525
  cv2.rectangle(overlay_uint8, (x, y), (x + w, y + h), color, line_thickness)
526
 
 
527
  rid = r.get("id", 0)
528
  label = str(rid)
529
  font = cv2.FONT_HERSHEY_SIMPLEX
 
629
  return False
630
 
631
 
 
 
 
 
 
 
 
 
 
 
 
632
  def classify_object_type(image_region, bbox):
633
  """
634
  Classify GROUND-LEVEL structural changes only.
 
789
  return best, min(conf, 1.0)
790
 
791
 
792
+ def classify_with_ensemble(image_region, bbox):
793
  """Ensemble: classify full region + sub-regions, vote with confidence weighting."""
794
  x, y, w, h = bbox
795
  sub_boxes = [(x, y, w, h)] # full region
app/main.py CHANGED
@@ -25,9 +25,11 @@ from .auth import (
25
  )
26
  from .database import Base, engine, get_db, DATA_DIR
27
  from .models import User, DetectionRun
28
- # detection_engine (cv2, sklearn) imported lazily in /api/detect to speed up startup on HF Spaces
29
  from .notifier import send_notification
30
 
 
 
 
31
  # Create tables and run migrations without crashing the app (HF Spaces can restart if startup fails)
32
  try:
33
  Base.metadata.create_all(bind=engine, checkfirst=True)
@@ -124,8 +126,8 @@ def register(data: UserCreate, db: Session = Depends(get_db)):
124
  except HTTPException:
125
  raise
126
  except Exception as e:
127
- print(f"[REGISTER] Error: {type(e).__name__}: {e}")
128
- raise HTTPException(status_code=500, detail=f"Registration failed: {type(e).__name__}: {e}")
129
 
130
 
131
  @app.post("/api/auth/login")
@@ -139,8 +141,8 @@ def login(data: UserLogin, db: Session = Depends(get_db)):
139
  except HTTPException:
140
  raise
141
  except Exception as e:
142
- print(f"[LOGIN] Error: {type(e).__name__}: {e}")
143
- raise HTTPException(status_code=500, detail=f"Login failed: {type(e).__name__}: {e}")
144
 
145
 
146
  @app.post("/api/auth/logout")
@@ -234,25 +236,34 @@ async def detect(
234
  overlay_path.parent.mkdir(parents=True, exist_ok=True)
235
  except Exception:
236
  pass
237
- Image.fromarray(result_image).save(overlay_path)
 
 
 
 
238
  relative_overlay = f"overlays/{overlay_filename}"
239
 
240
  # Save full-resolution before image (used by the before/after slider from history)
241
- before_full_file = OVERLAYS_DIR / f"{base_name}_before.png"
242
- before_pil.save(before_full_file)
243
- relative_before_full = f"overlays/{base_name}_before.png"
244
-
245
- # Save small thumbnails for the history table rows
246
- before_thumb_file = OVERLAYS_DIR / f"{base_name}_before_thumb.png"
247
- after_thumb_file = OVERLAYS_DIR / f"{base_name}_after_thumb.png"
248
- before_thumb_pil = before_pil.copy()
249
- before_thumb_pil.thumbnail((THUMB_MAX_SIZE, THUMB_MAX_SIZE), Image.Resampling.LANCZOS)
250
- before_thumb_pil.save(before_thumb_file)
251
- after_thumb_pil = after_pil.copy()
252
- after_thumb_pil.thumbnail((THUMB_MAX_SIZE, THUMB_MAX_SIZE), Image.Resampling.LANCZOS)
253
- after_thumb_pil.save(after_thumb_file)
254
- relative_before_thumb = f"overlays/{base_name}_before_thumb.png"
255
- relative_after_thumb = f"overlays/{base_name}_after_thumb.png"
 
 
 
 
 
256
 
257
  regions_serializable = [
258
  {
@@ -294,11 +305,8 @@ async def detect(
294
  db.add(run)
295
  db.commit()
296
  db.refresh(run)
297
- # Base64 overlay for immediate display
298
- buf = io.BytesIO()
299
- Image.fromarray(result_image).save(buf, format="PNG")
300
- buf.seek(0)
301
- overlay_b64 = base64.b64encode(buf.read()).decode("utf-8")
302
 
303
  # Send email notification if requested
304
  notification_sent = False
@@ -393,7 +401,10 @@ def get_run(
393
  run = db.query(DetectionRun).filter(DetectionRun.id == run_id, DetectionRun.user_id == user.id).first()
394
  if not run:
395
  raise HTTPException(status_code=404, detail="Run not found")
396
- regions = json.loads(run.regions_json) if run.regions_json else []
 
 
 
397
  return {
398
  "id": run.id,
399
  "title": run.title,
 
25
  )
26
  from .database import Base, engine, get_db, DATA_DIR
27
  from .models import User, DetectionRun
 
28
  from .notifier import send_notification
29
 
30
+ import logging
31
+ logger = logging.getLogger(__name__)
32
+
33
  # Create tables and run migrations without crashing the app (HF Spaces can restart if startup fails)
34
  try:
35
  Base.metadata.create_all(bind=engine, checkfirst=True)
 
126
  except HTTPException:
127
  raise
128
  except Exception as e:
129
+ logger.exception("Registration failed")
130
+ raise HTTPException(status_code=500, detail=f"Registration failed: {type(e).__name__}")
131
 
132
 
133
  @app.post("/api/auth/login")
 
141
  except HTTPException:
142
  raise
143
  except Exception as e:
144
+ logger.exception("Login failed")
145
+ raise HTTPException(status_code=500, detail=f"Login failed: {type(e).__name__}")
146
 
147
 
148
  @app.post("/api/auth/logout")
 
236
  overlay_path.parent.mkdir(parents=True, exist_ok=True)
237
  except Exception:
238
  pass
239
+ try:
240
+ Image.fromarray(result_image).save(overlay_path)
241
+ except Exception as exc:
242
+ logger.error("Failed to save overlay: %s", exc)
243
+ raise HTTPException(status_code=500, detail="Could not save result image")
244
  relative_overlay = f"overlays/{overlay_filename}"
245
 
246
  # Save full-resolution before image (used by the before/after slider from history)
247
+ relative_before_full = ""
248
+ relative_before_thumb = ""
249
+ relative_after_thumb = ""
250
+ try:
251
+ before_full_file = OVERLAYS_DIR / f"{base_name}_before.png"
252
+ before_pil.save(before_full_file)
253
+ relative_before_full = f"overlays/{base_name}_before.png"
254
+
255
+ before_thumb_file = OVERLAYS_DIR / f"{base_name}_before_thumb.png"
256
+ after_thumb_file = OVERLAYS_DIR / f"{base_name}_after_thumb.png"
257
+ before_thumb_pil = before_pil.copy()
258
+ before_thumb_pil.thumbnail((THUMB_MAX_SIZE, THUMB_MAX_SIZE), Image.Resampling.LANCZOS)
259
+ before_thumb_pil.save(before_thumb_file)
260
+ after_thumb_pil = after_pil.copy()
261
+ after_thumb_pil.thumbnail((THUMB_MAX_SIZE, THUMB_MAX_SIZE), Image.Resampling.LANCZOS)
262
+ after_thumb_pil.save(after_thumb_file)
263
+ relative_before_thumb = f"overlays/{base_name}_before_thumb.png"
264
+ relative_after_thumb = f"overlays/{base_name}_after_thumb.png"
265
+ except Exception as exc:
266
+ logger.warning("Failed to save thumbnails: %s", exc)
267
 
268
  regions_serializable = [
269
  {
 
305
  db.add(run)
306
  db.commit()
307
  db.refresh(run)
308
+ # Read already-saved overlay for base64 (avoids re-encoding the numpy array)
309
+ overlay_b64 = base64.b64encode(overlay_path.read_bytes()).decode("utf-8")
 
 
 
310
 
311
  # Send email notification if requested
312
  notification_sent = False
 
401
  run = db.query(DetectionRun).filter(DetectionRun.id == run_id, DetectionRun.user_id == user.id).first()
402
  if not run:
403
  raise HTTPException(status_code=404, detail="Run not found")
404
+ try:
405
+ regions = json.loads(run.regions_json) if run.regions_json else []
406
+ except (json.JSONDecodeError, TypeError):
407
+ regions = []
408
  return {
409
  "id": run.id,
410
  "title": run.title,
app/models.py CHANGED
@@ -1,10 +1,14 @@
1
- from datetime import datetime
2
  from sqlalchemy import Column, Integer, String, DateTime, Text, ForeignKey, Float
3
  from sqlalchemy.orm import relationship
4
 
5
  from .database import Base
6
 
7
 
 
 
 
 
8
  class User(Base):
9
  __tablename__ = "users"
10
 
@@ -12,7 +16,7 @@ class User(Base):
12
  email = Column(String(255), unique=True, index=True, nullable=False)
13
  hashed_password = Column(String(255), nullable=False)
14
  full_name = Column(String(255), default="")
15
- created_at = Column(DateTime, default=datetime.utcnow)
16
 
17
  detections = relationship("DetectionRun", back_populates="user", order_by="desc(DetectionRun.created_at)")
18
 
@@ -34,7 +38,7 @@ class DetectionRun(Base):
34
  after_thumb_path = Column(String(512), default="")
35
  zone = Column(String(128), default="")
36
  village = Column(String(128), default="")
37
- regions_json = Column(Text, default="[]") # JSON list of regions
38
- created_at = Column(DateTime, default=datetime.utcnow)
39
 
40
  user = relationship("User", back_populates="detections")
 
1
+ from datetime import datetime, timezone
2
  from sqlalchemy import Column, Integer, String, DateTime, Text, ForeignKey, Float
3
  from sqlalchemy.orm import relationship
4
 
5
  from .database import Base
6
 
7
 
8
+ def _utcnow():
9
+ return datetime.now(timezone.utc)
10
+
11
+
12
  class User(Base):
13
  __tablename__ = "users"
14
 
 
16
  email = Column(String(255), unique=True, index=True, nullable=False)
17
  hashed_password = Column(String(255), nullable=False)
18
  full_name = Column(String(255), default="")
19
+ created_at = Column(DateTime, default=_utcnow)
20
 
21
  detections = relationship("DetectionRun", back_populates="user", order_by="desc(DetectionRun.created_at)")
22
 
 
38
  after_thumb_path = Column(String(512), default="")
39
  zone = Column(String(128), default="")
40
  village = Column(String(128), default="")
41
+ regions_json = Column(Text, default="[]")
42
+ created_at = Column(DateTime, default=_utcnow)
43
 
44
  user = relationship("User", back_populates="detections")
app/notifier.py CHANGED
@@ -5,6 +5,7 @@ Credentials are read from environment variables — never hardcoded.
5
  """
6
  import logging
7
  import os
 
8
  import smtplib
9
  import ssl
10
  from datetime import datetime, timezone
@@ -16,7 +17,7 @@ logger = logging.getLogger(__name__)
16
 
17
  SMTP_HOST = os.environ.get("SMTP_HOST", "smtp.gmail.com")
18
  SMTP_PORT = int(os.environ.get("SMTP_PORT", "465"))
19
- SMTP_USER = os.environ.get("SMTP_USER", "vedangofficeserver@gmail.com")
20
  SMTP_PASS = os.environ.get("SMTP_PASS", "")
21
 
22
  TEMPLATE_PATH = Path(__file__).resolve().parent.parent / "templates" / "ChangeDetection.html"
@@ -86,7 +87,6 @@ def build_email_body(
86
  if regions:
87
  html = html.replace("{{#regions}}", "").replace("{{/regions}}", "")
88
  else:
89
- import re
90
  html = re.sub(r"\{\{#regions\}\}.*?\{\{/regions\}\}", "", html, flags=re.DOTALL)
91
 
92
  return html
@@ -107,8 +107,8 @@ def send_notification(
107
  Send detection report email to the recipient.
108
  Returns True on success, False on failure (never raises).
109
  """
110
- if not SMTP_PASS:
111
- logger.warning("SMTP_PASS not set — skipping email notification")
112
  return False
113
 
114
  html_body = build_email_body(
 
5
  """
6
  import logging
7
  import os
8
+ import re
9
  import smtplib
10
  import ssl
11
  from datetime import datetime, timezone
 
17
 
18
  SMTP_HOST = os.environ.get("SMTP_HOST", "smtp.gmail.com")
19
  SMTP_PORT = int(os.environ.get("SMTP_PORT", "465"))
20
+ SMTP_USER = os.environ.get("SMTP_USER", "")
21
  SMTP_PASS = os.environ.get("SMTP_PASS", "")
22
 
23
  TEMPLATE_PATH = Path(__file__).resolve().parent.parent / "templates" / "ChangeDetection.html"
 
87
  if regions:
88
  html = html.replace("{{#regions}}", "").replace("{{/regions}}", "")
89
  else:
 
90
  html = re.sub(r"\{\{#regions\}\}.*?\{\{/regions\}\}", "", html, flags=re.DOTALL)
91
 
92
  return html
 
107
  Send detection report email to the recipient.
108
  Returns True on success, False on failure (never raises).
109
  """
110
+ if not SMTP_PASS or not SMTP_USER:
111
+ logger.warning("SMTP_USER or SMTP_PASS not set — skipping email notification")
112
  return False
113
 
114
  html_body = build_email_body(
requirements.txt CHANGED
@@ -1,9 +1,7 @@
1
  fastapi>=0.104.0
2
  uvicorn[standard]>=0.24.0
3
- gunicorn>=21.2.0
4
  python-multipart>=0.0.6
5
  sqlalchemy>=2.0.0
6
- psycopg2-binary>=2.9.9
7
  python-jose[cryptography]>=3.3.0
8
  passlib[bcrypt]>=1.7.4
9
  bcrypt==4.0.1
@@ -11,4 +9,3 @@ pillow>=10.0.0
11
  numpy>=1.24.0
12
  opencv-python-headless>=4.8.0
13
  scikit-learn>=1.3.0
14
- scipy>=1.11.0
 
1
  fastapi>=0.104.0
2
  uvicorn[standard]>=0.24.0
 
3
  python-multipart>=0.0.6
4
  sqlalchemy>=2.0.0
 
5
  python-jose[cryptography]>=3.3.0
6
  passlib[bcrypt]>=1.7.4
7
  bcrypt==4.0.1
 
9
  numpy>=1.24.0
10
  opencv-python-headless>=4.8.0
11
  scikit-learn>=1.3.0
 
static/js/app.js CHANGED
@@ -358,9 +358,10 @@ document.getElementById('form-detect')?.addEventListener('submit', async (e) =>
358
 
359
  // ---- Show result ----
360
  function readFileAsDataURL(file) {
361
- return new Promise((resolve) => {
362
  const reader = new FileReader();
363
  reader.onload = () => resolve(reader.result);
 
364
  reader.readAsDataURL(file);
365
  });
366
  }
@@ -386,11 +387,15 @@ function showResult(data) {
386
 
387
  const locParts = [data.village, data.zone].filter(Boolean);
388
  const locLabel = locParts.length ? locParts.join(', ') : '—';
 
 
 
 
389
 
390
  statsEl.innerHTML = `
391
- <div class="stat-box"><div class="value">${data.statistics.changePercentage.toFixed(2)}%</div><div class="label">Changed</div></div>
392
- <div class="stat-box"><div class="value" title="${data.statistics.changedPixels.toLocaleString()}">${formatCompact(data.statistics.changedPixels)}</div><div class="label">Changed px</div></div>
393
- <div class="stat-box"><div class="value" title="${data.statistics.totalPixels.toLocaleString()}">${formatCompact(data.statistics.totalPixels)}</div><div class="label">Total px</div></div>
394
  <div class="stat-box"><div class="value">${(data.regions || []).length}</div><div class="label">Regions</div></div>
395
  <div class="stat-box stat-box-wide"><div class="value value-sm" title="${locLabel}">${locLabel}</div><div class="label">Location</div></div>
396
  `;
@@ -620,7 +625,7 @@ async function loadHistory() {
620
  <td class="thumb-cell">${afterThumb}</td>
621
  <td class="thumb-cell">${resultThumb}</td>
622
  <td class="stats-cell">${r.regionsCount} regions</td>
623
- <td class="stats-cell">${r.changePercentage.toFixed(2)}%</td>
624
  <td class="actions-cell">
625
  <button type="button" class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); openRunFromHistory(${r.id})">View</button>
626
  <button type="button" class="btn-icon" title="Delete" onclick="event.stopPropagation(); confirmDelete(${r.id})">
 
358
 
359
  // ---- Show result ----
360
  function readFileAsDataURL(file) {
361
+ return new Promise((resolve, reject) => {
362
  const reader = new FileReader();
363
  reader.onload = () => resolve(reader.result);
364
+ reader.onerror = () => reject(reader.error);
365
  reader.readAsDataURL(file);
366
  });
367
  }
 
387
 
388
  const locParts = [data.village, data.zone].filter(Boolean);
389
  const locLabel = locParts.length ? locParts.join(', ') : '—';
390
+ const stats = data.statistics || {};
391
+ const pct = (stats.changePercentage ?? 0).toFixed(2);
392
+ const chPx = stats.changedPixels ?? 0;
393
+ const totPx = stats.totalPixels ?? 0;
394
 
395
  statsEl.innerHTML = `
396
+ <div class="stat-box"><div class="value">${pct}%</div><div class="label">Changed</div></div>
397
+ <div class="stat-box"><div class="value" title="${chPx.toLocaleString()}">${formatCompact(chPx)}</div><div class="label">Changed px</div></div>
398
+ <div class="stat-box"><div class="value" title="${totPx.toLocaleString()}">${formatCompact(totPx)}</div><div class="label">Total px</div></div>
399
  <div class="stat-box"><div class="value">${(data.regions || []).length}</div><div class="label">Regions</div></div>
400
  <div class="stat-box stat-box-wide"><div class="value value-sm" title="${locLabel}">${locLabel}</div><div class="label">Location</div></div>
401
  `;
 
625
  <td class="thumb-cell">${afterThumb}</td>
626
  <td class="thumb-cell">${resultThumb}</td>
627
  <td class="stats-cell">${r.regionsCount} regions</td>
628
+ <td class="stats-cell">${(r.changePercentage ?? 0).toFixed(2)}%</td>
629
  <td class="actions-cell">
630
  <button type="button" class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); openRunFromHistory(${r.id})">View</button>
631
  <button type="button" class="btn-icon" title="Delete" onclick="event.stopPropagation(); confirmDelete(${r.id})">
templates/index.html CHANGED
@@ -345,6 +345,6 @@
345
  </div>
346
  </div>
347
 
348
- <script src="/static/js/app.js?v=19"></script>
349
  </body>
350
  </html>
 
345
  </div>
346
  </div>
347
 
348
+ <script src="/static/js/app.js?v=20"></script>
349
  </body>
350
  </html>