withcache 0.12.0__tar.gz → 0.13.0__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 (49) hide show
  1. {withcache-0.12.0 → withcache-0.13.0}/PKG-INFO +1 -1
  2. {withcache-0.12.0 → withcache-0.13.0}/shim/build.zig.zon +1 -1
  3. {withcache-0.12.0 → withcache-0.13.0}/src/withcache/__init__.py +1 -1
  4. {withcache-0.12.0 → withcache-0.13.0}/src/withcache/_api.py +22 -1
  5. {withcache-0.12.0 → withcache-0.13.0}/src/withcache/_app.py +266 -60
  6. withcache-0.13.0/src/withcache/_events_log.py +277 -0
  7. {withcache-0.12.0 → withcache-0.13.0}/src/withcache/_templates/ui/_layout.html +3 -0
  8. {withcache-0.12.0 → withcache-0.13.0}/src/withcache/_templates/ui/dashboard.html +35 -17
  9. withcache-0.13.0/src/withcache/_templates/ui/events.html +125 -0
  10. {withcache-0.12.0 → withcache-0.13.0}/src/withcache/server.py +86 -7
  11. withcache-0.13.0/tests/test_fastapi_events.py +205 -0
  12. {withcache-0.12.0 → withcache-0.13.0}/tests/test_fastapi_ui_pages.py +4 -3
  13. {withcache-0.12.0 → withcache-0.13.0}/.gitignore +0 -0
  14. {withcache-0.12.0 → withcache-0.13.0}/LICENSE +0 -0
  15. {withcache-0.12.0 → withcache-0.13.0}/README.md +0 -0
  16. {withcache-0.12.0 → withcache-0.13.0}/deploy/Containerfile +0 -0
  17. {withcache-0.12.0 → withcache-0.13.0}/deploy/compose.local-build.yml +0 -0
  18. {withcache-0.12.0 → withcache-0.13.0}/deploy/compose.yml +0 -0
  19. {withcache-0.12.0 → withcache-0.13.0}/deploy/envvars.example +0 -0
  20. {withcache-0.12.0 → withcache-0.13.0}/hatch_build.py +0 -0
  21. {withcache-0.12.0 → withcache-0.13.0}/pyproject.toml +0 -0
  22. {withcache-0.12.0 → withcache-0.13.0}/shim/build.zig +0 -0
  23. {withcache-0.12.0 → withcache-0.13.0}/shim/shim.zig +0 -0
  24. {withcache-0.12.0 → withcache-0.13.0}/src/withcache/_settings_store.py +0 -0
  25. {withcache-0.12.0 → withcache-0.13.0}/src/withcache/_shim.py +0 -0
  26. {withcache-0.12.0 → withcache-0.13.0}/src/withcache/_table_state.py +0 -0
  27. {withcache-0.12.0 → withcache-0.13.0}/src/withcache/_templates/ui/_table_macros.html +0 -0
  28. {withcache-0.12.0 → withcache-0.13.0}/src/withcache/_templates/ui/catalog.html +0 -0
  29. {withcache-0.12.0 → withcache-0.13.0}/src/withcache/_templates/ui/login.html +0 -0
  30. {withcache-0.12.0 → withcache-0.13.0}/src/withcache/_templates/ui/misses.html +0 -0
  31. {withcache-0.12.0 → withcache-0.13.0}/src/withcache/_templates/ui/settings.html +0 -0
  32. {withcache-0.12.0 → withcache-0.13.0}/src/withcache/client.py +0 -0
  33. {withcache-0.12.0 → withcache-0.13.0}/src/withcache/curlwithcache.py +0 -0
  34. {withcache-0.12.0 → withcache-0.13.0}/src/withcache/oras.py +0 -0
  35. {withcache-0.12.0 → withcache-0.13.0}/src/withcache/static/bootstrap-icons.min.css +0 -0
  36. {withcache-0.12.0 → withcache-0.13.0}/src/withcache/static/bootstrap.min.css +0 -0
  37. {withcache-0.12.0 → withcache-0.13.0}/src/withcache/static/fonts/bootstrap-icons.woff +0 -0
  38. {withcache-0.12.0 → withcache-0.13.0}/src/withcache/static/fonts/bootstrap-icons.woff2 +0 -0
  39. {withcache-0.12.0 → withcache-0.13.0}/src/withcache/static/htmx.min.js +0 -0
  40. {withcache-0.12.0 → withcache-0.13.0}/src/withcache/wgetwithcache.py +0 -0
  41. {withcache-0.12.0 → withcache-0.13.0}/tests/test_differential.py +0 -0
  42. {withcache-0.12.0 → withcache-0.13.0}/tests/test_fastapi_admin_forms.py +0 -0
  43. {withcache-0.12.0 → withcache-0.13.0}/tests/test_fastapi_blob.py +0 -0
  44. {withcache-0.12.0 → withcache-0.13.0}/tests/test_fastapi_catalog_api.py +0 -0
  45. {withcache-0.12.0 → withcache-0.13.0}/tests/test_fastapi_scaffold.py +0 -0
  46. {withcache-0.12.0 → withcache-0.13.0}/tests/test_fastapi_settings_persistence.py +0 -0
  47. {withcache-0.12.0 → withcache-0.13.0}/tests/test_fastapi_uvicorn_smoke.py +0 -0
  48. {withcache-0.12.0 → withcache-0.13.0}/tests/test_oras.py +0 -0
  49. {withcache-0.12.0 → withcache-0.13.0}/tests/test_withcache.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: withcache
3
- Version: 0.12.0
3
+ Version: 0.13.0
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.12.0",
5
+ .version = "0.13.0",
6
6
  .fingerprint = 0xd7d96c5ed212ccaa,
7
7
  .minimum_zig_version = "0.16.0",
8
8
  .paths = .{
@@ -22,6 +22,6 @@ inherit the framework floor.
22
22
  from . import oras
23
23
  from .client import blob_url, cache_base, is_cached, serve_url
24
24
 
25
- __version__ = "0.12.0"
25
+ __version__ = "0.13.0"
26
26
 
27
27
  __all__ = ["__version__", "blob_url", "cache_base", "is_cached", "oras", "serve_url"]
@@ -30,6 +30,7 @@ Downloads page shows in-flight transfers as they run.
30
30
  from __future__ import annotations
31
31
 
32
32
  import base64
33
+ import contextlib
33
34
  import urllib.parse
34
35
  from collections.abc import Iterator
35
36
  from typing import Any
@@ -88,7 +89,27 @@ def _serve_blob(
88
89
  row = None
89
90
 
90
91
  if row is None:
91
- store.record_miss(url)
92
+ fresh = store.record_miss(url)
93
+ if fresh:
94
+ # First miss for this URL: emit an audit event so the
95
+ # operator sees it on /ui/events. Subsequent misses for
96
+ # the same URL bump the counter without re-emitting.
97
+ from . import _events_log
98
+
99
+ with contextlib.suppress(Exception):
100
+ with store.conn() as ev_conn:
101
+ _events_log.record(
102
+ ev_conn,
103
+ kind="blob.miss.recorded",
104
+ summary=f"First cache miss for {url}",
105
+ subject_kind="blob",
106
+ subject_id=url,
107
+ actor="client",
108
+ source_ip=_events_log.normalize_ip(
109
+ request.client.host if request.client else None
110
+ ),
111
+ )
112
+ ev_conn.commit()
92
113
  return PlainTextResponse(
93
114
  "cache miss: this URL hasn't been downloaded yet. "
94
115
  "Pick the matching catalog entry on /ui/catalog and hit Download.\n",
@@ -30,7 +30,7 @@ from fastapi.staticfiles import StaticFiles
30
30
  from jinja2 import Environment, FileSystemLoader
31
31
  from starlette.middleware.sessions import SessionMiddleware
32
32
 
33
- from . import __version__, _settings_store, _table_state
33
+ from . import __version__, _events_log, _settings_store, _table_state
34
34
  from ._api import _persist_catalog, register_api_routes
35
35
  from .server import (
36
36
  DEFAULT_CATALOG_URL,
@@ -196,13 +196,47 @@ def create_app(
196
196
  )
197
197
  cs.load_persisted()
198
198
  app.state.catalog = cs
199
- # Ensure the settings table exists so the Settings render + save
200
- # handlers don't crash on a fresh cache.db.
199
+ # Ensure the settings + events tables exist so the Settings render
200
+ # + operator actions don't crash on a fresh cache.db.
201
201
  with app.state.store.conn() as _c:
202
202
  _settings_store.init(_c)
203
+ _events_log.init(_c)
203
204
 
204
205
  register_api_routes(app)
205
206
 
207
+ def _emit(
208
+ *,
209
+ kind: str,
210
+ summary: str,
211
+ request: Request | None = None,
212
+ subject_kind: str | None = None,
213
+ subject_id: str | None = None,
214
+ actor: str | None = "operator",
215
+ details: dict[str, Any] | None = None,
216
+ ) -> None:
217
+ """One-shot events emitter used by the UI action handlers.
218
+ Opens its own connection, records the event, commits, closes.
219
+ Never raises: any error is swallowed so a bad emit can't
220
+ break the request that produced it."""
221
+ try:
222
+ client_host = None
223
+ if request is not None and request.client is not None:
224
+ client_host = _events_log.normalize_ip(request.client.host)
225
+ with app.state.store.conn() as conn:
226
+ _events_log.record(
227
+ conn,
228
+ kind=kind,
229
+ summary=summary,
230
+ subject_kind=subject_kind,
231
+ subject_id=subject_id,
232
+ actor=actor,
233
+ source_ip=client_host,
234
+ details=details,
235
+ )
236
+ conn.commit()
237
+ except Exception: # noqa: BLE001 -- emit is best-effort
238
+ pass
239
+
206
240
  def render(name: str, request: Request, **ctx: Any) -> HTMLResponse:
207
241
  """Render a Jinja template + always-injected context.
208
242
  Same pattern as :func:`nbdmux._app.render`."""
@@ -253,13 +287,34 @@ def create_app(
253
287
  @app.post("/ui/login")
254
288
  def ui_login_submit(request: Request, password: str = Form(...)) -> Any:
255
289
  if not auth.check_password(password):
290
+ _emit(
291
+ kind="auth.login.failed",
292
+ summary="Login attempt with wrong password",
293
+ request=request,
294
+ subject_kind="auth",
295
+ actor="operator",
296
+ )
256
297
  return render("ui/login.html", request, error="Invalid password.")
257
298
  request.session[SESSION_AUTHED_KEY] = True
299
+ _emit(
300
+ kind="auth.login.succeeded",
301
+ summary="Operator logged in",
302
+ request=request,
303
+ subject_kind="auth",
304
+ actor="operator",
305
+ )
258
306
  return RedirectResponse(url="/ui/dashboard", status_code=status.HTTP_303_SEE_OTHER)
259
307
 
260
308
  @app.post("/ui/logout")
261
309
  def ui_logout(request: Request) -> RedirectResponse:
262
310
  request.session.clear()
311
+ _emit(
312
+ kind="auth.logout",
313
+ summary="Operator logged out",
314
+ request=request,
315
+ subject_kind="auth",
316
+ actor="operator",
317
+ )
263
318
  return RedirectResponse(url="/ui/login", status_code=status.HTTP_303_SEE_OTHER)
264
319
 
265
320
  # ---------- Root redirect + operator UI pages -----------------------
@@ -291,7 +346,6 @@ def create_app(
291
346
  jobs = mgr.list() if hasattr(mgr, "list") else []
292
347
  active_jobs = sum(1 for j in jobs if j.status in ("queued", "running"))
293
348
  failed_jobs = sum(1 for j in jobs if j.status == "failed")
294
- recent_misses = list(store.list_misses())[:5]
295
349
 
296
350
  sanity: list[dict[str, Any]] = []
297
351
  env_url = (os.environ.get("WITHCACHE_CATALOG_URL") or "").strip()
@@ -342,6 +396,23 @@ def create_app(
342
396
  }
343
397
  )
344
398
 
399
+ with store.conn() as _c:
400
+ recent_events = _events_log.list_recent(_c)
401
+ unack_failures = _events_log.count_unacknowledged_failures(_c)
402
+ if unack_failures:
403
+ sanity.append(
404
+ {
405
+ "label": "Unacknowledged failures",
406
+ "ok": False,
407
+ "info": False,
408
+ "detail": (
409
+ f"{unack_failures} failure event"
410
+ f"{'s' if unack_failures != 1 else ''} not yet acknowledged"
411
+ ),
412
+ "href": "/ui/events?q=failed",
413
+ "fix_href": "/ui/events?q=failed",
414
+ }
415
+ )
345
416
  return render(
346
417
  "ui/dashboard.html",
347
418
  request,
@@ -353,11 +424,70 @@ def create_app(
353
424
  miss_count=miss_count,
354
425
  active_jobs=active_jobs,
355
426
  failed_jobs=failed_jobs,
356
- recent_misses=recent_misses,
427
+ recent_events=recent_events,
428
+ recent_events_limit=_events_log.RECENT_EVENTS_LIMIT,
357
429
  sanity=sanity,
358
430
  catalog_url=cs.url,
359
431
  )
360
432
 
433
+ @app.get("/ui/events", response_class=HTMLResponse)
434
+ def ui_events(
435
+ request: Request,
436
+ q: str = "",
437
+ page: int = 1,
438
+ per_page: int = 25,
439
+ _auth_check: None = Depends(require_ui_auth),
440
+ ) -> HTMLResponse:
441
+ """Slim audit log view: newest-first, free-text filter,
442
+ per-page pagination. Same shape as bty's /ui/events."""
443
+ needle = (q or "").strip()
444
+ clamped_per_page = (
445
+ per_page if per_page in _table_state.PER_PAGE_CHOICES else _table_state.DEFAULT_PER_PAGE
446
+ )
447
+ clamped_page = max(1, page)
448
+ with app.state.store.conn() as conn:
449
+ total = _events_log.count_events(conn, q=needle)
450
+ page_state = _table_state.parse_pagination(
451
+ {"page": str(clamped_page), "per_page": str(clamped_per_page)},
452
+ total=total,
453
+ )
454
+ events = _events_log.search_events(
455
+ conn,
456
+ q=needle,
457
+ offset=page_state.offset,
458
+ limit=page_state.per_page,
459
+ )
460
+ preserved = {
461
+ "q": needle or None,
462
+ "per_page": (
463
+ str(page_state.per_page)
464
+ if page_state.per_page != _table_state.DEFAULT_PER_PAGE
465
+ else None
466
+ ),
467
+ }
468
+ return render(
469
+ "ui/events.html",
470
+ request,
471
+ nav_active="events",
472
+ events=events,
473
+ q=needle,
474
+ page=page_state,
475
+ preserved=preserved,
476
+ )
477
+
478
+ @app.post("/admin/events/{event_id}/ack")
479
+ def ui_admin_ack_event(
480
+ event_id: int,
481
+ _auth_check: None = Depends(require_ui_auth),
482
+ ) -> RedirectResponse:
483
+ """Mark one event acknowledged. Clears it from the
484
+ dashboard's unacknowledged-failures tripwire without
485
+ deleting the row."""
486
+ with app.state.store.conn() as conn:
487
+ _events_log.set_acknowledged(conn, event_id, True)
488
+ conn.commit()
489
+ return RedirectResponse(url="/ui/events", status_code=status.HTTP_303_SEE_OTHER)
490
+
361
491
  def _latest_job_by_url(mgr_jobs: list[Any]) -> dict[str, Any]:
362
492
  """Reduce ``mgr.list()`` to the newest job per URL. Used by the
363
493
  Catalog page to surface the last download's status + progress
@@ -560,19 +690,11 @@ def create_app(
560
690
 
561
691
  @app.post("/admin/settings/logging")
562
692
  def ui_admin_settings_logging(
693
+ request: Request,
563
694
  log_level: str = Form(""),
564
695
  _auth_check: None = Depends(require_ui_auth),
565
696
  ) -> RedirectResponse:
566
- """Persist the Logging card's log-level override. Empty
567
- submits clear the row so the resolver falls through to env /
568
- default. Invalid values 303 back with ``?error=<msg>`` and
569
- DO NOT persist -- rejecting at write time keeps the failure
570
- loud rather than deferring it to the next resolve.
571
-
572
- Syncs ``os.environ[WITHCACHE_LOG_LEVEL]`` at save time so
573
- code that reads the env var picks up the change without a
574
- restart; a cleared override restores whatever env value was
575
- present at process start (captured on first save)."""
697
+ """Persist the Logging card's log-level override."""
576
698
  import urllib.parse
577
699
 
578
700
  ll = (log_level or "").strip().lower()
@@ -588,6 +710,14 @@ def create_app(
588
710
  os.environ[_settings_store.ENV_LOG_LEVEL] = ll
589
711
  else:
590
712
  _settings_store.clear(conn, _settings_store.KEY_LOG_LEVEL)
713
+ _emit(
714
+ kind="settings.logging.updated",
715
+ summary=f"Log level set to {ll or '(cleared)'}",
716
+ request=request,
717
+ subject_kind="settings",
718
+ subject_id="logging",
719
+ details={"log_level": ll or None},
720
+ )
591
721
  return RedirectResponse(
592
722
  url="/ui/settings?saved=logging#logging",
593
723
  status_code=status.HTTP_303_SEE_OTHER,
@@ -644,113 +774,162 @@ def create_app(
644
774
 
645
775
  @app.post("/admin/fetch")
646
776
  def ui_admin_fetch(
777
+ request: Request,
647
778
  url: str = Form(""),
648
779
  header: str = Form(""),
649
780
  _auth_check: None = Depends(require_ui_auth),
650
781
  ) -> RedirectResponse:
651
782
  """Promote a URL to a first-class catalog entry AND enqueue
652
783
  its download in one click. Bound to the ``Fetch`` button on
653
- the ``/ui/misses`` page: the operator sees a URL clients
654
- keep asking for, hits Fetch, and the URL becomes a normal
655
- catalog entry that trio consumers (bty, nbdmux) can see as
656
- soon as its bytes land.
657
-
658
- Optional ``header`` carries a curated authorization payload
659
- parsed by :func:`withcache.server.parse_headers` so a
660
- token-gated origin can be fetched. No-op with a redirect
661
- when the URL is blank."""
784
+ the ``/ui/misses`` page."""
662
785
  from .server import parse_headers
663
786
 
664
787
  u = (url or "").strip()
665
788
  if not u:
666
789
  return RedirectResponse(url="/ui/misses", status_code=status.HTTP_303_SEE_OTHER)
667
- _promote_url_to_catalog(u)
790
+ added = _promote_url_to_catalog(u)
668
791
  app.state.mgr.enqueue(u, headers=parse_headers(header or ""))
792
+ if added is not None:
793
+ _emit(
794
+ kind="catalog.entry.added",
795
+ summary=f"Promoted miss {u} to catalog entry {added['name']}",
796
+ request=request,
797
+ subject_kind="catalog",
798
+ subject_id=added.get("name"),
799
+ details={"src": u, "via": "misses.fetch"},
800
+ )
801
+ _emit(
802
+ kind="catalog.entry.download.requested",
803
+ summary=f"Download requested for {u}",
804
+ request=request,
805
+ subject_kind="catalog",
806
+ subject_id=(added or {}).get("name") or u,
807
+ details={"src": u},
808
+ )
669
809
  return RedirectResponse(url="/ui/catalog", status_code=status.HTTP_303_SEE_OTHER)
670
810
 
671
811
  @app.post("/admin/dismiss")
672
812
  def ui_admin_dismiss(
813
+ request: Request,
673
814
  key: str = Form(""),
674
815
  _auth_check: None = Depends(require_ui_auth),
675
816
  ) -> RedirectResponse:
676
817
  k = (key or "").strip()
677
818
  if k:
678
819
  app.state.store.dismiss(k)
820
+ _emit(
821
+ kind="blob.miss.dismissed",
822
+ summary="Dismissed recorded miss",
823
+ request=request,
824
+ subject_kind="blob",
825
+ subject_id=k,
826
+ )
679
827
  return RedirectResponse(url="/ui/misses", status_code=status.HTTP_303_SEE_OTHER)
680
828
 
681
829
  @app.post("/admin/cancel_entry")
682
830
  def ui_admin_cancel_entry(
831
+ request: Request,
683
832
  name: str = Form(""),
684
833
  _auth_check: None = Depends(require_ui_auth),
685
834
  ) -> RedirectResponse:
686
835
  """Cancel the newest in-flight DownloadManager job for the
687
- named catalog entry. No-op when the entry is unknown or
688
- nothing is queued/running for its URL."""
836
+ named catalog entry."""
689
837
  cs: CatalogState = app.state.catalog
690
- entry = next((e for e in cs.entries if e.get("name") == (name or "").strip()), None)
838
+ entry_name = (name or "").strip()
839
+ entry = next((e for e in cs.entries if e.get("name") == entry_name), None)
691
840
  if entry is not None:
692
841
  fetch_url = entry.get("resolved_src") or entry.get("src") or ""
693
842
  for job in app.state.mgr.list():
694
843
  if job.url == fetch_url and job.status in ("queued", "running"):
695
844
  app.state.mgr.cancel(job.id)
845
+ _emit(
846
+ kind="catalog.entry.download.cancelled",
847
+ summary=f"Cancelled download of {entry_name}",
848
+ request=request,
849
+ subject_kind="catalog",
850
+ subject_id=entry_name,
851
+ details={"src": fetch_url, "job_id": job.id},
852
+ )
696
853
  break
697
854
  return RedirectResponse(url="/ui/catalog", status_code=status.HTTP_303_SEE_OTHER)
698
855
 
699
856
  @app.post("/admin/catalog_refresh")
700
857
  def ui_admin_catalog_refresh(
858
+ request: Request,
701
859
  next: str = Form("catalog"),
702
860
  _auth_check: None = Depends(require_ui_auth),
703
861
  ) -> RedirectResponse:
704
862
  """Re-fetch the currently-configured catalog source and
705
- re-parse entries. Callers pass ``next=catalog|settings`` so
706
- the 303 lands the operator back where they clicked."""
707
- app.state.catalog.fetch_now()
863
+ re-parse entries."""
864
+ cs: CatalogState = app.state.catalog
865
+ cs.fetch_now()
866
+ if cs.last_error:
867
+ _emit(
868
+ kind="catalog.refresh.failed",
869
+ summary=f"Catalog refresh failed: {cs.last_error}",
870
+ request=request,
871
+ subject_kind="catalog",
872
+ details={"url": cs.url, "error": cs.last_error},
873
+ )
874
+ else:
875
+ _emit(
876
+ kind="catalog.refreshed",
877
+ summary=f"Catalog refreshed from {cs.url}",
878
+ request=request,
879
+ subject_kind="catalog",
880
+ details={"url": cs.url, "entries": len(cs.entries)},
881
+ )
708
882
  target = "/ui/settings" if (next or "").strip() == "settings" else "/ui/catalog"
709
883
  return RedirectResponse(url=target, status_code=status.HTTP_303_SEE_OTHER)
710
884
 
711
885
  @app.post("/admin/catalog_set_url")
712
886
  def ui_admin_catalog_set_url(
887
+ request: Request,
713
888
  url: str = Form(""),
714
889
  _auth_check: None = Depends(require_ui_auth),
715
890
  ) -> RedirectResponse:
716
- """Persist an operator override for the catalog URL. Success
717
- triggers an immediate fetch so the entries table reflects the
718
- new source without a second click. Failure records the reason
719
- on ``catalog.last_error`` and the Settings page surfaces it.
720
- The form lives on ``/ui/settings`` since v0.12.0."""
891
+ """Persist an operator override for the catalog URL."""
721
892
  ok, msg = app.state.catalog.set_url_override(url or "")
722
893
  if ok:
723
894
  app.state.catalog.fetch_now()
895
+ _emit(
896
+ kind="catalog.source.updated",
897
+ summary=f"Catalog source URL set to {(url or '').strip() or '(cleared)'}",
898
+ request=request,
899
+ subject_kind="settings",
900
+ subject_id="catalog",
901
+ details={"url": (url or "").strip()},
902
+ )
724
903
  else:
725
904
  app.state.catalog.last_error = msg
726
905
  return RedirectResponse(url="/ui/settings", status_code=status.HTTP_303_SEE_OTHER)
727
906
 
728
907
  @app.post("/admin/catalog_add_oras")
729
908
  def ui_admin_catalog_add_oras(
909
+ request: Request,
730
910
  url: str = Form(""),
731
911
  _auth_check: None = Depends(require_ui_auth),
732
912
  ) -> RedirectResponse:
733
- app.state.catalog.add_oras_entry(url or "")
913
+ candidate = (url or "").strip()
914
+ ok, _msg = app.state.catalog.add_oras_entry(candidate)
915
+ if ok:
916
+ _emit(
917
+ kind="catalog.entry.added",
918
+ summary=f"Added ORAS entry {candidate}",
919
+ request=request,
920
+ subject_kind="catalog",
921
+ subject_id=candidate,
922
+ details={"src": candidate, "via": "catalog.add_oras"},
923
+ )
734
924
  return RedirectResponse(url="/ui/catalog", status_code=status.HTTP_303_SEE_OTHER)
735
925
 
736
926
  @app.post("/admin/catalog_add_entry")
737
927
  def ui_admin_catalog_add_entry(
928
+ request: Request,
738
929
  url: str = Form(""),
739
930
  _auth_check: None = Depends(require_ui_auth),
740
931
  ) -> RedirectResponse:
741
- """URL-only add-entry form for the Catalog subnav. Derives the
742
- catalog name from the URL basename (with a numeric collision
743
- suffix) and the ``format`` from a known compressor suffix
744
- when present. Empty submits are refused with a
745
- ``catalog.last_error`` note; other fields (sha256, arch,
746
- size, description) stay unset until the operator hits
747
- Download and the store row supplies size / hash. Same
748
- promote-a-URL helper the Misses page's Fetch button uses,
749
- minus the auto-download step.
750
-
751
- For ``oras://`` sources use ``/admin/catalog_add_oras``
752
- instead; the registry walk fills ``format`` + ``size``
753
- from the manifest."""
932
+ """URL-only add-entry form for the Catalog subnav."""
754
933
  cs: CatalogState = app.state.catalog
755
934
  u = (url or "").strip()
756
935
  if not u:
@@ -767,33 +946,47 @@ def create_app(
767
946
  cs.last_error = f"catalog already has an entry for {u}"
768
947
  else:
769
948
  cs.last_error = ""
949
+ _emit(
950
+ kind="catalog.entry.added",
951
+ summary=f"Added HTTPS entry {added['name']}",
952
+ request=request,
953
+ subject_kind="catalog",
954
+ subject_id=added["name"],
955
+ details={"src": u, "via": "catalog.add_entry"},
956
+ )
770
957
  return RedirectResponse(url="/ui/catalog", status_code=status.HTTP_303_SEE_OTHER)
771
958
 
772
959
  @app.post("/admin/catalog_delete_entry")
773
960
  def ui_admin_catalog_delete_entry(
961
+ request: Request,
774
962
  name: str = Form(""),
775
963
  _auth_check: None = Depends(require_ui_auth),
776
964
  ) -> RedirectResponse:
777
- ok, msg = app.state.catalog.delete_entry(name or "")
965
+ target = (name or "").strip()
966
+ ok, msg = app.state.catalog.delete_entry(target)
778
967
  if not ok:
779
968
  app.state.catalog.last_error = msg
969
+ else:
970
+ _emit(
971
+ kind="catalog.entry.deleted",
972
+ summary=f"Deleted catalog entry {target}",
973
+ request=request,
974
+ subject_kind="catalog",
975
+ subject_id=target,
976
+ )
780
977
  return RedirectResponse(url="/ui/catalog", status_code=status.HTTP_303_SEE_OTHER)
781
978
 
782
979
  @app.post("/admin/catalog_download_entry")
783
980
  def ui_admin_catalog_download_entry(
981
+ request: Request,
784
982
  name: str = Form(""),
785
983
  force: str = Form(""),
786
984
  _auth_check: None = Depends(require_ui_auth),
787
985
  ) -> RedirectResponse:
788
- """Form-encoded sibling of ``POST /catalog/entries/{name}/download``.
789
-
790
- Adds an enqueue for the named entry; a truthy ``force`` field
791
- drops any existing cached bytes first so the redownload
792
- replaces stale content instead of hitting the dedup-on-active
793
- branch in :class:`DownloadManager`.
794
- """
986
+ """Form-encoded sibling of ``POST /catalog/entries/{name}/download``."""
795
987
  cs: CatalogState = app.state.catalog
796
- entry = next((e for e in cs.entries if e.get("name") == (name or "").strip()), None)
988
+ target = (name or "").strip()
989
+ entry = next((e for e in cs.entries if e.get("name") == target), None)
797
990
  if entry is None:
798
991
  cs.last_error = f"no catalog entry with name={name!r}"
799
992
  return RedirectResponse(url="/ui/catalog", status_code=status.HTTP_303_SEE_OTHER)
@@ -801,12 +994,25 @@ def create_app(
801
994
  if not fetch_url:
802
995
  cs.last_error = f"catalog entry {name!r} has no ``src`` / ``resolved_src`` to fetch"
803
996
  return RedirectResponse(url="/ui/catalog", status_code=status.HTTP_303_SEE_OTHER)
804
- if force.strip().lower() in ("1", "true", "on", "yes"):
997
+ forced = force.strip().lower() in ("1", "true", "on", "yes")
998
+ if forced:
805
999
  existing = app.state.store.get_blob(fetch_url)
806
1000
  if existing is not None:
807
1001
  app.state.store.delete_blob(existing["key"])
808
1002
  app.state.mgr.enqueue(fetch_url)
809
1003
  cs.last_error = ""
1004
+ _emit(
1005
+ kind="catalog.entry.download.requested",
1006
+ summary=(
1007
+ f"Redownload requested for {target}"
1008
+ if forced
1009
+ else f"Download requested for {target}"
1010
+ ),
1011
+ request=request,
1012
+ subject_kind="catalog",
1013
+ subject_id=target,
1014
+ details={"src": fetch_url, "force": forced},
1015
+ )
810
1016
  return RedirectResponse(url="/ui/catalog", status_code=status.HTTP_303_SEE_OTHER)
811
1017
 
812
1018
  return app