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.
- {withcache-0.7.0 → withcache-0.7.2}/PKG-INFO +1 -1
- {withcache-0.7.0 → withcache-0.7.2}/shim/build.zig.zon +1 -1
- {withcache-0.7.0 → withcache-0.7.2}/src/withcache/__init__.py +1 -1
- {withcache-0.7.0 → withcache-0.7.2}/src/withcache/server.py +700 -134
- {withcache-0.7.0 → withcache-0.7.2}/.gitignore +0 -0
- {withcache-0.7.0 → withcache-0.7.2}/LICENSE +0 -0
- {withcache-0.7.0 → withcache-0.7.2}/README.md +0 -0
- {withcache-0.7.0 → withcache-0.7.2}/deploy/Containerfile +0 -0
- {withcache-0.7.0 → withcache-0.7.2}/deploy/compose.yml +0 -0
- {withcache-0.7.0 → withcache-0.7.2}/hatch_build.py +0 -0
- {withcache-0.7.0 → withcache-0.7.2}/pyproject.toml +0 -0
- {withcache-0.7.0 → withcache-0.7.2}/shim/build.zig +0 -0
- {withcache-0.7.0 → withcache-0.7.2}/shim/shim.zig +0 -0
- {withcache-0.7.0 → withcache-0.7.2}/src/withcache/_shim.py +0 -0
- {withcache-0.7.0 → withcache-0.7.2}/src/withcache/client.py +0 -0
- {withcache-0.7.0 → withcache-0.7.2}/src/withcache/curlwithcache.py +0 -0
- {withcache-0.7.0 → withcache-0.7.2}/src/withcache/oras.py +0 -0
- {withcache-0.7.0 → withcache-0.7.2}/src/withcache/static/bootstrap-icons.min.css +0 -0
- {withcache-0.7.0 → withcache-0.7.2}/src/withcache/static/bootstrap.min.css +0 -0
- {withcache-0.7.0 → withcache-0.7.2}/src/withcache/static/fonts/bootstrap-icons.woff +0 -0
- {withcache-0.7.0 → withcache-0.7.2}/src/withcache/static/fonts/bootstrap-icons.woff2 +0 -0
- {withcache-0.7.0 → withcache-0.7.2}/src/withcache/static/htmx.min.js +0 -0
- {withcache-0.7.0 → withcache-0.7.2}/src/withcache/wgetwithcache.py +0 -0
- {withcache-0.7.0 → withcache-0.7.2}/tests/test_differential.py +0 -0
- {withcache-0.7.0 → withcache-0.7.2}/tests/test_oras.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
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
|
-
/*
|
|
1151
|
-
|
|
1152
|
-
|
|
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 {{
|
|
1161
|
-
|
|
1162
|
-
|
|
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:
|
|
1167
|
-
across
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
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
|
-
/*
|
|
1178
|
-
|
|
1179
|
-
so the
|
|
1180
|
-
|
|
1181
|
-
|
|
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
|
-
<
|
|
1198
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
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
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
>
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
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
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
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
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
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.
|
|
1260
|
-
|
|
1261
|
-
the
|
|
1262
|
-
|
|
1263
|
-
|
|
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
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
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('
|
|
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('
|
|
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-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
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
|
-
|
|
1510
|
-
<div>
|
|
1511
|
-
<div><small class="text-muted">source</small>
|
|
1512
|
-
<code>{html.escape(self.catalog.url)}</code></div>
|
|
1513
|
-
<div><small class="text-muted">fetched</small>
|
|
1514
|
-
<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,
|
|
1540
|
-
withcache /b/ token (so a click through pulls via cache)
|
|
1541
|
-
|
|
1542
|
-
|
|
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 =
|
|
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="
|
|
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 & 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
|
+
· {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
|
|
1673
|
-
#
|
|
1674
|
-
#
|
|
1675
|
-
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|