@crvouga/mockingbird-service-sqlite 1.3.0 → 2.0.0

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.
package/dist/admin.js CHANGED
@@ -375,6 +375,34 @@ dialog:not([open]) {
375
375
  function isRecord(value) {
376
376
  return typeof value === "object" && value !== null && !Array.isArray(value);
377
377
  }
378
+ function highlightJsonText(text2) {
379
+ const escapeToken = (value) => value.replace(/[&<>"']/g, (char) => {
380
+ switch (char) {
381
+ case "&":
382
+ return "&amp;";
383
+ case "<":
384
+ return "&lt;";
385
+ case ">":
386
+ return "&gt;";
387
+ case '"':
388
+ return "&quot;";
389
+ default:
390
+ return "&#39;";
391
+ }
392
+ });
393
+ const token = /("(?:\\u[\da-fA-F]{4}|\\[^u]|[^\\"])*"\s*:|"(?:\\u[\da-fA-F]{4}|\\[^u]|[^\\"])*"|\b(?:true|false)\b|\bnull\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g;
394
+ let cursor = 0;
395
+ let html = "";
396
+ for (const match of text2.matchAll(token)) {
397
+ const index = match.index ?? 0;
398
+ const value = match[0];
399
+ html += escapeToken(text2.slice(cursor, index));
400
+ const kind = value.startsWith('"') ? value.trimEnd().endsWith(":") ? "key" : "string" : value === "true" || value === "false" ? "boolean" : value === "null" ? "null" : "number";
401
+ html += `<span class="json-${kind}">${escapeToken(value)}</span>`;
402
+ cursor = index + value.length;
403
+ }
404
+ return html + escapeToken(text2.slice(cursor));
405
+ }
378
406
  function mountAdminBrand(doc, host, service, catalog) {
379
407
  const clip = (value, max) => {
380
408
  if (typeof value !== "string") return null;
@@ -714,14 +742,18 @@ function bootAdmin(config) {
714
742
  const state = {
715
743
  namespace: "default",
716
744
  key: sessionStorage.getItem("mockingbird-admin-key") || "",
717
- view: "overview",
745
+ view: ["overview", "state", "clock", "faults", "journal", "routes"].includes(
746
+ location.hash.slice(1)
747
+ ) ? location.hash.slice(1) : "overview",
718
748
  collection: "",
719
749
  shape: null,
720
750
  route: null,
751
+ routes: [],
721
752
  editing: null
722
753
  };
723
754
  const standard = new Set(config.standardRoutes);
724
755
  const banner = el("banner", HTMLElement);
756
+ const bannerMessage = el("banner-message", HTMLElement);
725
757
  const keyInput = el("admin-key", HTMLInputElement);
726
758
  keyInput.value = state.key;
727
759
  const params = new URLSearchParams(location.search);
@@ -732,17 +764,20 @@ function bootAdmin(config) {
732
764
  sessionStorage.setItem("mockingbird-admin-key", state.key);
733
765
  params.delete("key");
734
766
  const next = params.toString();
735
- history.replaceState(null, "", location.pathname + (next ? `?${next}` : ""));
767
+ history.replaceState(null, "", location.pathname + (next ? `?${next}` : "") + location.hash);
736
768
  }
737
769
  const queryNamespace = params.get("namespace");
738
770
  if (queryNamespace) state.namespace = queryNamespace;
739
771
  const showError = (error) => {
740
- banner.textContent = error instanceof Error ? error.message : String(error);
772
+ bannerMessage.textContent = error instanceof Error ? error.message : String(error);
741
773
  banner.classList.add("show");
774
+ banner.scrollIntoView({ behavior: "smooth", block: "nearest" });
742
775
  };
743
776
  const clearError = () => {
744
777
  banner.classList.remove("show");
778
+ bannerMessage.textContent = "";
745
779
  };
780
+ el("banner-dismiss", HTMLButtonElement).addEventListener("click", clearError);
746
781
  const api = async (method, path, body) => {
747
782
  const headers = { accept: "application/json" };
748
783
  if (state.key !== "") headers[config.adminKeyHeader] = state.key;
@@ -781,13 +816,19 @@ function bootAdmin(config) {
781
816
  namespace: () => state.namespace
782
817
  };
783
818
  const isPanelScript = (value) => typeof value === "function";
819
+ const themeButton = el("theme", HTMLButtonElement);
784
820
  const setTheme = (theme) => {
785
821
  if (theme) document.documentElement.dataset.theme = theme;
786
822
  else delete document.documentElement.dataset.theme;
823
+ const dark = theme ? theme === "dark" : matchMedia("(prefers-color-scheme: dark)").matches;
824
+ themeButton.innerHTML = dark ? '<svg viewBox="0 0 20 20" fill="none" aria-hidden="true"><circle cx="10" cy="10" r="3.2" stroke="currentColor" stroke-width="1.5"/><path d="M10 2v1.5M10 16.5V18M18 10h-1.5M3.5 10H2M15.7 4.3l-1 1M5.3 14.7l-1 1M15.7 15.7l-1-1M5.3 5.3l-1-1" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>' : '<svg viewBox="0 0 20 20" fill="none" aria-hidden="true"><path d="M16.8 12.2A7 7 0 0 1 7.8 3.2a7 7 0 1 0 9 9Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></svg>';
825
+ const label = dark ? "Use light theme" : "Use dark theme";
826
+ themeButton.setAttribute("aria-label", label);
827
+ themeButton.title = label;
787
828
  };
788
829
  const storedTheme = localStorage.getItem("mockingbird-admin-theme");
789
- if (storedTheme) setTheme(storedTheme);
790
- el("theme", HTMLButtonElement).addEventListener("click", () => {
830
+ setTheme(storedTheme === "dark" || storedTheme === "light" ? storedTheme : null);
831
+ themeButton.addEventListener("click", () => {
791
832
  const current = document.documentElement.dataset.theme;
792
833
  const dark = current ? current === "dark" : matchMedia("(prefers-color-scheme: dark)").matches;
793
834
  const next = dark ? "light" : "dark";
@@ -804,6 +845,11 @@ function bootAdmin(config) {
804
845
  if (!(section instanceof HTMLElement)) continue;
805
846
  section.hidden = section.id !== `view-${view}`;
806
847
  }
848
+ history.replaceState(
849
+ null,
850
+ "",
851
+ `${location.pathname}${location.search}#${encodeURIComponent(view)}`
852
+ );
807
853
  };
808
854
  el("nav", HTMLElement).addEventListener("click", (event) => {
809
855
  const target = event.target;
@@ -816,7 +862,7 @@ function bootAdmin(config) {
816
862
  refresh().catch(showError);
817
863
  });
818
864
  const cards = (items) => `<div class="grid">${items.map(
819
- (item) => `<article class="card"><div class="k">${item.k}</div><div class="v">${item.v}</div></article>`
865
+ (item) => `<article class="card stat-card"><div class="k">${item.k}</div><div class="v">${item.v}</div></article>`
820
866
  ).join("")}</div>`;
821
867
  const table = (columns, rows) => {
822
868
  if (rows.length === 0) return '<p class="empty">Nothing here yet.</p>';
@@ -826,16 +872,22 @@ function bootAdmin(config) {
826
872
  const attrs = "attrs" in row && typeof row.attrs === "string" ? row.attrs : "";
827
873
  return `<tr${attrs}>${cells}</tr>`;
828
874
  }).join("");
829
- return `<table><thead>${head}</thead><tbody>${body}</tbody></table>`;
875
+ return `<div class="table-shell"><table><thead>${head}</thead><tbody>${body}</tbody></table></div>`;
830
876
  };
831
877
  const preview = (value) => {
832
878
  const text2 = JSON.stringify(value) ?? "null";
833
- return esc(text2.length > 180 ? `${text2.slice(0, 180)}\u2026` : text2);
879
+ const clipped = text2.length > 180 ? `${text2.slice(0, 180)}\u2026` : text2;
880
+ return `<code class="json-inline">${highlightJsonText(clipped)}</code>`;
834
881
  };
835
882
  const when = (epoch) => {
836
883
  const date = new Date(epoch);
837
884
  return Number.isNaN(date.getTime()) ? String(epoch) : date.toISOString();
838
885
  };
886
+ const journalWhen = (value) => {
887
+ const numeric2 = value.trim() === "" ? Number.NaN : Number(value);
888
+ const epoch = Number.isFinite(numeric2) ? numeric2 : Date.parse(value);
889
+ return Number.isFinite(epoch) ? when(epoch) : value;
890
+ };
839
891
  const clockCard = (epoch) => {
840
892
  const date = new Date(epoch);
841
893
  if (Number.isNaN(date.getTime())) return esc(String(epoch));
@@ -947,7 +999,7 @@ function bootAdmin(config) {
947
999
  };
948
1000
  const renderOverview = async () => {
949
1001
  const [health, shape, clock, journal] = await Promise.all([
950
- originGet("/health"),
1002
+ originGet(`${config.adminPrefix}/health`),
951
1003
  api("GET", "/state"),
952
1004
  api("GET", "/clock"),
953
1005
  api("GET", "/requests?limit=8")
@@ -965,7 +1017,9 @@ function bootAdmin(config) {
965
1017
  },
966
1018
  {
967
1019
  k: "Status",
968
- v: esc(typeof health.status === "string" && health.status !== "" ? health.status : "ok")
1020
+ v: `<span class="badge ok">${esc(
1021
+ typeof health.status === "string" && health.status !== "" ? health.status : "ok"
1022
+ )}</span>`
969
1023
  },
970
1024
  { k: "Namespace", v: esc(state.namespace) },
971
1025
  { k: "Collections", v: String(decoded.collections.length) },
@@ -1068,27 +1122,47 @@ function bootAdmin(config) {
1068
1122
  const journal = await api("GET", "/requests?limit=100");
1069
1123
  el("journal", HTMLElement).innerHTML = table(
1070
1124
  [
1071
- { label: "When", cell: (row) => esc(row.at) },
1072
- { label: "Method", cell: (row) => esc(row.method) },
1125
+ { label: "When", cell: (row) => esc(journalWhen(row.at)) },
1126
+ {
1127
+ label: "Method",
1128
+ cell: (row) => `<span class="method">${esc(row.method)}</span>`
1129
+ },
1073
1130
  { label: "Path", cell: (row) => esc(row.path) },
1074
- { label: "Status", cell: (row) => esc(row.status) },
1131
+ {
1132
+ label: "Status",
1133
+ cell: (row) => `<span class="badge ${row.status.startsWith("2") ? "ok" : row.status.startsWith("4") || row.status.startsWith("5") ? "warn" : ""}">${esc(row.status)}</span>`
1134
+ },
1075
1135
  { label: "ms", cell: (row) => esc(row.durationMs) }
1076
1136
  ],
1077
1137
  decodeJournal(journal)
1078
1138
  );
1079
1139
  };
1080
- const renderRoutes = async () => {
1081
- const data = await api("GET", "/");
1082
- if (!isRecord(data)) throw new Error("Route list was not an object");
1083
- const routes = requireList(data.routes, "Routes").map((route) => String(route));
1140
+ const paintRoutes = () => {
1141
+ const query = el("route-search", HTMLInputElement).value.trim().toLocaleLowerCase();
1142
+ const routes = state.routes.filter((route) => route.toLocaleLowerCase().includes(query));
1143
+ el("route-count", HTMLElement).textContent = `${routes.length} of ${state.routes.length}`;
1144
+ if (state.routes.length === 0) {
1145
+ el("route-list", HTMLElement).innerHTML = '<p class="empty">No admin routes are available.</p>';
1146
+ return;
1147
+ }
1148
+ if (routes.length === 0) {
1149
+ el("route-list", HTMLElement).innerHTML = '<p class="empty">No routes match this filter.</p>';
1150
+ return;
1151
+ }
1084
1152
  el("route-list", HTMLElement).innerHTML = routes.map((route) => {
1085
1153
  const extra = standard.has(route) ? "" : " <span class='badge'>extension</span>";
1086
1154
  const parts = route.split(" ");
1087
1155
  const method = parts[0] ?? "";
1088
1156
  const path = parts.slice(1).join(" ");
1089
- return `<div class="route"><span class="method">${esc(method)}</span><span>${esc(path)}${extra}</span><button class="btn" type="button" data-route="${esc(route)}">Use</button></div>`;
1157
+ return `<div class="route"><span class="method">${esc(method)}</span><span>${esc(path)}${extra}</span><button class="btn" type="button" data-route="${esc(route)}" aria-label="Use ${esc(route)}">Use</button></div>`;
1090
1158
  }).join("");
1091
1159
  };
1160
+ const renderRoutes = async () => {
1161
+ const data = await api("GET", "/");
1162
+ if (!isRecord(data)) throw new Error("Route list was not an object");
1163
+ state.routes = requireList(data.routes, "Routes").map((route) => String(route));
1164
+ paintRoutes();
1165
+ };
1092
1166
  const renderers = {
1093
1167
  overview: renderOverview,
1094
1168
  state: renderState,
@@ -1097,17 +1171,46 @@ function bootAdmin(config) {
1097
1171
  journal: renderJournal,
1098
1172
  routes: renderRoutes
1099
1173
  };
1174
+ let refreshToken = 0;
1100
1175
  async function refresh() {
1176
+ const token = ++refreshToken;
1101
1177
  clearError();
1178
+ const main = el("views", HTMLElement);
1179
+ const sync = el("sync-status", HTMLElement);
1180
+ main.setAttribute("aria-busy", "true");
1181
+ sync.classList.add("busy");
1182
+ sync.textContent = "Updating";
1102
1183
  const render = renderers[state.view];
1103
- if (render) await render();
1184
+ try {
1185
+ if (render) await render();
1186
+ if (token === refreshToken) sync.textContent = "Up to date";
1187
+ } finally {
1188
+ if (token === refreshToken) {
1189
+ main.removeAttribute("aria-busy");
1190
+ sync.classList.remove("busy");
1191
+ }
1192
+ }
1104
1193
  }
1105
1194
  const delay = (ms) => new Promise((resolve) => {
1106
1195
  window.setTimeout(resolve, ms);
1107
1196
  });
1108
- const confirmAction = async (button, pending, done, work) => {
1197
+ const confirmAction = async (button, pending, done, work, confirmLabel) => {
1109
1198
  if (button.dataset.pending === "1") return;
1110
1199
  const label = button.textContent ?? "";
1200
+ if (confirmLabel !== void 0 && button.dataset.confirming !== "1") {
1201
+ button.dataset.confirming = "1";
1202
+ button.classList.add("confirming");
1203
+ button.textContent = confirmLabel;
1204
+ window.setTimeout(() => {
1205
+ if (button.dataset.confirming !== "1") return;
1206
+ button.textContent = label;
1207
+ button.classList.remove("confirming");
1208
+ delete button.dataset.confirming;
1209
+ }, 3e3);
1210
+ return;
1211
+ }
1212
+ button.classList.remove("confirming");
1213
+ delete button.dataset.confirming;
1111
1214
  const width = button.offsetWidth;
1112
1215
  button.dataset.pending = "1";
1113
1216
  button.classList.add("pressed");
@@ -1128,6 +1231,72 @@ function bootAdmin(config) {
1128
1231
  delete button.dataset.pending;
1129
1232
  }
1130
1233
  };
1234
+ const wireJsonEditor = (textareaId, highlightId, statusId, shellId, allowEmpty) => {
1235
+ const textarea = el(textareaId, HTMLTextAreaElement);
1236
+ const highlight = el(highlightId, HTMLElement);
1237
+ const status = el(statusId, HTMLElement);
1238
+ const shell = el(shellId, HTMLElement);
1239
+ const sync = () => {
1240
+ const text2 = textarea.value;
1241
+ highlight.innerHTML = `${highlightJsonText(text2)}
1242
+ `;
1243
+ let valid = true;
1244
+ let message = "Valid JSON";
1245
+ if (allowEmpty && text2.trim() === "") message = "No body";
1246
+ else {
1247
+ try {
1248
+ readJson2(text2);
1249
+ } catch {
1250
+ valid = false;
1251
+ message = "Invalid JSON";
1252
+ }
1253
+ }
1254
+ shell.classList.toggle("invalid", !valid);
1255
+ status.classList.toggle("invalid", !valid);
1256
+ status.textContent = message;
1257
+ textarea.setAttribute("aria-invalid", valid ? "false" : "true");
1258
+ };
1259
+ const syncScroll = () => {
1260
+ const preview2 = highlight.parentElement;
1261
+ if (!(preview2 instanceof HTMLElement)) return;
1262
+ preview2.scrollTop = textarea.scrollTop;
1263
+ preview2.scrollLeft = textarea.scrollLeft;
1264
+ };
1265
+ textarea.addEventListener("input", sync);
1266
+ textarea.addEventListener("scroll", syncScroll);
1267
+ sync();
1268
+ return sync;
1269
+ };
1270
+ const syncRouteEditor = wireJsonEditor(
1271
+ "route-body",
1272
+ "route-highlight",
1273
+ "route-json-status",
1274
+ "route-editor",
1275
+ true
1276
+ );
1277
+ const syncRecordEditor = wireJsonEditor(
1278
+ "editor-value",
1279
+ "editor-highlight",
1280
+ "editor-json-status",
1281
+ "record-editor",
1282
+ false
1283
+ );
1284
+ for (const button of document.querySelectorAll("[data-format]")) {
1285
+ if (!(button instanceof HTMLButtonElement)) continue;
1286
+ button.addEventListener("click", () => {
1287
+ const target = button.dataset.format;
1288
+ if (target === void 0) return;
1289
+ const textarea = document.getElementById(target);
1290
+ if (!(textarea instanceof HTMLTextAreaElement) || textarea.value.trim() === "") return;
1291
+ try {
1292
+ textarea.value = JSON.stringify(readJson2(textarea.value), null, 2) ?? "";
1293
+ textarea.dispatchEvent(new Event("input"));
1294
+ textarea.focus();
1295
+ } catch {
1296
+ showError(new Error("Fix the JSON before formatting it"));
1297
+ }
1298
+ });
1299
+ }
1131
1300
  document.addEventListener("click", (event) => {
1132
1301
  const target = event.target;
1133
1302
  if (!(target instanceof Element)) return;
@@ -1198,6 +1367,7 @@ function bootAdmin(config) {
1198
1367
  } else {
1199
1368
  editorValue.value = "{\n \n}";
1200
1369
  }
1370
+ syncRecordEditor();
1201
1371
  editor.showModal();
1202
1372
  }
1203
1373
  el("new-record", HTMLButtonElement).addEventListener("click", () => {
@@ -1225,10 +1395,19 @@ function bootAdmin(config) {
1225
1395
  editorDelete.addEventListener("click", () => {
1226
1396
  const id = state.editing;
1227
1397
  if (!id) return;
1228
- api("DELETE", `/state/${encodeURIComponent(state.collection)}/${encodeURIComponent(id)}`).then(() => {
1229
- editor.close();
1230
- return renderState();
1231
- }).catch(showError);
1398
+ confirmAction(
1399
+ editorDelete,
1400
+ "Deleting...",
1401
+ "Deleted",
1402
+ () => api(
1403
+ "DELETE",
1404
+ `/state/${encodeURIComponent(state.collection)}/${encodeURIComponent(id)}`
1405
+ ).then(() => {
1406
+ editor.close();
1407
+ return renderState();
1408
+ }),
1409
+ "Confirm delete"
1410
+ ).catch(showError);
1232
1411
  });
1233
1412
  for (const button of document.querySelectorAll("[data-advance]")) {
1234
1413
  if (!(button instanceof HTMLButtonElement)) continue;
@@ -1276,7 +1455,8 @@ function bootAdmin(config) {
1276
1455
  el("fault-clear", HTMLButtonElement),
1277
1456
  "Clearing...",
1278
1457
  "Cleared",
1279
- () => api("DELETE", "/faults").then(() => renderFaults())
1458
+ () => api("DELETE", "/faults").then(() => renderFaults()),
1459
+ "Confirm clear"
1280
1460
  ).catch(showError);
1281
1461
  });
1282
1462
  el("journal-clear", HTMLButtonElement).addEventListener("click", () => {
@@ -1284,7 +1464,8 @@ function bootAdmin(config) {
1284
1464
  el("journal-clear", HTMLButtonElement),
1285
1465
  "Clearing...",
1286
1466
  "Cleared",
1287
- () => api("DELETE", "/requests").then(() => renderJournal())
1467
+ () => api("DELETE", "/requests").then(() => renderJournal()),
1468
+ "Confirm clear"
1288
1469
  ).catch(showError);
1289
1470
  });
1290
1471
  el("route-list", HTMLElement).addEventListener("click", (event) => {
@@ -1296,9 +1477,18 @@ function bootAdmin(config) {
1296
1477
  if (route === void 0) return;
1297
1478
  state.route = route;
1298
1479
  el("route-title", HTMLElement).textContent = route;
1480
+ el("route-send", HTMLButtonElement).disabled = false;
1299
1481
  const method = route.split(" ")[0] ?? "";
1300
- el("route-body", HTMLTextAreaElement).disabled = method === "GET" || method === "DELETE";
1482
+ const routeBody = el("route-body", HTMLTextAreaElement);
1483
+ routeBody.disabled = method === "GET" || method === "DELETE";
1484
+ el("route-editor", HTMLElement).hidden = routeBody.disabled;
1485
+ el("route-json-status", HTMLElement).parentElement?.toggleAttribute(
1486
+ "hidden",
1487
+ routeBody.disabled
1488
+ );
1489
+ syncRouteEditor();
1301
1490
  });
1491
+ el("route-search", HTMLInputElement).addEventListener("input", paintRoutes);
1302
1492
  el("route-form", HTMLFormElement).addEventListener("submit", (event) => {
1303
1493
  event.preventDefault();
1304
1494
  const send = async () => {
@@ -1321,7 +1511,8 @@ function bootAdmin(config) {
1321
1511
  }
1322
1512
  }
1323
1513
  const data = await api(method, concrete, body);
1324
- el("route-result", HTMLElement).textContent = JSON.stringify(data, null, 2);
1514
+ const text2 = JSON.stringify(data, null, 2) ?? "null";
1515
+ el("route-result", HTMLElement).innerHTML = `<code>${highlightJsonText(text2)}</code>`;
1325
1516
  };
1326
1517
  send().catch(showError);
1327
1518
  });
@@ -1441,11 +1632,13 @@ function bootAdmin(config) {
1441
1632
  views.append(section);
1442
1633
  mountSqlExplorer(body, panelApi);
1443
1634
  }
1635
+ show(document.getElementById(`view-${state.view}`) === null ? "overview" : state.view);
1444
1636
  return refresh();
1445
1637
  }).catch(showError);
1446
1638
  }
1447
1639
  var clientPrelude = [
1448
1640
  isRecord,
1641
+ highlightJsonText,
1449
1642
  mountAdminBrand,
1450
1643
  startAdminBrand,
1451
1644
  faultPresetList,
@@ -1465,13 +1658,21 @@ ${entry}(${payload});
1465
1658
  };
1466
1659
 
1467
1660
  // ../core/src/control.ts
1468
- var HEALTH_PATH = "/health";
1469
1661
  var ADMIN_PREFIX = "/__admin";
1662
+ var resolveAdminPrefix = (prefix = ADMIN_PREFIX) => {
1663
+ if (!/^\/[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_-]+)*$/.test(prefix)) {
1664
+ throw new Error(
1665
+ "adminPrefix must be an absolute non-root path of literal segments, without a trailing slash"
1666
+ );
1667
+ }
1668
+ return prefix;
1669
+ };
1670
+ var isAdminPath = (path, prefix) => path === prefix || path.startsWith(`${prefix}/`);
1470
1671
  var ADMIN_KEY_HEADER = "x-mockingbird-admin-key";
1471
1672
  var NAMESPACE_HEADER = "x-mockingbird-namespace";
1472
1673
  var json = (status, body) => new Response(JSON.stringify(body), {
1473
1674
  status,
1474
- headers: { "content-type": "application/json" }
1675
+ headers: { "content-type": "application/json; charset=utf-8" }
1475
1676
  });
1476
1677
  var adminError = (status, message) => json(status, { error: { type: "mockingbird_admin", message } });
1477
1678
  var UNITS = {
@@ -1514,11 +1715,25 @@ var readJson = async (request) => {
1514
1715
  };
1515
1716
  var isRecord2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1516
1717
  var createControlPlane = (context) => {
1718
+ const adminPrefix = resolveAdminPrefix(context.adminPrefix);
1517
1719
  const snapshots = /* @__PURE__ */ new Map();
1518
1720
  let snapshotCounter = 0;
1519
1721
  const headerNamespace = (request) => request.headers.get(NAMESPACE_HEADER) ?? context.defaultNamespace;
1520
1722
  const adminNamespace = (request, url) => url.searchParams.get("namespace") ?? headerNamespace(request);
1723
+ const health = () => {
1724
+ return json(200, {
1725
+ ...context.describe(),
1726
+ status: "ok",
1727
+ service: context.name,
1728
+ uptimeMs: context.wallNow() - context.startedAt,
1729
+ clock: context.clock.state(),
1730
+ namespaces: context.namespaces().length,
1731
+ adminPrefix,
1732
+ adminUi: `${adminPrefix}/ui`
1733
+ });
1734
+ };
1521
1735
  const builtin = {
1736
+ "GET /health": health,
1522
1737
  "GET /": () => json(200, {
1523
1738
  service: context.name,
1524
1739
  routes: [...Object.keys(builtin), ...Object.keys(context.routes)].sort()
@@ -1529,23 +1744,24 @@ var createControlPlane = (context) => {
1529
1744
  return json(200, { status: "ok", reset: target === "*" ? context.namespaces() : [target] });
1530
1745
  },
1531
1746
  "GET /namespaces": () => json(200, { default: context.defaultNamespace, namespaces: context.namespaces() }),
1532
- "GET /clock": () => json(200, context.clock.state()),
1533
- "POST /clock": ({ body }) => {
1747
+ "GET /clock": ({ namespace }) => json(200, (context.namespaceClock?.(namespace) ?? context.clock).state()),
1748
+ "POST /clock": ({ body, namespace }) => {
1749
+ const clock = context.namespaceClock?.(namespace) ?? context.clock;
1534
1750
  if (!isRecord2(body)) return adminError(400, "expected a JSON object");
1535
- if (body.reset === true) context.clock.reset();
1751
+ if (body.reset === true) clock.reset();
1752
+ if (body.freeze === true) clock.freeze();
1536
1753
  if (body.set !== void 0) {
1537
1754
  const instant = parseInstant(body.set);
1538
1755
  if (instant === void 0) return adminError(400, "set: expected epoch ms or ISO-8601");
1539
- context.clock.set(instant);
1756
+ clock.set(instant);
1540
1757
  }
1541
1758
  if (body.advance !== void 0) {
1542
1759
  const delta = parseDuration(body.advance);
1543
1760
  if (delta === void 0) return adminError(400, 'advance: expected ms or "15m"-style');
1544
- context.clock.advance(delta);
1761
+ clock.advance(delta);
1545
1762
  }
1546
- if (body.freeze === true) context.clock.freeze();
1547
- if (body.freeze === false) context.clock.unfreeze();
1548
- return json(200, context.clock.state());
1763
+ if (body.freeze === false) clock.unfreeze();
1764
+ return json(200, clock.state());
1549
1765
  },
1550
1766
  "GET /faults": () => json(200, { faults: context.faults.list() }),
1551
1767
  "POST /faults": ({ body, namespace }) => {
@@ -1680,7 +1896,13 @@ var createControlPlane = (context) => {
1680
1896
  return json(200, { status: "ok" });
1681
1897
  }
1682
1898
  };
1683
- const routes = [...Object.entries(context.routes), ...Object.entries(builtin)].map(
1899
+ for (const key of Object.keys(context.routes)) {
1900
+ if (Object.hasOwn(builtin, key)) throw new Error(`admin route ${key} overrides a shared route`);
1901
+ const path = key.slice(key.indexOf(" ") + 1);
1902
+ if (isAdminPath(path, "/ns") || isAdminPath(path, "/blobs"))
1903
+ throw new Error(`admin route ${key} overlaps an internal transport`);
1904
+ }
1905
+ const routes = [...Object.entries(builtin), ...Object.entries(context.routes)].map(
1684
1906
  ([key, handler]) => {
1685
1907
  const space = key.indexOf(" ");
1686
1908
  return { method: key.slice(0, space), pattern: key.slice(space + 1), handler };
@@ -1690,28 +1912,25 @@ var createControlPlane = (context) => {
1690
1912
  namespaceOf: headerNamespace,
1691
1913
  async handle(request) {
1692
1914
  const url = new URL(request.url);
1693
- if (url.pathname === HEALTH_PATH && request.method === "GET") {
1694
- return json(200, {
1695
- status: "ok",
1696
- service: context.name,
1697
- uptimeMs: context.wallNow() - context.startedAt,
1698
- clock: context.clock.state(),
1699
- namespaces: context.namespaces().length,
1700
- adminUi: `${ADMIN_PREFIX}/ui`,
1701
- ...context.describe()
1702
- });
1915
+ if (url.pathname === `${adminPrefix}/health` && request.method === "GET") {
1916
+ return health();
1703
1917
  }
1704
- if (url.pathname !== ADMIN_PREFIX && !url.pathname.startsWith(`${ADMIN_PREFIX}/`)) {
1918
+ if (!isAdminPath(url.pathname, adminPrefix)) {
1705
1919
  return void 0;
1706
1920
  }
1707
- const path = url.pathname.slice(ADMIN_PREFIX.length) || "/";
1921
+ const path = url.pathname.slice(adminPrefix.length) || "/";
1708
1922
  const shell = request.method === "GET" && (path === "/ui" || path === "/ui/");
1709
1923
  if (!shell && context.adminKey !== void 0 && request.headers.get(ADMIN_KEY_HEADER) !== context.adminKey) {
1710
1924
  return adminError(401, `missing or wrong ${ADMIN_KEY_HEADER}`);
1711
1925
  }
1712
1926
  for (const route of routes) {
1713
1927
  if (route.method !== request.method) continue;
1714
- const params = matchRoute(route.pattern, path);
1928
+ let params;
1929
+ try {
1930
+ params = matchRoute(route.pattern, path);
1931
+ } catch {
1932
+ return adminError(400, "invalid path encoding");
1933
+ }
1715
1934
  if (!params) continue;
1716
1935
  let body;
1717
1936
  try {
@@ -1719,17 +1938,19 @@ var createControlPlane = (context) => {
1719
1938
  } catch {
1720
1939
  return adminError(400, "request body is not valid JSON");
1721
1940
  }
1941
+ const namespace = adminNamespace(request, url);
1942
+ if (!/^[A-Za-z0-9_.-]{1,64}$/.test(namespace)) return adminError(400, "invalid namespace");
1722
1943
  return route.handler({
1723
1944
  request,
1724
1945
  url,
1725
1946
  params,
1726
- namespace: adminNamespace(request, url),
1947
+ namespace,
1727
1948
  body
1728
1949
  });
1729
1950
  }
1730
1951
  return adminError(
1731
1952
  404,
1732
- `no admin route ${request.method} ${path}; GET ${ADMIN_PREFIX} lists them`
1953
+ `no admin route ${request.method} ${path}; GET ${adminPrefix} lists them`
1733
1954
  );
1734
1955
  }
1735
1956
  };
@@ -1738,6 +1959,7 @@ var createControlPlane = (context) => {
1738
1959
  // ../core/src/surface.ts
1739
1960
  var STANDARD_ADMIN_ROUTES = [
1740
1961
  "GET /",
1962
+ "GET /health",
1741
1963
  "POST /reset",
1742
1964
  "GET /namespaces",
1743
1965
  "GET /clock",
@@ -1787,11 +2009,11 @@ var escapeHtml = (value) => value.replace(/[&<>"']/g, (char) => {
1787
2009
  }
1788
2010
  });
1789
2011
  var ADMIN_BRANDS_URL = "https://mockingbird.chrisvouga.dev/brands.json";
1790
- var renderAdminDocument = (service) => {
2012
+ var renderAdminDocument = (service, adminPrefix = ADMIN_PREFIX) => {
1791
2013
  const name = escapeHtml(service);
1792
2014
  const script = adminClientSource({
1793
2015
  standardRoutes: STANDARD_ADMIN_ROUTES,
1794
- adminPrefix: ADMIN_PREFIX,
2016
+ adminPrefix,
1795
2017
  adminKeyHeader: ADMIN_KEY_HEADER,
1796
2018
  brandsUrl: ADMIN_BRANDS_URL,
1797
2019
  service
@@ -1808,75 +2030,99 @@ var renderAdminDocument = (service) => {
1808
2030
  ${CSS_RESET}
1809
2031
  :root {
1810
2032
  color-scheme: light;
1811
- --bg: #fafafa;
2033
+ --bg: #f7f8fb;
1812
2034
  --bg-elev: #ffffff;
1813
- --bg-muted: #f4f4f5;
1814
- --bg-inset: #f4f4f5;
1815
- --line: #dedee3;
1816
- --line-strong: #c4c4cc;
1817
- --ink: #18181b;
1818
- --muted: #62626b;
1819
- --faint: #71717a;
1820
- --accent: #27272a;
2035
+ --bg-muted: #f1f3f7;
2036
+ --bg-inset: #f8f9fc;
2037
+ --line: #e4e7ec;
2038
+ --line-strong: #cfd4dc;
2039
+ --ink: #101828;
2040
+ --muted: #475467;
2041
+ --faint: #667085;
2042
+ --accent: #6941c6;
2043
+ --accent-hover: #5933b4;
1821
2044
  --accent-ink: #ffffff;
1822
- --accent-soft: #f4f4f5;
1823
- --ok: #166534;
1824
- --ok-soft: #f0fdf4;
1825
- --warn: #a16207;
1826
- --warn-soft: #fefce8;
1827
- --err: #a82d32;
1828
- --err-soft: #fef2f2;
1829
- --shadow: none;
2045
+ --accent-soft: #f4f0ff;
2046
+ --ok: #067647;
2047
+ --ok-soft: #ecfdf3;
2048
+ --warn: #b54708;
2049
+ --warn-soft: #fffaeb;
2050
+ --err: #b42318;
2051
+ --err-soft: #fef3f2;
2052
+ --code-key: #6941c6;
2053
+ --code-string: #067647;
2054
+ --code-number: #175cd3;
2055
+ --code-boolean: #c11574;
2056
+ --code-null: #667085;
2057
+ --shadow-xs: 0 1px 2px rgb(16 24 40 / 0.05);
2058
+ --shadow-sm: 0 1px 3px rgb(16 24 40 / 0.08), 0 1px 2px rgb(16 24 40 / 0.04);
2059
+ --shadow-lg: 0 18px 48px rgb(16 24 40 / 0.16), 0 4px 12px rgb(16 24 40 / 0.08);
1830
2060
  --sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
1831
2061
  --mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;
1832
- --radius: 10px;
1833
- --header: 56px;
1834
- --nav: 200px;
2062
+ --radius: 12px;
2063
+ --header: 72px;
2064
+ --nav: 224px;
1835
2065
  }
1836
2066
  :root[data-theme="dark"] {
1837
2067
  color-scheme: dark;
1838
- --bg: #111113;
1839
- --bg-elev: #19191c;
1840
- --bg-muted: #242428;
1841
- --bg-inset: #111113;
1842
- --line: #36363c;
1843
- --line-strong: #52525b;
1844
- --ink: #f4f4f5;
1845
- --muted: #a9a9b2;
1846
- --faint: #71717a;
1847
- --accent: #e4e4e7;
1848
- --accent-ink: #18181b;
1849
- --accent-soft: #242428;
1850
- --ok: #86efac;
1851
- --ok-soft: #14241b;
1852
- --warn: #fde68a;
1853
- --warn-soft: #2a2410;
1854
- --err: #ffaaaa;
1855
- --err-soft: #2c1516;
1856
- --shadow: none;
2068
+ --bg: #0c0e14;
2069
+ --bg-elev: #151821;
2070
+ --bg-muted: #20232e;
2071
+ --bg-inset: #11141c;
2072
+ --line: #2b303d;
2073
+ --line-strong: #414858;
2074
+ --ink: #f5f7fa;
2075
+ --muted: #b6bdc9;
2076
+ --faint: #8d96a7;
2077
+ --accent: #9e77ed;
2078
+ --accent-hover: #b692f6;
2079
+ --accent-ink: #ffffff;
2080
+ --accent-soft: #2b2142;
2081
+ --ok: #75e0a7;
2082
+ --ok-soft: #102a20;
2083
+ --warn: #fec84b;
2084
+ --warn-soft: #302611;
2085
+ --err: #fda29b;
2086
+ --err-soft: #321817;
2087
+ --code-key: #c3a6ff;
2088
+ --code-string: #75e0a7;
2089
+ --code-number: #84adff;
2090
+ --code-boolean: #f9a8d4;
2091
+ --code-null: #98a2b3;
2092
+ --shadow-xs: 0 1px 2px rgb(0 0 0 / 0.24);
2093
+ --shadow-sm: 0 4px 12px rgb(0 0 0 / 0.2);
2094
+ --shadow-lg: 0 20px 56px rgb(0 0 0 / 0.45);
1857
2095
  }
1858
2096
  @media (prefers-color-scheme: dark) {
1859
2097
  :root:not([data-theme="light"]) {
1860
2098
  color-scheme: dark;
1861
- --bg: #111113;
1862
- --bg-elev: #19191c;
1863
- --bg-muted: #242428;
1864
- --bg-inset: #111113;
1865
- --line: #36363c;
1866
- --line-strong: #52525b;
1867
- --ink: #f4f4f5;
1868
- --muted: #a9a9b2;
1869
- --faint: #71717a;
1870
- --accent: #e4e4e7;
1871
- --accent-ink: #18181b;
1872
- --accent-soft: #242428;
1873
- --ok: #86efac;
1874
- --ok-soft: #14241b;
1875
- --warn: #fde68a;
1876
- --warn-soft: #2a2410;
1877
- --err: #ffaaaa;
1878
- --err-soft: #2c1516;
1879
- --shadow: none;
2099
+ --bg: #0c0e14;
2100
+ --bg-elev: #151821;
2101
+ --bg-muted: #20232e;
2102
+ --bg-inset: #11141c;
2103
+ --line: #2b303d;
2104
+ --line-strong: #414858;
2105
+ --ink: #f5f7fa;
2106
+ --muted: #b6bdc9;
2107
+ --faint: #8d96a7;
2108
+ --accent: #9e77ed;
2109
+ --accent-hover: #b692f6;
2110
+ --accent-ink: #ffffff;
2111
+ --accent-soft: #2b2142;
2112
+ --ok: #75e0a7;
2113
+ --ok-soft: #102a20;
2114
+ --warn: #fec84b;
2115
+ --warn-soft: #302611;
2116
+ --err: #fda29b;
2117
+ --err-soft: #321817;
2118
+ --code-key: #c3a6ff;
2119
+ --code-string: #75e0a7;
2120
+ --code-number: #84adff;
2121
+ --code-boolean: #f9a8d4;
2122
+ --code-null: #98a2b3;
2123
+ --shadow-xs: 0 1px 2px rgb(0 0 0 / 0.24);
2124
+ --shadow-sm: 0 4px 12px rgb(0 0 0 / 0.2);
2125
+ --shadow-lg: 0 20px 56px rgb(0 0 0 / 0.45);
1880
2126
  }
1881
2127
  }
1882
2128
  /* Fill the viewport, then grow with the content so the background covers the scroll. */
@@ -1894,13 +2140,17 @@ var renderAdminDocument = (service) => {
1894
2140
  padding-right: env(safe-area-inset-right);
1895
2141
  -webkit-text-size-adjust: 100%;
1896
2142
  }
1897
- :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
2143
+ ::selection { background: color-mix(in srgb, var(--accent) 28%, transparent); }
2144
+ :focus-visible { outline: 3px solid color-mix(in srgb, var(--accent) 45%, transparent); outline-offset: 2px; }
2145
+ button, input, select, textarea { transition: border-color 140ms ease, box-shadow 140ms ease, transform 100ms ease; }
2146
+ button:disabled, input:disabled, select:disabled, textarea:disabled { cursor: not-allowed; opacity: 0.55; }
1898
2147
  .app { flex: 1 0 auto; display: flex; flex-direction: column; min-width: 0; width: 100%; }
1899
2148
  header.top {
1900
- position: sticky; top: 0; z-index: 5;
2149
+ position: sticky; top: 0; z-index: 20;
1901
2150
  display: flex; align-items: center; gap: 12px;
1902
- min-height: var(--header); padding: 10px 16px;
1903
- background: var(--bg-elev);
2151
+ min-height: var(--header); padding: 12px 20px;
2152
+ background: color-mix(in srgb, var(--bg-elev) 92%, transparent);
2153
+ backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
1904
2154
  border-bottom: 1px solid var(--line);
1905
2155
  min-width: 0; max-width: 100%;
1906
2156
  }
@@ -1908,12 +2158,21 @@ var renderAdminDocument = (service) => {
1908
2158
  display: flex; flex-flow: row wrap; align-items: center; gap: 14px;
1909
2159
  min-width: 0; max-width: 100%; margin-right: auto;
1910
2160
  }
1911
- .brand-id { display: flex; flex-direction: column; min-width: 0; flex: none; }
1912
- .brand-id strong { font-size: 15px; letter-spacing: -0.01em; }
2161
+ .brand-lockup { display: flex; align-items: center; gap: 10px; min-width: 0; }
2162
+ .brand-mark {
2163
+ width: 38px; height: 38px; display: grid; place-items: center; flex: none;
2164
+ border-radius: 11px; color: #fff;
2165
+ background: linear-gradient(145deg, #7f56d9 0%, #53389e 100%);
2166
+ box-shadow: 0 6px 16px rgb(105 65 198 / 0.24), inset 0 1px 0 rgb(255 255 255 / 0.25);
2167
+ }
2168
+ .brand-mark svg { width: 22px; height: 22px; }
2169
+ .brand-id { display: flex; flex-direction: column; min-width: 0; flex: none; line-height: 1.25; }
2170
+ .brand-id strong { font-size: 15px; letter-spacing: -0.015em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1913
2171
  .brand-id span { color: var(--faint); font-size: 12px; }
1914
2172
  .vendor {
1915
2173
  display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
1916
2174
  min-width: 0; max-width: 100%; font-size: 12px; color: var(--muted);
2175
+ padding: 5px 8px; border: 1px solid var(--line); border-radius: 999px; background: var(--bg-inset);
1917
2176
  }
1918
2177
  .vendor[hidden] { display: none; }
1919
2178
  /* Logos are drawn for a light tile, same as the docs site, so the plate stays white. */
@@ -1925,12 +2184,17 @@ var renderAdminDocument = (service) => {
1925
2184
  .vendor-sep { color: var(--faint); }
1926
2185
  .vendor-link { color: inherit; text-decoration: none; }
1927
2186
  .vendor-link:hover { color: var(--ink); text-decoration: underline; }
1928
- .controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
1929
- label.field { display: flex; flex-direction: column; gap: 2px; font-size: 11px; color: var(--faint); }
2187
+ .controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: flex-end; }
2188
+ .sync-status { align-self: center; display: flex; align-items: center; gap: 6px; color: var(--faint); font-size: 12px; white-space: nowrap; }
2189
+ .sync-status::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--ok); box-shadow: 0 0 0 3px var(--ok-soft); }
2190
+ .sync-status.busy::before { background: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); animation: pulse 1.2s ease-in-out infinite; }
2191
+ label.field { display: flex; flex-direction: column; gap: 4px; font-size: 11px; font-weight: 600; color: var(--faint); }
1930
2192
  label.field input, label.field select, .controls input[type="password"] {
1931
- background-color: var(--bg-inset); border: 1px solid var(--line); border-radius: 8px;
1932
- padding: 7px 8px; min-height: 36px; line-height: 1.2;
2193
+ background-color: var(--bg-elev); border: 1px solid var(--line-strong); border-radius: 9px;
2194
+ padding: 8px 10px; min-height: 38px; line-height: 1.2; box-shadow: var(--shadow-xs);
1933
2195
  }
2196
+ label.field input:hover, label.field select:hover, textarea:hover { border-color: color-mix(in srgb, var(--accent) 38%, var(--line-strong)); }
2197
+ label.field input:focus, label.field select:focus, textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 15%, transparent); outline: none; }
1934
2198
  /* A native menu ignores min-height, so Preset rendered about half as tall as
1935
2199
  the text fields in the same form. Paint it like those fields. */
1936
2200
  label.field select {
@@ -1947,12 +2211,20 @@ var renderAdminDocument = (service) => {
1947
2211
  dialog .field > textarea { width: 100%; }
1948
2212
  .icon-btn, .btn, .btn-primary, .btn-quiet, .btn-danger, nav.side button { cursor: pointer; }
1949
2213
  .icon-btn, .btn, .btn-primary, .btn-quiet, .btn-danger {
1950
- border-radius: 9px; min-height: 36px; padding: 0 12px; border: 1px solid var(--line);
1951
- background: var(--bg-elev);
1952
- }
1953
- .btn-primary { background: var(--accent); color: var(--accent-ink); border-color: transparent; }
1954
- .btn-quiet { background: transparent; }
1955
- .btn-danger { color: var(--err); }
2214
+ display: inline-flex; align-items: center; justify-content: center; gap: 7px;
2215
+ border-radius: 9px; min-height: 38px; padding: 0 13px; border: 1px solid var(--line-strong);
2216
+ background: var(--bg-elev); color: var(--ink); font-size: 13px; font-weight: 600; line-height: 1;
2217
+ box-shadow: var(--shadow-xs);
2218
+ }
2219
+ .icon-btn { width: 38px; padding: 0; }
2220
+ .icon-btn svg, .btn svg, .btn-primary svg { width: 16px; height: 16px; }
2221
+ .icon-btn:hover, .btn:hover { background: var(--bg-muted); border-color: var(--line-strong); }
2222
+ .btn-primary { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); box-shadow: 0 1px 2px rgb(16 24 40 / 0.08), 0 0 0 1px rgb(255 255 255 / 0.08) inset; }
2223
+ .btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
2224
+ .btn-quiet { background: transparent; border-color: transparent; box-shadow: none; }
2225
+ .btn-quiet:hover { background: var(--bg-muted); }
2226
+ .btn-danger { color: var(--err); border-color: color-mix(in srgb, var(--err) 25%, var(--line)); }
2227
+ .btn-danger:hover, .btn-danger.confirming { background: var(--err-soft); border-color: color-mix(in srgb, var(--err) 45%, var(--line)); }
1956
2228
  /* Held after mouseup. Clearing an empty journal changes nothing on screen,
1957
2229
  so the press itself has to stay visible. */
1958
2230
  .icon-btn:active, .btn:active, .btn-quiet:active, .btn-danger:active, nav.side button:active,
@@ -1968,93 +2240,159 @@ var renderAdminDocument = (service) => {
1968
2240
  flex: 1 0 auto; min-width: 0;
1969
2241
  }
1970
2242
  nav.side {
1971
- border-right: 1px solid var(--line); padding: 12px;
2243
+ position: sticky; top: var(--header); align-self: start; height: calc(100vh - var(--header));
2244
+ border-right: 1px solid var(--line); padding: 18px 12px;
1972
2245
  display: flex; flex-direction: column; gap: 4px;
1973
- background: var(--bg-elev);
2246
+ background: var(--bg-elev); overflow-y: auto;
1974
2247
  }
2248
+ .nav-label { padding: 0 10px 8px; color: var(--faint); font-size: 10px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; }
1975
2249
  nav.side button {
2250
+ position: relative; display: flex; align-items: center; gap: 10px; width: 100%;
1976
2251
  text-align: left; border: 0; background: transparent; border-radius: 9px;
1977
- padding: 9px 10px; min-height: 40px; color: var(--muted);
1978
- }
1979
- nav.side button[aria-current="true"] { background: var(--accent-soft); color: var(--ink); font-weight: 600; }
1980
- main { padding: 20px; max-width: 1100px; width: 100%; min-width: 0; }
1981
- .view h2 { margin: 0 0 4px; font-size: 22px; letter-spacing: -0.02em; }
1982
- .lede { margin: 0 0 16px; color: var(--muted); }
1983
- .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr)); gap: 12px; }
2252
+ padding: 9px 10px; min-height: 42px; color: var(--muted); font-size: 13px; font-weight: 550;
2253
+ }
2254
+ nav.side button:hover { background: var(--bg-muted); color: var(--ink); }
2255
+ nav.side button[aria-current="true"] { background: var(--accent-soft); color: var(--accent); font-weight: 650; }
2256
+ nav.side button[aria-current="true"]::before { content: ""; position: absolute; left: -12px; width: 3px; height: 22px; border-radius: 0 4px 4px 0; background: var(--accent); }
2257
+ .nav-icon { width: 18px; height: 18px; display: grid; place-items: center; flex: none; }
2258
+ .nav-icon svg { width: 18px; height: 18px; }
2259
+ main { padding: 32px clamp(20px, 4vw, 48px) 64px; max-width: 1320px; width: 100%; min-width: 0; }
2260
+ main[aria-busy="true"] .view { opacity: 0.62; }
2261
+ .view { animation: view-in 180ms ease-out; }
2262
+ .view[hidden] { display: none; }
2263
+ .view-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 22px; }
2264
+ .view h2 { margin: 0 0 5px; font-size: clamp(24px, 3vw, 30px); line-height: 1.2; letter-spacing: -0.035em; }
2265
+ .view h3 { margin: 24px 0 10px; font-size: 15px; letter-spacing: -0.01em; }
2266
+ .lede { margin: 0; color: var(--muted); max-width: 72ch; font-size: 14px; }
2267
+ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 175px), 1fr)); gap: 14px; }
1984
2268
  .card {
1985
2269
  background: var(--bg-elev); border: 1px solid var(--line); border-radius: var(--radius);
1986
- padding: 14px 16px; box-shadow: var(--shadow);
2270
+ padding: 18px; box-shadow: var(--shadow-sm);
1987
2271
  }
1988
- .card .k { color: var(--faint); font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; }
1989
- .card .v { display: flex; flex-direction: column; gap: 2px; font-size: 22px; margin-top: 4px; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
2272
+ .stat-card { position: relative; overflow: hidden; }
2273
+ .stat-card::after { content: ""; position: absolute; inset: 0 0 auto; height: 2px; background: linear-gradient(90deg, var(--accent), transparent 78%); opacity: 0.7; }
2274
+ .card .k { color: var(--faint); font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; }
2275
+ .card .v { display: flex; flex-direction: column; gap: 2px; font-size: 24px; font-weight: 650; margin-top: 7px; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; letter-spacing: -0.025em; }
1990
2276
  .card .v .clock-time { font-size: 14px; color: var(--muted); }
1991
- .split { display: grid; grid-template-columns: minmax(0, 240px) minmax(0, 1fr); gap: 16px; align-items: start; }
2277
+ .section-card { background: var(--bg-elev); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow-xs); overflow: hidden; }
2278
+ .section-title { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 16px 18px; border-bottom: 1px solid var(--line); }
2279
+ .section-title h3 { margin: 0; }
2280
+ .split { display: grid; grid-template-columns: minmax(0, 250px) minmax(0, 1fr); gap: 18px; align-items: start; }
2281
+ #collections { padding: 6px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg-elev); box-shadow: var(--shadow-xs); }
1992
2282
  .collection {
1993
2283
  width: 100%; text-align: left; border: 1px solid transparent; background: transparent;
1994
- border-radius: 10px; padding: 8px 10px; min-height: 44px;
2284
+ border-radius: 9px; padding: 10px 11px; min-height: 48px; color: var(--ink); cursor: pointer;
1995
2285
  }
1996
- .collection[aria-current="true"] { background: var(--bg-elev); border-color: var(--line); }
2286
+ .collection:hover { background: var(--bg-muted); }
2287
+ .collection[aria-current="true"] { background: var(--accent-soft); border-color: color-mix(in srgb, var(--accent) 18%, var(--line)); color: var(--accent); }
1997
2288
  .collection small { display: block; color: var(--faint); }
1998
2289
  .chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 14px; }
1999
- .chip {
2000
- font-family: var(--mono); font-size: 12px; background: var(--bg-muted);
2001
- border-radius: 999px; padding: 4px 8px;
2002
- }
2290
+ .chip { font-family: var(--mono); font-size: 11px; background: var(--bg-muted); border: 1px solid var(--line); border-radius: 999px; padding: 4px 8px; }
2003
2291
  .chip i { color: var(--faint); font-style: normal; }
2004
2292
  .sql-layout {
2005
2293
  display: grid; grid-template-columns: minmax(10rem, 16rem) minmax(0, 1fr); gap: 16px;
2006
2294
  }
2007
- .sql-tables { display: flex; flex-direction: column; gap: 4px; max-height: 28rem; overflow: auto; }
2008
- .sql-tables button { text-align: left; }
2295
+ .sql-tables { display: flex; flex-direction: column; gap: 4px; max-height: 28rem; overflow: auto; padding: 6px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg-elev); }
2296
+ .sql-tables button { text-align: left; border: 0; border-radius: 8px; padding: 9px 10px; background: transparent; color: var(--muted); cursor: pointer; }
2297
+ .sql-tables button:hover, .sql-tables button[aria-current="true"] { background: var(--accent-soft); color: var(--accent); }
2009
2298
  .sql-input { font-family: var(--mono); min-height: 7rem; width: 100%; }
2010
- table { width: 100%; border-collapse: collapse; font-size: 14px; }
2011
- th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--line); vertical-align: top; }
2012
- th { color: var(--faint); font-weight: 600; font-size: 12px; }
2299
+ .table-shell { width: 100%; overflow-x: auto; border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg-elev); box-shadow: var(--shadow-xs); }
2300
+ table { width: 100%; border-collapse: collapse; font-size: 13px; }
2301
+ th, td { text-align: left; padding: 11px 13px; border-bottom: 1px solid var(--line); vertical-align: top; }
2302
+ th { color: var(--faint); background: var(--bg-inset); font-weight: 650; font-size: 11px; text-transform: uppercase; letter-spacing: 0.045em; }
2303
+ tbody tr:last-child td { border-bottom: 0; }
2013
2304
  tr.clickable { cursor: pointer; }
2014
- tr.clickable:hover { background: var(--bg-muted); }
2305
+ tr.clickable:hover { background: var(--accent-soft); }
2015
2306
  pre, code, .mono { font-family: var(--mono); overflow-wrap: anywhere; }
2016
2307
  pre {
2017
2308
  margin: 0; white-space: pre-wrap; word-break: break-word;
2018
- background: var(--bg-inset); border-radius: 10px; padding: 12px; font-size: 12px;
2019
- }
2020
- .row-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0; }
2309
+ background: var(--bg-inset); border: 1px solid var(--line); border-radius: 10px; padding: 14px; font-size: 12px; line-height: 1.65;
2310
+ }
2311
+ .json-key { color: var(--code-key); }
2312
+ .json-string { color: var(--code-string); }
2313
+ .json-number { color: var(--code-number); }
2314
+ .json-boolean { color: var(--code-boolean); }
2315
+ .json-null { color: var(--code-null); font-style: italic; }
2316
+ .json-inline { display: block; max-width: 44rem; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
2317
+ .json-view { position: relative; max-height: 26rem; overflow: auto; }
2318
+ .json-editor { position: relative; min-height: 190px; border: 1px solid var(--line-strong); border-radius: 10px; background: var(--bg-inset); overflow: hidden; }
2319
+ .json-editor:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 15%, transparent); }
2320
+ .json-editor.invalid { border-color: var(--err); }
2321
+ .json-editor pre, .json-editor textarea { margin: 0; padding: 12px; border: 0; border-radius: 0; min-height: 190px; width: 100%; font: 12px/1.65 var(--mono); tab-size: 2; white-space: pre; overflow: auto; }
2322
+ .json-editor pre { position: absolute; inset: 0; pointer-events: none; background: transparent; }
2323
+ .json-editor textarea { position: relative; z-index: 1; resize: vertical; background: transparent; color: transparent; caret-color: var(--ink); -webkit-text-fill-color: transparent; }
2324
+ .json-editor textarea::selection { background: color-mix(in srgb, var(--accent) 32%, transparent); }
2325
+ .editor-meta { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 7px; }
2326
+ .json-status { display: inline-flex; align-items: center; gap: 6px; color: var(--ok); font-size: 11px; }
2327
+ .json-status::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
2328
+ .json-status.invalid { color: var(--err); }
2329
+ .format-json { border: 0; padding: 3px 5px; background: transparent; color: var(--accent); font-size: 11px; font-weight: 650; cursor: pointer; }
2330
+ .row-actions { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 8px; margin: 14px 0; }
2021
2331
  .banner {
2022
- display: none; margin-bottom: 12px; padding: 10px 12px; border-radius: 10px;
2023
- background: var(--err-soft); color: var(--err);
2024
- }
2025
- .banner.show { display: block; }
2026
- .empty { color: var(--muted); padding: 24px 8px; }
2332
+ display: none; position: sticky; top: calc(var(--header) + 12px); z-index: 15;
2333
+ align-items: center; gap: 10px; margin: 0 0 16px; padding: 11px 12px 11px 14px;
2334
+ border: 1px solid color-mix(in srgb, var(--err) 30%, var(--line)); border-radius: 10px;
2335
+ background: var(--err-soft); color: var(--err); box-shadow: var(--shadow-sm); font-size: 13px;
2336
+ }
2337
+ .banner.show { display: flex; }
2338
+ .banner span { flex: 1; }
2339
+ .banner button { width: 28px; height: 28px; display: grid; place-items: center; border: 0; border-radius: 7px; background: transparent; color: currentColor; cursor: pointer; }
2340
+ .banner button:hover { background: color-mix(in srgb, var(--err) 10%, transparent); }
2341
+ .empty { color: var(--muted); padding: 34px 18px; text-align: center; border: 1px dashed var(--line-strong); border-radius: var(--radius); background: color-mix(in srgb, var(--bg-elev) 55%, transparent); }
2027
2342
  dialog {
2028
- border: 1px solid var(--line); border-radius: var(--radius); padding: 0; background: var(--bg-elev);
2029
- color: var(--ink); width: min(640px, calc(100% - 24px)); max-width: calc(100vw - 24px); box-shadow: none;
2030
- }
2031
- dialog::backdrop { background: rgb(20 19 17 / 0.45); }
2032
- dialog form, .dialog-body { padding: 16px; display: flex; flex-direction: column; gap: 10px; }
2343
+ border: 1px solid var(--line); border-radius: 16px; padding: 0; background: var(--bg-elev);
2344
+ color: var(--ink); width: min(720px, calc(100% - 24px)); max-width: calc(100vw - 24px); box-shadow: var(--shadow-lg);
2345
+ }
2346
+ dialog[open] { animation: dialog-in 160ms ease-out; }
2347
+ dialog::backdrop { background: rgb(12 14 20 / 0.58); backdrop-filter: blur(3px); }
2348
+ dialog form, .dialog-body { padding: 22px; display: flex; flex-direction: column; gap: 14px; }
2349
+ .dialog-heading { display: flex; justify-content: space-between; gap: 16px; padding-bottom: 12px; border-bottom: 1px solid var(--line); }
2350
+ .dialog-heading strong { font-size: 18px; letter-spacing: -0.02em; }
2351
+ .dialog-heading span { color: var(--muted); font-size: 12px; }
2033
2352
  textarea {
2034
2353
  width: 100%; min-height: 180px; resize: vertical; border-radius: 10px;
2035
2354
  border: 1px solid var(--line); background: var(--bg-inset); padding: 10px;
2036
2355
  font-family: var(--mono); font-size: 12px;
2037
2356
  }
2038
- .dialog-actions { display: flex; justify-content: flex-end; gap: 8px; }
2039
- .badge { font-size: 11px; border-radius: 999px; padding: 2px 7px; background: var(--bg-muted); color: var(--muted); }
2357
+ .dialog-actions { display: flex; justify-content: flex-end; align-items: center; gap: 8px; padding-top: 4px; }
2358
+ .dialog-actions .btn-danger { margin-right: auto; }
2359
+ .badge { display: inline-flex; align-items: center; font-size: 10px; font-weight: 700; letter-spacing: 0.03em; border: 1px solid var(--line); border-radius: 999px; padding: 3px 7px; background: var(--bg-muted); color: var(--muted); }
2040
2360
  .badge.ok { background: var(--ok-soft); color: var(--ok); }
2041
2361
  .badge.warn { background: var(--warn-soft); color: var(--warn); }
2042
2362
  .status-2 { color: var(--ok); }
2043
2363
  .status-4, .status-5, .status-0 { color: var(--err); }
2044
2364
  .stack { display: flex; flex-direction: column; gap: 12px; }
2365
+ .form-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
2366
+ .form-grid .wide { grid-column: span 2; }
2045
2367
  .route {
2046
- display: grid; grid-template-columns: 88px 1fr auto; gap: 8px; align-items: center;
2047
- padding: 8px 0; border-bottom: 1px solid var(--line);
2048
- }
2049
- .method { font-family: var(--mono); font-size: 12px; font-weight: 700; }
2368
+ display: grid; grid-template-columns: 84px minmax(0, 1fr) auto; gap: 10px; align-items: center;
2369
+ padding: 10px 12px; border-bottom: 1px solid var(--line); background: var(--bg-elev);
2370
+ }
2371
+ .route:first-child { border-radius: var(--radius) var(--radius) 0 0; }
2372
+ .route:last-child { border-bottom: 0; border-radius: 0 0 var(--radius) var(--radius); }
2373
+ .route-tools { display: flex; align-items: flex-end; gap: 12px; margin-bottom: 12px; }
2374
+ .route-tools .field { flex: 1; }
2375
+ .route-tools input { width: 100%; }
2376
+ .route-count { padding-bottom: 10px; color: var(--faint); font-size: 12px; white-space: nowrap; }
2377
+ #route-list { max-height: 34rem; border: 1px solid var(--line); border-radius: var(--radius); overflow: auto; box-shadow: var(--shadow-xs); }
2378
+ .method { width: fit-content; font-family: var(--mono); font-size: 11px; font-weight: 750; color: var(--accent); background: var(--accent-soft); border-radius: 6px; padding: 4px 6px; }
2379
+ @keyframes view-in { from { transform: translateY(3px); } to { transform: translateY(0); } }
2380
+ @keyframes dialog-in { from { transform: translateY(8px) scale(0.99); } to { transform: translateY(0) scale(1); } }
2381
+ @keyframes pulse { 50% { opacity: 0.45; } }
2382
+ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } }
2383
+ @media (forced-colors: active) { .json-editor pre { display: none; } .json-editor textarea { color: CanvasText; -webkit-text-fill-color: CanvasText; } }
2050
2384
  @media (max-width: 800px) {
2385
+ :root { --header: 0px; }
2051
2386
  header.top {
2052
- align-items: stretch; flex-direction: column;
2053
- padding-top: max(10px, env(safe-area-inset-top));
2387
+ position: relative; align-items: stretch; flex-direction: column;
2388
+ padding: max(12px, env(safe-area-inset-top)) 14px 12px;
2054
2389
  }
2055
2390
  input, select, textarea { font-size: 16px; }
2056
2391
  .brand, .vendor, .controls { width: 100%; }
2392
+ .brand { gap: 10px; }
2393
+ .vendor { order: 2; border-radius: 9px; }
2057
2394
  label.field, label.field select, label.field input { flex: 1; min-width: 0; }
2395
+ .sync-status { display: none; }
2058
2396
  /* The layout fills the screen. A single-column grid stretches every auto
2059
2397
  row, so the tab bar grew with the leftover height and the buttons floated
2060
2398
  in the middle of it. The bar stays one row; the page below takes the rest. */
@@ -2064,14 +2402,23 @@ var renderAdminDocument = (service) => {
2064
2402
  align-content: start;
2065
2403
  }
2066
2404
  nav.side {
2067
- align-self: start; height: auto;
2405
+ position: sticky; top: 0; z-index: 12; align-self: start; height: auto;
2068
2406
  flex-direction: row; align-items: center; overflow-x: auto; min-width: 0;
2069
2407
  border-right: 0; border-bottom: 1px solid var(--line);
2070
- padding: 8px;
2408
+ padding: 8px; box-shadow: var(--shadow-xs);
2071
2409
  }
2072
- nav.side button { white-space: nowrap; flex: none; }
2410
+ .nav-label { display: none; }
2411
+ nav.side button { width: auto; white-space: nowrap; flex: none; }
2412
+ nav.side button[aria-current="true"]::before { inset: auto 9px -8px; width: auto; height: 3px; border-radius: 3px 3px 0 0; }
2073
2413
  .split, .sql-layout { grid-template-columns: minmax(0, 1fr); }
2074
- main { padding: 14px; }
2414
+ .form-grid { grid-template-columns: minmax(0, 1fr); }
2415
+ .form-grid .wide { grid-column: auto; }
2416
+ main { padding: 22px 14px 48px; }
2417
+ .view-head { margin-bottom: 18px; }
2418
+ .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
2419
+ .card { padding: 14px; }
2420
+ .card .v { font-size: 20px; }
2421
+ .table-shell { border: 0; background: transparent; box-shadow: none; overflow: visible; }
2075
2422
  table, thead, tbody, tr, th, td { display: block; }
2076
2423
  thead { display: none; }
2077
2424
  tr {
@@ -2086,50 +2433,63 @@ var renderAdminDocument = (service) => {
2086
2433
  .route { grid-template-columns: 72px 1fr; }
2087
2434
  .route button { grid-column: 1 / -1; }
2088
2435
  }
2436
+ @media (max-width: 460px) {
2437
+ .controls label.field { min-width: calc(50% - 4px); }
2438
+ .controls .icon-btn, .controls .btn { flex: 1; }
2439
+ .grid { grid-template-columns: minmax(0, 1fr); }
2440
+ .dialog-actions { flex-wrap: wrap; }
2441
+ .dialog-actions button { flex: 1; }
2442
+ .dialog-actions .btn-danger { flex-basis: 100%; margin-right: 0; }
2443
+ }
2089
2444
  </style>
2090
2445
  </head>
2091
2446
  <body>
2092
2447
  <div class="app" data-service="${name}" data-mockingbird-admin>
2093
2448
  <header class="top">
2094
2449
  <div class="brand">
2095
- <div class="brand-id">
2096
- <strong>${name}</strong>
2097
- <span>Admin</span>
2450
+ <div class="brand-lockup">
2451
+ <div class="brand-mark" aria-hidden="true">
2452
+ <svg viewBox="0 0 24 24" fill="none"><path d="M5 17.5V7l7 5 7-5v10.5" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/><path d="M8 15.5 12 18l4-2.5" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
2453
+ </div>
2454
+ <div class="brand-id">
2455
+ <strong>${name}</strong>
2456
+ <span>Mock admin</span>
2457
+ </div>
2098
2458
  </div>
2099
2459
  <div class="vendor" id="vendor" hidden></div>
2100
2460
  </div>
2101
2461
  <div class="controls">
2462
+ <span class="sync-status" id="sync-status" role="status" aria-live="polite">Ready</span>
2102
2463
  <label class="field">Namespace
2103
2464
  <select id="namespace" aria-label="Namespace"></select>
2104
2465
  </label>
2105
2466
  <label class="field">Admin key
2106
2467
  <input id="admin-key" type="password" autocomplete="off" spellcheck="false" aria-label="Admin key">
2107
2468
  </label>
2108
- <button class="icon-btn" id="theme" type="button" aria-label="Toggle color theme">Theme</button>
2109
- <button class="btn" id="refresh" type="button">Refresh</button>
2469
+ <button class="icon-btn" id="theme" type="button" aria-label="Use dark theme" title="Use dark theme"></button>
2470
+ <button class="btn" id="refresh" type="button"><svg viewBox="0 0 20 20" fill="none" aria-hidden="true"><path d="M15.8 7.2A6.2 6.2 0 1 0 16 12" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/><path d="M12.8 7.2h3v-3" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>Refresh</button>
2110
2471
  </div>
2111
2472
  </header>
2112
2473
  <div class="layout">
2113
2474
  <nav class="side" id="nav" aria-label="Admin sections">
2114
- <button type="button" data-view="overview" aria-current="true">Overview</button>
2115
- <button type="button" data-view="state">State</button>
2116
- <button type="button" data-view="clock">Clock</button>
2117
- <button type="button" data-view="faults">Faults</button>
2118
- <button type="button" data-view="journal">Journal</button>
2119
- <button type="button" data-view="routes">Routes</button>
2475
+ <span class="nav-label">Workspace</span>
2476
+ <button type="button" data-view="overview" aria-current="true"><span class="nav-icon" aria-hidden="true"><svg viewBox="0 0 20 20" fill="none"><rect x="2.5" y="2.5" width="6" height="6" rx="1.5" stroke="currentColor" stroke-width="1.5"/><rect x="11.5" y="2.5" width="6" height="6" rx="1.5" stroke="currentColor" stroke-width="1.5"/><rect x="2.5" y="11.5" width="6" height="6" rx="1.5" stroke="currentColor" stroke-width="1.5"/><rect x="11.5" y="11.5" width="6" height="6" rx="1.5" stroke="currentColor" stroke-width="1.5"/></svg></span>Overview</button>
2477
+ <button type="button" data-view="state"><span class="nav-icon" aria-hidden="true"><svg viewBox="0 0 20 20" fill="none"><ellipse cx="10" cy="5" rx="6.5" ry="2.5" stroke="currentColor" stroke-width="1.5"/><path d="M3.5 5v5c0 1.4 2.9 2.5 6.5 2.5s6.5-1.1 6.5-2.5V5M3.5 10v5c0 1.4 2.9 2.5 6.5 2.5s6.5-1.1 6.5-2.5v-5" stroke="currentColor" stroke-width="1.5"/></svg></span>State</button>
2478
+ <button type="button" data-view="clock"><span class="nav-icon" aria-hidden="true"><svg viewBox="0 0 20 20" fill="none"><circle cx="10" cy="10" r="7.5" stroke="currentColor" stroke-width="1.5"/><path d="M10 5.8v4.6l3 1.8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg></span>Clock</button>
2479
+ <button type="button" data-view="faults"><span class="nav-icon" aria-hidden="true"><svg viewBox="0 0 20 20" fill="none"><path d="m11.2 2.8-6.5 9h5.1l-1 5.4 6.5-9h-5.1l1-5.4Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></svg></span>Faults</button>
2480
+ <button type="button" data-view="journal"><span class="nav-icon" aria-hidden="true"><svg viewBox="0 0 20 20" fill="none"><path d="M5 3.5h10a1.5 1.5 0 0 1 1.5 1.5v12l-2.2-1.4-2.2 1.4-2.1-1.4L7.8 17l-2.2-1.4L3.5 17V5A1.5 1.5 0 0 1 5 3.5Z" stroke="currentColor" stroke-width="1.5"/><path d="M6.7 7h6.6M6.7 10h6.6M6.7 13h3.8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg></span>Journal</button>
2481
+ <button type="button" data-view="routes"><span class="nav-icon" aria-hidden="true"><svg viewBox="0 0 20 20" fill="none"><path d="M6.5 4.5h7a2 2 0 0 1 2 2v7M13 11l2.5 2.5L18 11M13.5 15.5h-7a2 2 0 0 1-2-2v-7M7 9 4.5 6.5 2 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg></span>Routes</button>
2120
2482
  </nav>
2121
2483
  <main id="views">
2122
- <p class="banner" id="banner" role="alert"></p>
2484
+ <div class="banner" id="banner" role="alert"><span id="banner-message"></span><button id="banner-dismiss" type="button" aria-label="Dismiss error">\xD7</button></div>
2123
2485
  <section class="view" id="view-overview">
2124
- <h2>Overview</h2>
2125
- <p class="lede">Health, clock, and the collections this namespace is holding.</p>
2486
+ <div class="view-head"><div><h2>Overview</h2><p class="lede">Health, clock, and the collections this namespace is holding.</p></div></div>
2126
2487
  <div class="grid" id="overview-cards"></div>
2127
2488
  <h3>Recent requests</h3>
2128
2489
  <div id="overview-requests"></div>
2129
2490
  </section>
2130
2491
  <section class="view" id="view-state" hidden>
2131
- <h2>State</h2>
2132
- <p class="lede">Every collection in this namespace. Declared fields stay visible before any row exists; stored rows add whatever else they contain.</p>
2492
+ <div class="view-head"><div><h2>State</h2><p class="lede">Inspect and edit every collection in this namespace.</p></div></div>
2133
2493
  <div class="split">
2134
2494
  <div id="collections"></div>
2135
2495
  <div>
@@ -2142,8 +2502,7 @@ var renderAdminDocument = (service) => {
2142
2502
  </div>
2143
2503
  </section>
2144
2504
  <section class="view" id="view-clock" hidden>
2145
- <h2>Clock</h2>
2146
- <p class="lede">The clock every timestamp in this mock reads.</p>
2505
+ <div class="view-head"><div><h2>Clock</h2><p class="lede">Control the source of time used by every timestamp in this mock.</p></div></div>
2147
2506
  <div class="card" id="clock-readout"></div>
2148
2507
  <div class="row-actions">
2149
2508
  <button class="btn" type="button" data-advance="60000">+1m</button>
@@ -2162,21 +2521,22 @@ var renderAdminDocument = (service) => {
2162
2521
  </form>
2163
2522
  </section>
2164
2523
  <section class="view" id="view-faults" hidden>
2165
- <h2>Faults</h2>
2166
- <p class="lede">Inject a response, a delay, or a named preset. Rules apply to this namespace.</p>
2524
+ <div class="view-head"><div><h2>Faults</h2><p class="lede">Test unhappy paths with a response, delay, or named preset scoped to this namespace.</p></div></div>
2167
2525
  <form id="fault-form" class="card stack">
2168
- <label class="field">Preset
2169
- <select id="fault-preset"><option value="">Custom rule</option></select>
2170
- </label>
2171
- <label class="field">Status
2172
- <input id="fault-status" inputmode="numeric" placeholder="503">
2173
- </label>
2174
- <label class="field">Path prefix
2175
- <input id="fault-path" placeholder="/v1">
2176
- </label>
2177
- <label class="field">Method
2178
- <input id="fault-method" placeholder="GET">
2179
- </label>
2526
+ <div class="form-grid">
2527
+ <label class="field wide">Preset
2528
+ <select id="fault-preset"><option value="">Custom rule</option></select>
2529
+ </label>
2530
+ <label class="field">Status
2531
+ <input id="fault-status" inputmode="numeric" placeholder="503">
2532
+ </label>
2533
+ <label class="field wide">Path prefix
2534
+ <input id="fault-path" placeholder="/v1">
2535
+ </label>
2536
+ <label class="field">Method
2537
+ <input id="fault-method" placeholder="GET">
2538
+ </label>
2539
+ </div>
2180
2540
  <div class="dialog-actions">
2181
2541
  <button class="btn-primary" type="submit">Add fault</button>
2182
2542
  <button class="btn-danger" id="fault-clear" type="button">Clear all</button>
@@ -2185,26 +2545,31 @@ var renderAdminDocument = (service) => {
2185
2545
  <div id="fault-list"></div>
2186
2546
  </section>
2187
2547
  <section class="view" id="view-journal" hidden>
2188
- <h2>Journal</h2>
2189
- <p class="lede">Requests this namespace has seen. Bodies are not stored.</p>
2190
- <div class="row-actions">
2191
- <button class="btn-danger" id="journal-clear" type="button">Clear journal</button>
2192
- </div>
2548
+ <div class="view-head"><div><h2>Journal</h2><p class="lede">Requests this namespace has seen. Bodies are never stored.</p></div><button class="btn-danger" id="journal-clear" type="button">Clear journal</button></div>
2193
2549
  <div id="journal"></div>
2194
2550
  </section>
2195
2551
  <section class="view" id="view-routes" hidden>
2196
- <h2>Routes</h2>
2197
- <p class="lede">Shared controls, plus any routes this mock adds. Extra routes are how a bespoke workflow shows up beside the default UI.</p>
2552
+ <div class="view-head"><div><h2>Routes</h2><p class="lede">Explore shared controls and service-specific admin endpoints.</p></div></div>
2553
+ <div class="route-tools">
2554
+ <label class="field">Filter routes
2555
+ <input id="route-search" type="search" placeholder="Method, path, or extension" autocomplete="off">
2556
+ </label>
2557
+ <span class="route-count" id="route-count"></span>
2558
+ </div>
2198
2559
  <div id="route-list"></div>
2199
- <form id="route-form" class="card stack">
2560
+ <form id="route-form" class="card stack" style="margin-top: 18px">
2200
2561
  <strong id="route-title">Select a route</strong>
2201
2562
  <label class="field">JSON body
2202
- <textarea id="route-body" placeholder="{ }"></textarea>
2563
+ <div class="json-editor" id="route-editor" hidden>
2564
+ <pre aria-hidden="true"><code id="route-highlight"></code></pre>
2565
+ <textarea id="route-body" placeholder="{ }" spellcheck="false" aria-describedby="route-json-status" disabled></textarea>
2566
+ </div>
2567
+ <span class="editor-meta" hidden><span class="json-status" id="route-json-status">Valid JSON</span><button class="format-json" type="button" data-format="route-body">Format JSON</button></span>
2203
2568
  </label>
2204
2569
  <div class="dialog-actions">
2205
- <button class="btn-primary" type="submit">Send</button>
2570
+ <button class="btn-primary" id="route-send" type="submit" disabled>Send request</button>
2206
2571
  </div>
2207
- <pre id="route-result">Response will show here.</pre>
2572
+ <pre class="json-view" id="route-result"><code>Response will show here.</code></pre>
2208
2573
  </form>
2209
2574
  </section>
2210
2575
  </main>
@@ -2212,12 +2577,16 @@ var renderAdminDocument = (service) => {
2212
2577
  </div>
2213
2578
  <dialog id="editor">
2214
2579
  <form method="dialog" id="editor-form">
2215
- <strong id="editor-title">Record</strong>
2580
+ <div class="dialog-heading"><div><strong id="editor-title">Record</strong><br><span>Edit the ID and JSON value stored in this collection.</span></div></div>
2216
2581
  <label class="field">Id
2217
2582
  <input id="editor-id" required>
2218
2583
  </label>
2219
2584
  <label class="field">JSON value
2220
- <textarea id="editor-value"></textarea>
2585
+ <div class="json-editor" id="record-editor">
2586
+ <pre aria-hidden="true"><code id="editor-highlight"></code></pre>
2587
+ <textarea id="editor-value" spellcheck="false" aria-describedby="editor-json-status"></textarea>
2588
+ </div>
2589
+ <span class="editor-meta"><span class="json-status" id="editor-json-status">Valid JSON</span><button class="format-json" type="button" data-format="editor-value">Format JSON</button></span>
2221
2590
  </label>
2222
2591
  <div class="dialog-actions">
2223
2592
  <button class="btn-danger" id="editor-delete" type="button" value="delete">Delete</button>
@@ -2259,8 +2628,8 @@ var describeExtension = (extension) => {
2259
2628
  ...extension.script !== void 0 ? { script: extension.script } : {}
2260
2629
  };
2261
2630
  };
2262
- var adminUiRoutes = (service, ui) => {
2263
- const shell = () => renderAdminDocument(service);
2631
+ var adminUiRoutes = (service, ui, adminPrefix = ADMIN_PREFIX) => {
2632
+ const shell = () => renderAdminDocument(service, adminPrefix);
2264
2633
  const document2 = () => ui?.render ? ui.render({ service, defaultHtml: shell }) : shell();
2265
2634
  return {
2266
2635
  "GET /ui": () => htmlResponse(document2()),
@@ -2383,7 +2752,34 @@ var faultResponse = (rule) => {
2383
2752
  };
2384
2753
  var createFaultRegistry = (rng = createRng(0), sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) => {
2385
2754
  const entries = [];
2755
+ let isolated = false;
2756
+ const scopedRngs = /* @__PURE__ */ new Map();
2757
+ const random = (namespace) => {
2758
+ if (!isolated || namespace === "default") return rng;
2759
+ let scoped = scopedRngs.get(namespace);
2760
+ if (!scoped) {
2761
+ scoped = createRng(rng.seed);
2762
+ scopedRngs.set(namespace, scoped);
2763
+ }
2764
+ return scoped;
2765
+ };
2386
2766
  return {
2767
+ isolateNamespaces() {
2768
+ isolated = true;
2769
+ },
2770
+ snapshot(namespace) {
2771
+ return {
2772
+ rules: structuredClone(
2773
+ entries.map((entry, position) => ({
2774
+ ...entry.rule,
2775
+ remaining: entry.remaining,
2776
+ hits: entry.hits,
2777
+ position
2778
+ })).filter((entry) => entry.namespace === namespace)
2779
+ ),
2780
+ rngState: random(namespace).state()
2781
+ };
2782
+ },
2387
2783
  add(rule) {
2388
2784
  const existing = entries.findIndex((e) => e.rule.id === rule.id);
2389
2785
  const entry = { rule, remaining: rule.count ?? null, hits: 0 };
@@ -2400,6 +2796,15 @@ var createFaultRegistry = (rng = createRng(0), sleep = (ms) => new Promise((reso
2400
2796
  },
2401
2797
  clear() {
2402
2798
  entries.length = 0;
2799
+ rng.reset();
2800
+ scopedRngs.clear();
2801
+ },
2802
+ restore(namespace, state) {
2803
+ for (let i = entries.length - 1; i >= 0; i--)
2804
+ if (entries[i]?.rule.namespace === namespace) entries.splice(i, 1);
2805
+ for (const { remaining, hits, position, ...rule } of state.rules)
2806
+ entries.splice(Math.min(position, entries.length), 0, { rule, remaining, hits });
2807
+ random(namespace).setState(state.rngState);
2403
2808
  },
2404
2809
  async take(candidate) {
2405
2810
  const hits = [];
@@ -2407,7 +2812,7 @@ var createFaultRegistry = (rng = createRng(0), sleep = (ms) => new Promise((reso
2407
2812
  if (entry.remaining === 0) continue;
2408
2813
  if (!matches(entry.rule, candidate)) continue;
2409
2814
  const rate = entry.rule.rate ?? 1;
2410
- if (rng.next() >= rate) continue;
2815
+ if (random(candidate.namespace).next() >= rate) continue;
2411
2816
  entry.hits++;
2412
2817
  if (entry.remaining !== null) entry.remaining--;
2413
2818
  const delay = entry.rule.delayMs ?? entry.rule.latencyMs;
@@ -2467,7 +2872,8 @@ var createJournal = (size = DEFAULT_JOURNAL_SIZE) => {
2467
2872
  };
2468
2873
 
2469
2874
  // ../core/src/metrics.ts
2470
- var createMetrics = () => {
2875
+ var createMetrics = (partition = true) => {
2876
+ const namespaces = /* @__PURE__ */ new Map();
2471
2877
  let requests = 0;
2472
2878
  let faults = 0;
2473
2879
  let totalDurationMs = 0;
@@ -2475,6 +2881,14 @@ var createMetrics = () => {
2475
2881
  const unmatched = /* @__PURE__ */ new Map();
2476
2882
  return {
2477
2883
  record(entry) {
2884
+ if (partition) {
2885
+ let scoped = namespaces.get(entry.namespace);
2886
+ if (!scoped) {
2887
+ scoped = createMetrics(false);
2888
+ namespaces.set(entry.namespace, scoped);
2889
+ }
2890
+ scoped.record(entry);
2891
+ }
2478
2892
  requests++;
2479
2893
  totalDurationMs += entry.durationMs;
2480
2894
  if (entry.faultId !== void 0) faults++;
@@ -2485,17 +2899,24 @@ var createMetrics = () => {
2485
2899
  unmatched.set(route, (unmatched.get(route) ?? 0) + 1);
2486
2900
  }
2487
2901
  },
2488
- report: () => ({
2902
+ report: (namespace) => namespace !== void 0 ? (namespaces.get(namespace) ?? createMetrics(false)).report() : {
2489
2903
  requests,
2490
- byOperation: Object.fromEntries([...byOperation].sort(([a], [b]) => a.localeCompare(b))),
2904
+ byOperation: Object.fromEntries(
2905
+ [...byOperation].sort(([a], [b]) => a.localeCompare(b))
2906
+ ),
2491
2907
  unmatched: [...unmatched].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([route, count]) => {
2492
2908
  const space = route.indexOf(" ");
2493
2909
  return { method: route.slice(0, space), path: route.slice(space + 1), count };
2494
2910
  }),
2495
2911
  faults,
2496
2912
  totalDurationMs
2497
- }),
2498
- reset() {
2913
+ },
2914
+ reset(namespace) {
2915
+ if (namespace !== void 0) {
2916
+ namespaces.delete(namespace);
2917
+ return;
2918
+ }
2919
+ namespaces.clear();
2499
2920
  requests = 0;
2500
2921
  faults = 0;
2501
2922
  totalDurationMs = 0;
@@ -2519,7 +2940,7 @@ var PAGE_DEFAULT = 50;
2519
2940
  var PAGE_MAX = 200;
2520
2941
  var json3 = (status, body) => new Response(JSON.stringify(body), {
2521
2942
  status,
2522
- headers: { "content-type": "application/json" }
2943
+ headers: { "content-type": "application/json; charset=utf-8" }
2523
2944
  });
2524
2945
  var adminError2 = (status, message) => json3(status, { error: { type: "mockingbird_admin", message } });
2525
2946
  var isRecord3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
@@ -2743,6 +3164,7 @@ var sqlRoutes = (engine) => ({
2743
3164
  }
2744
3165
  });
2745
3166
  var createEngineAdmin = (options) => {
3167
+ const adminPrefix = resolveAdminPrefix(options.adminPrefix);
2746
3168
  const engines = /* @__PURE__ */ new Map();
2747
3169
  const engine = (namespace) => {
2748
3170
  const existing = engines.get(namespace);
@@ -2756,7 +3178,7 @@ var createEngineAdmin = (options) => {
2756
3178
  ...credentialRoutes(createCredentialRegistry()),
2757
3179
  ...stateRoutes(engine),
2758
3180
  ...sqlRoutes(engine),
2759
- ...adminUiRoutes(options.name, { extensions: [SQL_ADMIN_EXTENSION] })
3181
+ ...adminUiRoutes(options.name, { extensions: [SQL_ADMIN_EXTENSION] }, adminPrefix)
2760
3182
  };
2761
3183
  const startedAt = Date.now();
2762
3184
  const plane = createControlPlane({
@@ -2786,6 +3208,7 @@ var createEngineAdmin = (options) => {
2786
3208
  },
2787
3209
  describe: () => options.dialect === void 0 ? {} : { dialect: options.dialect },
2788
3210
  routes,
3211
+ adminPrefix,
2789
3212
  adminKey: options.adminKey
2790
3213
  });
2791
3214
  return {
@@ -19167,6 +19590,7 @@ function createAdmin(options = {}) {
19167
19590
  return createEngineAdmin({
19168
19591
  name: "sqlite",
19169
19592
  dialect: "sqlite",
19593
+ ...options.adminPrefix !== void 0 ? { adminPrefix: options.adminPrefix } : {},
19170
19594
  ...options.adminKey !== void 0 ? { adminKey: options.adminKey } : {},
19171
19595
  open(namespace) {
19172
19596
  if (namespace === "default" && seeded) return adapt(seeded);