@crvouga/mockingbird-service-junction 1.8.2 → 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.
@@ -114,13 +114,31 @@ var Collection = class {
114
114
  };
115
115
 
116
116
  // ../core/dist/control.js
117
- var HEALTH_PATH = "/health";
118
117
  var ADMIN_PREFIX = "/__admin";
118
+ var resolveAdminPrefix = (prefix = ADMIN_PREFIX) => {
119
+ if (!/^\/[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_-]+)*$/.test(prefix)) {
120
+ throw new Error("adminPrefix must be an absolute non-root path of literal segments, without a trailing slash");
121
+ }
122
+ return prefix;
123
+ };
124
+ var isAdminPath = (path, prefix) => path === prefix || path.startsWith(`${prefix}/`);
125
+ var matchNamespacePath = (path, prefix) => /^\/ns\/([^/]+)(\/.*)?$/.exec(isAdminPath(path, prefix) ? path.slice(prefix.length) : "");
126
+ var assertAdminPrefixAvailable = (prefix, paths) => {
127
+ const reserved = prefix.split("/").slice(1);
128
+ for (const path of paths) {
129
+ const segments = path.split("/").slice(1);
130
+ if (segments.length < reserved.length || /^\{[^}]+\}$/.test(segments[0] ?? ""))
131
+ continue;
132
+ if (reserved.every((part, i) => segments[i] === part || /^\{[^}]+\}$/.test(segments[i] ?? ""))) {
133
+ throw new Error(`vendor route ${path} collides with adminPrefix ${prefix}; choose a different adminPrefix`);
134
+ }
135
+ }
136
+ };
119
137
  var ADMIN_KEY_HEADER = "x-mockingbird-admin-key";
120
138
  var NAMESPACE_HEADER = "x-mockingbird-namespace";
121
139
  var json = (status, body) => new Response(JSON.stringify(body), {
122
140
  status,
123
- headers: { "content-type": "application/json" }
141
+ headers: { "content-type": "application/json; charset=utf-8" }
124
142
  });
125
143
  var adminError = (status, message) => json(status, { error: { type: "mockingbird_admin", message } });
126
144
  var UNITS = {
@@ -172,11 +190,25 @@ var readJson = async (request2) => {
172
190
  };
173
191
  var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
174
192
  var createControlPlane = (context) => {
193
+ const adminPrefix = resolveAdminPrefix(context.adminPrefix);
175
194
  const snapshots = /* @__PURE__ */ new Map();
176
195
  let snapshotCounter = 0;
177
196
  const headerNamespace = (request2) => request2.headers.get(NAMESPACE_HEADER) ?? context.defaultNamespace;
178
197
  const adminNamespace = (request2, url) => url.searchParams.get("namespace") ?? headerNamespace(request2);
198
+ const health = () => {
199
+ return json(200, {
200
+ ...context.describe(),
201
+ status: "ok",
202
+ service: context.name,
203
+ uptimeMs: context.wallNow() - context.startedAt,
204
+ clock: context.clock.state(),
205
+ namespaces: context.namespaces().length,
206
+ adminPrefix,
207
+ adminUi: `${adminPrefix}/ui`
208
+ });
209
+ };
179
210
  const builtin = {
211
+ "GET /health": health,
180
212
  "GET /": () => json(200, {
181
213
  service: context.name,
182
214
  routes: [...Object.keys(builtin), ...Object.keys(context.routes)].sort()
@@ -187,29 +219,30 @@ var createControlPlane = (context) => {
187
219
  return json(200, { status: "ok", reset: target === "*" ? context.namespaces() : [target] });
188
220
  },
189
221
  "GET /namespaces": () => json(200, { default: context.defaultNamespace, namespaces: context.namespaces() }),
190
- "GET /clock": () => json(200, context.clock.state()),
191
- "POST /clock": ({ body }) => {
222
+ "GET /clock": ({ namespace }) => json(200, (context.namespaceClock?.(namespace) ?? context.clock).state()),
223
+ "POST /clock": ({ body, namespace }) => {
224
+ const clock = context.namespaceClock?.(namespace) ?? context.clock;
192
225
  if (!isRecord(body))
193
226
  return adminError(400, "expected a JSON object");
194
227
  if (body.reset === true)
195
- context.clock.reset();
228
+ clock.reset();
196
229
  if (body.freeze === true)
197
- context.clock.freeze();
230
+ clock.freeze();
198
231
  if (body.set !== void 0) {
199
232
  const instant2 = parseInstant(body.set);
200
233
  if (instant2 === void 0)
201
234
  return adminError(400, "set: expected epoch ms or ISO-8601");
202
- context.clock.set(instant2);
235
+ clock.set(instant2);
203
236
  }
204
237
  if (body.advance !== void 0) {
205
238
  const delta = parseDuration(body.advance);
206
239
  if (delta === void 0)
207
240
  return adminError(400, 'advance: expected ms or "15m"-style');
208
- context.clock.advance(delta);
241
+ clock.advance(delta);
209
242
  }
210
243
  if (body.freeze === false)
211
- context.clock.unfreeze();
212
- return json(200, context.clock.state());
244
+ clock.unfreeze();
245
+ return json(200, clock.state());
213
246
  },
214
247
  "GET /faults": () => json(200, { faults: context.faults.list() }),
215
248
  "POST /faults": ({ body, namespace }) => {
@@ -342,7 +375,14 @@ var createControlPlane = (context) => {
342
375
  return json(200, { status: "ok" });
343
376
  }
344
377
  };
345
- const routes = [...Object.entries(context.routes), ...Object.entries(builtin)].map(([key, handler]) => {
378
+ for (const key of Object.keys(context.routes)) {
379
+ if (Object.hasOwn(builtin, key))
380
+ throw new Error(`admin route ${key} overrides a shared route`);
381
+ const path = key.slice(key.indexOf(" ") + 1);
382
+ if (isAdminPath(path, "/ns") || isAdminPath(path, "/blobs"))
383
+ throw new Error(`admin route ${key} overlaps an internal transport`);
384
+ }
385
+ const routes = [...Object.entries(builtin), ...Object.entries(context.routes)].map(([key, handler]) => {
346
386
  const space = key.indexOf(" ");
347
387
  return { method: key.slice(0, space), pattern: key.slice(space + 1), handler };
348
388
  });
@@ -350,21 +390,13 @@ var createControlPlane = (context) => {
350
390
  namespaceOf: headerNamespace,
351
391
  async handle(request2) {
352
392
  const url = new URL(request2.url);
353
- if (url.pathname === HEALTH_PATH && request2.method === "GET") {
354
- return json(200, {
355
- status: "ok",
356
- service: context.name,
357
- uptimeMs: context.wallNow() - context.startedAt,
358
- clock: context.clock.state(),
359
- namespaces: context.namespaces().length,
360
- adminUi: `${ADMIN_PREFIX}/ui`,
361
- ...context.describe()
362
- });
393
+ if (url.pathname === `${adminPrefix}/health` && request2.method === "GET") {
394
+ return health();
363
395
  }
364
- if (url.pathname !== ADMIN_PREFIX && !url.pathname.startsWith(`${ADMIN_PREFIX}/`)) {
396
+ if (!isAdminPath(url.pathname, adminPrefix)) {
365
397
  return void 0;
366
398
  }
367
- const path = url.pathname.slice(ADMIN_PREFIX.length) || "/";
399
+ const path = url.pathname.slice(adminPrefix.length) || "/";
368
400
  const shell = request2.method === "GET" && (path === "/ui" || path === "/ui/");
369
401
  if (!shell && context.adminKey !== void 0 && request2.headers.get(ADMIN_KEY_HEADER) !== context.adminKey) {
370
402
  return adminError(401, `missing or wrong ${ADMIN_KEY_HEADER}`);
@@ -372,7 +404,12 @@ var createControlPlane = (context) => {
372
404
  for (const route of routes) {
373
405
  if (route.method !== request2.method)
374
406
  continue;
375
- const params = matchRoute(route.pattern, path);
407
+ let params;
408
+ try {
409
+ params = matchRoute(route.pattern, path);
410
+ } catch {
411
+ return adminError(400, "invalid path encoding");
412
+ }
376
413
  if (!params)
377
414
  continue;
378
415
  let body;
@@ -381,15 +418,18 @@ var createControlPlane = (context) => {
381
418
  } catch {
382
419
  return adminError(400, "request body is not valid JSON");
383
420
  }
421
+ const namespace = adminNamespace(request2, url);
422
+ if (!/^[A-Za-z0-9_.-]{1,64}$/.test(namespace))
423
+ return adminError(400, "invalid namespace");
384
424
  return route.handler({
385
425
  request: request2,
386
426
  url,
387
427
  params,
388
- namespace: adminNamespace(request2, url),
428
+ namespace,
389
429
  body
390
430
  });
391
431
  }
392
- return adminError(404, `no admin route ${request2.method} ${path}; GET ${ADMIN_PREFIX} lists them`);
432
+ return adminError(404, `no admin route ${request2.method} ${path}; GET ${adminPrefix} lists them`);
393
433
  }
394
434
  };
395
435
  };
@@ -471,7 +511,33 @@ var faultResponse = (rule) => {
471
511
  };
472
512
  var createFaultRegistry = (rng = createRng(0), sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) => {
473
513
  const entries = [];
514
+ let isolated = false;
515
+ const scopedRngs = /* @__PURE__ */ new Map();
516
+ const random = (namespace) => {
517
+ if (!isolated || namespace === "default")
518
+ return rng;
519
+ let scoped = scopedRngs.get(namespace);
520
+ if (!scoped) {
521
+ scoped = createRng(rng.seed);
522
+ scopedRngs.set(namespace, scoped);
523
+ }
524
+ return scoped;
525
+ };
474
526
  return {
527
+ isolateNamespaces() {
528
+ isolated = true;
529
+ },
530
+ snapshot(namespace) {
531
+ return {
532
+ rules: structuredClone(entries.map((entry, position) => ({
533
+ ...entry.rule,
534
+ remaining: entry.remaining,
535
+ hits: entry.hits,
536
+ position
537
+ })).filter((entry) => entry.namespace === namespace)),
538
+ rngState: random(namespace).state()
539
+ };
540
+ },
475
541
  add(rule) {
476
542
  const existing = entries.findIndex((e) => e.rule.id === rule.id);
477
543
  const entry = { rule, remaining: rule.count ?? null, hits: 0 };
@@ -491,6 +557,16 @@ var createFaultRegistry = (rng = createRng(0), sleep = (ms) => new Promise((reso
491
557
  },
492
558
  clear() {
493
559
  entries.length = 0;
560
+ rng.reset();
561
+ scopedRngs.clear();
562
+ },
563
+ restore(namespace, state) {
564
+ for (let i = entries.length - 1; i >= 0; i--)
565
+ if (entries[i]?.rule.namespace === namespace)
566
+ entries.splice(i, 1);
567
+ for (const { remaining, hits, position, ...rule } of state.rules)
568
+ entries.splice(Math.min(position, entries.length), 0, { rule, remaining, hits });
569
+ random(namespace).setState(state.rngState);
494
570
  },
495
571
  async take(candidate) {
496
572
  const hits = [];
@@ -500,7 +576,7 @@ var createFaultRegistry = (rng = createRng(0), sleep = (ms) => new Promise((reso
500
576
  if (!matches(entry.rule, candidate))
501
577
  continue;
502
578
  const rate = entry.rule.rate ?? 1;
503
- if (rng.next() >= rate)
579
+ if (random(candidate.namespace).next() >= rate)
504
580
  continue;
505
581
  entry.hits++;
506
582
  if (entry.remaining !== null)
@@ -877,7 +953,8 @@ var annotateResponse = (response, extra) => {
877
953
  var responseNotes = (response) => notes.get(response);
878
954
 
879
955
  // ../core/dist/metrics.js
880
- var createMetrics = () => {
956
+ var createMetrics = (partition = true) => {
957
+ const namespaces = /* @__PURE__ */ new Map();
881
958
  let requests = 0;
882
959
  let faults = 0;
883
960
  let totalDurationMs = 0;
@@ -885,6 +962,14 @@ var createMetrics = () => {
885
962
  const unmatched = /* @__PURE__ */ new Map();
886
963
  return {
887
964
  record(entry) {
965
+ if (partition) {
966
+ let scoped = namespaces.get(entry.namespace);
967
+ if (!scoped) {
968
+ scoped = createMetrics(false);
969
+ namespaces.set(entry.namespace, scoped);
970
+ }
971
+ scoped.record(entry);
972
+ }
888
973
  requests++;
889
974
  totalDurationMs += entry.durationMs;
890
975
  if (entry.faultId !== void 0)
@@ -896,7 +981,7 @@ var createMetrics = () => {
896
981
  unmatched.set(route, (unmatched.get(route) ?? 0) + 1);
897
982
  }
898
983
  },
899
- report: () => ({
984
+ report: (namespace) => namespace !== void 0 ? (namespaces.get(namespace) ?? createMetrics(false)).report() : {
900
985
  requests,
901
986
  byOperation: Object.fromEntries([...byOperation].sort(([a], [b]) => a.localeCompare(b))),
902
987
  unmatched: [...unmatched].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([route, count]) => {
@@ -905,8 +990,13 @@ var createMetrics = () => {
905
990
  }),
906
991
  faults,
907
992
  totalDurationMs
908
- }),
909
- reset() {
993
+ },
994
+ reset(namespace) {
995
+ if (namespace !== void 0) {
996
+ namespaces.delete(namespace);
997
+ return;
998
+ }
999
+ namespaces.clear();
910
1000
  requests = 0;
911
1001
  faults = 0;
912
1002
  totalDurationMs = 0;
@@ -1519,6 +1609,34 @@ dialog:not([open]) {
1519
1609
  function isRecord2(value) {
1520
1610
  return typeof value === "object" && value !== null && !Array.isArray(value);
1521
1611
  }
1612
+ function highlightJsonText(text) {
1613
+ const escapeToken = (value) => value.replace(/[&<>"']/g, (char) => {
1614
+ switch (char) {
1615
+ case "&":
1616
+ return "&amp;";
1617
+ case "<":
1618
+ return "&lt;";
1619
+ case ">":
1620
+ return "&gt;";
1621
+ case '"':
1622
+ return "&quot;";
1623
+ default:
1624
+ return "&#39;";
1625
+ }
1626
+ });
1627
+ 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;
1628
+ let cursor = 0;
1629
+ let html = "";
1630
+ for (const match of text.matchAll(token)) {
1631
+ const index = match.index ?? 0;
1632
+ const value = match[0];
1633
+ html += escapeToken(text.slice(cursor, index));
1634
+ const kind = value.startsWith('"') ? value.trimEnd().endsWith(":") ? "key" : "string" : value === "true" || value === "false" ? "boolean" : value === "null" ? "null" : "number";
1635
+ html += `<span class="json-${kind}">${escapeToken(value)}</span>`;
1636
+ cursor = index + value.length;
1637
+ }
1638
+ return html + escapeToken(text.slice(cursor));
1639
+ }
1522
1640
  function mountAdminBrand(doc, host, service, catalog) {
1523
1641
  const clip = (value, max) => {
1524
1642
  if (typeof value !== "string")
@@ -1880,14 +1998,16 @@ function bootAdmin(config) {
1880
1998
  const state = {
1881
1999
  namespace: "default",
1882
2000
  key: sessionStorage.getItem("mockingbird-admin-key") || "",
1883
- view: "overview",
2001
+ view: ["overview", "state", "clock", "faults", "journal", "routes"].includes(location.hash.slice(1)) ? location.hash.slice(1) : "overview",
1884
2002
  collection: "",
1885
2003
  shape: null,
1886
2004
  route: null,
2005
+ routes: [],
1887
2006
  editing: null
1888
2007
  };
1889
2008
  const standard = new Set(config.standardRoutes);
1890
2009
  const banner = el("banner", HTMLElement);
2010
+ const bannerMessage = el("banner-message", HTMLElement);
1891
2011
  const keyInput = el("admin-key", HTMLInputElement);
1892
2012
  keyInput.value = state.key;
1893
2013
  const params = new URLSearchParams(location.search);
@@ -1898,18 +2018,21 @@ function bootAdmin(config) {
1898
2018
  sessionStorage.setItem("mockingbird-admin-key", state.key);
1899
2019
  params.delete("key");
1900
2020
  const next = params.toString();
1901
- history.replaceState(null, "", location.pathname + (next ? `?${next}` : ""));
2021
+ history.replaceState(null, "", location.pathname + (next ? `?${next}` : "") + location.hash);
1902
2022
  }
1903
2023
  const queryNamespace = params.get("namespace");
1904
2024
  if (queryNamespace)
1905
2025
  state.namespace = queryNamespace;
1906
2026
  const showError = (error) => {
1907
- banner.textContent = error instanceof Error ? error.message : String(error);
2027
+ bannerMessage.textContent = error instanceof Error ? error.message : String(error);
1908
2028
  banner.classList.add("show");
2029
+ banner.scrollIntoView({ behavior: "smooth", block: "nearest" });
1909
2030
  };
1910
2031
  const clearError = () => {
1911
2032
  banner.classList.remove("show");
2033
+ bannerMessage.textContent = "";
1912
2034
  };
2035
+ el("banner-dismiss", HTMLButtonElement).addEventListener("click", clearError);
1913
2036
  const api = async (method, path, body) => {
1914
2037
  const headers = { accept: "application/json" };
1915
2038
  if (state.key !== "")
@@ -1953,16 +2076,21 @@ function bootAdmin(config) {
1953
2076
  namespace: () => state.namespace
1954
2077
  };
1955
2078
  const isPanelScript = (value) => typeof value === "function";
2079
+ const themeButton = el("theme", HTMLButtonElement);
1956
2080
  const setTheme = (theme) => {
1957
2081
  if (theme)
1958
2082
  document.documentElement.dataset.theme = theme;
1959
2083
  else
1960
2084
  delete document.documentElement.dataset.theme;
2085
+ const dark = theme ? theme === "dark" : matchMedia("(prefers-color-scheme: dark)").matches;
2086
+ 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>';
2087
+ const label = dark ? "Use light theme" : "Use dark theme";
2088
+ themeButton.setAttribute("aria-label", label);
2089
+ themeButton.title = label;
1961
2090
  };
1962
2091
  const storedTheme = localStorage.getItem("mockingbird-admin-theme");
1963
- if (storedTheme)
1964
- setTheme(storedTheme);
1965
- el("theme", HTMLButtonElement).addEventListener("click", () => {
2092
+ setTheme(storedTheme === "dark" || storedTheme === "light" ? storedTheme : null);
2093
+ themeButton.addEventListener("click", () => {
1966
2094
  const current = document.documentElement.dataset.theme;
1967
2095
  const dark = current ? current === "dark" : matchMedia("(prefers-color-scheme: dark)").matches;
1968
2096
  const next = dark ? "light" : "dark";
@@ -1981,6 +2109,7 @@ function bootAdmin(config) {
1981
2109
  continue;
1982
2110
  section.hidden = section.id !== `view-${view}`;
1983
2111
  }
2112
+ history.replaceState(null, "", `${location.pathname}${location.search}#${encodeURIComponent(view)}`);
1984
2113
  };
1985
2114
  el("nav", HTMLElement).addEventListener("click", (event) => {
1986
2115
  const target = event.target;
@@ -1995,7 +2124,7 @@ function bootAdmin(config) {
1995
2124
  show(view);
1996
2125
  refresh().catch(showError);
1997
2126
  });
1998
- 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>`;
2127
+ 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>`;
1999
2128
  const table = (columns, rows) => {
2000
2129
  if (rows.length === 0)
2001
2130
  return '<p class="empty">Nothing here yet.</p>';
@@ -2005,16 +2134,22 @@ function bootAdmin(config) {
2005
2134
  const attrs = "attrs" in row && typeof row.attrs === "string" ? row.attrs : "";
2006
2135
  return `<tr${attrs}>${cells}</tr>`;
2007
2136
  }).join("");
2008
- return `<table><thead>${head}</thead><tbody>${body}</tbody></table>`;
2137
+ return `<div class="table-shell"><table><thead>${head}</thead><tbody>${body}</tbody></table></div>`;
2009
2138
  };
2010
2139
  const preview = (value) => {
2011
2140
  const text = JSON.stringify(value) ?? "null";
2012
- return esc(text.length > 180 ? `${text.slice(0, 180)}\u2026` : text);
2141
+ const clipped = text.length > 180 ? `${text.slice(0, 180)}\u2026` : text;
2142
+ return `<code class="json-inline">${highlightJsonText(clipped)}</code>`;
2013
2143
  };
2014
2144
  const when = (epoch) => {
2015
2145
  const date = new Date(epoch);
2016
2146
  return Number.isNaN(date.getTime()) ? String(epoch) : date.toISOString();
2017
2147
  };
2148
+ const journalWhen = (value) => {
2149
+ const numeric = value.trim() === "" ? Number.NaN : Number(value);
2150
+ const epoch = Number.isFinite(numeric) ? numeric : Date.parse(value);
2151
+ return Number.isFinite(epoch) ? when(epoch) : value;
2152
+ };
2018
2153
  const clockCard = (epoch) => {
2019
2154
  const date = new Date(epoch);
2020
2155
  if (Number.isNaN(date.getTime()))
@@ -2134,7 +2269,7 @@ function bootAdmin(config) {
2134
2269
  };
2135
2270
  const renderOverview = async () => {
2136
2271
  const [health, shape, clock, journal] = await Promise.all([
2137
- originGet("/health"),
2272
+ originGet(`${config.adminPrefix}/health`),
2138
2273
  api("GET", "/state"),
2139
2274
  api("GET", "/clock"),
2140
2275
  api("GET", "/requests?limit=8")
@@ -2151,7 +2286,7 @@ function bootAdmin(config) {
2151
2286
  },
2152
2287
  {
2153
2288
  k: "Status",
2154
- v: esc(typeof health.status === "string" && health.status !== "" ? health.status : "ok")
2289
+ v: `<span class="badge ok">${esc(typeof health.status === "string" && health.status !== "" ? health.status : "ok")}</span>`
2155
2290
  },
2156
2291
  { k: "Namespace", v: esc(state.namespace) },
2157
2292
  { k: "Collections", v: String(decoded.collections.length) },
@@ -2238,26 +2373,46 @@ function bootAdmin(config) {
2238
2373
  const renderJournal = async () => {
2239
2374
  const journal = await api("GET", "/requests?limit=100");
2240
2375
  el("journal", HTMLElement).innerHTML = table([
2241
- { label: "When", cell: (row) => esc(row.at) },
2242
- { label: "Method", cell: (row) => esc(row.method) },
2376
+ { label: "When", cell: (row) => esc(journalWhen(row.at)) },
2377
+ {
2378
+ label: "Method",
2379
+ cell: (row) => `<span class="method">${esc(row.method)}</span>`
2380
+ },
2243
2381
  { label: "Path", cell: (row) => esc(row.path) },
2244
- { label: "Status", cell: (row) => esc(row.status) },
2382
+ {
2383
+ label: "Status",
2384
+ cell: (row) => `<span class="badge ${row.status.startsWith("2") ? "ok" : row.status.startsWith("4") || row.status.startsWith("5") ? "warn" : ""}">${esc(row.status)}</span>`
2385
+ },
2245
2386
  { label: "ms", cell: (row) => esc(row.durationMs) }
2246
2387
  ], decodeJournal(journal));
2247
2388
  };
2248
- const renderRoutes = async () => {
2249
- const data = await api("GET", "/");
2250
- if (!isRecord2(data))
2251
- throw new Error("Route list was not an object");
2252
- const routes = requireList(data.routes, "Routes").map((route) => String(route));
2389
+ const paintRoutes = () => {
2390
+ const query = el("route-search", HTMLInputElement).value.trim().toLocaleLowerCase();
2391
+ const routes = state.routes.filter((route) => route.toLocaleLowerCase().includes(query));
2392
+ el("route-count", HTMLElement).textContent = `${routes.length} of ${state.routes.length}`;
2393
+ if (state.routes.length === 0) {
2394
+ el("route-list", HTMLElement).innerHTML = '<p class="empty">No admin routes are available.</p>';
2395
+ return;
2396
+ }
2397
+ if (routes.length === 0) {
2398
+ el("route-list", HTMLElement).innerHTML = '<p class="empty">No routes match this filter.</p>';
2399
+ return;
2400
+ }
2253
2401
  el("route-list", HTMLElement).innerHTML = routes.map((route) => {
2254
2402
  const extra = standard.has(route) ? "" : " <span class='badge'>extension</span>";
2255
2403
  const parts = route.split(" ");
2256
2404
  const method = parts[0] ?? "";
2257
2405
  const path = parts.slice(1).join(" ");
2258
- 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>`;
2406
+ 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>`;
2259
2407
  }).join("");
2260
2408
  };
2409
+ const renderRoutes = async () => {
2410
+ const data = await api("GET", "/");
2411
+ if (!isRecord2(data))
2412
+ throw new Error("Route list was not an object");
2413
+ state.routes = requireList(data.routes, "Routes").map((route) => String(route));
2414
+ paintRoutes();
2415
+ };
2261
2416
  const renderers = {
2262
2417
  overview: renderOverview,
2263
2418
  state: renderState,
@@ -2266,19 +2421,50 @@ function bootAdmin(config) {
2266
2421
  journal: renderJournal,
2267
2422
  routes: renderRoutes
2268
2423
  };
2424
+ let refreshToken = 0;
2269
2425
  async function refresh() {
2426
+ const token = ++refreshToken;
2270
2427
  clearError();
2428
+ const main = el("views", HTMLElement);
2429
+ const sync = el("sync-status", HTMLElement);
2430
+ main.setAttribute("aria-busy", "true");
2431
+ sync.classList.add("busy");
2432
+ sync.textContent = "Updating";
2271
2433
  const render2 = renderers[state.view];
2272
- if (render2)
2273
- await render2();
2434
+ try {
2435
+ if (render2)
2436
+ await render2();
2437
+ if (token === refreshToken)
2438
+ sync.textContent = "Up to date";
2439
+ } finally {
2440
+ if (token === refreshToken) {
2441
+ main.removeAttribute("aria-busy");
2442
+ sync.classList.remove("busy");
2443
+ }
2444
+ }
2274
2445
  }
2275
2446
  const delay = (ms) => new Promise((resolve) => {
2276
2447
  window.setTimeout(resolve, ms);
2277
2448
  });
2278
- const confirmAction = async (button, pending, done, work) => {
2449
+ const confirmAction = async (button, pending, done, work, confirmLabel) => {
2279
2450
  if (button.dataset.pending === "1")
2280
2451
  return;
2281
2452
  const label = button.textContent ?? "";
2453
+ if (confirmLabel !== void 0 && button.dataset.confirming !== "1") {
2454
+ button.dataset.confirming = "1";
2455
+ button.classList.add("confirming");
2456
+ button.textContent = confirmLabel;
2457
+ window.setTimeout(() => {
2458
+ if (button.dataset.confirming !== "1")
2459
+ return;
2460
+ button.textContent = label;
2461
+ button.classList.remove("confirming");
2462
+ delete button.dataset.confirming;
2463
+ }, 3e3);
2464
+ return;
2465
+ }
2466
+ button.classList.remove("confirming");
2467
+ delete button.dataset.confirming;
2282
2468
  const width = button.offsetWidth;
2283
2469
  button.dataset.pending = "1";
2284
2470
  button.classList.add("pressed");
@@ -2299,6 +2485,65 @@ function bootAdmin(config) {
2299
2485
  delete button.dataset.pending;
2300
2486
  }
2301
2487
  };
2488
+ const wireJsonEditor = (textareaId, highlightId, statusId, shellId, allowEmpty) => {
2489
+ const textarea = el(textareaId, HTMLTextAreaElement);
2490
+ const highlight = el(highlightId, HTMLElement);
2491
+ const status = el(statusId, HTMLElement);
2492
+ const shell = el(shellId, HTMLElement);
2493
+ const sync = () => {
2494
+ const text = textarea.value;
2495
+ highlight.innerHTML = `${highlightJsonText(text)}
2496
+ `;
2497
+ let valid = true;
2498
+ let message = "Valid JSON";
2499
+ if (allowEmpty && text.trim() === "")
2500
+ message = "No body";
2501
+ else {
2502
+ try {
2503
+ readJson3(text);
2504
+ } catch {
2505
+ valid = false;
2506
+ message = "Invalid JSON";
2507
+ }
2508
+ }
2509
+ shell.classList.toggle("invalid", !valid);
2510
+ status.classList.toggle("invalid", !valid);
2511
+ status.textContent = message;
2512
+ textarea.setAttribute("aria-invalid", valid ? "false" : "true");
2513
+ };
2514
+ const syncScroll = () => {
2515
+ const preview2 = highlight.parentElement;
2516
+ if (!(preview2 instanceof HTMLElement))
2517
+ return;
2518
+ preview2.scrollTop = textarea.scrollTop;
2519
+ preview2.scrollLeft = textarea.scrollLeft;
2520
+ };
2521
+ textarea.addEventListener("input", sync);
2522
+ textarea.addEventListener("scroll", syncScroll);
2523
+ sync();
2524
+ return sync;
2525
+ };
2526
+ const syncRouteEditor = wireJsonEditor("route-body", "route-highlight", "route-json-status", "route-editor", true);
2527
+ const syncRecordEditor = wireJsonEditor("editor-value", "editor-highlight", "editor-json-status", "record-editor", false);
2528
+ for (const button of document.querySelectorAll("[data-format]")) {
2529
+ if (!(button instanceof HTMLButtonElement))
2530
+ continue;
2531
+ button.addEventListener("click", () => {
2532
+ const target = button.dataset.format;
2533
+ if (target === void 0)
2534
+ return;
2535
+ const textarea = document.getElementById(target);
2536
+ if (!(textarea instanceof HTMLTextAreaElement) || textarea.value.trim() === "")
2537
+ return;
2538
+ try {
2539
+ textarea.value = JSON.stringify(readJson3(textarea.value), null, 2) ?? "";
2540
+ textarea.dispatchEvent(new Event("input"));
2541
+ textarea.focus();
2542
+ } catch {
2543
+ showError(new Error("Fix the JSON before formatting it"));
2544
+ }
2545
+ });
2546
+ }
2302
2547
  document.addEventListener("click", (event) => {
2303
2548
  const target = event.target;
2304
2549
  if (!(target instanceof Element))
@@ -2371,6 +2616,7 @@ function bootAdmin(config) {
2371
2616
  } else {
2372
2617
  editorValue.value = "{\n \n}";
2373
2618
  }
2619
+ syncRecordEditor();
2374
2620
  editor.showModal();
2375
2621
  }
2376
2622
  el("new-record", HTMLButtonElement).addEventListener("click", () => {
@@ -2400,10 +2646,10 @@ function bootAdmin(config) {
2400
2646
  const id = state.editing;
2401
2647
  if (!id)
2402
2648
  return;
2403
- api("DELETE", `/state/${encodeURIComponent(state.collection)}/${encodeURIComponent(id)}`).then(() => {
2649
+ confirmAction(editorDelete, "Deleting...", "Deleted", () => api("DELETE", `/state/${encodeURIComponent(state.collection)}/${encodeURIComponent(id)}`).then(() => {
2404
2650
  editor.close();
2405
2651
  return renderState();
2406
- }).catch(showError);
2652
+ }), "Confirm delete").catch(showError);
2407
2653
  });
2408
2654
  for (const button of document.querySelectorAll("[data-advance]")) {
2409
2655
  if (!(button instanceof HTMLButtonElement))
@@ -2449,10 +2695,10 @@ function bootAdmin(config) {
2449
2695
  }).then(() => renderFaults()).catch(showError);
2450
2696
  });
2451
2697
  el("fault-clear", HTMLButtonElement).addEventListener("click", () => {
2452
- confirmAction(el("fault-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/faults").then(() => renderFaults())).catch(showError);
2698
+ confirmAction(el("fault-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/faults").then(() => renderFaults()), "Confirm clear").catch(showError);
2453
2699
  });
2454
2700
  el("journal-clear", HTMLButtonElement).addEventListener("click", () => {
2455
- confirmAction(el("journal-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/requests").then(() => renderJournal())).catch(showError);
2701
+ confirmAction(el("journal-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/requests").then(() => renderJournal()), "Confirm clear").catch(showError);
2456
2702
  });
2457
2703
  el("route-list", HTMLElement).addEventListener("click", (event) => {
2458
2704
  const target = event.target;
@@ -2466,9 +2712,15 @@ function bootAdmin(config) {
2466
2712
  return;
2467
2713
  state.route = route;
2468
2714
  el("route-title", HTMLElement).textContent = route;
2715
+ el("route-send", HTMLButtonElement).disabled = false;
2469
2716
  const method = route.split(" ")[0] ?? "";
2470
- el("route-body", HTMLTextAreaElement).disabled = method === "GET" || method === "DELETE";
2717
+ const routeBody = el("route-body", HTMLTextAreaElement);
2718
+ routeBody.disabled = method === "GET" || method === "DELETE";
2719
+ el("route-editor", HTMLElement).hidden = routeBody.disabled;
2720
+ el("route-json-status", HTMLElement).parentElement?.toggleAttribute("hidden", routeBody.disabled);
2721
+ syncRouteEditor();
2471
2722
  });
2723
+ el("route-search", HTMLInputElement).addEventListener("input", paintRoutes);
2472
2724
  el("route-form", HTMLFormElement).addEventListener("submit", (event) => {
2473
2725
  event.preventDefault();
2474
2726
  const send = async () => {
@@ -2490,7 +2742,8 @@ function bootAdmin(config) {
2490
2742
  }
2491
2743
  }
2492
2744
  const data = await api(method, concrete, body);
2493
- el("route-result", HTMLElement).textContent = JSON.stringify(data, null, 2);
2745
+ const text = JSON.stringify(data, null, 2) ?? "null";
2746
+ el("route-result", HTMLElement).innerHTML = `<code>${highlightJsonText(text)}</code>`;
2494
2747
  };
2495
2748
  send().catch(showError);
2496
2749
  });
@@ -2608,11 +2861,13 @@ function bootAdmin(config) {
2608
2861
  views.append(section);
2609
2862
  mountSqlExplorer(body, panelApi);
2610
2863
  }
2864
+ show(document.getElementById(`view-${state.view}`) === null ? "overview" : state.view);
2611
2865
  return refresh();
2612
2866
  }).catch(showError);
2613
2867
  }
2614
2868
  var clientPrelude = [
2615
2869
  isRecord2,
2870
+ highlightJsonText,
2616
2871
  mountAdminBrand,
2617
2872
  startAdminBrand,
2618
2873
  faultPresetList,
@@ -2635,6 +2890,7 @@ ${entry}(${payload});
2635
2890
  var defineMock = (runtime) => runtime;
2636
2891
  var STANDARD_ADMIN_ROUTES = [
2637
2892
  "GET /",
2893
+ "GET /health",
2638
2894
  "POST /reset",
2639
2895
  "GET /namespaces",
2640
2896
  "GET /clock",
@@ -2714,11 +2970,11 @@ var escapeHtml = (value) => value.replace(/[&<>"']/g, (char) => {
2714
2970
  }
2715
2971
  });
2716
2972
  var ADMIN_BRANDS_URL = "https://mockingbird.chrisvouga.dev/brands.json";
2717
- var renderAdminDocument = (service) => {
2973
+ var renderAdminDocument = (service, adminPrefix = ADMIN_PREFIX) => {
2718
2974
  const name = escapeHtml(service);
2719
2975
  const script = adminClientSource({
2720
2976
  standardRoutes: STANDARD_ADMIN_ROUTES,
2721
- adminPrefix: ADMIN_PREFIX,
2977
+ adminPrefix,
2722
2978
  adminKeyHeader: ADMIN_KEY_HEADER,
2723
2979
  brandsUrl: ADMIN_BRANDS_URL,
2724
2980
  service
@@ -2735,75 +2991,99 @@ var renderAdminDocument = (service) => {
2735
2991
  ${CSS_RESET}
2736
2992
  :root {
2737
2993
  color-scheme: light;
2738
- --bg: #fafafa;
2994
+ --bg: #f7f8fb;
2739
2995
  --bg-elev: #ffffff;
2740
- --bg-muted: #f4f4f5;
2741
- --bg-inset: #f4f4f5;
2742
- --line: #dedee3;
2743
- --line-strong: #c4c4cc;
2744
- --ink: #18181b;
2745
- --muted: #62626b;
2746
- --faint: #71717a;
2747
- --accent: #27272a;
2996
+ --bg-muted: #f1f3f7;
2997
+ --bg-inset: #f8f9fc;
2998
+ --line: #e4e7ec;
2999
+ --line-strong: #cfd4dc;
3000
+ --ink: #101828;
3001
+ --muted: #475467;
3002
+ --faint: #667085;
3003
+ --accent: #6941c6;
3004
+ --accent-hover: #5933b4;
2748
3005
  --accent-ink: #ffffff;
2749
- --accent-soft: #f4f4f5;
2750
- --ok: #166534;
2751
- --ok-soft: #f0fdf4;
2752
- --warn: #a16207;
2753
- --warn-soft: #fefce8;
2754
- --err: #a82d32;
2755
- --err-soft: #fef2f2;
2756
- --shadow: none;
3006
+ --accent-soft: #f4f0ff;
3007
+ --ok: #067647;
3008
+ --ok-soft: #ecfdf3;
3009
+ --warn: #b54708;
3010
+ --warn-soft: #fffaeb;
3011
+ --err: #b42318;
3012
+ --err-soft: #fef3f2;
3013
+ --code-key: #6941c6;
3014
+ --code-string: #067647;
3015
+ --code-number: #175cd3;
3016
+ --code-boolean: #c11574;
3017
+ --code-null: #667085;
3018
+ --shadow-xs: 0 1px 2px rgb(16 24 40 / 0.05);
3019
+ --shadow-sm: 0 1px 3px rgb(16 24 40 / 0.08), 0 1px 2px rgb(16 24 40 / 0.04);
3020
+ --shadow-lg: 0 18px 48px rgb(16 24 40 / 0.16), 0 4px 12px rgb(16 24 40 / 0.08);
2757
3021
  --sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
2758
3022
  --mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;
2759
- --radius: 10px;
2760
- --header: 56px;
2761
- --nav: 200px;
3023
+ --radius: 12px;
3024
+ --header: 72px;
3025
+ --nav: 224px;
2762
3026
  }
2763
3027
  :root[data-theme="dark"] {
2764
3028
  color-scheme: dark;
2765
- --bg: #111113;
2766
- --bg-elev: #19191c;
2767
- --bg-muted: #242428;
2768
- --bg-inset: #111113;
2769
- --line: #36363c;
2770
- --line-strong: #52525b;
2771
- --ink: #f4f4f5;
2772
- --muted: #a9a9b2;
2773
- --faint: #71717a;
2774
- --accent: #e4e4e7;
2775
- --accent-ink: #18181b;
2776
- --accent-soft: #242428;
2777
- --ok: #86efac;
2778
- --ok-soft: #14241b;
2779
- --warn: #fde68a;
2780
- --warn-soft: #2a2410;
2781
- --err: #ffaaaa;
2782
- --err-soft: #2c1516;
2783
- --shadow: none;
3029
+ --bg: #0c0e14;
3030
+ --bg-elev: #151821;
3031
+ --bg-muted: #20232e;
3032
+ --bg-inset: #11141c;
3033
+ --line: #2b303d;
3034
+ --line-strong: #414858;
3035
+ --ink: #f5f7fa;
3036
+ --muted: #b6bdc9;
3037
+ --faint: #8d96a7;
3038
+ --accent: #9e77ed;
3039
+ --accent-hover: #b692f6;
3040
+ --accent-ink: #ffffff;
3041
+ --accent-soft: #2b2142;
3042
+ --ok: #75e0a7;
3043
+ --ok-soft: #102a20;
3044
+ --warn: #fec84b;
3045
+ --warn-soft: #302611;
3046
+ --err: #fda29b;
3047
+ --err-soft: #321817;
3048
+ --code-key: #c3a6ff;
3049
+ --code-string: #75e0a7;
3050
+ --code-number: #84adff;
3051
+ --code-boolean: #f9a8d4;
3052
+ --code-null: #98a2b3;
3053
+ --shadow-xs: 0 1px 2px rgb(0 0 0 / 0.24);
3054
+ --shadow-sm: 0 4px 12px rgb(0 0 0 / 0.2);
3055
+ --shadow-lg: 0 20px 56px rgb(0 0 0 / 0.45);
2784
3056
  }
2785
3057
  @media (prefers-color-scheme: dark) {
2786
3058
  :root:not([data-theme="light"]) {
2787
3059
  color-scheme: dark;
2788
- --bg: #111113;
2789
- --bg-elev: #19191c;
2790
- --bg-muted: #242428;
2791
- --bg-inset: #111113;
2792
- --line: #36363c;
2793
- --line-strong: #52525b;
2794
- --ink: #f4f4f5;
2795
- --muted: #a9a9b2;
2796
- --faint: #71717a;
2797
- --accent: #e4e4e7;
2798
- --accent-ink: #18181b;
2799
- --accent-soft: #242428;
2800
- --ok: #86efac;
2801
- --ok-soft: #14241b;
2802
- --warn: #fde68a;
2803
- --warn-soft: #2a2410;
2804
- --err: #ffaaaa;
2805
- --err-soft: #2c1516;
2806
- --shadow: none;
3060
+ --bg: #0c0e14;
3061
+ --bg-elev: #151821;
3062
+ --bg-muted: #20232e;
3063
+ --bg-inset: #11141c;
3064
+ --line: #2b303d;
3065
+ --line-strong: #414858;
3066
+ --ink: #f5f7fa;
3067
+ --muted: #b6bdc9;
3068
+ --faint: #8d96a7;
3069
+ --accent: #9e77ed;
3070
+ --accent-hover: #b692f6;
3071
+ --accent-ink: #ffffff;
3072
+ --accent-soft: #2b2142;
3073
+ --ok: #75e0a7;
3074
+ --ok-soft: #102a20;
3075
+ --warn: #fec84b;
3076
+ --warn-soft: #302611;
3077
+ --err: #fda29b;
3078
+ --err-soft: #321817;
3079
+ --code-key: #c3a6ff;
3080
+ --code-string: #75e0a7;
3081
+ --code-number: #84adff;
3082
+ --code-boolean: #f9a8d4;
3083
+ --code-null: #98a2b3;
3084
+ --shadow-xs: 0 1px 2px rgb(0 0 0 / 0.24);
3085
+ --shadow-sm: 0 4px 12px rgb(0 0 0 / 0.2);
3086
+ --shadow-lg: 0 20px 56px rgb(0 0 0 / 0.45);
2807
3087
  }
2808
3088
  }
2809
3089
  /* Fill the viewport, then grow with the content so the background covers the scroll. */
@@ -2821,13 +3101,17 @@ var renderAdminDocument = (service) => {
2821
3101
  padding-right: env(safe-area-inset-right);
2822
3102
  -webkit-text-size-adjust: 100%;
2823
3103
  }
2824
- :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
3104
+ ::selection { background: color-mix(in srgb, var(--accent) 28%, transparent); }
3105
+ :focus-visible { outline: 3px solid color-mix(in srgb, var(--accent) 45%, transparent); outline-offset: 2px; }
3106
+ button, input, select, textarea { transition: border-color 140ms ease, box-shadow 140ms ease, transform 100ms ease; }
3107
+ button:disabled, input:disabled, select:disabled, textarea:disabled { cursor: not-allowed; opacity: 0.55; }
2825
3108
  .app { flex: 1 0 auto; display: flex; flex-direction: column; min-width: 0; width: 100%; }
2826
3109
  header.top {
2827
- position: sticky; top: 0; z-index: 5;
3110
+ position: sticky; top: 0; z-index: 20;
2828
3111
  display: flex; align-items: center; gap: 12px;
2829
- min-height: var(--header); padding: 10px 16px;
2830
- background: var(--bg-elev);
3112
+ min-height: var(--header); padding: 12px 20px;
3113
+ background: color-mix(in srgb, var(--bg-elev) 92%, transparent);
3114
+ backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
2831
3115
  border-bottom: 1px solid var(--line);
2832
3116
  min-width: 0; max-width: 100%;
2833
3117
  }
@@ -2835,12 +3119,21 @@ var renderAdminDocument = (service) => {
2835
3119
  display: flex; flex-flow: row wrap; align-items: center; gap: 14px;
2836
3120
  min-width: 0; max-width: 100%; margin-right: auto;
2837
3121
  }
2838
- .brand-id { display: flex; flex-direction: column; min-width: 0; flex: none; }
2839
- .brand-id strong { font-size: 15px; letter-spacing: -0.01em; }
3122
+ .brand-lockup { display: flex; align-items: center; gap: 10px; min-width: 0; }
3123
+ .brand-mark {
3124
+ width: 38px; height: 38px; display: grid; place-items: center; flex: none;
3125
+ border-radius: 11px; color: #fff;
3126
+ background: linear-gradient(145deg, #7f56d9 0%, #53389e 100%);
3127
+ box-shadow: 0 6px 16px rgb(105 65 198 / 0.24), inset 0 1px 0 rgb(255 255 255 / 0.25);
3128
+ }
3129
+ .brand-mark svg { width: 22px; height: 22px; }
3130
+ .brand-id { display: flex; flex-direction: column; min-width: 0; flex: none; line-height: 1.25; }
3131
+ .brand-id strong { font-size: 15px; letter-spacing: -0.015em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
2840
3132
  .brand-id span { color: var(--faint); font-size: 12px; }
2841
3133
  .vendor {
2842
3134
  display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
2843
3135
  min-width: 0; max-width: 100%; font-size: 12px; color: var(--muted);
3136
+ padding: 5px 8px; border: 1px solid var(--line); border-radius: 999px; background: var(--bg-inset);
2844
3137
  }
2845
3138
  .vendor[hidden] { display: none; }
2846
3139
  /* Logos are drawn for a light tile, same as the docs site, so the plate stays white. */
@@ -2852,12 +3145,17 @@ var renderAdminDocument = (service) => {
2852
3145
  .vendor-sep { color: var(--faint); }
2853
3146
  .vendor-link { color: inherit; text-decoration: none; }
2854
3147
  .vendor-link:hover { color: var(--ink); text-decoration: underline; }
2855
- .controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
2856
- label.field { display: flex; flex-direction: column; gap: 2px; font-size: 11px; color: var(--faint); }
3148
+ .controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: flex-end; }
3149
+ .sync-status { align-self: center; display: flex; align-items: center; gap: 6px; color: var(--faint); font-size: 12px; white-space: nowrap; }
3150
+ .sync-status::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--ok); box-shadow: 0 0 0 3px var(--ok-soft); }
3151
+ .sync-status.busy::before { background: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); animation: pulse 1.2s ease-in-out infinite; }
3152
+ label.field { display: flex; flex-direction: column; gap: 4px; font-size: 11px; font-weight: 600; color: var(--faint); }
2857
3153
  label.field input, label.field select, .controls input[type="password"] {
2858
- background-color: var(--bg-inset); border: 1px solid var(--line); border-radius: 8px;
2859
- padding: 7px 8px; min-height: 36px; line-height: 1.2;
3154
+ background-color: var(--bg-elev); border: 1px solid var(--line-strong); border-radius: 9px;
3155
+ padding: 8px 10px; min-height: 38px; line-height: 1.2; box-shadow: var(--shadow-xs);
2860
3156
  }
3157
+ label.field input:hover, label.field select:hover, textarea:hover { border-color: color-mix(in srgb, var(--accent) 38%, var(--line-strong)); }
3158
+ 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; }
2861
3159
  /* A native menu ignores min-height, so Preset rendered about half as tall as
2862
3160
  the text fields in the same form. Paint it like those fields. */
2863
3161
  label.field select {
@@ -2874,12 +3172,20 @@ var renderAdminDocument = (service) => {
2874
3172
  dialog .field > textarea { width: 100%; }
2875
3173
  .icon-btn, .btn, .btn-primary, .btn-quiet, .btn-danger, nav.side button { cursor: pointer; }
2876
3174
  .icon-btn, .btn, .btn-primary, .btn-quiet, .btn-danger {
2877
- border-radius: 9px; min-height: 36px; padding: 0 12px; border: 1px solid var(--line);
2878
- background: var(--bg-elev);
2879
- }
2880
- .btn-primary { background: var(--accent); color: var(--accent-ink); border-color: transparent; }
2881
- .btn-quiet { background: transparent; }
2882
- .btn-danger { color: var(--err); }
3175
+ display: inline-flex; align-items: center; justify-content: center; gap: 7px;
3176
+ border-radius: 9px; min-height: 38px; padding: 0 13px; border: 1px solid var(--line-strong);
3177
+ background: var(--bg-elev); color: var(--ink); font-size: 13px; font-weight: 600; line-height: 1;
3178
+ box-shadow: var(--shadow-xs);
3179
+ }
3180
+ .icon-btn { width: 38px; padding: 0; }
3181
+ .icon-btn svg, .btn svg, .btn-primary svg { width: 16px; height: 16px; }
3182
+ .icon-btn:hover, .btn:hover { background: var(--bg-muted); border-color: var(--line-strong); }
3183
+ .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; }
3184
+ .btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
3185
+ .btn-quiet { background: transparent; border-color: transparent; box-shadow: none; }
3186
+ .btn-quiet:hover { background: var(--bg-muted); }
3187
+ .btn-danger { color: var(--err); border-color: color-mix(in srgb, var(--err) 25%, var(--line)); }
3188
+ .btn-danger:hover, .btn-danger.confirming { background: var(--err-soft); border-color: color-mix(in srgb, var(--err) 45%, var(--line)); }
2883
3189
  /* Held after mouseup. Clearing an empty journal changes nothing on screen,
2884
3190
  so the press itself has to stay visible. */
2885
3191
  .icon-btn:active, .btn:active, .btn-quiet:active, .btn-danger:active, nav.side button:active,
@@ -2895,93 +3201,159 @@ var renderAdminDocument = (service) => {
2895
3201
  flex: 1 0 auto; min-width: 0;
2896
3202
  }
2897
3203
  nav.side {
2898
- border-right: 1px solid var(--line); padding: 12px;
3204
+ position: sticky; top: var(--header); align-self: start; height: calc(100vh - var(--header));
3205
+ border-right: 1px solid var(--line); padding: 18px 12px;
2899
3206
  display: flex; flex-direction: column; gap: 4px;
2900
- background: var(--bg-elev);
3207
+ background: var(--bg-elev); overflow-y: auto;
2901
3208
  }
3209
+ .nav-label { padding: 0 10px 8px; color: var(--faint); font-size: 10px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; }
2902
3210
  nav.side button {
3211
+ position: relative; display: flex; align-items: center; gap: 10px; width: 100%;
2903
3212
  text-align: left; border: 0; background: transparent; border-radius: 9px;
2904
- padding: 9px 10px; min-height: 40px; color: var(--muted);
2905
- }
2906
- nav.side button[aria-current="true"] { background: var(--accent-soft); color: var(--ink); font-weight: 600; }
2907
- main { padding: 20px; max-width: 1100px; width: 100%; min-width: 0; }
2908
- .view h2 { margin: 0 0 4px; font-size: 22px; letter-spacing: -0.02em; }
2909
- .lede { margin: 0 0 16px; color: var(--muted); }
2910
- .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr)); gap: 12px; }
3213
+ padding: 9px 10px; min-height: 42px; color: var(--muted); font-size: 13px; font-weight: 550;
3214
+ }
3215
+ nav.side button:hover { background: var(--bg-muted); color: var(--ink); }
3216
+ nav.side button[aria-current="true"] { background: var(--accent-soft); color: var(--accent); font-weight: 650; }
3217
+ 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); }
3218
+ .nav-icon { width: 18px; height: 18px; display: grid; place-items: center; flex: none; }
3219
+ .nav-icon svg { width: 18px; height: 18px; }
3220
+ main { padding: 32px clamp(20px, 4vw, 48px) 64px; max-width: 1320px; width: 100%; min-width: 0; }
3221
+ main[aria-busy="true"] .view { opacity: 0.62; }
3222
+ .view { animation: view-in 180ms ease-out; }
3223
+ .view[hidden] { display: none; }
3224
+ .view-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 22px; }
3225
+ .view h2 { margin: 0 0 5px; font-size: clamp(24px, 3vw, 30px); line-height: 1.2; letter-spacing: -0.035em; }
3226
+ .view h3 { margin: 24px 0 10px; font-size: 15px; letter-spacing: -0.01em; }
3227
+ .lede { margin: 0; color: var(--muted); max-width: 72ch; font-size: 14px; }
3228
+ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 175px), 1fr)); gap: 14px; }
2911
3229
  .card {
2912
3230
  background: var(--bg-elev); border: 1px solid var(--line); border-radius: var(--radius);
2913
- padding: 14px 16px; box-shadow: var(--shadow);
3231
+ padding: 18px; box-shadow: var(--shadow-sm);
2914
3232
  }
2915
- .card .k { color: var(--faint); font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; }
2916
- .card .v { display: flex; flex-direction: column; gap: 2px; font-size: 22px; margin-top: 4px; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
3233
+ .stat-card { position: relative; overflow: hidden; }
3234
+ .stat-card::after { content: ""; position: absolute; inset: 0 0 auto; height: 2px; background: linear-gradient(90deg, var(--accent), transparent 78%); opacity: 0.7; }
3235
+ .card .k { color: var(--faint); font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; }
3236
+ .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; }
2917
3237
  .card .v .clock-time { font-size: 14px; color: var(--muted); }
2918
- .split { display: grid; grid-template-columns: minmax(0, 240px) minmax(0, 1fr); gap: 16px; align-items: start; }
3238
+ .section-card { background: var(--bg-elev); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow-xs); overflow: hidden; }
3239
+ .section-title { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 16px 18px; border-bottom: 1px solid var(--line); }
3240
+ .section-title h3 { margin: 0; }
3241
+ .split { display: grid; grid-template-columns: minmax(0, 250px) minmax(0, 1fr); gap: 18px; align-items: start; }
3242
+ #collections { padding: 6px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg-elev); box-shadow: var(--shadow-xs); }
2919
3243
  .collection {
2920
3244
  width: 100%; text-align: left; border: 1px solid transparent; background: transparent;
2921
- border-radius: 10px; padding: 8px 10px; min-height: 44px;
3245
+ border-radius: 9px; padding: 10px 11px; min-height: 48px; color: var(--ink); cursor: pointer;
2922
3246
  }
2923
- .collection[aria-current="true"] { background: var(--bg-elev); border-color: var(--line); }
3247
+ .collection:hover { background: var(--bg-muted); }
3248
+ .collection[aria-current="true"] { background: var(--accent-soft); border-color: color-mix(in srgb, var(--accent) 18%, var(--line)); color: var(--accent); }
2924
3249
  .collection small { display: block; color: var(--faint); }
2925
3250
  .chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 14px; }
2926
- .chip {
2927
- font-family: var(--mono); font-size: 12px; background: var(--bg-muted);
2928
- border-radius: 999px; padding: 4px 8px;
2929
- }
3251
+ .chip { font-family: var(--mono); font-size: 11px; background: var(--bg-muted); border: 1px solid var(--line); border-radius: 999px; padding: 4px 8px; }
2930
3252
  .chip i { color: var(--faint); font-style: normal; }
2931
3253
  .sql-layout {
2932
3254
  display: grid; grid-template-columns: minmax(10rem, 16rem) minmax(0, 1fr); gap: 16px;
2933
3255
  }
2934
- .sql-tables { display: flex; flex-direction: column; gap: 4px; max-height: 28rem; overflow: auto; }
2935
- .sql-tables button { text-align: left; }
3256
+ .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); }
3257
+ .sql-tables button { text-align: left; border: 0; border-radius: 8px; padding: 9px 10px; background: transparent; color: var(--muted); cursor: pointer; }
3258
+ .sql-tables button:hover, .sql-tables button[aria-current="true"] { background: var(--accent-soft); color: var(--accent); }
2936
3259
  .sql-input { font-family: var(--mono); min-height: 7rem; width: 100%; }
2937
- table { width: 100%; border-collapse: collapse; font-size: 14px; }
2938
- th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--line); vertical-align: top; }
2939
- th { color: var(--faint); font-weight: 600; font-size: 12px; }
3260
+ .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); }
3261
+ table { width: 100%; border-collapse: collapse; font-size: 13px; }
3262
+ th, td { text-align: left; padding: 11px 13px; border-bottom: 1px solid var(--line); vertical-align: top; }
3263
+ th { color: var(--faint); background: var(--bg-inset); font-weight: 650; font-size: 11px; text-transform: uppercase; letter-spacing: 0.045em; }
3264
+ tbody tr:last-child td { border-bottom: 0; }
2940
3265
  tr.clickable { cursor: pointer; }
2941
- tr.clickable:hover { background: var(--bg-muted); }
3266
+ tr.clickable:hover { background: var(--accent-soft); }
2942
3267
  pre, code, .mono { font-family: var(--mono); overflow-wrap: anywhere; }
2943
3268
  pre {
2944
3269
  margin: 0; white-space: pre-wrap; word-break: break-word;
2945
- background: var(--bg-inset); border-radius: 10px; padding: 12px; font-size: 12px;
2946
- }
2947
- .row-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0; }
3270
+ background: var(--bg-inset); border: 1px solid var(--line); border-radius: 10px; padding: 14px; font-size: 12px; line-height: 1.65;
3271
+ }
3272
+ .json-key { color: var(--code-key); }
3273
+ .json-string { color: var(--code-string); }
3274
+ .json-number { color: var(--code-number); }
3275
+ .json-boolean { color: var(--code-boolean); }
3276
+ .json-null { color: var(--code-null); font-style: italic; }
3277
+ .json-inline { display: block; max-width: 44rem; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
3278
+ .json-view { position: relative; max-height: 26rem; overflow: auto; }
3279
+ .json-editor { position: relative; min-height: 190px; border: 1px solid var(--line-strong); border-radius: 10px; background: var(--bg-inset); overflow: hidden; }
3280
+ .json-editor:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 15%, transparent); }
3281
+ .json-editor.invalid { border-color: var(--err); }
3282
+ .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; }
3283
+ .json-editor pre { position: absolute; inset: 0; pointer-events: none; background: transparent; }
3284
+ .json-editor textarea { position: relative; z-index: 1; resize: vertical; background: transparent; color: transparent; caret-color: var(--ink); -webkit-text-fill-color: transparent; }
3285
+ .json-editor textarea::selection { background: color-mix(in srgb, var(--accent) 32%, transparent); }
3286
+ .editor-meta { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 7px; }
3287
+ .json-status { display: inline-flex; align-items: center; gap: 6px; color: var(--ok); font-size: 11px; }
3288
+ .json-status::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
3289
+ .json-status.invalid { color: var(--err); }
3290
+ .format-json { border: 0; padding: 3px 5px; background: transparent; color: var(--accent); font-size: 11px; font-weight: 650; cursor: pointer; }
3291
+ .row-actions { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 8px; margin: 14px 0; }
2948
3292
  .banner {
2949
- display: none; margin-bottom: 12px; padding: 10px 12px; border-radius: 10px;
2950
- background: var(--err-soft); color: var(--err);
2951
- }
2952
- .banner.show { display: block; }
2953
- .empty { color: var(--muted); padding: 24px 8px; }
3293
+ display: none; position: sticky; top: calc(var(--header) + 12px); z-index: 15;
3294
+ align-items: center; gap: 10px; margin: 0 0 16px; padding: 11px 12px 11px 14px;
3295
+ border: 1px solid color-mix(in srgb, var(--err) 30%, var(--line)); border-radius: 10px;
3296
+ background: var(--err-soft); color: var(--err); box-shadow: var(--shadow-sm); font-size: 13px;
3297
+ }
3298
+ .banner.show { display: flex; }
3299
+ .banner span { flex: 1; }
3300
+ .banner button { width: 28px; height: 28px; display: grid; place-items: center; border: 0; border-radius: 7px; background: transparent; color: currentColor; cursor: pointer; }
3301
+ .banner button:hover { background: color-mix(in srgb, var(--err) 10%, transparent); }
3302
+ .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); }
2954
3303
  dialog {
2955
- border: 1px solid var(--line); border-radius: var(--radius); padding: 0; background: var(--bg-elev);
2956
- color: var(--ink); width: min(640px, calc(100% - 24px)); max-width: calc(100vw - 24px); box-shadow: none;
2957
- }
2958
- dialog::backdrop { background: rgb(20 19 17 / 0.45); }
2959
- dialog form, .dialog-body { padding: 16px; display: flex; flex-direction: column; gap: 10px; }
3304
+ border: 1px solid var(--line); border-radius: 16px; padding: 0; background: var(--bg-elev);
3305
+ color: var(--ink); width: min(720px, calc(100% - 24px)); max-width: calc(100vw - 24px); box-shadow: var(--shadow-lg);
3306
+ }
3307
+ dialog[open] { animation: dialog-in 160ms ease-out; }
3308
+ dialog::backdrop { background: rgb(12 14 20 / 0.58); backdrop-filter: blur(3px); }
3309
+ dialog form, .dialog-body { padding: 22px; display: flex; flex-direction: column; gap: 14px; }
3310
+ .dialog-heading { display: flex; justify-content: space-between; gap: 16px; padding-bottom: 12px; border-bottom: 1px solid var(--line); }
3311
+ .dialog-heading strong { font-size: 18px; letter-spacing: -0.02em; }
3312
+ .dialog-heading span { color: var(--muted); font-size: 12px; }
2960
3313
  textarea {
2961
3314
  width: 100%; min-height: 180px; resize: vertical; border-radius: 10px;
2962
3315
  border: 1px solid var(--line); background: var(--bg-inset); padding: 10px;
2963
3316
  font-family: var(--mono); font-size: 12px;
2964
3317
  }
2965
- .dialog-actions { display: flex; justify-content: flex-end; gap: 8px; }
2966
- .badge { font-size: 11px; border-radius: 999px; padding: 2px 7px; background: var(--bg-muted); color: var(--muted); }
3318
+ .dialog-actions { display: flex; justify-content: flex-end; align-items: center; gap: 8px; padding-top: 4px; }
3319
+ .dialog-actions .btn-danger { margin-right: auto; }
3320
+ .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); }
2967
3321
  .badge.ok { background: var(--ok-soft); color: var(--ok); }
2968
3322
  .badge.warn { background: var(--warn-soft); color: var(--warn); }
2969
3323
  .status-2 { color: var(--ok); }
2970
3324
  .status-4, .status-5, .status-0 { color: var(--err); }
2971
3325
  .stack { display: flex; flex-direction: column; gap: 12px; }
3326
+ .form-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
3327
+ .form-grid .wide { grid-column: span 2; }
2972
3328
  .route {
2973
- display: grid; grid-template-columns: 88px 1fr auto; gap: 8px; align-items: center;
2974
- padding: 8px 0; border-bottom: 1px solid var(--line);
2975
- }
2976
- .method { font-family: var(--mono); font-size: 12px; font-weight: 700; }
3329
+ display: grid; grid-template-columns: 84px minmax(0, 1fr) auto; gap: 10px; align-items: center;
3330
+ padding: 10px 12px; border-bottom: 1px solid var(--line); background: var(--bg-elev);
3331
+ }
3332
+ .route:first-child { border-radius: var(--radius) var(--radius) 0 0; }
3333
+ .route:last-child { border-bottom: 0; border-radius: 0 0 var(--radius) var(--radius); }
3334
+ .route-tools { display: flex; align-items: flex-end; gap: 12px; margin-bottom: 12px; }
3335
+ .route-tools .field { flex: 1; }
3336
+ .route-tools input { width: 100%; }
3337
+ .route-count { padding-bottom: 10px; color: var(--faint); font-size: 12px; white-space: nowrap; }
3338
+ #route-list { max-height: 34rem; border: 1px solid var(--line); border-radius: var(--radius); overflow: auto; box-shadow: var(--shadow-xs); }
3339
+ .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; }
3340
+ @keyframes view-in { from { transform: translateY(3px); } to { transform: translateY(0); } }
3341
+ @keyframes dialog-in { from { transform: translateY(8px) scale(0.99); } to { transform: translateY(0) scale(1); } }
3342
+ @keyframes pulse { 50% { opacity: 0.45; } }
3343
+ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } }
3344
+ @media (forced-colors: active) { .json-editor pre { display: none; } .json-editor textarea { color: CanvasText; -webkit-text-fill-color: CanvasText; } }
2977
3345
  @media (max-width: 800px) {
3346
+ :root { --header: 0px; }
2978
3347
  header.top {
2979
- align-items: stretch; flex-direction: column;
2980
- padding-top: max(10px, env(safe-area-inset-top));
3348
+ position: relative; align-items: stretch; flex-direction: column;
3349
+ padding: max(12px, env(safe-area-inset-top)) 14px 12px;
2981
3350
  }
2982
3351
  input, select, textarea { font-size: 16px; }
2983
3352
  .brand, .vendor, .controls { width: 100%; }
3353
+ .brand { gap: 10px; }
3354
+ .vendor { order: 2; border-radius: 9px; }
2984
3355
  label.field, label.field select, label.field input { flex: 1; min-width: 0; }
3356
+ .sync-status { display: none; }
2985
3357
  /* The layout fills the screen. A single-column grid stretches every auto
2986
3358
  row, so the tab bar grew with the leftover height and the buttons floated
2987
3359
  in the middle of it. The bar stays one row; the page below takes the rest. */
@@ -2991,14 +3363,23 @@ var renderAdminDocument = (service) => {
2991
3363
  align-content: start;
2992
3364
  }
2993
3365
  nav.side {
2994
- align-self: start; height: auto;
3366
+ position: sticky; top: 0; z-index: 12; align-self: start; height: auto;
2995
3367
  flex-direction: row; align-items: center; overflow-x: auto; min-width: 0;
2996
3368
  border-right: 0; border-bottom: 1px solid var(--line);
2997
- padding: 8px;
3369
+ padding: 8px; box-shadow: var(--shadow-xs);
2998
3370
  }
2999
- nav.side button { white-space: nowrap; flex: none; }
3371
+ .nav-label { display: none; }
3372
+ nav.side button { width: auto; white-space: nowrap; flex: none; }
3373
+ nav.side button[aria-current="true"]::before { inset: auto 9px -8px; width: auto; height: 3px; border-radius: 3px 3px 0 0; }
3000
3374
  .split, .sql-layout { grid-template-columns: minmax(0, 1fr); }
3001
- main { padding: 14px; }
3375
+ .form-grid { grid-template-columns: minmax(0, 1fr); }
3376
+ .form-grid .wide { grid-column: auto; }
3377
+ main { padding: 22px 14px 48px; }
3378
+ .view-head { margin-bottom: 18px; }
3379
+ .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
3380
+ .card { padding: 14px; }
3381
+ .card .v { font-size: 20px; }
3382
+ .table-shell { border: 0; background: transparent; box-shadow: none; overflow: visible; }
3002
3383
  table, thead, tbody, tr, th, td { display: block; }
3003
3384
  thead { display: none; }
3004
3385
  tr {
@@ -3013,50 +3394,63 @@ var renderAdminDocument = (service) => {
3013
3394
  .route { grid-template-columns: 72px 1fr; }
3014
3395
  .route button { grid-column: 1 / -1; }
3015
3396
  }
3397
+ @media (max-width: 460px) {
3398
+ .controls label.field { min-width: calc(50% - 4px); }
3399
+ .controls .icon-btn, .controls .btn { flex: 1; }
3400
+ .grid { grid-template-columns: minmax(0, 1fr); }
3401
+ .dialog-actions { flex-wrap: wrap; }
3402
+ .dialog-actions button { flex: 1; }
3403
+ .dialog-actions .btn-danger { flex-basis: 100%; margin-right: 0; }
3404
+ }
3016
3405
  </style>
3017
3406
  </head>
3018
3407
  <body>
3019
3408
  <div class="app" data-service="${name}" data-mockingbird-admin>
3020
3409
  <header class="top">
3021
3410
  <div class="brand">
3022
- <div class="brand-id">
3023
- <strong>${name}</strong>
3024
- <span>Admin</span>
3411
+ <div class="brand-lockup">
3412
+ <div class="brand-mark" aria-hidden="true">
3413
+ <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>
3414
+ </div>
3415
+ <div class="brand-id">
3416
+ <strong>${name}</strong>
3417
+ <span>Mock admin</span>
3418
+ </div>
3025
3419
  </div>
3026
3420
  <div class="vendor" id="vendor" hidden></div>
3027
3421
  </div>
3028
3422
  <div class="controls">
3423
+ <span class="sync-status" id="sync-status" role="status" aria-live="polite">Ready</span>
3029
3424
  <label class="field">Namespace
3030
3425
  <select id="namespace" aria-label="Namespace"></select>
3031
3426
  </label>
3032
3427
  <label class="field">Admin key
3033
3428
  <input id="admin-key" type="password" autocomplete="off" spellcheck="false" aria-label="Admin key">
3034
3429
  </label>
3035
- <button class="icon-btn" id="theme" type="button" aria-label="Toggle color theme">Theme</button>
3036
- <button class="btn" id="refresh" type="button">Refresh</button>
3430
+ <button class="icon-btn" id="theme" type="button" aria-label="Use dark theme" title="Use dark theme"></button>
3431
+ <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>
3037
3432
  </div>
3038
3433
  </header>
3039
3434
  <div class="layout">
3040
3435
  <nav class="side" id="nav" aria-label="Admin sections">
3041
- <button type="button" data-view="overview" aria-current="true">Overview</button>
3042
- <button type="button" data-view="state">State</button>
3043
- <button type="button" data-view="clock">Clock</button>
3044
- <button type="button" data-view="faults">Faults</button>
3045
- <button type="button" data-view="journal">Journal</button>
3046
- <button type="button" data-view="routes">Routes</button>
3436
+ <span class="nav-label">Workspace</span>
3437
+ <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>
3438
+ <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>
3439
+ <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>
3440
+ <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>
3441
+ <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>
3442
+ <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>
3047
3443
  </nav>
3048
3444
  <main id="views">
3049
- <p class="banner" id="banner" role="alert"></p>
3445
+ <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>
3050
3446
  <section class="view" id="view-overview">
3051
- <h2>Overview</h2>
3052
- <p class="lede">Health, clock, and the collections this namespace is holding.</p>
3447
+ <div class="view-head"><div><h2>Overview</h2><p class="lede">Health, clock, and the collections this namespace is holding.</p></div></div>
3053
3448
  <div class="grid" id="overview-cards"></div>
3054
3449
  <h3>Recent requests</h3>
3055
3450
  <div id="overview-requests"></div>
3056
3451
  </section>
3057
3452
  <section class="view" id="view-state" hidden>
3058
- <h2>State</h2>
3059
- <p class="lede">Every collection in this namespace. Declared fields stay visible before any row exists; stored rows add whatever else they contain.</p>
3453
+ <div class="view-head"><div><h2>State</h2><p class="lede">Inspect and edit every collection in this namespace.</p></div></div>
3060
3454
  <div class="split">
3061
3455
  <div id="collections"></div>
3062
3456
  <div>
@@ -3069,8 +3463,7 @@ var renderAdminDocument = (service) => {
3069
3463
  </div>
3070
3464
  </section>
3071
3465
  <section class="view" id="view-clock" hidden>
3072
- <h2>Clock</h2>
3073
- <p class="lede">The clock every timestamp in this mock reads.</p>
3466
+ <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>
3074
3467
  <div class="card" id="clock-readout"></div>
3075
3468
  <div class="row-actions">
3076
3469
  <button class="btn" type="button" data-advance="60000">+1m</button>
@@ -3089,21 +3482,22 @@ var renderAdminDocument = (service) => {
3089
3482
  </form>
3090
3483
  </section>
3091
3484
  <section class="view" id="view-faults" hidden>
3092
- <h2>Faults</h2>
3093
- <p class="lede">Inject a response, a delay, or a named preset. Rules apply to this namespace.</p>
3485
+ <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>
3094
3486
  <form id="fault-form" class="card stack">
3095
- <label class="field">Preset
3096
- <select id="fault-preset"><option value="">Custom rule</option></select>
3097
- </label>
3098
- <label class="field">Status
3099
- <input id="fault-status" inputmode="numeric" placeholder="503">
3100
- </label>
3101
- <label class="field">Path prefix
3102
- <input id="fault-path" placeholder="/v1">
3103
- </label>
3104
- <label class="field">Method
3105
- <input id="fault-method" placeholder="GET">
3106
- </label>
3487
+ <div class="form-grid">
3488
+ <label class="field wide">Preset
3489
+ <select id="fault-preset"><option value="">Custom rule</option></select>
3490
+ </label>
3491
+ <label class="field">Status
3492
+ <input id="fault-status" inputmode="numeric" placeholder="503">
3493
+ </label>
3494
+ <label class="field wide">Path prefix
3495
+ <input id="fault-path" placeholder="/v1">
3496
+ </label>
3497
+ <label class="field">Method
3498
+ <input id="fault-method" placeholder="GET">
3499
+ </label>
3500
+ </div>
3107
3501
  <div class="dialog-actions">
3108
3502
  <button class="btn-primary" type="submit">Add fault</button>
3109
3503
  <button class="btn-danger" id="fault-clear" type="button">Clear all</button>
@@ -3112,26 +3506,31 @@ var renderAdminDocument = (service) => {
3112
3506
  <div id="fault-list"></div>
3113
3507
  </section>
3114
3508
  <section class="view" id="view-journal" hidden>
3115
- <h2>Journal</h2>
3116
- <p class="lede">Requests this namespace has seen. Bodies are not stored.</p>
3117
- <div class="row-actions">
3118
- <button class="btn-danger" id="journal-clear" type="button">Clear journal</button>
3119
- </div>
3509
+ <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>
3120
3510
  <div id="journal"></div>
3121
3511
  </section>
3122
3512
  <section class="view" id="view-routes" hidden>
3123
- <h2>Routes</h2>
3124
- <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>
3513
+ <div class="view-head"><div><h2>Routes</h2><p class="lede">Explore shared controls and service-specific admin endpoints.</p></div></div>
3514
+ <div class="route-tools">
3515
+ <label class="field">Filter routes
3516
+ <input id="route-search" type="search" placeholder="Method, path, or extension" autocomplete="off">
3517
+ </label>
3518
+ <span class="route-count" id="route-count"></span>
3519
+ </div>
3125
3520
  <div id="route-list"></div>
3126
- <form id="route-form" class="card stack">
3521
+ <form id="route-form" class="card stack" style="margin-top: 18px">
3127
3522
  <strong id="route-title">Select a route</strong>
3128
3523
  <label class="field">JSON body
3129
- <textarea id="route-body" placeholder="{ }"></textarea>
3524
+ <div class="json-editor" id="route-editor" hidden>
3525
+ <pre aria-hidden="true"><code id="route-highlight"></code></pre>
3526
+ <textarea id="route-body" placeholder="{ }" spellcheck="false" aria-describedby="route-json-status" disabled></textarea>
3527
+ </div>
3528
+ <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>
3130
3529
  </label>
3131
3530
  <div class="dialog-actions">
3132
- <button class="btn-primary" type="submit">Send</button>
3531
+ <button class="btn-primary" id="route-send" type="submit" disabled>Send request</button>
3133
3532
  </div>
3134
- <pre id="route-result">Response will show here.</pre>
3533
+ <pre class="json-view" id="route-result"><code>Response will show here.</code></pre>
3135
3534
  </form>
3136
3535
  </section>
3137
3536
  </main>
@@ -3139,12 +3538,16 @@ var renderAdminDocument = (service) => {
3139
3538
  </div>
3140
3539
  <dialog id="editor">
3141
3540
  <form method="dialog" id="editor-form">
3142
- <strong id="editor-title">Record</strong>
3541
+ <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>
3143
3542
  <label class="field">Id
3144
3543
  <input id="editor-id" required>
3145
3544
  </label>
3146
3545
  <label class="field">JSON value
3147
- <textarea id="editor-value"></textarea>
3546
+ <div class="json-editor" id="record-editor">
3547
+ <pre aria-hidden="true"><code id="editor-highlight"></code></pre>
3548
+ <textarea id="editor-value" spellcheck="false" aria-describedby="editor-json-status"></textarea>
3549
+ </div>
3550
+ <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>
3148
3551
  </label>
3149
3552
  <div class="dialog-actions">
3150
3553
  <button class="btn-danger" id="editor-delete" type="button" value="delete">Delete</button>
@@ -3186,8 +3589,8 @@ var describeExtension = (extension) => {
3186
3589
  ...extension.script !== void 0 ? { script: extension.script } : {}
3187
3590
  };
3188
3591
  };
3189
- var adminUiRoutes = (service, ui) => {
3190
- const shell = () => renderAdminDocument(service);
3592
+ var adminUiRoutes = (service, ui, adminPrefix = ADMIN_PREFIX) => {
3593
+ const shell = () => renderAdminDocument(service, adminPrefix);
3191
3594
  const document3 = () => ui?.render ? ui.render({ service, defaultHtml: shell }) : shell();
3192
3595
  return {
3193
3596
  "GET /ui": () => htmlResponse(document3()),
@@ -3804,10 +4207,13 @@ var stateAdminRoutes = (routes) => {
3804
4207
  };
3805
4208
 
3806
4209
  // ../core/dist/version.js
3807
- var PACKAGE_VERSION = true ? "1.8.2" : UNRELEASED_VERSION;
4210
+ var PACKAGE_VERSION = true ? "2.0.0" : UNRELEASED_VERSION;
3808
4211
 
3809
4212
  // ../core/dist/webhooks.js
3810
- var json4 = (status, body) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
4213
+ var json4 = (status, body) => new Response(JSON.stringify(body), {
4214
+ status,
4215
+ headers: { "content-type": "application/json; charset=utf-8" }
4216
+ });
3811
4217
  var adminError3 = (status, message) => json4(status, { error: { type: "mockingbird_admin", message } });
3812
4218
  var isRecord5 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3813
4219
  var parseEndpoint = (value) => {
@@ -3915,7 +4321,6 @@ var AT_HEADER = "x-mockingbird-at";
3915
4321
  var CHECKPOINT_HEADER = "x-mockingbird-checkpoint";
3916
4322
  var DEFAULT_NAMESPACE = "default";
3917
4323
  var NAMESPACE_PATTERN = /^[A-Za-z0-9_.-]{1,64}$/;
3918
- var PATH_PREFIX = /^\/ns\/([^/]+)(\/.*)?$/;
3919
4324
  var BRANCH_PATTERN2 = /^[A-Za-z0-9_.-]{1,64}$/;
3920
4325
  var MUTATING_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
3921
4326
  var effects = /* @__PURE__ */ new WeakMap();
@@ -3954,6 +4359,22 @@ var operationMatcher = (document3) => {
3954
4359
  return (request2, path) => matchers.find((m) => m.method === request2.method && m.pattern.test(path))?.operationId;
3955
4360
  };
3956
4361
  var createRuntime = (options) => {
4362
+ const adminPrefix = resolveAdminPrefix(options.adminPrefix);
4363
+ const internalPaths = Object.entries(options.document?.paths ?? {}).filter(([, item]) => item["x-mockingbird-internal"] === true).map(([path]) => path);
4364
+ for (const path of internalPaths) {
4365
+ if (!path.startsWith(`${ADMIN_PREFIX}/blobs/`))
4366
+ throw new Error(`internal transport ${path} must live under ${ADMIN_PREFIX}/blobs/`);
4367
+ }
4368
+ assertAdminPrefixAvailable(adminPrefix, Object.keys(options.document?.paths ?? {}).filter((path) => !internalPaths.includes(path)));
4369
+ const internalMatch = (path) => {
4370
+ for (const pattern of internalPaths) {
4371
+ const actual = path.split("/");
4372
+ const expected = pattern.split("/");
4373
+ if (actual.length === expected.length && expected.every((part, i) => part.startsWith("{") || part === actual[i]))
4374
+ return pattern;
4375
+ }
4376
+ return void 0;
4377
+ };
3957
4378
  assertAdminUi(options.adminUi);
3958
4379
  const sqlite = bootSqlite(options.sqlite);
3959
4380
  const clock = options.clock ?? createClock();
@@ -3968,6 +4389,20 @@ var createRuntime = (options) => {
3968
4389
  const instances = /* @__PURE__ */ new Map();
3969
4390
  const publicNamespaces = /* @__PURE__ */ new Set();
3970
4391
  const branchRngs = /* @__PURE__ */ new Map();
4392
+ const namespaceClocks = /* @__PURE__ */ new Map();
4393
+ const namespaceForStorage = /* @__PURE__ */ new Map();
4394
+ let isolatedNamespaces = false;
4395
+ const namespaceClock = (name = DEFAULT_NAMESPACE) => {
4396
+ if (!isolatedNamespaces || name === DEFAULT_NAMESPACE)
4397
+ return clock;
4398
+ let found = namespaceClocks.get(name);
4399
+ if (!found) {
4400
+ found = createClock();
4401
+ namespaceClocks.set(name, found);
4402
+ }
4403
+ return found;
4404
+ };
4405
+ const fleetPoints = /* @__PURE__ */ new WeakMap();
3971
4406
  const timelines = /* @__PURE__ */ new Map();
3972
4407
  const branchStorage = /* @__PURE__ */ new Map();
3973
4408
  const captured = /* @__PURE__ */ new Map();
@@ -3981,17 +4416,22 @@ var createRuntime = (options) => {
3981
4416
  if (!NAMESPACE_PATTERN.test(key) || !NAMESPACE_PATTERN.test(publicNamespace)) {
3982
4417
  throw new RangeError(`namespace must match ${NAMESPACE_PATTERN}: ${JSON.stringify(publicNamespace)}`);
3983
4418
  }
4419
+ const createdContextRng = isolatedRng ?? (isolatedNamespaces && key !== DEFAULT_NAMESPACE ? createRng(options.seed ?? 0) : rng);
3984
4420
  const created = options.create({
3985
4421
  namespace: storageNamespace(key),
3986
4422
  publicNamespace,
4423
+ adminPrefix,
3987
4424
  sqlite,
3988
- clock,
3989
- rng: isolatedRng ?? rng
4425
+ clock: namespaceClock(publicNamespace),
4426
+ rng: createdContextRng
3990
4427
  });
3991
4428
  instances.set(key, created);
4429
+ namespaceForStorage.set(key, publicNamespace);
3992
4430
  publicNamespaces.add(publicNamespace);
3993
4431
  if (isolatedRng)
3994
4432
  branchRngs.set(key, isolatedRng);
4433
+ else if (isolatedNamespaces && key !== DEFAULT_NAMESPACE)
4434
+ branchRngs.set(key, createdContextRng);
3995
4435
  return created;
3996
4436
  };
3997
4437
  const instance = (name = DEFAULT_NAMESPACE) => instanceFor(name);
@@ -4009,7 +4449,7 @@ var createRuntime = (options) => {
4009
4449
  captured.set(storage, snapshot2);
4010
4450
  return Object.freeze({
4011
4451
  snapshot: snapshot2,
4012
- clock: Object.freeze(clock.state()),
4452
+ clock: Object.freeze(namespaceClock(namespaceForStorage.get(storage) ?? storage).state()),
4013
4453
  rngState: (branchRngs.get(storage) ?? rng).state()
4014
4454
  });
4015
4455
  };
@@ -4019,7 +4459,7 @@ var createRuntime = (options) => {
4019
4459
  return found;
4020
4460
  instance(name);
4021
4461
  found = new Timeline({
4022
- now: clock.now,
4462
+ now: namespaceClock(name).now,
4023
4463
  ...options.maxCheckpoints !== void 0 ? { maxCheckpoints: options.maxCheckpoints } : {}
4024
4464
  });
4025
4465
  found.commit(capture(name));
@@ -4046,12 +4486,13 @@ var createRuntime = (options) => {
4046
4486
  const point = history2.checkout("main", at);
4047
4487
  restoreNamespace(sqlite, storageNamespace(namespace), point.value.snapshot);
4048
4488
  captured.set(namespace, point.value.snapshot);
4049
- rng.setState(point.value.rngState);
4050
- clock.set(point.value.clock.now);
4489
+ (branchRngs.get(namespace) ?? rng).setState(point.value.rngState);
4490
+ const selectedClock = namespaceClock(namespace);
4491
+ selectedClock.set(point.value.clock.now);
4051
4492
  if (point.value.clock.frozen)
4052
- clock.freeze();
4493
+ selectedClock.freeze();
4053
4494
  else
4054
- clock.unfreeze();
4495
+ selectedClock.unfreeze();
4055
4496
  }
4056
4497
  return namespace;
4057
4498
  }
@@ -4109,11 +4550,12 @@ var createRuntime = (options) => {
4109
4550
  const storage = ensureBranch(namespace, branchName);
4110
4551
  restoreNamespace(sqlite, storageNamespace(storage), point.value.snapshot);
4111
4552
  captured.set(storage, point.value.snapshot);
4112
- clock.set(point.value.clock.now);
4553
+ const selectedClock = namespaceClock(namespace);
4554
+ selectedClock.set(point.value.clock.now);
4113
4555
  if (point.value.clock.frozen)
4114
- clock.freeze();
4556
+ selectedClock.freeze();
4115
4557
  else
4116
- clock.unfreeze();
4558
+ selectedClock.unfreeze();
4117
4559
  (branchRngs.get(storage) ?? rng).setState(point.value.rngState);
4118
4560
  };
4119
4561
  const reset = async (name = DEFAULT_NAMESPACE) => {
@@ -4121,10 +4563,21 @@ var createRuntime = (options) => {
4121
4563
  options.webhooks?.clear();
4122
4564
  for (const each of instances.values())
4123
4565
  await each.reset();
4566
+ for (const each of branchRngs.values())
4567
+ each.reset();
4124
4568
  timelines.clear();
4569
+ for (const storage of branchStorage.values()) {
4570
+ instances.delete(storage);
4571
+ branchRngs.delete(storage);
4572
+ namespaceForStorage.delete(storage);
4573
+ }
4125
4574
  branchStorage.clear();
4126
- branchRngs.clear();
4127
4575
  captured.clear();
4576
+ faults.clear();
4577
+ clock.reset();
4578
+ rng.reset();
4579
+ for (const each of namespaceClocks.values())
4580
+ each.reset();
4128
4581
  return;
4129
4582
  }
4130
4583
  options.webhooks?.clear(name);
@@ -4142,11 +4595,18 @@ var createRuntime = (options) => {
4142
4595
  else
4143
4596
  clearNamespace(sqlite, storageNamespace(storage));
4144
4597
  branchStorage.delete(mapping);
4598
+ instances.delete(storage);
4599
+ namespaceForStorage.delete(storage);
4145
4600
  branchRngs.delete(storage);
4146
4601
  captured.delete(storage);
4147
4602
  }
4148
4603
  timelines.delete(name);
4149
4604
  captured.delete(name);
4605
+ faults.restore(name, { rules: [], rngState: createRng(options.seed ?? 0).state() });
4606
+ namespaceClock(name).reset();
4607
+ (branchRngs.get(name) ?? rng).reset();
4608
+ journal.clear(name);
4609
+ metrics.reset(name);
4150
4610
  };
4151
4611
  const snapshot = (name = DEFAULT_NAMESPACE) => {
4152
4612
  return checkpoint(name, "main").value.snapshot;
@@ -4184,6 +4644,53 @@ var createRuntime = (options) => {
4184
4644
  rng,
4185
4645
  credentials,
4186
4646
  webhooks: options.webhooks,
4647
+ isolateNamespaces() {
4648
+ if (publicNamespaces.size > 1)
4649
+ throw new Error("enable namespace isolation before creating named namespaces");
4650
+ isolatedNamespaces = true;
4651
+ faults.isolateNamespaces();
4652
+ },
4653
+ namespaceClock,
4654
+ namespaceOf(request2) {
4655
+ const url = new URL(request2.url);
4656
+ const prefixed = matchNamespacePath(url.pathname, adminPrefix);
4657
+ const selected = request2.headers.get(NAMESPACE_HEADER) ?? (prefixed ? decodeURIComponent(prefixed[1]) : void 0);
4658
+ const path = prefixed ? prefixed[2] ?? "/" : url.pathname;
4659
+ if (isAdminPath(path, adminPrefix))
4660
+ return url.searchParams.get("namespace") ?? selected ?? DEFAULT_NAMESPACE;
4661
+ if (selected !== void 0)
4662
+ return selected;
4663
+ const credential = options.credential?.(request2);
4664
+ return (credential !== void 0 ? credentials.get(credential) : void 0) ?? DEFAULT_NAMESPACE;
4665
+ },
4666
+ fleetSnapshot(namespace) {
4667
+ instance(namespace);
4668
+ const handle = {};
4669
+ fleetPoints.set(handle, {
4670
+ namespace,
4671
+ state: capture(namespace),
4672
+ faults: faults.snapshot(namespace),
4673
+ webhooks: options.webhooks?.snapshot(namespace)
4674
+ });
4675
+ return handle;
4676
+ },
4677
+ fleetRestore(handle, namespace) {
4678
+ const point = typeof handle === "object" && handle !== null ? fleetPoints.get(handle) : void 0;
4679
+ if (!point || point.namespace !== namespace)
4680
+ throw new Error("invalid fleet checkpoint");
4681
+ restoreNamespace(sqlite, storageNamespace(namespace), point.state.snapshot);
4682
+ captured.set(namespace, point.state.snapshot);
4683
+ const selectedClock = namespaceClock(namespace);
4684
+ selectedClock.freeze();
4685
+ selectedClock.set(point.state.clock.now);
4686
+ if (!point.state.clock.frozen)
4687
+ selectedClock.unfreeze();
4688
+ (branchRngs.get(namespace) ?? rng).setState(point.state.rngState);
4689
+ faults.restore(namespace, point.faults);
4690
+ if (point.webhooks !== void 0)
4691
+ options.webhooks?.restore(namespace, point.webhooks);
4692
+ timelines.delete(namespace);
4693
+ },
4187
4694
  applyPreset: (name, namespace = DEFAULT_NAMESPACE, overrides = {}) => {
4188
4695
  const preset = options.presets?.[name];
4189
4696
  if (!preset)
@@ -4215,7 +4722,7 @@ var createRuntime = (options) => {
4215
4722
  state: (namespace = DEFAULT_NAMESPACE) => inspectState(stateScope(namespace)),
4216
4723
  fetch: async (incoming) => {
4217
4724
  let request2 = incoming;
4218
- const prefixed = PATH_PREFIX.exec(new URL(request2.url).pathname);
4725
+ const prefixed = matchNamespacePath(new URL(request2.url).pathname, adminPrefix);
4219
4726
  if (prefixed) {
4220
4727
  const url2 = new URL(request2.url);
4221
4728
  url2.pathname = prefixed[2] ?? "/";
@@ -4231,7 +4738,15 @@ var createRuntime = (options) => {
4231
4738
  signal: request2.signal
4232
4739
  });
4233
4740
  }
4234
- let namespace = control.namespaceOf(request2);
4741
+ const internalUrl = new URL(request2.url);
4742
+ const internalPath = isAdminPath(internalUrl.pathname, adminPrefix) ? `${ADMIN_PREFIX}${internalUrl.pathname.slice(adminPrefix.length)}` : "";
4743
+ const internal = internalMatch(internalPath);
4744
+ const obsoleteInternalPath = adminPrefix !== ADMIN_PREFIX && internalMatch(internalUrl.pathname) && !internal;
4745
+ if (internal) {
4746
+ internalUrl.pathname = internalPath;
4747
+ request2 = new Request(internalUrl, request2);
4748
+ }
4749
+ let namespace = internal ? internalUrl.searchParams.get("namespace") ?? control.namespaceOf(request2) : control.namespaceOf(request2);
4235
4750
  if (!request2.headers.has(NAMESPACE_HEADER) && options.credential) {
4236
4751
  const credential = options.credential(request2);
4237
4752
  const mapped = credential !== void 0 ? credentials.get(credential) : void 0;
@@ -4251,7 +4766,9 @@ var createRuntime = (options) => {
4251
4766
  return copy;
4252
4767
  }
4253
4768
  };
4254
- const handled = await control.handle(request2);
4769
+ if (obsoleteInternalPath)
4770
+ return stamp(adminFail(404, "internal transport is only available under adminPrefix"));
4771
+ const handled = internal ? void 0 : await control.handle(request2);
4255
4772
  if (handled)
4256
4773
  return stamp(handled);
4257
4774
  const started = monotonicNow();
@@ -4292,7 +4809,7 @@ var createRuntime = (options) => {
4292
4809
  type: "mockingbird_admin",
4293
4810
  message: `${NAMESPACE_HEADER} must match ${NAMESPACE_PATTERN}`
4294
4811
  }
4295
- }), { status: 400, headers: { "content-type": "application/json" } }));
4812
+ }), { status: 400, headers: { "content-type": "application/json; charset=utf-8" } }));
4296
4813
  }
4297
4814
  if (!BRANCH_PATTERN2.test(selectedBranch)) {
4298
4815
  log(400);
@@ -4359,6 +4876,7 @@ var createRuntime = (options) => {
4359
4876
  startedAt: wallNow(),
4360
4877
  wallNow,
4361
4878
  clock,
4879
+ namespaceClock,
4362
4880
  faults,
4363
4881
  metrics,
4364
4882
  journal,
@@ -4408,15 +4926,16 @@ var createRuntime = (options) => {
4408
4926
  ...options.admin?.(runtime) ?? {},
4409
4927
  // Shell routes win a colliding key. Fault presets are one of them: the admin page
4410
4928
  // maps `presets`, and a service route must not replace that list with a record.
4411
- ...options.presets ? presetRoutes(options.presets, runtime) : {},
4929
+ ...options.presets ? presetRoutes(options.presets, runtime, adminPrefix) : {},
4412
4930
  ...stateAdminRoutes({
4413
4931
  open: stateScope,
4414
4932
  afterWrite: (namespace) => {
4415
4933
  checkpoint(namespace, "main");
4416
4934
  }
4417
4935
  }),
4418
- ...adminUiRoutes(options.name, options.adminUi)
4936
+ ...adminUiRoutes(options.name, options.adminUi, adminPrefix)
4419
4937
  },
4938
+ adminPrefix,
4420
4939
  adminKey: options.adminKey
4421
4940
  });
4422
4941
  return defineMock(runtime);
@@ -4430,7 +4949,10 @@ var mutableResponse = (response) => {
4430
4949
  return new Response(response.body, response);
4431
4950
  }
4432
4951
  };
4433
- var adminJson = (status, body) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
4952
+ var adminJson = (status, body) => new Response(JSON.stringify(body), {
4953
+ status,
4954
+ headers: { "content-type": "application/json; charset=utf-8" }
4955
+ });
4434
4956
  var adminFail = (status, message) => adminJson(status, { error: { type: "mockingbird_admin", message } });
4435
4957
  var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4436
4958
  var credentialRoutes = (registry) => ({
@@ -4482,7 +5004,7 @@ var credentialRoutes = (registry) => ({
4482
5004
  return adminJson(200, { status: "ok" });
4483
5005
  }
4484
5006
  });
4485
- var presetRoutes = (presets, runtime) => ({
5007
+ var presetRoutes = (presets, runtime, adminPrefix) => ({
4486
5008
  "GET /faults/presets": () => {
4487
5009
  const body = {
4488
5010
  presets: Object.entries(presets).map(([name, preset]) => ({ name, ...preset }))
@@ -4492,7 +5014,7 @@ var presetRoutes = (presets, runtime) => ({
4492
5014
  "POST /faults/presets/:name": ({ params, body, namespace }) => {
4493
5015
  const name = params.name;
4494
5016
  if (!presets[name])
4495
- return adminFail(404, `no fault preset ${name}; GET /__admin/faults/presets`);
5017
+ return adminFail(404, `no fault preset ${name}; GET ${adminPrefix}/faults/presets`);
4496
5018
  const overrides = isObject(body) ? body : {};
4497
5019
  return adminJson(201, { preset: name, rules: runtime.applyPreset(name, namespace, overrides) });
4498
5020
  }
@@ -5379,7 +5901,7 @@ var JunctionState = class {
5379
5901
  adoptedRequests = /* @__PURE__ */ new WeakSet();
5380
5902
  /** ZIPs the loaded corpus has serviceability records for. */
5381
5903
  coveredZips = /* @__PURE__ */ new Set();
5382
- /** Identifies the loaded corpus in errors and `/health`. */
5904
+ /** Identifies the loaded corpus in errors and `/__admin/health`. */
5383
5905
  corpusLabel;
5384
5906
  /**
5385
5907
  * The loaded corpus's recorded reads. Immutable and shared by every namespace, so it
@@ -11502,6 +12024,7 @@ var createRuntime2 = (options = {}) => {
11502
12024
  ...options.sqlite ? { sqlite: options.sqlite } : {},
11503
12025
  ...options.clock ? { clock: options.clock } : {},
11504
12026
  ...options.seed !== void 0 ? { seed: options.seed } : {},
12027
+ ...options.adminPrefix !== void 0 ? { adminPrefix: options.adminPrefix } : {},
11505
12028
  ...options.adminKey !== void 0 ? { adminKey: options.adminKey } : {},
11506
12029
  ...options.onLog ? { onLog: options.onLog } : {},
11507
12030
  ...options.journalSize !== void 0 ? { journalSize: options.journalSize } : {},
@@ -12361,6 +12884,9 @@ var normalizePath = (request2) => {
12361
12884
  };
12362
12885
 
12363
12886
  export {
12887
+ createClock,
12888
+ resolveAdminPrefix,
12889
+ parseDuration,
12364
12890
  SANDBOX_KEY_PREFIXES,
12365
12891
  isSandboxKey,
12366
12892
  DEFAULT_LIMITS,
@@ -12417,4 +12943,4 @@ export {
12417
12943
  JunctionAPI,
12418
12944
  LabAccountConflict
12419
12945
  };
12420
- //# sourceMappingURL=chunk-ADSZBAJZ.js.map
12946
+ //# sourceMappingURL=chunk-FZVFIPML.js.map