@crvouga/mockingbird-service-step-functions 1.7.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -110,13 +110,31 @@ var Collection = class {
110
110
  };
111
111
 
112
112
  // ../core/dist/control.js
113
- var HEALTH_PATH = "/health";
114
113
  var ADMIN_PREFIX = "/__admin";
114
+ var resolveAdminPrefix = (prefix = ADMIN_PREFIX) => {
115
+ if (!/^\/[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_-]+)*$/.test(prefix)) {
116
+ throw new Error("adminPrefix must be an absolute non-root path of literal segments, without a trailing slash");
117
+ }
118
+ return prefix;
119
+ };
120
+ var isAdminPath = (path, prefix) => path === prefix || path.startsWith(`${prefix}/`);
121
+ var matchNamespacePath = (path, prefix) => /^\/ns\/([^/]+)(\/.*)?$/.exec(isAdminPath(path, prefix) ? path.slice(prefix.length) : "");
122
+ var assertAdminPrefixAvailable = (prefix, paths) => {
123
+ const reserved = prefix.split("/").slice(1);
124
+ for (const path of paths) {
125
+ const segments = path.split("/").slice(1);
126
+ if (segments.length < reserved.length || /^\{[^}]+\}$/.test(segments[0] ?? ""))
127
+ continue;
128
+ if (reserved.every((part, i) => segments[i] === part || /^\{[^}]+\}$/.test(segments[i] ?? ""))) {
129
+ throw new Error(`vendor route ${path} collides with adminPrefix ${prefix}; choose a different adminPrefix`);
130
+ }
131
+ }
132
+ };
115
133
  var ADMIN_KEY_HEADER = "x-mockingbird-admin-key";
116
134
  var NAMESPACE_HEADER = "x-mockingbird-namespace";
117
135
  var json = (status, body) => new Response(JSON.stringify(body), {
118
136
  status,
119
- headers: { "content-type": "application/json" }
137
+ headers: { "content-type": "application/json; charset=utf-8" }
120
138
  });
121
139
  var adminError = (status, message) => json(status, { error: { type: "mockingbird_admin", message } });
122
140
  var UNITS = {
@@ -168,11 +186,25 @@ var readJson = async (request) => {
168
186
  };
169
187
  var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
170
188
  var createControlPlane = (context) => {
189
+ const adminPrefix = resolveAdminPrefix(context.adminPrefix);
171
190
  const snapshots = /* @__PURE__ */ new Map();
172
191
  let snapshotCounter = 0;
173
192
  const headerNamespace = (request) => request.headers.get(NAMESPACE_HEADER) ?? context.defaultNamespace;
174
193
  const adminNamespace = (request, url) => url.searchParams.get("namespace") ?? headerNamespace(request);
194
+ const health = () => {
195
+ return json(200, {
196
+ ...context.describe(),
197
+ status: "ok",
198
+ service: context.name,
199
+ uptimeMs: context.wallNow() - context.startedAt,
200
+ clock: context.clock.state(),
201
+ namespaces: context.namespaces().length,
202
+ adminPrefix,
203
+ adminUi: `${adminPrefix}/ui`
204
+ });
205
+ };
175
206
  const builtin = {
207
+ "GET /health": health,
176
208
  "GET /": () => json(200, {
177
209
  service: context.name,
178
210
  routes: [...Object.keys(builtin), ...Object.keys(context.routes)].sort()
@@ -183,29 +215,30 @@ var createControlPlane = (context) => {
183
215
  return json(200, { status: "ok", reset: target === "*" ? context.namespaces() : [target] });
184
216
  },
185
217
  "GET /namespaces": () => json(200, { default: context.defaultNamespace, namespaces: context.namespaces() }),
186
- "GET /clock": () => json(200, context.clock.state()),
187
- "POST /clock": ({ body }) => {
218
+ "GET /clock": ({ namespace }) => json(200, (context.namespaceClock?.(namespace) ?? context.clock).state()),
219
+ "POST /clock": ({ body, namespace }) => {
220
+ const clock = context.namespaceClock?.(namespace) ?? context.clock;
188
221
  if (!isRecord(body))
189
222
  return adminError(400, "expected a JSON object");
190
223
  if (body.reset === true)
191
- context.clock.reset();
224
+ clock.reset();
192
225
  if (body.freeze === true)
193
- context.clock.freeze();
226
+ clock.freeze();
194
227
  if (body.set !== void 0) {
195
228
  const instant = parseInstant(body.set);
196
229
  if (instant === void 0)
197
230
  return adminError(400, "set: expected epoch ms or ISO-8601");
198
- context.clock.set(instant);
231
+ clock.set(instant);
199
232
  }
200
233
  if (body.advance !== void 0) {
201
234
  const delta = parseDuration(body.advance);
202
235
  if (delta === void 0)
203
236
  return adminError(400, 'advance: expected ms or "15m"-style');
204
- context.clock.advance(delta);
237
+ clock.advance(delta);
205
238
  }
206
239
  if (body.freeze === false)
207
- context.clock.unfreeze();
208
- return json(200, context.clock.state());
240
+ clock.unfreeze();
241
+ return json(200, clock.state());
209
242
  },
210
243
  "GET /faults": () => json(200, { faults: context.faults.list() }),
211
244
  "POST /faults": ({ body, namespace }) => {
@@ -338,7 +371,14 @@ var createControlPlane = (context) => {
338
371
  return json(200, { status: "ok" });
339
372
  }
340
373
  };
341
- const routes = [...Object.entries(context.routes), ...Object.entries(builtin)].map(([key, handler]) => {
374
+ for (const key of Object.keys(context.routes)) {
375
+ if (Object.hasOwn(builtin, key))
376
+ throw new Error(`admin route ${key} overrides a shared route`);
377
+ const path = key.slice(key.indexOf(" ") + 1);
378
+ if (isAdminPath(path, "/ns") || isAdminPath(path, "/blobs"))
379
+ throw new Error(`admin route ${key} overlaps an internal transport`);
380
+ }
381
+ const routes = [...Object.entries(builtin), ...Object.entries(context.routes)].map(([key, handler]) => {
342
382
  const space = key.indexOf(" ");
343
383
  return { method: key.slice(0, space), pattern: key.slice(space + 1), handler };
344
384
  });
@@ -346,21 +386,13 @@ var createControlPlane = (context) => {
346
386
  namespaceOf: headerNamespace,
347
387
  async handle(request) {
348
388
  const url = new URL(request.url);
349
- if (url.pathname === HEALTH_PATH && request.method === "GET") {
350
- return json(200, {
351
- status: "ok",
352
- service: context.name,
353
- uptimeMs: context.wallNow() - context.startedAt,
354
- clock: context.clock.state(),
355
- namespaces: context.namespaces().length,
356
- adminUi: `${ADMIN_PREFIX}/ui`,
357
- ...context.describe()
358
- });
389
+ if (url.pathname === `${adminPrefix}/health` && request.method === "GET") {
390
+ return health();
359
391
  }
360
- if (url.pathname !== ADMIN_PREFIX && !url.pathname.startsWith(`${ADMIN_PREFIX}/`)) {
392
+ if (!isAdminPath(url.pathname, adminPrefix)) {
361
393
  return void 0;
362
394
  }
363
- const path = url.pathname.slice(ADMIN_PREFIX.length) || "/";
395
+ const path = url.pathname.slice(adminPrefix.length) || "/";
364
396
  const shell = request.method === "GET" && (path === "/ui" || path === "/ui/");
365
397
  if (!shell && context.adminKey !== void 0 && request.headers.get(ADMIN_KEY_HEADER) !== context.adminKey) {
366
398
  return adminError(401, `missing or wrong ${ADMIN_KEY_HEADER}`);
@@ -368,7 +400,12 @@ var createControlPlane = (context) => {
368
400
  for (const route of routes) {
369
401
  if (route.method !== request.method)
370
402
  continue;
371
- const params = matchRoute(route.pattern, path);
403
+ let params;
404
+ try {
405
+ params = matchRoute(route.pattern, path);
406
+ } catch {
407
+ return adminError(400, "invalid path encoding");
408
+ }
372
409
  if (!params)
373
410
  continue;
374
411
  let body;
@@ -377,15 +414,18 @@ var createControlPlane = (context) => {
377
414
  } catch {
378
415
  return adminError(400, "request body is not valid JSON");
379
416
  }
417
+ const namespace = adminNamespace(request, url);
418
+ if (!/^[A-Za-z0-9_.-]{1,64}$/.test(namespace))
419
+ return adminError(400, "invalid namespace");
380
420
  return route.handler({
381
421
  request,
382
422
  url,
383
423
  params,
384
- namespace: adminNamespace(request, url),
424
+ namespace,
385
425
  body
386
426
  });
387
427
  }
388
- return adminError(404, `no admin route ${request.method} ${path}; GET ${ADMIN_PREFIX} lists them`);
428
+ return adminError(404, `no admin route ${request.method} ${path}; GET ${adminPrefix} lists them`);
389
429
  }
390
430
  };
391
431
  };
@@ -475,7 +515,33 @@ var faultResponse = (rule) => {
475
515
  };
476
516
  var createFaultRegistry = (rng = createRng(0), sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) => {
477
517
  const entries = [];
518
+ let isolated = false;
519
+ const scopedRngs = /* @__PURE__ */ new Map();
520
+ const random = (namespace) => {
521
+ if (!isolated || namespace === "default")
522
+ return rng;
523
+ let scoped = scopedRngs.get(namespace);
524
+ if (!scoped) {
525
+ scoped = createRng(rng.seed);
526
+ scopedRngs.set(namespace, scoped);
527
+ }
528
+ return scoped;
529
+ };
478
530
  return {
531
+ isolateNamespaces() {
532
+ isolated = true;
533
+ },
534
+ snapshot(namespace) {
535
+ return {
536
+ rules: structuredClone(entries.map((entry, position) => ({
537
+ ...entry.rule,
538
+ remaining: entry.remaining,
539
+ hits: entry.hits,
540
+ position
541
+ })).filter((entry) => entry.namespace === namespace)),
542
+ rngState: random(namespace).state()
543
+ };
544
+ },
479
545
  add(rule) {
480
546
  const existing = entries.findIndex((e) => e.rule.id === rule.id);
481
547
  const entry = { rule, remaining: rule.count ?? null, hits: 0 };
@@ -495,6 +561,16 @@ var createFaultRegistry = (rng = createRng(0), sleep = (ms) => new Promise((reso
495
561
  },
496
562
  clear() {
497
563
  entries.length = 0;
564
+ rng.reset();
565
+ scopedRngs.clear();
566
+ },
567
+ restore(namespace, state) {
568
+ for (let i = entries.length - 1; i >= 0; i--)
569
+ if (entries[i]?.rule.namespace === namespace)
570
+ entries.splice(i, 1);
571
+ for (const { remaining, hits, position, ...rule } of state.rules)
572
+ entries.splice(Math.min(position, entries.length), 0, { rule, remaining, hits });
573
+ random(namespace).setState(state.rngState);
498
574
  },
499
575
  async take(candidate) {
500
576
  const hits = [];
@@ -504,7 +580,7 @@ var createFaultRegistry = (rng = createRng(0), sleep = (ms) => new Promise((reso
504
580
  if (!matches(entry.rule, candidate))
505
581
  continue;
506
582
  const rate = entry.rule.rate ?? 1;
507
- if (rng.next() >= rate)
583
+ if (random(candidate.namespace).next() >= rate)
508
584
  continue;
509
585
  entry.hits++;
510
586
  if (entry.remaining !== null)
@@ -706,7 +782,8 @@ var notes = /* @__PURE__ */ new WeakMap();
706
782
  var responseNotes = (response) => notes.get(response);
707
783
 
708
784
  // ../core/dist/metrics.js
709
- var createMetrics = () => {
785
+ var createMetrics = (partition = true) => {
786
+ const namespaces = /* @__PURE__ */ new Map();
710
787
  let requests = 0;
711
788
  let faults = 0;
712
789
  let totalDurationMs = 0;
@@ -714,6 +791,14 @@ var createMetrics = () => {
714
791
  const unmatched = /* @__PURE__ */ new Map();
715
792
  return {
716
793
  record(entry) {
794
+ if (partition) {
795
+ let scoped = namespaces.get(entry.namespace);
796
+ if (!scoped) {
797
+ scoped = createMetrics(false);
798
+ namespaces.set(entry.namespace, scoped);
799
+ }
800
+ scoped.record(entry);
801
+ }
717
802
  requests++;
718
803
  totalDurationMs += entry.durationMs;
719
804
  if (entry.faultId !== void 0)
@@ -725,7 +810,7 @@ var createMetrics = () => {
725
810
  unmatched.set(route, (unmatched.get(route) ?? 0) + 1);
726
811
  }
727
812
  },
728
- report: () => ({
813
+ report: (namespace) => namespace !== void 0 ? (namespaces.get(namespace) ?? createMetrics(false)).report() : {
729
814
  requests,
730
815
  byOperation: Object.fromEntries([...byOperation].sort(([a], [b]) => a.localeCompare(b))),
731
816
  unmatched: [...unmatched].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([route, count]) => {
@@ -734,8 +819,13 @@ var createMetrics = () => {
734
819
  }),
735
820
  faults,
736
821
  totalDurationMs
737
- }),
738
- reset() {
822
+ },
823
+ reset(namespace) {
824
+ if (namespace !== void 0) {
825
+ namespaces.delete(namespace);
826
+ return;
827
+ }
828
+ namespaces.clear();
739
829
  requests = 0;
740
830
  faults = 0;
741
831
  totalDurationMs = 0;
@@ -1348,6 +1438,34 @@ dialog:not([open]) {
1348
1438
  function isRecord2(value) {
1349
1439
  return typeof value === "object" && value !== null && !Array.isArray(value);
1350
1440
  }
1441
+ function highlightJsonText(text) {
1442
+ const escapeToken = (value) => value.replace(/[&<>"']/g, (char) => {
1443
+ switch (char) {
1444
+ case "&":
1445
+ return "&amp;";
1446
+ case "<":
1447
+ return "&lt;";
1448
+ case ">":
1449
+ return "&gt;";
1450
+ case '"':
1451
+ return "&quot;";
1452
+ default:
1453
+ return "&#39;";
1454
+ }
1455
+ });
1456
+ 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;
1457
+ let cursor = 0;
1458
+ let html = "";
1459
+ for (const match2 of text.matchAll(token)) {
1460
+ const index = match2.index ?? 0;
1461
+ const value = match2[0];
1462
+ html += escapeToken(text.slice(cursor, index));
1463
+ const kind = value.startsWith('"') ? value.trimEnd().endsWith(":") ? "key" : "string" : value === "true" || value === "false" ? "boolean" : value === "null" ? "null" : "number";
1464
+ html += `<span class="json-${kind}">${escapeToken(value)}</span>`;
1465
+ cursor = index + value.length;
1466
+ }
1467
+ return html + escapeToken(text.slice(cursor));
1468
+ }
1351
1469
  function mountAdminBrand(doc, host, service, catalog) {
1352
1470
  const clip = (value, max) => {
1353
1471
  if (typeof value !== "string")
@@ -1709,14 +1827,16 @@ function bootAdmin(config) {
1709
1827
  const state = {
1710
1828
  namespace: "default",
1711
1829
  key: sessionStorage.getItem("mockingbird-admin-key") || "",
1712
- view: "overview",
1830
+ view: ["overview", "state", "clock", "faults", "journal", "routes"].includes(location.hash.slice(1)) ? location.hash.slice(1) : "overview",
1713
1831
  collection: "",
1714
1832
  shape: null,
1715
1833
  route: null,
1834
+ routes: [],
1716
1835
  editing: null
1717
1836
  };
1718
1837
  const standard = new Set(config.standardRoutes);
1719
1838
  const banner = el("banner", HTMLElement);
1839
+ const bannerMessage = el("banner-message", HTMLElement);
1720
1840
  const keyInput = el("admin-key", HTMLInputElement);
1721
1841
  keyInput.value = state.key;
1722
1842
  const params = new URLSearchParams(location.search);
@@ -1727,18 +1847,21 @@ function bootAdmin(config) {
1727
1847
  sessionStorage.setItem("mockingbird-admin-key", state.key);
1728
1848
  params.delete("key");
1729
1849
  const next = params.toString();
1730
- history.replaceState(null, "", location.pathname + (next ? `?${next}` : ""));
1850
+ history.replaceState(null, "", location.pathname + (next ? `?${next}` : "") + location.hash);
1731
1851
  }
1732
1852
  const queryNamespace = params.get("namespace");
1733
1853
  if (queryNamespace)
1734
1854
  state.namespace = queryNamespace;
1735
1855
  const showError = (error) => {
1736
- banner.textContent = error instanceof Error ? error.message : String(error);
1856
+ bannerMessage.textContent = error instanceof Error ? error.message : String(error);
1737
1857
  banner.classList.add("show");
1858
+ banner.scrollIntoView({ behavior: "smooth", block: "nearest" });
1738
1859
  };
1739
1860
  const clearError = () => {
1740
1861
  banner.classList.remove("show");
1862
+ bannerMessage.textContent = "";
1741
1863
  };
1864
+ el("banner-dismiss", HTMLButtonElement).addEventListener("click", clearError);
1742
1865
  const api = async (method, path, body) => {
1743
1866
  const headers = { accept: "application/json" };
1744
1867
  if (state.key !== "")
@@ -1782,16 +1905,21 @@ function bootAdmin(config) {
1782
1905
  namespace: () => state.namespace
1783
1906
  };
1784
1907
  const isPanelScript = (value) => typeof value === "function";
1908
+ const themeButton = el("theme", HTMLButtonElement);
1785
1909
  const setTheme = (theme) => {
1786
1910
  if (theme)
1787
1911
  document.documentElement.dataset.theme = theme;
1788
1912
  else
1789
1913
  delete document.documentElement.dataset.theme;
1914
+ const dark = theme ? theme === "dark" : matchMedia("(prefers-color-scheme: dark)").matches;
1915
+ 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>';
1916
+ const label = dark ? "Use light theme" : "Use dark theme";
1917
+ themeButton.setAttribute("aria-label", label);
1918
+ themeButton.title = label;
1790
1919
  };
1791
1920
  const storedTheme = localStorage.getItem("mockingbird-admin-theme");
1792
- if (storedTheme)
1793
- setTheme(storedTheme);
1794
- el("theme", HTMLButtonElement).addEventListener("click", () => {
1921
+ setTheme(storedTheme === "dark" || storedTheme === "light" ? storedTheme : null);
1922
+ themeButton.addEventListener("click", () => {
1795
1923
  const current = document.documentElement.dataset.theme;
1796
1924
  const dark = current ? current === "dark" : matchMedia("(prefers-color-scheme: dark)").matches;
1797
1925
  const next = dark ? "light" : "dark";
@@ -1810,6 +1938,7 @@ function bootAdmin(config) {
1810
1938
  continue;
1811
1939
  section.hidden = section.id !== `view-${view}`;
1812
1940
  }
1941
+ history.replaceState(null, "", `${location.pathname}${location.search}#${encodeURIComponent(view)}`);
1813
1942
  };
1814
1943
  el("nav", HTMLElement).addEventListener("click", (event) => {
1815
1944
  const target = event.target;
@@ -1824,7 +1953,7 @@ function bootAdmin(config) {
1824
1953
  show(view);
1825
1954
  refresh().catch(showError);
1826
1955
  });
1827
- 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>`;
1956
+ 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>`;
1828
1957
  const table = (columns, rows) => {
1829
1958
  if (rows.length === 0)
1830
1959
  return '<p class="empty">Nothing here yet.</p>';
@@ -1834,16 +1963,22 @@ function bootAdmin(config) {
1834
1963
  const attrs = "attrs" in row && typeof row.attrs === "string" ? row.attrs : "";
1835
1964
  return `<tr${attrs}>${cells}</tr>`;
1836
1965
  }).join("");
1837
- return `<table><thead>${head}</thead><tbody>${body}</tbody></table>`;
1966
+ return `<div class="table-shell"><table><thead>${head}</thead><tbody>${body}</tbody></table></div>`;
1838
1967
  };
1839
1968
  const preview = (value) => {
1840
1969
  const text = JSON.stringify(value) ?? "null";
1841
- return esc(text.length > 180 ? `${text.slice(0, 180)}\u2026` : text);
1970
+ const clipped = text.length > 180 ? `${text.slice(0, 180)}\u2026` : text;
1971
+ return `<code class="json-inline">${highlightJsonText(clipped)}</code>`;
1842
1972
  };
1843
1973
  const when = (epoch) => {
1844
1974
  const date = new Date(epoch);
1845
1975
  return Number.isNaN(date.getTime()) ? String(epoch) : date.toISOString();
1846
1976
  };
1977
+ const journalWhen = (value) => {
1978
+ const numeric = value.trim() === "" ? Number.NaN : Number(value);
1979
+ const epoch = Number.isFinite(numeric) ? numeric : Date.parse(value);
1980
+ return Number.isFinite(epoch) ? when(epoch) : value;
1981
+ };
1847
1982
  const clockCard = (epoch) => {
1848
1983
  const date = new Date(epoch);
1849
1984
  if (Number.isNaN(date.getTime()))
@@ -1963,7 +2098,7 @@ function bootAdmin(config) {
1963
2098
  };
1964
2099
  const renderOverview = async () => {
1965
2100
  const [health, shape, clock, journal] = await Promise.all([
1966
- originGet("/health"),
2101
+ originGet(`${config.adminPrefix}/health`),
1967
2102
  api("GET", "/state"),
1968
2103
  api("GET", "/clock"),
1969
2104
  api("GET", "/requests?limit=8")
@@ -1980,7 +2115,7 @@ function bootAdmin(config) {
1980
2115
  },
1981
2116
  {
1982
2117
  k: "Status",
1983
- v: esc(typeof health.status === "string" && health.status !== "" ? health.status : "ok")
2118
+ v: `<span class="badge ok">${esc(typeof health.status === "string" && health.status !== "" ? health.status : "ok")}</span>`
1984
2119
  },
1985
2120
  { k: "Namespace", v: esc(state.namespace) },
1986
2121
  { k: "Collections", v: String(decoded.collections.length) },
@@ -2067,26 +2202,46 @@ function bootAdmin(config) {
2067
2202
  const renderJournal = async () => {
2068
2203
  const journal = await api("GET", "/requests?limit=100");
2069
2204
  el("journal", HTMLElement).innerHTML = table([
2070
- { label: "When", cell: (row) => esc(row.at) },
2071
- { label: "Method", cell: (row) => esc(row.method) },
2205
+ { label: "When", cell: (row) => esc(journalWhen(row.at)) },
2206
+ {
2207
+ label: "Method",
2208
+ cell: (row) => `<span class="method">${esc(row.method)}</span>`
2209
+ },
2072
2210
  { label: "Path", cell: (row) => esc(row.path) },
2073
- { label: "Status", cell: (row) => esc(row.status) },
2211
+ {
2212
+ label: "Status",
2213
+ cell: (row) => `<span class="badge ${row.status.startsWith("2") ? "ok" : row.status.startsWith("4") || row.status.startsWith("5") ? "warn" : ""}">${esc(row.status)}</span>`
2214
+ },
2074
2215
  { label: "ms", cell: (row) => esc(row.durationMs) }
2075
2216
  ], decodeJournal(journal));
2076
2217
  };
2077
- const renderRoutes = async () => {
2078
- const data = await api("GET", "/");
2079
- if (!isRecord2(data))
2080
- throw new Error("Route list was not an object");
2081
- const routes = requireList(data.routes, "Routes").map((route) => String(route));
2218
+ const paintRoutes = () => {
2219
+ const query = el("route-search", HTMLInputElement).value.trim().toLocaleLowerCase();
2220
+ const routes = state.routes.filter((route) => route.toLocaleLowerCase().includes(query));
2221
+ el("route-count", HTMLElement).textContent = `${routes.length} of ${state.routes.length}`;
2222
+ if (state.routes.length === 0) {
2223
+ el("route-list", HTMLElement).innerHTML = '<p class="empty">No admin routes are available.</p>';
2224
+ return;
2225
+ }
2226
+ if (routes.length === 0) {
2227
+ el("route-list", HTMLElement).innerHTML = '<p class="empty">No routes match this filter.</p>';
2228
+ return;
2229
+ }
2082
2230
  el("route-list", HTMLElement).innerHTML = routes.map((route) => {
2083
2231
  const extra = standard.has(route) ? "" : " <span class='badge'>extension</span>";
2084
2232
  const parts = route.split(" ");
2085
2233
  const method = parts[0] ?? "";
2086
2234
  const path = parts.slice(1).join(" ");
2087
- 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>`;
2235
+ 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>`;
2088
2236
  }).join("");
2089
2237
  };
2238
+ const renderRoutes = async () => {
2239
+ const data = await api("GET", "/");
2240
+ if (!isRecord2(data))
2241
+ throw new Error("Route list was not an object");
2242
+ state.routes = requireList(data.routes, "Routes").map((route) => String(route));
2243
+ paintRoutes();
2244
+ };
2090
2245
  const renderers = {
2091
2246
  overview: renderOverview,
2092
2247
  state: renderState,
@@ -2095,19 +2250,50 @@ function bootAdmin(config) {
2095
2250
  journal: renderJournal,
2096
2251
  routes: renderRoutes
2097
2252
  };
2253
+ let refreshToken = 0;
2098
2254
  async function refresh() {
2255
+ const token = ++refreshToken;
2099
2256
  clearError();
2257
+ const main = el("views", HTMLElement);
2258
+ const sync = el("sync-status", HTMLElement);
2259
+ main.setAttribute("aria-busy", "true");
2260
+ sync.classList.add("busy");
2261
+ sync.textContent = "Updating";
2100
2262
  const render = renderers[state.view];
2101
- if (render)
2102
- await render();
2263
+ try {
2264
+ if (render)
2265
+ await render();
2266
+ if (token === refreshToken)
2267
+ sync.textContent = "Up to date";
2268
+ } finally {
2269
+ if (token === refreshToken) {
2270
+ main.removeAttribute("aria-busy");
2271
+ sync.classList.remove("busy");
2272
+ }
2273
+ }
2103
2274
  }
2104
2275
  const delay = (ms) => new Promise((resolve) => {
2105
2276
  window.setTimeout(resolve, ms);
2106
2277
  });
2107
- const confirmAction = async (button, pending, done, work) => {
2278
+ const confirmAction = async (button, pending, done, work, confirmLabel) => {
2108
2279
  if (button.dataset.pending === "1")
2109
2280
  return;
2110
2281
  const label = button.textContent ?? "";
2282
+ if (confirmLabel !== void 0 && button.dataset.confirming !== "1") {
2283
+ button.dataset.confirming = "1";
2284
+ button.classList.add("confirming");
2285
+ button.textContent = confirmLabel;
2286
+ window.setTimeout(() => {
2287
+ if (button.dataset.confirming !== "1")
2288
+ return;
2289
+ button.textContent = label;
2290
+ button.classList.remove("confirming");
2291
+ delete button.dataset.confirming;
2292
+ }, 3e3);
2293
+ return;
2294
+ }
2295
+ button.classList.remove("confirming");
2296
+ delete button.dataset.confirming;
2111
2297
  const width = button.offsetWidth;
2112
2298
  button.dataset.pending = "1";
2113
2299
  button.classList.add("pressed");
@@ -2128,6 +2314,65 @@ function bootAdmin(config) {
2128
2314
  delete button.dataset.pending;
2129
2315
  }
2130
2316
  };
2317
+ const wireJsonEditor = (textareaId, highlightId, statusId, shellId, allowEmpty) => {
2318
+ const textarea = el(textareaId, HTMLTextAreaElement);
2319
+ const highlight = el(highlightId, HTMLElement);
2320
+ const status = el(statusId, HTMLElement);
2321
+ const shell = el(shellId, HTMLElement);
2322
+ const sync = () => {
2323
+ const text = textarea.value;
2324
+ highlight.innerHTML = `${highlightJsonText(text)}
2325
+ `;
2326
+ let valid = true;
2327
+ let message = "Valid JSON";
2328
+ if (allowEmpty && text.trim() === "")
2329
+ message = "No body";
2330
+ else {
2331
+ try {
2332
+ readJson2(text);
2333
+ } catch {
2334
+ valid = false;
2335
+ message = "Invalid JSON";
2336
+ }
2337
+ }
2338
+ shell.classList.toggle("invalid", !valid);
2339
+ status.classList.toggle("invalid", !valid);
2340
+ status.textContent = message;
2341
+ textarea.setAttribute("aria-invalid", valid ? "false" : "true");
2342
+ };
2343
+ const syncScroll = () => {
2344
+ const preview2 = highlight.parentElement;
2345
+ if (!(preview2 instanceof HTMLElement))
2346
+ return;
2347
+ preview2.scrollTop = textarea.scrollTop;
2348
+ preview2.scrollLeft = textarea.scrollLeft;
2349
+ };
2350
+ textarea.addEventListener("input", sync);
2351
+ textarea.addEventListener("scroll", syncScroll);
2352
+ sync();
2353
+ return sync;
2354
+ };
2355
+ const syncRouteEditor = wireJsonEditor("route-body", "route-highlight", "route-json-status", "route-editor", true);
2356
+ const syncRecordEditor = wireJsonEditor("editor-value", "editor-highlight", "editor-json-status", "record-editor", false);
2357
+ for (const button of document.querySelectorAll("[data-format]")) {
2358
+ if (!(button instanceof HTMLButtonElement))
2359
+ continue;
2360
+ button.addEventListener("click", () => {
2361
+ const target = button.dataset.format;
2362
+ if (target === void 0)
2363
+ return;
2364
+ const textarea = document.getElementById(target);
2365
+ if (!(textarea instanceof HTMLTextAreaElement) || textarea.value.trim() === "")
2366
+ return;
2367
+ try {
2368
+ textarea.value = JSON.stringify(readJson2(textarea.value), null, 2) ?? "";
2369
+ textarea.dispatchEvent(new Event("input"));
2370
+ textarea.focus();
2371
+ } catch {
2372
+ showError(new Error("Fix the JSON before formatting it"));
2373
+ }
2374
+ });
2375
+ }
2131
2376
  document.addEventListener("click", (event) => {
2132
2377
  const target = event.target;
2133
2378
  if (!(target instanceof Element))
@@ -2200,6 +2445,7 @@ function bootAdmin(config) {
2200
2445
  } else {
2201
2446
  editorValue.value = "{\n \n}";
2202
2447
  }
2448
+ syncRecordEditor();
2203
2449
  editor.showModal();
2204
2450
  }
2205
2451
  el("new-record", HTMLButtonElement).addEventListener("click", () => {
@@ -2229,10 +2475,10 @@ function bootAdmin(config) {
2229
2475
  const id = state.editing;
2230
2476
  if (!id)
2231
2477
  return;
2232
- api("DELETE", `/state/${encodeURIComponent(state.collection)}/${encodeURIComponent(id)}`).then(() => {
2478
+ confirmAction(editorDelete, "Deleting...", "Deleted", () => api("DELETE", `/state/${encodeURIComponent(state.collection)}/${encodeURIComponent(id)}`).then(() => {
2233
2479
  editor.close();
2234
2480
  return renderState();
2235
- }).catch(showError);
2481
+ }), "Confirm delete").catch(showError);
2236
2482
  });
2237
2483
  for (const button of document.querySelectorAll("[data-advance]")) {
2238
2484
  if (!(button instanceof HTMLButtonElement))
@@ -2278,10 +2524,10 @@ function bootAdmin(config) {
2278
2524
  }).then(() => renderFaults()).catch(showError);
2279
2525
  });
2280
2526
  el("fault-clear", HTMLButtonElement).addEventListener("click", () => {
2281
- confirmAction(el("fault-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/faults").then(() => renderFaults())).catch(showError);
2527
+ confirmAction(el("fault-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/faults").then(() => renderFaults()), "Confirm clear").catch(showError);
2282
2528
  });
2283
2529
  el("journal-clear", HTMLButtonElement).addEventListener("click", () => {
2284
- confirmAction(el("journal-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/requests").then(() => renderJournal())).catch(showError);
2530
+ confirmAction(el("journal-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/requests").then(() => renderJournal()), "Confirm clear").catch(showError);
2285
2531
  });
2286
2532
  el("route-list", HTMLElement).addEventListener("click", (event) => {
2287
2533
  const target = event.target;
@@ -2295,9 +2541,15 @@ function bootAdmin(config) {
2295
2541
  return;
2296
2542
  state.route = route;
2297
2543
  el("route-title", HTMLElement).textContent = route;
2544
+ el("route-send", HTMLButtonElement).disabled = false;
2298
2545
  const method = route.split(" ")[0] ?? "";
2299
- el("route-body", HTMLTextAreaElement).disabled = method === "GET" || method === "DELETE";
2546
+ const routeBody = el("route-body", HTMLTextAreaElement);
2547
+ routeBody.disabled = method === "GET" || method === "DELETE";
2548
+ el("route-editor", HTMLElement).hidden = routeBody.disabled;
2549
+ el("route-json-status", HTMLElement).parentElement?.toggleAttribute("hidden", routeBody.disabled);
2550
+ syncRouteEditor();
2300
2551
  });
2552
+ el("route-search", HTMLInputElement).addEventListener("input", paintRoutes);
2301
2553
  el("route-form", HTMLFormElement).addEventListener("submit", (event) => {
2302
2554
  event.preventDefault();
2303
2555
  const send = async () => {
@@ -2319,7 +2571,8 @@ function bootAdmin(config) {
2319
2571
  }
2320
2572
  }
2321
2573
  const data = await api(method, concrete, body);
2322
- el("route-result", HTMLElement).textContent = JSON.stringify(data, null, 2);
2574
+ const text = JSON.stringify(data, null, 2) ?? "null";
2575
+ el("route-result", HTMLElement).innerHTML = `<code>${highlightJsonText(text)}</code>`;
2323
2576
  };
2324
2577
  send().catch(showError);
2325
2578
  });
@@ -2437,11 +2690,13 @@ function bootAdmin(config) {
2437
2690
  views.append(section);
2438
2691
  mountSqlExplorer(body, panelApi);
2439
2692
  }
2693
+ show(document.getElementById(`view-${state.view}`) === null ? "overview" : state.view);
2440
2694
  return refresh();
2441
2695
  }).catch(showError);
2442
2696
  }
2443
2697
  var clientPrelude = [
2444
2698
  isRecord2,
2699
+ highlightJsonText,
2445
2700
  mountAdminBrand,
2446
2701
  startAdminBrand,
2447
2702
  faultPresetList,
@@ -2464,6 +2719,7 @@ ${entry}(${payload});
2464
2719
  var defineMock = (runtime) => runtime;
2465
2720
  var STANDARD_ADMIN_ROUTES = [
2466
2721
  "GET /",
2722
+ "GET /health",
2467
2723
  "POST /reset",
2468
2724
  "GET /namespaces",
2469
2725
  "GET /clock",
@@ -2543,11 +2799,11 @@ var escapeHtml = (value) => value.replace(/[&<>"']/g, (char) => {
2543
2799
  }
2544
2800
  });
2545
2801
  var ADMIN_BRANDS_URL = "https://mockingbird.chrisvouga.dev/brands.json";
2546
- var renderAdminDocument = (service) => {
2802
+ var renderAdminDocument = (service, adminPrefix = ADMIN_PREFIX) => {
2547
2803
  const name = escapeHtml(service);
2548
2804
  const script = adminClientSource({
2549
2805
  standardRoutes: STANDARD_ADMIN_ROUTES,
2550
- adminPrefix: ADMIN_PREFIX,
2806
+ adminPrefix,
2551
2807
  adminKeyHeader: ADMIN_KEY_HEADER,
2552
2808
  brandsUrl: ADMIN_BRANDS_URL,
2553
2809
  service
@@ -2564,75 +2820,99 @@ var renderAdminDocument = (service) => {
2564
2820
  ${CSS_RESET}
2565
2821
  :root {
2566
2822
  color-scheme: light;
2567
- --bg: #fafafa;
2823
+ --bg: #f7f8fb;
2568
2824
  --bg-elev: #ffffff;
2569
- --bg-muted: #f4f4f5;
2570
- --bg-inset: #f4f4f5;
2571
- --line: #dedee3;
2572
- --line-strong: #c4c4cc;
2573
- --ink: #18181b;
2574
- --muted: #62626b;
2575
- --faint: #71717a;
2576
- --accent: #27272a;
2825
+ --bg-muted: #f1f3f7;
2826
+ --bg-inset: #f8f9fc;
2827
+ --line: #e4e7ec;
2828
+ --line-strong: #cfd4dc;
2829
+ --ink: #101828;
2830
+ --muted: #475467;
2831
+ --faint: #667085;
2832
+ --accent: #6941c6;
2833
+ --accent-hover: #5933b4;
2577
2834
  --accent-ink: #ffffff;
2578
- --accent-soft: #f4f4f5;
2579
- --ok: #166534;
2580
- --ok-soft: #f0fdf4;
2581
- --warn: #a16207;
2582
- --warn-soft: #fefce8;
2583
- --err: #a82d32;
2584
- --err-soft: #fef2f2;
2585
- --shadow: none;
2835
+ --accent-soft: #f4f0ff;
2836
+ --ok: #067647;
2837
+ --ok-soft: #ecfdf3;
2838
+ --warn: #b54708;
2839
+ --warn-soft: #fffaeb;
2840
+ --err: #b42318;
2841
+ --err-soft: #fef3f2;
2842
+ --code-key: #6941c6;
2843
+ --code-string: #067647;
2844
+ --code-number: #175cd3;
2845
+ --code-boolean: #c11574;
2846
+ --code-null: #667085;
2847
+ --shadow-xs: 0 1px 2px rgb(16 24 40 / 0.05);
2848
+ --shadow-sm: 0 1px 3px rgb(16 24 40 / 0.08), 0 1px 2px rgb(16 24 40 / 0.04);
2849
+ --shadow-lg: 0 18px 48px rgb(16 24 40 / 0.16), 0 4px 12px rgb(16 24 40 / 0.08);
2586
2850
  --sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
2587
2851
  --mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;
2588
- --radius: 10px;
2589
- --header: 56px;
2590
- --nav: 200px;
2852
+ --radius: 12px;
2853
+ --header: 72px;
2854
+ --nav: 224px;
2591
2855
  }
2592
2856
  :root[data-theme="dark"] {
2593
2857
  color-scheme: dark;
2594
- --bg: #111113;
2595
- --bg-elev: #19191c;
2596
- --bg-muted: #242428;
2597
- --bg-inset: #111113;
2598
- --line: #36363c;
2599
- --line-strong: #52525b;
2600
- --ink: #f4f4f5;
2601
- --muted: #a9a9b2;
2602
- --faint: #71717a;
2603
- --accent: #e4e4e7;
2604
- --accent-ink: #18181b;
2605
- --accent-soft: #242428;
2606
- --ok: #86efac;
2607
- --ok-soft: #14241b;
2608
- --warn: #fde68a;
2609
- --warn-soft: #2a2410;
2610
- --err: #ffaaaa;
2611
- --err-soft: #2c1516;
2612
- --shadow: none;
2858
+ --bg: #0c0e14;
2859
+ --bg-elev: #151821;
2860
+ --bg-muted: #20232e;
2861
+ --bg-inset: #11141c;
2862
+ --line: #2b303d;
2863
+ --line-strong: #414858;
2864
+ --ink: #f5f7fa;
2865
+ --muted: #b6bdc9;
2866
+ --faint: #8d96a7;
2867
+ --accent: #9e77ed;
2868
+ --accent-hover: #b692f6;
2869
+ --accent-ink: #ffffff;
2870
+ --accent-soft: #2b2142;
2871
+ --ok: #75e0a7;
2872
+ --ok-soft: #102a20;
2873
+ --warn: #fec84b;
2874
+ --warn-soft: #302611;
2875
+ --err: #fda29b;
2876
+ --err-soft: #321817;
2877
+ --code-key: #c3a6ff;
2878
+ --code-string: #75e0a7;
2879
+ --code-number: #84adff;
2880
+ --code-boolean: #f9a8d4;
2881
+ --code-null: #98a2b3;
2882
+ --shadow-xs: 0 1px 2px rgb(0 0 0 / 0.24);
2883
+ --shadow-sm: 0 4px 12px rgb(0 0 0 / 0.2);
2884
+ --shadow-lg: 0 20px 56px rgb(0 0 0 / 0.45);
2613
2885
  }
2614
2886
  @media (prefers-color-scheme: dark) {
2615
2887
  :root:not([data-theme="light"]) {
2616
2888
  color-scheme: dark;
2617
- --bg: #111113;
2618
- --bg-elev: #19191c;
2619
- --bg-muted: #242428;
2620
- --bg-inset: #111113;
2621
- --line: #36363c;
2622
- --line-strong: #52525b;
2623
- --ink: #f4f4f5;
2624
- --muted: #a9a9b2;
2625
- --faint: #71717a;
2626
- --accent: #e4e4e7;
2627
- --accent-ink: #18181b;
2628
- --accent-soft: #242428;
2629
- --ok: #86efac;
2630
- --ok-soft: #14241b;
2631
- --warn: #fde68a;
2632
- --warn-soft: #2a2410;
2633
- --err: #ffaaaa;
2634
- --err-soft: #2c1516;
2635
- --shadow: none;
2889
+ --bg: #0c0e14;
2890
+ --bg-elev: #151821;
2891
+ --bg-muted: #20232e;
2892
+ --bg-inset: #11141c;
2893
+ --line: #2b303d;
2894
+ --line-strong: #414858;
2895
+ --ink: #f5f7fa;
2896
+ --muted: #b6bdc9;
2897
+ --faint: #8d96a7;
2898
+ --accent: #9e77ed;
2899
+ --accent-hover: #b692f6;
2900
+ --accent-ink: #ffffff;
2901
+ --accent-soft: #2b2142;
2902
+ --ok: #75e0a7;
2903
+ --ok-soft: #102a20;
2904
+ --warn: #fec84b;
2905
+ --warn-soft: #302611;
2906
+ --err: #fda29b;
2907
+ --err-soft: #321817;
2908
+ --code-key: #c3a6ff;
2909
+ --code-string: #75e0a7;
2910
+ --code-number: #84adff;
2911
+ --code-boolean: #f9a8d4;
2912
+ --code-null: #98a2b3;
2913
+ --shadow-xs: 0 1px 2px rgb(0 0 0 / 0.24);
2914
+ --shadow-sm: 0 4px 12px rgb(0 0 0 / 0.2);
2915
+ --shadow-lg: 0 20px 56px rgb(0 0 0 / 0.45);
2636
2916
  }
2637
2917
  }
2638
2918
  /* Fill the viewport, then grow with the content so the background covers the scroll. */
@@ -2650,13 +2930,17 @@ var renderAdminDocument = (service) => {
2650
2930
  padding-right: env(safe-area-inset-right);
2651
2931
  -webkit-text-size-adjust: 100%;
2652
2932
  }
2653
- :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
2933
+ ::selection { background: color-mix(in srgb, var(--accent) 28%, transparent); }
2934
+ :focus-visible { outline: 3px solid color-mix(in srgb, var(--accent) 45%, transparent); outline-offset: 2px; }
2935
+ button, input, select, textarea { transition: border-color 140ms ease, box-shadow 140ms ease, transform 100ms ease; }
2936
+ button:disabled, input:disabled, select:disabled, textarea:disabled { cursor: not-allowed; opacity: 0.55; }
2654
2937
  .app { flex: 1 0 auto; display: flex; flex-direction: column; min-width: 0; width: 100%; }
2655
2938
  header.top {
2656
- position: sticky; top: 0; z-index: 5;
2939
+ position: sticky; top: 0; z-index: 20;
2657
2940
  display: flex; align-items: center; gap: 12px;
2658
- min-height: var(--header); padding: 10px 16px;
2659
- background: var(--bg-elev);
2941
+ min-height: var(--header); padding: 12px 20px;
2942
+ background: color-mix(in srgb, var(--bg-elev) 92%, transparent);
2943
+ backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
2660
2944
  border-bottom: 1px solid var(--line);
2661
2945
  min-width: 0; max-width: 100%;
2662
2946
  }
@@ -2664,12 +2948,21 @@ var renderAdminDocument = (service) => {
2664
2948
  display: flex; flex-flow: row wrap; align-items: center; gap: 14px;
2665
2949
  min-width: 0; max-width: 100%; margin-right: auto;
2666
2950
  }
2667
- .brand-id { display: flex; flex-direction: column; min-width: 0; flex: none; }
2668
- .brand-id strong { font-size: 15px; letter-spacing: -0.01em; }
2951
+ .brand-lockup { display: flex; align-items: center; gap: 10px; min-width: 0; }
2952
+ .brand-mark {
2953
+ width: 38px; height: 38px; display: grid; place-items: center; flex: none;
2954
+ border-radius: 11px; color: #fff;
2955
+ background: linear-gradient(145deg, #7f56d9 0%, #53389e 100%);
2956
+ box-shadow: 0 6px 16px rgb(105 65 198 / 0.24), inset 0 1px 0 rgb(255 255 255 / 0.25);
2957
+ }
2958
+ .brand-mark svg { width: 22px; height: 22px; }
2959
+ .brand-id { display: flex; flex-direction: column; min-width: 0; flex: none; line-height: 1.25; }
2960
+ .brand-id strong { font-size: 15px; letter-spacing: -0.015em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
2669
2961
  .brand-id span { color: var(--faint); font-size: 12px; }
2670
2962
  .vendor {
2671
2963
  display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
2672
2964
  min-width: 0; max-width: 100%; font-size: 12px; color: var(--muted);
2965
+ padding: 5px 8px; border: 1px solid var(--line); border-radius: 999px; background: var(--bg-inset);
2673
2966
  }
2674
2967
  .vendor[hidden] { display: none; }
2675
2968
  /* Logos are drawn for a light tile, same as the docs site, so the plate stays white. */
@@ -2681,12 +2974,17 @@ var renderAdminDocument = (service) => {
2681
2974
  .vendor-sep { color: var(--faint); }
2682
2975
  .vendor-link { color: inherit; text-decoration: none; }
2683
2976
  .vendor-link:hover { color: var(--ink); text-decoration: underline; }
2684
- .controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
2685
- label.field { display: flex; flex-direction: column; gap: 2px; font-size: 11px; color: var(--faint); }
2977
+ .controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: flex-end; }
2978
+ .sync-status { align-self: center; display: flex; align-items: center; gap: 6px; color: var(--faint); font-size: 12px; white-space: nowrap; }
2979
+ .sync-status::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--ok); box-shadow: 0 0 0 3px var(--ok-soft); }
2980
+ .sync-status.busy::before { background: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); animation: pulse 1.2s ease-in-out infinite; }
2981
+ label.field { display: flex; flex-direction: column; gap: 4px; font-size: 11px; font-weight: 600; color: var(--faint); }
2686
2982
  label.field input, label.field select, .controls input[type="password"] {
2687
- background-color: var(--bg-inset); border: 1px solid var(--line); border-radius: 8px;
2688
- padding: 7px 8px; min-height: 36px; line-height: 1.2;
2983
+ background-color: var(--bg-elev); border: 1px solid var(--line-strong); border-radius: 9px;
2984
+ padding: 8px 10px; min-height: 38px; line-height: 1.2; box-shadow: var(--shadow-xs);
2689
2985
  }
2986
+ label.field input:hover, label.field select:hover, textarea:hover { border-color: color-mix(in srgb, var(--accent) 38%, var(--line-strong)); }
2987
+ 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; }
2690
2988
  /* A native menu ignores min-height, so Preset rendered about half as tall as
2691
2989
  the text fields in the same form. Paint it like those fields. */
2692
2990
  label.field select {
@@ -2703,12 +3001,20 @@ var renderAdminDocument = (service) => {
2703
3001
  dialog .field > textarea { width: 100%; }
2704
3002
  .icon-btn, .btn, .btn-primary, .btn-quiet, .btn-danger, nav.side button { cursor: pointer; }
2705
3003
  .icon-btn, .btn, .btn-primary, .btn-quiet, .btn-danger {
2706
- border-radius: 9px; min-height: 36px; padding: 0 12px; border: 1px solid var(--line);
2707
- background: var(--bg-elev);
2708
- }
2709
- .btn-primary { background: var(--accent); color: var(--accent-ink); border-color: transparent; }
2710
- .btn-quiet { background: transparent; }
2711
- .btn-danger { color: var(--err); }
3004
+ display: inline-flex; align-items: center; justify-content: center; gap: 7px;
3005
+ border-radius: 9px; min-height: 38px; padding: 0 13px; border: 1px solid var(--line-strong);
3006
+ background: var(--bg-elev); color: var(--ink); font-size: 13px; font-weight: 600; line-height: 1;
3007
+ box-shadow: var(--shadow-xs);
3008
+ }
3009
+ .icon-btn { width: 38px; padding: 0; }
3010
+ .icon-btn svg, .btn svg, .btn-primary svg { width: 16px; height: 16px; }
3011
+ .icon-btn:hover, .btn:hover { background: var(--bg-muted); border-color: var(--line-strong); }
3012
+ .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; }
3013
+ .btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
3014
+ .btn-quiet { background: transparent; border-color: transparent; box-shadow: none; }
3015
+ .btn-quiet:hover { background: var(--bg-muted); }
3016
+ .btn-danger { color: var(--err); border-color: color-mix(in srgb, var(--err) 25%, var(--line)); }
3017
+ .btn-danger:hover, .btn-danger.confirming { background: var(--err-soft); border-color: color-mix(in srgb, var(--err) 45%, var(--line)); }
2712
3018
  /* Held after mouseup. Clearing an empty journal changes nothing on screen,
2713
3019
  so the press itself has to stay visible. */
2714
3020
  .icon-btn:active, .btn:active, .btn-quiet:active, .btn-danger:active, nav.side button:active,
@@ -2724,93 +3030,159 @@ var renderAdminDocument = (service) => {
2724
3030
  flex: 1 0 auto; min-width: 0;
2725
3031
  }
2726
3032
  nav.side {
2727
- border-right: 1px solid var(--line); padding: 12px;
3033
+ position: sticky; top: var(--header); align-self: start; height: calc(100vh - var(--header));
3034
+ border-right: 1px solid var(--line); padding: 18px 12px;
2728
3035
  display: flex; flex-direction: column; gap: 4px;
2729
- background: var(--bg-elev);
3036
+ background: var(--bg-elev); overflow-y: auto;
2730
3037
  }
3038
+ .nav-label { padding: 0 10px 8px; color: var(--faint); font-size: 10px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; }
2731
3039
  nav.side button {
3040
+ position: relative; display: flex; align-items: center; gap: 10px; width: 100%;
2732
3041
  text-align: left; border: 0; background: transparent; border-radius: 9px;
2733
- padding: 9px 10px; min-height: 40px; color: var(--muted);
2734
- }
2735
- nav.side button[aria-current="true"] { background: var(--accent-soft); color: var(--ink); font-weight: 600; }
2736
- main { padding: 20px; max-width: 1100px; width: 100%; min-width: 0; }
2737
- .view h2 { margin: 0 0 4px; font-size: 22px; letter-spacing: -0.02em; }
2738
- .lede { margin: 0 0 16px; color: var(--muted); }
2739
- .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr)); gap: 12px; }
3042
+ padding: 9px 10px; min-height: 42px; color: var(--muted); font-size: 13px; font-weight: 550;
3043
+ }
3044
+ nav.side button:hover { background: var(--bg-muted); color: var(--ink); }
3045
+ nav.side button[aria-current="true"] { background: var(--accent-soft); color: var(--accent); font-weight: 650; }
3046
+ 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); }
3047
+ .nav-icon { width: 18px; height: 18px; display: grid; place-items: center; flex: none; }
3048
+ .nav-icon svg { width: 18px; height: 18px; }
3049
+ main { padding: 32px clamp(20px, 4vw, 48px) 64px; max-width: 1320px; width: 100%; min-width: 0; }
3050
+ main[aria-busy="true"] .view { opacity: 0.62; }
3051
+ .view { animation: view-in 180ms ease-out; }
3052
+ .view[hidden] { display: none; }
3053
+ .view-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 22px; }
3054
+ .view h2 { margin: 0 0 5px; font-size: clamp(24px, 3vw, 30px); line-height: 1.2; letter-spacing: -0.035em; }
3055
+ .view h3 { margin: 24px 0 10px; font-size: 15px; letter-spacing: -0.01em; }
3056
+ .lede { margin: 0; color: var(--muted); max-width: 72ch; font-size: 14px; }
3057
+ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 175px), 1fr)); gap: 14px; }
2740
3058
  .card {
2741
3059
  background: var(--bg-elev); border: 1px solid var(--line); border-radius: var(--radius);
2742
- padding: 14px 16px; box-shadow: var(--shadow);
3060
+ padding: 18px; box-shadow: var(--shadow-sm);
2743
3061
  }
2744
- .card .k { color: var(--faint); font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; }
2745
- .card .v { display: flex; flex-direction: column; gap: 2px; font-size: 22px; margin-top: 4px; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
3062
+ .stat-card { position: relative; overflow: hidden; }
3063
+ .stat-card::after { content: ""; position: absolute; inset: 0 0 auto; height: 2px; background: linear-gradient(90deg, var(--accent), transparent 78%); opacity: 0.7; }
3064
+ .card .k { color: var(--faint); font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; }
3065
+ .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; }
2746
3066
  .card .v .clock-time { font-size: 14px; color: var(--muted); }
2747
- .split { display: grid; grid-template-columns: minmax(0, 240px) minmax(0, 1fr); gap: 16px; align-items: start; }
3067
+ .section-card { background: var(--bg-elev); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow-xs); overflow: hidden; }
3068
+ .section-title { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 16px 18px; border-bottom: 1px solid var(--line); }
3069
+ .section-title h3 { margin: 0; }
3070
+ .split { display: grid; grid-template-columns: minmax(0, 250px) minmax(0, 1fr); gap: 18px; align-items: start; }
3071
+ #collections { padding: 6px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg-elev); box-shadow: var(--shadow-xs); }
2748
3072
  .collection {
2749
3073
  width: 100%; text-align: left; border: 1px solid transparent; background: transparent;
2750
- border-radius: 10px; padding: 8px 10px; min-height: 44px;
3074
+ border-radius: 9px; padding: 10px 11px; min-height: 48px; color: var(--ink); cursor: pointer;
2751
3075
  }
2752
- .collection[aria-current="true"] { background: var(--bg-elev); border-color: var(--line); }
3076
+ .collection:hover { background: var(--bg-muted); }
3077
+ .collection[aria-current="true"] { background: var(--accent-soft); border-color: color-mix(in srgb, var(--accent) 18%, var(--line)); color: var(--accent); }
2753
3078
  .collection small { display: block; color: var(--faint); }
2754
3079
  .chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 14px; }
2755
- .chip {
2756
- font-family: var(--mono); font-size: 12px; background: var(--bg-muted);
2757
- border-radius: 999px; padding: 4px 8px;
2758
- }
3080
+ .chip { font-family: var(--mono); font-size: 11px; background: var(--bg-muted); border: 1px solid var(--line); border-radius: 999px; padding: 4px 8px; }
2759
3081
  .chip i { color: var(--faint); font-style: normal; }
2760
3082
  .sql-layout {
2761
3083
  display: grid; grid-template-columns: minmax(10rem, 16rem) minmax(0, 1fr); gap: 16px;
2762
3084
  }
2763
- .sql-tables { display: flex; flex-direction: column; gap: 4px; max-height: 28rem; overflow: auto; }
2764
- .sql-tables button { text-align: left; }
3085
+ .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); }
3086
+ .sql-tables button { text-align: left; border: 0; border-radius: 8px; padding: 9px 10px; background: transparent; color: var(--muted); cursor: pointer; }
3087
+ .sql-tables button:hover, .sql-tables button[aria-current="true"] { background: var(--accent-soft); color: var(--accent); }
2765
3088
  .sql-input { font-family: var(--mono); min-height: 7rem; width: 100%; }
2766
- table { width: 100%; border-collapse: collapse; font-size: 14px; }
2767
- th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--line); vertical-align: top; }
2768
- th { color: var(--faint); font-weight: 600; font-size: 12px; }
3089
+ .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); }
3090
+ table { width: 100%; border-collapse: collapse; font-size: 13px; }
3091
+ th, td { text-align: left; padding: 11px 13px; border-bottom: 1px solid var(--line); vertical-align: top; }
3092
+ th { color: var(--faint); background: var(--bg-inset); font-weight: 650; font-size: 11px; text-transform: uppercase; letter-spacing: 0.045em; }
3093
+ tbody tr:last-child td { border-bottom: 0; }
2769
3094
  tr.clickable { cursor: pointer; }
2770
- tr.clickable:hover { background: var(--bg-muted); }
3095
+ tr.clickable:hover { background: var(--accent-soft); }
2771
3096
  pre, code, .mono { font-family: var(--mono); overflow-wrap: anywhere; }
2772
3097
  pre {
2773
3098
  margin: 0; white-space: pre-wrap; word-break: break-word;
2774
- background: var(--bg-inset); border-radius: 10px; padding: 12px; font-size: 12px;
2775
- }
2776
- .row-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0; }
3099
+ background: var(--bg-inset); border: 1px solid var(--line); border-radius: 10px; padding: 14px; font-size: 12px; line-height: 1.65;
3100
+ }
3101
+ .json-key { color: var(--code-key); }
3102
+ .json-string { color: var(--code-string); }
3103
+ .json-number { color: var(--code-number); }
3104
+ .json-boolean { color: var(--code-boolean); }
3105
+ .json-null { color: var(--code-null); font-style: italic; }
3106
+ .json-inline { display: block; max-width: 44rem; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
3107
+ .json-view { position: relative; max-height: 26rem; overflow: auto; }
3108
+ .json-editor { position: relative; min-height: 190px; border: 1px solid var(--line-strong); border-radius: 10px; background: var(--bg-inset); overflow: hidden; }
3109
+ .json-editor:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 15%, transparent); }
3110
+ .json-editor.invalid { border-color: var(--err); }
3111
+ .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; }
3112
+ .json-editor pre { position: absolute; inset: 0; pointer-events: none; background: transparent; }
3113
+ .json-editor textarea { position: relative; z-index: 1; resize: vertical; background: transparent; color: transparent; caret-color: var(--ink); -webkit-text-fill-color: transparent; }
3114
+ .json-editor textarea::selection { background: color-mix(in srgb, var(--accent) 32%, transparent); }
3115
+ .editor-meta { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 7px; }
3116
+ .json-status { display: inline-flex; align-items: center; gap: 6px; color: var(--ok); font-size: 11px; }
3117
+ .json-status::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
3118
+ .json-status.invalid { color: var(--err); }
3119
+ .format-json { border: 0; padding: 3px 5px; background: transparent; color: var(--accent); font-size: 11px; font-weight: 650; cursor: pointer; }
3120
+ .row-actions { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 8px; margin: 14px 0; }
2777
3121
  .banner {
2778
- display: none; margin-bottom: 12px; padding: 10px 12px; border-radius: 10px;
2779
- background: var(--err-soft); color: var(--err);
2780
- }
2781
- .banner.show { display: block; }
2782
- .empty { color: var(--muted); padding: 24px 8px; }
3122
+ display: none; position: sticky; top: calc(var(--header) + 12px); z-index: 15;
3123
+ align-items: center; gap: 10px; margin: 0 0 16px; padding: 11px 12px 11px 14px;
3124
+ border: 1px solid color-mix(in srgb, var(--err) 30%, var(--line)); border-radius: 10px;
3125
+ background: var(--err-soft); color: var(--err); box-shadow: var(--shadow-sm); font-size: 13px;
3126
+ }
3127
+ .banner.show { display: flex; }
3128
+ .banner span { flex: 1; }
3129
+ .banner button { width: 28px; height: 28px; display: grid; place-items: center; border: 0; border-radius: 7px; background: transparent; color: currentColor; cursor: pointer; }
3130
+ .banner button:hover { background: color-mix(in srgb, var(--err) 10%, transparent); }
3131
+ .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); }
2783
3132
  dialog {
2784
- border: 1px solid var(--line); border-radius: var(--radius); padding: 0; background: var(--bg-elev);
2785
- color: var(--ink); width: min(640px, calc(100% - 24px)); max-width: calc(100vw - 24px); box-shadow: none;
2786
- }
2787
- dialog::backdrop { background: rgb(20 19 17 / 0.45); }
2788
- dialog form, .dialog-body { padding: 16px; display: flex; flex-direction: column; gap: 10px; }
3133
+ border: 1px solid var(--line); border-radius: 16px; padding: 0; background: var(--bg-elev);
3134
+ color: var(--ink); width: min(720px, calc(100% - 24px)); max-width: calc(100vw - 24px); box-shadow: var(--shadow-lg);
3135
+ }
3136
+ dialog[open] { animation: dialog-in 160ms ease-out; }
3137
+ dialog::backdrop { background: rgb(12 14 20 / 0.58); backdrop-filter: blur(3px); }
3138
+ dialog form, .dialog-body { padding: 22px; display: flex; flex-direction: column; gap: 14px; }
3139
+ .dialog-heading { display: flex; justify-content: space-between; gap: 16px; padding-bottom: 12px; border-bottom: 1px solid var(--line); }
3140
+ .dialog-heading strong { font-size: 18px; letter-spacing: -0.02em; }
3141
+ .dialog-heading span { color: var(--muted); font-size: 12px; }
2789
3142
  textarea {
2790
3143
  width: 100%; min-height: 180px; resize: vertical; border-radius: 10px;
2791
3144
  border: 1px solid var(--line); background: var(--bg-inset); padding: 10px;
2792
3145
  font-family: var(--mono); font-size: 12px;
2793
3146
  }
2794
- .dialog-actions { display: flex; justify-content: flex-end; gap: 8px; }
2795
- .badge { font-size: 11px; border-radius: 999px; padding: 2px 7px; background: var(--bg-muted); color: var(--muted); }
3147
+ .dialog-actions { display: flex; justify-content: flex-end; align-items: center; gap: 8px; padding-top: 4px; }
3148
+ .dialog-actions .btn-danger { margin-right: auto; }
3149
+ .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); }
2796
3150
  .badge.ok { background: var(--ok-soft); color: var(--ok); }
2797
3151
  .badge.warn { background: var(--warn-soft); color: var(--warn); }
2798
3152
  .status-2 { color: var(--ok); }
2799
3153
  .status-4, .status-5, .status-0 { color: var(--err); }
2800
3154
  .stack { display: flex; flex-direction: column; gap: 12px; }
3155
+ .form-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
3156
+ .form-grid .wide { grid-column: span 2; }
2801
3157
  .route {
2802
- display: grid; grid-template-columns: 88px 1fr auto; gap: 8px; align-items: center;
2803
- padding: 8px 0; border-bottom: 1px solid var(--line);
2804
- }
2805
- .method { font-family: var(--mono); font-size: 12px; font-weight: 700; }
3158
+ display: grid; grid-template-columns: 84px minmax(0, 1fr) auto; gap: 10px; align-items: center;
3159
+ padding: 10px 12px; border-bottom: 1px solid var(--line); background: var(--bg-elev);
3160
+ }
3161
+ .route:first-child { border-radius: var(--radius) var(--radius) 0 0; }
3162
+ .route:last-child { border-bottom: 0; border-radius: 0 0 var(--radius) var(--radius); }
3163
+ .route-tools { display: flex; align-items: flex-end; gap: 12px; margin-bottom: 12px; }
3164
+ .route-tools .field { flex: 1; }
3165
+ .route-tools input { width: 100%; }
3166
+ .route-count { padding-bottom: 10px; color: var(--faint); font-size: 12px; white-space: nowrap; }
3167
+ #route-list { max-height: 34rem; border: 1px solid var(--line); border-radius: var(--radius); overflow: auto; box-shadow: var(--shadow-xs); }
3168
+ .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; }
3169
+ @keyframes view-in { from { transform: translateY(3px); } to { transform: translateY(0); } }
3170
+ @keyframes dialog-in { from { transform: translateY(8px) scale(0.99); } to { transform: translateY(0) scale(1); } }
3171
+ @keyframes pulse { 50% { opacity: 0.45; } }
3172
+ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } }
3173
+ @media (forced-colors: active) { .json-editor pre { display: none; } .json-editor textarea { color: CanvasText; -webkit-text-fill-color: CanvasText; } }
2806
3174
  @media (max-width: 800px) {
3175
+ :root { --header: 0px; }
2807
3176
  header.top {
2808
- align-items: stretch; flex-direction: column;
2809
- padding-top: max(10px, env(safe-area-inset-top));
3177
+ position: relative; align-items: stretch; flex-direction: column;
3178
+ padding: max(12px, env(safe-area-inset-top)) 14px 12px;
2810
3179
  }
2811
3180
  input, select, textarea { font-size: 16px; }
2812
3181
  .brand, .vendor, .controls { width: 100%; }
3182
+ .brand { gap: 10px; }
3183
+ .vendor { order: 2; border-radius: 9px; }
2813
3184
  label.field, label.field select, label.field input { flex: 1; min-width: 0; }
3185
+ .sync-status { display: none; }
2814
3186
  /* The layout fills the screen. A single-column grid stretches every auto
2815
3187
  row, so the tab bar grew with the leftover height and the buttons floated
2816
3188
  in the middle of it. The bar stays one row; the page below takes the rest. */
@@ -2820,14 +3192,23 @@ var renderAdminDocument = (service) => {
2820
3192
  align-content: start;
2821
3193
  }
2822
3194
  nav.side {
2823
- align-self: start; height: auto;
3195
+ position: sticky; top: 0; z-index: 12; align-self: start; height: auto;
2824
3196
  flex-direction: row; align-items: center; overflow-x: auto; min-width: 0;
2825
3197
  border-right: 0; border-bottom: 1px solid var(--line);
2826
- padding: 8px;
3198
+ padding: 8px; box-shadow: var(--shadow-xs);
2827
3199
  }
2828
- nav.side button { white-space: nowrap; flex: none; }
3200
+ .nav-label { display: none; }
3201
+ nav.side button { width: auto; white-space: nowrap; flex: none; }
3202
+ nav.side button[aria-current="true"]::before { inset: auto 9px -8px; width: auto; height: 3px; border-radius: 3px 3px 0 0; }
2829
3203
  .split, .sql-layout { grid-template-columns: minmax(0, 1fr); }
2830
- main { padding: 14px; }
3204
+ .form-grid { grid-template-columns: minmax(0, 1fr); }
3205
+ .form-grid .wide { grid-column: auto; }
3206
+ main { padding: 22px 14px 48px; }
3207
+ .view-head { margin-bottom: 18px; }
3208
+ .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
3209
+ .card { padding: 14px; }
3210
+ .card .v { font-size: 20px; }
3211
+ .table-shell { border: 0; background: transparent; box-shadow: none; overflow: visible; }
2831
3212
  table, thead, tbody, tr, th, td { display: block; }
2832
3213
  thead { display: none; }
2833
3214
  tr {
@@ -2842,50 +3223,63 @@ var renderAdminDocument = (service) => {
2842
3223
  .route { grid-template-columns: 72px 1fr; }
2843
3224
  .route button { grid-column: 1 / -1; }
2844
3225
  }
3226
+ @media (max-width: 460px) {
3227
+ .controls label.field { min-width: calc(50% - 4px); }
3228
+ .controls .icon-btn, .controls .btn { flex: 1; }
3229
+ .grid { grid-template-columns: minmax(0, 1fr); }
3230
+ .dialog-actions { flex-wrap: wrap; }
3231
+ .dialog-actions button { flex: 1; }
3232
+ .dialog-actions .btn-danger { flex-basis: 100%; margin-right: 0; }
3233
+ }
2845
3234
  </style>
2846
3235
  </head>
2847
3236
  <body>
2848
3237
  <div class="app" data-service="${name}" data-mockingbird-admin>
2849
3238
  <header class="top">
2850
3239
  <div class="brand">
2851
- <div class="brand-id">
2852
- <strong>${name}</strong>
2853
- <span>Admin</span>
3240
+ <div class="brand-lockup">
3241
+ <div class="brand-mark" aria-hidden="true">
3242
+ <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>
3243
+ </div>
3244
+ <div class="brand-id">
3245
+ <strong>${name}</strong>
3246
+ <span>Mock admin</span>
3247
+ </div>
2854
3248
  </div>
2855
3249
  <div class="vendor" id="vendor" hidden></div>
2856
3250
  </div>
2857
3251
  <div class="controls">
3252
+ <span class="sync-status" id="sync-status" role="status" aria-live="polite">Ready</span>
2858
3253
  <label class="field">Namespace
2859
3254
  <select id="namespace" aria-label="Namespace"></select>
2860
3255
  </label>
2861
3256
  <label class="field">Admin key
2862
3257
  <input id="admin-key" type="password" autocomplete="off" spellcheck="false" aria-label="Admin key">
2863
3258
  </label>
2864
- <button class="icon-btn" id="theme" type="button" aria-label="Toggle color theme">Theme</button>
2865
- <button class="btn" id="refresh" type="button">Refresh</button>
3259
+ <button class="icon-btn" id="theme" type="button" aria-label="Use dark theme" title="Use dark theme"></button>
3260
+ <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>
2866
3261
  </div>
2867
3262
  </header>
2868
3263
  <div class="layout">
2869
3264
  <nav class="side" id="nav" aria-label="Admin sections">
2870
- <button type="button" data-view="overview" aria-current="true">Overview</button>
2871
- <button type="button" data-view="state">State</button>
2872
- <button type="button" data-view="clock">Clock</button>
2873
- <button type="button" data-view="faults">Faults</button>
2874
- <button type="button" data-view="journal">Journal</button>
2875
- <button type="button" data-view="routes">Routes</button>
3265
+ <span class="nav-label">Workspace</span>
3266
+ <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>
3267
+ <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>
3268
+ <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>
3269
+ <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>
3270
+ <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>
3271
+ <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>
2876
3272
  </nav>
2877
3273
  <main id="views">
2878
- <p class="banner" id="banner" role="alert"></p>
3274
+ <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>
2879
3275
  <section class="view" id="view-overview">
2880
- <h2>Overview</h2>
2881
- <p class="lede">Health, clock, and the collections this namespace is holding.</p>
3276
+ <div class="view-head"><div><h2>Overview</h2><p class="lede">Health, clock, and the collections this namespace is holding.</p></div></div>
2882
3277
  <div class="grid" id="overview-cards"></div>
2883
3278
  <h3>Recent requests</h3>
2884
3279
  <div id="overview-requests"></div>
2885
3280
  </section>
2886
3281
  <section class="view" id="view-state" hidden>
2887
- <h2>State</h2>
2888
- <p class="lede">Every collection in this namespace. Declared fields stay visible before any row exists; stored rows add whatever else they contain.</p>
3282
+ <div class="view-head"><div><h2>State</h2><p class="lede">Inspect and edit every collection in this namespace.</p></div></div>
2889
3283
  <div class="split">
2890
3284
  <div id="collections"></div>
2891
3285
  <div>
@@ -2898,8 +3292,7 @@ var renderAdminDocument = (service) => {
2898
3292
  </div>
2899
3293
  </section>
2900
3294
  <section class="view" id="view-clock" hidden>
2901
- <h2>Clock</h2>
2902
- <p class="lede">The clock every timestamp in this mock reads.</p>
3295
+ <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>
2903
3296
  <div class="card" id="clock-readout"></div>
2904
3297
  <div class="row-actions">
2905
3298
  <button class="btn" type="button" data-advance="60000">+1m</button>
@@ -2918,21 +3311,22 @@ var renderAdminDocument = (service) => {
2918
3311
  </form>
2919
3312
  </section>
2920
3313
  <section class="view" id="view-faults" hidden>
2921
- <h2>Faults</h2>
2922
- <p class="lede">Inject a response, a delay, or a named preset. Rules apply to this namespace.</p>
3314
+ <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>
2923
3315
  <form id="fault-form" class="card stack">
2924
- <label class="field">Preset
2925
- <select id="fault-preset"><option value="">Custom rule</option></select>
2926
- </label>
2927
- <label class="field">Status
2928
- <input id="fault-status" inputmode="numeric" placeholder="503">
2929
- </label>
2930
- <label class="field">Path prefix
2931
- <input id="fault-path" placeholder="/v1">
2932
- </label>
2933
- <label class="field">Method
2934
- <input id="fault-method" placeholder="GET">
2935
- </label>
3316
+ <div class="form-grid">
3317
+ <label class="field wide">Preset
3318
+ <select id="fault-preset"><option value="">Custom rule</option></select>
3319
+ </label>
3320
+ <label class="field">Status
3321
+ <input id="fault-status" inputmode="numeric" placeholder="503">
3322
+ </label>
3323
+ <label class="field wide">Path prefix
3324
+ <input id="fault-path" placeholder="/v1">
3325
+ </label>
3326
+ <label class="field">Method
3327
+ <input id="fault-method" placeholder="GET">
3328
+ </label>
3329
+ </div>
2936
3330
  <div class="dialog-actions">
2937
3331
  <button class="btn-primary" type="submit">Add fault</button>
2938
3332
  <button class="btn-danger" id="fault-clear" type="button">Clear all</button>
@@ -2941,26 +3335,31 @@ var renderAdminDocument = (service) => {
2941
3335
  <div id="fault-list"></div>
2942
3336
  </section>
2943
3337
  <section class="view" id="view-journal" hidden>
2944
- <h2>Journal</h2>
2945
- <p class="lede">Requests this namespace has seen. Bodies are not stored.</p>
2946
- <div class="row-actions">
2947
- <button class="btn-danger" id="journal-clear" type="button">Clear journal</button>
2948
- </div>
3338
+ <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>
2949
3339
  <div id="journal"></div>
2950
3340
  </section>
2951
3341
  <section class="view" id="view-routes" hidden>
2952
- <h2>Routes</h2>
2953
- <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>
3342
+ <div class="view-head"><div><h2>Routes</h2><p class="lede">Explore shared controls and service-specific admin endpoints.</p></div></div>
3343
+ <div class="route-tools">
3344
+ <label class="field">Filter routes
3345
+ <input id="route-search" type="search" placeholder="Method, path, or extension" autocomplete="off">
3346
+ </label>
3347
+ <span class="route-count" id="route-count"></span>
3348
+ </div>
2954
3349
  <div id="route-list"></div>
2955
- <form id="route-form" class="card stack">
3350
+ <form id="route-form" class="card stack" style="margin-top: 18px">
2956
3351
  <strong id="route-title">Select a route</strong>
2957
3352
  <label class="field">JSON body
2958
- <textarea id="route-body" placeholder="{ }"></textarea>
3353
+ <div class="json-editor" id="route-editor" hidden>
3354
+ <pre aria-hidden="true"><code id="route-highlight"></code></pre>
3355
+ <textarea id="route-body" placeholder="{ }" spellcheck="false" aria-describedby="route-json-status" disabled></textarea>
3356
+ </div>
3357
+ <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>
2959
3358
  </label>
2960
3359
  <div class="dialog-actions">
2961
- <button class="btn-primary" type="submit">Send</button>
3360
+ <button class="btn-primary" id="route-send" type="submit" disabled>Send request</button>
2962
3361
  </div>
2963
- <pre id="route-result">Response will show here.</pre>
3362
+ <pre class="json-view" id="route-result"><code>Response will show here.</code></pre>
2964
3363
  </form>
2965
3364
  </section>
2966
3365
  </main>
@@ -2968,12 +3367,16 @@ var renderAdminDocument = (service) => {
2968
3367
  </div>
2969
3368
  <dialog id="editor">
2970
3369
  <form method="dialog" id="editor-form">
2971
- <strong id="editor-title">Record</strong>
3370
+ <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>
2972
3371
  <label class="field">Id
2973
3372
  <input id="editor-id" required>
2974
3373
  </label>
2975
3374
  <label class="field">JSON value
2976
- <textarea id="editor-value"></textarea>
3375
+ <div class="json-editor" id="record-editor">
3376
+ <pre aria-hidden="true"><code id="editor-highlight"></code></pre>
3377
+ <textarea id="editor-value" spellcheck="false" aria-describedby="editor-json-status"></textarea>
3378
+ </div>
3379
+ <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>
2977
3380
  </label>
2978
3381
  <div class="dialog-actions">
2979
3382
  <button class="btn-danger" id="editor-delete" type="button" value="delete">Delete</button>
@@ -3015,8 +3418,8 @@ var describeExtension = (extension) => {
3015
3418
  ...extension.script !== void 0 ? { script: extension.script } : {}
3016
3419
  };
3017
3420
  };
3018
- var adminUiRoutes = (service, ui) => {
3019
- const shell = () => renderAdminDocument(service);
3421
+ var adminUiRoutes = (service, ui, adminPrefix = ADMIN_PREFIX) => {
3422
+ const shell = () => renderAdminDocument(service, adminPrefix);
3020
3423
  const document3 = () => ui?.render ? ui.render({ service, defaultHtml: shell }) : shell();
3021
3424
  return {
3022
3425
  "GET /ui": () => htmlResponse(document3()),
@@ -3937,10 +4340,13 @@ var stateAdminRoutes = (routes) => {
3937
4340
  };
3938
4341
 
3939
4342
  // ../core/dist/version.js
3940
- var PACKAGE_VERSION = true ? "1.7.1" : UNRELEASED_VERSION;
4343
+ var PACKAGE_VERSION = true ? "2.0.0" : UNRELEASED_VERSION;
3941
4344
 
3942
4345
  // ../core/dist/webhooks.js
3943
- var json4 = (status, body) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
4346
+ var json4 = (status, body) => new Response(JSON.stringify(body), {
4347
+ status,
4348
+ headers: { "content-type": "application/json; charset=utf-8" }
4349
+ });
3944
4350
  var adminError3 = (status, message) => json4(status, { error: { type: "mockingbird_admin", message } });
3945
4351
  var isRecord4 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3946
4352
  var parseEndpoint = (value) => {
@@ -4048,7 +4454,6 @@ var AT_HEADER = "x-mockingbird-at";
4048
4454
  var CHECKPOINT_HEADER = "x-mockingbird-checkpoint";
4049
4455
  var DEFAULT_NAMESPACE = "default";
4050
4456
  var NAMESPACE_PATTERN = /^[A-Za-z0-9_.-]{1,64}$/;
4051
- var PATH_PREFIX = /^\/ns\/([^/]+)(\/.*)?$/;
4052
4457
  var BRANCH_PATTERN2 = /^[A-Za-z0-9_.-]{1,64}$/;
4053
4458
  var MUTATING_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
4054
4459
  var effects = /* @__PURE__ */ new WeakMap();
@@ -4087,6 +4492,22 @@ var operationMatcher = (document3) => {
4087
4492
  return (request, path) => matchers.find((m) => m.method === request.method && m.pattern.test(path))?.operationId;
4088
4493
  };
4089
4494
  var createRuntime = (options) => {
4495
+ const adminPrefix = resolveAdminPrefix(options.adminPrefix);
4496
+ const internalPaths = Object.entries(options.document?.paths ?? {}).filter(([, item]) => item["x-mockingbird-internal"] === true).map(([path]) => path);
4497
+ for (const path of internalPaths) {
4498
+ if (!path.startsWith(`${ADMIN_PREFIX}/blobs/`))
4499
+ throw new Error(`internal transport ${path} must live under ${ADMIN_PREFIX}/blobs/`);
4500
+ }
4501
+ assertAdminPrefixAvailable(adminPrefix, Object.keys(options.document?.paths ?? {}).filter((path) => !internalPaths.includes(path)));
4502
+ const internalMatch = (path) => {
4503
+ for (const pattern of internalPaths) {
4504
+ const actual = path.split("/");
4505
+ const expected = pattern.split("/");
4506
+ if (actual.length === expected.length && expected.every((part, i) => part.startsWith("{") || part === actual[i]))
4507
+ return pattern;
4508
+ }
4509
+ return void 0;
4510
+ };
4090
4511
  assertAdminUi(options.adminUi);
4091
4512
  const sqlite = bootSqlite(options.sqlite);
4092
4513
  const clock = options.clock ?? createClock();
@@ -4101,6 +4522,20 @@ var createRuntime = (options) => {
4101
4522
  const instances = /* @__PURE__ */ new Map();
4102
4523
  const publicNamespaces = /* @__PURE__ */ new Set();
4103
4524
  const branchRngs = /* @__PURE__ */ new Map();
4525
+ const namespaceClocks = /* @__PURE__ */ new Map();
4526
+ const namespaceForStorage = /* @__PURE__ */ new Map();
4527
+ let isolatedNamespaces = false;
4528
+ const namespaceClock = (name = DEFAULT_NAMESPACE) => {
4529
+ if (!isolatedNamespaces || name === DEFAULT_NAMESPACE)
4530
+ return clock;
4531
+ let found = namespaceClocks.get(name);
4532
+ if (!found) {
4533
+ found = createClock();
4534
+ namespaceClocks.set(name, found);
4535
+ }
4536
+ return found;
4537
+ };
4538
+ const fleetPoints = /* @__PURE__ */ new WeakMap();
4104
4539
  const timelines = /* @__PURE__ */ new Map();
4105
4540
  const branchStorage = /* @__PURE__ */ new Map();
4106
4541
  const captured = /* @__PURE__ */ new Map();
@@ -4114,17 +4549,22 @@ var createRuntime = (options) => {
4114
4549
  if (!NAMESPACE_PATTERN.test(key) || !NAMESPACE_PATTERN.test(publicNamespace)) {
4115
4550
  throw new RangeError(`namespace must match ${NAMESPACE_PATTERN}: ${JSON.stringify(publicNamespace)}`);
4116
4551
  }
4552
+ const createdContextRng = isolatedRng ?? (isolatedNamespaces && key !== DEFAULT_NAMESPACE ? createRng(options.seed ?? 0) : rng);
4117
4553
  const created = options.create({
4118
4554
  namespace: storageNamespace(key),
4119
4555
  publicNamespace,
4556
+ adminPrefix,
4120
4557
  sqlite,
4121
- clock,
4122
- rng: isolatedRng ?? rng
4558
+ clock: namespaceClock(publicNamespace),
4559
+ rng: createdContextRng
4123
4560
  });
4124
4561
  instances.set(key, created);
4562
+ namespaceForStorage.set(key, publicNamespace);
4125
4563
  publicNamespaces.add(publicNamespace);
4126
4564
  if (isolatedRng)
4127
4565
  branchRngs.set(key, isolatedRng);
4566
+ else if (isolatedNamespaces && key !== DEFAULT_NAMESPACE)
4567
+ branchRngs.set(key, createdContextRng);
4128
4568
  return created;
4129
4569
  };
4130
4570
  const instance = (name = DEFAULT_NAMESPACE) => instanceFor(name);
@@ -4142,7 +4582,7 @@ var createRuntime = (options) => {
4142
4582
  captured.set(storage, snapshot2);
4143
4583
  return Object.freeze({
4144
4584
  snapshot: snapshot2,
4145
- clock: Object.freeze(clock.state()),
4585
+ clock: Object.freeze(namespaceClock(namespaceForStorage.get(storage) ?? storage).state()),
4146
4586
  rngState: (branchRngs.get(storage) ?? rng).state()
4147
4587
  });
4148
4588
  };
@@ -4152,7 +4592,7 @@ var createRuntime = (options) => {
4152
4592
  return found;
4153
4593
  instance(name);
4154
4594
  found = new Timeline({
4155
- now: clock.now,
4595
+ now: namespaceClock(name).now,
4156
4596
  ...options.maxCheckpoints !== void 0 ? { maxCheckpoints: options.maxCheckpoints } : {}
4157
4597
  });
4158
4598
  found.commit(capture(name));
@@ -4179,12 +4619,13 @@ var createRuntime = (options) => {
4179
4619
  const point = history2.checkout("main", at);
4180
4620
  restoreNamespace(sqlite, storageNamespace(namespace), point.value.snapshot);
4181
4621
  captured.set(namespace, point.value.snapshot);
4182
- rng.setState(point.value.rngState);
4183
- clock.set(point.value.clock.now);
4622
+ (branchRngs.get(namespace) ?? rng).setState(point.value.rngState);
4623
+ const selectedClock = namespaceClock(namespace);
4624
+ selectedClock.set(point.value.clock.now);
4184
4625
  if (point.value.clock.frozen)
4185
- clock.freeze();
4626
+ selectedClock.freeze();
4186
4627
  else
4187
- clock.unfreeze();
4628
+ selectedClock.unfreeze();
4188
4629
  }
4189
4630
  return namespace;
4190
4631
  }
@@ -4242,11 +4683,12 @@ var createRuntime = (options) => {
4242
4683
  const storage = ensureBranch(namespace, branchName);
4243
4684
  restoreNamespace(sqlite, storageNamespace(storage), point.value.snapshot);
4244
4685
  captured.set(storage, point.value.snapshot);
4245
- clock.set(point.value.clock.now);
4686
+ const selectedClock = namespaceClock(namespace);
4687
+ selectedClock.set(point.value.clock.now);
4246
4688
  if (point.value.clock.frozen)
4247
- clock.freeze();
4689
+ selectedClock.freeze();
4248
4690
  else
4249
- clock.unfreeze();
4691
+ selectedClock.unfreeze();
4250
4692
  (branchRngs.get(storage) ?? rng).setState(point.value.rngState);
4251
4693
  };
4252
4694
  const reset = async (name = DEFAULT_NAMESPACE) => {
@@ -4254,10 +4696,21 @@ var createRuntime = (options) => {
4254
4696
  options.webhooks?.clear();
4255
4697
  for (const each of instances.values())
4256
4698
  await each.reset();
4699
+ for (const each of branchRngs.values())
4700
+ each.reset();
4257
4701
  timelines.clear();
4702
+ for (const storage of branchStorage.values()) {
4703
+ instances.delete(storage);
4704
+ branchRngs.delete(storage);
4705
+ namespaceForStorage.delete(storage);
4706
+ }
4258
4707
  branchStorage.clear();
4259
- branchRngs.clear();
4260
4708
  captured.clear();
4709
+ faults.clear();
4710
+ clock.reset();
4711
+ rng.reset();
4712
+ for (const each of namespaceClocks.values())
4713
+ each.reset();
4261
4714
  return;
4262
4715
  }
4263
4716
  options.webhooks?.clear(name);
@@ -4275,11 +4728,18 @@ var createRuntime = (options) => {
4275
4728
  else
4276
4729
  clearNamespace(sqlite, storageNamespace(storage));
4277
4730
  branchStorage.delete(mapping);
4731
+ instances.delete(storage);
4732
+ namespaceForStorage.delete(storage);
4278
4733
  branchRngs.delete(storage);
4279
4734
  captured.delete(storage);
4280
4735
  }
4281
4736
  timelines.delete(name);
4282
4737
  captured.delete(name);
4738
+ faults.restore(name, { rules: [], rngState: createRng(options.seed ?? 0).state() });
4739
+ namespaceClock(name).reset();
4740
+ (branchRngs.get(name) ?? rng).reset();
4741
+ journal.clear(name);
4742
+ metrics.reset(name);
4283
4743
  };
4284
4744
  const snapshot = (name = DEFAULT_NAMESPACE) => {
4285
4745
  return checkpoint(name, "main").value.snapshot;
@@ -4317,6 +4777,53 @@ var createRuntime = (options) => {
4317
4777
  rng,
4318
4778
  credentials,
4319
4779
  webhooks: options.webhooks,
4780
+ isolateNamespaces() {
4781
+ if (publicNamespaces.size > 1)
4782
+ throw new Error("enable namespace isolation before creating named namespaces");
4783
+ isolatedNamespaces = true;
4784
+ faults.isolateNamespaces();
4785
+ },
4786
+ namespaceClock,
4787
+ namespaceOf(request) {
4788
+ const url = new URL(request.url);
4789
+ const prefixed = matchNamespacePath(url.pathname, adminPrefix);
4790
+ const selected = request.headers.get(NAMESPACE_HEADER) ?? (prefixed ? decodeURIComponent(prefixed[1]) : void 0);
4791
+ const path = prefixed ? prefixed[2] ?? "/" : url.pathname;
4792
+ if (isAdminPath(path, adminPrefix))
4793
+ return url.searchParams.get("namespace") ?? selected ?? DEFAULT_NAMESPACE;
4794
+ if (selected !== void 0)
4795
+ return selected;
4796
+ const credential = options.credential?.(request);
4797
+ return (credential !== void 0 ? credentials.get(credential) : void 0) ?? DEFAULT_NAMESPACE;
4798
+ },
4799
+ fleetSnapshot(namespace) {
4800
+ instance(namespace);
4801
+ const handle = {};
4802
+ fleetPoints.set(handle, {
4803
+ namespace,
4804
+ state: capture(namespace),
4805
+ faults: faults.snapshot(namespace),
4806
+ webhooks: options.webhooks?.snapshot(namespace)
4807
+ });
4808
+ return handle;
4809
+ },
4810
+ fleetRestore(handle, namespace) {
4811
+ const point = typeof handle === "object" && handle !== null ? fleetPoints.get(handle) : void 0;
4812
+ if (!point || point.namespace !== namespace)
4813
+ throw new Error("invalid fleet checkpoint");
4814
+ restoreNamespace(sqlite, storageNamespace(namespace), point.state.snapshot);
4815
+ captured.set(namespace, point.state.snapshot);
4816
+ const selectedClock = namespaceClock(namespace);
4817
+ selectedClock.freeze();
4818
+ selectedClock.set(point.state.clock.now);
4819
+ if (!point.state.clock.frozen)
4820
+ selectedClock.unfreeze();
4821
+ (branchRngs.get(namespace) ?? rng).setState(point.state.rngState);
4822
+ faults.restore(namespace, point.faults);
4823
+ if (point.webhooks !== void 0)
4824
+ options.webhooks?.restore(namespace, point.webhooks);
4825
+ timelines.delete(namespace);
4826
+ },
4320
4827
  applyPreset: (name, namespace = DEFAULT_NAMESPACE, overrides = {}) => {
4321
4828
  const preset = options.presets?.[name];
4322
4829
  if (!preset)
@@ -4348,7 +4855,7 @@ var createRuntime = (options) => {
4348
4855
  state: (namespace = DEFAULT_NAMESPACE) => inspectState(stateScope(namespace)),
4349
4856
  fetch: async (incoming) => {
4350
4857
  let request = incoming;
4351
- const prefixed = PATH_PREFIX.exec(new URL(request.url).pathname);
4858
+ const prefixed = matchNamespacePath(new URL(request.url).pathname, adminPrefix);
4352
4859
  if (prefixed) {
4353
4860
  const url2 = new URL(request.url);
4354
4861
  url2.pathname = prefixed[2] ?? "/";
@@ -4364,7 +4871,15 @@ var createRuntime = (options) => {
4364
4871
  signal: request.signal
4365
4872
  });
4366
4873
  }
4367
- let namespace = control.namespaceOf(request);
4874
+ const internalUrl = new URL(request.url);
4875
+ const internalPath = isAdminPath(internalUrl.pathname, adminPrefix) ? `${ADMIN_PREFIX}${internalUrl.pathname.slice(adminPrefix.length)}` : "";
4876
+ const internal = internalMatch(internalPath);
4877
+ const obsoleteInternalPath = adminPrefix !== ADMIN_PREFIX && internalMatch(internalUrl.pathname) && !internal;
4878
+ if (internal) {
4879
+ internalUrl.pathname = internalPath;
4880
+ request = new Request(internalUrl, request);
4881
+ }
4882
+ let namespace = internal ? internalUrl.searchParams.get("namespace") ?? control.namespaceOf(request) : control.namespaceOf(request);
4368
4883
  if (!request.headers.has(NAMESPACE_HEADER) && options.credential) {
4369
4884
  const credential = options.credential(request);
4370
4885
  const mapped = credential !== void 0 ? credentials.get(credential) : void 0;
@@ -4384,7 +4899,9 @@ var createRuntime = (options) => {
4384
4899
  return copy;
4385
4900
  }
4386
4901
  };
4387
- const handled = await control.handle(request);
4902
+ if (obsoleteInternalPath)
4903
+ return stamp(adminFail(404, "internal transport is only available under adminPrefix"));
4904
+ const handled = internal ? void 0 : await control.handle(request);
4388
4905
  if (handled)
4389
4906
  return stamp(handled);
4390
4907
  const started = monotonicNow();
@@ -4425,7 +4942,7 @@ var createRuntime = (options) => {
4425
4942
  type: "mockingbird_admin",
4426
4943
  message: `${NAMESPACE_HEADER} must match ${NAMESPACE_PATTERN}`
4427
4944
  }
4428
- }), { status: 400, headers: { "content-type": "application/json" } }));
4945
+ }), { status: 400, headers: { "content-type": "application/json; charset=utf-8" } }));
4429
4946
  }
4430
4947
  if (!BRANCH_PATTERN2.test(selectedBranch)) {
4431
4948
  log(400);
@@ -4492,6 +5009,7 @@ var createRuntime = (options) => {
4492
5009
  startedAt: wallNow(),
4493
5010
  wallNow,
4494
5011
  clock,
5012
+ namespaceClock,
4495
5013
  faults,
4496
5014
  metrics,
4497
5015
  journal,
@@ -4541,15 +5059,16 @@ var createRuntime = (options) => {
4541
5059
  ...options.admin?.(runtime) ?? {},
4542
5060
  // Shell routes win a colliding key. Fault presets are one of them: the admin page
4543
5061
  // maps `presets`, and a service route must not replace that list with a record.
4544
- ...options.presets ? presetRoutes(options.presets, runtime) : {},
5062
+ ...options.presets ? presetRoutes(options.presets, runtime, adminPrefix) : {},
4545
5063
  ...stateAdminRoutes({
4546
5064
  open: stateScope,
4547
5065
  afterWrite: (namespace) => {
4548
5066
  checkpoint(namespace, "main");
4549
5067
  }
4550
5068
  }),
4551
- ...adminUiRoutes(options.name, options.adminUi)
5069
+ ...adminUiRoutes(options.name, options.adminUi, adminPrefix)
4552
5070
  },
5071
+ adminPrefix,
4553
5072
  adminKey: options.adminKey
4554
5073
  });
4555
5074
  return defineMock(runtime);
@@ -4563,7 +5082,10 @@ var mutableResponse = (response) => {
4563
5082
  return new Response(response.body, response);
4564
5083
  }
4565
5084
  };
4566
- var adminJson = (status, body) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
5085
+ var adminJson = (status, body) => new Response(JSON.stringify(body), {
5086
+ status,
5087
+ headers: { "content-type": "application/json; charset=utf-8" }
5088
+ });
4567
5089
  var adminFail = (status, message) => adminJson(status, { error: { type: "mockingbird_admin", message } });
4568
5090
  var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4569
5091
  var credentialRoutes = (registry) => ({
@@ -4615,7 +5137,7 @@ var credentialRoutes = (registry) => ({
4615
5137
  return adminJson(200, { status: "ok" });
4616
5138
  }
4617
5139
  });
4618
- var presetRoutes = (presets, runtime) => ({
5140
+ var presetRoutes = (presets, runtime, adminPrefix) => ({
4619
5141
  "GET /faults/presets": () => {
4620
5142
  const body = {
4621
5143
  presets: Object.entries(presets).map(([name, preset]) => ({ name, ...preset }))
@@ -4625,7 +5147,7 @@ var presetRoutes = (presets, runtime) => ({
4625
5147
  "POST /faults/presets/:name": ({ params, body, namespace }) => {
4626
5148
  const name = params.name;
4627
5149
  if (!presets[name])
4628
- return adminFail(404, `no fault preset ${name}; GET /__admin/faults/presets`);
5150
+ return adminFail(404, `no fault preset ${name}; GET ${adminPrefix}/faults/presets`);
4629
5151
  const overrides = isObject(body) ? body : {};
4630
5152
  return adminJson(201, { preset: name, rules: runtime.applyPreset(name, namespace, overrides) });
4631
5153
  }
@@ -4701,6 +5223,7 @@ var createRuntime2 = (options = {}) => createRuntime({
4701
5223
  ...options.sqlite ? { sqlite: options.sqlite } : {},
4702
5224
  ...options.clock ? { clock: options.clock } : {},
4703
5225
  ...options.seed !== void 0 ? { seed: options.seed } : {},
5226
+ ...options.adminPrefix !== void 0 ? { adminPrefix: options.adminPrefix } : {},
4704
5227
  ...options.adminKey !== void 0 ? { adminKey: options.adminKey } : {},
4705
5228
  ...options.onLog ? { onLog: options.onLog } : {},
4706
5229
  credential: accessKeyCredential,
@@ -4960,6 +5483,9 @@ var StepFunctionsAPI = class {
4960
5483
  };
4961
5484
 
4962
5485
  export {
5486
+ createClock,
5487
+ resolveAdminPrefix,
5488
+ parseDuration,
4963
5489
  document2 as document,
4964
5490
  operationIds,
4965
5491
  supportedOperationIds,
@@ -4969,4 +5495,4 @@ export {
4969
5495
  accessKeyCredential,
4970
5496
  StepFunctionsAPI
4971
5497
  };
4972
- //# sourceMappingURL=chunk-E4E6U7WJ.js.map
5498
+ //# sourceMappingURL=chunk-EBPLA3FJ.js.map