withcache 0.7.0__tar.gz → 0.7.2__tar.gz

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.
Files changed (26) hide show
  1. {withcache-0.7.0 → withcache-0.7.2}/PKG-INFO +1 -1
  2. {withcache-0.7.0 → withcache-0.7.2}/shim/build.zig.zon +1 -1
  3. {withcache-0.7.0 → withcache-0.7.2}/src/withcache/__init__.py +1 -1
  4. {withcache-0.7.0 → withcache-0.7.2}/src/withcache/server.py +700 -134
  5. {withcache-0.7.0 → withcache-0.7.2}/.gitignore +0 -0
  6. {withcache-0.7.0 → withcache-0.7.2}/LICENSE +0 -0
  7. {withcache-0.7.0 → withcache-0.7.2}/README.md +0 -0
  8. {withcache-0.7.0 → withcache-0.7.2}/deploy/Containerfile +0 -0
  9. {withcache-0.7.0 → withcache-0.7.2}/deploy/compose.yml +0 -0
  10. {withcache-0.7.0 → withcache-0.7.2}/hatch_build.py +0 -0
  11. {withcache-0.7.0 → withcache-0.7.2}/pyproject.toml +0 -0
  12. {withcache-0.7.0 → withcache-0.7.2}/shim/build.zig +0 -0
  13. {withcache-0.7.0 → withcache-0.7.2}/shim/shim.zig +0 -0
  14. {withcache-0.7.0 → withcache-0.7.2}/src/withcache/_shim.py +0 -0
  15. {withcache-0.7.0 → withcache-0.7.2}/src/withcache/client.py +0 -0
  16. {withcache-0.7.0 → withcache-0.7.2}/src/withcache/curlwithcache.py +0 -0
  17. {withcache-0.7.0 → withcache-0.7.2}/src/withcache/oras.py +0 -0
  18. {withcache-0.7.0 → withcache-0.7.2}/src/withcache/static/bootstrap-icons.min.css +0 -0
  19. {withcache-0.7.0 → withcache-0.7.2}/src/withcache/static/bootstrap.min.css +0 -0
  20. {withcache-0.7.0 → withcache-0.7.2}/src/withcache/static/fonts/bootstrap-icons.woff +0 -0
  21. {withcache-0.7.0 → withcache-0.7.2}/src/withcache/static/fonts/bootstrap-icons.woff2 +0 -0
  22. {withcache-0.7.0 → withcache-0.7.2}/src/withcache/static/htmx.min.js +0 -0
  23. {withcache-0.7.0 → withcache-0.7.2}/src/withcache/wgetwithcache.py +0 -0
  24. {withcache-0.7.0 → withcache-0.7.2}/tests/test_differential.py +0 -0
  25. {withcache-0.7.0 → withcache-0.7.2}/tests/test_oras.py +0 -0
  26. {withcache-0.7.0 → withcache-0.7.2}/tests/test_withcache.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: withcache
3
- Version: 0.7.0
3
+ Version: 0.7.2
4
4
  Summary: Operator-curated, URL-keyed artifact cache for a small lab (CUDA/ROCm/DOCA/firmware)
5
5
  Project-URL: Homepage, https://github.com/safl/withcache
6
6
  Author-email: "Simon A. F. Lund" <safl@safl.dk>
@@ -2,7 +2,7 @@
2
2
  .name = .withcache_shim,
3
3
  // Zig requires a literal here; keep it in lockstep with the project's
4
4
  // single source (src/withcache/__init__.py) via `make bump` / `make version-check`.
5
- .version = "0.7.0",
5
+ .version = "0.7.2",
6
6
  .fingerprint = 0xd7d96c5ed212ccaa,
7
7
  .minimum_zig_version = "0.16.0",
8
8
  .paths = .{
@@ -17,6 +17,6 @@ All modules are stdlib-only and self-contained.
17
17
  from . import oras
18
18
  from .client import blob_url, cache_base, is_cached, serve_url
19
19
 
20
- __version__ = "0.7.0"
20
+ __version__ = "0.7.2"
21
21
 
22
22
  __all__ = ["__version__", "blob_url", "cache_base", "is_cached", "oras", "serve_url"]
@@ -152,6 +152,30 @@ def resolve_secret(data_dir: str) -> bytes:
152
152
  DEFAULT_CATALOG_URL = "https://github.com/safl/nosi/releases/latest/download/catalog.toml"
153
153
 
154
154
 
155
+ def _serialise_catalog(entries: list[dict[str, Any]]) -> bytes:
156
+ """Serialise a list of catalog entries back to TOML bytes matching the
157
+ nosi ``version = 1`` + ``[[images]]`` schema. Stdlib-only, no
158
+ ``tomli_w`` dep: the schema is flat so a hand-rolled emitter is
159
+ safer than pulling in a write library. Only known scalar keys are
160
+ emitted; unknown keys are dropped (silently) so an operator-added
161
+ row can't smuggle arbitrary TOML through."""
162
+ out: list[str] = ["version = 1", ""]
163
+ for e in entries:
164
+ out.append("[[images]]")
165
+ for key in ("name", "src", "format", "arch", "sha256"):
166
+ val = e.get(key)
167
+ if val is None or val == "":
168
+ continue
169
+ # Escape backslashes + quotes then wrap in double quotes.
170
+ escaped = str(val).replace("\\", "\\\\").replace('"', '\\"')
171
+ out.append(f'{key} = "{escaped}"')
172
+ size = e.get("size_bytes")
173
+ if isinstance(size, int):
174
+ out.append(f"size_bytes = {size}")
175
+ out.append("")
176
+ return "\n".join(out).encode("utf-8")
177
+
178
+
155
179
  @dataclass
156
180
  class CatalogState:
157
181
  """Live state of the fetched image catalog.
@@ -167,20 +191,40 @@ class CatalogState:
167
191
  so a restart doesn't wipe the last known good catalog and the
168
192
  same file can be re-served verbatim to consumers on a future
169
193
  ``GET /catalog.toml`` route.
194
+
195
+ ``env_url`` records the value pinned via ``$WITHCACHE_CATALOG_URL``
196
+ (empty if unset). The operator can override the effective URL at
197
+ runtime via ``POST /admin/catalog_set_url``; the override is
198
+ persisted to ``<data_dir>/catalog_url`` and wins over the built-in
199
+ default, but the env var still wins over the operator override so
200
+ an operator can't silently unblock a locked-down deploy.
170
201
  """
171
202
 
172
203
  url: str
173
204
  persist_path: str
205
+ env_url: str = ""
206
+ url_override_path: str = ""
174
207
  entries: list[dict[str, Any]] = field(default_factory=list)
175
208
  fetched_at: str = ""
176
209
  last_error: str = ""
210
+ last_info: str = ""
177
211
  _lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
178
212
 
179
213
  def load_persisted(self) -> None:
180
214
  """Best-effort: seed ``entries`` + ``fetched_at`` from the on-disk
181
- ``catalog.toml`` if it exists. Never raises; a corrupt or missing
182
- file leaves the state empty so the operator sees the "not
183
- fetched yet" hint in the dashboard."""
215
+ ``catalog.toml`` if it exists. Also loads the operator URL
216
+ override from ``<data_dir>/catalog_url`` when the env var is
217
+ not set. Never raises; a corrupt or missing file leaves the
218
+ state empty so the operator sees the "not fetched yet" hint
219
+ in the dashboard."""
220
+ if self.url_override_path and os.path.isfile(self.url_override_path) and not self.env_url:
221
+ try:
222
+ with open(self.url_override_path, encoding="utf-8") as f:
223
+ override = f.read().strip()
224
+ if override:
225
+ self.url = override
226
+ except OSError:
227
+ pass
184
228
  if not os.path.isfile(self.persist_path):
185
229
  return
186
230
  try:
@@ -210,13 +254,11 @@ class CatalogState:
210
254
  entries = list(parsed.get("images") or [])
211
255
  # Atomic write: tempfile + rename so a crash mid-write
212
256
  # never leaves a half-written catalog.toml.
213
- tmp = self.persist_path + ".tmp"
214
- with open(tmp, "wb") as f:
215
- f.write(raw)
216
- os.replace(tmp, self.persist_path)
257
+ self._persist_bytes(raw)
217
258
  self.entries = entries
218
259
  self.fetched_at = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
219
260
  self.last_error = ""
261
+ self.last_info = f"fetched {len(entries)} entries from {self.url}"
220
262
  except (
221
263
  urllib.error.URLError,
222
264
  OSError,
@@ -226,6 +268,109 @@ class CatalogState:
226
268
  ) as e:
227
269
  self.last_error = str(e)
228
270
 
271
+ def set_url_override(self, new_url: str) -> tuple[bool, str]:
272
+ """Persist an operator-set URL override to
273
+ ``<data_dir>/catalog_url``. Env-var pin wins: if
274
+ ``$WITHCACHE_CATALOG_URL`` is set, the override is rejected
275
+ so a locked-down deploy stays locked down. Returns
276
+ ``(ok, message)`` for the UI to surface."""
277
+ if self.env_url:
278
+ return False, "env WITHCACHE_CATALOG_URL is pinned; unset it to allow overrides"
279
+ candidate = new_url.strip()
280
+ if not candidate:
281
+ return False, "url is empty"
282
+ if not candidate.startswith(("http://", "https://")):
283
+ return False, "url must start with http:// or https://"
284
+ with self._lock:
285
+ try:
286
+ if self.url_override_path:
287
+ tmp = self.url_override_path + ".tmp"
288
+ with open(tmp, "w", encoding="utf-8") as f:
289
+ f.write(candidate + "\n")
290
+ os.replace(tmp, self.url_override_path)
291
+ self.url = candidate
292
+ except OSError as e:
293
+ return False, str(e)
294
+ return True, f"catalog url set to {candidate}"
295
+
296
+ def replace_from_bytes(self, raw: bytes, *, source_label: str) -> tuple[bool, str]:
297
+ """Validate + persist an operator-supplied ``catalog.toml``
298
+ payload (from an upload). On success the in-memory ``entries``
299
+ are refreshed and ``last_info`` records the source. On
300
+ validation failure nothing on disk is touched."""
301
+ try:
302
+ parsed = tomllib.loads(raw.decode("utf-8"))
303
+ except (UnicodeDecodeError, tomllib.TOMLDecodeError) as e:
304
+ return False, f"not a valid TOML file: {e}"
305
+ entries = list(parsed.get("images") or [])
306
+ with self._lock:
307
+ try:
308
+ self._persist_bytes(raw)
309
+ except OSError as e:
310
+ return False, str(e)
311
+ self.entries = entries
312
+ self.fetched_at = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
313
+ self.last_error = ""
314
+ self.last_info = f"loaded {len(entries)} entries from {source_label}"
315
+ return True, self.last_info
316
+
317
+ def add_entry(self, entry: dict[str, Any]) -> tuple[bool, str]:
318
+ """Append a single ``[[images]]`` row to the persisted catalog
319
+ and refresh the in-memory list. ``entry`` must have at least
320
+ ``name`` and ``src``. Duplicate ``name`` replaces the existing
321
+ row (matches how ``bty-web``'s catalog-import handled dupes)."""
322
+ name = str(entry.get("name") or "").strip()
323
+ src = str(entry.get("src") or "").strip()
324
+ if not name:
325
+ return False, "name is required"
326
+ if not src:
327
+ return False, "src is required"
328
+ clean: dict[str, Any] = {"name": name, "src": src}
329
+ for key in ("format", "arch", "sha256"):
330
+ val = entry.get(key)
331
+ if isinstance(val, str) and val.strip():
332
+ clean[key] = val.strip()
333
+ with self._lock:
334
+ new_entries = [e for e in self.entries if str(e.get("name") or "") != name]
335
+ new_entries.append(clean)
336
+ raw = _serialise_catalog(new_entries)
337
+ try:
338
+ self._persist_bytes(raw)
339
+ except OSError as e:
340
+ return False, str(e)
341
+ self.entries = new_entries
342
+ self.last_error = ""
343
+ self.last_info = f"added entry: {name}"
344
+ return True, self.last_info
345
+
346
+ def delete_entry(self, name: str) -> tuple[bool, str]:
347
+ """Remove an entry by ``name``. No-op if absent."""
348
+ target = name.strip()
349
+ if not target:
350
+ return False, "name is required"
351
+ with self._lock:
352
+ new_entries = [e for e in self.entries if str(e.get("name") or "") != target]
353
+ if len(new_entries) == len(self.entries):
354
+ return False, f"no entry named {target!r}"
355
+ raw = _serialise_catalog(new_entries)
356
+ try:
357
+ self._persist_bytes(raw)
358
+ except OSError as e:
359
+ return False, str(e)
360
+ self.entries = new_entries
361
+ self.last_error = ""
362
+ self.last_info = f"deleted entry: {target}"
363
+ return True, self.last_info
364
+
365
+ def _persist_bytes(self, raw: bytes) -> None:
366
+ """Atomic-replace ``self.persist_path`` with ``raw`` via
367
+ tempfile + rename so a crash mid-write can never leave a
368
+ half-written catalog.toml. Callers hold ``self._lock``."""
369
+ tmp = self.persist_path + ".tmp"
370
+ with open(tmp, "wb") as f:
371
+ f.write(raw)
372
+ os.replace(tmp, self.persist_path)
373
+
229
374
 
230
375
  class Auth:
231
376
  COOKIE = "withcache-token"
@@ -878,10 +1023,24 @@ class Handler(http.server.BaseHTTPRequestHandler):
878
1023
  "/admin/cancel",
879
1024
  "/admin/clear",
880
1025
  "/admin/catalog_refresh",
1026
+ "/admin/catalog_set_url",
1027
+ "/admin/catalog_upload",
1028
+ "/admin/catalog_add_entry",
1029
+ "/admin/catalog_delete_entry",
881
1030
  )
882
1031
 
883
1032
  def do_POST(self):
884
1033
  parsed = urllib.parse.urlsplit(self.path)
1034
+ # /admin/catalog_upload reads its own body (multipart); everything
1035
+ # else uses the form parser. Don't try to url-decode multipart
1036
+ # bytes.
1037
+ if parsed.path == "/admin/catalog_upload":
1038
+ if not self.is_authed():
1039
+ self.send_text(401, "login required\n")
1040
+ return
1041
+ self._handle_catalog_upload()
1042
+ self.respond_admin()
1043
+ return
885
1044
  form = self.read_form()
886
1045
  if parsed.path == "/ui/login":
887
1046
  self.handle_login_submit(form)
@@ -907,6 +1066,29 @@ class Handler(http.server.BaseHTTPRequestHandler):
907
1066
  self.mgr.clear_finished()
908
1067
  elif parsed.path == "/admin/catalog_refresh":
909
1068
  self.catalog.fetch_now()
1069
+ elif parsed.path == "/admin/catalog_set_url":
1070
+ ok, msg = self.catalog.set_url_override(form.get("url", ""))
1071
+ if ok:
1072
+ # Trigger an immediate fetch so the operator sees
1073
+ # the new source's entries without a second click.
1074
+ self.catalog.fetch_now()
1075
+ else:
1076
+ self.catalog.last_error = msg
1077
+ elif parsed.path == "/admin/catalog_add_entry":
1078
+ entry = {
1079
+ "name": form.get("name", ""),
1080
+ "src": form.get("src", ""),
1081
+ "format": form.get("format", ""),
1082
+ "arch": form.get("arch", ""),
1083
+ "sha256": form.get("sha256", ""),
1084
+ }
1085
+ ok, msg = self.catalog.add_entry(entry)
1086
+ if not ok:
1087
+ self.catalog.last_error = msg
1088
+ elif parsed.path == "/admin/catalog_delete_entry":
1089
+ ok, msg = self.catalog.delete_entry(form.get("name", ""))
1090
+ if not ok:
1091
+ self.catalog.last_error = msg
910
1092
  self.respond_admin()
911
1093
  else:
912
1094
  self.send_text(404, "not found\n")
@@ -1089,6 +1271,55 @@ class Handler(http.server.BaseHTTPRequestHandler):
1089
1271
  body = self.rfile.read(length).decode("utf-8") if length else ""
1090
1272
  return {k: v[0] for k, v in urllib.parse.parse_qs(body).items()}
1091
1273
 
1274
+ def _handle_catalog_upload(self) -> None:
1275
+ """Parse a multipart/form-data body with a single ``catalog``
1276
+ file part and forward the raw bytes to
1277
+ ``CatalogState.replace_from_bytes``. Stdlib-only: uses
1278
+ ``email.parser.BytesParser`` to walk the parts (avoids the
1279
+ ``cgi`` module which is slated for removal). On success or
1280
+ failure the outcome is recorded in ``last_info`` /
1281
+ ``last_error`` so ``respond_admin`` renders it inline."""
1282
+ content_type = self.headers.get("Content-Type", "") or ""
1283
+ if not content_type.startswith("multipart/"):
1284
+ self.catalog.last_error = "expected multipart/form-data"
1285
+ return
1286
+ length = int(self.headers.get("Content-Length", 0) or 0)
1287
+ if length <= 0:
1288
+ self.catalog.last_error = "empty upload"
1289
+ return
1290
+ body = self.rfile.read(length)
1291
+ # BytesParser needs a full "MIME message" header line so it
1292
+ # knows the Content-Type; prepend it verbatim from the request.
1293
+ prelude = f"MIME-Version: 1.0\r\nContent-Type: {content_type}\r\n\r\n".encode()
1294
+ try:
1295
+ import email.parser
1296
+ import email.policy
1297
+
1298
+ msg = email.parser.BytesParser(policy=email.policy.default).parsebytes(prelude + body)
1299
+ except Exception as e: # noqa: BLE001 -- any parse error is a bad upload
1300
+ self.catalog.last_error = f"multipart parse failed: {e}"
1301
+ return
1302
+ if not msg.is_multipart():
1303
+ self.catalog.last_error = "no multipart parts found"
1304
+ return
1305
+ payload_bytes: bytes | None = None
1306
+ filename = "upload"
1307
+ for part in msg.iter_parts():
1308
+ disp = part.get("Content-Disposition", "") or ""
1309
+ if 'name="catalog"' not in disp:
1310
+ continue
1311
+ payload_bytes = part.get_payload(decode=True)
1312
+ candidate_filename = part.get_filename()
1313
+ if candidate_filename:
1314
+ filename = candidate_filename
1315
+ break
1316
+ if not payload_bytes:
1317
+ self.catalog.last_error = "upload missing 'catalog' file part"
1318
+ return
1319
+ ok, msg_text = self.catalog.replace_from_bytes(payload_bytes, source_label=filename)
1320
+ if not ok:
1321
+ self.catalog.last_error = msg_text
1322
+
1092
1323
  def send_text(self, code: int, text: str):
1093
1324
  data = text.encode("utf-8")
1094
1325
  self.send_response(code)
@@ -1136,6 +1367,13 @@ class Handler(http.server.BaseHTTPRequestHandler):
1136
1367
  _PRIMARY_RGB = "143, 27, 113"
1137
1368
 
1138
1369
  def _head(self, title: str) -> str:
1370
+ """Emit the shared page prelude: Bootstrap + icons + htmx +
1371
+ the full bty-family chrome CSS. Every service (bty, withcache,
1372
+ nbdmux) uses this same block; the only per-service knob is the
1373
+ primary hue (``--bs-primary`` and the derived button /
1374
+ rgba() variants). See bty's ``_layout.html`` for the origin
1375
+ of these class names -- kept identical here so operators
1376
+ moving between consoles see one visual grammar."""
1139
1377
  primary = self._PRIMARY_HEX
1140
1378
  hover = self._PRIMARY_HOVER
1141
1379
  rgb = self._PRIMARY_RGB
@@ -1147,9 +1385,11 @@ class Handler(http.server.BaseHTTPRequestHandler):
1147
1385
  <link rel="stylesheet" href="/static/bootstrap-icons.min.css">
1148
1386
  <script src="/static/htmx.min.js"></script>
1149
1387
  <style>
1150
- /* Bootstrap 5 exposes --bs-primary + a matching -rgb triplet
1151
- used for translucent variants (alerts, focus rings, .bg-*-
1152
- subtle). Overriding both here re-tints every stock component
1388
+ /* Palette anchor for the three-service trio (bty navy,
1389
+ withcache dark-magenta, nbdmux magenta). Bootstrap 5
1390
+ exposes --bs-primary + a matching -rgb triplet used for
1391
+ translucent variants (alerts, focus rings, .bg-*-subtle).
1392
+ Overriding both here re-tints every stock component
1153
1393
  without patching bootstrap.min.css. */
1154
1394
  :root {{
1155
1395
  --bs-primary: {primary};
@@ -1157,51 +1397,254 @@ class Handler(http.server.BaseHTTPRequestHandler):
1157
1397
  --bs-link-color: {primary};
1158
1398
  --bs-link-hover-color: {hover};
1159
1399
  }}
1160
- .btn-primary {{ --bs-btn-bg: {primary}; --bs-btn-border-color: {primary};
1161
- --bs-btn-hover-bg: {hover}; --bs-btn-hover-border-color: {hover};
1162
- --bs-btn-active-bg: {hover}; --bs-btn-active-border-color: {hover}; }}
1400
+ .btn-primary {{
1401
+ --bs-btn-bg: {primary};
1402
+ --bs-btn-border-color: {primary};
1403
+ --bs-btn-hover-bg: {hover};
1404
+ --bs-btn-hover-border-color: {hover};
1405
+ --bs-btn-active-bg: {hover};
1406
+ --bs-btn-active-border-color: {hover};
1407
+ }}
1163
1408
  .bg-primary {{ --bs-bg-opacity: 1; background-color: {primary} !important; }}
1164
1409
  .text-primary {{ --bs-text-opacity: 1; color: {primary} !important; }}
1165
1410
  .border-primary {{ --bs-border-opacity: 1; border-color: {primary} !important; }}
1166
- /* Brand strip: navy -> dark-magenta -> magenta gradient shared
1167
- across bty (navy), withcache (dark-magenta) and nbdmux
1168
- (magenta) so the trio reads as one product family from any
1169
- of the three consoles. */
1170
- .brand-accent {{ height: 3px;
1171
- background: linear-gradient(90deg, #0d3585 0%, #8f1b71 50%, #d63384 100%); }}
1411
+ /* Brand strip: a thin gradient accent above the navbar.
1412
+ Stable across themes; defined inline rather than in
1413
+ bootstrap.min.css so it renders even when the CSS load
1414
+ races the initial paint. Shared navy -> dark-magenta ->
1415
+ magenta gradient across bty (navy), withcache
1416
+ (dark-magenta) and nbdmux (magenta) so the trio reads as
1417
+ one product family from any of the three consoles. */
1418
+ .brand-accent {{
1419
+ height: 3px;
1420
+ background: linear-gradient(90deg, #0d3585 0%, #8f1b71 50%, #d63384 100%);
1421
+ }}
1422
+ /* The accent + navbar + sub-nav pin to the top as one unit so
1423
+ the sub-nav jump links stay visible while scrolling. */
1424
+ .sticky-header {{
1425
+ position: sticky;
1426
+ top: 0;
1427
+ z-index: 1030;
1428
+ }}
1429
+ /* In-page ``#anchor`` jumps (the sub-nav tab pills) land below
1430
+ the sticky header instead of under it. */
1431
+ html {{
1432
+ scroll-padding-top: 6.5rem;
1433
+ scroll-behavior: smooth;
1434
+ }}
1435
+ /* Brand pill keeps the same padding + radius everywhere; the
1436
+ ``brand-active`` variant lights up on the home page so the
1437
+ brand doubles as a Home indicator. */
1438
+ .navbar-brand {{
1439
+ border-radius: 0.5rem;
1440
+ padding-left: 0.6rem;
1441
+ padding-right: 0.6rem;
1442
+ margin-right: 0.25rem;
1443
+ transition: background-color 0.15s;
1444
+ }}
1445
+ .navbar-brand.brand-active {{
1446
+ background-color: rgba({rgb}, 0.85);
1447
+ }}
1448
+ .navbar-brand:hover {{
1449
+ background-color: rgba(255, 255, 255, 0.06);
1450
+ }}
1451
+ .navbar-brand.brand-active:hover {{
1452
+ background-color: rgba({rgb}, 0.95);
1453
+ }}
1454
+ /* Version sits in the navbar alongside the brand pill but
1455
+ OUTSIDE it -- so the brand button stays a clean click target
1456
+ and the version reads as adjacent metadata. */
1457
+ .navbar-version {{
1458
+ color: rgba(255, 255, 255, 0.55);
1459
+ font-weight: 400;
1460
+ font-size: 0.85rem;
1461
+ align-self: center;
1462
+ white-space: nowrap;
1463
+ }}
1464
+ .navbar-brand .brand-icon {{
1465
+ /* Sized to match bty's mascot PNG (1.05rem tall). Using a
1466
+ Bootstrap Icon rather than an image keeps the shell
1467
+ stdlib-only. */
1468
+ font-size: 1.05rem;
1469
+ vertical-align: -0.05rem;
1470
+ }}
1471
+ .navbar .nav-btn {{
1472
+ display: inline-flex;
1473
+ align-items: center;
1474
+ gap: 0.4rem;
1475
+ padding: 0.4rem 0.8rem;
1476
+ margin-right: 0.25rem;
1477
+ border-radius: 0.5rem;
1478
+ color: rgba(255, 255, 255, 0.85);
1479
+ text-decoration: none;
1480
+ transition: background-color 0.15s;
1481
+ }}
1482
+ .navbar .nav-btn:hover {{
1483
+ background-color: rgba(255, 255, 255, 0.10);
1484
+ color: #fff;
1485
+ }}
1486
+ .navbar .nav-btn.active {{
1487
+ background-color: {primary};
1488
+ color: #fff;
1489
+ box-shadow: 0 0 0 1px rgba({rgb}, 0.6);
1490
+ }}
1491
+ .navbar .nav-btn i {{
1492
+ font-size: 1.05rem;
1493
+ }}
1494
+ /* User-bar: a single pill containing username + logout, with a
1495
+ thin vertical divider between them. Visually one widget,
1496
+ but two click targets and zero JavaScript. */
1497
+ .user-bar {{
1498
+ display: inline-flex;
1499
+ align-items: stretch;
1500
+ border-radius: 999px;
1501
+ background-color: rgba(255, 255, 255, 0.08);
1502
+ border: 1px solid rgba(255, 255, 255, 0.12);
1503
+ overflow: hidden;
1504
+ font-size: 0.85rem;
1505
+ }}
1506
+ .user-bar-name {{
1507
+ display: inline-flex;
1508
+ align-items: center;
1509
+ gap: 0.4rem;
1510
+ padding: 0.35rem 0.8rem;
1511
+ color: rgba(255, 255, 255, 0.92);
1512
+ }}
1513
+ .user-bar-name code {{
1514
+ color: #fff;
1515
+ background: transparent;
1516
+ padding: 0;
1517
+ }}
1518
+ .user-bar-divider {{
1519
+ width: 1px;
1520
+ background-color: rgba(255, 255, 255, 0.18);
1521
+ }}
1522
+ .user-bar-action {{
1523
+ display: inline-flex;
1524
+ align-items: center;
1525
+ padding: 0.35rem 0.7rem;
1526
+ background: transparent;
1527
+ border: none;
1528
+ color: rgba(255, 255, 255, 0.85);
1529
+ text-decoration: none;
1530
+ transition: background-color 0.15s, color 0.15s;
1531
+ }}
1532
+ .user-bar-action:hover,
1533
+ .user-bar-action:focus {{
1534
+ background-color: rgba(255, 255, 255, 0.10);
1535
+ color: #fff;
1536
+ outline: none;
1537
+ }}
1538
+ .user-bar-action.active {{
1539
+ background-color: rgba(255, 255, 255, 0.16);
1540
+ color: #fff;
1541
+ }}
1542
+ /* Logout hover keeps the danger-red signal so it doesn't
1543
+ look indistinguishable from the account action. */
1544
+ .user-bar-logout:hover,
1545
+ .user-bar-logout:focus {{
1546
+ background-color: rgba(220, 53, 69, 0.65);
1547
+ color: #fff;
1548
+ }}
1549
+ /* Sub-nav strip. Sits immediately below the main ``bg-dark``
1550
+ navbar; coloured CLEARLY lighter so it reads as "second-tier
1551
+ nav, still chrome". Same ``#495057`` (--bs-gray-700) bty
1552
+ uses so the boundary is unambiguous regardless of theme. */
1553
+ .subnav-strip {{
1554
+ background-color: #495057;
1555
+ border-bottom: 1px solid rgba(255, 255, 255, 0.10);
1556
+ padding-top: 0.25rem;
1557
+ padding-bottom: 0.25rem;
1558
+ font-size: 0.85rem;
1559
+ line-height: 1.4;
1560
+ }}
1561
+ .subnav-strip > .container {{
1562
+ min-height: 2rem;
1563
+ display: flex;
1564
+ align-items: center;
1565
+ gap: 0.6rem;
1566
+ }}
1567
+ .subnav-strip .form-control-sm,
1568
+ .subnav-strip .btn-sm,
1569
+ .subnav-strip a,
1570
+ .subnav-strip label,
1571
+ .subnav-strip .small,
1572
+ .subnav-strip small {{
1573
+ font-size: inherit;
1574
+ line-height: inherit;
1575
+ }}
1576
+ .subnav-strip .form-control-sm,
1577
+ .subnav-strip .btn-sm {{
1578
+ padding-top: 0.15rem;
1579
+ padding-bottom: 0.15rem;
1580
+ }}
1581
+ .subnav-strip .nav-pills {{
1582
+ gap: 0.25rem;
1583
+ }}
1584
+ .subnav-strip .nav-pills .nav-link {{
1585
+ color: rgba(255, 255, 255, 0.78);
1586
+ padding: 0.15rem 0.55rem;
1587
+ }}
1588
+ .subnav-strip .nav-pills .nav-link:hover {{
1589
+ color: #fff;
1590
+ background-color: rgba(255, 255, 255, 0.10);
1591
+ }}
1592
+ .subnav-strip .nav-pills .nav-link.active-tab {{
1593
+ color: #fff;
1594
+ background-color: {primary};
1595
+ }}
1596
+ .subnav-strip .text-muted {{
1597
+ color: rgba(255, 255, 255, 0.55) !important;
1598
+ }}
1599
+ .subnav-strip code {{
1600
+ color: rgba(255, 255, 255, 0.85);
1601
+ background-color: transparent;
1602
+ }}
1603
+ .subnav-strip a {{
1604
+ color: rgba(255, 255, 255, 0.78);
1605
+ }}
1606
+ .subnav-strip a:hover {{
1607
+ color: #fff;
1608
+ }}
1609
+ /* Withcache-specific bits kept minimal. Everything else
1610
+ inherits from Bootstrap + the chrome above. */
1172
1611
  code {{ color: inherit; font-size: .85em; }}
1173
1612
  .url {{ word-break: break-all; }}
1174
1613
  .num {{ text-align: right; }}
1175
1614
  .mono {{ font-family: var(--bs-font-monospace); font-size: .85em; }}
1176
1615
  #spin {{ width: 7rem; height: .5rem; margin: 0; }}
1177
- /* Tabs: Bootstrap nav-tabs, tinted via the active class. The
1178
- content panels toggle visibility via ``section.tab.active-tab``
1179
- so the same 1 Hz htmx innerHTML swap that used to work with
1180
- the Pico shell keeps working here without changing the JS. */
1181
- nav.tabs .nav-link {{ color: var(--bs-secondary-color); border: none;
1182
- border-bottom: 2px solid transparent; padding: .45rem .9rem;
1183
- font-size: .9rem; margin-bottom: -1px; }}
1184
- nav.tabs .nav-link:hover {{ color: var(--bs-body-color); }}
1185
- nav.tabs .nav-link.active-tab {{ color: var(--bs-body-color);
1186
- border-bottom-color: var(--bs-primary); font-weight: 600; }}
1187
- section.tab {{ display: none; padding-top: .75rem; }}
1616
+ /* Tab content panels: shown only when the section carries
1617
+ ``.active-tab``. Same hook the previous nav-tabs code used;
1618
+ unchanged so the existing htmx post-swap script keeps
1619
+ working. */
1620
+ section.tab {{ display: none; padding-top: .25rem; }}
1188
1621
  section.tab.active-tab {{ display: block; }}
1189
1622
  </style>
1190
1623
  </head>"""
1191
1624
 
1192
1625
  def render_login(self, error: str = "") -> str:
1626
+ """The unauthenticated shell: accent strip + dark navbar with the
1627
+ brand pill (no user-bar since there's no session), then a
1628
+ centered login card. Same chrome the authenticated page uses,
1629
+ so the operator sees continuity across the auth boundary."""
1193
1630
  err = f'<div class="alert alert-danger">{html.escape(error)}</div>' if error else ""
1194
1631
  return f"""{self._head("withcache - login")}
1195
- <body>
1632
+ <body class="bg-light">
1633
+ <div class="sticky-header">
1196
1634
  <div class="brand-accent"></div>
1197
- <main class="container py-5">
1198
- <div class="card mx-auto" style="max-width: 24rem;">
1635
+ <nav class="navbar navbar-expand-md bg-dark navbar-dark py-2">
1636
+ <div class="container">
1637
+ <a class="navbar-brand fw-semibold brand-active" href="/">
1638
+ <i class="bi bi-database brand-icon me-1"></i>WITHCACHE
1639
+ </a>
1640
+ <span class="navbar-version">v{html.escape(__version__)}</span>
1641
+ </div>
1642
+ </nav>
1643
+ </div>
1644
+ <main class="container">
1645
+ <div class="card mx-auto mt-5" style="max-width: 24rem;">
1199
1646
  <div class="card-body">
1200
- <h3 class="card-title fw-bold text-primary">
1201
- <i class="bi bi-hdd-stack"></i> withcache
1202
- <small class="text-muted fs-6">v{html.escape(__version__)}</small>
1203
- </h3>
1204
- <p class="text-muted small mb-3">Operator login</p>
1647
+ <h5 class="card-title mb-3">Operator login</h5>
1205
1648
  {err}
1206
1649
  <form method="post" action="/ui/login">
1207
1650
  <div class="mb-3">
@@ -1215,56 +1658,91 @@ class Handler(http.server.BaseHTTPRequestHandler):
1215
1658
  </main></body></html>"""
1216
1659
 
1217
1660
  def render_page(self) -> str:
1218
- logout = (
1219
- '<form method="post" action="/ui/logout" class="d-inline m-0">'
1220
- '<button type="submit" class="btn btn-sm btn-outline-secondary">'
1221
- "Log out</button></form>"
1661
+ """The authenticated shell: sticky-header wrapping the accent
1662
+ strip, the dark navbar (brand pill + version + user-bar), and
1663
+ the subnav strip carrying the tab pills. Then main.container
1664
+ with the "Add from URI" card and the htmx-swapped #dash
1665
+ region. Same chrome bty uses; only ``--bs-primary`` differs."""
1666
+ user_bar = (
1667
+ '<div class="user-bar mt-2 mt-md-0" title="Operator session">'
1668
+ '<span class="user-bar-name">'
1669
+ '<i class="bi bi-person-circle"></i><code>operator</code>'
1670
+ "</span>"
1671
+ '<span class="user-bar-divider"></span>'
1672
+ '<form action="/ui/logout" method="post" class="m-0 d-inline-flex">'
1673
+ '<button type="submit" class="user-bar-action user-bar-logout" title="Sign out">'
1674
+ '<i class="bi bi-box-arrow-right"></i>'
1675
+ "</button></form>"
1676
+ "</div>"
1222
1677
  if self.auth.enabled
1223
1678
  else ""
1224
1679
  )
1225
1680
  return f"""{self._head("withcache cache-host")}
1226
- <body>
1681
+ <body class="bg-light">
1682
+ <!-- Sticky header: the accent strip, the main navbar, and the
1683
+ sub-nav strip travel together and pin to the top so the tab
1684
+ pills stay reachable while scrolling a long list. -->
1685
+ <div class="sticky-header">
1227
1686
  <div class="brand-accent"></div>
1228
- <nav class="navbar navbar-expand navbar-light bg-light border-bottom">
1229
- <div class="container">
1230
- <a class="navbar-brand fw-bold text-primary" href="/">
1231
- <i class="bi bi-hdd-stack"></i> withcache
1232
- <span class="badge bg-primary bg-opacity-10 text-primary ms-1"
1233
- >v{html.escape(__version__)}</span>
1234
- </a>
1235
- <div class="d-flex align-items-center gap-3">
1236
- <progress id="spin" class="htmx-indicator"></progress>
1237
- {logout}
1687
+ <nav class="navbar navbar-expand-md bg-dark navbar-dark py-2">
1688
+ <div class="container">
1689
+ <a class="navbar-brand fw-semibold brand-active" href="/">
1690
+ <i class="bi bi-database brand-icon me-1"></i>WITHCACHE
1691
+ </a>
1692
+ <div class="d-flex flex-grow-1 align-items-center flex-wrap">
1693
+ <div class="me-auto d-flex flex-wrap">
1694
+ <!-- withcache is a single-page dashboard: no middle
1695
+ nav-btns. me-auto pushes version + user-bar right. -->
1696
+ </div>
1697
+ <span class="navbar-version me-2">v{html.escape(__version__)}</span>
1698
+ {user_bar}
1699
+ </div>
1238
1700
  </div>
1239
- </div>
1240
1701
  </nav>
1241
- <main class="container py-4">
1242
- <div class="card mb-4">
1243
- <div class="card-header"><i class="bi bi-cloud-download text-primary"></i> Add from URI</div>
1244
- <div class="card-body">
1702
+
1703
+ <!-- Sub-nav strip. Left side carries the five tab pills below the
1704
+ main navbar; right side carries the page-level "Add from URI"
1705
+ action (bty convention: page-level actions live in the subnav's
1706
+ right-hand subnav-actions slot, not as a full-width card in
1707
+ main). The tab pills are rendered STATIC here so labels don't
1708
+ flicker on the 1 Hz htmx dashboard swap. Per-tab counts live
1709
+ in the summary line inside the dash content and update on
1710
+ every swap. -->
1711
+ <div class="subnav-strip">
1712
+ <div class="container">
1713
+ <ul class="nav nav-pills flex-nowrap flex-md-wrap m-0">
1714
+ <li class="nav-item"><a class="nav-link" href="#tab-cached">Cached</a></li>
1715
+ <li class="nav-item"><a class="nav-link" href="#tab-streams">Streams</a></li>
1716
+ <li class="nav-item"><a class="nav-link" href="#tab-downloads">Downloads</a></li>
1717
+ <li class="nav-item"><a class="nav-link" href="#tab-misses">Misses</a></li>
1718
+ <li class="nav-item"><a class="nav-link" href="#tab-catalog">Catalog</a></li>
1719
+ </ul>
1720
+ <div class="subnav-actions ms-auto d-flex align-items-center gap-1">
1245
1721
  <form hx-post="/admin/fetch" hx-target="#dash" hx-swap="innerHTML"
1246
- hx-indicator="#spin" hx-on::after-request="this.reset()">
1247
- <div class="input-group">
1248
- <input class="form-control" type="url" name="url"
1249
- placeholder="https://origin/path/artifact.tar.gz" required>
1250
- <button class="btn btn-primary" type="submit" style="white-space: nowrap;"
1251
- >Fetch &amp; store</button>
1252
- </div>
1722
+ hx-indicator="#spin" hx-on::after-request="this.reset()"
1723
+ class="m-0 d-flex align-items-center gap-1">
1724
+ <input class="form-control form-control-sm" type="url" name="url"
1725
+ style="width: 22rem;"
1726
+ placeholder="https://origin/path/artifact.tar.gz" required>
1727
+ <button class="btn btn-sm btn-primary" type="submit" title="Add from URI"
1728
+ >Fetch</button>
1253
1729
  </form>
1730
+ <progress id="spin" class="htmx-indicator ms-1"
1731
+ style="width:4rem;height:.4rem;"></progress>
1254
1732
  </div>
1255
1733
  </div>
1734
+ </div>
1735
+ </div><!-- /.sticky-header -->
1736
+
1737
+ <main class="container py-4">
1256
1738
 
1257
1739
  <!-- The hx-trigger gates polling on the user NOT having an active
1258
1740
  text selection, so highlight-and-copy a URL out of a table cell
1259
- isn't wiped by the 1 Hz refresh. ``isCollapsed`` is true when
1260
- there's no selection or the caret is a zero-width point; once
1261
- the operator releases / clears the selection polling resumes
1262
- on the next 1 s tick.
1263
- ``hx-headers`` sends the current URL hash (the active tab id)
1264
- as ``X-Active-Tab`` on every refresh so the server can bake
1265
- ``.active-tab`` into the rendered HTML -- eliminating the
1266
- visible flicker the post-swap JS-applied class would otherwise
1267
- cause when the new innerHTML lands without the class. -->
1741
+ isn't wiped by the 1 Hz refresh. hx-headers sends the current
1742
+ URL hash (the active tab id) as X-Active-Tab on every refresh
1743
+ so the server can bake .active-tab into the rendered HTML,
1744
+ eliminating the visible flicker the post-swap JS-applied class
1745
+ would otherwise cause when the new innerHTML lands. -->
1268
1746
  <div id="dash" hx-get="/admin/dash"
1269
1747
  hx-trigger="load, every 1s [document.getSelection().isCollapsed]"
1270
1748
  hx-swap="innerHTML"
@@ -1272,16 +1750,15 @@ class Handler(http.server.BaseHTTPRequestHandler):
1272
1750
  {self.render_dash(active_tab=(self.headers.get("X-Active-Tab") or "").strip())}
1273
1751
  </div>
1274
1752
 
1275
- <!-- Tab activation. Applies an ``active-tab`` class to the
1276
- ``section.tab`` whose id matches the URL hash (defaulting to
1277
- the first section when no hash is set) and to the
1278
- corresponding ``nav.tabs a``. Runs on initial load, on every
1279
- click into a tab link (so the operator gets immediate
1280
- feedback before the next htmx tick), and on every
1281
- ``htmx:afterSettle`` so the class survives the 1 Hz
1282
- innerHTML replacement of ``#dash``. Without this the
1283
- previous ``:target``-based CSS would snap the operator back
1284
- to the first tab within a second of any click. -->
1753
+ <!-- Tab activation: apply .active-tab to the section (inside
1754
+ #dash) AND to the matching subnav pill (outside #dash) whose
1755
+ id/hash matches the current URL hash. Runs on hashchange, on
1756
+ click into a tab link, and on every htmx:afterSettle so the
1757
+ class survives the 1 Hz innerHTML replacement of #dash.
1758
+ Subnav pills live outside #dash so the JS-applied class on
1759
+ them is not touched by the swap; sections inside #dash also
1760
+ get the class baked server-side (via X-Active-Tab) so there
1761
+ is no visible flicker between swap and JS re-apply. -->
1285
1762
  <script>
1286
1763
  (function () {{
1287
1764
  function applyActiveTab() {{
@@ -1293,7 +1770,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
1293
1770
  sections.forEach(function (s) {{
1294
1771
  s.classList.toggle('active-tab', s.id === hash);
1295
1772
  }});
1296
- document.querySelectorAll('#dash nav.tabs a').forEach(function (a) {{
1773
+ document.querySelectorAll('.subnav-strip .nav-pills .nav-link').forEach(function (a) {{
1297
1774
  var target = (a.getAttribute('href') || '').replace(/^#/, '');
1298
1775
  a.classList.toggle('active-tab', target === hash);
1299
1776
  }});
@@ -1301,7 +1778,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
1301
1778
  window.addEventListener('hashchange', applyActiveTab);
1302
1779
  document.body.addEventListener('htmx:afterSettle', applyActiveTab);
1303
1780
  document.addEventListener('click', function (ev) {{
1304
- var a = ev.target.closest && ev.target.closest('#dash nav.tabs a');
1781
+ var a = ev.target.closest && ev.target.closest('.subnav-strip .nav-pills .nav-link');
1305
1782
  if (a) setTimeout(applyActiveTab, 0);
1306
1783
  }});
1307
1784
  applyActiveTab();
@@ -1317,7 +1794,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
1317
1794
  # 1 Hz refresh (and on every operator click via the same
1318
1795
  # script that watches hashchange). Unknown / empty value
1319
1796
  # defaults to the first tab.
1320
- _TAB_IDS = ("tab-cached", "tab-streams", "tab-downloads", "tab-misses")
1797
+ _TAB_IDS = ("tab-cached", "tab-streams", "tab-downloads", "tab-misses", "tab-catalog")
1321
1798
  if active_tab not in _TAB_IDS:
1322
1799
  active_tab = _TAB_IDS[0]
1323
1800
 
@@ -1442,24 +1919,22 @@ class Handler(http.server.BaseHTTPRequestHandler):
1442
1919
  njobs = len(jobs)
1443
1920
 
1444
1921
  catalog_rows = self._catalog_rows()
1922
+ ncatalog = len(self.catalog.entries)
1445
1923
 
1924
+ # The per-tab count summary lives here (inside the htmx-swapped
1925
+ # region) so it updates every second. Tab labels themselves
1926
+ # live in the static sub-nav strip in ``render_page`` per bty
1927
+ # convention -- labels are chrome, counts are content.
1446
1928
  return f"""
1447
- <p class="text-muted small mb-2">{nblobs} cached ({used}){full}
1448
- &middot; {nmisses} pending miss(es)</p>
1449
- <nav class="tabs border-bottom mb-2">
1450
- <ul class="nav">
1451
- <li class="nav-item"><a class="nav-link {_active("tab-cached").lstrip()}"
1452
- href="#tab-cached">Cached ({nblobs})</a></li>
1453
- <li class="nav-item"><a class="nav-link {_active("tab-streams").lstrip()}"
1454
- href="#tab-streams">Streams ({nstreams})</a></li>
1455
- <li class="nav-item"><a class="nav-link {_active("tab-downloads").lstrip()}"
1456
- href="#tab-downloads">Downloads ({njobs})</a></li>
1457
- <li class="nav-item"><a class="nav-link {_active("tab-misses").lstrip()}"
1458
- href="#tab-misses">Misses ({nmisses})</a></li>
1459
- <li class="nav-item"><a class="nav-link {_active("tab-catalog").lstrip()}"
1460
- href="#tab-catalog">Catalog ({len(self.catalog.entries)})</a></li>
1461
- </ul>
1462
- </nav>
1929
+ <p class="text-muted small mb-3">
1930
+ <span class="badge bg-primary bg-opacity-10 text-primary me-1">{nblobs} cached ({used}){
1931
+ full
1932
+ }</span>
1933
+ <span class="badge bg-secondary bg-opacity-10 text-secondary me-1">{nstreams} streams</span>
1934
+ <span class="badge bg-secondary bg-opacity-10 text-secondary me-1">{njobs} downloads</span>
1935
+ <span class="badge bg-secondary bg-opacity-10 text-secondary me-1">{nmisses} misses</span>
1936
+ <span class="badge bg-secondary bg-opacity-10 text-secondary">{ncatalog} catalog</span>
1937
+ </p>
1463
1938
 
1464
1939
  <section id="tab-cached" class="tab{_active("tab-cached")}">
1465
1940
  <div class="table-responsive"><table class="table table-sm table-striped table-hover mb-0">
@@ -1506,29 +1981,10 @@ class Handler(http.server.BaseHTTPRequestHandler):
1506
1981
  </section>
1507
1982
 
1508
1983
  <section id="tab-catalog" class="tab{_active("tab-catalog")}">
1509
- <div class="d-flex align-items-center justify-content-between mb-2 flex-wrap gap-2">
1510
- <div>
1511
- <div><small class="text-muted">source</small>
1512
- &nbsp;<code>{html.escape(self.catalog.url)}</code></div>
1513
- <div><small class="text-muted">fetched</small>
1514
- &nbsp;<code>{html.escape(self.catalog.fetched_at) or "never"}</code>
1515
- {
1516
- (
1517
- f'<span class="badge bg-danger-subtle text-danger ms-1">'
1518
- f"{html.escape(self.catalog.last_error)}</span>"
1519
- )
1520
- if self.catalog.last_error
1521
- else ""
1522
- }</div>
1523
- </div>
1524
- <form hx-post="/admin/catalog_refresh" hx-target="#dash" hx-swap="innerHTML"
1525
- hx-indicator="#spin" class="m-0">
1526
- <button class="btn btn-sm btn-primary" type="submit">Refresh</button>
1527
- </form>
1528
- </div>
1984
+ {self._catalog_controls_html()}
1529
1985
  <div class="table-responsive"><table class="table table-sm table-striped table-hover mb-0">
1530
1986
  <thead class="table-light"><tr>
1531
- <th>Name</th><th>Format</th><th>Arch</th><th>Size</th><th>Source</th>
1987
+ <th>Name</th><th>Format</th><th>Arch</th><th>Size</th><th>Source</th><th></th>
1532
1988
  </tr></thead>
1533
1989
  <tbody>{catalog_rows}</tbody>
1534
1990
  </table></div>
@@ -1536,15 +1992,20 @@ class Handler(http.server.BaseHTTPRequestHandler):
1536
1992
 
1537
1993
  def _catalog_rows(self) -> str:
1538
1994
  """One <tr> per catalog image. Cells: name, format, arch,
1539
- human-readable size, and a Source cell whose URL is the
1540
- withcache /b/ token (so a click through pulls via cache).
1541
- Empty catalog rows to a "not fetched yet" placeholder + a
1542
- hint about the Refresh button."""
1995
+ human-readable size, a Source cell whose URL is the
1996
+ withcache /b/ token (so a click through pulls via cache),
1997
+ and a Delete button that removes the entry via
1998
+ ``/admin/catalog_delete_entry``. Empty catalog rows to a
1999
+ "not fetched yet" placeholder + a hint about the Refresh
2000
+ or Fetch controls above."""
1543
2001
  entries = self.catalog.entries
1544
2002
  if not entries:
1545
- hint = self.catalog.last_error or "click Refresh above to fetch the catalog manifest"
2003
+ hint = (
2004
+ self.catalog.last_error
2005
+ or "click Fetch / Refresh above, or upload a catalog.toml, to populate."
2006
+ )
1546
2007
  return (
1547
- '<tr><td colspan="5" class="text-center text-muted">'
2008
+ '<tr><td colspan="6" class="text-center text-muted">'
1548
2009
  f"<em>{html.escape(hint)}</em></td></tr>"
1549
2010
  )
1550
2011
  rows: list[str] = []
@@ -1566,6 +2027,13 @@ class Handler(http.server.BaseHTTPRequestHandler):
1566
2027
  src_cell = f'<a href="{link}" title="{html.escape(src)}">{html.escape(src)}</a>'
1567
2028
  else:
1568
2029
  src_cell = html.escape(src)
2030
+ delete_cell = (
2031
+ '<form hx-post="/admin/catalog_delete_entry" hx-target="#dash" '
2032
+ 'hx-swap="innerHTML" hx-confirm="Delete this catalog entry?" class="m-0">'
2033
+ f'<input type="hidden" name="name" value="{html.escape(name, quote=True)}">'
2034
+ '<button class="btn btn-sm btn-outline-danger" type="submit">Delete</button>'
2035
+ "</form>"
2036
+ )
1569
2037
  rows.append(
1570
2038
  f"<tr>"
1571
2039
  f"<td>{html.escape(name)}</td>"
@@ -1573,10 +2041,103 @@ class Handler(http.server.BaseHTTPRequestHandler):
1573
2041
  f'<td class="mono"><small>{html.escape(arch)}</small></td>'
1574
2042
  f"<td>{html.escape(size)}</td>"
1575
2043
  f'<td class="url">{src_cell}</td>'
2044
+ f"<td>{delete_cell}</td>"
1576
2045
  "</tr>"
1577
2046
  )
1578
2047
  return "".join(rows)
1579
2048
 
2049
+ def _catalog_controls_html(self) -> str:
2050
+ """The top of the Catalog tab: source + fetched-at metadata,
2051
+ the operator control forms (set URL / upload catalog.toml /
2052
+ add single entry), and the outcome banner (last_info or
2053
+ last_error). Split out so ``render_dash`` stays readable."""
2054
+ catalog = self.catalog
2055
+ env_pinned = bool(catalog.env_url)
2056
+ # Outcome banner: preferred order is error (red) > info (green).
2057
+ # Both are transient (overwritten on the next admin action).
2058
+ banner = ""
2059
+ if catalog.last_error:
2060
+ banner = (
2061
+ '<div class="alert alert-danger py-1 px-2 mb-2 small">'
2062
+ f'<i class="bi bi-exclamation-triangle-fill me-1"></i>'
2063
+ f"{html.escape(catalog.last_error)}</div>"
2064
+ )
2065
+ elif catalog.last_info:
2066
+ banner = (
2067
+ '<div class="alert alert-success py-1 px-2 mb-2 small">'
2068
+ f'<i class="bi bi-check-circle-fill me-1"></i>'
2069
+ f"{html.escape(catalog.last_info)}</div>"
2070
+ )
2071
+ url_input_attrs = (
2072
+ 'disabled title="pinned by env WITHCACHE_CATALOG_URL"' if env_pinned else ""
2073
+ )
2074
+ env_hint = (
2075
+ '<span class="badge bg-secondary bg-opacity-10 text-secondary ms-1">env-pinned</span>'
2076
+ if env_pinned
2077
+ else ""
2078
+ )
2079
+ return f"""
2080
+ <div class="row g-2 mb-2">
2081
+ <div class="col-md-6">
2082
+ <div class="d-flex flex-column">
2083
+ <label class="form-label mb-1 small text-muted">Catalog URL {env_hint}</label>
2084
+ <form hx-post="/admin/catalog_set_url" hx-target="#dash" hx-swap="innerHTML"
2085
+ hx-indicator="#spin" class="m-0 d-flex align-items-center gap-1">
2086
+ <input class="form-control form-control-sm" type="url" name="url"
2087
+ value="{html.escape(catalog.url, quote=True)}" required {url_input_attrs}>
2088
+ <button class="btn btn-sm btn-primary" type="submit"
2089
+ {url_input_attrs}>Set &amp; fetch</button>
2090
+ <button class="btn btn-sm btn-outline-secondary" type="button"
2091
+ hx-post="/admin/catalog_refresh" hx-target="#dash" hx-swap="innerHTML"
2092
+ hx-indicator="#spin" title="Refetch from current URL">Refresh</button>
2093
+ </form>
2094
+ </div>
2095
+ </div>
2096
+ <div class="col-md-6">
2097
+ <label class="form-label mb-1 small text-muted">Upload catalog.toml</label>
2098
+ <form hx-post="/admin/catalog_upload" hx-target="#dash" hx-swap="innerHTML"
2099
+ hx-encoding="multipart/form-data" hx-indicator="#spin"
2100
+ class="m-0 d-flex align-items-center gap-1">
2101
+ <input class="form-control form-control-sm" type="file" name="catalog"
2102
+ accept=".toml,text/plain" required>
2103
+ <button class="btn btn-sm btn-primary" type="submit">Upload</button>
2104
+ </form>
2105
+ </div>
2106
+ </div>
2107
+ <div class="d-flex flex-wrap align-items-end gap-2 mb-2">
2108
+ <div>
2109
+ <label class="form-label mb-0 small text-muted">Last fetched</label>
2110
+ <div><code>{html.escape(catalog.fetched_at) or "never"}</code>
2111
+ &middot; {len(catalog.entries)} entries</div>
2112
+ </div>
2113
+ <form hx-post="/admin/catalog_add_entry" hx-target="#dash" hx-swap="innerHTML"
2114
+ hx-indicator="#spin" hx-on::after-request="this.reset()"
2115
+ class="m-0 ms-auto d-flex align-items-end flex-wrap gap-1">
2116
+ <div>
2117
+ <label class="form-label mb-0 small text-muted">name</label>
2118
+ <input class="form-control form-control-sm" name="name"
2119
+ style="width: 10rem;" placeholder="debian-13" required>
2120
+ </div>
2121
+ <div>
2122
+ <label class="form-label mb-0 small text-muted">src</label>
2123
+ <input class="form-control form-control-sm" name="src"
2124
+ style="width: 16rem;" placeholder="https://.../image.img.zst" required>
2125
+ </div>
2126
+ <div>
2127
+ <label class="form-label mb-0 small text-muted">format</label>
2128
+ <input class="form-control form-control-sm" name="format"
2129
+ style="width: 7rem;" placeholder="img.zst">
2130
+ </div>
2131
+ <div>
2132
+ <label class="form-label mb-0 small text-muted">arch</label>
2133
+ <input class="form-control form-control-sm" name="arch"
2134
+ style="width: 6rem;" placeholder="x86_64">
2135
+ </div>
2136
+ <button class="btn btn-sm btn-primary" type="submit">Add entry</button>
2137
+ </form>
2138
+ </div>
2139
+ {banner}"""
2140
+
1580
2141
  def _stream_progress_cell(self, s: Stream) -> str:
1581
2142
  """One progress cell for an active stream: a <progress> bar when the
1582
2143
  total is known (always for a cached blob, since the size came off
@@ -1669,13 +2230,18 @@ def main():
1669
2230
  httpd.mgr = mgr # type: ignore[attr-defined]
1670
2231
  httpd.auto_fetch = not args.curate # type: ignore[attr-defined]
1671
2232
  httpd.streams = StreamRegistry() # type: ignore[attr-defined]
1672
- # Catalog state: env override, else the shipping default (nosi's
1673
- # rolling catalog manifest). Seeded from the last persisted
1674
- # catalog.toml on disk so a restart doesn't wipe the cache.
1675
- catalog_url = (os.environ.get("WITHCACHE_CATALOG_URL") or DEFAULT_CATALOG_URL).strip()
2233
+ # Catalog state: env pin wins over everything, then the operator
2234
+ # override on disk (set via /admin/catalog_set_url), then the
2235
+ # shipping default (nosi's rolling catalog manifest). Seeded from
2236
+ # the last persisted catalog.toml on disk so a restart doesn't
2237
+ # wipe the cache.
2238
+ env_catalog_url = (os.environ.get("WITHCACHE_CATALOG_URL") or "").strip()
2239
+ catalog_url = env_catalog_url or DEFAULT_CATALOG_URL
1676
2240
  catalog = CatalogState(
1677
2241
  url=catalog_url,
1678
2242
  persist_path=os.path.join(store.data_dir, "catalog.toml"),
2243
+ env_url=env_catalog_url,
2244
+ url_override_path=os.path.join(store.data_dir, "catalog_url"),
1679
2245
  )
1680
2246
  catalog.load_persisted()
1681
2247
  httpd.catalog = catalog # type: ignore[attr-defined]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes