@crvouga/mockingbird-service-posthog 1.7.1 → 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.
@@ -110,13 +110,31 @@ var Collection = class {
110
110
  };
111
111
 
112
112
  // ../core/dist/control.js
113
- var HEALTH_PATH = "/health";
114
113
  var ADMIN_PREFIX = "/__admin";
114
+ var resolveAdminPrefix = (prefix = ADMIN_PREFIX) => {
115
+ if (!/^\/[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_-]+)*$/.test(prefix)) {
116
+ throw new Error("adminPrefix must be an absolute non-root path of literal segments, without a trailing slash");
117
+ }
118
+ return prefix;
119
+ };
120
+ var isAdminPath = (path, prefix) => path === prefix || path.startsWith(`${prefix}/`);
121
+ var matchNamespacePath = (path, prefix) => /^\/ns\/([^/]+)(\/.*)?$/.exec(isAdminPath(path, prefix) ? path.slice(prefix.length) : "");
122
+ var assertAdminPrefixAvailable = (prefix, paths) => {
123
+ const reserved = prefix.split("/").slice(1);
124
+ for (const path of paths) {
125
+ const segments = path.split("/").slice(1);
126
+ if (segments.length < reserved.length || /^\{[^}]+\}$/.test(segments[0] ?? ""))
127
+ continue;
128
+ if (reserved.every((part, i) => segments[i] === part || /^\{[^}]+\}$/.test(segments[i] ?? ""))) {
129
+ throw new Error(`vendor route ${path} collides with adminPrefix ${prefix}; choose a different adminPrefix`);
130
+ }
131
+ }
132
+ };
115
133
  var ADMIN_KEY_HEADER = "x-mockingbird-admin-key";
116
134
  var NAMESPACE_HEADER = "x-mockingbird-namespace";
117
135
  var json = (status, body) => new Response(JSON.stringify(body), {
118
136
  status,
119
- headers: { "content-type": "application/json" }
137
+ headers: { "content-type": "application/json; charset=utf-8" }
120
138
  });
121
139
  var adminError = (status, message) => json(status, { error: { type: "mockingbird_admin", message } });
122
140
  var UNITS = {
@@ -168,11 +186,25 @@ var readJson = async (request) => {
168
186
  };
169
187
  var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
170
188
  var createControlPlane = (context) => {
189
+ const adminPrefix = resolveAdminPrefix(context.adminPrefix);
171
190
  const snapshots = /* @__PURE__ */ new Map();
172
191
  let snapshotCounter = 0;
173
192
  const headerNamespace = (request) => request.headers.get(NAMESPACE_HEADER) ?? context.defaultNamespace;
174
193
  const adminNamespace = (request, url) => url.searchParams.get("namespace") ?? headerNamespace(request);
194
+ const health = () => {
195
+ return json(200, {
196
+ ...context.describe(),
197
+ status: "ok",
198
+ service: context.name,
199
+ uptimeMs: context.wallNow() - context.startedAt,
200
+ clock: context.clock.state(),
201
+ namespaces: context.namespaces().length,
202
+ adminPrefix,
203
+ adminUi: `${adminPrefix}/ui`
204
+ });
205
+ };
175
206
  const builtin = {
207
+ "GET /health": health,
176
208
  "GET /": () => json(200, {
177
209
  service: context.name,
178
210
  routes: [...Object.keys(builtin), ...Object.keys(context.routes)].sort()
@@ -183,29 +215,30 @@ var createControlPlane = (context) => {
183
215
  return json(200, { status: "ok", reset: target === "*" ? context.namespaces() : [target] });
184
216
  },
185
217
  "GET /namespaces": () => json(200, { default: context.defaultNamespace, namespaces: context.namespaces() }),
186
- "GET /clock": () => json(200, context.clock.state()),
187
- "POST /clock": ({ body }) => {
218
+ "GET /clock": ({ namespace }) => json(200, (context.namespaceClock?.(namespace) ?? context.clock).state()),
219
+ "POST /clock": ({ body, namespace }) => {
220
+ const clock = context.namespaceClock?.(namespace) ?? context.clock;
188
221
  if (!isRecord(body))
189
222
  return adminError(400, "expected a JSON object");
190
223
  if (body.reset === true)
191
- context.clock.reset();
224
+ clock.reset();
192
225
  if (body.freeze === true)
193
- context.clock.freeze();
226
+ clock.freeze();
194
227
  if (body.set !== void 0) {
195
228
  const instant = parseInstant(body.set);
196
229
  if (instant === void 0)
197
230
  return adminError(400, "set: expected epoch ms or ISO-8601");
198
- context.clock.set(instant);
231
+ clock.set(instant);
199
232
  }
200
233
  if (body.advance !== void 0) {
201
234
  const delta = parseDuration(body.advance);
202
235
  if (delta === void 0)
203
236
  return adminError(400, 'advance: expected ms or "15m"-style');
204
- context.clock.advance(delta);
237
+ clock.advance(delta);
205
238
  }
206
239
  if (body.freeze === false)
207
- context.clock.unfreeze();
208
- return json(200, context.clock.state());
240
+ clock.unfreeze();
241
+ return json(200, clock.state());
209
242
  },
210
243
  "GET /faults": () => json(200, { faults: context.faults.list() }),
211
244
  "POST /faults": ({ body, namespace }) => {
@@ -338,7 +371,14 @@ var createControlPlane = (context) => {
338
371
  return json(200, { status: "ok" });
339
372
  }
340
373
  };
341
- const routes = [...Object.entries(context.routes), ...Object.entries(builtin)].map(([key, handler]) => {
374
+ for (const key of Object.keys(context.routes)) {
375
+ if (Object.hasOwn(builtin, key))
376
+ throw new Error(`admin route ${key} overrides a shared route`);
377
+ const path = key.slice(key.indexOf(" ") + 1);
378
+ if (isAdminPath(path, "/ns") || isAdminPath(path, "/blobs"))
379
+ throw new Error(`admin route ${key} overlaps an internal transport`);
380
+ }
381
+ const routes = [...Object.entries(builtin), ...Object.entries(context.routes)].map(([key, handler]) => {
342
382
  const space = key.indexOf(" ");
343
383
  return { method: key.slice(0, space), pattern: key.slice(space + 1), handler };
344
384
  });
@@ -346,21 +386,13 @@ var createControlPlane = (context) => {
346
386
  namespaceOf: headerNamespace,
347
387
  async handle(request) {
348
388
  const url = new URL(request.url);
349
- if (url.pathname === HEALTH_PATH && request.method === "GET") {
350
- return json(200, {
351
- status: "ok",
352
- service: context.name,
353
- uptimeMs: context.wallNow() - context.startedAt,
354
- clock: context.clock.state(),
355
- namespaces: context.namespaces().length,
356
- adminUi: `${ADMIN_PREFIX}/ui`,
357
- ...context.describe()
358
- });
389
+ if (url.pathname === `${adminPrefix}/health` && request.method === "GET") {
390
+ return health();
359
391
  }
360
- if (url.pathname !== ADMIN_PREFIX && !url.pathname.startsWith(`${ADMIN_PREFIX}/`)) {
392
+ if (!isAdminPath(url.pathname, adminPrefix)) {
361
393
  return void 0;
362
394
  }
363
- const path = url.pathname.slice(ADMIN_PREFIX.length) || "/";
395
+ const path = url.pathname.slice(adminPrefix.length) || "/";
364
396
  const shell = request.method === "GET" && (path === "/ui" || path === "/ui/");
365
397
  if (!shell && context.adminKey !== void 0 && request.headers.get(ADMIN_KEY_HEADER) !== context.adminKey) {
366
398
  return adminError(401, `missing or wrong ${ADMIN_KEY_HEADER}`);
@@ -368,7 +400,12 @@ var createControlPlane = (context) => {
368
400
  for (const route of routes) {
369
401
  if (route.method !== request.method)
370
402
  continue;
371
- const params = matchRoute(route.pattern, path);
403
+ let params;
404
+ try {
405
+ params = matchRoute(route.pattern, path);
406
+ } catch {
407
+ return adminError(400, "invalid path encoding");
408
+ }
372
409
  if (!params)
373
410
  continue;
374
411
  let body;
@@ -377,15 +414,18 @@ var createControlPlane = (context) => {
377
414
  } catch {
378
415
  return adminError(400, "request body is not valid JSON");
379
416
  }
417
+ const namespace = adminNamespace(request, url);
418
+ if (!/^[A-Za-z0-9_.-]{1,64}$/.test(namespace))
419
+ return adminError(400, "invalid namespace");
380
420
  return route.handler({
381
421
  request,
382
422
  url,
383
423
  params,
384
- namespace: adminNamespace(request, url),
424
+ namespace,
385
425
  body
386
426
  });
387
427
  }
388
- return adminError(404, `no admin route ${request.method} ${path}; GET ${ADMIN_PREFIX} lists them`);
428
+ return adminError(404, `no admin route ${request.method} ${path}; GET ${adminPrefix} lists them`);
389
429
  }
390
430
  };
391
431
  };
@@ -474,7 +514,33 @@ var faultResponse = (rule) => {
474
514
  };
475
515
  var createFaultRegistry = (rng = createRng(0), sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) => {
476
516
  const entries = [];
517
+ let isolated = false;
518
+ const scopedRngs = /* @__PURE__ */ new Map();
519
+ const random = (namespace) => {
520
+ if (!isolated || namespace === "default")
521
+ return rng;
522
+ let scoped = scopedRngs.get(namespace);
523
+ if (!scoped) {
524
+ scoped = createRng(rng.seed);
525
+ scopedRngs.set(namespace, scoped);
526
+ }
527
+ return scoped;
528
+ };
477
529
  return {
530
+ isolateNamespaces() {
531
+ isolated = true;
532
+ },
533
+ snapshot(namespace) {
534
+ return {
535
+ rules: structuredClone(entries.map((entry, position) => ({
536
+ ...entry.rule,
537
+ remaining: entry.remaining,
538
+ hits: entry.hits,
539
+ position
540
+ })).filter((entry) => entry.namespace === namespace)),
541
+ rngState: random(namespace).state()
542
+ };
543
+ },
478
544
  add(rule) {
479
545
  const existing = entries.findIndex((e) => e.rule.id === rule.id);
480
546
  const entry = { rule, remaining: rule.count ?? null, hits: 0 };
@@ -494,6 +560,16 @@ var createFaultRegistry = (rng = createRng(0), sleep = (ms) => new Promise((reso
494
560
  },
495
561
  clear() {
496
562
  entries.length = 0;
563
+ rng.reset();
564
+ scopedRngs.clear();
565
+ },
566
+ restore(namespace, state) {
567
+ for (let i = entries.length - 1; i >= 0; i--)
568
+ if (entries[i]?.rule.namespace === namespace)
569
+ entries.splice(i, 1);
570
+ for (const { remaining, hits, position, ...rule } of state.rules)
571
+ entries.splice(Math.min(position, entries.length), 0, { rule, remaining, hits });
572
+ random(namespace).setState(state.rngState);
497
573
  },
498
574
  async take(candidate) {
499
575
  const hits = [];
@@ -503,7 +579,7 @@ var createFaultRegistry = (rng = createRng(0), sleep = (ms) => new Promise((reso
503
579
  if (!matches(entry.rule, candidate))
504
580
  continue;
505
581
  const rate = entry.rule.rate ?? 1;
506
- if (rng.next() >= rate)
582
+ if (random(candidate.namespace).next() >= rate)
507
583
  continue;
508
584
  entry.hits++;
509
585
  if (entry.remaining !== null)
@@ -880,7 +956,8 @@ var annotateResponse = (response, extra) => {
880
956
  var responseNotes = (response) => notes.get(response);
881
957
 
882
958
  // ../core/dist/metrics.js
883
- var createMetrics = () => {
959
+ var createMetrics = (partition = true) => {
960
+ const namespaces = /* @__PURE__ */ new Map();
884
961
  let requests = 0;
885
962
  let faults = 0;
886
963
  let totalDurationMs = 0;
@@ -888,6 +965,14 @@ var createMetrics = () => {
888
965
  const unmatched = /* @__PURE__ */ new Map();
889
966
  return {
890
967
  record(entry) {
968
+ if (partition) {
969
+ let scoped = namespaces.get(entry.namespace);
970
+ if (!scoped) {
971
+ scoped = createMetrics(false);
972
+ namespaces.set(entry.namespace, scoped);
973
+ }
974
+ scoped.record(entry);
975
+ }
891
976
  requests++;
892
977
  totalDurationMs += entry.durationMs;
893
978
  if (entry.faultId !== void 0)
@@ -899,7 +984,7 @@ var createMetrics = () => {
899
984
  unmatched.set(route, (unmatched.get(route) ?? 0) + 1);
900
985
  }
901
986
  },
902
- report: () => ({
987
+ report: (namespace) => namespace !== void 0 ? (namespaces.get(namespace) ?? createMetrics(false)).report() : {
903
988
  requests,
904
989
  byOperation: Object.fromEntries([...byOperation].sort(([a], [b]) => a.localeCompare(b))),
905
990
  unmatched: [...unmatched].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([route, count]) => {
@@ -908,8 +993,13 @@ var createMetrics = () => {
908
993
  }),
909
994
  faults,
910
995
  totalDurationMs
911
- }),
912
- reset() {
996
+ },
997
+ reset(namespace) {
998
+ if (namespace !== void 0) {
999
+ namespaces.delete(namespace);
1000
+ return;
1001
+ }
1002
+ namespaces.clear();
913
1003
  requests = 0;
914
1004
  faults = 0;
915
1005
  totalDurationMs = 0;
@@ -1530,6 +1620,34 @@ dialog:not([open]) {
1530
1620
  function isRecord2(value) {
1531
1621
  return typeof value === "object" && value !== null && !Array.isArray(value);
1532
1622
  }
1623
+ function highlightJsonText(text) {
1624
+ const escapeToken = (value) => value.replace(/[&<>"']/g, (char) => {
1625
+ switch (char) {
1626
+ case "&":
1627
+ return "&amp;";
1628
+ case "<":
1629
+ return "&lt;";
1630
+ case ">":
1631
+ return "&gt;";
1632
+ case '"':
1633
+ return "&quot;";
1634
+ default:
1635
+ return "&#39;";
1636
+ }
1637
+ });
1638
+ 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;
1639
+ let cursor = 0;
1640
+ let html = "";
1641
+ for (const match of text.matchAll(token)) {
1642
+ const index = match.index ?? 0;
1643
+ const value = match[0];
1644
+ html += escapeToken(text.slice(cursor, index));
1645
+ const kind = value.startsWith('"') ? value.trimEnd().endsWith(":") ? "key" : "string" : value === "true" || value === "false" ? "boolean" : value === "null" ? "null" : "number";
1646
+ html += `<span class="json-${kind}">${escapeToken(value)}</span>`;
1647
+ cursor = index + value.length;
1648
+ }
1649
+ return html + escapeToken(text.slice(cursor));
1650
+ }
1533
1651
  function mountAdminBrand(doc, host, service, catalog) {
1534
1652
  const clip = (value, max) => {
1535
1653
  if (typeof value !== "string")
@@ -1891,14 +2009,16 @@ function bootAdmin(config) {
1891
2009
  const state = {
1892
2010
  namespace: "default",
1893
2011
  key: sessionStorage.getItem("mockingbird-admin-key") || "",
1894
- view: "overview",
2012
+ view: ["overview", "state", "clock", "faults", "journal", "routes"].includes(location.hash.slice(1)) ? location.hash.slice(1) : "overview",
1895
2013
  collection: "",
1896
2014
  shape: null,
1897
2015
  route: null,
2016
+ routes: [],
1898
2017
  editing: null
1899
2018
  };
1900
2019
  const standard = new Set(config.standardRoutes);
1901
2020
  const banner = el("banner", HTMLElement);
2021
+ const bannerMessage = el("banner-message", HTMLElement);
1902
2022
  const keyInput = el("admin-key", HTMLInputElement);
1903
2023
  keyInput.value = state.key;
1904
2024
  const params = new URLSearchParams(location.search);
@@ -1909,18 +2029,21 @@ function bootAdmin(config) {
1909
2029
  sessionStorage.setItem("mockingbird-admin-key", state.key);
1910
2030
  params.delete("key");
1911
2031
  const next = params.toString();
1912
- history.replaceState(null, "", location.pathname + (next ? `?${next}` : ""));
2032
+ history.replaceState(null, "", location.pathname + (next ? `?${next}` : "") + location.hash);
1913
2033
  }
1914
2034
  const queryNamespace = params.get("namespace");
1915
2035
  if (queryNamespace)
1916
2036
  state.namespace = queryNamespace;
1917
2037
  const showError = (error2) => {
1918
- banner.textContent = error2 instanceof Error ? error2.message : String(error2);
2038
+ bannerMessage.textContent = error2 instanceof Error ? error2.message : String(error2);
1919
2039
  banner.classList.add("show");
2040
+ banner.scrollIntoView({ behavior: "smooth", block: "nearest" });
1920
2041
  };
1921
2042
  const clearError = () => {
1922
2043
  banner.classList.remove("show");
2044
+ bannerMessage.textContent = "";
1923
2045
  };
2046
+ el("banner-dismiss", HTMLButtonElement).addEventListener("click", clearError);
1924
2047
  const api = async (method, path, body) => {
1925
2048
  const headers = { accept: "application/json" };
1926
2049
  if (state.key !== "")
@@ -1964,16 +2087,21 @@ function bootAdmin(config) {
1964
2087
  namespace: () => state.namespace
1965
2088
  };
1966
2089
  const isPanelScript = (value) => typeof value === "function";
2090
+ const themeButton = el("theme", HTMLButtonElement);
1967
2091
  const setTheme = (theme) => {
1968
2092
  if (theme)
1969
2093
  document.documentElement.dataset.theme = theme;
1970
2094
  else
1971
2095
  delete document.documentElement.dataset.theme;
2096
+ const dark = theme ? theme === "dark" : matchMedia("(prefers-color-scheme: dark)").matches;
2097
+ 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>';
2098
+ const label = dark ? "Use light theme" : "Use dark theme";
2099
+ themeButton.setAttribute("aria-label", label);
2100
+ themeButton.title = label;
1972
2101
  };
1973
2102
  const storedTheme = localStorage.getItem("mockingbird-admin-theme");
1974
- if (storedTheme)
1975
- setTheme(storedTheme);
1976
- el("theme", HTMLButtonElement).addEventListener("click", () => {
2103
+ setTheme(storedTheme === "dark" || storedTheme === "light" ? storedTheme : null);
2104
+ themeButton.addEventListener("click", () => {
1977
2105
  const current = document.documentElement.dataset.theme;
1978
2106
  const dark = current ? current === "dark" : matchMedia("(prefers-color-scheme: dark)").matches;
1979
2107
  const next = dark ? "light" : "dark";
@@ -1992,6 +2120,7 @@ function bootAdmin(config) {
1992
2120
  continue;
1993
2121
  section.hidden = section.id !== `view-${view}`;
1994
2122
  }
2123
+ history.replaceState(null, "", `${location.pathname}${location.search}#${encodeURIComponent(view)}`);
1995
2124
  };
1996
2125
  el("nav", HTMLElement).addEventListener("click", (event) => {
1997
2126
  const target = event.target;
@@ -2006,7 +2135,7 @@ function bootAdmin(config) {
2006
2135
  show(view);
2007
2136
  refresh().catch(showError);
2008
2137
  });
2009
- 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>`;
2138
+ 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>`;
2010
2139
  const table = (columns, rows) => {
2011
2140
  if (rows.length === 0)
2012
2141
  return '<p class="empty">Nothing here yet.</p>';
@@ -2016,16 +2145,22 @@ function bootAdmin(config) {
2016
2145
  const attrs = "attrs" in row && typeof row.attrs === "string" ? row.attrs : "";
2017
2146
  return `<tr${attrs}>${cells}</tr>`;
2018
2147
  }).join("");
2019
- return `<table><thead>${head}</thead><tbody>${body}</tbody></table>`;
2148
+ return `<div class="table-shell"><table><thead>${head}</thead><tbody>${body}</tbody></table></div>`;
2020
2149
  };
2021
2150
  const preview = (value) => {
2022
2151
  const text = JSON.stringify(value) ?? "null";
2023
- return esc(text.length > 180 ? `${text.slice(0, 180)}\u2026` : text);
2152
+ const clipped = text.length > 180 ? `${text.slice(0, 180)}\u2026` : text;
2153
+ return `<code class="json-inline">${highlightJsonText(clipped)}</code>`;
2024
2154
  };
2025
2155
  const when = (epoch) => {
2026
2156
  const date = new Date(epoch);
2027
2157
  return Number.isNaN(date.getTime()) ? String(epoch) : date.toISOString();
2028
2158
  };
2159
+ const journalWhen = (value) => {
2160
+ const numeric = value.trim() === "" ? Number.NaN : Number(value);
2161
+ const epoch = Number.isFinite(numeric) ? numeric : Date.parse(value);
2162
+ return Number.isFinite(epoch) ? when(epoch) : value;
2163
+ };
2029
2164
  const clockCard = (epoch) => {
2030
2165
  const date = new Date(epoch);
2031
2166
  if (Number.isNaN(date.getTime()))
@@ -2145,7 +2280,7 @@ function bootAdmin(config) {
2145
2280
  };
2146
2281
  const renderOverview = async () => {
2147
2282
  const [health, shape, clock, journal] = await Promise.all([
2148
- originGet("/health"),
2283
+ originGet(`${config.adminPrefix}/health`),
2149
2284
  api("GET", "/state"),
2150
2285
  api("GET", "/clock"),
2151
2286
  api("GET", "/requests?limit=8")
@@ -2162,7 +2297,7 @@ function bootAdmin(config) {
2162
2297
  },
2163
2298
  {
2164
2299
  k: "Status",
2165
- v: esc(typeof health.status === "string" && health.status !== "" ? health.status : "ok")
2300
+ v: `<span class="badge ok">${esc(typeof health.status === "string" && health.status !== "" ? health.status : "ok")}</span>`
2166
2301
  },
2167
2302
  { k: "Namespace", v: esc(state.namespace) },
2168
2303
  { k: "Collections", v: String(decoded2.collections.length) },
@@ -2249,26 +2384,46 @@ function bootAdmin(config) {
2249
2384
  const renderJournal = async () => {
2250
2385
  const journal = await api("GET", "/requests?limit=100");
2251
2386
  el("journal", HTMLElement).innerHTML = table([
2252
- { label: "When", cell: (row) => esc(row.at) },
2253
- { label: "Method", cell: (row) => esc(row.method) },
2387
+ { label: "When", cell: (row) => esc(journalWhen(row.at)) },
2388
+ {
2389
+ label: "Method",
2390
+ cell: (row) => `<span class="method">${esc(row.method)}</span>`
2391
+ },
2254
2392
  { label: "Path", cell: (row) => esc(row.path) },
2255
- { label: "Status", cell: (row) => esc(row.status) },
2393
+ {
2394
+ label: "Status",
2395
+ cell: (row) => `<span class="badge ${row.status.startsWith("2") ? "ok" : row.status.startsWith("4") || row.status.startsWith("5") ? "warn" : ""}">${esc(row.status)}</span>`
2396
+ },
2256
2397
  { label: "ms", cell: (row) => esc(row.durationMs) }
2257
2398
  ], decodeJournal(journal));
2258
2399
  };
2259
- const renderRoutes = async () => {
2260
- const data = await api("GET", "/");
2261
- if (!isRecord2(data))
2262
- throw new Error("Route list was not an object");
2263
- const routes = requireList(data.routes, "Routes").map((route) => String(route));
2400
+ const paintRoutes = () => {
2401
+ const query = el("route-search", HTMLInputElement).value.trim().toLocaleLowerCase();
2402
+ const routes = state.routes.filter((route) => route.toLocaleLowerCase().includes(query));
2403
+ el("route-count", HTMLElement).textContent = `${routes.length} of ${state.routes.length}`;
2404
+ if (state.routes.length === 0) {
2405
+ el("route-list", HTMLElement).innerHTML = '<p class="empty">No admin routes are available.</p>';
2406
+ return;
2407
+ }
2408
+ if (routes.length === 0) {
2409
+ el("route-list", HTMLElement).innerHTML = '<p class="empty">No routes match this filter.</p>';
2410
+ return;
2411
+ }
2264
2412
  el("route-list", HTMLElement).innerHTML = routes.map((route) => {
2265
2413
  const extra = standard.has(route) ? "" : " <span class='badge'>extension</span>";
2266
2414
  const parts = route.split(" ");
2267
2415
  const method = parts[0] ?? "";
2268
2416
  const path = parts.slice(1).join(" ");
2269
- 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>`;
2417
+ 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>`;
2270
2418
  }).join("");
2271
2419
  };
2420
+ const renderRoutes = async () => {
2421
+ const data = await api("GET", "/");
2422
+ if (!isRecord2(data))
2423
+ throw new Error("Route list was not an object");
2424
+ state.routes = requireList(data.routes, "Routes").map((route) => String(route));
2425
+ paintRoutes();
2426
+ };
2272
2427
  const renderers = {
2273
2428
  overview: renderOverview,
2274
2429
  state: renderState,
@@ -2277,19 +2432,50 @@ function bootAdmin(config) {
2277
2432
  journal: renderJournal,
2278
2433
  routes: renderRoutes
2279
2434
  };
2435
+ let refreshToken = 0;
2280
2436
  async function refresh() {
2437
+ const token = ++refreshToken;
2281
2438
  clearError();
2439
+ const main = el("views", HTMLElement);
2440
+ const sync = el("sync-status", HTMLElement);
2441
+ main.setAttribute("aria-busy", "true");
2442
+ sync.classList.add("busy");
2443
+ sync.textContent = "Updating";
2282
2444
  const render = renderers[state.view];
2283
- if (render)
2284
- await render();
2445
+ try {
2446
+ if (render)
2447
+ await render();
2448
+ if (token === refreshToken)
2449
+ sync.textContent = "Up to date";
2450
+ } finally {
2451
+ if (token === refreshToken) {
2452
+ main.removeAttribute("aria-busy");
2453
+ sync.classList.remove("busy");
2454
+ }
2455
+ }
2285
2456
  }
2286
2457
  const delay = (ms) => new Promise((resolve) => {
2287
2458
  window.setTimeout(resolve, ms);
2288
2459
  });
2289
- const confirmAction = async (button, pending, done, work) => {
2460
+ const confirmAction = async (button, pending, done, work, confirmLabel) => {
2290
2461
  if (button.dataset.pending === "1")
2291
2462
  return;
2292
2463
  const label = button.textContent ?? "";
2464
+ if (confirmLabel !== void 0 && button.dataset.confirming !== "1") {
2465
+ button.dataset.confirming = "1";
2466
+ button.classList.add("confirming");
2467
+ button.textContent = confirmLabel;
2468
+ window.setTimeout(() => {
2469
+ if (button.dataset.confirming !== "1")
2470
+ return;
2471
+ button.textContent = label;
2472
+ button.classList.remove("confirming");
2473
+ delete button.dataset.confirming;
2474
+ }, 3e3);
2475
+ return;
2476
+ }
2477
+ button.classList.remove("confirming");
2478
+ delete button.dataset.confirming;
2293
2479
  const width = button.offsetWidth;
2294
2480
  button.dataset.pending = "1";
2295
2481
  button.classList.add("pressed");
@@ -2310,6 +2496,65 @@ function bootAdmin(config) {
2310
2496
  delete button.dataset.pending;
2311
2497
  }
2312
2498
  };
2499
+ const wireJsonEditor = (textareaId, highlightId, statusId, shellId, allowEmpty) => {
2500
+ const textarea = el(textareaId, HTMLTextAreaElement);
2501
+ const highlight = el(highlightId, HTMLElement);
2502
+ const status = el(statusId, HTMLElement);
2503
+ const shell = el(shellId, HTMLElement);
2504
+ const sync = () => {
2505
+ const text = textarea.value;
2506
+ highlight.innerHTML = `${highlightJsonText(text)}
2507
+ `;
2508
+ let valid = true;
2509
+ let message = "Valid JSON";
2510
+ if (allowEmpty && text.trim() === "")
2511
+ message = "No body";
2512
+ else {
2513
+ try {
2514
+ readJson2(text);
2515
+ } catch {
2516
+ valid = false;
2517
+ message = "Invalid JSON";
2518
+ }
2519
+ }
2520
+ shell.classList.toggle("invalid", !valid);
2521
+ status.classList.toggle("invalid", !valid);
2522
+ status.textContent = message;
2523
+ textarea.setAttribute("aria-invalid", valid ? "false" : "true");
2524
+ };
2525
+ const syncScroll = () => {
2526
+ const preview2 = highlight.parentElement;
2527
+ if (!(preview2 instanceof HTMLElement))
2528
+ return;
2529
+ preview2.scrollTop = textarea.scrollTop;
2530
+ preview2.scrollLeft = textarea.scrollLeft;
2531
+ };
2532
+ textarea.addEventListener("input", sync);
2533
+ textarea.addEventListener("scroll", syncScroll);
2534
+ sync();
2535
+ return sync;
2536
+ };
2537
+ const syncRouteEditor = wireJsonEditor("route-body", "route-highlight", "route-json-status", "route-editor", true);
2538
+ const syncRecordEditor = wireJsonEditor("editor-value", "editor-highlight", "editor-json-status", "record-editor", false);
2539
+ for (const button of document.querySelectorAll("[data-format]")) {
2540
+ if (!(button instanceof HTMLButtonElement))
2541
+ continue;
2542
+ button.addEventListener("click", () => {
2543
+ const target = button.dataset.format;
2544
+ if (target === void 0)
2545
+ return;
2546
+ const textarea = document.getElementById(target);
2547
+ if (!(textarea instanceof HTMLTextAreaElement) || textarea.value.trim() === "")
2548
+ return;
2549
+ try {
2550
+ textarea.value = JSON.stringify(readJson2(textarea.value), null, 2) ?? "";
2551
+ textarea.dispatchEvent(new Event("input"));
2552
+ textarea.focus();
2553
+ } catch {
2554
+ showError(new Error("Fix the JSON before formatting it"));
2555
+ }
2556
+ });
2557
+ }
2313
2558
  document.addEventListener("click", (event) => {
2314
2559
  const target = event.target;
2315
2560
  if (!(target instanceof Element))
@@ -2382,6 +2627,7 @@ function bootAdmin(config) {
2382
2627
  } else {
2383
2628
  editorValue.value = "{\n \n}";
2384
2629
  }
2630
+ syncRecordEditor();
2385
2631
  editor.showModal();
2386
2632
  }
2387
2633
  el("new-record", HTMLButtonElement).addEventListener("click", () => {
@@ -2411,10 +2657,10 @@ function bootAdmin(config) {
2411
2657
  const id = state.editing;
2412
2658
  if (!id)
2413
2659
  return;
2414
- api("DELETE", `/state/${encodeURIComponent(state.collection)}/${encodeURIComponent(id)}`).then(() => {
2660
+ confirmAction(editorDelete, "Deleting...", "Deleted", () => api("DELETE", `/state/${encodeURIComponent(state.collection)}/${encodeURIComponent(id)}`).then(() => {
2415
2661
  editor.close();
2416
2662
  return renderState();
2417
- }).catch(showError);
2663
+ }), "Confirm delete").catch(showError);
2418
2664
  });
2419
2665
  for (const button of document.querySelectorAll("[data-advance]")) {
2420
2666
  if (!(button instanceof HTMLButtonElement))
@@ -2460,10 +2706,10 @@ function bootAdmin(config) {
2460
2706
  }).then(() => renderFaults()).catch(showError);
2461
2707
  });
2462
2708
  el("fault-clear", HTMLButtonElement).addEventListener("click", () => {
2463
- confirmAction(el("fault-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/faults").then(() => renderFaults())).catch(showError);
2709
+ confirmAction(el("fault-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/faults").then(() => renderFaults()), "Confirm clear").catch(showError);
2464
2710
  });
2465
2711
  el("journal-clear", HTMLButtonElement).addEventListener("click", () => {
2466
- confirmAction(el("journal-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/requests").then(() => renderJournal())).catch(showError);
2712
+ confirmAction(el("journal-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/requests").then(() => renderJournal()), "Confirm clear").catch(showError);
2467
2713
  });
2468
2714
  el("route-list", HTMLElement).addEventListener("click", (event) => {
2469
2715
  const target = event.target;
@@ -2477,9 +2723,15 @@ function bootAdmin(config) {
2477
2723
  return;
2478
2724
  state.route = route;
2479
2725
  el("route-title", HTMLElement).textContent = route;
2726
+ el("route-send", HTMLButtonElement).disabled = false;
2480
2727
  const method = route.split(" ")[0] ?? "";
2481
- el("route-body", HTMLTextAreaElement).disabled = method === "GET" || method === "DELETE";
2728
+ const routeBody = el("route-body", HTMLTextAreaElement);
2729
+ routeBody.disabled = method === "GET" || method === "DELETE";
2730
+ el("route-editor", HTMLElement).hidden = routeBody.disabled;
2731
+ el("route-json-status", HTMLElement).parentElement?.toggleAttribute("hidden", routeBody.disabled);
2732
+ syncRouteEditor();
2482
2733
  });
2734
+ el("route-search", HTMLInputElement).addEventListener("input", paintRoutes);
2483
2735
  el("route-form", HTMLFormElement).addEventListener("submit", (event) => {
2484
2736
  event.preventDefault();
2485
2737
  const send = async () => {
@@ -2501,7 +2753,8 @@ function bootAdmin(config) {
2501
2753
  }
2502
2754
  }
2503
2755
  const data = await api(method, concrete, body);
2504
- el("route-result", HTMLElement).textContent = JSON.stringify(data, null, 2);
2756
+ const text = JSON.stringify(data, null, 2) ?? "null";
2757
+ el("route-result", HTMLElement).innerHTML = `<code>${highlightJsonText(text)}</code>`;
2505
2758
  };
2506
2759
  send().catch(showError);
2507
2760
  });
@@ -2619,11 +2872,13 @@ function bootAdmin(config) {
2619
2872
  views.append(section);
2620
2873
  mountSqlExplorer(body, panelApi);
2621
2874
  }
2875
+ show(document.getElementById(`view-${state.view}`) === null ? "overview" : state.view);
2622
2876
  return refresh();
2623
2877
  }).catch(showError);
2624
2878
  }
2625
2879
  var clientPrelude = [
2626
2880
  isRecord2,
2881
+ highlightJsonText,
2627
2882
  mountAdminBrand,
2628
2883
  startAdminBrand,
2629
2884
  faultPresetList,
@@ -2646,6 +2901,7 @@ ${entry}(${payload});
2646
2901
  var defineMock = (runtime) => runtime;
2647
2902
  var STANDARD_ADMIN_ROUTES = [
2648
2903
  "GET /",
2904
+ "GET /health",
2649
2905
  "POST /reset",
2650
2906
  "GET /namespaces",
2651
2907
  "GET /clock",
@@ -2725,11 +2981,11 @@ var escapeHtml = (value) => value.replace(/[&<>"']/g, (char) => {
2725
2981
  }
2726
2982
  });
2727
2983
  var ADMIN_BRANDS_URL = "https://mockingbird.chrisvouga.dev/brands.json";
2728
- var renderAdminDocument = (service) => {
2984
+ var renderAdminDocument = (service, adminPrefix = ADMIN_PREFIX) => {
2729
2985
  const name = escapeHtml(service);
2730
2986
  const script = adminClientSource({
2731
2987
  standardRoutes: STANDARD_ADMIN_ROUTES,
2732
- adminPrefix: ADMIN_PREFIX,
2988
+ adminPrefix,
2733
2989
  adminKeyHeader: ADMIN_KEY_HEADER,
2734
2990
  brandsUrl: ADMIN_BRANDS_URL,
2735
2991
  service
@@ -2746,75 +3002,99 @@ var renderAdminDocument = (service) => {
2746
3002
  ${CSS_RESET}
2747
3003
  :root {
2748
3004
  color-scheme: light;
2749
- --bg: #fafafa;
3005
+ --bg: #f7f8fb;
2750
3006
  --bg-elev: #ffffff;
2751
- --bg-muted: #f4f4f5;
2752
- --bg-inset: #f4f4f5;
2753
- --line: #dedee3;
2754
- --line-strong: #c4c4cc;
2755
- --ink: #18181b;
2756
- --muted: #62626b;
2757
- --faint: #71717a;
2758
- --accent: #27272a;
3007
+ --bg-muted: #f1f3f7;
3008
+ --bg-inset: #f8f9fc;
3009
+ --line: #e4e7ec;
3010
+ --line-strong: #cfd4dc;
3011
+ --ink: #101828;
3012
+ --muted: #475467;
3013
+ --faint: #667085;
3014
+ --accent: #6941c6;
3015
+ --accent-hover: #5933b4;
2759
3016
  --accent-ink: #ffffff;
2760
- --accent-soft: #f4f4f5;
2761
- --ok: #166534;
2762
- --ok-soft: #f0fdf4;
2763
- --warn: #a16207;
2764
- --warn-soft: #fefce8;
2765
- --err: #a82d32;
2766
- --err-soft: #fef2f2;
2767
- --shadow: none;
3017
+ --accent-soft: #f4f0ff;
3018
+ --ok: #067647;
3019
+ --ok-soft: #ecfdf3;
3020
+ --warn: #b54708;
3021
+ --warn-soft: #fffaeb;
3022
+ --err: #b42318;
3023
+ --err-soft: #fef3f2;
3024
+ --code-key: #6941c6;
3025
+ --code-string: #067647;
3026
+ --code-number: #175cd3;
3027
+ --code-boolean: #c11574;
3028
+ --code-null: #667085;
3029
+ --shadow-xs: 0 1px 2px rgb(16 24 40 / 0.05);
3030
+ --shadow-sm: 0 1px 3px rgb(16 24 40 / 0.08), 0 1px 2px rgb(16 24 40 / 0.04);
3031
+ --shadow-lg: 0 18px 48px rgb(16 24 40 / 0.16), 0 4px 12px rgb(16 24 40 / 0.08);
2768
3032
  --sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
2769
3033
  --mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;
2770
- --radius: 10px;
2771
- --header: 56px;
2772
- --nav: 200px;
3034
+ --radius: 12px;
3035
+ --header: 72px;
3036
+ --nav: 224px;
2773
3037
  }
2774
3038
  :root[data-theme="dark"] {
2775
3039
  color-scheme: dark;
2776
- --bg: #111113;
2777
- --bg-elev: #19191c;
2778
- --bg-muted: #242428;
2779
- --bg-inset: #111113;
2780
- --line: #36363c;
2781
- --line-strong: #52525b;
2782
- --ink: #f4f4f5;
2783
- --muted: #a9a9b2;
2784
- --faint: #71717a;
2785
- --accent: #e4e4e7;
2786
- --accent-ink: #18181b;
2787
- --accent-soft: #242428;
2788
- --ok: #86efac;
2789
- --ok-soft: #14241b;
2790
- --warn: #fde68a;
2791
- --warn-soft: #2a2410;
2792
- --err: #ffaaaa;
2793
- --err-soft: #2c1516;
2794
- --shadow: none;
3040
+ --bg: #0c0e14;
3041
+ --bg-elev: #151821;
3042
+ --bg-muted: #20232e;
3043
+ --bg-inset: #11141c;
3044
+ --line: #2b303d;
3045
+ --line-strong: #414858;
3046
+ --ink: #f5f7fa;
3047
+ --muted: #b6bdc9;
3048
+ --faint: #8d96a7;
3049
+ --accent: #9e77ed;
3050
+ --accent-hover: #b692f6;
3051
+ --accent-ink: #ffffff;
3052
+ --accent-soft: #2b2142;
3053
+ --ok: #75e0a7;
3054
+ --ok-soft: #102a20;
3055
+ --warn: #fec84b;
3056
+ --warn-soft: #302611;
3057
+ --err: #fda29b;
3058
+ --err-soft: #321817;
3059
+ --code-key: #c3a6ff;
3060
+ --code-string: #75e0a7;
3061
+ --code-number: #84adff;
3062
+ --code-boolean: #f9a8d4;
3063
+ --code-null: #98a2b3;
3064
+ --shadow-xs: 0 1px 2px rgb(0 0 0 / 0.24);
3065
+ --shadow-sm: 0 4px 12px rgb(0 0 0 / 0.2);
3066
+ --shadow-lg: 0 20px 56px rgb(0 0 0 / 0.45);
2795
3067
  }
2796
3068
  @media (prefers-color-scheme: dark) {
2797
3069
  :root:not([data-theme="light"]) {
2798
3070
  color-scheme: dark;
2799
- --bg: #111113;
2800
- --bg-elev: #19191c;
2801
- --bg-muted: #242428;
2802
- --bg-inset: #111113;
2803
- --line: #36363c;
2804
- --line-strong: #52525b;
2805
- --ink: #f4f4f5;
2806
- --muted: #a9a9b2;
2807
- --faint: #71717a;
2808
- --accent: #e4e4e7;
2809
- --accent-ink: #18181b;
2810
- --accent-soft: #242428;
2811
- --ok: #86efac;
2812
- --ok-soft: #14241b;
2813
- --warn: #fde68a;
2814
- --warn-soft: #2a2410;
2815
- --err: #ffaaaa;
2816
- --err-soft: #2c1516;
2817
- --shadow: none;
3071
+ --bg: #0c0e14;
3072
+ --bg-elev: #151821;
3073
+ --bg-muted: #20232e;
3074
+ --bg-inset: #11141c;
3075
+ --line: #2b303d;
3076
+ --line-strong: #414858;
3077
+ --ink: #f5f7fa;
3078
+ --muted: #b6bdc9;
3079
+ --faint: #8d96a7;
3080
+ --accent: #9e77ed;
3081
+ --accent-hover: #b692f6;
3082
+ --accent-ink: #ffffff;
3083
+ --accent-soft: #2b2142;
3084
+ --ok: #75e0a7;
3085
+ --ok-soft: #102a20;
3086
+ --warn: #fec84b;
3087
+ --warn-soft: #302611;
3088
+ --err: #fda29b;
3089
+ --err-soft: #321817;
3090
+ --code-key: #c3a6ff;
3091
+ --code-string: #75e0a7;
3092
+ --code-number: #84adff;
3093
+ --code-boolean: #f9a8d4;
3094
+ --code-null: #98a2b3;
3095
+ --shadow-xs: 0 1px 2px rgb(0 0 0 / 0.24);
3096
+ --shadow-sm: 0 4px 12px rgb(0 0 0 / 0.2);
3097
+ --shadow-lg: 0 20px 56px rgb(0 0 0 / 0.45);
2818
3098
  }
2819
3099
  }
2820
3100
  /* Fill the viewport, then grow with the content so the background covers the scroll. */
@@ -2832,13 +3112,17 @@ var renderAdminDocument = (service) => {
2832
3112
  padding-right: env(safe-area-inset-right);
2833
3113
  -webkit-text-size-adjust: 100%;
2834
3114
  }
2835
- :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
3115
+ ::selection { background: color-mix(in srgb, var(--accent) 28%, transparent); }
3116
+ :focus-visible { outline: 3px solid color-mix(in srgb, var(--accent) 45%, transparent); outline-offset: 2px; }
3117
+ button, input, select, textarea { transition: border-color 140ms ease, box-shadow 140ms ease, transform 100ms ease; }
3118
+ button:disabled, input:disabled, select:disabled, textarea:disabled { cursor: not-allowed; opacity: 0.55; }
2836
3119
  .app { flex: 1 0 auto; display: flex; flex-direction: column; min-width: 0; width: 100%; }
2837
3120
  header.top {
2838
- position: sticky; top: 0; z-index: 5;
3121
+ position: sticky; top: 0; z-index: 20;
2839
3122
  display: flex; align-items: center; gap: 12px;
2840
- min-height: var(--header); padding: 10px 16px;
2841
- background: var(--bg-elev);
3123
+ min-height: var(--header); padding: 12px 20px;
3124
+ background: color-mix(in srgb, var(--bg-elev) 92%, transparent);
3125
+ backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
2842
3126
  border-bottom: 1px solid var(--line);
2843
3127
  min-width: 0; max-width: 100%;
2844
3128
  }
@@ -2846,12 +3130,21 @@ var renderAdminDocument = (service) => {
2846
3130
  display: flex; flex-flow: row wrap; align-items: center; gap: 14px;
2847
3131
  min-width: 0; max-width: 100%; margin-right: auto;
2848
3132
  }
2849
- .brand-id { display: flex; flex-direction: column; min-width: 0; flex: none; }
2850
- .brand-id strong { font-size: 15px; letter-spacing: -0.01em; }
3133
+ .brand-lockup { display: flex; align-items: center; gap: 10px; min-width: 0; }
3134
+ .brand-mark {
3135
+ width: 38px; height: 38px; display: grid; place-items: center; flex: none;
3136
+ border-radius: 11px; color: #fff;
3137
+ background: linear-gradient(145deg, #7f56d9 0%, #53389e 100%);
3138
+ box-shadow: 0 6px 16px rgb(105 65 198 / 0.24), inset 0 1px 0 rgb(255 255 255 / 0.25);
3139
+ }
3140
+ .brand-mark svg { width: 22px; height: 22px; }
3141
+ .brand-id { display: flex; flex-direction: column; min-width: 0; flex: none; line-height: 1.25; }
3142
+ .brand-id strong { font-size: 15px; letter-spacing: -0.015em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
2851
3143
  .brand-id span { color: var(--faint); font-size: 12px; }
2852
3144
  .vendor {
2853
3145
  display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
2854
3146
  min-width: 0; max-width: 100%; font-size: 12px; color: var(--muted);
3147
+ padding: 5px 8px; border: 1px solid var(--line); border-radius: 999px; background: var(--bg-inset);
2855
3148
  }
2856
3149
  .vendor[hidden] { display: none; }
2857
3150
  /* Logos are drawn for a light tile, same as the docs site, so the plate stays white. */
@@ -2863,12 +3156,17 @@ var renderAdminDocument = (service) => {
2863
3156
  .vendor-sep { color: var(--faint); }
2864
3157
  .vendor-link { color: inherit; text-decoration: none; }
2865
3158
  .vendor-link:hover { color: var(--ink); text-decoration: underline; }
2866
- .controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
2867
- label.field { display: flex; flex-direction: column; gap: 2px; font-size: 11px; color: var(--faint); }
3159
+ .controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: flex-end; }
3160
+ .sync-status { align-self: center; display: flex; align-items: center; gap: 6px; color: var(--faint); font-size: 12px; white-space: nowrap; }
3161
+ .sync-status::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--ok); box-shadow: 0 0 0 3px var(--ok-soft); }
3162
+ .sync-status.busy::before { background: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); animation: pulse 1.2s ease-in-out infinite; }
3163
+ label.field { display: flex; flex-direction: column; gap: 4px; font-size: 11px; font-weight: 600; color: var(--faint); }
2868
3164
  label.field input, label.field select, .controls input[type="password"] {
2869
- background-color: var(--bg-inset); border: 1px solid var(--line); border-radius: 8px;
2870
- padding: 7px 8px; min-height: 36px; line-height: 1.2;
3165
+ background-color: var(--bg-elev); border: 1px solid var(--line-strong); border-radius: 9px;
3166
+ padding: 8px 10px; min-height: 38px; line-height: 1.2; box-shadow: var(--shadow-xs);
2871
3167
  }
3168
+ label.field input:hover, label.field select:hover, textarea:hover { border-color: color-mix(in srgb, var(--accent) 38%, var(--line-strong)); }
3169
+ 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; }
2872
3170
  /* A native menu ignores min-height, so Preset rendered about half as tall as
2873
3171
  the text fields in the same form. Paint it like those fields. */
2874
3172
  label.field select {
@@ -2885,12 +3183,20 @@ var renderAdminDocument = (service) => {
2885
3183
  dialog .field > textarea { width: 100%; }
2886
3184
  .icon-btn, .btn, .btn-primary, .btn-quiet, .btn-danger, nav.side button { cursor: pointer; }
2887
3185
  .icon-btn, .btn, .btn-primary, .btn-quiet, .btn-danger {
2888
- border-radius: 9px; min-height: 36px; padding: 0 12px; border: 1px solid var(--line);
2889
- background: var(--bg-elev);
2890
- }
2891
- .btn-primary { background: var(--accent); color: var(--accent-ink); border-color: transparent; }
2892
- .btn-quiet { background: transparent; }
2893
- .btn-danger { color: var(--err); }
3186
+ display: inline-flex; align-items: center; justify-content: center; gap: 7px;
3187
+ border-radius: 9px; min-height: 38px; padding: 0 13px; border: 1px solid var(--line-strong);
3188
+ background: var(--bg-elev); color: var(--ink); font-size: 13px; font-weight: 600; line-height: 1;
3189
+ box-shadow: var(--shadow-xs);
3190
+ }
3191
+ .icon-btn { width: 38px; padding: 0; }
3192
+ .icon-btn svg, .btn svg, .btn-primary svg { width: 16px; height: 16px; }
3193
+ .icon-btn:hover, .btn:hover { background: var(--bg-muted); border-color: var(--line-strong); }
3194
+ .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; }
3195
+ .btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
3196
+ .btn-quiet { background: transparent; border-color: transparent; box-shadow: none; }
3197
+ .btn-quiet:hover { background: var(--bg-muted); }
3198
+ .btn-danger { color: var(--err); border-color: color-mix(in srgb, var(--err) 25%, var(--line)); }
3199
+ .btn-danger:hover, .btn-danger.confirming { background: var(--err-soft); border-color: color-mix(in srgb, var(--err) 45%, var(--line)); }
2894
3200
  /* Held after mouseup. Clearing an empty journal changes nothing on screen,
2895
3201
  so the press itself has to stay visible. */
2896
3202
  .icon-btn:active, .btn:active, .btn-quiet:active, .btn-danger:active, nav.side button:active,
@@ -2906,93 +3212,159 @@ var renderAdminDocument = (service) => {
2906
3212
  flex: 1 0 auto; min-width: 0;
2907
3213
  }
2908
3214
  nav.side {
2909
- border-right: 1px solid var(--line); padding: 12px;
3215
+ position: sticky; top: var(--header); align-self: start; height: calc(100vh - var(--header));
3216
+ border-right: 1px solid var(--line); padding: 18px 12px;
2910
3217
  display: flex; flex-direction: column; gap: 4px;
2911
- background: var(--bg-elev);
3218
+ background: var(--bg-elev); overflow-y: auto;
2912
3219
  }
3220
+ .nav-label { padding: 0 10px 8px; color: var(--faint); font-size: 10px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; }
2913
3221
  nav.side button {
3222
+ position: relative; display: flex; align-items: center; gap: 10px; width: 100%;
2914
3223
  text-align: left; border: 0; background: transparent; border-radius: 9px;
2915
- padding: 9px 10px; min-height: 40px; color: var(--muted);
2916
- }
2917
- nav.side button[aria-current="true"] { background: var(--accent-soft); color: var(--ink); font-weight: 600; }
2918
- main { padding: 20px; max-width: 1100px; width: 100%; min-width: 0; }
2919
- .view h2 { margin: 0 0 4px; font-size: 22px; letter-spacing: -0.02em; }
2920
- .lede { margin: 0 0 16px; color: var(--muted); }
2921
- .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr)); gap: 12px; }
3224
+ padding: 9px 10px; min-height: 42px; color: var(--muted); font-size: 13px; font-weight: 550;
3225
+ }
3226
+ nav.side button:hover { background: var(--bg-muted); color: var(--ink); }
3227
+ nav.side button[aria-current="true"] { background: var(--accent-soft); color: var(--accent); font-weight: 650; }
3228
+ 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); }
3229
+ .nav-icon { width: 18px; height: 18px; display: grid; place-items: center; flex: none; }
3230
+ .nav-icon svg { width: 18px; height: 18px; }
3231
+ main { padding: 32px clamp(20px, 4vw, 48px) 64px; max-width: 1320px; width: 100%; min-width: 0; }
3232
+ main[aria-busy="true"] .view { opacity: 0.62; }
3233
+ .view { animation: view-in 180ms ease-out; }
3234
+ .view[hidden] { display: none; }
3235
+ .view-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 22px; }
3236
+ .view h2 { margin: 0 0 5px; font-size: clamp(24px, 3vw, 30px); line-height: 1.2; letter-spacing: -0.035em; }
3237
+ .view h3 { margin: 24px 0 10px; font-size: 15px; letter-spacing: -0.01em; }
3238
+ .lede { margin: 0; color: var(--muted); max-width: 72ch; font-size: 14px; }
3239
+ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 175px), 1fr)); gap: 14px; }
2922
3240
  .card {
2923
3241
  background: var(--bg-elev); border: 1px solid var(--line); border-radius: var(--radius);
2924
- padding: 14px 16px; box-shadow: var(--shadow);
3242
+ padding: 18px; box-shadow: var(--shadow-sm);
2925
3243
  }
2926
- .card .k { color: var(--faint); font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; }
2927
- .card .v { display: flex; flex-direction: column; gap: 2px; font-size: 22px; margin-top: 4px; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
3244
+ .stat-card { position: relative; overflow: hidden; }
3245
+ .stat-card::after { content: ""; position: absolute; inset: 0 0 auto; height: 2px; background: linear-gradient(90deg, var(--accent), transparent 78%); opacity: 0.7; }
3246
+ .card .k { color: var(--faint); font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; }
3247
+ .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; }
2928
3248
  .card .v .clock-time { font-size: 14px; color: var(--muted); }
2929
- .split { display: grid; grid-template-columns: minmax(0, 240px) minmax(0, 1fr); gap: 16px; align-items: start; }
3249
+ .section-card { background: var(--bg-elev); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow-xs); overflow: hidden; }
3250
+ .section-title { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 16px 18px; border-bottom: 1px solid var(--line); }
3251
+ .section-title h3 { margin: 0; }
3252
+ .split { display: grid; grid-template-columns: minmax(0, 250px) minmax(0, 1fr); gap: 18px; align-items: start; }
3253
+ #collections { padding: 6px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg-elev); box-shadow: var(--shadow-xs); }
2930
3254
  .collection {
2931
3255
  width: 100%; text-align: left; border: 1px solid transparent; background: transparent;
2932
- border-radius: 10px; padding: 8px 10px; min-height: 44px;
3256
+ border-radius: 9px; padding: 10px 11px; min-height: 48px; color: var(--ink); cursor: pointer;
2933
3257
  }
2934
- .collection[aria-current="true"] { background: var(--bg-elev); border-color: var(--line); }
3258
+ .collection:hover { background: var(--bg-muted); }
3259
+ .collection[aria-current="true"] { background: var(--accent-soft); border-color: color-mix(in srgb, var(--accent) 18%, var(--line)); color: var(--accent); }
2935
3260
  .collection small { display: block; color: var(--faint); }
2936
3261
  .chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 14px; }
2937
- .chip {
2938
- font-family: var(--mono); font-size: 12px; background: var(--bg-muted);
2939
- border-radius: 999px; padding: 4px 8px;
2940
- }
3262
+ .chip { font-family: var(--mono); font-size: 11px; background: var(--bg-muted); border: 1px solid var(--line); border-radius: 999px; padding: 4px 8px; }
2941
3263
  .chip i { color: var(--faint); font-style: normal; }
2942
3264
  .sql-layout {
2943
3265
  display: grid; grid-template-columns: minmax(10rem, 16rem) minmax(0, 1fr); gap: 16px;
2944
3266
  }
2945
- .sql-tables { display: flex; flex-direction: column; gap: 4px; max-height: 28rem; overflow: auto; }
2946
- .sql-tables button { text-align: left; }
3267
+ .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); }
3268
+ .sql-tables button { text-align: left; border: 0; border-radius: 8px; padding: 9px 10px; background: transparent; color: var(--muted); cursor: pointer; }
3269
+ .sql-tables button:hover, .sql-tables button[aria-current="true"] { background: var(--accent-soft); color: var(--accent); }
2947
3270
  .sql-input { font-family: var(--mono); min-height: 7rem; width: 100%; }
2948
- table { width: 100%; border-collapse: collapse; font-size: 14px; }
2949
- th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--line); vertical-align: top; }
2950
- th { color: var(--faint); font-weight: 600; font-size: 12px; }
3271
+ .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); }
3272
+ table { width: 100%; border-collapse: collapse; font-size: 13px; }
3273
+ th, td { text-align: left; padding: 11px 13px; border-bottom: 1px solid var(--line); vertical-align: top; }
3274
+ th { color: var(--faint); background: var(--bg-inset); font-weight: 650; font-size: 11px; text-transform: uppercase; letter-spacing: 0.045em; }
3275
+ tbody tr:last-child td { border-bottom: 0; }
2951
3276
  tr.clickable { cursor: pointer; }
2952
- tr.clickable:hover { background: var(--bg-muted); }
3277
+ tr.clickable:hover { background: var(--accent-soft); }
2953
3278
  pre, code, .mono { font-family: var(--mono); overflow-wrap: anywhere; }
2954
3279
  pre {
2955
3280
  margin: 0; white-space: pre-wrap; word-break: break-word;
2956
- background: var(--bg-inset); border-radius: 10px; padding: 12px; font-size: 12px;
2957
- }
2958
- .row-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0; }
3281
+ background: var(--bg-inset); border: 1px solid var(--line); border-radius: 10px; padding: 14px; font-size: 12px; line-height: 1.65;
3282
+ }
3283
+ .json-key { color: var(--code-key); }
3284
+ .json-string { color: var(--code-string); }
3285
+ .json-number { color: var(--code-number); }
3286
+ .json-boolean { color: var(--code-boolean); }
3287
+ .json-null { color: var(--code-null); font-style: italic; }
3288
+ .json-inline { display: block; max-width: 44rem; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
3289
+ .json-view { position: relative; max-height: 26rem; overflow: auto; }
3290
+ .json-editor { position: relative; min-height: 190px; border: 1px solid var(--line-strong); border-radius: 10px; background: var(--bg-inset); overflow: hidden; }
3291
+ .json-editor:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 15%, transparent); }
3292
+ .json-editor.invalid { border-color: var(--err); }
3293
+ .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; }
3294
+ .json-editor pre { position: absolute; inset: 0; pointer-events: none; background: transparent; }
3295
+ .json-editor textarea { position: relative; z-index: 1; resize: vertical; background: transparent; color: transparent; caret-color: var(--ink); -webkit-text-fill-color: transparent; }
3296
+ .json-editor textarea::selection { background: color-mix(in srgb, var(--accent) 32%, transparent); }
3297
+ .editor-meta { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 7px; }
3298
+ .json-status { display: inline-flex; align-items: center; gap: 6px; color: var(--ok); font-size: 11px; }
3299
+ .json-status::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
3300
+ .json-status.invalid { color: var(--err); }
3301
+ .format-json { border: 0; padding: 3px 5px; background: transparent; color: var(--accent); font-size: 11px; font-weight: 650; cursor: pointer; }
3302
+ .row-actions { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 8px; margin: 14px 0; }
2959
3303
  .banner {
2960
- display: none; margin-bottom: 12px; padding: 10px 12px; border-radius: 10px;
2961
- background: var(--err-soft); color: var(--err);
2962
- }
2963
- .banner.show { display: block; }
2964
- .empty { color: var(--muted); padding: 24px 8px; }
3304
+ display: none; position: sticky; top: calc(var(--header) + 12px); z-index: 15;
3305
+ align-items: center; gap: 10px; margin: 0 0 16px; padding: 11px 12px 11px 14px;
3306
+ border: 1px solid color-mix(in srgb, var(--err) 30%, var(--line)); border-radius: 10px;
3307
+ background: var(--err-soft); color: var(--err); box-shadow: var(--shadow-sm); font-size: 13px;
3308
+ }
3309
+ .banner.show { display: flex; }
3310
+ .banner span { flex: 1; }
3311
+ .banner button { width: 28px; height: 28px; display: grid; place-items: center; border: 0; border-radius: 7px; background: transparent; color: currentColor; cursor: pointer; }
3312
+ .banner button:hover { background: color-mix(in srgb, var(--err) 10%, transparent); }
3313
+ .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); }
2965
3314
  dialog {
2966
- border: 1px solid var(--line); border-radius: var(--radius); padding: 0; background: var(--bg-elev);
2967
- color: var(--ink); width: min(640px, calc(100% - 24px)); max-width: calc(100vw - 24px); box-shadow: none;
2968
- }
2969
- dialog::backdrop { background: rgb(20 19 17 / 0.45); }
2970
- dialog form, .dialog-body { padding: 16px; display: flex; flex-direction: column; gap: 10px; }
3315
+ border: 1px solid var(--line); border-radius: 16px; padding: 0; background: var(--bg-elev);
3316
+ color: var(--ink); width: min(720px, calc(100% - 24px)); max-width: calc(100vw - 24px); box-shadow: var(--shadow-lg);
3317
+ }
3318
+ dialog[open] { animation: dialog-in 160ms ease-out; }
3319
+ dialog::backdrop { background: rgb(12 14 20 / 0.58); backdrop-filter: blur(3px); }
3320
+ dialog form, .dialog-body { padding: 22px; display: flex; flex-direction: column; gap: 14px; }
3321
+ .dialog-heading { display: flex; justify-content: space-between; gap: 16px; padding-bottom: 12px; border-bottom: 1px solid var(--line); }
3322
+ .dialog-heading strong { font-size: 18px; letter-spacing: -0.02em; }
3323
+ .dialog-heading span { color: var(--muted); font-size: 12px; }
2971
3324
  textarea {
2972
3325
  width: 100%; min-height: 180px; resize: vertical; border-radius: 10px;
2973
3326
  border: 1px solid var(--line); background: var(--bg-inset); padding: 10px;
2974
3327
  font-family: var(--mono); font-size: 12px;
2975
3328
  }
2976
- .dialog-actions { display: flex; justify-content: flex-end; gap: 8px; }
2977
- .badge { font-size: 11px; border-radius: 999px; padding: 2px 7px; background: var(--bg-muted); color: var(--muted); }
3329
+ .dialog-actions { display: flex; justify-content: flex-end; align-items: center; gap: 8px; padding-top: 4px; }
3330
+ .dialog-actions .btn-danger { margin-right: auto; }
3331
+ .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); }
2978
3332
  .badge.ok { background: var(--ok-soft); color: var(--ok); }
2979
3333
  .badge.warn { background: var(--warn-soft); color: var(--warn); }
2980
3334
  .status-2 { color: var(--ok); }
2981
3335
  .status-4, .status-5, .status-0 { color: var(--err); }
2982
3336
  .stack { display: flex; flex-direction: column; gap: 12px; }
3337
+ .form-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
3338
+ .form-grid .wide { grid-column: span 2; }
2983
3339
  .route {
2984
- display: grid; grid-template-columns: 88px 1fr auto; gap: 8px; align-items: center;
2985
- padding: 8px 0; border-bottom: 1px solid var(--line);
2986
- }
2987
- .method { font-family: var(--mono); font-size: 12px; font-weight: 700; }
3340
+ display: grid; grid-template-columns: 84px minmax(0, 1fr) auto; gap: 10px; align-items: center;
3341
+ padding: 10px 12px; border-bottom: 1px solid var(--line); background: var(--bg-elev);
3342
+ }
3343
+ .route:first-child { border-radius: var(--radius) var(--radius) 0 0; }
3344
+ .route:last-child { border-bottom: 0; border-radius: 0 0 var(--radius) var(--radius); }
3345
+ .route-tools { display: flex; align-items: flex-end; gap: 12px; margin-bottom: 12px; }
3346
+ .route-tools .field { flex: 1; }
3347
+ .route-tools input { width: 100%; }
3348
+ .route-count { padding-bottom: 10px; color: var(--faint); font-size: 12px; white-space: nowrap; }
3349
+ #route-list { max-height: 34rem; border: 1px solid var(--line); border-radius: var(--radius); overflow: auto; box-shadow: var(--shadow-xs); }
3350
+ .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; }
3351
+ @keyframes view-in { from { transform: translateY(3px); } to { transform: translateY(0); } }
3352
+ @keyframes dialog-in { from { transform: translateY(8px) scale(0.99); } to { transform: translateY(0) scale(1); } }
3353
+ @keyframes pulse { 50% { opacity: 0.45; } }
3354
+ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } }
3355
+ @media (forced-colors: active) { .json-editor pre { display: none; } .json-editor textarea { color: CanvasText; -webkit-text-fill-color: CanvasText; } }
2988
3356
  @media (max-width: 800px) {
3357
+ :root { --header: 0px; }
2989
3358
  header.top {
2990
- align-items: stretch; flex-direction: column;
2991
- padding-top: max(10px, env(safe-area-inset-top));
3359
+ position: relative; align-items: stretch; flex-direction: column;
3360
+ padding: max(12px, env(safe-area-inset-top)) 14px 12px;
2992
3361
  }
2993
3362
  input, select, textarea { font-size: 16px; }
2994
3363
  .brand, .vendor, .controls { width: 100%; }
3364
+ .brand { gap: 10px; }
3365
+ .vendor { order: 2; border-radius: 9px; }
2995
3366
  label.field, label.field select, label.field input { flex: 1; min-width: 0; }
3367
+ .sync-status { display: none; }
2996
3368
  /* The layout fills the screen. A single-column grid stretches every auto
2997
3369
  row, so the tab bar grew with the leftover height and the buttons floated
2998
3370
  in the middle of it. The bar stays one row; the page below takes the rest. */
@@ -3002,14 +3374,23 @@ var renderAdminDocument = (service) => {
3002
3374
  align-content: start;
3003
3375
  }
3004
3376
  nav.side {
3005
- align-self: start; height: auto;
3377
+ position: sticky; top: 0; z-index: 12; align-self: start; height: auto;
3006
3378
  flex-direction: row; align-items: center; overflow-x: auto; min-width: 0;
3007
3379
  border-right: 0; border-bottom: 1px solid var(--line);
3008
- padding: 8px;
3380
+ padding: 8px; box-shadow: var(--shadow-xs);
3009
3381
  }
3010
- nav.side button { white-space: nowrap; flex: none; }
3382
+ .nav-label { display: none; }
3383
+ nav.side button { width: auto; white-space: nowrap; flex: none; }
3384
+ nav.side button[aria-current="true"]::before { inset: auto 9px -8px; width: auto; height: 3px; border-radius: 3px 3px 0 0; }
3011
3385
  .split, .sql-layout { grid-template-columns: minmax(0, 1fr); }
3012
- main { padding: 14px; }
3386
+ .form-grid { grid-template-columns: minmax(0, 1fr); }
3387
+ .form-grid .wide { grid-column: auto; }
3388
+ main { padding: 22px 14px 48px; }
3389
+ .view-head { margin-bottom: 18px; }
3390
+ .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
3391
+ .card { padding: 14px; }
3392
+ .card .v { font-size: 20px; }
3393
+ .table-shell { border: 0; background: transparent; box-shadow: none; overflow: visible; }
3013
3394
  table, thead, tbody, tr, th, td { display: block; }
3014
3395
  thead { display: none; }
3015
3396
  tr {
@@ -3024,50 +3405,63 @@ var renderAdminDocument = (service) => {
3024
3405
  .route { grid-template-columns: 72px 1fr; }
3025
3406
  .route button { grid-column: 1 / -1; }
3026
3407
  }
3408
+ @media (max-width: 460px) {
3409
+ .controls label.field { min-width: calc(50% - 4px); }
3410
+ .controls .icon-btn, .controls .btn { flex: 1; }
3411
+ .grid { grid-template-columns: minmax(0, 1fr); }
3412
+ .dialog-actions { flex-wrap: wrap; }
3413
+ .dialog-actions button { flex: 1; }
3414
+ .dialog-actions .btn-danger { flex-basis: 100%; margin-right: 0; }
3415
+ }
3027
3416
  </style>
3028
3417
  </head>
3029
3418
  <body>
3030
3419
  <div class="app" data-service="${name}" data-mockingbird-admin>
3031
3420
  <header class="top">
3032
3421
  <div class="brand">
3033
- <div class="brand-id">
3034
- <strong>${name}</strong>
3035
- <span>Admin</span>
3422
+ <div class="brand-lockup">
3423
+ <div class="brand-mark" aria-hidden="true">
3424
+ <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>
3425
+ </div>
3426
+ <div class="brand-id">
3427
+ <strong>${name}</strong>
3428
+ <span>Mock admin</span>
3429
+ </div>
3036
3430
  </div>
3037
3431
  <div class="vendor" id="vendor" hidden></div>
3038
3432
  </div>
3039
3433
  <div class="controls">
3434
+ <span class="sync-status" id="sync-status" role="status" aria-live="polite">Ready</span>
3040
3435
  <label class="field">Namespace
3041
3436
  <select id="namespace" aria-label="Namespace"></select>
3042
3437
  </label>
3043
3438
  <label class="field">Admin key
3044
3439
  <input id="admin-key" type="password" autocomplete="off" spellcheck="false" aria-label="Admin key">
3045
3440
  </label>
3046
- <button class="icon-btn" id="theme" type="button" aria-label="Toggle color theme">Theme</button>
3047
- <button class="btn" id="refresh" type="button">Refresh</button>
3441
+ <button class="icon-btn" id="theme" type="button" aria-label="Use dark theme" title="Use dark theme"></button>
3442
+ <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>
3048
3443
  </div>
3049
3444
  </header>
3050
3445
  <div class="layout">
3051
3446
  <nav class="side" id="nav" aria-label="Admin sections">
3052
- <button type="button" data-view="overview" aria-current="true">Overview</button>
3053
- <button type="button" data-view="state">State</button>
3054
- <button type="button" data-view="clock">Clock</button>
3055
- <button type="button" data-view="faults">Faults</button>
3056
- <button type="button" data-view="journal">Journal</button>
3057
- <button type="button" data-view="routes">Routes</button>
3447
+ <span class="nav-label">Workspace</span>
3448
+ <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>
3449
+ <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>
3450
+ <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>
3451
+ <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>
3452
+ <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>
3453
+ <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>
3058
3454
  </nav>
3059
3455
  <main id="views">
3060
- <p class="banner" id="banner" role="alert"></p>
3456
+ <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>
3061
3457
  <section class="view" id="view-overview">
3062
- <h2>Overview</h2>
3063
- <p class="lede">Health, clock, and the collections this namespace is holding.</p>
3458
+ <div class="view-head"><div><h2>Overview</h2><p class="lede">Health, clock, and the collections this namespace is holding.</p></div></div>
3064
3459
  <div class="grid" id="overview-cards"></div>
3065
3460
  <h3>Recent requests</h3>
3066
3461
  <div id="overview-requests"></div>
3067
3462
  </section>
3068
3463
  <section class="view" id="view-state" hidden>
3069
- <h2>State</h2>
3070
- <p class="lede">Every collection in this namespace. Declared fields stay visible before any row exists; stored rows add whatever else they contain.</p>
3464
+ <div class="view-head"><div><h2>State</h2><p class="lede">Inspect and edit every collection in this namespace.</p></div></div>
3071
3465
  <div class="split">
3072
3466
  <div id="collections"></div>
3073
3467
  <div>
@@ -3080,8 +3474,7 @@ var renderAdminDocument = (service) => {
3080
3474
  </div>
3081
3475
  </section>
3082
3476
  <section class="view" id="view-clock" hidden>
3083
- <h2>Clock</h2>
3084
- <p class="lede">The clock every timestamp in this mock reads.</p>
3477
+ <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>
3085
3478
  <div class="card" id="clock-readout"></div>
3086
3479
  <div class="row-actions">
3087
3480
  <button class="btn" type="button" data-advance="60000">+1m</button>
@@ -3100,21 +3493,22 @@ var renderAdminDocument = (service) => {
3100
3493
  </form>
3101
3494
  </section>
3102
3495
  <section class="view" id="view-faults" hidden>
3103
- <h2>Faults</h2>
3104
- <p class="lede">Inject a response, a delay, or a named preset. Rules apply to this namespace.</p>
3496
+ <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>
3105
3497
  <form id="fault-form" class="card stack">
3106
- <label class="field">Preset
3107
- <select id="fault-preset"><option value="">Custom rule</option></select>
3108
- </label>
3109
- <label class="field">Status
3110
- <input id="fault-status" inputmode="numeric" placeholder="503">
3111
- </label>
3112
- <label class="field">Path prefix
3113
- <input id="fault-path" placeholder="/v1">
3114
- </label>
3115
- <label class="field">Method
3116
- <input id="fault-method" placeholder="GET">
3117
- </label>
3498
+ <div class="form-grid">
3499
+ <label class="field wide">Preset
3500
+ <select id="fault-preset"><option value="">Custom rule</option></select>
3501
+ </label>
3502
+ <label class="field">Status
3503
+ <input id="fault-status" inputmode="numeric" placeholder="503">
3504
+ </label>
3505
+ <label class="field wide">Path prefix
3506
+ <input id="fault-path" placeholder="/v1">
3507
+ </label>
3508
+ <label class="field">Method
3509
+ <input id="fault-method" placeholder="GET">
3510
+ </label>
3511
+ </div>
3118
3512
  <div class="dialog-actions">
3119
3513
  <button class="btn-primary" type="submit">Add fault</button>
3120
3514
  <button class="btn-danger" id="fault-clear" type="button">Clear all</button>
@@ -3123,26 +3517,31 @@ var renderAdminDocument = (service) => {
3123
3517
  <div id="fault-list"></div>
3124
3518
  </section>
3125
3519
  <section class="view" id="view-journal" hidden>
3126
- <h2>Journal</h2>
3127
- <p class="lede">Requests this namespace has seen. Bodies are not stored.</p>
3128
- <div class="row-actions">
3129
- <button class="btn-danger" id="journal-clear" type="button">Clear journal</button>
3130
- </div>
3520
+ <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>
3131
3521
  <div id="journal"></div>
3132
3522
  </section>
3133
3523
  <section class="view" id="view-routes" hidden>
3134
- <h2>Routes</h2>
3135
- <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>
3524
+ <div class="view-head"><div><h2>Routes</h2><p class="lede">Explore shared controls and service-specific admin endpoints.</p></div></div>
3525
+ <div class="route-tools">
3526
+ <label class="field">Filter routes
3527
+ <input id="route-search" type="search" placeholder="Method, path, or extension" autocomplete="off">
3528
+ </label>
3529
+ <span class="route-count" id="route-count"></span>
3530
+ </div>
3136
3531
  <div id="route-list"></div>
3137
- <form id="route-form" class="card stack">
3532
+ <form id="route-form" class="card stack" style="margin-top: 18px">
3138
3533
  <strong id="route-title">Select a route</strong>
3139
3534
  <label class="field">JSON body
3140
- <textarea id="route-body" placeholder="{ }"></textarea>
3535
+ <div class="json-editor" id="route-editor" hidden>
3536
+ <pre aria-hidden="true"><code id="route-highlight"></code></pre>
3537
+ <textarea id="route-body" placeholder="{ }" spellcheck="false" aria-describedby="route-json-status" disabled></textarea>
3538
+ </div>
3539
+ <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>
3141
3540
  </label>
3142
3541
  <div class="dialog-actions">
3143
- <button class="btn-primary" type="submit">Send</button>
3542
+ <button class="btn-primary" id="route-send" type="submit" disabled>Send request</button>
3144
3543
  </div>
3145
- <pre id="route-result">Response will show here.</pre>
3544
+ <pre class="json-view" id="route-result"><code>Response will show here.</code></pre>
3146
3545
  </form>
3147
3546
  </section>
3148
3547
  </main>
@@ -3150,12 +3549,16 @@ var renderAdminDocument = (service) => {
3150
3549
  </div>
3151
3550
  <dialog id="editor">
3152
3551
  <form method="dialog" id="editor-form">
3153
- <strong id="editor-title">Record</strong>
3552
+ <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>
3154
3553
  <label class="field">Id
3155
3554
  <input id="editor-id" required>
3156
3555
  </label>
3157
3556
  <label class="field">JSON value
3158
- <textarea id="editor-value"></textarea>
3557
+ <div class="json-editor" id="record-editor">
3558
+ <pre aria-hidden="true"><code id="editor-highlight"></code></pre>
3559
+ <textarea id="editor-value" spellcheck="false" aria-describedby="editor-json-status"></textarea>
3560
+ </div>
3561
+ <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>
3159
3562
  </label>
3160
3563
  <div class="dialog-actions">
3161
3564
  <button class="btn-danger" id="editor-delete" type="button" value="delete">Delete</button>
@@ -3197,8 +3600,8 @@ var describeExtension = (extension) => {
3197
3600
  ...extension.script !== void 0 ? { script: extension.script } : {}
3198
3601
  };
3199
3602
  };
3200
- var adminUiRoutes = (service, ui) => {
3201
- const shell = () => renderAdminDocument(service);
3603
+ var adminUiRoutes = (service, ui, adminPrefix = ADMIN_PREFIX) => {
3604
+ const shell = () => renderAdminDocument(service, adminPrefix);
3202
3605
  const document3 = () => ui?.render ? ui.render({ service, defaultHtml: shell }) : shell();
3203
3606
  return {
3204
3607
  "GET /ui": () => htmlResponse(document3()),
@@ -3815,14 +4218,17 @@ var stateAdminRoutes = (routes) => {
3815
4218
  };
3816
4219
 
3817
4220
  // ../core/dist/version.js
3818
- var PACKAGE_VERSION = true ? "1.7.1" : UNRELEASED_VERSION;
4221
+ var PACKAGE_VERSION = true ? "2.0.0" : UNRELEASED_VERSION;
3819
4222
 
3820
4223
  // ../core/dist/signing.js
3821
4224
  var encoder = new TextEncoder();
3822
4225
  var fromBase64 = (value) => Uint8Array.from(atob(value), (char) => char.charCodeAt(0));
3823
4226
 
3824
4227
  // ../core/dist/webhooks.js
3825
- var json4 = (status, body) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
4228
+ var json4 = (status, body) => new Response(JSON.stringify(body), {
4229
+ status,
4230
+ headers: { "content-type": "application/json; charset=utf-8" }
4231
+ });
3826
4232
  var adminError3 = (status, message) => json4(status, { error: { type: "mockingbird_admin", message } });
3827
4233
  var isRecord5 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3828
4234
  var parseEndpoint = (value) => {
@@ -3930,7 +4336,6 @@ var AT_HEADER = "x-mockingbird-at";
3930
4336
  var CHECKPOINT_HEADER = "x-mockingbird-checkpoint";
3931
4337
  var DEFAULT_NAMESPACE = "default";
3932
4338
  var NAMESPACE_PATTERN = /^[A-Za-z0-9_.-]{1,64}$/;
3933
- var PATH_PREFIX = /^\/ns\/([^/]+)(\/.*)?$/;
3934
4339
  var BRANCH_PATTERN2 = /^[A-Za-z0-9_.-]{1,64}$/;
3935
4340
  var MUTATING_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
3936
4341
  var effects = /* @__PURE__ */ new WeakMap();
@@ -3970,6 +4375,22 @@ var operationMatcher = (document3) => {
3970
4375
  return (request, path) => matchers.find((m) => m.method === request.method && m.pattern.test(path))?.operationId;
3971
4376
  };
3972
4377
  var createRuntime = (options) => {
4378
+ const adminPrefix = resolveAdminPrefix(options.adminPrefix);
4379
+ const internalPaths = Object.entries(options.document?.paths ?? {}).filter(([, item]) => item["x-mockingbird-internal"] === true).map(([path]) => path);
4380
+ for (const path of internalPaths) {
4381
+ if (!path.startsWith(`${ADMIN_PREFIX}/blobs/`))
4382
+ throw new Error(`internal transport ${path} must live under ${ADMIN_PREFIX}/blobs/`);
4383
+ }
4384
+ assertAdminPrefixAvailable(adminPrefix, Object.keys(options.document?.paths ?? {}).filter((path) => !internalPaths.includes(path)));
4385
+ const internalMatch = (path) => {
4386
+ for (const pattern of internalPaths) {
4387
+ const actual = path.split("/");
4388
+ const expected = pattern.split("/");
4389
+ if (actual.length === expected.length && expected.every((part, i) => part.startsWith("{") || part === actual[i]))
4390
+ return pattern;
4391
+ }
4392
+ return void 0;
4393
+ };
3973
4394
  assertAdminUi(options.adminUi);
3974
4395
  const sqlite = bootSqlite(options.sqlite);
3975
4396
  const clock = options.clock ?? createClock();
@@ -3984,6 +4405,20 @@ var createRuntime = (options) => {
3984
4405
  const instances = /* @__PURE__ */ new Map();
3985
4406
  const publicNamespaces = /* @__PURE__ */ new Set();
3986
4407
  const branchRngs = /* @__PURE__ */ new Map();
4408
+ const namespaceClocks = /* @__PURE__ */ new Map();
4409
+ const namespaceForStorage = /* @__PURE__ */ new Map();
4410
+ let isolatedNamespaces = false;
4411
+ const namespaceClock = (name = DEFAULT_NAMESPACE) => {
4412
+ if (!isolatedNamespaces || name === DEFAULT_NAMESPACE)
4413
+ return clock;
4414
+ let found = namespaceClocks.get(name);
4415
+ if (!found) {
4416
+ found = createClock();
4417
+ namespaceClocks.set(name, found);
4418
+ }
4419
+ return found;
4420
+ };
4421
+ const fleetPoints = /* @__PURE__ */ new WeakMap();
3987
4422
  const timelines = /* @__PURE__ */ new Map();
3988
4423
  const branchStorage = /* @__PURE__ */ new Map();
3989
4424
  const captured = /* @__PURE__ */ new Map();
@@ -3997,17 +4432,22 @@ var createRuntime = (options) => {
3997
4432
  if (!NAMESPACE_PATTERN.test(key) || !NAMESPACE_PATTERN.test(publicNamespace)) {
3998
4433
  throw new RangeError(`namespace must match ${NAMESPACE_PATTERN}: ${JSON.stringify(publicNamespace)}`);
3999
4434
  }
4435
+ const createdContextRng = isolatedRng ?? (isolatedNamespaces && key !== DEFAULT_NAMESPACE ? createRng(options.seed ?? 0) : rng);
4000
4436
  const created = options.create({
4001
4437
  namespace: storageNamespace(key),
4002
4438
  publicNamespace,
4439
+ adminPrefix,
4003
4440
  sqlite,
4004
- clock,
4005
- rng: isolatedRng ?? rng
4441
+ clock: namespaceClock(publicNamespace),
4442
+ rng: createdContextRng
4006
4443
  });
4007
4444
  instances.set(key, created);
4445
+ namespaceForStorage.set(key, publicNamespace);
4008
4446
  publicNamespaces.add(publicNamespace);
4009
4447
  if (isolatedRng)
4010
4448
  branchRngs.set(key, isolatedRng);
4449
+ else if (isolatedNamespaces && key !== DEFAULT_NAMESPACE)
4450
+ branchRngs.set(key, createdContextRng);
4011
4451
  return created;
4012
4452
  };
4013
4453
  const instance = (name = DEFAULT_NAMESPACE) => instanceFor(name);
@@ -4025,7 +4465,7 @@ var createRuntime = (options) => {
4025
4465
  captured.set(storage, snapshot2);
4026
4466
  return Object.freeze({
4027
4467
  snapshot: snapshot2,
4028
- clock: Object.freeze(clock.state()),
4468
+ clock: Object.freeze(namespaceClock(namespaceForStorage.get(storage) ?? storage).state()),
4029
4469
  rngState: (branchRngs.get(storage) ?? rng).state()
4030
4470
  });
4031
4471
  };
@@ -4035,7 +4475,7 @@ var createRuntime = (options) => {
4035
4475
  return found;
4036
4476
  instance(name);
4037
4477
  found = new Timeline({
4038
- now: clock.now,
4478
+ now: namespaceClock(name).now,
4039
4479
  ...options.maxCheckpoints !== void 0 ? { maxCheckpoints: options.maxCheckpoints } : {}
4040
4480
  });
4041
4481
  found.commit(capture(name));
@@ -4062,12 +4502,13 @@ var createRuntime = (options) => {
4062
4502
  const point = history2.checkout("main", at);
4063
4503
  restoreNamespace(sqlite, storageNamespace(namespace), point.value.snapshot);
4064
4504
  captured.set(namespace, point.value.snapshot);
4065
- rng.setState(point.value.rngState);
4066
- clock.set(point.value.clock.now);
4505
+ (branchRngs.get(namespace) ?? rng).setState(point.value.rngState);
4506
+ const selectedClock = namespaceClock(namespace);
4507
+ selectedClock.set(point.value.clock.now);
4067
4508
  if (point.value.clock.frozen)
4068
- clock.freeze();
4509
+ selectedClock.freeze();
4069
4510
  else
4070
- clock.unfreeze();
4511
+ selectedClock.unfreeze();
4071
4512
  }
4072
4513
  return namespace;
4073
4514
  }
@@ -4125,11 +4566,12 @@ var createRuntime = (options) => {
4125
4566
  const storage = ensureBranch(namespace, branchName);
4126
4567
  restoreNamespace(sqlite, storageNamespace(storage), point.value.snapshot);
4127
4568
  captured.set(storage, point.value.snapshot);
4128
- clock.set(point.value.clock.now);
4569
+ const selectedClock = namespaceClock(namespace);
4570
+ selectedClock.set(point.value.clock.now);
4129
4571
  if (point.value.clock.frozen)
4130
- clock.freeze();
4572
+ selectedClock.freeze();
4131
4573
  else
4132
- clock.unfreeze();
4574
+ selectedClock.unfreeze();
4133
4575
  (branchRngs.get(storage) ?? rng).setState(point.value.rngState);
4134
4576
  };
4135
4577
  const reset = async (name = DEFAULT_NAMESPACE) => {
@@ -4137,10 +4579,21 @@ var createRuntime = (options) => {
4137
4579
  options.webhooks?.clear();
4138
4580
  for (const each of instances.values())
4139
4581
  await each.reset();
4582
+ for (const each of branchRngs.values())
4583
+ each.reset();
4140
4584
  timelines.clear();
4585
+ for (const storage of branchStorage.values()) {
4586
+ instances.delete(storage);
4587
+ branchRngs.delete(storage);
4588
+ namespaceForStorage.delete(storage);
4589
+ }
4141
4590
  branchStorage.clear();
4142
- branchRngs.clear();
4143
4591
  captured.clear();
4592
+ faults.clear();
4593
+ clock.reset();
4594
+ rng.reset();
4595
+ for (const each of namespaceClocks.values())
4596
+ each.reset();
4144
4597
  return;
4145
4598
  }
4146
4599
  options.webhooks?.clear(name);
@@ -4158,11 +4611,18 @@ var createRuntime = (options) => {
4158
4611
  else
4159
4612
  clearNamespace(sqlite, storageNamespace(storage));
4160
4613
  branchStorage.delete(mapping);
4614
+ instances.delete(storage);
4615
+ namespaceForStorage.delete(storage);
4161
4616
  branchRngs.delete(storage);
4162
4617
  captured.delete(storage);
4163
4618
  }
4164
4619
  timelines.delete(name);
4165
4620
  captured.delete(name);
4621
+ faults.restore(name, { rules: [], rngState: createRng(options.seed ?? 0).state() });
4622
+ namespaceClock(name).reset();
4623
+ (branchRngs.get(name) ?? rng).reset();
4624
+ journal.clear(name);
4625
+ metrics.reset(name);
4166
4626
  };
4167
4627
  const snapshot = (name = DEFAULT_NAMESPACE) => {
4168
4628
  return checkpoint(name, "main").value.snapshot;
@@ -4200,6 +4660,53 @@ var createRuntime = (options) => {
4200
4660
  rng,
4201
4661
  credentials,
4202
4662
  webhooks: options.webhooks,
4663
+ isolateNamespaces() {
4664
+ if (publicNamespaces.size > 1)
4665
+ throw new Error("enable namespace isolation before creating named namespaces");
4666
+ isolatedNamespaces = true;
4667
+ faults.isolateNamespaces();
4668
+ },
4669
+ namespaceClock,
4670
+ namespaceOf(request) {
4671
+ const url = new URL(request.url);
4672
+ const prefixed = matchNamespacePath(url.pathname, adminPrefix);
4673
+ const selected = request.headers.get(NAMESPACE_HEADER) ?? (prefixed ? decodeURIComponent(prefixed[1]) : void 0);
4674
+ const path = prefixed ? prefixed[2] ?? "/" : url.pathname;
4675
+ if (isAdminPath(path, adminPrefix))
4676
+ return url.searchParams.get("namespace") ?? selected ?? DEFAULT_NAMESPACE;
4677
+ if (selected !== void 0)
4678
+ return selected;
4679
+ const credential = options.credential?.(request);
4680
+ return (credential !== void 0 ? credentials.get(credential) : void 0) ?? DEFAULT_NAMESPACE;
4681
+ },
4682
+ fleetSnapshot(namespace) {
4683
+ instance(namespace);
4684
+ const handle = {};
4685
+ fleetPoints.set(handle, {
4686
+ namespace,
4687
+ state: capture(namespace),
4688
+ faults: faults.snapshot(namespace),
4689
+ webhooks: options.webhooks?.snapshot(namespace)
4690
+ });
4691
+ return handle;
4692
+ },
4693
+ fleetRestore(handle, namespace) {
4694
+ const point = typeof handle === "object" && handle !== null ? fleetPoints.get(handle) : void 0;
4695
+ if (!point || point.namespace !== namespace)
4696
+ throw new Error("invalid fleet checkpoint");
4697
+ restoreNamespace(sqlite, storageNamespace(namespace), point.state.snapshot);
4698
+ captured.set(namespace, point.state.snapshot);
4699
+ const selectedClock = namespaceClock(namespace);
4700
+ selectedClock.freeze();
4701
+ selectedClock.set(point.state.clock.now);
4702
+ if (!point.state.clock.frozen)
4703
+ selectedClock.unfreeze();
4704
+ (branchRngs.get(namespace) ?? rng).setState(point.state.rngState);
4705
+ faults.restore(namespace, point.faults);
4706
+ if (point.webhooks !== void 0)
4707
+ options.webhooks?.restore(namespace, point.webhooks);
4708
+ timelines.delete(namespace);
4709
+ },
4203
4710
  applyPreset: (name, namespace = DEFAULT_NAMESPACE, overrides = {}) => {
4204
4711
  const preset = options.presets?.[name];
4205
4712
  if (!preset)
@@ -4231,7 +4738,7 @@ var createRuntime = (options) => {
4231
4738
  state: (namespace = DEFAULT_NAMESPACE) => inspectState(stateScope(namespace)),
4232
4739
  fetch: async (incoming) => {
4233
4740
  let request = incoming;
4234
- const prefixed = PATH_PREFIX.exec(new URL(request.url).pathname);
4741
+ const prefixed = matchNamespacePath(new URL(request.url).pathname, adminPrefix);
4235
4742
  if (prefixed) {
4236
4743
  const url2 = new URL(request.url);
4237
4744
  url2.pathname = prefixed[2] ?? "/";
@@ -4247,7 +4754,15 @@ var createRuntime = (options) => {
4247
4754
  signal: request.signal
4248
4755
  });
4249
4756
  }
4250
- let namespace = control.namespaceOf(request);
4757
+ const internalUrl = new URL(request.url);
4758
+ const internalPath = isAdminPath(internalUrl.pathname, adminPrefix) ? `${ADMIN_PREFIX}${internalUrl.pathname.slice(adminPrefix.length)}` : "";
4759
+ const internal = internalMatch(internalPath);
4760
+ const obsoleteInternalPath = adminPrefix !== ADMIN_PREFIX && internalMatch(internalUrl.pathname) && !internal;
4761
+ if (internal) {
4762
+ internalUrl.pathname = internalPath;
4763
+ request = new Request(internalUrl, request);
4764
+ }
4765
+ let namespace = internal ? internalUrl.searchParams.get("namespace") ?? control.namespaceOf(request) : control.namespaceOf(request);
4251
4766
  if (!request.headers.has(NAMESPACE_HEADER) && options.credential) {
4252
4767
  const credential = options.credential(request);
4253
4768
  const mapped = credential !== void 0 ? credentials.get(credential) : void 0;
@@ -4267,7 +4782,9 @@ var createRuntime = (options) => {
4267
4782
  return copy;
4268
4783
  }
4269
4784
  };
4270
- const handled = await control.handle(request);
4785
+ if (obsoleteInternalPath)
4786
+ return stamp(adminFail(404, "internal transport is only available under adminPrefix"));
4787
+ const handled = internal ? void 0 : await control.handle(request);
4271
4788
  if (handled)
4272
4789
  return stamp(handled);
4273
4790
  const started = monotonicNow();
@@ -4308,7 +4825,7 @@ var createRuntime = (options) => {
4308
4825
  type: "mockingbird_admin",
4309
4826
  message: `${NAMESPACE_HEADER} must match ${NAMESPACE_PATTERN}`
4310
4827
  }
4311
- }), { status: 400, headers: { "content-type": "application/json" } }));
4828
+ }), { status: 400, headers: { "content-type": "application/json; charset=utf-8" } }));
4312
4829
  }
4313
4830
  if (!BRANCH_PATTERN2.test(selectedBranch)) {
4314
4831
  log(400);
@@ -4375,6 +4892,7 @@ var createRuntime = (options) => {
4375
4892
  startedAt: wallNow(),
4376
4893
  wallNow,
4377
4894
  clock,
4895
+ namespaceClock,
4378
4896
  faults,
4379
4897
  metrics,
4380
4898
  journal,
@@ -4424,15 +4942,16 @@ var createRuntime = (options) => {
4424
4942
  ...options.admin?.(runtime) ?? {},
4425
4943
  // Shell routes win a colliding key. Fault presets are one of them: the admin page
4426
4944
  // maps `presets`, and a service route must not replace that list with a record.
4427
- ...options.presets ? presetRoutes(options.presets, runtime) : {},
4945
+ ...options.presets ? presetRoutes(options.presets, runtime, adminPrefix) : {},
4428
4946
  ...stateAdminRoutes({
4429
4947
  open: stateScope,
4430
4948
  afterWrite: (namespace) => {
4431
4949
  checkpoint(namespace, "main");
4432
4950
  }
4433
4951
  }),
4434
- ...adminUiRoutes(options.name, options.adminUi)
4952
+ ...adminUiRoutes(options.name, options.adminUi, adminPrefix)
4435
4953
  },
4954
+ adminPrefix,
4436
4955
  adminKey: options.adminKey
4437
4956
  });
4438
4957
  return defineMock(runtime);
@@ -4446,7 +4965,10 @@ var mutableResponse = (response) => {
4446
4965
  return new Response(response.body, response);
4447
4966
  }
4448
4967
  };
4449
- var adminJson = (status, body) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
4968
+ var adminJson = (status, body) => new Response(JSON.stringify(body), {
4969
+ status,
4970
+ headers: { "content-type": "application/json; charset=utf-8" }
4971
+ });
4450
4972
  var adminFail = (status, message) => adminJson(status, { error: { type: "mockingbird_admin", message } });
4451
4973
  var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4452
4974
  var credentialRoutes = (registry) => ({
@@ -4498,7 +5020,7 @@ var credentialRoutes = (registry) => ({
4498
5020
  return adminJson(200, { status: "ok" });
4499
5021
  }
4500
5022
  });
4501
- var presetRoutes = (presets, runtime) => ({
5023
+ var presetRoutes = (presets, runtime, adminPrefix) => ({
4502
5024
  "GET /faults/presets": () => {
4503
5025
  const body = {
4504
5026
  presets: Object.entries(presets).map(([name, preset]) => ({ name, ...preset }))
@@ -4508,7 +5030,7 @@ var presetRoutes = (presets, runtime) => ({
4508
5030
  "POST /faults/presets/:name": ({ params, body, namespace }) => {
4509
5031
  const name = params.name;
4510
5032
  if (!presets[name])
4511
- return adminFail(404, `no fault preset ${name}; GET /__admin/faults/presets`);
5033
+ return adminFail(404, `no fault preset ${name}; GET ${adminPrefix}/faults/presets`);
4512
5034
  const overrides = isObject(body) ? body : {};
4513
5035
  return adminJson(201, { preset: name, rules: runtime.applyPreset(name, namespace, overrides) });
4514
5036
  }
@@ -5121,6 +5643,7 @@ var createRuntime2 = (options = {}) => {
5121
5643
  ...options.sqlite ? { sqlite: options.sqlite } : {},
5122
5644
  ...options.clock ? { clock: options.clock } : {},
5123
5645
  ...options.seed !== void 0 ? { seed: options.seed } : {},
5646
+ ...options.adminPrefix !== void 0 ? { adminPrefix: options.adminPrefix } : {},
5124
5647
  ...options.adminKey !== void 0 ? { adminKey: options.adminKey } : {},
5125
5648
  ...options.onLog ? { onLog: options.onLog } : {},
5126
5649
  credential: (request) => tokens.get(request),
@@ -5138,7 +5661,7 @@ var createRuntime2 = (options = {}) => {
5138
5661
  const inner = runtime.fetch;
5139
5662
  const fetch2 = async (request) => {
5140
5663
  const path = new URL(request.url).pathname;
5141
- if (!request.headers.has(NAMESPACE_HEADER) && !path.startsWith("/ns/") && !path.startsWith("/__admin") && path !== "/health") {
5664
+ if (!request.headers.has(NAMESPACE_HEADER) && !(path === (options.adminPrefix ?? "/__admin") || path.startsWith(`${options.adminPrefix ?? "/__admin"}/`))) {
5142
5665
  const token = await requestToken(request, path) ?? bearerToken(request);
5143
5666
  if (token !== void 0) tokens.set(request, token);
5144
5667
  }
@@ -5469,6 +5992,9 @@ var malformedBody = () => ({
5469
5992
  });
5470
5993
 
5471
5994
  export {
5995
+ createClock,
5996
+ resolveAdminPrefix,
5997
+ parseDuration,
5472
5998
  decodePostHogBody,
5473
5999
  tokenFromBody,
5474
6000
  evaluateFlag,
@@ -5486,4 +6012,4 @@ export {
5486
6012
  scrubProperties,
5487
6013
  PostHogAPI
5488
6014
  };
5489
- //# sourceMappingURL=chunk-ZRB53DZ6.js.map
6015
+ //# sourceMappingURL=chunk-NRFKITRM.js.map