feng-x commited on
Commit
e88c8ff
Β·
verified Β·
1 Parent(s): c9dcf1c

Upload folder using huggingface_hub

Browse files
script/storage_recompress_oneshot.py ADDED
@@ -0,0 +1,298 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """One-shot Supabase storage recompress (v7 "hot cache, low-res").
3
+
4
+ Context: the `ring-measurements` bucket crossed the 1 GB free-tier quota.
5
+ The measurement pipeline never sees past ~1024px (SAM downscales internally),
6
+ so storing full-res user uploads buys nothing for re-measurement. This script
7
+ shrinks the existing objects in place:
8
+
9
+ - photos/ -> 2048px long-edge JPEG q80 (validated measurement-safe)
10
+ - results/ -> 1024px long-edge JPEG q80 (overlay, never re-measured)
11
+
12
+ Safety model (free tier has NO rollback):
13
+ 1. `backup` downloads every photos/ object to a local dir FIRST and leaves
14
+ it pristine. That is the ultimate fallback for photos.
15
+ 2. Recompress encodes in memory, RE-DECODES to verify the bytes are a valid
16
+ image, and only then overwrites. Never writes garbage over a good object.
17
+ 3. Photos are recompressed only if a verified local backup exists.
18
+ 4. skip-if-not-smaller: never write a candidate that isn't meaningfully
19
+ smaller than what's already there (avoids re-encode bloat).
20
+
21
+ Subcommands:
22
+ inventory read-only: count + bytes for photos/ + results/
23
+ backup download all photos/ -> BACKUP_DIR (idempotent)
24
+ test-mechanism upload+overwrite+verify+delete a throwaway object
25
+ recompress --prefix photos --max-side 2048 [--apply]
26
+ recompress --prefix results --max-side 1024 [--apply]
27
+
28
+ Without --apply, recompress is a DRY RUN (projects savings, writes nothing).
29
+
30
+ Requires SUPABASE_URL / SUPABASE_SERVICE_KEY in the environment.
31
+ """
32
+ from __future__ import annotations
33
+
34
+ import argparse
35
+ import sys
36
+ from pathlib import Path
37
+ from typing import Dict, List, Optional, Tuple
38
+
39
+ import cv2
40
+ import numpy as np
41
+
42
+ ROOT = Path(__file__).resolve().parents[1]
43
+ sys.path.insert(0, str(ROOT))
44
+
45
+ from web_demo.supabase_client import _get_client, BUCKET # noqa: E402
46
+
47
+ BACKUP_DIR = ROOT / "input" / "supabase_archive"
48
+ PAGE_LIMIT = 100
49
+ JPEG_QUALITY = 80
50
+ MIN_GAIN = 0.95 # only write if candidate <= 95% of original size
51
+
52
+
53
+ # --------------------------------------------------------------------------- #
54
+ # storage helpers
55
+ # --------------------------------------------------------------------------- #
56
+ def _storage():
57
+ client = _get_client()
58
+ if client is None:
59
+ print("ERROR: Supabase client not initialized "
60
+ "(check SUPABASE_URL / SUPABASE_SERVICE_KEY).")
61
+ sys.exit(1)
62
+ return client.storage.from_(BUCKET)
63
+
64
+
65
+ def _obj_size(obj: dict) -> Optional[int]:
66
+ md = obj.get("metadata") or {}
67
+ return md.get("size") if md.get("size") is not None else obj.get("size")
68
+
69
+
70
+ def list_all(storage, folder: str) -> List[dict]:
71
+ out: List[dict] = []
72
+ offset = 0
73
+ while True:
74
+ resp = storage.list(folder, {"limit": PAGE_LIMIT, "offset": offset})
75
+ if not resp:
76
+ break
77
+ # storage may return a placeholder row for the folder itself; drop dirs
78
+ out.extend([o for o in resp if o.get("name") and _obj_size(o) is not None])
79
+ if len(resp) < PAGE_LIMIT:
80
+ break
81
+ offset += PAGE_LIMIT
82
+ return out
83
+
84
+
85
+ def _human(n: float) -> str:
86
+ for unit in ("B", "KB", "MB", "GB"):
87
+ if abs(n) < 1024:
88
+ return f"{n:.1f} {unit}"
89
+ n /= 1024
90
+ return f"{n:.1f} TB"
91
+
92
+
93
+ def _overwrite(storage, path: str, data: bytes, content_type: str) -> None:
94
+ """Overwrite an existing object in place. Tries update(), falls back to
95
+ upload() with x-upsert."""
96
+ opts = {"content-type": content_type, "cache-control": "3600"}
97
+ try:
98
+ storage.update(path, data, file_options=opts)
99
+ except Exception:
100
+ storage.upload(path, data,
101
+ file_options={**opts, "upsert": "true"})
102
+
103
+
104
+ # --------------------------------------------------------------------------- #
105
+ # encode
106
+ # --------------------------------------------------------------------------- #
107
+ def recompress_bytes(raw: bytes, max_side: int) -> Optional[bytes]:
108
+ """Decode -> (optionally) downscale to max_side -> JPEG q80 -> re-decode
109
+ verify. Returns new bytes, or None if decode/verify failed."""
110
+ arr = np.frombuffer(raw, dtype=np.uint8)
111
+ img = cv2.imdecode(arr, cv2.IMREAD_COLOR) # drops alpha; BGR
112
+ if img is None:
113
+ return None
114
+ h, w = img.shape[:2]
115
+ long_side = max(h, w)
116
+ if long_side > max_side:
117
+ scale = max_side / long_side
118
+ img = cv2.resize(img, (int(round(w * scale)), int(round(h * scale))),
119
+ interpolation=cv2.INTER_AREA)
120
+ ok, enc = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, JPEG_QUALITY])
121
+ if not ok:
122
+ return None
123
+ out = enc.tobytes()
124
+ # verify: the new bytes must decode back to a valid image
125
+ if cv2.imdecode(np.frombuffer(out, np.uint8), cv2.IMREAD_COLOR) is None:
126
+ return None
127
+ return out
128
+
129
+
130
+ # --------------------------------------------------------------------------- #
131
+ # subcommands
132
+ # --------------------------------------------------------------------------- #
133
+ def cmd_inventory(_args) -> int:
134
+ storage = _storage()
135
+ grand = 0
136
+ for folder in ("photos", "results", "feedback"):
137
+ objs = list_all(storage, folder)
138
+ total = sum(_obj_size(o) or 0 for o in objs)
139
+ grand += total
140
+ print(f"{folder}/: {len(objs)} objects, {_human(total)}")
141
+ print(f"TOTAL (3 prefixes): {_human(grand)}")
142
+ return 0
143
+
144
+
145
+ def cmd_backup(_args) -> int:
146
+ storage = _storage()
147
+ dest = BACKUP_DIR / "photos"
148
+ dest.mkdir(parents=True, exist_ok=True)
149
+ objs = list_all(storage, "photos")
150
+ print(f"photos/: {len(objs)} objects to back up -> {dest}")
151
+ done = skipped = failed = 0
152
+ for i, o in enumerate(objs, 1):
153
+ name = o["name"]
154
+ size = _obj_size(o) or 0
155
+ local = dest / name
156
+ if local.exists() and local.stat().st_size == size:
157
+ skipped += 1
158
+ continue
159
+ try:
160
+ data = storage.download(f"photos/{name}")
161
+ local.write_bytes(data)
162
+ if local.stat().st_size != size:
163
+ print(f" [{i}] SIZE MISMATCH {name}: "
164
+ f"got {len(data)} expected {size}")
165
+ failed += 1
166
+ continue
167
+ done += 1
168
+ if done % 25 == 0:
169
+ print(f" ...{done} downloaded")
170
+ except Exception as e:
171
+ print(f" [{i}] FAILED {name}: {e}")
172
+ failed += 1
173
+ print(f"backup done: {done} new, {skipped} already present, {failed} failed")
174
+ return 1 if failed else 0
175
+
176
+
177
+ def cmd_test_mechanism(_args) -> int:
178
+ storage = _storage()
179
+ path = "photos/__recompress_selftest__.jpg"
180
+ a = cv2.imencode(".jpg", np.full((64, 64, 3), 30, np.uint8))[1].tobytes()
181
+ b = cv2.imencode(".jpg", np.full((64, 64, 3), 200, np.uint8))[1].tobytes()
182
+ try:
183
+ try:
184
+ storage.remove([path])
185
+ except Exception:
186
+ pass
187
+ storage.upload(path, a, file_options={"content-type": "image/jpeg"})
188
+ _overwrite(storage, path, b, "image/jpeg")
189
+ back = storage.download(path)
190
+ ok = (back == b)
191
+ print(f"overwrite-in-place {'OK' if ok else 'FAILED'} "
192
+ f"(round-tripped {len(back)} bytes, expected {len(b)})")
193
+ return 0 if ok else 1
194
+ finally:
195
+ try:
196
+ storage.remove([path])
197
+ except Exception:
198
+ pass
199
+
200
+
201
+ def cmd_recompress(args) -> int:
202
+ storage = _storage()
203
+ prefix = args.prefix
204
+ max_side = args.max_side
205
+ apply = args.apply
206
+ objs = list_all(storage, prefix)
207
+ print(f"{prefix}/: {len(objs)} objects, target {max_side}px/q{JPEG_QUALITY}, "
208
+ f"{'APPLY' if apply else 'DRY RUN'}")
209
+
210
+ backup_photos = BACKUP_DIR / "photos"
211
+ before = after = 0
212
+ written = skipped_small = skipped_nobackup = failed = 0
213
+
214
+ for i, o in enumerate(objs, 1):
215
+ name = o["name"]
216
+ if name.startswith("__recompress_selftest__"):
217
+ continue
218
+ size = _obj_size(o) or 0
219
+ before += size
220
+ path = f"{prefix}/{name}"
221
+
222
+ # photos: require a verified local backup before destructive write
223
+ if prefix == "photos":
224
+ b = backup_photos / name
225
+ if not (b.exists() and b.stat().st_size == size):
226
+ skipped_nobackup += 1
227
+ after += size
228
+ continue
229
+
230
+ try:
231
+ raw = storage.download(path)
232
+ except Exception as e:
233
+ print(f" [{i}] download FAILED {name}: {e}")
234
+ failed += 1
235
+ after += size
236
+ continue
237
+
238
+ new = recompress_bytes(raw, max_side)
239
+ if new is None:
240
+ print(f" [{i}] decode/verify FAILED {name} β€” left untouched")
241
+ failed += 1
242
+ after += size
243
+ continue
244
+
245
+ if len(new) >= size * MIN_GAIN:
246
+ skipped_small += 1
247
+ after += size
248
+ continue
249
+
250
+ after += len(new)
251
+ if apply:
252
+ try:
253
+ _overwrite(storage, path, new, "image/jpeg")
254
+ written += 1
255
+ if written % 25 == 0:
256
+ print(f" ...{written} rewritten")
257
+ except Exception as e:
258
+ print(f" [{i}] upload FAILED {name}: {e}")
259
+ failed += 1
260
+ after += size - len(new) # revert projection
261
+ else:
262
+ written += 1 # would-write count in dry run
263
+
264
+ verb = "rewrote" if apply else "would rewrite"
265
+ print(f"\n{prefix}/ summary:")
266
+ print(f" {verb}: {written}")
267
+ print(f" skipped (not smaller): {skipped_small}")
268
+ if prefix == "photos":
269
+ print(f" skipped (no backup): {skipped_nobackup}")
270
+ print(f" failed: {failed}")
271
+ print(f" size: {_human(before)} -> {_human(after)} "
272
+ f"({(1 - after / before) * 100:.1f}% smaller)" if before else " size: 0")
273
+ return 1 if failed else 0
274
+
275
+
276
+ def main() -> int:
277
+ p = argparse.ArgumentParser(description=__doc__,
278
+ formatter_class=argparse.RawDescriptionHelpFormatter)
279
+ sub = p.add_subparsers(dest="cmd", required=True)
280
+ sub.add_parser("inventory")
281
+ sub.add_parser("backup")
282
+ sub.add_parser("test-mechanism")
283
+ r = sub.add_parser("recompress")
284
+ r.add_argument("--prefix", required=True, choices=("photos", "results"))
285
+ r.add_argument("--max-side", type=int, required=True)
286
+ r.add_argument("--apply", action="store_true",
287
+ help="actually overwrite (default: dry run)")
288
+ args = p.parse_args()
289
+ return {
290
+ "inventory": cmd_inventory,
291
+ "backup": cmd_backup,
292
+ "test-mechanism": cmd_test_mechanism,
293
+ "recompress": cmd_recompress,
294
+ }[args.cmd](args)
295
+
296
+
297
+ if __name__ == "__main__":
298
+ sys.exit(main())
web_demo/app.py CHANGED
@@ -35,6 +35,7 @@ from src.ring_size import recommend_ring_size, RING_MODELS, VALID_RING_MODELS, D
35
  from src.ai_recommendation import ai_explain_recommendation
36
  from web_demo.supabase_client import (
37
  upload_file,
 
38
  save_measurement,
39
  save_feedback,
40
  persistence_enabled,
@@ -50,6 +51,7 @@ from web_demo.supabase_client import (
50
  update_ground_truth,
51
  delete_measurement,
52
  delete_feedback,
 
53
  )
54
  from src.confidence_constants import (
55
  CONFIDENCE_LEVEL_HIGH_THRESHOLD,
@@ -79,6 +81,50 @@ logger = logging.getLogger(__name__)
79
  # request queues one task, and we don't need ordering.
80
  _persist_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="supa-persist")
81
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
 
83
  def _persist_measurement_async(
84
  *,
@@ -98,9 +144,14 @@ def _persist_measurement_async(
98
  photo_url = None
99
  result_url = None
100
  if upload_path and upload_path.exists():
101
- photo_url = upload_file(str(upload_path), f"photos/{upload_name}")
 
102
  if result_png_path.exists():
103
- result_url = upload_file(str(result_png_path), f"results/{result_png_name}")
 
 
 
 
104
  record_with_urls = dict(record)
105
  record_with_urls["photo_url"] = photo_url
106
  record_with_urls["result_url"] = result_url
@@ -972,6 +1023,17 @@ def api_admin_stats():
972
  return jsonify(_compute_stats(rows, days=days))
973
 
974
 
 
 
 
 
 
 
 
 
 
 
 
975
  _CSV_INJECTION_LEAD = ("=", "+", "-", "@", "\t", "\r")
976
 
977
 
 
35
  from src.ai_recommendation import ai_explain_recommendation
36
  from web_demo.supabase_client import (
37
  upload_file,
38
+ upload_bytes,
39
  save_measurement,
40
  save_feedback,
41
  persistence_enabled,
 
51
  update_ground_truth,
52
  delete_measurement,
53
  delete_feedback,
54
+ storage_usage,
55
  )
56
  from src.confidence_constants import (
57
  CONFIDENCE_LEVEL_HIGH_THRESHOLD,
 
81
  # request queues one task, and we don't need ordering.
82
  _persist_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="supa-persist")
83
 
84
+ # v7 storage diet: shrink what we store, not what we measure. Measurement
85
+ # runs on the full-res in-memory image FIRST; only the stored copies are
86
+ # downscaled. The pipeline never sees past ~1024px anyway (SAM downscales
87
+ # internally), so a 2048px stored photo reproduces the full-res measurement
88
+ # (validated: median 0.07 mm delta, zero new failures at 2048/q80). The
89
+ # result overlay is display-only and never re-measured, so it goes smaller.
90
+ PHOTO_MAX_SIDE = 2048
91
+ RESULT_MAX_SIDE = 1024
92
+ STORE_JPEG_QUALITY = 80
93
+
94
+
95
+ def _shrink_jpeg(image_path: Path, max_side: int) -> Optional[bytes]:
96
+ """Read an image, downscale to max_side long-edge (never upscale), and
97
+ return JPEG q80 bytes. Returns None on any decode/encode failure so the
98
+ caller can fall back to uploading the original file."""
99
+ try:
100
+ img = cv2.imread(str(image_path))
101
+ if img is None:
102
+ return None
103
+ h, w = img.shape[:2]
104
+ long_side = max(h, w)
105
+ if long_side > max_side:
106
+ scale = max_side / long_side
107
+ img = cv2.resize(img, (int(round(w * scale)), int(round(h * scale))),
108
+ interpolation=cv2.INTER_AREA)
109
+ ok, enc = cv2.imencode(".jpg", img,
110
+ [cv2.IMWRITE_JPEG_QUALITY, STORE_JPEG_QUALITY])
111
+ if not ok:
112
+ return None
113
+ return enc.tobytes()
114
+ except Exception as exc: # noqa: BLE001
115
+ logger.warning("shrink failed for %s: %s", image_path, exc)
116
+ return None
117
+
118
+
119
+ def _upload_shrunk(local_path: Path, storage_path: str, max_side: int) -> Optional[str]:
120
+ """Upload a downscaled JPEG copy of local_path. Falls back to uploading
121
+ the original file if shrink fails, so a recompress hiccup never drops the
122
+ image entirely."""
123
+ data = _shrink_jpeg(local_path, max_side)
124
+ if data is not None:
125
+ return upload_bytes(data, storage_path, "image/jpeg")
126
+ return upload_file(str(local_path), storage_path)
127
+
128
 
129
  def _persist_measurement_async(
130
  *,
 
144
  photo_url = None
145
  result_url = None
146
  if upload_path and upload_path.exists():
147
+ photo_url = _upload_shrunk(
148
+ upload_path, f"photos/{upload_name}", PHOTO_MAX_SIDE)
149
  if result_png_path.exists():
150
+ # Keep the historical `_result.png` object name (DB result_url
151
+ # points at it) but store JPEG bytes β€” browsers honor the
152
+ # image/jpeg content-type over the .png extension.
153
+ result_url = _upload_shrunk(
154
+ result_png_path, f"results/{result_png_name}", RESULT_MAX_SIDE)
155
  record_with_urls = dict(record)
156
  record_with_urls["photo_url"] = photo_url
157
  record_with_urls["result_url"] = result_url
 
1023
  return jsonify(_compute_stats(rows, days=days))
1024
 
1025
 
1026
+ @app.route("/api/admin/storage")
1027
+ def api_admin_storage():
1028
+ if not _check_admin_token():
1029
+ return jsonify({"error": "Unauthorized"}), 401
1030
+ usage = storage_usage()
1031
+ if usage is None:
1032
+ return jsonify({"available": False})
1033
+ usage["available"] = True
1034
+ return jsonify(usage)
1035
+
1036
+
1037
  _CSV_INJECTION_LEAD = ("=", "+", "-", "@", "\t", "\r")
1038
 
1039
 
web_demo/supabase_client.py CHANGED
@@ -64,6 +64,29 @@ def persistence_enabled() -> bool:
64
  BUCKET = "ring-measurements"
65
 
66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  def upload_file(local_path: str, storage_path: str) -> Optional[str]:
68
  """Upload a file to Supabase Storage. Returns public URL or None."""
69
  client = _get_client()
@@ -204,6 +227,64 @@ def count_feedback() -> Optional[int]:
204
  return None
205
 
206
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
207
  def list_measurements(limit: int = 200, offset: int = 0) -> List[Dict[str, Any]]:
208
  """Fetch measurements for admin page, newest first."""
209
  client = _get_client()
 
64
  BUCKET = "ring-measurements"
65
 
66
 
67
+ def upload_bytes(data: bytes, storage_path: str, content_type: str) -> Optional[str]:
68
+ """Upload raw bytes to Supabase Storage. Returns public URL or None.
69
+
70
+ Used by the web demo to upload an in-memory downscaled JPEG (v7 storage
71
+ diet) without first writing it to a temp file. content_type is explicit
72
+ because the stored object name may keep a legacy extension (e.g. a
73
+ `_result.png` object that now carries JPEG bytes)."""
74
+ client = _get_client()
75
+ if client is None:
76
+ return None
77
+ try:
78
+ client.storage.from_(BUCKET).upload(
79
+ storage_path,
80
+ data,
81
+ file_options={"content-type": content_type},
82
+ )
83
+ logger.info("Storage upload %s: %s bytes", storage_path, len(data))
84
+ return client.storage.from_(BUCKET).get_public_url(storage_path)
85
+ except Exception as e:
86
+ logger.error("Failed to upload %s (%s bytes): %s", storage_path, len(data), e)
87
+ return None
88
+
89
+
90
  def upload_file(local_path: str, storage_path: str) -> Optional[str]:
91
  """Upload a file to Supabase Storage. Returns public URL or None."""
92
  client = _get_client()
 
227
  return None
228
 
229
 
230
+ def _list_prefix_objects(storage, prefix: str) -> List[Dict[str, Any]]:
231
+ """Paginate storage.list(prefix) and return every real object (skips the
232
+ folder placeholder row that has no size metadata)."""
233
+ out: List[Dict[str, Any]] = []
234
+ offset = 0
235
+ page = 100
236
+ while True:
237
+ resp = storage.list(prefix, {"limit": page, "offset": offset})
238
+ if not resp:
239
+ break
240
+ for o in resp:
241
+ md = o.get("metadata") or {}
242
+ if md.get("size") is not None:
243
+ out.append(o)
244
+ if len(resp) < page:
245
+ break
246
+ offset += page
247
+ return out
248
+
249
+
250
+ # Free-tier storage quota; used only to compute a headroom % for the admin
251
+ # panel. Supabase enforces 1 GB; we surface headroom against a slightly
252
+ # conservative 1.0 GB so the number nudges before the hard wall.
253
+ STORAGE_QUOTA_BYTES = 1024 * 1024 * 1024
254
+
255
+
256
+ def storage_usage() -> Optional[Dict[str, Any]]:
257
+ """Summarize bucket usage for the admin dashboard: per-prefix object count
258
+ + bytes, grand total, and headroom vs the free-tier quota. Returns None
259
+ when persistence is disabled so the UI can show 'unknown' rather than 0.
260
+
261
+ Walks the storage list API (the bucket has no size column); a few hundred
262
+ objects across three prefixes is one quick paginated sweep per prefix.
263
+ """
264
+ client = _get_client()
265
+ if client is None:
266
+ return None
267
+ try:
268
+ storage = client.storage.from_(BUCKET)
269
+ prefixes = {}
270
+ total = 0
271
+ for prefix in ("photos", "results", "feedback"):
272
+ objs = _list_prefix_objects(storage, prefix)
273
+ size = sum((o.get("metadata") or {}).get("size", 0) for o in objs)
274
+ prefixes[prefix] = {"objects": len(objs), "bytes": size}
275
+ total += size
276
+ return {
277
+ "prefixes": prefixes,
278
+ "total_bytes": total,
279
+ "quota_bytes": STORAGE_QUOTA_BYTES,
280
+ "used_fraction": (total / STORAGE_QUOTA_BYTES
281
+ if STORAGE_QUOTA_BYTES else None),
282
+ }
283
+ except Exception as e:
284
+ logger.error("Failed to compute storage usage: %s", e)
285
+ return None
286
+
287
+
288
  def list_measurements(limit: int = 200, offset: int = 0) -> List[Dict[str, Any]]:
289
  """Fetch measurements for admin page, newest first."""
290
  client = _get_client()
web_demo/templates/admin.html CHANGED
@@ -334,6 +334,7 @@
334
  adminContent.style.display = "block";
335
  document.getElementById("exportCsvLink").href = `/api/admin/export-csv?token=${encodeURIComponent(adminToken)}`;
336
  loadStats();
 
337
  loadData();
338
  loadFeedback();
339
  return true;
@@ -610,6 +611,31 @@
610
 
611
  const fmtPct = (v) => v == null ? "-" : (v * 100).toFixed(1) + "%";
612
  const fmtInt = (v) => v == null ? "-" : v.toLocaleString();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
613
  const fmtDay = (iso) => {
614
  if (!iso) return "β€”";
615
  // YYYY-MM-DD β†’ MM-DD for compact axis labels
@@ -626,6 +652,7 @@
626
  };
627
 
628
  const renderStatCards = (s) => {
 
629
  const t = s.totals;
630
  const last7Delta = deltaArrow(t.last_7_days, t.prev_7_days);
631
  const cards = [
@@ -639,6 +666,7 @@
639
  value: t.rating_count ? `${t.avg_rating.toFixed(2)} β˜…` : "β€”",
640
  sub: `${fmtInt(t.rating_count)} rated Β· ${fmtInt(t.comment_count)} comments`,
641
  },
 
642
  ];
643
  statGrid.innerHTML = cards.map((c) => `
644
  <div class="stat-card">
@@ -786,7 +814,18 @@
786
  }
787
  };
788
 
789
- document.getElementById("dashRefreshBtn").addEventListener("click", loadStats);
 
 
 
 
 
 
 
 
 
 
 
790
  windowSelect.addEventListener("change", loadStats);
791
  </script>
792
  </body>
 
334
  adminContent.style.display = "block";
335
  document.getElementById("exportCsvLink").href = `/api/admin/export-csv?token=${encodeURIComponent(adminToken)}`;
336
  loadStats();
337
+ loadStorage();
338
  loadData();
339
  loadFeedback();
340
  return true;
 
611
 
612
  const fmtPct = (v) => v == null ? "-" : (v * 100).toFixed(1) + "%";
613
  const fmtInt = (v) => v == null ? "-" : v.toLocaleString();
614
+ const fmtBytes = (n) => {
615
+ if (n == null) return "β€”";
616
+ const u = ["B", "KB", "MB", "GB", "TB"];
617
+ let i = 0;
618
+ while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
619
+ return n.toFixed(n < 10 && i > 0 ? 1 : 0) + " " + u[i];
620
+ };
621
+
622
+ // Storage usage is fetched separately from /api/admin/stats; cached here
623
+ // and folded into the stat-card grid so it sits with the headline numbers.
624
+ let storageStats = null;
625
+ let lastStats = null;
626
+
627
+ const storageCard = () => {
628
+ if (storageStats === null) return { label: "Storage used", value: "…", sub: "" };
629
+ if (!storageStats.available) return { label: "Storage used", value: "n/a", sub: "persistence off" };
630
+ const s = storageStats;
631
+ const pct = s.used_fraction != null ? (s.used_fraction * 100).toFixed(0) + "%" : "";
632
+ const p = s.prefixes || {};
633
+ const part = (k) => (p[k] ? `${k} ${fmtBytes(p[k].bytes)}` : "");
634
+ const sub = `${pct} of ${fmtBytes(s.quota_bytes)} Β· ` +
635
+ [part("photos"), part("results"), part("feedback")].filter(Boolean).join(" Β· ");
636
+ const subCls = (s.used_fraction != null && s.used_fraction > 0.8) ? "delta-down" : "";
637
+ return { label: "Storage used", value: fmtBytes(s.total_bytes), sub, subCls };
638
+ };
639
  const fmtDay = (iso) => {
640
  if (!iso) return "β€”";
641
  // YYYY-MM-DD β†’ MM-DD for compact axis labels
 
652
  };
653
 
654
  const renderStatCards = (s) => {
655
+ lastStats = s;
656
  const t = s.totals;
657
  const last7Delta = deltaArrow(t.last_7_days, t.prev_7_days);
658
  const cards = [
 
666
  value: t.rating_count ? `${t.avg_rating.toFixed(2)} β˜…` : "β€”",
667
  sub: `${fmtInt(t.rating_count)} rated Β· ${fmtInt(t.comment_count)} comments`,
668
  },
669
+ storageCard(),
670
  ];
671
  statGrid.innerHTML = cards.map((c) => `
672
  <div class="stat-card">
 
814
  }
815
  };
816
 
817
+ const loadStorage = async () => {
818
+ try {
819
+ const resp = await fetch(`/api/admin/storage?token=${encodeURIComponent(adminToken)}`);
820
+ if (resp.status === 401) return;
821
+ storageStats = await resp.json();
822
+ if (lastStats) renderStatCards(lastStats); // re-render so the card fills in
823
+ } catch (e) {
824
+ console.error("Failed to load storage", e);
825
+ }
826
+ };
827
+
828
+ document.getElementById("dashRefreshBtn").addEventListener("click", () => { loadStats(); loadStorage(); });
829
  windowSelect.addEventListener("change", loadStats);
830
  </script>
831
  </body>