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.
Files changed (26) hide show
  1. {withcache-0.6.5 → withcache-0.7.1}/PKG-INFO +2 -2
  2. {withcache-0.6.5 → withcache-0.7.1}/pyproject.toml +2 -2
  3. {withcache-0.6.5 → withcache-0.7.1}/shim/build.zig.zon +1 -1
  4. {withcache-0.6.5 → withcache-0.7.1}/src/withcache/__init__.py +1 -1
  5. {withcache-0.6.5 → withcache-0.7.1}/src/withcache/server.py +512 -86
  6. {withcache-0.6.5 → withcache-0.7.1}/tests/test_withcache.py +8 -0
  7. {withcache-0.6.5 → withcache-0.7.1}/.gitignore +0 -0
  8. {withcache-0.6.5 → withcache-0.7.1}/LICENSE +0 -0
  9. {withcache-0.6.5 → withcache-0.7.1}/README.md +0 -0
  10. {withcache-0.6.5 → withcache-0.7.1}/deploy/Containerfile +0 -0
  11. {withcache-0.6.5 → withcache-0.7.1}/deploy/compose.yml +0 -0
  12. {withcache-0.6.5 → withcache-0.7.1}/hatch_build.py +0 -0
  13. {withcache-0.6.5 → withcache-0.7.1}/shim/build.zig +0 -0
  14. {withcache-0.6.5 → withcache-0.7.1}/shim/shim.zig +0 -0
  15. {withcache-0.6.5 → withcache-0.7.1}/src/withcache/_shim.py +0 -0
  16. {withcache-0.6.5 → withcache-0.7.1}/src/withcache/client.py +0 -0
  17. {withcache-0.6.5 → withcache-0.7.1}/src/withcache/curlwithcache.py +0 -0
  18. {withcache-0.6.5 → withcache-0.7.1}/src/withcache/oras.py +0 -0
  19. {withcache-0.6.5 → withcache-0.7.1}/src/withcache/static/bootstrap-icons.min.css +0 -0
  20. {withcache-0.6.5 → withcache-0.7.1}/src/withcache/static/bootstrap.min.css +0 -0
  21. {withcache-0.6.5 → withcache-0.7.1}/src/withcache/static/fonts/bootstrap-icons.woff +0 -0
  22. {withcache-0.6.5 → withcache-0.7.1}/src/withcache/static/fonts/bootstrap-icons.woff2 +0 -0
  23. {withcache-0.6.5 → withcache-0.7.1}/src/withcache/static/htmx.min.js +0 -0
  24. {withcache-0.6.5 → withcache-0.7.1}/src/withcache/wgetwithcache.py +0 -0
  25. {withcache-0.6.5 → withcache-0.7.1}/tests/test_differential.py +0 -0
  26. {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.6.5
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.10
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"
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 = "py310"
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.6.5",
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.6.5"
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 datetime, timezone
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(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
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
- /* Bootstrap 5 exposes --bs-primary + a matching -rgb triplet
1064
- used for translucent variants (alerts, focus rings, .bg-*-
1065
- subtle). Overriding both here re-tints every stock component
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 {{ --bs-btn-bg: {primary}; --bs-btn-border-color: {primary};
1074
- --bs-btn-hover-bg: {hover}; --bs-btn-hover-border-color: {hover};
1075
- --bs-btn-active-bg: {hover}; --bs-btn-active-border-color: {hover}; }}
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: navy -> dark-magenta -> magenta gradient shared
1080
- across bty (navy), withcache (dark-magenta) and nbdmux
1081
- (magenta) so the trio reads as one product family from any
1082
- of the three consoles. */
1083
- .brand-accent {{ height: 3px;
1084
- background: linear-gradient(90deg, #0d3585 0%, #8f1b71 50%, #d63384 100%); }}
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
- /* Tabs: Bootstrap nav-tabs, tinted via the active class. The
1091
- content panels toggle visibility via ``section.tab.active-tab``
1092
- so the same 1 Hz htmx innerHTML swap that used to work with
1093
- the Pico shell keeps working here without changing the JS. */
1094
- nav.tabs .nav-link {{ color: var(--bs-secondary-color); border: none;
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
- <main class="container py-5">
1111
- <div class="card mx-auto" style="max-width: 24rem;">
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
- <h3 class="card-title fw-bold text-primary">
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
- logout = (
1132
- '<form method="post" action="/ui/logout" class="d-inline m-0">'
1133
- '<button type="submit" class="btn btn-sm btn-outline-secondary">'
1134
- "Log out</button></form>"
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 navbar-light bg-light border-bottom">
1142
- <div class="container">
1143
- <a class="navbar-brand fw-bold text-primary" href="/">
1144
- <i class="bi bi-hdd-stack"></i> withcache
1145
- <span class="badge bg-primary bg-opacity-10 text-primary ms-1"
1146
- >v{html.escape(__version__)}</span>
1147
- </a>
1148
- <div class="d-flex align-items-center gap-3">
1149
- <progress id="spin" class="htmx-indicator"></progress>
1150
- {logout}
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. ``isCollapsed`` is true when
1173
- there's no selection or the caret is a zero-width point; once
1174
- the operator releases / clears the selection polling resumes
1175
- on the next 1 s tick.
1176
- ``hx-headers`` sends the current URL hash (the active tab id)
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. Applies an ``active-tab`` class to the
1189
- ``section.tab`` whose id matches the URL hash (defaulting to
1190
- the first section when no hash is set) and to the
1191
- corresponding ``nav.tabs a``. Runs on initial load, on every
1192
- click into a tab link (so the operator gets immediate
1193
- feedback before the next htmx tick), and on every
1194
- ``htmx:afterSettle`` so the class survives the 1 Hz
1195
- innerHTML replacement of ``#dash``. Without this the
1196
- previous ``:target``-based CSS would snap the operator back
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('#dash nav.tabs a').forEach(function (a) {{
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('#dash nav.tabs a');
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-2">{nblobs} cached ({used}){full}
1359
- &middot; {nmisses} pending miss(es)</p>
1360
- <nav class="tabs border-bottom mb-2">
1361
- <ul class="nav">
1362
- <li class="nav-item"><a class="nav-link {_active("tab-cached").lstrip()}"
1363
- href="#tab-cached">Cached ({nblobs})</a></li>
1364
- <li class="nav-item"><a class="nav-link {_active("tab-streams").lstrip()}"
1365
- href="#tab-streams">Streams ({nstreams})</a></li>
1366
- <li class="nav-item"><a class="nav-link {_active("tab-downloads").lstrip()}"
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
+ &nbsp;<code>{html.escape(self.catalog.url)}</code></div>
1757
+ <div><small class="text-muted">fetched</small>
1758
+ &nbsp;<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