@crvouga/mockingbird-service-postgres 1.6.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(text) {
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 text.matchAll(token)) {
397
+ const index = match.index ?? 0;
398
+ const value = match[0];
399
+ html += escapeToken(text.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(text.slice(cursor));
405
+ }
378
406
  function mountAdminBrand(doc, host, service, catalog) {
379
407
  const clip = (value, max) => {
380
408
  if (typeof value !== "string")
@@ -736,14 +764,16 @@ function bootAdmin(config) {
736
764
  const state = {
737
765
  namespace: "default",
738
766
  key: sessionStorage.getItem("mockingbird-admin-key") || "",
739
- view: "overview",
767
+ view: ["overview", "state", "clock", "faults", "journal", "routes"].includes(location.hash.slice(1)) ? location.hash.slice(1) : "overview",
740
768
  collection: "",
741
769
  shape: null,
742
770
  route: null,
771
+ routes: [],
743
772
  editing: null
744
773
  };
745
774
  const standard = new Set(config.standardRoutes);
746
775
  const banner = el("banner", HTMLElement);
776
+ const bannerMessage = el("banner-message", HTMLElement);
747
777
  const keyInput = el("admin-key", HTMLInputElement);
748
778
  keyInput.value = state.key;
749
779
  const params = new URLSearchParams(location.search);
@@ -754,18 +784,21 @@ function bootAdmin(config) {
754
784
  sessionStorage.setItem("mockingbird-admin-key", state.key);
755
785
  params.delete("key");
756
786
  const next = params.toString();
757
- history.replaceState(null, "", location.pathname + (next ? `?${next}` : ""));
787
+ history.replaceState(null, "", location.pathname + (next ? `?${next}` : "") + location.hash);
758
788
  }
759
789
  const queryNamespace = params.get("namespace");
760
790
  if (queryNamespace)
761
791
  state.namespace = queryNamespace;
762
792
  const showError = (error) => {
763
- banner.textContent = error instanceof Error ? error.message : String(error);
793
+ bannerMessage.textContent = error instanceof Error ? error.message : String(error);
764
794
  banner.classList.add("show");
795
+ banner.scrollIntoView({ behavior: "smooth", block: "nearest" });
765
796
  };
766
797
  const clearError = () => {
767
798
  banner.classList.remove("show");
799
+ bannerMessage.textContent = "";
768
800
  };
801
+ el("banner-dismiss", HTMLButtonElement).addEventListener("click", clearError);
769
802
  const api = async (method, path, body) => {
770
803
  const headers = { accept: "application/json" };
771
804
  if (state.key !== "")
@@ -809,16 +842,21 @@ function bootAdmin(config) {
809
842
  namespace: () => state.namespace
810
843
  };
811
844
  const isPanelScript = (value) => typeof value === "function";
845
+ const themeButton = el("theme", HTMLButtonElement);
812
846
  const setTheme = (theme) => {
813
847
  if (theme)
814
848
  document.documentElement.dataset.theme = theme;
815
849
  else
816
850
  delete document.documentElement.dataset.theme;
851
+ const dark = theme ? theme === "dark" : matchMedia("(prefers-color-scheme: dark)").matches;
852
+ 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>';
853
+ const label = dark ? "Use light theme" : "Use dark theme";
854
+ themeButton.setAttribute("aria-label", label);
855
+ themeButton.title = label;
817
856
  };
818
857
  const storedTheme = localStorage.getItem("mockingbird-admin-theme");
819
- if (storedTheme)
820
- setTheme(storedTheme);
821
- el("theme", HTMLButtonElement).addEventListener("click", () => {
858
+ setTheme(storedTheme === "dark" || storedTheme === "light" ? storedTheme : null);
859
+ themeButton.addEventListener("click", () => {
822
860
  const current = document.documentElement.dataset.theme;
823
861
  const dark = current ? current === "dark" : matchMedia("(prefers-color-scheme: dark)").matches;
824
862
  const next = dark ? "light" : "dark";
@@ -837,6 +875,7 @@ function bootAdmin(config) {
837
875
  continue;
838
876
  section.hidden = section.id !== `view-${view}`;
839
877
  }
878
+ history.replaceState(null, "", `${location.pathname}${location.search}#${encodeURIComponent(view)}`);
840
879
  };
841
880
  el("nav", HTMLElement).addEventListener("click", (event) => {
842
881
  const target = event.target;
@@ -851,7 +890,7 @@ function bootAdmin(config) {
851
890
  show(view);
852
891
  refresh().catch(showError);
853
892
  });
854
- const cards = (items) => `<div class="grid">${items.map((item) => `<article class="card"><div class="k">${item.k}</div><div class="v">${item.v}</div></article>`).join("")}</div>`;
893
+ const cards = (items) => `<div class="grid">${items.map((item) => `<article class="card stat-card"><div class="k">${item.k}</div><div class="v">${item.v}</div></article>`).join("")}</div>`;
855
894
  const table = (columns, rows) => {
856
895
  if (rows.length === 0)
857
896
  return '<p class="empty">Nothing here yet.</p>';
@@ -861,16 +900,22 @@ function bootAdmin(config) {
861
900
  const attrs = "attrs" in row && typeof row.attrs === "string" ? row.attrs : "";
862
901
  return `<tr${attrs}>${cells}</tr>`;
863
902
  }).join("");
864
- return `<table><thead>${head}</thead><tbody>${body}</tbody></table>`;
903
+ return `<div class="table-shell"><table><thead>${head}</thead><tbody>${body}</tbody></table></div>`;
865
904
  };
866
905
  const preview = (value) => {
867
906
  const text = JSON.stringify(value) ?? "null";
868
- return esc(text.length > 180 ? `${text.slice(0, 180)}\u2026` : text);
907
+ const clipped = text.length > 180 ? `${text.slice(0, 180)}\u2026` : text;
908
+ return `<code class="json-inline">${highlightJsonText(clipped)}</code>`;
869
909
  };
870
910
  const when = (epoch) => {
871
911
  const date = new Date(epoch);
872
912
  return Number.isNaN(date.getTime()) ? String(epoch) : date.toISOString();
873
913
  };
914
+ const journalWhen = (value) => {
915
+ const numeric = value.trim() === "" ? Number.NaN : Number(value);
916
+ const epoch = Number.isFinite(numeric) ? numeric : Date.parse(value);
917
+ return Number.isFinite(epoch) ? when(epoch) : value;
918
+ };
874
919
  const clockCard = (epoch) => {
875
920
  const date = new Date(epoch);
876
921
  if (Number.isNaN(date.getTime()))
@@ -990,7 +1035,7 @@ function bootAdmin(config) {
990
1035
  };
991
1036
  const renderOverview = async () => {
992
1037
  const [health, shape, clock, journal] = await Promise.all([
993
- originGet("/health"),
1038
+ originGet(`${config.adminPrefix}/health`),
994
1039
  api("GET", "/state"),
995
1040
  api("GET", "/clock"),
996
1041
  api("GET", "/requests?limit=8")
@@ -1007,7 +1052,7 @@ function bootAdmin(config) {
1007
1052
  },
1008
1053
  {
1009
1054
  k: "Status",
1010
- v: esc(typeof health.status === "string" && health.status !== "" ? health.status : "ok")
1055
+ v: `<span class="badge ok">${esc(typeof health.status === "string" && health.status !== "" ? health.status : "ok")}</span>`
1011
1056
  },
1012
1057
  { k: "Namespace", v: esc(state.namespace) },
1013
1058
  { k: "Collections", v: String(decoded.collections.length) },
@@ -1094,26 +1139,46 @@ function bootAdmin(config) {
1094
1139
  const renderJournal = async () => {
1095
1140
  const journal = await api("GET", "/requests?limit=100");
1096
1141
  el("journal", HTMLElement).innerHTML = table([
1097
- { label: "When", cell: (row) => esc(row.at) },
1098
- { label: "Method", cell: (row) => esc(row.method) },
1142
+ { label: "When", cell: (row) => esc(journalWhen(row.at)) },
1143
+ {
1144
+ label: "Method",
1145
+ cell: (row) => `<span class="method">${esc(row.method)}</span>`
1146
+ },
1099
1147
  { label: "Path", cell: (row) => esc(row.path) },
1100
- { label: "Status", cell: (row) => esc(row.status) },
1148
+ {
1149
+ label: "Status",
1150
+ cell: (row) => `<span class="badge ${row.status.startsWith("2") ? "ok" : row.status.startsWith("4") || row.status.startsWith("5") ? "warn" : ""}">${esc(row.status)}</span>`
1151
+ },
1101
1152
  { label: "ms", cell: (row) => esc(row.durationMs) }
1102
1153
  ], decodeJournal(journal));
1103
1154
  };
1104
- const renderRoutes = async () => {
1105
- const data = await api("GET", "/");
1106
- if (!isRecord(data))
1107
- throw new Error("Route list was not an object");
1108
- const routes = requireList(data.routes, "Routes").map((route) => String(route));
1155
+ const paintRoutes = () => {
1156
+ const query = el("route-search", HTMLInputElement).value.trim().toLocaleLowerCase();
1157
+ const routes = state.routes.filter((route) => route.toLocaleLowerCase().includes(query));
1158
+ el("route-count", HTMLElement).textContent = `${routes.length} of ${state.routes.length}`;
1159
+ if (state.routes.length === 0) {
1160
+ el("route-list", HTMLElement).innerHTML = '<p class="empty">No admin routes are available.</p>';
1161
+ return;
1162
+ }
1163
+ if (routes.length === 0) {
1164
+ el("route-list", HTMLElement).innerHTML = '<p class="empty">No routes match this filter.</p>';
1165
+ return;
1166
+ }
1109
1167
  el("route-list", HTMLElement).innerHTML = routes.map((route) => {
1110
1168
  const extra = standard.has(route) ? "" : " <span class='badge'>extension</span>";
1111
1169
  const parts = route.split(" ");
1112
1170
  const method = parts[0] ?? "";
1113
1171
  const path = parts.slice(1).join(" ");
1114
- 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>`;
1172
+ 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>`;
1115
1173
  }).join("");
1116
1174
  };
1175
+ const renderRoutes = async () => {
1176
+ const data = await api("GET", "/");
1177
+ if (!isRecord(data))
1178
+ throw new Error("Route list was not an object");
1179
+ state.routes = requireList(data.routes, "Routes").map((route) => String(route));
1180
+ paintRoutes();
1181
+ };
1117
1182
  const renderers = {
1118
1183
  overview: renderOverview,
1119
1184
  state: renderState,
@@ -1122,19 +1187,50 @@ function bootAdmin(config) {
1122
1187
  journal: renderJournal,
1123
1188
  routes: renderRoutes
1124
1189
  };
1190
+ let refreshToken = 0;
1125
1191
  async function refresh() {
1192
+ const token = ++refreshToken;
1126
1193
  clearError();
1194
+ const main = el("views", HTMLElement);
1195
+ const sync = el("sync-status", HTMLElement);
1196
+ main.setAttribute("aria-busy", "true");
1197
+ sync.classList.add("busy");
1198
+ sync.textContent = "Updating";
1127
1199
  const render = renderers[state.view];
1128
- if (render)
1129
- await render();
1200
+ try {
1201
+ if (render)
1202
+ await render();
1203
+ if (token === refreshToken)
1204
+ sync.textContent = "Up to date";
1205
+ } finally {
1206
+ if (token === refreshToken) {
1207
+ main.removeAttribute("aria-busy");
1208
+ sync.classList.remove("busy");
1209
+ }
1210
+ }
1130
1211
  }
1131
1212
  const delay = (ms) => new Promise((resolve) => {
1132
1213
  window.setTimeout(resolve, ms);
1133
1214
  });
1134
- const confirmAction = async (button, pending, done, work) => {
1215
+ const confirmAction = async (button, pending, done, work, confirmLabel) => {
1135
1216
  if (button.dataset.pending === "1")
1136
1217
  return;
1137
1218
  const label = button.textContent ?? "";
1219
+ if (confirmLabel !== void 0 && button.dataset.confirming !== "1") {
1220
+ button.dataset.confirming = "1";
1221
+ button.classList.add("confirming");
1222
+ button.textContent = confirmLabel;
1223
+ window.setTimeout(() => {
1224
+ if (button.dataset.confirming !== "1")
1225
+ return;
1226
+ button.textContent = label;
1227
+ button.classList.remove("confirming");
1228
+ delete button.dataset.confirming;
1229
+ }, 3e3);
1230
+ return;
1231
+ }
1232
+ button.classList.remove("confirming");
1233
+ delete button.dataset.confirming;
1138
1234
  const width = button.offsetWidth;
1139
1235
  button.dataset.pending = "1";
1140
1236
  button.classList.add("pressed");
@@ -1155,6 +1251,65 @@ function bootAdmin(config) {
1155
1251
  delete button.dataset.pending;
1156
1252
  }
1157
1253
  };
1254
+ const wireJsonEditor = (textareaId, highlightId, statusId, shellId, allowEmpty) => {
1255
+ const textarea = el(textareaId, HTMLTextAreaElement);
1256
+ const highlight = el(highlightId, HTMLElement);
1257
+ const status = el(statusId, HTMLElement);
1258
+ const shell = el(shellId, HTMLElement);
1259
+ const sync = () => {
1260
+ const text = textarea.value;
1261
+ highlight.innerHTML = `${highlightJsonText(text)}
1262
+ `;
1263
+ let valid = true;
1264
+ let message = "Valid JSON";
1265
+ if (allowEmpty && text.trim() === "")
1266
+ message = "No body";
1267
+ else {
1268
+ try {
1269
+ readJson2(text);
1270
+ } catch {
1271
+ valid = false;
1272
+ message = "Invalid JSON";
1273
+ }
1274
+ }
1275
+ shell.classList.toggle("invalid", !valid);
1276
+ status.classList.toggle("invalid", !valid);
1277
+ status.textContent = message;
1278
+ textarea.setAttribute("aria-invalid", valid ? "false" : "true");
1279
+ };
1280
+ const syncScroll = () => {
1281
+ const preview2 = highlight.parentElement;
1282
+ if (!(preview2 instanceof HTMLElement))
1283
+ return;
1284
+ preview2.scrollTop = textarea.scrollTop;
1285
+ preview2.scrollLeft = textarea.scrollLeft;
1286
+ };
1287
+ textarea.addEventListener("input", sync);
1288
+ textarea.addEventListener("scroll", syncScroll);
1289
+ sync();
1290
+ return sync;
1291
+ };
1292
+ const syncRouteEditor = wireJsonEditor("route-body", "route-highlight", "route-json-status", "route-editor", true);
1293
+ const syncRecordEditor = wireJsonEditor("editor-value", "editor-highlight", "editor-json-status", "record-editor", false);
1294
+ for (const button of document.querySelectorAll("[data-format]")) {
1295
+ if (!(button instanceof HTMLButtonElement))
1296
+ continue;
1297
+ button.addEventListener("click", () => {
1298
+ const target = button.dataset.format;
1299
+ if (target === void 0)
1300
+ return;
1301
+ const textarea = document.getElementById(target);
1302
+ if (!(textarea instanceof HTMLTextAreaElement) || textarea.value.trim() === "")
1303
+ return;
1304
+ try {
1305
+ textarea.value = JSON.stringify(readJson2(textarea.value), null, 2) ?? "";
1306
+ textarea.dispatchEvent(new Event("input"));
1307
+ textarea.focus();
1308
+ } catch {
1309
+ showError(new Error("Fix the JSON before formatting it"));
1310
+ }
1311
+ });
1312
+ }
1158
1313
  document.addEventListener("click", (event) => {
1159
1314
  const target = event.target;
1160
1315
  if (!(target instanceof Element))
@@ -1227,6 +1382,7 @@ function bootAdmin(config) {
1227
1382
  } else {
1228
1383
  editorValue.value = "{\n \n}";
1229
1384
  }
1385
+ syncRecordEditor();
1230
1386
  editor.showModal();
1231
1387
  }
1232
1388
  el("new-record", HTMLButtonElement).addEventListener("click", () => {
@@ -1256,10 +1412,10 @@ function bootAdmin(config) {
1256
1412
  const id = state.editing;
1257
1413
  if (!id)
1258
1414
  return;
1259
- api("DELETE", `/state/${encodeURIComponent(state.collection)}/${encodeURIComponent(id)}`).then(() => {
1415
+ confirmAction(editorDelete, "Deleting...", "Deleted", () => api("DELETE", `/state/${encodeURIComponent(state.collection)}/${encodeURIComponent(id)}`).then(() => {
1260
1416
  editor.close();
1261
1417
  return renderState();
1262
- }).catch(showError);
1418
+ }), "Confirm delete").catch(showError);
1263
1419
  });
1264
1420
  for (const button of document.querySelectorAll("[data-advance]")) {
1265
1421
  if (!(button instanceof HTMLButtonElement))
@@ -1305,10 +1461,10 @@ function bootAdmin(config) {
1305
1461
  }).then(() => renderFaults()).catch(showError);
1306
1462
  });
1307
1463
  el("fault-clear", HTMLButtonElement).addEventListener("click", () => {
1308
- confirmAction(el("fault-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/faults").then(() => renderFaults())).catch(showError);
1464
+ confirmAction(el("fault-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/faults").then(() => renderFaults()), "Confirm clear").catch(showError);
1309
1465
  });
1310
1466
  el("journal-clear", HTMLButtonElement).addEventListener("click", () => {
1311
- confirmAction(el("journal-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/requests").then(() => renderJournal())).catch(showError);
1467
+ confirmAction(el("journal-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/requests").then(() => renderJournal()), "Confirm clear").catch(showError);
1312
1468
  });
1313
1469
  el("route-list", HTMLElement).addEventListener("click", (event) => {
1314
1470
  const target = event.target;
@@ -1322,9 +1478,15 @@ function bootAdmin(config) {
1322
1478
  return;
1323
1479
  state.route = route;
1324
1480
  el("route-title", HTMLElement).textContent = route;
1481
+ el("route-send", HTMLButtonElement).disabled = false;
1325
1482
  const method = route.split(" ")[0] ?? "";
1326
- el("route-body", HTMLTextAreaElement).disabled = method === "GET" || method === "DELETE";
1483
+ const routeBody = el("route-body", HTMLTextAreaElement);
1484
+ routeBody.disabled = method === "GET" || method === "DELETE";
1485
+ el("route-editor", HTMLElement).hidden = routeBody.disabled;
1486
+ el("route-json-status", HTMLElement).parentElement?.toggleAttribute("hidden", routeBody.disabled);
1487
+ syncRouteEditor();
1327
1488
  });
1489
+ el("route-search", HTMLInputElement).addEventListener("input", paintRoutes);
1328
1490
  el("route-form", HTMLFormElement).addEventListener("submit", (event) => {
1329
1491
  event.preventDefault();
1330
1492
  const send = async () => {
@@ -1346,7 +1508,8 @@ function bootAdmin(config) {
1346
1508
  }
1347
1509
  }
1348
1510
  const data = await api(method, concrete, body);
1349
- el("route-result", HTMLElement).textContent = JSON.stringify(data, null, 2);
1511
+ const text = JSON.stringify(data, null, 2) ?? "null";
1512
+ el("route-result", HTMLElement).innerHTML = `<code>${highlightJsonText(text)}</code>`;
1350
1513
  };
1351
1514
  send().catch(showError);
1352
1515
  });
@@ -1464,11 +1627,13 @@ function bootAdmin(config) {
1464
1627
  views.append(section);
1465
1628
  mountSqlExplorer(body, panelApi);
1466
1629
  }
1630
+ show(document.getElementById(`view-${state.view}`) === null ? "overview" : state.view);
1467
1631
  return refresh();
1468
1632
  }).catch(showError);
1469
1633
  }
1470
1634
  var clientPrelude = [
1471
1635
  isRecord,
1636
+ highlightJsonText,
1472
1637
  mountAdminBrand,
1473
1638
  startAdminBrand,
1474
1639
  faultPresetList,
@@ -1488,13 +1653,19 @@ ${entry}(${payload});
1488
1653
  };
1489
1654
 
1490
1655
  // ../core/dist/control.js
1491
- var HEALTH_PATH = "/health";
1492
1656
  var ADMIN_PREFIX = "/__admin";
1657
+ var resolveAdminPrefix = (prefix = ADMIN_PREFIX) => {
1658
+ if (!/^\/[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_-]+)*$/.test(prefix)) {
1659
+ throw new Error("adminPrefix must be an absolute non-root path of literal segments, without a trailing slash");
1660
+ }
1661
+ return prefix;
1662
+ };
1663
+ var isAdminPath = (path, prefix) => path === prefix || path.startsWith(`${prefix}/`);
1493
1664
  var ADMIN_KEY_HEADER = "x-mockingbird-admin-key";
1494
1665
  var NAMESPACE_HEADER = "x-mockingbird-namespace";
1495
1666
  var json = (status, body) => new Response(JSON.stringify(body), {
1496
1667
  status,
1497
- headers: { "content-type": "application/json" }
1668
+ headers: { "content-type": "application/json; charset=utf-8" }
1498
1669
  });
1499
1670
  var adminError = (status, message) => json(status, { error: { type: "mockingbird_admin", message } });
1500
1671
  var UNITS = {
@@ -1546,11 +1717,25 @@ var readJson = async (request) => {
1546
1717
  };
1547
1718
  var isRecord2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1548
1719
  var createControlPlane = (context) => {
1720
+ const adminPrefix = resolveAdminPrefix(context.adminPrefix);
1549
1721
  const snapshots = /* @__PURE__ */ new Map();
1550
1722
  let snapshotCounter = 0;
1551
1723
  const headerNamespace = (request) => request.headers.get(NAMESPACE_HEADER) ?? context.defaultNamespace;
1552
1724
  const adminNamespace = (request, url) => url.searchParams.get("namespace") ?? headerNamespace(request);
1725
+ const health = () => {
1726
+ return json(200, {
1727
+ ...context.describe(),
1728
+ status: "ok",
1729
+ service: context.name,
1730
+ uptimeMs: context.wallNow() - context.startedAt,
1731
+ clock: context.clock.state(),
1732
+ namespaces: context.namespaces().length,
1733
+ adminPrefix,
1734
+ adminUi: `${adminPrefix}/ui`
1735
+ });
1736
+ };
1553
1737
  const builtin = {
1738
+ "GET /health": health,
1554
1739
  "GET /": () => json(200, {
1555
1740
  service: context.name,
1556
1741
  routes: [...Object.keys(builtin), ...Object.keys(context.routes)].sort()
@@ -1561,29 +1746,30 @@ var createControlPlane = (context) => {
1561
1746
  return json(200, { status: "ok", reset: target === "*" ? context.namespaces() : [target] });
1562
1747
  },
1563
1748
  "GET /namespaces": () => json(200, { default: context.defaultNamespace, namespaces: context.namespaces() }),
1564
- "GET /clock": () => json(200, context.clock.state()),
1565
- "POST /clock": ({ body }) => {
1749
+ "GET /clock": ({ namespace }) => json(200, (context.namespaceClock?.(namespace) ?? context.clock).state()),
1750
+ "POST /clock": ({ body, namespace }) => {
1751
+ const clock = context.namespaceClock?.(namespace) ?? context.clock;
1566
1752
  if (!isRecord2(body))
1567
1753
  return adminError(400, "expected a JSON object");
1568
1754
  if (body.reset === true)
1569
- context.clock.reset();
1755
+ clock.reset();
1570
1756
  if (body.freeze === true)
1571
- context.clock.freeze();
1757
+ clock.freeze();
1572
1758
  if (body.set !== void 0) {
1573
1759
  const instant = parseInstant(body.set);
1574
1760
  if (instant === void 0)
1575
1761
  return adminError(400, "set: expected epoch ms or ISO-8601");
1576
- context.clock.set(instant);
1762
+ clock.set(instant);
1577
1763
  }
1578
1764
  if (body.advance !== void 0) {
1579
1765
  const delta = parseDuration(body.advance);
1580
1766
  if (delta === void 0)
1581
1767
  return adminError(400, 'advance: expected ms or "15m"-style');
1582
- context.clock.advance(delta);
1768
+ clock.advance(delta);
1583
1769
  }
1584
1770
  if (body.freeze === false)
1585
- context.clock.unfreeze();
1586
- return json(200, context.clock.state());
1771
+ clock.unfreeze();
1772
+ return json(200, clock.state());
1587
1773
  },
1588
1774
  "GET /faults": () => json(200, { faults: context.faults.list() }),
1589
1775
  "POST /faults": ({ body, namespace }) => {
@@ -1716,7 +1902,14 @@ var createControlPlane = (context) => {
1716
1902
  return json(200, { status: "ok" });
1717
1903
  }
1718
1904
  };
1719
- const routes = [...Object.entries(context.routes), ...Object.entries(builtin)].map(([key, handler]) => {
1905
+ for (const key of Object.keys(context.routes)) {
1906
+ if (Object.hasOwn(builtin, key))
1907
+ throw new Error(`admin route ${key} overrides a shared route`);
1908
+ const path = key.slice(key.indexOf(" ") + 1);
1909
+ if (isAdminPath(path, "/ns") || isAdminPath(path, "/blobs"))
1910
+ throw new Error(`admin route ${key} overlaps an internal transport`);
1911
+ }
1912
+ const routes = [...Object.entries(builtin), ...Object.entries(context.routes)].map(([key, handler]) => {
1720
1913
  const space = key.indexOf(" ");
1721
1914
  return { method: key.slice(0, space), pattern: key.slice(space + 1), handler };
1722
1915
  });
@@ -1724,21 +1917,13 @@ var createControlPlane = (context) => {
1724
1917
  namespaceOf: headerNamespace,
1725
1918
  async handle(request) {
1726
1919
  const url = new URL(request.url);
1727
- if (url.pathname === HEALTH_PATH && request.method === "GET") {
1728
- return json(200, {
1729
- status: "ok",
1730
- service: context.name,
1731
- uptimeMs: context.wallNow() - context.startedAt,
1732
- clock: context.clock.state(),
1733
- namespaces: context.namespaces().length,
1734
- adminUi: `${ADMIN_PREFIX}/ui`,
1735
- ...context.describe()
1736
- });
1920
+ if (url.pathname === `${adminPrefix}/health` && request.method === "GET") {
1921
+ return health();
1737
1922
  }
1738
- if (url.pathname !== ADMIN_PREFIX && !url.pathname.startsWith(`${ADMIN_PREFIX}/`)) {
1923
+ if (!isAdminPath(url.pathname, adminPrefix)) {
1739
1924
  return void 0;
1740
1925
  }
1741
- const path = url.pathname.slice(ADMIN_PREFIX.length) || "/";
1926
+ const path = url.pathname.slice(adminPrefix.length) || "/";
1742
1927
  const shell = request.method === "GET" && (path === "/ui" || path === "/ui/");
1743
1928
  if (!shell && context.adminKey !== void 0 && request.headers.get(ADMIN_KEY_HEADER) !== context.adminKey) {
1744
1929
  return adminError(401, `missing or wrong ${ADMIN_KEY_HEADER}`);
@@ -1746,7 +1931,12 @@ var createControlPlane = (context) => {
1746
1931
  for (const route of routes) {
1747
1932
  if (route.method !== request.method)
1748
1933
  continue;
1749
- const params = matchRoute(route.pattern, path);
1934
+ let params;
1935
+ try {
1936
+ params = matchRoute(route.pattern, path);
1937
+ } catch {
1938
+ return adminError(400, "invalid path encoding");
1939
+ }
1750
1940
  if (!params)
1751
1941
  continue;
1752
1942
  let body;
@@ -1755,15 +1945,18 @@ var createControlPlane = (context) => {
1755
1945
  } catch {
1756
1946
  return adminError(400, "request body is not valid JSON");
1757
1947
  }
1948
+ const namespace = adminNamespace(request, url);
1949
+ if (!/^[A-Za-z0-9_.-]{1,64}$/.test(namespace))
1950
+ return adminError(400, "invalid namespace");
1758
1951
  return route.handler({
1759
1952
  request,
1760
1953
  url,
1761
1954
  params,
1762
- namespace: adminNamespace(request, url),
1955
+ namespace,
1763
1956
  body
1764
1957
  });
1765
1958
  }
1766
- return adminError(404, `no admin route ${request.method} ${path}; GET ${ADMIN_PREFIX} lists them`);
1959
+ return adminError(404, `no admin route ${request.method} ${path}; GET ${adminPrefix} lists them`);
1767
1960
  }
1768
1961
  };
1769
1962
  };
@@ -1771,6 +1964,7 @@ var createControlPlane = (context) => {
1771
1964
  // ../core/dist/surface.js
1772
1965
  var STANDARD_ADMIN_ROUTES = [
1773
1966
  "GET /",
1967
+ "GET /health",
1774
1968
  "POST /reset",
1775
1969
  "GET /namespaces",
1776
1970
  "GET /clock",
@@ -1820,11 +2014,11 @@ var escapeHtml = (value) => value.replace(/[&<>"']/g, (char) => {
1820
2014
  }
1821
2015
  });
1822
2016
  var ADMIN_BRANDS_URL = "https://mockingbird.chrisvouga.dev/brands.json";
1823
- var renderAdminDocument = (service) => {
2017
+ var renderAdminDocument = (service, adminPrefix = ADMIN_PREFIX) => {
1824
2018
  const name = escapeHtml(service);
1825
2019
  const script = adminClientSource({
1826
2020
  standardRoutes: STANDARD_ADMIN_ROUTES,
1827
- adminPrefix: ADMIN_PREFIX,
2021
+ adminPrefix,
1828
2022
  adminKeyHeader: ADMIN_KEY_HEADER,
1829
2023
  brandsUrl: ADMIN_BRANDS_URL,
1830
2024
  service
@@ -1841,75 +2035,99 @@ var renderAdminDocument = (service) => {
1841
2035
  ${CSS_RESET}
1842
2036
  :root {
1843
2037
  color-scheme: light;
1844
- --bg: #fafafa;
2038
+ --bg: #f7f8fb;
1845
2039
  --bg-elev: #ffffff;
1846
- --bg-muted: #f4f4f5;
1847
- --bg-inset: #f4f4f5;
1848
- --line: #dedee3;
1849
- --line-strong: #c4c4cc;
1850
- --ink: #18181b;
1851
- --muted: #62626b;
1852
- --faint: #71717a;
1853
- --accent: #27272a;
2040
+ --bg-muted: #f1f3f7;
2041
+ --bg-inset: #f8f9fc;
2042
+ --line: #e4e7ec;
2043
+ --line-strong: #cfd4dc;
2044
+ --ink: #101828;
2045
+ --muted: #475467;
2046
+ --faint: #667085;
2047
+ --accent: #6941c6;
2048
+ --accent-hover: #5933b4;
1854
2049
  --accent-ink: #ffffff;
1855
- --accent-soft: #f4f4f5;
1856
- --ok: #166534;
1857
- --ok-soft: #f0fdf4;
1858
- --warn: #a16207;
1859
- --warn-soft: #fefce8;
1860
- --err: #a82d32;
1861
- --err-soft: #fef2f2;
1862
- --shadow: none;
2050
+ --accent-soft: #f4f0ff;
2051
+ --ok: #067647;
2052
+ --ok-soft: #ecfdf3;
2053
+ --warn: #b54708;
2054
+ --warn-soft: #fffaeb;
2055
+ --err: #b42318;
2056
+ --err-soft: #fef3f2;
2057
+ --code-key: #6941c6;
2058
+ --code-string: #067647;
2059
+ --code-number: #175cd3;
2060
+ --code-boolean: #c11574;
2061
+ --code-null: #667085;
2062
+ --shadow-xs: 0 1px 2px rgb(16 24 40 / 0.05);
2063
+ --shadow-sm: 0 1px 3px rgb(16 24 40 / 0.08), 0 1px 2px rgb(16 24 40 / 0.04);
2064
+ --shadow-lg: 0 18px 48px rgb(16 24 40 / 0.16), 0 4px 12px rgb(16 24 40 / 0.08);
1863
2065
  --sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
1864
2066
  --mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;
1865
- --radius: 10px;
1866
- --header: 56px;
1867
- --nav: 200px;
2067
+ --radius: 12px;
2068
+ --header: 72px;
2069
+ --nav: 224px;
1868
2070
  }
1869
2071
  :root[data-theme="dark"] {
1870
2072
  color-scheme: dark;
1871
- --bg: #111113;
1872
- --bg-elev: #19191c;
1873
- --bg-muted: #242428;
1874
- --bg-inset: #111113;
1875
- --line: #36363c;
1876
- --line-strong: #52525b;
1877
- --ink: #f4f4f5;
1878
- --muted: #a9a9b2;
1879
- --faint: #71717a;
1880
- --accent: #e4e4e7;
1881
- --accent-ink: #18181b;
1882
- --accent-soft: #242428;
1883
- --ok: #86efac;
1884
- --ok-soft: #14241b;
1885
- --warn: #fde68a;
1886
- --warn-soft: #2a2410;
1887
- --err: #ffaaaa;
1888
- --err-soft: #2c1516;
1889
- --shadow: none;
2073
+ --bg: #0c0e14;
2074
+ --bg-elev: #151821;
2075
+ --bg-muted: #20232e;
2076
+ --bg-inset: #11141c;
2077
+ --line: #2b303d;
2078
+ --line-strong: #414858;
2079
+ --ink: #f5f7fa;
2080
+ --muted: #b6bdc9;
2081
+ --faint: #8d96a7;
2082
+ --accent: #9e77ed;
2083
+ --accent-hover: #b692f6;
2084
+ --accent-ink: #ffffff;
2085
+ --accent-soft: #2b2142;
2086
+ --ok: #75e0a7;
2087
+ --ok-soft: #102a20;
2088
+ --warn: #fec84b;
2089
+ --warn-soft: #302611;
2090
+ --err: #fda29b;
2091
+ --err-soft: #321817;
2092
+ --code-key: #c3a6ff;
2093
+ --code-string: #75e0a7;
2094
+ --code-number: #84adff;
2095
+ --code-boolean: #f9a8d4;
2096
+ --code-null: #98a2b3;
2097
+ --shadow-xs: 0 1px 2px rgb(0 0 0 / 0.24);
2098
+ --shadow-sm: 0 4px 12px rgb(0 0 0 / 0.2);
2099
+ --shadow-lg: 0 20px 56px rgb(0 0 0 / 0.45);
1890
2100
  }
1891
2101
  @media (prefers-color-scheme: dark) {
1892
2102
  :root:not([data-theme="light"]) {
1893
2103
  color-scheme: dark;
1894
- --bg: #111113;
1895
- --bg-elev: #19191c;
1896
- --bg-muted: #242428;
1897
- --bg-inset: #111113;
1898
- --line: #36363c;
1899
- --line-strong: #52525b;
1900
- --ink: #f4f4f5;
1901
- --muted: #a9a9b2;
1902
- --faint: #71717a;
1903
- --accent: #e4e4e7;
1904
- --accent-ink: #18181b;
1905
- --accent-soft: #242428;
1906
- --ok: #86efac;
1907
- --ok-soft: #14241b;
1908
- --warn: #fde68a;
1909
- --warn-soft: #2a2410;
1910
- --err: #ffaaaa;
1911
- --err-soft: #2c1516;
1912
- --shadow: none;
2104
+ --bg: #0c0e14;
2105
+ --bg-elev: #151821;
2106
+ --bg-muted: #20232e;
2107
+ --bg-inset: #11141c;
2108
+ --line: #2b303d;
2109
+ --line-strong: #414858;
2110
+ --ink: #f5f7fa;
2111
+ --muted: #b6bdc9;
2112
+ --faint: #8d96a7;
2113
+ --accent: #9e77ed;
2114
+ --accent-hover: #b692f6;
2115
+ --accent-ink: #ffffff;
2116
+ --accent-soft: #2b2142;
2117
+ --ok: #75e0a7;
2118
+ --ok-soft: #102a20;
2119
+ --warn: #fec84b;
2120
+ --warn-soft: #302611;
2121
+ --err: #fda29b;
2122
+ --err-soft: #321817;
2123
+ --code-key: #c3a6ff;
2124
+ --code-string: #75e0a7;
2125
+ --code-number: #84adff;
2126
+ --code-boolean: #f9a8d4;
2127
+ --code-null: #98a2b3;
2128
+ --shadow-xs: 0 1px 2px rgb(0 0 0 / 0.24);
2129
+ --shadow-sm: 0 4px 12px rgb(0 0 0 / 0.2);
2130
+ --shadow-lg: 0 20px 56px rgb(0 0 0 / 0.45);
1913
2131
  }
1914
2132
  }
1915
2133
  /* Fill the viewport, then grow with the content so the background covers the scroll. */
@@ -1927,13 +2145,17 @@ var renderAdminDocument = (service) => {
1927
2145
  padding-right: env(safe-area-inset-right);
1928
2146
  -webkit-text-size-adjust: 100%;
1929
2147
  }
1930
- :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
2148
+ ::selection { background: color-mix(in srgb, var(--accent) 28%, transparent); }
2149
+ :focus-visible { outline: 3px solid color-mix(in srgb, var(--accent) 45%, transparent); outline-offset: 2px; }
2150
+ button, input, select, textarea { transition: border-color 140ms ease, box-shadow 140ms ease, transform 100ms ease; }
2151
+ button:disabled, input:disabled, select:disabled, textarea:disabled { cursor: not-allowed; opacity: 0.55; }
1931
2152
  .app { flex: 1 0 auto; display: flex; flex-direction: column; min-width: 0; width: 100%; }
1932
2153
  header.top {
1933
- position: sticky; top: 0; z-index: 5;
2154
+ position: sticky; top: 0; z-index: 20;
1934
2155
  display: flex; align-items: center; gap: 12px;
1935
- min-height: var(--header); padding: 10px 16px;
1936
- background: var(--bg-elev);
2156
+ min-height: var(--header); padding: 12px 20px;
2157
+ background: color-mix(in srgb, var(--bg-elev) 92%, transparent);
2158
+ backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
1937
2159
  border-bottom: 1px solid var(--line);
1938
2160
  min-width: 0; max-width: 100%;
1939
2161
  }
@@ -1941,12 +2163,21 @@ var renderAdminDocument = (service) => {
1941
2163
  display: flex; flex-flow: row wrap; align-items: center; gap: 14px;
1942
2164
  min-width: 0; max-width: 100%; margin-right: auto;
1943
2165
  }
1944
- .brand-id { display: flex; flex-direction: column; min-width: 0; flex: none; }
1945
- .brand-id strong { font-size: 15px; letter-spacing: -0.01em; }
2166
+ .brand-lockup { display: flex; align-items: center; gap: 10px; min-width: 0; }
2167
+ .brand-mark {
2168
+ width: 38px; height: 38px; display: grid; place-items: center; flex: none;
2169
+ border-radius: 11px; color: #fff;
2170
+ background: linear-gradient(145deg, #7f56d9 0%, #53389e 100%);
2171
+ box-shadow: 0 6px 16px rgb(105 65 198 / 0.24), inset 0 1px 0 rgb(255 255 255 / 0.25);
2172
+ }
2173
+ .brand-mark svg { width: 22px; height: 22px; }
2174
+ .brand-id { display: flex; flex-direction: column; min-width: 0; flex: none; line-height: 1.25; }
2175
+ .brand-id strong { font-size: 15px; letter-spacing: -0.015em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1946
2176
  .brand-id span { color: var(--faint); font-size: 12px; }
1947
2177
  .vendor {
1948
2178
  display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
1949
2179
  min-width: 0; max-width: 100%; font-size: 12px; color: var(--muted);
2180
+ padding: 5px 8px; border: 1px solid var(--line); border-radius: 999px; background: var(--bg-inset);
1950
2181
  }
1951
2182
  .vendor[hidden] { display: none; }
1952
2183
  /* Logos are drawn for a light tile, same as the docs site, so the plate stays white. */
@@ -1958,12 +2189,17 @@ var renderAdminDocument = (service) => {
1958
2189
  .vendor-sep { color: var(--faint); }
1959
2190
  .vendor-link { color: inherit; text-decoration: none; }
1960
2191
  .vendor-link:hover { color: var(--ink); text-decoration: underline; }
1961
- .controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
1962
- label.field { display: flex; flex-direction: column; gap: 2px; font-size: 11px; color: var(--faint); }
2192
+ .controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: flex-end; }
2193
+ .sync-status { align-self: center; display: flex; align-items: center; gap: 6px; color: var(--faint); font-size: 12px; white-space: nowrap; }
2194
+ .sync-status::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--ok); box-shadow: 0 0 0 3px var(--ok-soft); }
2195
+ .sync-status.busy::before { background: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); animation: pulse 1.2s ease-in-out infinite; }
2196
+ label.field { display: flex; flex-direction: column; gap: 4px; font-size: 11px; font-weight: 600; color: var(--faint); }
1963
2197
  label.field input, label.field select, .controls input[type="password"] {
1964
- background-color: var(--bg-inset); border: 1px solid var(--line); border-radius: 8px;
1965
- padding: 7px 8px; min-height: 36px; line-height: 1.2;
2198
+ background-color: var(--bg-elev); border: 1px solid var(--line-strong); border-radius: 9px;
2199
+ padding: 8px 10px; min-height: 38px; line-height: 1.2; box-shadow: var(--shadow-xs);
1966
2200
  }
2201
+ label.field input:hover, label.field select:hover, textarea:hover { border-color: color-mix(in srgb, var(--accent) 38%, var(--line-strong)); }
2202
+ 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; }
1967
2203
  /* A native menu ignores min-height, so Preset rendered about half as tall as
1968
2204
  the text fields in the same form. Paint it like those fields. */
1969
2205
  label.field select {
@@ -1980,12 +2216,20 @@ var renderAdminDocument = (service) => {
1980
2216
  dialog .field > textarea { width: 100%; }
1981
2217
  .icon-btn, .btn, .btn-primary, .btn-quiet, .btn-danger, nav.side button { cursor: pointer; }
1982
2218
  .icon-btn, .btn, .btn-primary, .btn-quiet, .btn-danger {
1983
- border-radius: 9px; min-height: 36px; padding: 0 12px; border: 1px solid var(--line);
1984
- background: var(--bg-elev);
1985
- }
1986
- .btn-primary { background: var(--accent); color: var(--accent-ink); border-color: transparent; }
1987
- .btn-quiet { background: transparent; }
1988
- .btn-danger { color: var(--err); }
2219
+ display: inline-flex; align-items: center; justify-content: center; gap: 7px;
2220
+ border-radius: 9px; min-height: 38px; padding: 0 13px; border: 1px solid var(--line-strong);
2221
+ background: var(--bg-elev); color: var(--ink); font-size: 13px; font-weight: 600; line-height: 1;
2222
+ box-shadow: var(--shadow-xs);
2223
+ }
2224
+ .icon-btn { width: 38px; padding: 0; }
2225
+ .icon-btn svg, .btn svg, .btn-primary svg { width: 16px; height: 16px; }
2226
+ .icon-btn:hover, .btn:hover { background: var(--bg-muted); border-color: var(--line-strong); }
2227
+ .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; }
2228
+ .btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
2229
+ .btn-quiet { background: transparent; border-color: transparent; box-shadow: none; }
2230
+ .btn-quiet:hover { background: var(--bg-muted); }
2231
+ .btn-danger { color: var(--err); border-color: color-mix(in srgb, var(--err) 25%, var(--line)); }
2232
+ .btn-danger:hover, .btn-danger.confirming { background: var(--err-soft); border-color: color-mix(in srgb, var(--err) 45%, var(--line)); }
1989
2233
  /* Held after mouseup. Clearing an empty journal changes nothing on screen,
1990
2234
  so the press itself has to stay visible. */
1991
2235
  .icon-btn:active, .btn:active, .btn-quiet:active, .btn-danger:active, nav.side button:active,
@@ -2001,93 +2245,159 @@ var renderAdminDocument = (service) => {
2001
2245
  flex: 1 0 auto; min-width: 0;
2002
2246
  }
2003
2247
  nav.side {
2004
- border-right: 1px solid var(--line); padding: 12px;
2248
+ position: sticky; top: var(--header); align-self: start; height: calc(100vh - var(--header));
2249
+ border-right: 1px solid var(--line); padding: 18px 12px;
2005
2250
  display: flex; flex-direction: column; gap: 4px;
2006
- background: var(--bg-elev);
2251
+ background: var(--bg-elev); overflow-y: auto;
2007
2252
  }
2253
+ .nav-label { padding: 0 10px 8px; color: var(--faint); font-size: 10px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; }
2008
2254
  nav.side button {
2255
+ position: relative; display: flex; align-items: center; gap: 10px; width: 100%;
2009
2256
  text-align: left; border: 0; background: transparent; border-radius: 9px;
2010
- padding: 9px 10px; min-height: 40px; color: var(--muted);
2011
- }
2012
- nav.side button[aria-current="true"] { background: var(--accent-soft); color: var(--ink); font-weight: 600; }
2013
- main { padding: 20px; max-width: 1100px; width: 100%; min-width: 0; }
2014
- .view h2 { margin: 0 0 4px; font-size: 22px; letter-spacing: -0.02em; }
2015
- .lede { margin: 0 0 16px; color: var(--muted); }
2016
- .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr)); gap: 12px; }
2257
+ padding: 9px 10px; min-height: 42px; color: var(--muted); font-size: 13px; font-weight: 550;
2258
+ }
2259
+ nav.side button:hover { background: var(--bg-muted); color: var(--ink); }
2260
+ nav.side button[aria-current="true"] { background: var(--accent-soft); color: var(--accent); font-weight: 650; }
2261
+ 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); }
2262
+ .nav-icon { width: 18px; height: 18px; display: grid; place-items: center; flex: none; }
2263
+ .nav-icon svg { width: 18px; height: 18px; }
2264
+ main { padding: 32px clamp(20px, 4vw, 48px) 64px; max-width: 1320px; width: 100%; min-width: 0; }
2265
+ main[aria-busy="true"] .view { opacity: 0.62; }
2266
+ .view { animation: view-in 180ms ease-out; }
2267
+ .view[hidden] { display: none; }
2268
+ .view-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 22px; }
2269
+ .view h2 { margin: 0 0 5px; font-size: clamp(24px, 3vw, 30px); line-height: 1.2; letter-spacing: -0.035em; }
2270
+ .view h3 { margin: 24px 0 10px; font-size: 15px; letter-spacing: -0.01em; }
2271
+ .lede { margin: 0; color: var(--muted); max-width: 72ch; font-size: 14px; }
2272
+ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 175px), 1fr)); gap: 14px; }
2017
2273
  .card {
2018
2274
  background: var(--bg-elev); border: 1px solid var(--line); border-radius: var(--radius);
2019
- padding: 14px 16px; box-shadow: var(--shadow);
2275
+ padding: 18px; box-shadow: var(--shadow-sm);
2020
2276
  }
2021
- .card .k { color: var(--faint); font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; }
2022
- .card .v { display: flex; flex-direction: column; gap: 2px; font-size: 22px; margin-top: 4px; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
2277
+ .stat-card { position: relative; overflow: hidden; }
2278
+ .stat-card::after { content: ""; position: absolute; inset: 0 0 auto; height: 2px; background: linear-gradient(90deg, var(--accent), transparent 78%); opacity: 0.7; }
2279
+ .card .k { color: var(--faint); font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; }
2280
+ .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; }
2023
2281
  .card .v .clock-time { font-size: 14px; color: var(--muted); }
2024
- .split { display: grid; grid-template-columns: minmax(0, 240px) minmax(0, 1fr); gap: 16px; align-items: start; }
2282
+ .section-card { background: var(--bg-elev); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow-xs); overflow: hidden; }
2283
+ .section-title { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 16px 18px; border-bottom: 1px solid var(--line); }
2284
+ .section-title h3 { margin: 0; }
2285
+ .split { display: grid; grid-template-columns: minmax(0, 250px) minmax(0, 1fr); gap: 18px; align-items: start; }
2286
+ #collections { padding: 6px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg-elev); box-shadow: var(--shadow-xs); }
2025
2287
  .collection {
2026
2288
  width: 100%; text-align: left; border: 1px solid transparent; background: transparent;
2027
- border-radius: 10px; padding: 8px 10px; min-height: 44px;
2289
+ border-radius: 9px; padding: 10px 11px; min-height: 48px; color: var(--ink); cursor: pointer;
2028
2290
  }
2029
- .collection[aria-current="true"] { background: var(--bg-elev); border-color: var(--line); }
2291
+ .collection:hover { background: var(--bg-muted); }
2292
+ .collection[aria-current="true"] { background: var(--accent-soft); border-color: color-mix(in srgb, var(--accent) 18%, var(--line)); color: var(--accent); }
2030
2293
  .collection small { display: block; color: var(--faint); }
2031
2294
  .chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 14px; }
2032
- .chip {
2033
- font-family: var(--mono); font-size: 12px; background: var(--bg-muted);
2034
- border-radius: 999px; padding: 4px 8px;
2035
- }
2295
+ .chip { font-family: var(--mono); font-size: 11px; background: var(--bg-muted); border: 1px solid var(--line); border-radius: 999px; padding: 4px 8px; }
2036
2296
  .chip i { color: var(--faint); font-style: normal; }
2037
2297
  .sql-layout {
2038
2298
  display: grid; grid-template-columns: minmax(10rem, 16rem) minmax(0, 1fr); gap: 16px;
2039
2299
  }
2040
- .sql-tables { display: flex; flex-direction: column; gap: 4px; max-height: 28rem; overflow: auto; }
2041
- .sql-tables button { text-align: left; }
2300
+ .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); }
2301
+ .sql-tables button { text-align: left; border: 0; border-radius: 8px; padding: 9px 10px; background: transparent; color: var(--muted); cursor: pointer; }
2302
+ .sql-tables button:hover, .sql-tables button[aria-current="true"] { background: var(--accent-soft); color: var(--accent); }
2042
2303
  .sql-input { font-family: var(--mono); min-height: 7rem; width: 100%; }
2043
- table { width: 100%; border-collapse: collapse; font-size: 14px; }
2044
- th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--line); vertical-align: top; }
2045
- th { color: var(--faint); font-weight: 600; font-size: 12px; }
2304
+ .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); }
2305
+ table { width: 100%; border-collapse: collapse; font-size: 13px; }
2306
+ th, td { text-align: left; padding: 11px 13px; border-bottom: 1px solid var(--line); vertical-align: top; }
2307
+ th { color: var(--faint); background: var(--bg-inset); font-weight: 650; font-size: 11px; text-transform: uppercase; letter-spacing: 0.045em; }
2308
+ tbody tr:last-child td { border-bottom: 0; }
2046
2309
  tr.clickable { cursor: pointer; }
2047
- tr.clickable:hover { background: var(--bg-muted); }
2310
+ tr.clickable:hover { background: var(--accent-soft); }
2048
2311
  pre, code, .mono { font-family: var(--mono); overflow-wrap: anywhere; }
2049
2312
  pre {
2050
2313
  margin: 0; white-space: pre-wrap; word-break: break-word;
2051
- background: var(--bg-inset); border-radius: 10px; padding: 12px; font-size: 12px;
2052
- }
2053
- .row-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0; }
2314
+ background: var(--bg-inset); border: 1px solid var(--line); border-radius: 10px; padding: 14px; font-size: 12px; line-height: 1.65;
2315
+ }
2316
+ .json-key { color: var(--code-key); }
2317
+ .json-string { color: var(--code-string); }
2318
+ .json-number { color: var(--code-number); }
2319
+ .json-boolean { color: var(--code-boolean); }
2320
+ .json-null { color: var(--code-null); font-style: italic; }
2321
+ .json-inline { display: block; max-width: 44rem; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
2322
+ .json-view { position: relative; max-height: 26rem; overflow: auto; }
2323
+ .json-editor { position: relative; min-height: 190px; border: 1px solid var(--line-strong); border-radius: 10px; background: var(--bg-inset); overflow: hidden; }
2324
+ .json-editor:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 15%, transparent); }
2325
+ .json-editor.invalid { border-color: var(--err); }
2326
+ .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; }
2327
+ .json-editor pre { position: absolute; inset: 0; pointer-events: none; background: transparent; }
2328
+ .json-editor textarea { position: relative; z-index: 1; resize: vertical; background: transparent; color: transparent; caret-color: var(--ink); -webkit-text-fill-color: transparent; }
2329
+ .json-editor textarea::selection { background: color-mix(in srgb, var(--accent) 32%, transparent); }
2330
+ .editor-meta { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 7px; }
2331
+ .json-status { display: inline-flex; align-items: center; gap: 6px; color: var(--ok); font-size: 11px; }
2332
+ .json-status::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
2333
+ .json-status.invalid { color: var(--err); }
2334
+ .format-json { border: 0; padding: 3px 5px; background: transparent; color: var(--accent); font-size: 11px; font-weight: 650; cursor: pointer; }
2335
+ .row-actions { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 8px; margin: 14px 0; }
2054
2336
  .banner {
2055
- display: none; margin-bottom: 12px; padding: 10px 12px; border-radius: 10px;
2056
- background: var(--err-soft); color: var(--err);
2057
- }
2058
- .banner.show { display: block; }
2059
- .empty { color: var(--muted); padding: 24px 8px; }
2337
+ display: none; position: sticky; top: calc(var(--header) + 12px); z-index: 15;
2338
+ align-items: center; gap: 10px; margin: 0 0 16px; padding: 11px 12px 11px 14px;
2339
+ border: 1px solid color-mix(in srgb, var(--err) 30%, var(--line)); border-radius: 10px;
2340
+ background: var(--err-soft); color: var(--err); box-shadow: var(--shadow-sm); font-size: 13px;
2341
+ }
2342
+ .banner.show { display: flex; }
2343
+ .banner span { flex: 1; }
2344
+ .banner button { width: 28px; height: 28px; display: grid; place-items: center; border: 0; border-radius: 7px; background: transparent; color: currentColor; cursor: pointer; }
2345
+ .banner button:hover { background: color-mix(in srgb, var(--err) 10%, transparent); }
2346
+ .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); }
2060
2347
  dialog {
2061
- border: 1px solid var(--line); border-radius: var(--radius); padding: 0; background: var(--bg-elev);
2062
- color: var(--ink); width: min(640px, calc(100% - 24px)); max-width: calc(100vw - 24px); box-shadow: none;
2063
- }
2064
- dialog::backdrop { background: rgb(20 19 17 / 0.45); }
2065
- dialog form, .dialog-body { padding: 16px; display: flex; flex-direction: column; gap: 10px; }
2348
+ border: 1px solid var(--line); border-radius: 16px; padding: 0; background: var(--bg-elev);
2349
+ color: var(--ink); width: min(720px, calc(100% - 24px)); max-width: calc(100vw - 24px); box-shadow: var(--shadow-lg);
2350
+ }
2351
+ dialog[open] { animation: dialog-in 160ms ease-out; }
2352
+ dialog::backdrop { background: rgb(12 14 20 / 0.58); backdrop-filter: blur(3px); }
2353
+ dialog form, .dialog-body { padding: 22px; display: flex; flex-direction: column; gap: 14px; }
2354
+ .dialog-heading { display: flex; justify-content: space-between; gap: 16px; padding-bottom: 12px; border-bottom: 1px solid var(--line); }
2355
+ .dialog-heading strong { font-size: 18px; letter-spacing: -0.02em; }
2356
+ .dialog-heading span { color: var(--muted); font-size: 12px; }
2066
2357
  textarea {
2067
2358
  width: 100%; min-height: 180px; resize: vertical; border-radius: 10px;
2068
2359
  border: 1px solid var(--line); background: var(--bg-inset); padding: 10px;
2069
2360
  font-family: var(--mono); font-size: 12px;
2070
2361
  }
2071
- .dialog-actions { display: flex; justify-content: flex-end; gap: 8px; }
2072
- .badge { font-size: 11px; border-radius: 999px; padding: 2px 7px; background: var(--bg-muted); color: var(--muted); }
2362
+ .dialog-actions { display: flex; justify-content: flex-end; align-items: center; gap: 8px; padding-top: 4px; }
2363
+ .dialog-actions .btn-danger { margin-right: auto; }
2364
+ .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); }
2073
2365
  .badge.ok { background: var(--ok-soft); color: var(--ok); }
2074
2366
  .badge.warn { background: var(--warn-soft); color: var(--warn); }
2075
2367
  .status-2 { color: var(--ok); }
2076
2368
  .status-4, .status-5, .status-0 { color: var(--err); }
2077
2369
  .stack { display: flex; flex-direction: column; gap: 12px; }
2370
+ .form-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
2371
+ .form-grid .wide { grid-column: span 2; }
2078
2372
  .route {
2079
- display: grid; grid-template-columns: 88px 1fr auto; gap: 8px; align-items: center;
2080
- padding: 8px 0; border-bottom: 1px solid var(--line);
2081
- }
2082
- .method { font-family: var(--mono); font-size: 12px; font-weight: 700; }
2373
+ display: grid; grid-template-columns: 84px minmax(0, 1fr) auto; gap: 10px; align-items: center;
2374
+ padding: 10px 12px; border-bottom: 1px solid var(--line); background: var(--bg-elev);
2375
+ }
2376
+ .route:first-child { border-radius: var(--radius) var(--radius) 0 0; }
2377
+ .route:last-child { border-bottom: 0; border-radius: 0 0 var(--radius) var(--radius); }
2378
+ .route-tools { display: flex; align-items: flex-end; gap: 12px; margin-bottom: 12px; }
2379
+ .route-tools .field { flex: 1; }
2380
+ .route-tools input { width: 100%; }
2381
+ .route-count { padding-bottom: 10px; color: var(--faint); font-size: 12px; white-space: nowrap; }
2382
+ #route-list { max-height: 34rem; border: 1px solid var(--line); border-radius: var(--radius); overflow: auto; box-shadow: var(--shadow-xs); }
2383
+ .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; }
2384
+ @keyframes view-in { from { transform: translateY(3px); } to { transform: translateY(0); } }
2385
+ @keyframes dialog-in { from { transform: translateY(8px) scale(0.99); } to { transform: translateY(0) scale(1); } }
2386
+ @keyframes pulse { 50% { opacity: 0.45; } }
2387
+ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } }
2388
+ @media (forced-colors: active) { .json-editor pre { display: none; } .json-editor textarea { color: CanvasText; -webkit-text-fill-color: CanvasText; } }
2083
2389
  @media (max-width: 800px) {
2390
+ :root { --header: 0px; }
2084
2391
  header.top {
2085
- align-items: stretch; flex-direction: column;
2086
- padding-top: max(10px, env(safe-area-inset-top));
2392
+ position: relative; align-items: stretch; flex-direction: column;
2393
+ padding: max(12px, env(safe-area-inset-top)) 14px 12px;
2087
2394
  }
2088
2395
  input, select, textarea { font-size: 16px; }
2089
2396
  .brand, .vendor, .controls { width: 100%; }
2397
+ .brand { gap: 10px; }
2398
+ .vendor { order: 2; border-radius: 9px; }
2090
2399
  label.field, label.field select, label.field input { flex: 1; min-width: 0; }
2400
+ .sync-status { display: none; }
2091
2401
  /* The layout fills the screen. A single-column grid stretches every auto
2092
2402
  row, so the tab bar grew with the leftover height and the buttons floated
2093
2403
  in the middle of it. The bar stays one row; the page below takes the rest. */
@@ -2097,14 +2407,23 @@ var renderAdminDocument = (service) => {
2097
2407
  align-content: start;
2098
2408
  }
2099
2409
  nav.side {
2100
- align-self: start; height: auto;
2410
+ position: sticky; top: 0; z-index: 12; align-self: start; height: auto;
2101
2411
  flex-direction: row; align-items: center; overflow-x: auto; min-width: 0;
2102
2412
  border-right: 0; border-bottom: 1px solid var(--line);
2103
- padding: 8px;
2413
+ padding: 8px; box-shadow: var(--shadow-xs);
2104
2414
  }
2105
- nav.side button { white-space: nowrap; flex: none; }
2415
+ .nav-label { display: none; }
2416
+ nav.side button { width: auto; white-space: nowrap; flex: none; }
2417
+ nav.side button[aria-current="true"]::before { inset: auto 9px -8px; width: auto; height: 3px; border-radius: 3px 3px 0 0; }
2106
2418
  .split, .sql-layout { grid-template-columns: minmax(0, 1fr); }
2107
- main { padding: 14px; }
2419
+ .form-grid { grid-template-columns: minmax(0, 1fr); }
2420
+ .form-grid .wide { grid-column: auto; }
2421
+ main { padding: 22px 14px 48px; }
2422
+ .view-head { margin-bottom: 18px; }
2423
+ .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
2424
+ .card { padding: 14px; }
2425
+ .card .v { font-size: 20px; }
2426
+ .table-shell { border: 0; background: transparent; box-shadow: none; overflow: visible; }
2108
2427
  table, thead, tbody, tr, th, td { display: block; }
2109
2428
  thead { display: none; }
2110
2429
  tr {
@@ -2119,50 +2438,63 @@ var renderAdminDocument = (service) => {
2119
2438
  .route { grid-template-columns: 72px 1fr; }
2120
2439
  .route button { grid-column: 1 / -1; }
2121
2440
  }
2441
+ @media (max-width: 460px) {
2442
+ .controls label.field { min-width: calc(50% - 4px); }
2443
+ .controls .icon-btn, .controls .btn { flex: 1; }
2444
+ .grid { grid-template-columns: minmax(0, 1fr); }
2445
+ .dialog-actions { flex-wrap: wrap; }
2446
+ .dialog-actions button { flex: 1; }
2447
+ .dialog-actions .btn-danger { flex-basis: 100%; margin-right: 0; }
2448
+ }
2122
2449
  </style>
2123
2450
  </head>
2124
2451
  <body>
2125
2452
  <div class="app" data-service="${name}" data-mockingbird-admin>
2126
2453
  <header class="top">
2127
2454
  <div class="brand">
2128
- <div class="brand-id">
2129
- <strong>${name}</strong>
2130
- <span>Admin</span>
2455
+ <div class="brand-lockup">
2456
+ <div class="brand-mark" aria-hidden="true">
2457
+ <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>
2458
+ </div>
2459
+ <div class="brand-id">
2460
+ <strong>${name}</strong>
2461
+ <span>Mock admin</span>
2462
+ </div>
2131
2463
  </div>
2132
2464
  <div class="vendor" id="vendor" hidden></div>
2133
2465
  </div>
2134
2466
  <div class="controls">
2467
+ <span class="sync-status" id="sync-status" role="status" aria-live="polite">Ready</span>
2135
2468
  <label class="field">Namespace
2136
2469
  <select id="namespace" aria-label="Namespace"></select>
2137
2470
  </label>
2138
2471
  <label class="field">Admin key
2139
2472
  <input id="admin-key" type="password" autocomplete="off" spellcheck="false" aria-label="Admin key">
2140
2473
  </label>
2141
- <button class="icon-btn" id="theme" type="button" aria-label="Toggle color theme">Theme</button>
2142
- <button class="btn" id="refresh" type="button">Refresh</button>
2474
+ <button class="icon-btn" id="theme" type="button" aria-label="Use dark theme" title="Use dark theme"></button>
2475
+ <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>
2143
2476
  </div>
2144
2477
  </header>
2145
2478
  <div class="layout">
2146
2479
  <nav class="side" id="nav" aria-label="Admin sections">
2147
- <button type="button" data-view="overview" aria-current="true">Overview</button>
2148
- <button type="button" data-view="state">State</button>
2149
- <button type="button" data-view="clock">Clock</button>
2150
- <button type="button" data-view="faults">Faults</button>
2151
- <button type="button" data-view="journal">Journal</button>
2152
- <button type="button" data-view="routes">Routes</button>
2480
+ <span class="nav-label">Workspace</span>
2481
+ <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>
2482
+ <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>
2483
+ <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>
2484
+ <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>
2485
+ <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>
2486
+ <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>
2153
2487
  </nav>
2154
2488
  <main id="views">
2155
- <p class="banner" id="banner" role="alert"></p>
2489
+ <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>
2156
2490
  <section class="view" id="view-overview">
2157
- <h2>Overview</h2>
2158
- <p class="lede">Health, clock, and the collections this namespace is holding.</p>
2491
+ <div class="view-head"><div><h2>Overview</h2><p class="lede">Health, clock, and the collections this namespace is holding.</p></div></div>
2159
2492
  <div class="grid" id="overview-cards"></div>
2160
2493
  <h3>Recent requests</h3>
2161
2494
  <div id="overview-requests"></div>
2162
2495
  </section>
2163
2496
  <section class="view" id="view-state" hidden>
2164
- <h2>State</h2>
2165
- <p class="lede">Every collection in this namespace. Declared fields stay visible before any row exists; stored rows add whatever else they contain.</p>
2497
+ <div class="view-head"><div><h2>State</h2><p class="lede">Inspect and edit every collection in this namespace.</p></div></div>
2166
2498
  <div class="split">
2167
2499
  <div id="collections"></div>
2168
2500
  <div>
@@ -2175,8 +2507,7 @@ var renderAdminDocument = (service) => {
2175
2507
  </div>
2176
2508
  </section>
2177
2509
  <section class="view" id="view-clock" hidden>
2178
- <h2>Clock</h2>
2179
- <p class="lede">The clock every timestamp in this mock reads.</p>
2510
+ <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>
2180
2511
  <div class="card" id="clock-readout"></div>
2181
2512
  <div class="row-actions">
2182
2513
  <button class="btn" type="button" data-advance="60000">+1m</button>
@@ -2195,21 +2526,22 @@ var renderAdminDocument = (service) => {
2195
2526
  </form>
2196
2527
  </section>
2197
2528
  <section class="view" id="view-faults" hidden>
2198
- <h2>Faults</h2>
2199
- <p class="lede">Inject a response, a delay, or a named preset. Rules apply to this namespace.</p>
2529
+ <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>
2200
2530
  <form id="fault-form" class="card stack">
2201
- <label class="field">Preset
2202
- <select id="fault-preset"><option value="">Custom rule</option></select>
2203
- </label>
2204
- <label class="field">Status
2205
- <input id="fault-status" inputmode="numeric" placeholder="503">
2206
- </label>
2207
- <label class="field">Path prefix
2208
- <input id="fault-path" placeholder="/v1">
2209
- </label>
2210
- <label class="field">Method
2211
- <input id="fault-method" placeholder="GET">
2212
- </label>
2531
+ <div class="form-grid">
2532
+ <label class="field wide">Preset
2533
+ <select id="fault-preset"><option value="">Custom rule</option></select>
2534
+ </label>
2535
+ <label class="field">Status
2536
+ <input id="fault-status" inputmode="numeric" placeholder="503">
2537
+ </label>
2538
+ <label class="field wide">Path prefix
2539
+ <input id="fault-path" placeholder="/v1">
2540
+ </label>
2541
+ <label class="field">Method
2542
+ <input id="fault-method" placeholder="GET">
2543
+ </label>
2544
+ </div>
2213
2545
  <div class="dialog-actions">
2214
2546
  <button class="btn-primary" type="submit">Add fault</button>
2215
2547
  <button class="btn-danger" id="fault-clear" type="button">Clear all</button>
@@ -2218,26 +2550,31 @@ var renderAdminDocument = (service) => {
2218
2550
  <div id="fault-list"></div>
2219
2551
  </section>
2220
2552
  <section class="view" id="view-journal" hidden>
2221
- <h2>Journal</h2>
2222
- <p class="lede">Requests this namespace has seen. Bodies are not stored.</p>
2223
- <div class="row-actions">
2224
- <button class="btn-danger" id="journal-clear" type="button">Clear journal</button>
2225
- </div>
2553
+ <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>
2226
2554
  <div id="journal"></div>
2227
2555
  </section>
2228
2556
  <section class="view" id="view-routes" hidden>
2229
- <h2>Routes</h2>
2230
- <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>
2557
+ <div class="view-head"><div><h2>Routes</h2><p class="lede">Explore shared controls and service-specific admin endpoints.</p></div></div>
2558
+ <div class="route-tools">
2559
+ <label class="field">Filter routes
2560
+ <input id="route-search" type="search" placeholder="Method, path, or extension" autocomplete="off">
2561
+ </label>
2562
+ <span class="route-count" id="route-count"></span>
2563
+ </div>
2231
2564
  <div id="route-list"></div>
2232
- <form id="route-form" class="card stack">
2565
+ <form id="route-form" class="card stack" style="margin-top: 18px">
2233
2566
  <strong id="route-title">Select a route</strong>
2234
2567
  <label class="field">JSON body
2235
- <textarea id="route-body" placeholder="{ }"></textarea>
2568
+ <div class="json-editor" id="route-editor" hidden>
2569
+ <pre aria-hidden="true"><code id="route-highlight"></code></pre>
2570
+ <textarea id="route-body" placeholder="{ }" spellcheck="false" aria-describedby="route-json-status" disabled></textarea>
2571
+ </div>
2572
+ <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>
2236
2573
  </label>
2237
2574
  <div class="dialog-actions">
2238
- <button class="btn-primary" type="submit">Send</button>
2575
+ <button class="btn-primary" id="route-send" type="submit" disabled>Send request</button>
2239
2576
  </div>
2240
- <pre id="route-result">Response will show here.</pre>
2577
+ <pre class="json-view" id="route-result"><code>Response will show here.</code></pre>
2241
2578
  </form>
2242
2579
  </section>
2243
2580
  </main>
@@ -2245,12 +2582,16 @@ var renderAdminDocument = (service) => {
2245
2582
  </div>
2246
2583
  <dialog id="editor">
2247
2584
  <form method="dialog" id="editor-form">
2248
- <strong id="editor-title">Record</strong>
2585
+ <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>
2249
2586
  <label class="field">Id
2250
2587
  <input id="editor-id" required>
2251
2588
  </label>
2252
2589
  <label class="field">JSON value
2253
- <textarea id="editor-value"></textarea>
2590
+ <div class="json-editor" id="record-editor">
2591
+ <pre aria-hidden="true"><code id="editor-highlight"></code></pre>
2592
+ <textarea id="editor-value" spellcheck="false" aria-describedby="editor-json-status"></textarea>
2593
+ </div>
2594
+ <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>
2254
2595
  </label>
2255
2596
  <div class="dialog-actions">
2256
2597
  <button class="btn-danger" id="editor-delete" type="button" value="delete">Delete</button>
@@ -2292,8 +2633,8 @@ var describeExtension = (extension) => {
2292
2633
  ...extension.script !== void 0 ? { script: extension.script } : {}
2293
2634
  };
2294
2635
  };
2295
- var adminUiRoutes = (service, ui) => {
2296
- const shell = () => renderAdminDocument(service);
2636
+ var adminUiRoutes = (service, ui, adminPrefix = ADMIN_PREFIX) => {
2637
+ const shell = () => renderAdminDocument(service, adminPrefix);
2297
2638
  const document2 = () => ui?.render ? ui.render({ service, defaultHtml: shell }) : shell();
2298
2639
  return {
2299
2640
  "GET /ui": () => htmlResponse(document2()),
@@ -2425,7 +2766,33 @@ var faultResponse = (rule) => {
2425
2766
  };
2426
2767
  var createFaultRegistry = (rng = createRng(0), sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) => {
2427
2768
  const entries = [];
2769
+ let isolated = false;
2770
+ const scopedRngs = /* @__PURE__ */ new Map();
2771
+ const random = (namespace) => {
2772
+ if (!isolated || namespace === "default")
2773
+ return rng;
2774
+ let scoped = scopedRngs.get(namespace);
2775
+ if (!scoped) {
2776
+ scoped = createRng(rng.seed);
2777
+ scopedRngs.set(namespace, scoped);
2778
+ }
2779
+ return scoped;
2780
+ };
2428
2781
  return {
2782
+ isolateNamespaces() {
2783
+ isolated = true;
2784
+ },
2785
+ snapshot(namespace) {
2786
+ return {
2787
+ rules: structuredClone(entries.map((entry, position) => ({
2788
+ ...entry.rule,
2789
+ remaining: entry.remaining,
2790
+ hits: entry.hits,
2791
+ position
2792
+ })).filter((entry) => entry.namespace === namespace)),
2793
+ rngState: random(namespace).state()
2794
+ };
2795
+ },
2429
2796
  add(rule) {
2430
2797
  const existing = entries.findIndex((e) => e.rule.id === rule.id);
2431
2798
  const entry = { rule, remaining: rule.count ?? null, hits: 0 };
@@ -2445,6 +2812,16 @@ var createFaultRegistry = (rng = createRng(0), sleep = (ms) => new Promise((reso
2445
2812
  },
2446
2813
  clear() {
2447
2814
  entries.length = 0;
2815
+ rng.reset();
2816
+ scopedRngs.clear();
2817
+ },
2818
+ restore(namespace, state) {
2819
+ for (let i = entries.length - 1; i >= 0; i--)
2820
+ if (entries[i]?.rule.namespace === namespace)
2821
+ entries.splice(i, 1);
2822
+ for (const { remaining, hits, position, ...rule } of state.rules)
2823
+ entries.splice(Math.min(position, entries.length), 0, { rule, remaining, hits });
2824
+ random(namespace).setState(state.rngState);
2448
2825
  },
2449
2826
  async take(candidate) {
2450
2827
  const hits = [];
@@ -2454,7 +2831,7 @@ var createFaultRegistry = (rng = createRng(0), sleep = (ms) => new Promise((reso
2454
2831
  if (!matches(entry.rule, candidate))
2455
2832
  continue;
2456
2833
  const rate = entry.rule.rate ?? 1;
2457
- if (rng.next() >= rate)
2834
+ if (random(candidate.namespace).next() >= rate)
2458
2835
  continue;
2459
2836
  entry.hits++;
2460
2837
  if (entry.remaining !== null)
@@ -2521,7 +2898,8 @@ var createJournal = (size = DEFAULT_JOURNAL_SIZE) => {
2521
2898
  };
2522
2899
 
2523
2900
  // ../core/dist/metrics.js
2524
- var createMetrics = () => {
2901
+ var createMetrics = (partition = true) => {
2902
+ const namespaces = /* @__PURE__ */ new Map();
2525
2903
  let requests = 0;
2526
2904
  let faults = 0;
2527
2905
  let totalDurationMs = 0;
@@ -2529,6 +2907,14 @@ var createMetrics = () => {
2529
2907
  const unmatched = /* @__PURE__ */ new Map();
2530
2908
  return {
2531
2909
  record(entry) {
2910
+ if (partition) {
2911
+ let scoped = namespaces.get(entry.namespace);
2912
+ if (!scoped) {
2913
+ scoped = createMetrics(false);
2914
+ namespaces.set(entry.namespace, scoped);
2915
+ }
2916
+ scoped.record(entry);
2917
+ }
2532
2918
  requests++;
2533
2919
  totalDurationMs += entry.durationMs;
2534
2920
  if (entry.faultId !== void 0)
@@ -2540,7 +2926,7 @@ var createMetrics = () => {
2540
2926
  unmatched.set(route, (unmatched.get(route) ?? 0) + 1);
2541
2927
  }
2542
2928
  },
2543
- report: () => ({
2929
+ report: (namespace) => namespace !== void 0 ? (namespaces.get(namespace) ?? createMetrics(false)).report() : {
2544
2930
  requests,
2545
2931
  byOperation: Object.fromEntries([...byOperation].sort(([a], [b]) => a.localeCompare(b))),
2546
2932
  unmatched: [...unmatched].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([route, count]) => {
@@ -2549,8 +2935,13 @@ var createMetrics = () => {
2549
2935
  }),
2550
2936
  faults,
2551
2937
  totalDurationMs
2552
- }),
2553
- reset() {
2938
+ },
2939
+ reset(namespace) {
2940
+ if (namespace !== void 0) {
2941
+ namespaces.delete(namespace);
2942
+ return;
2943
+ }
2944
+ namespaces.clear();
2554
2945
  requests = 0;
2555
2946
  faults = 0;
2556
2947
  totalDurationMs = 0;
@@ -2574,7 +2965,7 @@ var PAGE_DEFAULT = 50;
2574
2965
  var PAGE_MAX = 200;
2575
2966
  var json3 = (status, body) => new Response(JSON.stringify(body), {
2576
2967
  status,
2577
- headers: { "content-type": "application/json" }
2968
+ headers: { "content-type": "application/json; charset=utf-8" }
2578
2969
  });
2579
2970
  var adminError2 = (status, message) => json3(status, { error: { type: "mockingbird_admin", message } });
2580
2971
  var isRecord3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
@@ -2827,6 +3218,7 @@ var sqlRoutes = (engine) => ({
2827
3218
  }
2828
3219
  });
2829
3220
  var createEngineAdmin = (options) => {
3221
+ const adminPrefix = resolveAdminPrefix(options.adminPrefix);
2830
3222
  const engines = /* @__PURE__ */ new Map();
2831
3223
  const engine = (namespace) => {
2832
3224
  const existing = engines.get(namespace);
@@ -2841,7 +3233,7 @@ var createEngineAdmin = (options) => {
2841
3233
  ...credentialRoutes(createCredentialRegistry()),
2842
3234
  ...stateRoutes(engine),
2843
3235
  ...sqlRoutes(engine),
2844
- ...adminUiRoutes(options.name, { extensions: [SQL_ADMIN_EXTENSION] })
3236
+ ...adminUiRoutes(options.name, { extensions: [SQL_ADMIN_EXTENSION] }, adminPrefix)
2845
3237
  };
2846
3238
  const startedAt = Date.now();
2847
3239
  const plane = createControlPlane({
@@ -2872,6 +3264,7 @@ var createEngineAdmin = (options) => {
2872
3264
  },
2873
3265
  describe: () => options.dialect === void 0 ? {} : { dialect: options.dialect },
2874
3266
  routes,
3267
+ adminPrefix,
2875
3268
  adminKey: options.adminKey
2876
3269
  });
2877
3270
  return {
@@ -26711,6 +27104,7 @@ function createAdmin(options = {}) {
26711
27104
  return createEngineAdmin({
26712
27105
  name: "postgres",
26713
27106
  dialect: "postgres",
27107
+ ...options.adminPrefix !== void 0 ? { adminPrefix: options.adminPrefix } : {},
26714
27108
  ...options.adminKey !== void 0 ? { adminKey: options.adminKey } : {},
26715
27109
  open(namespace) {
26716
27110
  if (namespace === "default" && seeded) return adapt(seeded);