withcache 0.6.2__tar.gz → 0.6.4__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 (27) hide show
  1. {withcache-0.6.2 → withcache-0.6.4}/PKG-INFO +15 -1
  2. {withcache-0.6.2 → withcache-0.6.4}/README.md +14 -0
  3. {withcache-0.6.2 → withcache-0.6.4}/shim/build.zig.zon +1 -1
  4. {withcache-0.6.2 → withcache-0.6.4}/src/withcache/__init__.py +1 -1
  5. {withcache-0.6.2 → withcache-0.6.4}/src/withcache/server.py +196 -132
  6. withcache-0.6.4/src/withcache/static/bootstrap-icons.min.css +5 -0
  7. withcache-0.6.4/src/withcache/static/bootstrap.min.css +12 -0
  8. withcache-0.6.4/src/withcache/static/fonts/bootstrap-icons.woff +0 -0
  9. withcache-0.6.4/src/withcache/static/fonts/bootstrap-icons.woff2 +0 -0
  10. {withcache-0.6.2 → withcache-0.6.4}/tests/test_differential.py +1 -1
  11. {withcache-0.6.2 → withcache-0.6.4}/tests/test_oras.py +16 -10
  12. {withcache-0.6.2 → withcache-0.6.4}/tests/test_withcache.py +10 -12
  13. withcache-0.6.2/src/withcache/static/pico.min.css +0 -4
  14. {withcache-0.6.2 → withcache-0.6.4}/.gitignore +0 -0
  15. {withcache-0.6.2 → withcache-0.6.4}/LICENSE +0 -0
  16. {withcache-0.6.2 → withcache-0.6.4}/deploy/Containerfile +0 -0
  17. {withcache-0.6.2 → withcache-0.6.4}/deploy/compose.yml +0 -0
  18. {withcache-0.6.2 → withcache-0.6.4}/hatch_build.py +0 -0
  19. {withcache-0.6.2 → withcache-0.6.4}/pyproject.toml +0 -0
  20. {withcache-0.6.2 → withcache-0.6.4}/shim/build.zig +0 -0
  21. {withcache-0.6.2 → withcache-0.6.4}/shim/shim.zig +0 -0
  22. {withcache-0.6.2 → withcache-0.6.4}/src/withcache/_shim.py +0 -0
  23. {withcache-0.6.2 → withcache-0.6.4}/src/withcache/client.py +0 -0
  24. {withcache-0.6.2 → withcache-0.6.4}/src/withcache/curlwithcache.py +0 -0
  25. {withcache-0.6.2 → withcache-0.6.4}/src/withcache/oras.py +0 -0
  26. {withcache-0.6.2 → withcache-0.6.4}/src/withcache/static/htmx.min.js +0 -0
  27. {withcache-0.6.2 → withcache-0.6.4}/src/withcache/wgetwithcache.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: withcache
3
- Version: 0.6.2
3
+ Version: 0.6.4
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>
@@ -285,6 +285,20 @@ the probe records the miss and, in auto-fetch mode, enqueues the fill, so the
285
285
  next call flips to the cache. The encoding is shared with the shims and server,
286
286
  so consumers stay in lockstep with the cache-host.
287
287
 
288
+ ### Pull an `oras://` artifact (oras + client together)
289
+
290
+ For a registry blob, pair `withcache.oras` (resolve the reference to a blob URL
291
+ + bearer) with `client.serve_url` (prefer the cache when warm). A runnable
292
+ example is in [`examples/pull_oras_blob.py`](examples/pull_oras_blob.py):
293
+
294
+ ```python
295
+ from withcache import client, oras
296
+
297
+ resolved = oras.resolve_ref("oras://ghcr.io/<owner>/<repo>@sha256:<digest>")
298
+ url, headers = resolved.blob_url, dict(resolved.headers)
299
+ url = client.serve_url(server, url, headers=headers) or url # cache when warm
300
+ ```
301
+
288
302
  ## Tests
289
303
 
290
304
  ```sh
@@ -267,6 +267,20 @@ the probe records the miss and, in auto-fetch mode, enqueues the fill, so the
267
267
  next call flips to the cache. The encoding is shared with the shims and server,
268
268
  so consumers stay in lockstep with the cache-host.
269
269
 
270
+ ### Pull an `oras://` artifact (oras + client together)
271
+
272
+ For a registry blob, pair `withcache.oras` (resolve the reference to a blob URL
273
+ + bearer) with `client.serve_url` (prefer the cache when warm). A runnable
274
+ example is in [`examples/pull_oras_blob.py`](examples/pull_oras_blob.py):
275
+
276
+ ```python
277
+ from withcache import client, oras
278
+
279
+ resolved = oras.resolve_ref("oras://ghcr.io/<owner>/<repo>@sha256:<digest>")
280
+ url, headers = resolved.blob_url, dict(resolved.headers)
281
+ url = client.serve_url(server, url, headers=headers) or url # cache when warm
282
+ ```
283
+
270
284
  ## Tests
271
285
 
272
286
  ```sh
@@ -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.2",
5
+ .version = "0.6.4",
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.2"
20
+ __version__ = "0.6.4"
21
21
 
22
22
  __all__ = ["__version__", "blob_url", "cache_base", "is_cached", "oras", "serve_url"]
@@ -22,6 +22,7 @@ admin password is set, the operator UI is left open (with a startup warning).
22
22
 
23
23
  import argparse
24
24
  import base64
25
+ import contextlib
25
26
  import hashlib
26
27
  import hmac
27
28
  import html
@@ -40,6 +41,7 @@ import urllib.parse
40
41
  import urllib.request
41
42
  from dataclasses import dataclass, field
42
43
  from datetime import datetime, timezone
44
+ from typing import ClassVar
43
45
 
44
46
  from . import __version__, oras
45
47
 
@@ -56,7 +58,14 @@ USER_AGENT = f"withcache-cache/{__version__}"
56
58
  # operating depth.
57
59
  RESUME_MAX_ATTEMPTS = 5
58
60
  STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
59
- MIME_TYPES = {".css": "text/css; charset=utf-8", ".js": "application/javascript; charset=utf-8"}
61
+ MIME_TYPES = {
62
+ ".css": "text/css; charset=utf-8",
63
+ ".js": "application/javascript; charset=utf-8",
64
+ ".woff": "font/woff",
65
+ ".woff2": "font/woff2",
66
+ ".png": "image/png",
67
+ ".svg": "image/svg+xml",
68
+ }
60
69
  _DB_WRITE_LOCK = threading.Lock()
61
70
 
62
71
 
@@ -304,10 +313,8 @@ class Store:
304
313
  """Drop a cached artifact (row + bytes). The manual half of eviction."""
305
314
  with _DB_WRITE_LOCK, self.conn() as c:
306
315
  c.execute("DELETE FROM blobs WHERE key=?", (key,))
307
- try:
316
+ with contextlib.suppress(FileNotFoundError):
308
317
  os.remove(self.blob_path(key))
309
- except FileNotFoundError:
310
- pass
311
318
 
312
319
  def store_from_origin(
313
320
  self,
@@ -471,7 +478,6 @@ class Store:
471
478
  # --------------------------------------------------------------------------
472
479
  # Background download manager (thread pool; modelled on bty's job managers)
473
480
  # --------------------------------------------------------------------------
474
- JOB_STATES = ("queued", "running", "completed", "cancelled", "failed")
475
481
  PENDING_STATES = frozenset(("queued", "running"))
476
482
 
477
483
 
@@ -841,14 +847,24 @@ class Handler(http.server.BaseHTTPRequestHandler):
841
847
  return self.headers.get("HX-Request") == "true"
842
848
 
843
849
  def serve_static(self, parsed):
844
- name = os.path.basename(parsed.path) # basename blocks path traversal
845
- path = os.path.join(STATIC_DIR, name)
846
- if not name or not os.path.isfile(path):
850
+ """Serve files under ``src/withcache/static/`` (Bootstrap CSS +
851
+ Bootstrap Icons CSS + htmx.min.js + the icon font files under
852
+ ``static/fonts/`` that bootstrap-icons.min.css references via
853
+ a relative ``fonts/…`` src). Constrain to ``static/`` and
854
+ ``static/fonts/`` explicitly; abspath+startswith rejects any
855
+ ``..`` traversal past the static root."""
856
+ rel = parsed.path[len("/static/") :]
857
+ if not rel or rel.endswith("/"):
847
858
  self.send_text(404, "not found\n")
848
859
  return
849
- with open(path, "rb") as f:
860
+ target = os.path.abspath(os.path.join(STATIC_DIR, rel))
861
+ static_root = os.path.abspath(STATIC_DIR) + os.sep
862
+ if not target.startswith(static_root) or not os.path.isfile(target):
863
+ self.send_text(404, "not found\n")
864
+ return
865
+ with open(target, "rb") as f:
850
866
  data = f.read()
851
- ext = os.path.splitext(name)[1]
867
+ ext = os.path.splitext(target)[1]
852
868
  self.send_response(200)
853
869
  self.send_header("Content-Type", MIME_TYPES.get(ext, "application/octet-stream"))
854
870
  self.send_header("Content-Length", str(len(data)))
@@ -1012,89 +1028,144 @@ class Handler(http.server.BaseHTTPRequestHandler):
1012
1028
  self.end_headers()
1013
1029
 
1014
1030
  # -- HTML --------------------------------------------------------------
1015
- STATUS_COLORS = {
1016
- "queued": "#888",
1017
- "running": "var(--pico-primary, #0172ad)",
1018
- "completed": "#2e7d32",
1019
- "failed": "#c0392b",
1020
- "cancelled": "#888",
1031
+ STATUS_COLORS: ClassVar[dict[str, str]] = {
1032
+ "queued": "var(--bs-secondary)",
1033
+ "running": "var(--bs-primary)",
1034
+ "completed": "var(--bs-success)",
1035
+ "failed": "var(--bs-danger)",
1036
+ "cancelled": "var(--bs-secondary)",
1021
1037
  }
1022
1038
 
1039
+ # bty ships a Bootstrap 5 stack (bootstrap.min.css +
1040
+ # bootstrap-icons.min.css + htmx). All three ecosystem services
1041
+ # (bty, nbdmux, withcache) share that stack so operators only
1042
+ # learn one UI grammar; the primary hue is what tells them
1043
+ # which service they're on. The trio sits on a
1044
+ # navy -> dark-magenta -> magenta gradient (cool -> hot);
1045
+ # withcache is the dark-magenta middle (the byte cache that
1046
+ # feeds nbdmux and every other consumer).
1047
+ _PRIMARY_HEX = "#8f1b71" # dark-magenta
1048
+ _PRIMARY_HOVER = "#7a1861"
1049
+ _PRIMARY_RGB = "143, 27, 113"
1050
+
1023
1051
  def _head(self, title: str) -> str:
1052
+ primary = self._PRIMARY_HEX
1053
+ hover = self._PRIMARY_HOVER
1054
+ rgb = self._PRIMARY_RGB
1024
1055
  return f"""<!doctype html>
1025
1056
  <html lang="en"><head>
1026
1057
  <meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
1027
1058
  <title>{title}</title>
1028
- <link rel="stylesheet" href="/static/pico.min.css">
1059
+ <link rel="stylesheet" href="/static/bootstrap.min.css">
1060
+ <link rel="stylesheet" href="/static/bootstrap-icons.min.css">
1029
1061
  <script src="/static/htmx.min.js"></script>
1030
1062
  <style>
1031
- main.container {{ max-width: 1100px; padding-top: 1rem; }}
1032
- h4 {{ margin-bottom: .4rem; }}
1033
- table {{ font-size: .9rem; margin-bottom: 0; }}
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
1066
+ without patching bootstrap.min.css. */
1067
+ :root {{
1068
+ --bs-primary: {primary};
1069
+ --bs-primary-rgb: {rgb};
1070
+ --bs-link-color: {primary};
1071
+ --bs-link-hover-color: {hover};
1072
+ }}
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}; }}
1076
+ .bg-primary {{ --bs-bg-opacity: 1; background-color: {primary} !important; }}
1077
+ .text-primary {{ --bs-text-opacity: 1; color: {primary} !important; }}
1078
+ .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%); }}
1085
+ code {{ color: inherit; font-size: .85em; }}
1034
1086
  .url {{ word-break: break-all; }}
1035
1087
  .num {{ text-align: right; }}
1036
- .mono {{ font-family: var(--pico-font-family-monospace); font-size: .85em; }}
1037
- td form {{ display: inline; margin: 0; }}
1038
- td button {{ width: auto; display: inline-block; margin: 0 .3rem 0 0;
1039
- padding: .15rem .6rem; font-size: .8rem; }}
1040
- td progress {{ margin: 0 0 .15rem; }}
1088
+ .mono {{ font-family: var(--bs-font-monospace); font-size: .85em; }}
1041
1089
  #spin {{ width: 7rem; height: .5rem; margin: 0; }}
1042
- .row {{ display: flex; align-items: center; justify-content: space-between; }}
1043
- .err {{ background: var(--pico-del-color, #c0392b); color: #fff;
1044
- padding: .7rem 1rem; border-radius: var(--pico-border-radius); margin-bottom: 1rem; }}
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; }}
1101
+ section.tab.active-tab {{ display: block; }}
1045
1102
  </style>
1046
1103
  </head>"""
1047
1104
 
1048
1105
  def render_login(self, error: str = "") -> str:
1049
- err = f'<div class="err">{html.escape(error)}</div>' if error else ""
1050
- return f"""{self._head("withcache login")}
1051
- <body><main class="container">
1052
- <article style="max-width: 24rem; margin: 4rem auto;">
1053
- <hgroup>
1054
- <h2>withcache <small class="mono">v{html.escape(__version__)}</small></h2>
1055
- <p>operator login</p>
1056
- </hgroup>
1057
- {err}
1058
- <form method="post" action="/ui/login">
1059
- <input type="password" name="password" placeholder="Admin password" autofocus required>
1060
- <button type="submit">Log in</button>
1061
- </form>
1062
- </article>
1106
+ err = f'<div class="alert alert-danger">{html.escape(error)}</div>' if error else ""
1107
+ return f"""{self._head("withcache - login")}
1108
+ <body>
1109
+ <div class="brand-accent"></div>
1110
+ <main class="container py-5">
1111
+ <div class="card mx-auto" style="max-width: 24rem;">
1112
+ <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>
1118
+ {err}
1119
+ <form method="post" action="/ui/login">
1120
+ <div class="mb-3">
1121
+ <label class="form-label" for="pw">Admin password</label>
1122
+ <input class="form-control" id="pw" type="password" name="password" autofocus required>
1123
+ </div>
1124
+ <button class="btn btn-primary w-100" type="submit">Log in</button>
1125
+ </form>
1126
+ </div>
1127
+ </div>
1063
1128
  </main></body></html>"""
1064
1129
 
1065
1130
  def render_page(self) -> str:
1066
1131
  logout = (
1067
- '<li><form method="post" action="/ui/logout" style="margin:0">'
1068
- '<button type="submit" class="secondary outline" '
1069
- 'style="width:auto;padding:.3rem .8rem">Log out</button></form></li>'
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>"
1070
1135
  if self.auth.enabled
1071
1136
  else ""
1072
1137
  )
1073
1138
  return f"""{self._head("withcache cache-host")}
1074
- <body><main class="container">
1075
- <nav>
1076
- <ul><li>
1077
- <strong>withcache</strong>
1078
- &nbsp;<small class="mono">v{html.escape(__version__)}</small>
1079
- </li></ul>
1080
- <ul>
1081
- <li><progress id="spin" class="htmx-indicator"></progress></li>
1139
+ <body>
1140
+ <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>
1082
1150
  {logout}
1083
- </ul>
1084
- </nav>
1085
-
1086
- <h4>Add from URI</h4>
1087
- <form hx-post="/admin/fetch" hx-target="#dash" hx-swap="innerHTML"
1088
- hx-indicator="#spin" hx-on::after-request="this.reset()">
1089
- <fieldset role="group">
1090
- <input type="url" name="url" placeholder="https://origin/path/artifact.tar.gz" required>
1091
- <!-- white-space: nowrap so the label "Fetch & store" stays on
1092
- one line; default flex-grow inside fieldset[role=group]
1093
- shrinks the button to content width, which on a narrow
1094
- viewport wrapped the ampersand to a second line. -->
1095
- <button type="submit" style="white-space: nowrap;">Fetch &amp; store</button>
1096
- </fieldset>
1097
- </form>
1151
+ </div>
1152
+ </div>
1153
+ </nav>
1154
+ <main class="container py-4">
1155
+ <div class="card mb-4">
1156
+ <div class="card-header"><i class="bi bi-cloud-download text-primary"></i> Add from URI</div>
1157
+ <div class="card-body">
1158
+ <form hx-post="/admin/fetch" hx-target="#dash" hx-swap="innerHTML"
1159
+ hx-indicator="#spin" hx-on::after-request="this.reset()">
1160
+ <div class="input-group">
1161
+ <input class="form-control" type="url" name="url"
1162
+ placeholder="https://origin/path/artifact.tar.gz" required>
1163
+ <button class="btn btn-primary" type="submit" style="white-space: nowrap;"
1164
+ >Fetch &amp; store</button>
1165
+ </div>
1166
+ </form>
1167
+ </div>
1168
+ </div>
1098
1169
 
1099
1170
  <!-- The hx-trigger gates polling on the user NOT having an active
1100
1171
  text selection, so highlight-and-copy a URL out of a table cell
@@ -1190,26 +1261,8 @@ class Handler(http.server.BaseHTTPRequestHandler):
1190
1261
  # newly-inserted node is not the one ``:target`` resolved to
1191
1262
  # at hash-change time). The visible symptom was the tab
1192
1263
  # snapping back to Streams within a second of every click.
1193
- tab_style = """
1194
- <style>
1195
- nav.tabs { margin: 1rem 0 .25rem; border-bottom: 1px solid var(--pico-muted-border-color); }
1196
- nav.tabs ul { display: flex; gap: 0; padding: 0; margin: 0; list-style: none; }
1197
- nav.tabs li { margin: 0; }
1198
- nav.tabs a {
1199
- display: inline-block; padding: .45rem .9rem; text-decoration: none;
1200
- color: var(--pico-muted-color); border-bottom: 2px solid transparent;
1201
- margin-bottom: -1px; font-size: .9rem;
1202
- }
1203
- nav.tabs a:hover { color: var(--pico-color); }
1204
- nav.tabs a.active-tab {
1205
- color: var(--pico-color);
1206
- border-bottom-color: var(--pico-primary, #0172ad);
1207
- font-weight: 600;
1208
- }
1209
- section.tab { display: none; padding-top: .75rem; }
1210
- section.tab.active-tab { display: block; }
1211
- </style>
1212
- """
1264
+ # The class hooks live in _head's <style> so this dash render
1265
+ # can be swapped 1 Hz without re-declaring them.
1213
1266
 
1214
1267
  stream_rows = (
1215
1268
  "".join(
@@ -1221,12 +1274,14 @@ class Handler(http.server.BaseHTTPRequestHandler):
1221
1274
  </tr>"""
1222
1275
  for s in streams
1223
1276
  )
1224
- or '<tr><td colspan="4"><em>No active streams.</em></td></tr>'
1277
+ or '<tr><td colspan="4" class="text-center text-muted">'
1278
+ "<em>No active streams.</em></td></tr>"
1225
1279
  )
1226
1280
 
1227
1281
  job_rows = (
1228
1282
  "".join(self._job_row(j) for j in jobs)
1229
- or '<tr><td colspan="4"><em>No downloads yet.</em></td></tr>'
1283
+ or '<tr><td colspan="4" class="text-center text-muted">'
1284
+ "<em>No downloads yet.</em></td></tr>"
1230
1285
  )
1231
1286
 
1232
1287
  miss_rows = (
@@ -1235,21 +1290,23 @@ class Handler(http.server.BaseHTTPRequestHandler):
1235
1290
  <td class="url">{html.escape(m["url"])}</td>
1236
1291
  <td class="num">{m["count"]}</td>
1237
1292
  <td><small>{html.escape(m["last_seen"])}</small></td>
1238
- <td>
1293
+ <td class="d-flex gap-2 flex-wrap">
1239
1294
  <form hx-post="/admin/fetch" hx-target="#dash"
1240
- hx-swap="innerHTML" hx-indicator="#spin">
1295
+ hx-swap="innerHTML" hx-indicator="#spin" class="m-0">
1241
1296
  <input type="hidden" name="url" value="{html.escape(m["url"], quote=True)}">
1242
- <button type="submit">Download</button>
1297
+ <button class="btn btn-sm btn-primary" type="submit">Download</button>
1243
1298
  </form>
1244
- <form hx-post="/admin/dismiss" hx-target="#dash" hx-swap="innerHTML">
1299
+ <form hx-post="/admin/dismiss" hx-target="#dash" hx-swap="innerHTML" class="m-0">
1245
1300
  <input type="hidden" name="key" value="{html.escape(m["key"], quote=True)}">
1246
- <button type="submit" class="secondary outline">Dismiss</button>
1301
+ <button class="btn btn-sm btn-outline-secondary" type="submit"
1302
+ >Dismiss</button>
1247
1303
  </form>
1248
1304
  </td>
1249
1305
  </tr>"""
1250
1306
  for m in misses
1251
1307
  )
1252
- or '<tr><td colspan="4"><em>No misses recorded.</em></td></tr>'
1308
+ or '<tr><td colspan="4" class="text-center text-muted">'
1309
+ "<em>No misses recorded.</em></td></tr>"
1253
1310
  )
1254
1311
 
1255
1312
  # Build the per-row /b/ serve URL once; the cell wraps the
@@ -1275,19 +1332,21 @@ class Handler(http.server.BaseHTTPRequestHandler):
1275
1332
  <td>{human_size(b["size"])}</td>
1276
1333
  <td class="num">{b["hits"]}</td>
1277
1334
  <td class="num">{b["misses"]}</td>
1278
- <td class="mono">{html.escape(b["sha256"][:12])}…</td>
1335
+ <td class="mono">{html.escape(b["sha256"][:12])}...</td>
1279
1336
  <td><small>{html.escape(b["fetched_at"])}</small></td>
1280
1337
  <td>
1281
1338
  <form hx-post="/admin/delete" hx-target="#dash" hx-swap="innerHTML"
1282
- hx-confirm="Delete this cached artifact?">
1339
+ hx-confirm="Delete this cached artifact?" class="m-0">
1283
1340
  <input type="hidden" name="key" value="{html.escape(b["key"], quote=True)}">
1284
- <button type="submit" class="secondary outline">Delete</button>
1341
+ <button class="btn btn-sm btn-outline-danger" type="submit"
1342
+ >Delete</button>
1285
1343
  </form>
1286
1344
  </td>
1287
1345
  </tr>"""
1288
1346
  for b in blobs
1289
1347
  )
1290
- or '<tr><td colspan="7"><em>Cache is empty.</em></td></tr>'
1348
+ or '<tr><td colspan="7" class="text-center text-muted">'
1349
+ "<em>Cache is empty.</em></td></tr>"
1291
1350
  )
1292
1351
 
1293
1352
  # Per-tab counts let the operator see at a glance whether each
@@ -1296,59 +1355,63 @@ class Handler(http.server.BaseHTTPRequestHandler):
1296
1355
  njobs = len(jobs)
1297
1356
 
1298
1357
  return f"""
1299
- <p><small>{nblobs} cached ({used}){full} &middot; {nmisses} pending miss(es)</small></p>
1300
- {tab_style}
1301
- <nav class="tabs"><ul>
1302
- <li><a href="#tab-cached" class="{_active("tab-cached").lstrip()}"
1303
- >Cached ({nblobs})</a></li>
1304
- <li><a href="#tab-streams" class="{_active("tab-streams").lstrip()}"
1305
- >Streams ({nstreams})</a></li>
1306
- <li><a href="#tab-downloads" class="{_active("tab-downloads").lstrip()}"
1307
- >Downloads ({njobs})</a></li>
1308
- <li><a href="#tab-misses" class="{_active("tab-misses").lstrip()}"
1309
- >Misses ({nmisses})</a></li>
1310
- </ul></nav>
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>
1311
1372
 
1312
1373
  <section id="tab-cached" class="tab{_active("tab-cached")}">
1313
- <figure><table class="striped">
1314
- <thead><tr>
1374
+ <div class="table-responsive"><table class="table table-sm table-striped table-hover mb-0">
1375
+ <thead class="table-light"><tr>
1315
1376
  <th>URL</th><th>Size</th><th class="num">Hits</th><th class="num">Misses</th>
1316
1377
  <th>SHA-256</th><th>Fetched</th><th>Action</th>
1317
1378
  </tr></thead>
1318
1379
  <tbody>{blob_rows}</tbody>
1319
- </table></figure>
1380
+ </table></div>
1320
1381
  </section>
1321
1382
 
1322
1383
  <section id="tab-streams" class="tab{_active("tab-streams")}">
1323
- <figure><table class="striped">
1324
- <thead><tr>
1384
+ <div class="table-responsive"><table class="table table-sm table-striped table-hover mb-0">
1385
+ <thead class="table-light"><tr>
1325
1386
  <th>URL</th><th>Client</th><th>Progress</th><th>Age</th>
1326
1387
  </tr></thead>
1327
1388
  <tbody>{stream_rows}</tbody>
1328
- </table></figure>
1389
+ </table></div>
1329
1390
  </section>
1330
1391
 
1331
1392
  <section id="tab-downloads" class="tab{_active("tab-downloads")}">
1332
- <div class="row">
1333
- <small>Auto-fetch workers feeding the cache.</small>
1334
- <form hx-post="/admin/clear" hx-target="#dash" hx-swap="innerHTML" style="margin:0">
1335
- <button type="submit" class="secondary outline" style="width:auto;padding:.2rem .7rem">
1336
- Clear finished</button>
1393
+ <div class="d-flex align-items-center justify-content-between mb-2">
1394
+ <small class="text-muted">Auto-fetch workers feeding the cache.</small>
1395
+ <form hx-post="/admin/clear" hx-target="#dash" hx-swap="innerHTML" class="m-0">
1396
+ <button class="btn btn-sm btn-outline-secondary" type="submit"
1397
+ >Clear finished</button>
1337
1398
  </form>
1338
1399
  </div>
1339
- <figure><table class="striped">
1340
- <thead><tr><th>Artifact</th><th>Progress</th><th>Status</th><th></th></tr></thead>
1400
+ <div class="table-responsive"><table class="table table-sm table-striped table-hover mb-0">
1401
+ <thead class="table-light"><tr>
1402
+ <th>Artifact</th><th>Progress</th><th>Status</th><th></th>
1403
+ </tr></thead>
1341
1404
  <tbody>{job_rows}</tbody>
1342
- </table></figure>
1405
+ </table></div>
1343
1406
  </section>
1344
1407
 
1345
1408
  <section id="tab-misses" class="tab{_active("tab-misses")}">
1346
- <figure><table class="striped">
1347
- <thead><tr>
1409
+ <div class="table-responsive"><table class="table table-sm table-striped table-hover mb-0">
1410
+ <thead class="table-light"><tr>
1348
1411
  <th>URL</th><th class="num">Misses</th><th>Last seen</th><th>Action</th>
1349
1412
  </tr></thead>
1350
1413
  <tbody>{miss_rows}</tbody>
1351
- </table></figure>
1414
+ </table></div>
1352
1415
  </section>"""
1353
1416
 
1354
1417
  def _stream_progress_cell(self, s: Stream) -> str:
@@ -1386,11 +1449,12 @@ class Handler(http.server.BaseHTTPRequestHandler):
1386
1449
  cancel = ""
1387
1450
  if j.status in PENDING_STATES:
1388
1451
  cancel = (
1389
- '<form hx-post="/admin/cancel" hx-target="#dash" hx-swap="innerHTML">'
1452
+ '<form hx-post="/admin/cancel" hx-target="#dash" hx-swap="innerHTML" class="m-0">'
1390
1453
  f'<input type="hidden" name="id" value="{j.id}">'
1391
- '<button type="submit" class="secondary outline">Cancel</button></form>'
1454
+ '<button class="btn btn-sm btn-outline-secondary" type="submit"'
1455
+ ">Cancel</button></form>"
1392
1456
  )
1393
- color = self.STATUS_COLORS.get(j.status, "#888")
1457
+ color = self.STATUS_COLORS.get(j.status, "var(--bs-secondary)")
1394
1458
  return f"""<tr>
1395
1459
  <td class="url" title="{html.escape(j.url, quote=True)}">{html.escape(name)}</td>
1396
1460
  <td>{prog}</td>