withcache 0.6.5__tar.gz → 0.7.1__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.6.5 → withcache-0.7.1}/PKG-INFO +2 -2
- {withcache-0.6.5 → withcache-0.7.1}/pyproject.toml +2 -2
- {withcache-0.6.5 → withcache-0.7.1}/shim/build.zig.zon +1 -1
- {withcache-0.6.5 → withcache-0.7.1}/src/withcache/__init__.py +1 -1
- {withcache-0.6.5 → withcache-0.7.1}/src/withcache/server.py +512 -86
- {withcache-0.6.5 → withcache-0.7.1}/tests/test_withcache.py +8 -0
- {withcache-0.6.5 → withcache-0.7.1}/.gitignore +0 -0
- {withcache-0.6.5 → withcache-0.7.1}/LICENSE +0 -0
- {withcache-0.6.5 → withcache-0.7.1}/README.md +0 -0
- {withcache-0.6.5 → withcache-0.7.1}/deploy/Containerfile +0 -0
- {withcache-0.6.5 → withcache-0.7.1}/deploy/compose.yml +0 -0
- {withcache-0.6.5 → withcache-0.7.1}/hatch_build.py +0 -0
- {withcache-0.6.5 → withcache-0.7.1}/shim/build.zig +0 -0
- {withcache-0.6.5 → withcache-0.7.1}/shim/shim.zig +0 -0
- {withcache-0.6.5 → withcache-0.7.1}/src/withcache/_shim.py +0 -0
- {withcache-0.6.5 → withcache-0.7.1}/src/withcache/client.py +0 -0
- {withcache-0.6.5 → withcache-0.7.1}/src/withcache/curlwithcache.py +0 -0
- {withcache-0.6.5 → withcache-0.7.1}/src/withcache/oras.py +0 -0
- {withcache-0.6.5 → withcache-0.7.1}/src/withcache/static/bootstrap-icons.min.css +0 -0
- {withcache-0.6.5 → withcache-0.7.1}/src/withcache/static/bootstrap.min.css +0 -0
- {withcache-0.6.5 → withcache-0.7.1}/src/withcache/static/fonts/bootstrap-icons.woff +0 -0
- {withcache-0.6.5 → withcache-0.7.1}/src/withcache/static/fonts/bootstrap-icons.woff2 +0 -0
- {withcache-0.6.5 → withcache-0.7.1}/src/withcache/static/htmx.min.js +0 -0
- {withcache-0.6.5 → withcache-0.7.1}/src/withcache/wgetwithcache.py +0 -0
- {withcache-0.6.5 → withcache-0.7.1}/tests/test_differential.py +0 -0
- {withcache-0.6.5 → withcache-0.7.1}/tests/test_oras.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: withcache
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.1
|
|
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>
|
|
@@ -13,7 +13,7 @@ Classifier: Intended Audience :: System Administrators
|
|
|
13
13
|
Classifier: License :: OSI Approved :: BSD License
|
|
14
14
|
Classifier: Programming Language :: Python :: 3
|
|
15
15
|
Classifier: Topic :: System :: Archiving :: Mirroring
|
|
16
|
-
Requires-Python: >=3.
|
|
16
|
+
Requires-Python: >=3.11
|
|
17
17
|
Description-Content-Type: text/markdown
|
|
18
18
|
|
|
19
19
|
# withcache
|
|
@@ -7,7 +7,7 @@ name = "withcache"
|
|
|
7
7
|
dynamic = ["version"] # single source of truth: src/withcache/__init__.py:__version__
|
|
8
8
|
description = "Operator-curated, URL-keyed artifact cache for a small lab (CUDA/ROCm/DOCA/firmware)"
|
|
9
9
|
readme = "README.md"
|
|
10
|
-
requires-python = ">=3.
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
11
|
license = { text = "BSD-3-Clause" }
|
|
12
12
|
authors = [{ name = "Simon A. F. Lund", email = "safl@safl.dk" }]
|
|
13
13
|
keywords = ["cache", "artifacts", "cuda", "rocm", "doca", "firmware", "lab"]
|
|
@@ -45,7 +45,7 @@ include = ["src/withcache", "shim", "README.md", "LICENSE", "deploy", "tests"]
|
|
|
45
45
|
|
|
46
46
|
[tool.ruff]
|
|
47
47
|
line-length = 100
|
|
48
|
-
target-version = "
|
|
48
|
+
target-version = "py311"
|
|
49
49
|
|
|
50
50
|
[tool.ruff.lint]
|
|
51
51
|
select = ["E", "F", "W", "I", "UP", "B"]
|
|
@@ -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.7.1",
|
|
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.
|
|
20
|
+
__version__ = "0.7.1"
|
|
21
21
|
|
|
22
22
|
__all__ = ["__version__", "blob_url", "cache_base", "is_cached", "oras", "serve_url"]
|
|
@@ -37,11 +37,13 @@ import socketserver
|
|
|
37
37
|
import sqlite3
|
|
38
38
|
import threading
|
|
39
39
|
import time
|
|
40
|
+
import tomllib
|
|
41
|
+
import urllib.error
|
|
40
42
|
import urllib.parse
|
|
41
43
|
import urllib.request
|
|
42
44
|
from dataclasses import dataclass, field
|
|
43
|
-
from datetime import
|
|
44
|
-
from typing import ClassVar
|
|
45
|
+
from datetime import UTC, datetime
|
|
46
|
+
from typing import Any, ClassVar
|
|
45
47
|
|
|
46
48
|
from . import __version__, oras
|
|
47
49
|
|
|
@@ -70,7 +72,7 @@ _DB_WRITE_LOCK = threading.Lock()
|
|
|
70
72
|
|
|
71
73
|
|
|
72
74
|
def now_iso() -> str:
|
|
73
|
-
return datetime.now(
|
|
75
|
+
return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
74
76
|
|
|
75
77
|
|
|
76
78
|
def _age_human(started_at: float, *, now: float | None = None) -> str:
|
|
@@ -147,6 +149,84 @@ def resolve_secret(data_dir: str) -> bytes:
|
|
|
147
149
|
return secret
|
|
148
150
|
|
|
149
151
|
|
|
152
|
+
DEFAULT_CATALOG_URL = "https://github.com/safl/nosi/releases/latest/download/catalog.toml"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@dataclass
|
|
156
|
+
class CatalogState:
|
|
157
|
+
"""Live state of the fetched image catalog.
|
|
158
|
+
|
|
159
|
+
withcache does not run a background poller. The catalog is
|
|
160
|
+
fetched at process start (best-effort, persisted result from
|
|
161
|
+
the last successful fetch survives a restart), refetched
|
|
162
|
+
lazily when the dashboard renders and the in-memory copy is
|
|
163
|
+
older than :data:`CATALOG_CACHE_TTL`, and force-refetched by
|
|
164
|
+
the operator via the Refresh button (POST /admin/catalog_refresh).
|
|
165
|
+
|
|
166
|
+
The raw TOML bytes are persisted to ``<data_dir>/catalog.toml``
|
|
167
|
+
so a restart doesn't wipe the last known good catalog and the
|
|
168
|
+
same file can be re-served verbatim to consumers on a future
|
|
169
|
+
``GET /catalog.toml`` route.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
url: str
|
|
173
|
+
persist_path: str
|
|
174
|
+
entries: list[dict[str, Any]] = field(default_factory=list)
|
|
175
|
+
fetched_at: str = ""
|
|
176
|
+
last_error: str = ""
|
|
177
|
+
_lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
|
|
178
|
+
|
|
179
|
+
def load_persisted(self) -> None:
|
|
180
|
+
"""Best-effort: seed ``entries`` + ``fetched_at`` from the on-disk
|
|
181
|
+
``catalog.toml`` if it exists. Never raises; a corrupt or missing
|
|
182
|
+
file leaves the state empty so the operator sees the "not
|
|
183
|
+
fetched yet" hint in the dashboard."""
|
|
184
|
+
if not os.path.isfile(self.persist_path):
|
|
185
|
+
return
|
|
186
|
+
try:
|
|
187
|
+
with open(self.persist_path, "rb") as f:
|
|
188
|
+
raw = f.read()
|
|
189
|
+
parsed = tomllib.loads(raw.decode("utf-8"))
|
|
190
|
+
mtime = os.path.getmtime(self.persist_path)
|
|
191
|
+
self.entries = list(parsed.get("images") or [])
|
|
192
|
+
self.fetched_at = datetime.fromtimestamp(mtime, UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
193
|
+
except (OSError, ValueError, UnicodeDecodeError, tomllib.TOMLDecodeError) as e:
|
|
194
|
+
self.last_error = f"failed to load {self.persist_path}: {e}"
|
|
195
|
+
|
|
196
|
+
def fetch_now(self, *, timeout: float = 15.0) -> None:
|
|
197
|
+
"""Fetch the catalog URL, parse, persist raw bytes, populate
|
|
198
|
+
``entries``. On failure the previously-cached entries remain;
|
|
199
|
+
the error is recorded so the tab can surface it. Single-writer
|
|
200
|
+
via ``self._lock`` so a burst of clicks doesn't double-fetch."""
|
|
201
|
+
with self._lock:
|
|
202
|
+
try:
|
|
203
|
+
req = urllib.request.Request(self.url, headers={"User-Agent": USER_AGENT})
|
|
204
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
205
|
+
raw = resp.read()
|
|
206
|
+
# Validate before persisting -- a 500 from upstream that
|
|
207
|
+
# returns HTML must not clobber a previously-good
|
|
208
|
+
# catalog.toml on disk.
|
|
209
|
+
parsed = tomllib.loads(raw.decode("utf-8"))
|
|
210
|
+
entries = list(parsed.get("images") or [])
|
|
211
|
+
# Atomic write: tempfile + rename so a crash mid-write
|
|
212
|
+
# never leaves a half-written catalog.toml.
|
|
213
|
+
tmp = self.persist_path + ".tmp"
|
|
214
|
+
with open(tmp, "wb") as f:
|
|
215
|
+
f.write(raw)
|
|
216
|
+
os.replace(tmp, self.persist_path)
|
|
217
|
+
self.entries = entries
|
|
218
|
+
self.fetched_at = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
219
|
+
self.last_error = ""
|
|
220
|
+
except (
|
|
221
|
+
urllib.error.URLError,
|
|
222
|
+
OSError,
|
|
223
|
+
ValueError,
|
|
224
|
+
UnicodeDecodeError,
|
|
225
|
+
tomllib.TOMLDecodeError,
|
|
226
|
+
) as e:
|
|
227
|
+
self.last_error = str(e)
|
|
228
|
+
|
|
229
|
+
|
|
150
230
|
class Auth:
|
|
151
231
|
COOKIE = "withcache-token"
|
|
152
232
|
MAX_AGE = 7 * 24 * 3600 # cookie lifetime, seconds
|
|
@@ -745,6 +825,10 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|
|
745
825
|
def streams(self) -> StreamRegistry:
|
|
746
826
|
return self.server.streams # type: ignore[attr-defined]
|
|
747
827
|
|
|
828
|
+
@property
|
|
829
|
+
def catalog(self) -> CatalogState:
|
|
830
|
+
return self.server.catalog # type: ignore[attr-defined]
|
|
831
|
+
|
|
748
832
|
def log_message(self, format, *args): # quieter, single-line
|
|
749
833
|
print(f"{self.address_string()} - {format % args}", flush=True)
|
|
750
834
|
|
|
@@ -793,6 +877,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|
|
793
877
|
"/admin/delete",
|
|
794
878
|
"/admin/cancel",
|
|
795
879
|
"/admin/clear",
|
|
880
|
+
"/admin/catalog_refresh",
|
|
796
881
|
)
|
|
797
882
|
|
|
798
883
|
def do_POST(self):
|
|
@@ -820,6 +905,8 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|
|
820
905
|
self.mgr.cancel(int(jid))
|
|
821
906
|
elif parsed.path == "/admin/clear":
|
|
822
907
|
self.mgr.clear_finished()
|
|
908
|
+
elif parsed.path == "/admin/catalog_refresh":
|
|
909
|
+
self.catalog.fetch_now()
|
|
823
910
|
self.respond_admin()
|
|
824
911
|
else:
|
|
825
912
|
self.send_text(404, "not found\n")
|
|
@@ -1049,6 +1136,13 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|
|
1049
1136
|
_PRIMARY_RGB = "143, 27, 113"
|
|
1050
1137
|
|
|
1051
1138
|
def _head(self, title: str) -> str:
|
|
1139
|
+
"""Emit the shared page prelude: Bootstrap + icons + htmx +
|
|
1140
|
+
the full bty-family chrome CSS. Every service (bty, withcache,
|
|
1141
|
+
nbdmux) uses this same block; the only per-service knob is the
|
|
1142
|
+
primary hue (``--bs-primary`` and the derived button /
|
|
1143
|
+
rgba() variants). See bty's ``_layout.html`` for the origin
|
|
1144
|
+
of these class names -- kept identical here so operators
|
|
1145
|
+
moving between consoles see one visual grammar."""
|
|
1052
1146
|
primary = self._PRIMARY_HEX
|
|
1053
1147
|
hover = self._PRIMARY_HOVER
|
|
1054
1148
|
rgb = self._PRIMARY_RGB
|
|
@@ -1060,9 +1154,11 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|
|
1060
1154
|
<link rel="stylesheet" href="/static/bootstrap-icons.min.css">
|
|
1061
1155
|
<script src="/static/htmx.min.js"></script>
|
|
1062
1156
|
<style>
|
|
1063
|
-
/*
|
|
1064
|
-
|
|
1065
|
-
|
|
1157
|
+
/* Palette anchor for the three-service trio (bty navy,
|
|
1158
|
+
withcache dark-magenta, nbdmux magenta). Bootstrap 5
|
|
1159
|
+
exposes --bs-primary + a matching -rgb triplet used for
|
|
1160
|
+
translucent variants (alerts, focus rings, .bg-*-subtle).
|
|
1161
|
+
Overriding both here re-tints every stock component
|
|
1066
1162
|
without patching bootstrap.min.css. */
|
|
1067
1163
|
:root {{
|
|
1068
1164
|
--bs-primary: {primary};
|
|
@@ -1070,51 +1166,254 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|
|
1070
1166
|
--bs-link-color: {primary};
|
|
1071
1167
|
--bs-link-hover-color: {hover};
|
|
1072
1168
|
}}
|
|
1073
|
-
.btn-primary {{
|
|
1074
|
-
|
|
1075
|
-
|
|
1169
|
+
.btn-primary {{
|
|
1170
|
+
--bs-btn-bg: {primary};
|
|
1171
|
+
--bs-btn-border-color: {primary};
|
|
1172
|
+
--bs-btn-hover-bg: {hover};
|
|
1173
|
+
--bs-btn-hover-border-color: {hover};
|
|
1174
|
+
--bs-btn-active-bg: {hover};
|
|
1175
|
+
--bs-btn-active-border-color: {hover};
|
|
1176
|
+
}}
|
|
1076
1177
|
.bg-primary {{ --bs-bg-opacity: 1; background-color: {primary} !important; }}
|
|
1077
1178
|
.text-primary {{ --bs-text-opacity: 1; color: {primary} !important; }}
|
|
1078
1179
|
.border-primary {{ --bs-border-opacity: 1; border-color: {primary} !important; }}
|
|
1079
|
-
/* Brand strip:
|
|
1080
|
-
across
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1180
|
+
/* Brand strip: a thin gradient accent above the navbar.
|
|
1181
|
+
Stable across themes; defined inline rather than in
|
|
1182
|
+
bootstrap.min.css so it renders even when the CSS load
|
|
1183
|
+
races the initial paint. Shared navy -> dark-magenta ->
|
|
1184
|
+
magenta gradient across bty (navy), withcache
|
|
1185
|
+
(dark-magenta) and nbdmux (magenta) so the trio reads as
|
|
1186
|
+
one product family from any of the three consoles. */
|
|
1187
|
+
.brand-accent {{
|
|
1188
|
+
height: 3px;
|
|
1189
|
+
background: linear-gradient(90deg, #0d3585 0%, #8f1b71 50%, #d63384 100%);
|
|
1190
|
+
}}
|
|
1191
|
+
/* The accent + navbar + sub-nav pin to the top as one unit so
|
|
1192
|
+
the sub-nav jump links stay visible while scrolling. */
|
|
1193
|
+
.sticky-header {{
|
|
1194
|
+
position: sticky;
|
|
1195
|
+
top: 0;
|
|
1196
|
+
z-index: 1030;
|
|
1197
|
+
}}
|
|
1198
|
+
/* In-page ``#anchor`` jumps (the sub-nav tab pills) land below
|
|
1199
|
+
the sticky header instead of under it. */
|
|
1200
|
+
html {{
|
|
1201
|
+
scroll-padding-top: 6.5rem;
|
|
1202
|
+
scroll-behavior: smooth;
|
|
1203
|
+
}}
|
|
1204
|
+
/* Brand pill keeps the same padding + radius everywhere; the
|
|
1205
|
+
``brand-active`` variant lights up on the home page so the
|
|
1206
|
+
brand doubles as a Home indicator. */
|
|
1207
|
+
.navbar-brand {{
|
|
1208
|
+
border-radius: 0.5rem;
|
|
1209
|
+
padding-left: 0.6rem;
|
|
1210
|
+
padding-right: 0.6rem;
|
|
1211
|
+
margin-right: 0.25rem;
|
|
1212
|
+
transition: background-color 0.15s;
|
|
1213
|
+
}}
|
|
1214
|
+
.navbar-brand.brand-active {{
|
|
1215
|
+
background-color: rgba({rgb}, 0.85);
|
|
1216
|
+
}}
|
|
1217
|
+
.navbar-brand:hover {{
|
|
1218
|
+
background-color: rgba(255, 255, 255, 0.06);
|
|
1219
|
+
}}
|
|
1220
|
+
.navbar-brand.brand-active:hover {{
|
|
1221
|
+
background-color: rgba({rgb}, 0.95);
|
|
1222
|
+
}}
|
|
1223
|
+
/* Version sits in the navbar alongside the brand pill but
|
|
1224
|
+
OUTSIDE it -- so the brand button stays a clean click target
|
|
1225
|
+
and the version reads as adjacent metadata. */
|
|
1226
|
+
.navbar-version {{
|
|
1227
|
+
color: rgba(255, 255, 255, 0.55);
|
|
1228
|
+
font-weight: 400;
|
|
1229
|
+
font-size: 0.85rem;
|
|
1230
|
+
align-self: center;
|
|
1231
|
+
white-space: nowrap;
|
|
1232
|
+
}}
|
|
1233
|
+
.navbar-brand .brand-icon {{
|
|
1234
|
+
/* Sized to match bty's mascot PNG (1.05rem tall). Using a
|
|
1235
|
+
Bootstrap Icon rather than an image keeps the shell
|
|
1236
|
+
stdlib-only. */
|
|
1237
|
+
font-size: 1.05rem;
|
|
1238
|
+
vertical-align: -0.05rem;
|
|
1239
|
+
}}
|
|
1240
|
+
.navbar .nav-btn {{
|
|
1241
|
+
display: inline-flex;
|
|
1242
|
+
align-items: center;
|
|
1243
|
+
gap: 0.4rem;
|
|
1244
|
+
padding: 0.4rem 0.8rem;
|
|
1245
|
+
margin-right: 0.25rem;
|
|
1246
|
+
border-radius: 0.5rem;
|
|
1247
|
+
color: rgba(255, 255, 255, 0.85);
|
|
1248
|
+
text-decoration: none;
|
|
1249
|
+
transition: background-color 0.15s;
|
|
1250
|
+
}}
|
|
1251
|
+
.navbar .nav-btn:hover {{
|
|
1252
|
+
background-color: rgba(255, 255, 255, 0.10);
|
|
1253
|
+
color: #fff;
|
|
1254
|
+
}}
|
|
1255
|
+
.navbar .nav-btn.active {{
|
|
1256
|
+
background-color: {primary};
|
|
1257
|
+
color: #fff;
|
|
1258
|
+
box-shadow: 0 0 0 1px rgba({rgb}, 0.6);
|
|
1259
|
+
}}
|
|
1260
|
+
.navbar .nav-btn i {{
|
|
1261
|
+
font-size: 1.05rem;
|
|
1262
|
+
}}
|
|
1263
|
+
/* User-bar: a single pill containing username + logout, with a
|
|
1264
|
+
thin vertical divider between them. Visually one widget,
|
|
1265
|
+
but two click targets and zero JavaScript. */
|
|
1266
|
+
.user-bar {{
|
|
1267
|
+
display: inline-flex;
|
|
1268
|
+
align-items: stretch;
|
|
1269
|
+
border-radius: 999px;
|
|
1270
|
+
background-color: rgba(255, 255, 255, 0.08);
|
|
1271
|
+
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
1272
|
+
overflow: hidden;
|
|
1273
|
+
font-size: 0.85rem;
|
|
1274
|
+
}}
|
|
1275
|
+
.user-bar-name {{
|
|
1276
|
+
display: inline-flex;
|
|
1277
|
+
align-items: center;
|
|
1278
|
+
gap: 0.4rem;
|
|
1279
|
+
padding: 0.35rem 0.8rem;
|
|
1280
|
+
color: rgba(255, 255, 255, 0.92);
|
|
1281
|
+
}}
|
|
1282
|
+
.user-bar-name code {{
|
|
1283
|
+
color: #fff;
|
|
1284
|
+
background: transparent;
|
|
1285
|
+
padding: 0;
|
|
1286
|
+
}}
|
|
1287
|
+
.user-bar-divider {{
|
|
1288
|
+
width: 1px;
|
|
1289
|
+
background-color: rgba(255, 255, 255, 0.18);
|
|
1290
|
+
}}
|
|
1291
|
+
.user-bar-action {{
|
|
1292
|
+
display: inline-flex;
|
|
1293
|
+
align-items: center;
|
|
1294
|
+
padding: 0.35rem 0.7rem;
|
|
1295
|
+
background: transparent;
|
|
1296
|
+
border: none;
|
|
1297
|
+
color: rgba(255, 255, 255, 0.85);
|
|
1298
|
+
text-decoration: none;
|
|
1299
|
+
transition: background-color 0.15s, color 0.15s;
|
|
1300
|
+
}}
|
|
1301
|
+
.user-bar-action:hover,
|
|
1302
|
+
.user-bar-action:focus {{
|
|
1303
|
+
background-color: rgba(255, 255, 255, 0.10);
|
|
1304
|
+
color: #fff;
|
|
1305
|
+
outline: none;
|
|
1306
|
+
}}
|
|
1307
|
+
.user-bar-action.active {{
|
|
1308
|
+
background-color: rgba(255, 255, 255, 0.16);
|
|
1309
|
+
color: #fff;
|
|
1310
|
+
}}
|
|
1311
|
+
/* Logout hover keeps the danger-red signal so it doesn't
|
|
1312
|
+
look indistinguishable from the account action. */
|
|
1313
|
+
.user-bar-logout:hover,
|
|
1314
|
+
.user-bar-logout:focus {{
|
|
1315
|
+
background-color: rgba(220, 53, 69, 0.65);
|
|
1316
|
+
color: #fff;
|
|
1317
|
+
}}
|
|
1318
|
+
/* Sub-nav strip. Sits immediately below the main ``bg-dark``
|
|
1319
|
+
navbar; coloured CLEARLY lighter so it reads as "second-tier
|
|
1320
|
+
nav, still chrome". Same ``#495057`` (--bs-gray-700) bty
|
|
1321
|
+
uses so the boundary is unambiguous regardless of theme. */
|
|
1322
|
+
.subnav-strip {{
|
|
1323
|
+
background-color: #495057;
|
|
1324
|
+
border-bottom: 1px solid rgba(255, 255, 255, 0.10);
|
|
1325
|
+
padding-top: 0.25rem;
|
|
1326
|
+
padding-bottom: 0.25rem;
|
|
1327
|
+
font-size: 0.85rem;
|
|
1328
|
+
line-height: 1.4;
|
|
1329
|
+
}}
|
|
1330
|
+
.subnav-strip > .container {{
|
|
1331
|
+
min-height: 2rem;
|
|
1332
|
+
display: flex;
|
|
1333
|
+
align-items: center;
|
|
1334
|
+
gap: 0.6rem;
|
|
1335
|
+
}}
|
|
1336
|
+
.subnav-strip .form-control-sm,
|
|
1337
|
+
.subnav-strip .btn-sm,
|
|
1338
|
+
.subnav-strip a,
|
|
1339
|
+
.subnav-strip label,
|
|
1340
|
+
.subnav-strip .small,
|
|
1341
|
+
.subnav-strip small {{
|
|
1342
|
+
font-size: inherit;
|
|
1343
|
+
line-height: inherit;
|
|
1344
|
+
}}
|
|
1345
|
+
.subnav-strip .form-control-sm,
|
|
1346
|
+
.subnav-strip .btn-sm {{
|
|
1347
|
+
padding-top: 0.15rem;
|
|
1348
|
+
padding-bottom: 0.15rem;
|
|
1349
|
+
}}
|
|
1350
|
+
.subnav-strip .nav-pills {{
|
|
1351
|
+
gap: 0.25rem;
|
|
1352
|
+
}}
|
|
1353
|
+
.subnav-strip .nav-pills .nav-link {{
|
|
1354
|
+
color: rgba(255, 255, 255, 0.78);
|
|
1355
|
+
padding: 0.15rem 0.55rem;
|
|
1356
|
+
}}
|
|
1357
|
+
.subnav-strip .nav-pills .nav-link:hover {{
|
|
1358
|
+
color: #fff;
|
|
1359
|
+
background-color: rgba(255, 255, 255, 0.10);
|
|
1360
|
+
}}
|
|
1361
|
+
.subnav-strip .nav-pills .nav-link.active-tab {{
|
|
1362
|
+
color: #fff;
|
|
1363
|
+
background-color: {primary};
|
|
1364
|
+
}}
|
|
1365
|
+
.subnav-strip .text-muted {{
|
|
1366
|
+
color: rgba(255, 255, 255, 0.55) !important;
|
|
1367
|
+
}}
|
|
1368
|
+
.subnav-strip code {{
|
|
1369
|
+
color: rgba(255, 255, 255, 0.85);
|
|
1370
|
+
background-color: transparent;
|
|
1371
|
+
}}
|
|
1372
|
+
.subnav-strip a {{
|
|
1373
|
+
color: rgba(255, 255, 255, 0.78);
|
|
1374
|
+
}}
|
|
1375
|
+
.subnav-strip a:hover {{
|
|
1376
|
+
color: #fff;
|
|
1377
|
+
}}
|
|
1378
|
+
/* Withcache-specific bits kept minimal. Everything else
|
|
1379
|
+
inherits from Bootstrap + the chrome above. */
|
|
1085
1380
|
code {{ color: inherit; font-size: .85em; }}
|
|
1086
1381
|
.url {{ word-break: break-all; }}
|
|
1087
1382
|
.num {{ text-align: right; }}
|
|
1088
1383
|
.mono {{ font-family: var(--bs-font-monospace); font-size: .85em; }}
|
|
1089
1384
|
#spin {{ width: 7rem; height: .5rem; margin: 0; }}
|
|
1090
|
-
/*
|
|
1091
|
-
|
|
1092
|
-
so the
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
border-bottom: 2px solid transparent; padding: .45rem .9rem;
|
|
1096
|
-
font-size: .9rem; margin-bottom: -1px; }}
|
|
1097
|
-
nav.tabs .nav-link:hover {{ color: var(--bs-body-color); }}
|
|
1098
|
-
nav.tabs .nav-link.active-tab {{ color: var(--bs-body-color);
|
|
1099
|
-
border-bottom-color: var(--bs-primary); font-weight: 600; }}
|
|
1100
|
-
section.tab {{ display: none; padding-top: .75rem; }}
|
|
1385
|
+
/* Tab content panels: shown only when the section carries
|
|
1386
|
+
``.active-tab``. Same hook the previous nav-tabs code used;
|
|
1387
|
+
unchanged so the existing htmx post-swap script keeps
|
|
1388
|
+
working. */
|
|
1389
|
+
section.tab {{ display: none; padding-top: .25rem; }}
|
|
1101
1390
|
section.tab.active-tab {{ display: block; }}
|
|
1102
1391
|
</style>
|
|
1103
1392
|
</head>"""
|
|
1104
1393
|
|
|
1105
1394
|
def render_login(self, error: str = "") -> str:
|
|
1395
|
+
"""The unauthenticated shell: accent strip + dark navbar with the
|
|
1396
|
+
brand pill (no user-bar since there's no session), then a
|
|
1397
|
+
centered login card. Same chrome the authenticated page uses,
|
|
1398
|
+
so the operator sees continuity across the auth boundary."""
|
|
1106
1399
|
err = f'<div class="alert alert-danger">{html.escape(error)}</div>' if error else ""
|
|
1107
1400
|
return f"""{self._head("withcache - login")}
|
|
1108
|
-
<body>
|
|
1401
|
+
<body class="bg-light">
|
|
1402
|
+
<div class="sticky-header">
|
|
1109
1403
|
<div class="brand-accent"></div>
|
|
1110
|
-
<
|
|
1111
|
-
|
|
1404
|
+
<nav class="navbar navbar-expand-md bg-dark navbar-dark py-2">
|
|
1405
|
+
<div class="container">
|
|
1406
|
+
<a class="navbar-brand fw-semibold brand-active" href="/">
|
|
1407
|
+
<i class="bi bi-database brand-icon me-1"></i>WITHCACHE
|
|
1408
|
+
</a>
|
|
1409
|
+
<span class="navbar-version">v{html.escape(__version__)}</span>
|
|
1410
|
+
</div>
|
|
1411
|
+
</nav>
|
|
1412
|
+
</div>
|
|
1413
|
+
<main class="container">
|
|
1414
|
+
<div class="card mx-auto mt-5" style="max-width: 24rem;">
|
|
1112
1415
|
<div class="card-body">
|
|
1113
|
-
<
|
|
1114
|
-
<i class="bi bi-hdd-stack"></i> withcache
|
|
1115
|
-
<small class="text-muted fs-6">v{html.escape(__version__)}</small>
|
|
1116
|
-
</h3>
|
|
1117
|
-
<p class="text-muted small mb-3">Operator login</p>
|
|
1416
|
+
<h5 class="card-title mb-3">Operator login</h5>
|
|
1118
1417
|
{err}
|
|
1119
1418
|
<form method="post" action="/ui/login">
|
|
1120
1419
|
<div class="mb-3">
|
|
@@ -1128,29 +1427,68 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|
|
1128
1427
|
</main></body></html>"""
|
|
1129
1428
|
|
|
1130
1429
|
def render_page(self) -> str:
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1430
|
+
"""The authenticated shell: sticky-header wrapping the accent
|
|
1431
|
+
strip, the dark navbar (brand pill + version + user-bar), and
|
|
1432
|
+
the subnav strip carrying the tab pills. Then main.container
|
|
1433
|
+
with the "Add from URI" card and the htmx-swapped #dash
|
|
1434
|
+
region. Same chrome bty uses; only ``--bs-primary`` differs."""
|
|
1435
|
+
user_bar = (
|
|
1436
|
+
'<div class="user-bar mt-2 mt-md-0" title="Operator session">'
|
|
1437
|
+
'<span class="user-bar-name">'
|
|
1438
|
+
'<i class="bi bi-person-circle"></i><code>operator</code>'
|
|
1439
|
+
"</span>"
|
|
1440
|
+
'<span class="user-bar-divider"></span>'
|
|
1441
|
+
'<form action="/ui/logout" method="post" class="m-0 d-inline-flex">'
|
|
1442
|
+
'<button type="submit" class="user-bar-action user-bar-logout" title="Sign out">'
|
|
1443
|
+
'<i class="bi bi-box-arrow-right"></i>'
|
|
1444
|
+
"</button></form>"
|
|
1445
|
+
"</div>"
|
|
1135
1446
|
if self.auth.enabled
|
|
1136
1447
|
else ""
|
|
1137
1448
|
)
|
|
1138
1449
|
return f"""{self._head("withcache cache-host")}
|
|
1139
|
-
<body>
|
|
1450
|
+
<body class="bg-light">
|
|
1451
|
+
<!-- Sticky header: the accent strip, the main navbar, and the
|
|
1452
|
+
sub-nav strip travel together and pin to the top so the tab
|
|
1453
|
+
pills stay reachable while scrolling a long list. -->
|
|
1454
|
+
<div class="sticky-header">
|
|
1140
1455
|
<div class="brand-accent"></div>
|
|
1141
|
-
<nav class="navbar navbar-expand
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
>
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1456
|
+
<nav class="navbar navbar-expand-md bg-dark navbar-dark py-2">
|
|
1457
|
+
<div class="container">
|
|
1458
|
+
<a class="navbar-brand fw-semibold brand-active" href="/">
|
|
1459
|
+
<i class="bi bi-database brand-icon me-1"></i>WITHCACHE
|
|
1460
|
+
</a>
|
|
1461
|
+
<div class="d-flex flex-grow-1 align-items-center flex-wrap">
|
|
1462
|
+
<div class="me-auto d-flex flex-wrap">
|
|
1463
|
+
<!-- withcache is a single-page dashboard: no middle
|
|
1464
|
+
nav-btns. me-auto pushes version + user-bar right. -->
|
|
1465
|
+
</div>
|
|
1466
|
+
<span class="navbar-version me-2">v{html.escape(__version__)}</span>
|
|
1467
|
+
{user_bar}
|
|
1468
|
+
</div>
|
|
1151
1469
|
</div>
|
|
1152
|
-
</div>
|
|
1153
1470
|
</nav>
|
|
1471
|
+
|
|
1472
|
+
<!-- Sub-nav strip. Carries the five tab pills below the main
|
|
1473
|
+
navbar; visually attached (same sticky container, #495057
|
|
1474
|
+
background) but rendered STATIC here so tab labels don't
|
|
1475
|
+
flicker on the 1 Hz htmx dashboard swap. Per-tab counts live
|
|
1476
|
+
in the summary line inside the dash content and update on
|
|
1477
|
+
every swap. -->
|
|
1478
|
+
<div class="subnav-strip">
|
|
1479
|
+
<div class="container">
|
|
1480
|
+
<ul class="nav nav-pills flex-nowrap flex-md-wrap">
|
|
1481
|
+
<li class="nav-item"><a class="nav-link" href="#tab-cached">Cached</a></li>
|
|
1482
|
+
<li class="nav-item"><a class="nav-link" href="#tab-streams">Streams</a></li>
|
|
1483
|
+
<li class="nav-item"><a class="nav-link" href="#tab-downloads">Downloads</a></li>
|
|
1484
|
+
<li class="nav-item"><a class="nav-link" href="#tab-misses">Misses</a></li>
|
|
1485
|
+
<li class="nav-item"><a class="nav-link" href="#tab-catalog">Catalog</a></li>
|
|
1486
|
+
<span class="ms-auto"><progress id="spin" class="htmx-indicator"></progress></span>
|
|
1487
|
+
</ul>
|
|
1488
|
+
</div>
|
|
1489
|
+
</div>
|
|
1490
|
+
</div><!-- /.sticky-header -->
|
|
1491
|
+
|
|
1154
1492
|
<main class="container py-4">
|
|
1155
1493
|
<div class="card mb-4">
|
|
1156
1494
|
<div class="card-header"><i class="bi bi-cloud-download text-primary"></i> Add from URI</div>
|
|
@@ -1169,15 +1507,11 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|
|
1169
1507
|
|
|
1170
1508
|
<!-- The hx-trigger gates polling on the user NOT having an active
|
|
1171
1509
|
text selection, so highlight-and-copy a URL out of a table cell
|
|
1172
|
-
isn't wiped by the 1 Hz refresh.
|
|
1173
|
-
|
|
1174
|
-
the
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
as ``X-Active-Tab`` on every refresh so the server can bake
|
|
1178
|
-
``.active-tab`` into the rendered HTML -- eliminating the
|
|
1179
|
-
visible flicker the post-swap JS-applied class would otherwise
|
|
1180
|
-
cause when the new innerHTML lands without the class. -->
|
|
1510
|
+
isn't wiped by the 1 Hz refresh. hx-headers sends the current
|
|
1511
|
+
URL hash (the active tab id) as X-Active-Tab on every refresh
|
|
1512
|
+
so the server can bake .active-tab into the rendered HTML,
|
|
1513
|
+
eliminating the visible flicker the post-swap JS-applied class
|
|
1514
|
+
would otherwise cause when the new innerHTML lands. -->
|
|
1181
1515
|
<div id="dash" hx-get="/admin/dash"
|
|
1182
1516
|
hx-trigger="load, every 1s [document.getSelection().isCollapsed]"
|
|
1183
1517
|
hx-swap="innerHTML"
|
|
@@ -1185,16 +1519,15 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|
|
1185
1519
|
{self.render_dash(active_tab=(self.headers.get("X-Active-Tab") or "").strip())}
|
|
1186
1520
|
</div>
|
|
1187
1521
|
|
|
1188
|
-
<!-- Tab activation
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
to the first tab within a second of any click. -->
|
|
1522
|
+
<!-- Tab activation: apply .active-tab to the section (inside
|
|
1523
|
+
#dash) AND to the matching subnav pill (outside #dash) whose
|
|
1524
|
+
id/hash matches the current URL hash. Runs on hashchange, on
|
|
1525
|
+
click into a tab link, and on every htmx:afterSettle so the
|
|
1526
|
+
class survives the 1 Hz innerHTML replacement of #dash.
|
|
1527
|
+
Subnav pills live outside #dash so the JS-applied class on
|
|
1528
|
+
them is not touched by the swap; sections inside #dash also
|
|
1529
|
+
get the class baked server-side (via X-Active-Tab) so there
|
|
1530
|
+
is no visible flicker between swap and JS re-apply. -->
|
|
1198
1531
|
<script>
|
|
1199
1532
|
(function () {{
|
|
1200
1533
|
function applyActiveTab() {{
|
|
@@ -1206,7 +1539,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|
|
1206
1539
|
sections.forEach(function (s) {{
|
|
1207
1540
|
s.classList.toggle('active-tab', s.id === hash);
|
|
1208
1541
|
}});
|
|
1209
|
-
document.querySelectorAll('
|
|
1542
|
+
document.querySelectorAll('.subnav-strip .nav-pills .nav-link').forEach(function (a) {{
|
|
1210
1543
|
var target = (a.getAttribute('href') || '').replace(/^#/, '');
|
|
1211
1544
|
a.classList.toggle('active-tab', target === hash);
|
|
1212
1545
|
}});
|
|
@@ -1214,7 +1547,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|
|
1214
1547
|
window.addEventListener('hashchange', applyActiveTab);
|
|
1215
1548
|
document.body.addEventListener('htmx:afterSettle', applyActiveTab);
|
|
1216
1549
|
document.addEventListener('click', function (ev) {{
|
|
1217
|
-
var a = ev.target.closest && ev.target.closest('
|
|
1550
|
+
var a = ev.target.closest && ev.target.closest('.subnav-strip .nav-pills .nav-link');
|
|
1218
1551
|
if (a) setTimeout(applyActiveTab, 0);
|
|
1219
1552
|
}});
|
|
1220
1553
|
applyActiveTab();
|
|
@@ -1230,7 +1563,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|
|
1230
1563
|
# 1 Hz refresh (and on every operator click via the same
|
|
1231
1564
|
# script that watches hashchange). Unknown / empty value
|
|
1232
1565
|
# defaults to the first tab.
|
|
1233
|
-
_TAB_IDS = ("tab-cached", "tab-streams", "tab-downloads", "tab-misses")
|
|
1566
|
+
_TAB_IDS = ("tab-cached", "tab-streams", "tab-downloads", "tab-misses", "tab-catalog")
|
|
1234
1567
|
if active_tab not in _TAB_IDS:
|
|
1235
1568
|
active_tab = _TAB_IDS[0]
|
|
1236
1569
|
|
|
@@ -1354,21 +1687,23 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|
|
1354
1687
|
nstreams = len(streams)
|
|
1355
1688
|
njobs = len(jobs)
|
|
1356
1689
|
|
|
1690
|
+
catalog_rows = self._catalog_rows()
|
|
1691
|
+
ncatalog = len(self.catalog.entries)
|
|
1692
|
+
|
|
1693
|
+
# The per-tab count summary lives here (inside the htmx-swapped
|
|
1694
|
+
# region) so it updates every second. Tab labels themselves
|
|
1695
|
+
# live in the static sub-nav strip in ``render_page`` per bty
|
|
1696
|
+
# convention -- labels are chrome, counts are content.
|
|
1357
1697
|
return f"""
|
|
1358
|
-
<p class="text-muted small mb-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
href="#tab-downloads">Downloads ({njobs})</a></li>
|
|
1368
|
-
<li class="nav-item"><a class="nav-link {_active("tab-misses").lstrip()}"
|
|
1369
|
-
href="#tab-misses">Misses ({nmisses})</a></li>
|
|
1370
|
-
</ul>
|
|
1371
|
-
</nav>
|
|
1698
|
+
<p class="text-muted small mb-3">
|
|
1699
|
+
<span class="badge bg-primary bg-opacity-10 text-primary me-1">{nblobs} cached ({used}){
|
|
1700
|
+
full
|
|
1701
|
+
}</span>
|
|
1702
|
+
<span class="badge bg-secondary bg-opacity-10 text-secondary me-1">{nstreams} streams</span>
|
|
1703
|
+
<span class="badge bg-secondary bg-opacity-10 text-secondary me-1">{njobs} downloads</span>
|
|
1704
|
+
<span class="badge bg-secondary bg-opacity-10 text-secondary me-1">{nmisses} misses</span>
|
|
1705
|
+
<span class="badge bg-secondary bg-opacity-10 text-secondary">{ncatalog} catalog</span>
|
|
1706
|
+
</p>
|
|
1372
1707
|
|
|
1373
1708
|
<section id="tab-cached" class="tab{_active("tab-cached")}">
|
|
1374
1709
|
<div class="table-responsive"><table class="table table-sm table-striped table-hover mb-0">
|
|
@@ -1412,8 +1747,80 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|
|
1412
1747
|
</tr></thead>
|
|
1413
1748
|
<tbody>{miss_rows}</tbody>
|
|
1414
1749
|
</table></div>
|
|
1750
|
+
</section>
|
|
1751
|
+
|
|
1752
|
+
<section id="tab-catalog" class="tab{_active("tab-catalog")}">
|
|
1753
|
+
<div class="d-flex align-items-center justify-content-between mb-2 flex-wrap gap-2">
|
|
1754
|
+
<div>
|
|
1755
|
+
<div><small class="text-muted">source</small>
|
|
1756
|
+
<code>{html.escape(self.catalog.url)}</code></div>
|
|
1757
|
+
<div><small class="text-muted">fetched</small>
|
|
1758
|
+
<code>{html.escape(self.catalog.fetched_at) or "never"}</code>
|
|
1759
|
+
{
|
|
1760
|
+
(
|
|
1761
|
+
f'<span class="badge bg-danger-subtle text-danger ms-1">'
|
|
1762
|
+
f"{html.escape(self.catalog.last_error)}</span>"
|
|
1763
|
+
)
|
|
1764
|
+
if self.catalog.last_error
|
|
1765
|
+
else ""
|
|
1766
|
+
}</div>
|
|
1767
|
+
</div>
|
|
1768
|
+
<form hx-post="/admin/catalog_refresh" hx-target="#dash" hx-swap="innerHTML"
|
|
1769
|
+
hx-indicator="#spin" class="m-0">
|
|
1770
|
+
<button class="btn btn-sm btn-primary" type="submit">Refresh</button>
|
|
1771
|
+
</form>
|
|
1772
|
+
</div>
|
|
1773
|
+
<div class="table-responsive"><table class="table table-sm table-striped table-hover mb-0">
|
|
1774
|
+
<thead class="table-light"><tr>
|
|
1775
|
+
<th>Name</th><th>Format</th><th>Arch</th><th>Size</th><th>Source</th>
|
|
1776
|
+
</tr></thead>
|
|
1777
|
+
<tbody>{catalog_rows}</tbody>
|
|
1778
|
+
</table></div>
|
|
1415
1779
|
</section>"""
|
|
1416
1780
|
|
|
1781
|
+
def _catalog_rows(self) -> str:
|
|
1782
|
+
"""One <tr> per catalog image. Cells: name, format, arch,
|
|
1783
|
+
human-readable size, and a Source cell whose URL is the
|
|
1784
|
+
withcache /b/ token (so a click through pulls via cache).
|
|
1785
|
+
Empty catalog rows to a "not fetched yet" placeholder + a
|
|
1786
|
+
hint about the Refresh button."""
|
|
1787
|
+
entries = self.catalog.entries
|
|
1788
|
+
if not entries:
|
|
1789
|
+
hint = self.catalog.last_error or "click Refresh above to fetch the catalog manifest"
|
|
1790
|
+
return (
|
|
1791
|
+
'<tr><td colspan="5" class="text-center text-muted">'
|
|
1792
|
+
f"<em>{html.escape(hint)}</em></td></tr>"
|
|
1793
|
+
)
|
|
1794
|
+
rows: list[str] = []
|
|
1795
|
+
for entry in entries:
|
|
1796
|
+
name = str(entry.get("name") or "")
|
|
1797
|
+
src = str(entry.get("src") or "")
|
|
1798
|
+
fmt = str(entry.get("format") or "")
|
|
1799
|
+
arch = str(entry.get("arch") or "")
|
|
1800
|
+
size_bytes = entry.get("size_bytes")
|
|
1801
|
+
size = human_size(int(size_bytes)) if isinstance(size_bytes, int) else ""
|
|
1802
|
+
# Route the Source column via /b/<token>/<name> so a
|
|
1803
|
+
# click serves through withcache -- first hit fills the
|
|
1804
|
+
# cache, subsequent hits serve from disk. Mirrors the
|
|
1805
|
+
# blob-row rendering above.
|
|
1806
|
+
if src.startswith(("http://", "https://", "oras://")):
|
|
1807
|
+
token = base64.urlsafe_b64encode(src.encode("utf-8")).decode("ascii").rstrip("=")
|
|
1808
|
+
filename = urllib.parse.urlsplit(src).path.rsplit("/", 1)[-1] or "download"
|
|
1809
|
+
link = f"/b/{token}/{urllib.parse.quote(filename)}"
|
|
1810
|
+
src_cell = f'<a href="{link}" title="{html.escape(src)}">{html.escape(src)}</a>'
|
|
1811
|
+
else:
|
|
1812
|
+
src_cell = html.escape(src)
|
|
1813
|
+
rows.append(
|
|
1814
|
+
f"<tr>"
|
|
1815
|
+
f"<td>{html.escape(name)}</td>"
|
|
1816
|
+
f'<td class="mono"><small>{html.escape(fmt)}</small></td>'
|
|
1817
|
+
f'<td class="mono"><small>{html.escape(arch)}</small></td>'
|
|
1818
|
+
f"<td>{html.escape(size)}</td>"
|
|
1819
|
+
f'<td class="url">{src_cell}</td>'
|
|
1820
|
+
"</tr>"
|
|
1821
|
+
)
|
|
1822
|
+
return "".join(rows)
|
|
1823
|
+
|
|
1417
1824
|
def _stream_progress_cell(self, s: Stream) -> str:
|
|
1418
1825
|
"""One progress cell for an active stream: a <progress> bar when the
|
|
1419
1826
|
total is known (always for a cached blob, since the size came off
|
|
@@ -1506,6 +1913,25 @@ def main():
|
|
|
1506
1913
|
httpd.mgr = mgr # type: ignore[attr-defined]
|
|
1507
1914
|
httpd.auto_fetch = not args.curate # type: ignore[attr-defined]
|
|
1508
1915
|
httpd.streams = StreamRegistry() # type: ignore[attr-defined]
|
|
1916
|
+
# Catalog state: env override, else the shipping default (nosi's
|
|
1917
|
+
# rolling catalog manifest). Seeded from the last persisted
|
|
1918
|
+
# catalog.toml on disk so a restart doesn't wipe the cache.
|
|
1919
|
+
catalog_url = (os.environ.get("WITHCACHE_CATALOG_URL") or DEFAULT_CATALOG_URL).strip()
|
|
1920
|
+
catalog = CatalogState(
|
|
1921
|
+
url=catalog_url,
|
|
1922
|
+
persist_path=os.path.join(store.data_dir, "catalog.toml"),
|
|
1923
|
+
)
|
|
1924
|
+
catalog.load_persisted()
|
|
1925
|
+
httpd.catalog = catalog # type: ignore[attr-defined]
|
|
1926
|
+
# If no catalog is persisted yet, kick a single startup fetch
|
|
1927
|
+
# in a daemon thread so the operator has something to look at
|
|
1928
|
+
# without needing to click Refresh on their first visit.
|
|
1929
|
+
# Failures record ``last_error`` in memory; the dashboard row
|
|
1930
|
+
# shows it. Never blocks the serve loop.
|
|
1931
|
+
if not catalog.entries:
|
|
1932
|
+
threading.Thread(
|
|
1933
|
+
target=catalog.fetch_now, name="withcache-catalog-init", daemon=True
|
|
1934
|
+
).start()
|
|
1509
1935
|
print(
|
|
1510
1936
|
f"withcache cache-host on http://{args.host}:{args.port} "
|
|
1511
1937
|
f"(data={store.data_dir}, keep_query={args.keep_query}, workers={args.workers}, "
|
|
@@ -643,6 +643,14 @@ def _start_withcache(auto_fetch=False):
|
|
|
643
643
|
httpd.mgr = server.DownloadManager(store, workers=1)
|
|
644
644
|
httpd.auto_fetch = auto_fetch
|
|
645
645
|
httpd.streams = server.StreamRegistry()
|
|
646
|
+
# Empty catalog with a bogus URL so the tab renders "click Refresh
|
|
647
|
+
# above to fetch the catalog manifest" instead of firing a real
|
|
648
|
+
# HTTP call. Tests that specifically exercise the catalog code
|
|
649
|
+
# can override this attribute after the helper returns.
|
|
650
|
+
httpd.catalog = server.CatalogState(
|
|
651
|
+
url="http://localhost/catalog.toml",
|
|
652
|
+
persist_path=os.path.join(store.data_dir, "catalog.toml"),
|
|
653
|
+
)
|
|
646
654
|
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
|
647
655
|
return httpd, store
|
|
648
656
|
|
|
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
|