withcache 0.6.3__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.3 → withcache-0.6.4}/PKG-INFO +1 -1
  2. {withcache-0.6.3 → withcache-0.6.4}/shim/build.zig.zon +1 -1
  3. {withcache-0.6.3 → withcache-0.6.4}/src/withcache/__init__.py +1 -1
  4. {withcache-0.6.3 → withcache-0.6.4}/src/withcache/server.py +192 -127
  5. withcache-0.6.4/src/withcache/static/bootstrap-icons.min.css +5 -0
  6. withcache-0.6.4/src/withcache/static/bootstrap.min.css +12 -0
  7. withcache-0.6.4/src/withcache/static/fonts/bootstrap-icons.woff +0 -0
  8. withcache-0.6.4/src/withcache/static/fonts/bootstrap-icons.woff2 +0 -0
  9. withcache-0.6.3/src/withcache/static/pico.min.css +0 -4
  10. {withcache-0.6.3 → withcache-0.6.4}/.gitignore +0 -0
  11. {withcache-0.6.3 → withcache-0.6.4}/LICENSE +0 -0
  12. {withcache-0.6.3 → withcache-0.6.4}/README.md +0 -0
  13. {withcache-0.6.3 → withcache-0.6.4}/deploy/Containerfile +0 -0
  14. {withcache-0.6.3 → withcache-0.6.4}/deploy/compose.yml +0 -0
  15. {withcache-0.6.3 → withcache-0.6.4}/hatch_build.py +0 -0
  16. {withcache-0.6.3 → withcache-0.6.4}/pyproject.toml +0 -0
  17. {withcache-0.6.3 → withcache-0.6.4}/shim/build.zig +0 -0
  18. {withcache-0.6.3 → withcache-0.6.4}/shim/shim.zig +0 -0
  19. {withcache-0.6.3 → withcache-0.6.4}/src/withcache/_shim.py +0 -0
  20. {withcache-0.6.3 → withcache-0.6.4}/src/withcache/client.py +0 -0
  21. {withcache-0.6.3 → withcache-0.6.4}/src/withcache/curlwithcache.py +0 -0
  22. {withcache-0.6.3 → withcache-0.6.4}/src/withcache/oras.py +0 -0
  23. {withcache-0.6.3 → withcache-0.6.4}/src/withcache/static/htmx.min.js +0 -0
  24. {withcache-0.6.3 → withcache-0.6.4}/src/withcache/wgetwithcache.py +0 -0
  25. {withcache-0.6.3 → withcache-0.6.4}/tests/test_differential.py +0 -0
  26. {withcache-0.6.3 → withcache-0.6.4}/tests/test_oras.py +0 -0
  27. {withcache-0.6.3 → withcache-0.6.4}/tests/test_withcache.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: withcache
3
- Version: 0.6.3
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>
@@ -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.3",
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.3"
20
+ __version__ = "0.6.4"
21
21
 
22
22
  __all__ = ["__version__", "blob_url", "cache_base", "is_cached", "oras", "serve_url"]
@@ -58,7 +58,14 @@ USER_AGENT = f"withcache-cache/{__version__}"
58
58
  # operating depth.
59
59
  RESUME_MAX_ATTEMPTS = 5
60
60
  STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
61
- 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
+ }
62
69
  _DB_WRITE_LOCK = threading.Lock()
63
70
 
64
71
 
@@ -840,14 +847,24 @@ class Handler(http.server.BaseHTTPRequestHandler):
840
847
  return self.headers.get("HX-Request") == "true"
841
848
 
842
849
  def serve_static(self, parsed):
843
- name = os.path.basename(parsed.path) # basename blocks path traversal
844
- path = os.path.join(STATIC_DIR, name)
845
- 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("/"):
846
858
  self.send_text(404, "not found\n")
847
859
  return
848
- 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:
849
866
  data = f.read()
850
- ext = os.path.splitext(name)[1]
867
+ ext = os.path.splitext(target)[1]
851
868
  self.send_response(200)
852
869
  self.send_header("Content-Type", MIME_TYPES.get(ext, "application/octet-stream"))
853
870
  self.send_header("Content-Length", str(len(data)))
@@ -1012,88 +1029,143 @@ class Handler(http.server.BaseHTTPRequestHandler):
1012
1029
 
1013
1030
  # -- HTML --------------------------------------------------------------
1014
1031
  STATUS_COLORS: ClassVar[dict[str, str]] = {
1015
- "queued": "#888",
1016
- "running": "var(--pico-primary, #0172ad)",
1017
- "completed": "#2e7d32",
1018
- "failed": "#c0392b",
1019
- "cancelled": "#888",
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)",
1020
1037
  }
1021
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
+
1022
1051
  def _head(self, title: str) -> str:
1052
+ primary = self._PRIMARY_HEX
1053
+ hover = self._PRIMARY_HOVER
1054
+ rgb = self._PRIMARY_RGB
1023
1055
  return f"""<!doctype html>
1024
1056
  <html lang="en"><head>
1025
1057
  <meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
1026
1058
  <title>{title}</title>
1027
- <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">
1028
1061
  <script src="/static/htmx.min.js"></script>
1029
1062
  <style>
1030
- main.container {{ max-width: 1100px; padding-top: 1rem; }}
1031
- h4 {{ margin-bottom: .4rem; }}
1032
- 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; }}
1033
1086
  .url {{ word-break: break-all; }}
1034
1087
  .num {{ text-align: right; }}
1035
- .mono {{ font-family: var(--pico-font-family-monospace); font-size: .85em; }}
1036
- td form {{ display: inline; margin: 0; }}
1037
- td button {{ width: auto; display: inline-block; margin: 0 .3rem 0 0;
1038
- padding: .15rem .6rem; font-size: .8rem; }}
1039
- td progress {{ margin: 0 0 .15rem; }}
1088
+ .mono {{ font-family: var(--bs-font-monospace); font-size: .85em; }}
1040
1089
  #spin {{ width: 7rem; height: .5rem; margin: 0; }}
1041
- .row {{ display: flex; align-items: center; justify-content: space-between; }}
1042
- .err {{ background: var(--pico-del-color, #c0392b); color: #fff;
1043
- 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; }}
1044
1102
  </style>
1045
1103
  </head>"""
1046
1104
 
1047
1105
  def render_login(self, error: str = "") -> str:
1048
- err = f'<div class="err">{html.escape(error)}</div>' if error else ""
1049
- return f"""{self._head("withcache login")}
1050
- <body><main class="container">
1051
- <article style="max-width: 24rem; margin: 4rem auto;">
1052
- <hgroup>
1053
- <h2>withcache <small class="mono">v{html.escape(__version__)}</small></h2>
1054
- <p>operator login</p>
1055
- </hgroup>
1056
- {err}
1057
- <form method="post" action="/ui/login">
1058
- <input type="password" name="password" placeholder="Admin password" autofocus required>
1059
- <button type="submit">Log in</button>
1060
- </form>
1061
- </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>
1062
1128
  </main></body></html>"""
1063
1129
 
1064
1130
  def render_page(self) -> str:
1065
1131
  logout = (
1066
- '<li><form method="post" action="/ui/logout" style="margin:0">'
1067
- '<button type="submit" class="secondary outline" '
1068
- '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>"
1069
1135
  if self.auth.enabled
1070
1136
  else ""
1071
1137
  )
1072
1138
  return f"""{self._head("withcache cache-host")}
1073
- <body><main class="container">
1074
- <nav>
1075
- <ul><li>
1076
- <strong>withcache</strong>
1077
- &nbsp;<small class="mono">v{html.escape(__version__)}</small>
1078
- </li></ul>
1079
- <ul>
1080
- <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>
1081
1150
  {logout}
1082
- </ul>
1083
- </nav>
1084
-
1085
- <h4>Add from URI</h4>
1086
- <form hx-post="/admin/fetch" hx-target="#dash" hx-swap="innerHTML"
1087
- hx-indicator="#spin" hx-on::after-request="this.reset()">
1088
- <fieldset role="group">
1089
- <input type="url" name="url" placeholder="https://origin/path/artifact.tar.gz" required>
1090
- <!-- white-space: nowrap so the label "Fetch & store" stays on
1091
- one line; default flex-grow inside fieldset[role=group]
1092
- shrinks the button to content width, which on a narrow
1093
- viewport wrapped the ampersand to a second line. -->
1094
- <button type="submit" style="white-space: nowrap;">Fetch &amp; store</button>
1095
- </fieldset>
1096
- </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>
1097
1169
 
1098
1170
  <!-- The hx-trigger gates polling on the user NOT having an active
1099
1171
  text selection, so highlight-and-copy a URL out of a table cell
@@ -1189,26 +1261,8 @@ class Handler(http.server.BaseHTTPRequestHandler):
1189
1261
  # newly-inserted node is not the one ``:target`` resolved to
1190
1262
  # at hash-change time). The visible symptom was the tab
1191
1263
  # snapping back to Streams within a second of every click.
1192
- tab_style = """
1193
- <style>
1194
- nav.tabs { margin: 1rem 0 .25rem; border-bottom: 1px solid var(--pico-muted-border-color); }
1195
- nav.tabs ul { display: flex; gap: 0; padding: 0; margin: 0; list-style: none; }
1196
- nav.tabs li { margin: 0; }
1197
- nav.tabs a {
1198
- display: inline-block; padding: .45rem .9rem; text-decoration: none;
1199
- color: var(--pico-muted-color); border-bottom: 2px solid transparent;
1200
- margin-bottom: -1px; font-size: .9rem;
1201
- }
1202
- nav.tabs a:hover { color: var(--pico-color); }
1203
- nav.tabs a.active-tab {
1204
- color: var(--pico-color);
1205
- border-bottom-color: var(--pico-primary, #0172ad);
1206
- font-weight: 600;
1207
- }
1208
- section.tab { display: none; padding-top: .75rem; }
1209
- section.tab.active-tab { display: block; }
1210
- </style>
1211
- """
1264
+ # The class hooks live in _head's <style> so this dash render
1265
+ # can be swapped 1 Hz without re-declaring them.
1212
1266
 
1213
1267
  stream_rows = (
1214
1268
  "".join(
@@ -1220,12 +1274,14 @@ class Handler(http.server.BaseHTTPRequestHandler):
1220
1274
  </tr>"""
1221
1275
  for s in streams
1222
1276
  )
1223
- 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>"
1224
1279
  )
1225
1280
 
1226
1281
  job_rows = (
1227
1282
  "".join(self._job_row(j) for j in jobs)
1228
- 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>"
1229
1285
  )
1230
1286
 
1231
1287
  miss_rows = (
@@ -1234,21 +1290,23 @@ class Handler(http.server.BaseHTTPRequestHandler):
1234
1290
  <td class="url">{html.escape(m["url"])}</td>
1235
1291
  <td class="num">{m["count"]}</td>
1236
1292
  <td><small>{html.escape(m["last_seen"])}</small></td>
1237
- <td>
1293
+ <td class="d-flex gap-2 flex-wrap">
1238
1294
  <form hx-post="/admin/fetch" hx-target="#dash"
1239
- hx-swap="innerHTML" hx-indicator="#spin">
1295
+ hx-swap="innerHTML" hx-indicator="#spin" class="m-0">
1240
1296
  <input type="hidden" name="url" value="{html.escape(m["url"], quote=True)}">
1241
- <button type="submit">Download</button>
1297
+ <button class="btn btn-sm btn-primary" type="submit">Download</button>
1242
1298
  </form>
1243
- <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">
1244
1300
  <input type="hidden" name="key" value="{html.escape(m["key"], quote=True)}">
1245
- <button type="submit" class="secondary outline">Dismiss</button>
1301
+ <button class="btn btn-sm btn-outline-secondary" type="submit"
1302
+ >Dismiss</button>
1246
1303
  </form>
1247
1304
  </td>
1248
1305
  </tr>"""
1249
1306
  for m in misses
1250
1307
  )
1251
- 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>"
1252
1310
  )
1253
1311
 
1254
1312
  # Build the per-row /b/ serve URL once; the cell wraps the
@@ -1274,19 +1332,21 @@ class Handler(http.server.BaseHTTPRequestHandler):
1274
1332
  <td>{human_size(b["size"])}</td>
1275
1333
  <td class="num">{b["hits"]}</td>
1276
1334
  <td class="num">{b["misses"]}</td>
1277
- <td class="mono">{html.escape(b["sha256"][:12])}…</td>
1335
+ <td class="mono">{html.escape(b["sha256"][:12])}...</td>
1278
1336
  <td><small>{html.escape(b["fetched_at"])}</small></td>
1279
1337
  <td>
1280
1338
  <form hx-post="/admin/delete" hx-target="#dash" hx-swap="innerHTML"
1281
- hx-confirm="Delete this cached artifact?">
1339
+ hx-confirm="Delete this cached artifact?" class="m-0">
1282
1340
  <input type="hidden" name="key" value="{html.escape(b["key"], quote=True)}">
1283
- <button type="submit" class="secondary outline">Delete</button>
1341
+ <button class="btn btn-sm btn-outline-danger" type="submit"
1342
+ >Delete</button>
1284
1343
  </form>
1285
1344
  </td>
1286
1345
  </tr>"""
1287
1346
  for b in blobs
1288
1347
  )
1289
- 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>"
1290
1350
  )
1291
1351
 
1292
1352
  # Per-tab counts let the operator see at a glance whether each
@@ -1295,59 +1355,63 @@ class Handler(http.server.BaseHTTPRequestHandler):
1295
1355
  njobs = len(jobs)
1296
1356
 
1297
1357
  return f"""
1298
- <p><small>{nblobs} cached ({used}){full} &middot; {nmisses} pending miss(es)</small></p>
1299
- {tab_style}
1300
- <nav class="tabs"><ul>
1301
- <li><a href="#tab-cached" class="{_active("tab-cached").lstrip()}"
1302
- >Cached ({nblobs})</a></li>
1303
- <li><a href="#tab-streams" class="{_active("tab-streams").lstrip()}"
1304
- >Streams ({nstreams})</a></li>
1305
- <li><a href="#tab-downloads" class="{_active("tab-downloads").lstrip()}"
1306
- >Downloads ({njobs})</a></li>
1307
- <li><a href="#tab-misses" class="{_active("tab-misses").lstrip()}"
1308
- >Misses ({nmisses})</a></li>
1309
- </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>
1310
1372
 
1311
1373
  <section id="tab-cached" class="tab{_active("tab-cached")}">
1312
- <figure><table class="striped">
1313
- <thead><tr>
1374
+ <div class="table-responsive"><table class="table table-sm table-striped table-hover mb-0">
1375
+ <thead class="table-light"><tr>
1314
1376
  <th>URL</th><th>Size</th><th class="num">Hits</th><th class="num">Misses</th>
1315
1377
  <th>SHA-256</th><th>Fetched</th><th>Action</th>
1316
1378
  </tr></thead>
1317
1379
  <tbody>{blob_rows}</tbody>
1318
- </table></figure>
1380
+ </table></div>
1319
1381
  </section>
1320
1382
 
1321
1383
  <section id="tab-streams" class="tab{_active("tab-streams")}">
1322
- <figure><table class="striped">
1323
- <thead><tr>
1384
+ <div class="table-responsive"><table class="table table-sm table-striped table-hover mb-0">
1385
+ <thead class="table-light"><tr>
1324
1386
  <th>URL</th><th>Client</th><th>Progress</th><th>Age</th>
1325
1387
  </tr></thead>
1326
1388
  <tbody>{stream_rows}</tbody>
1327
- </table></figure>
1389
+ </table></div>
1328
1390
  </section>
1329
1391
 
1330
1392
  <section id="tab-downloads" class="tab{_active("tab-downloads")}">
1331
- <div class="row">
1332
- <small>Auto-fetch workers feeding the cache.</small>
1333
- <form hx-post="/admin/clear" hx-target="#dash" hx-swap="innerHTML" style="margin:0">
1334
- <button type="submit" class="secondary outline" style="width:auto;padding:.2rem .7rem">
1335
- 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>
1336
1398
  </form>
1337
1399
  </div>
1338
- <figure><table class="striped">
1339
- <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>
1340
1404
  <tbody>{job_rows}</tbody>
1341
- </table></figure>
1405
+ </table></div>
1342
1406
  </section>
1343
1407
 
1344
1408
  <section id="tab-misses" class="tab{_active("tab-misses")}">
1345
- <figure><table class="striped">
1346
- <thead><tr>
1409
+ <div class="table-responsive"><table class="table table-sm table-striped table-hover mb-0">
1410
+ <thead class="table-light"><tr>
1347
1411
  <th>URL</th><th class="num">Misses</th><th>Last seen</th><th>Action</th>
1348
1412
  </tr></thead>
1349
1413
  <tbody>{miss_rows}</tbody>
1350
- </table></figure>
1414
+ </table></div>
1351
1415
  </section>"""
1352
1416
 
1353
1417
  def _stream_progress_cell(self, s: Stream) -> str:
@@ -1385,11 +1449,12 @@ class Handler(http.server.BaseHTTPRequestHandler):
1385
1449
  cancel = ""
1386
1450
  if j.status in PENDING_STATES:
1387
1451
  cancel = (
1388
- '<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">'
1389
1453
  f'<input type="hidden" name="id" value="{j.id}">'
1390
- '<button type="submit" class="secondary outline">Cancel</button></form>'
1454
+ '<button class="btn btn-sm btn-outline-secondary" type="submit"'
1455
+ ">Cancel</button></form>"
1391
1456
  )
1392
- color = self.STATUS_COLORS.get(j.status, "#888")
1457
+ color = self.STATUS_COLORS.get(j.status, "var(--bs-secondary)")
1393
1458
  return f"""<tr>
1394
1459
  <td class="url" title="{html.escape(j.url, quote=True)}">{html.escape(name)}</td>
1395
1460
  <td>{prog}</td>