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.
- {withcache-0.12.0 → withcache-0.13.0}/PKG-INFO +1 -1
- {withcache-0.12.0 → withcache-0.13.0}/shim/build.zig.zon +1 -1
- {withcache-0.12.0 → withcache-0.13.0}/src/withcache/__init__.py +1 -1
- {withcache-0.12.0 → withcache-0.13.0}/src/withcache/_api.py +22 -1
- {withcache-0.12.0 → withcache-0.13.0}/src/withcache/_app.py +266 -60
- withcache-0.13.0/src/withcache/_events_log.py +277 -0
- {withcache-0.12.0 → withcache-0.13.0}/src/withcache/_templates/ui/_layout.html +3 -0
- {withcache-0.12.0 → withcache-0.13.0}/src/withcache/_templates/ui/dashboard.html +35 -17
- withcache-0.13.0/src/withcache/_templates/ui/events.html +125 -0
- {withcache-0.12.0 → withcache-0.13.0}/src/withcache/server.py +86 -7
- withcache-0.13.0/tests/test_fastapi_events.py +205 -0
- {withcache-0.12.0 → withcache-0.13.0}/tests/test_fastapi_ui_pages.py +4 -3
- {withcache-0.12.0 → withcache-0.13.0}/.gitignore +0 -0
- {withcache-0.12.0 → withcache-0.13.0}/LICENSE +0 -0
- {withcache-0.12.0 → withcache-0.13.0}/README.md +0 -0
- {withcache-0.12.0 → withcache-0.13.0}/deploy/Containerfile +0 -0
- {withcache-0.12.0 → withcache-0.13.0}/deploy/compose.local-build.yml +0 -0
- {withcache-0.12.0 → withcache-0.13.0}/deploy/compose.yml +0 -0
- {withcache-0.12.0 → withcache-0.13.0}/deploy/envvars.example +0 -0
- {withcache-0.12.0 → withcache-0.13.0}/hatch_build.py +0 -0
- {withcache-0.12.0 → withcache-0.13.0}/pyproject.toml +0 -0
- {withcache-0.12.0 → withcache-0.13.0}/shim/build.zig +0 -0
- {withcache-0.12.0 → withcache-0.13.0}/shim/shim.zig +0 -0
- {withcache-0.12.0 → withcache-0.13.0}/src/withcache/_settings_store.py +0 -0
- {withcache-0.12.0 → withcache-0.13.0}/src/withcache/_shim.py +0 -0
- {withcache-0.12.0 → withcache-0.13.0}/src/withcache/_table_state.py +0 -0
- {withcache-0.12.0 → withcache-0.13.0}/src/withcache/_templates/ui/_table_macros.html +0 -0
- {withcache-0.12.0 → withcache-0.13.0}/src/withcache/_templates/ui/catalog.html +0 -0
- {withcache-0.12.0 → withcache-0.13.0}/src/withcache/_templates/ui/login.html +0 -0
- {withcache-0.12.0 → withcache-0.13.0}/src/withcache/_templates/ui/misses.html +0 -0
- {withcache-0.12.0 → withcache-0.13.0}/src/withcache/_templates/ui/settings.html +0 -0
- {withcache-0.12.0 → withcache-0.13.0}/src/withcache/client.py +0 -0
- {withcache-0.12.0 → withcache-0.13.0}/src/withcache/curlwithcache.py +0 -0
- {withcache-0.12.0 → withcache-0.13.0}/src/withcache/oras.py +0 -0
- {withcache-0.12.0 → withcache-0.13.0}/src/withcache/static/bootstrap-icons.min.css +0 -0
- {withcache-0.12.0 → withcache-0.13.0}/src/withcache/static/bootstrap.min.css +0 -0
- {withcache-0.12.0 → withcache-0.13.0}/src/withcache/static/fonts/bootstrap-icons.woff +0 -0
- {withcache-0.12.0 → withcache-0.13.0}/src/withcache/static/fonts/bootstrap-icons.woff2 +0 -0
- {withcache-0.12.0 → withcache-0.13.0}/src/withcache/static/htmx.min.js +0 -0
- {withcache-0.12.0 → withcache-0.13.0}/src/withcache/wgetwithcache.py +0 -0
- {withcache-0.12.0 → withcache-0.13.0}/tests/test_differential.py +0 -0
- {withcache-0.12.0 → withcache-0.13.0}/tests/test_fastapi_admin_forms.py +0 -0
- {withcache-0.12.0 → withcache-0.13.0}/tests/test_fastapi_blob.py +0 -0
- {withcache-0.12.0 → withcache-0.13.0}/tests/test_fastapi_catalog_api.py +0 -0
- {withcache-0.12.0 → withcache-0.13.0}/tests/test_fastapi_scaffold.py +0 -0
- {withcache-0.12.0 → withcache-0.13.0}/tests/test_fastapi_settings_persistence.py +0 -0
- {withcache-0.12.0 → withcache-0.13.0}/tests/test_fastapi_uvicorn_smoke.py +0 -0
- {withcache-0.12.0 → withcache-0.13.0}/tests/test_oras.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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
|
|
200
|
-
#
|
|
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
|
-
|
|
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.
|
|
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
|
|
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.
|
|
688
|
-
nothing is queued/running for its URL."""
|
|
836
|
+
named catalog entry."""
|
|
689
837
|
cs: CatalogState = app.state.catalog
|
|
690
|
-
|
|
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.
|
|
706
|
-
|
|
707
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|