@crvouga/mockingbird-service-prism 1.7.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -110,13 +110,31 @@ var Collection = class {
110
110
  };
111
111
 
112
112
  // ../core/dist/control.js
113
- var HEALTH_PATH = "/health";
114
113
  var ADMIN_PREFIX = "/__admin";
114
+ var resolveAdminPrefix = (prefix = ADMIN_PREFIX) => {
115
+ if (!/^\/[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_-]+)*$/.test(prefix)) {
116
+ throw new Error("adminPrefix must be an absolute non-root path of literal segments, without a trailing slash");
117
+ }
118
+ return prefix;
119
+ };
120
+ var isAdminPath = (path, prefix) => path === prefix || path.startsWith(`${prefix}/`);
121
+ var matchNamespacePath = (path, prefix) => /^\/ns\/([^/]+)(\/.*)?$/.exec(isAdminPath(path, prefix) ? path.slice(prefix.length) : "");
122
+ var assertAdminPrefixAvailable = (prefix, paths) => {
123
+ const reserved = prefix.split("/").slice(1);
124
+ for (const path of paths) {
125
+ const segments = path.split("/").slice(1);
126
+ if (segments.length < reserved.length || /^\{[^}]+\}$/.test(segments[0] ?? ""))
127
+ continue;
128
+ if (reserved.every((part, i) => segments[i] === part || /^\{[^}]+\}$/.test(segments[i] ?? ""))) {
129
+ throw new Error(`vendor route ${path} collides with adminPrefix ${prefix}; choose a different adminPrefix`);
130
+ }
131
+ }
132
+ };
115
133
  var ADMIN_KEY_HEADER = "x-mockingbird-admin-key";
116
134
  var NAMESPACE_HEADER = "x-mockingbird-namespace";
117
135
  var json = (status, body) => new Response(JSON.stringify(body), {
118
136
  status,
119
- headers: { "content-type": "application/json" }
137
+ headers: { "content-type": "application/json; charset=utf-8" }
120
138
  });
121
139
  var adminError = (status, message) => json(status, { error: { type: "mockingbird_admin", message } });
122
140
  var UNITS = {
@@ -168,11 +186,25 @@ var readJson = async (request) => {
168
186
  };
169
187
  var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
170
188
  var createControlPlane = (context) => {
189
+ const adminPrefix = resolveAdminPrefix(context.adminPrefix);
171
190
  const snapshots = /* @__PURE__ */ new Map();
172
191
  let snapshotCounter = 0;
173
192
  const headerNamespace = (request) => request.headers.get(NAMESPACE_HEADER) ?? context.defaultNamespace;
174
193
  const adminNamespace = (request, url) => url.searchParams.get("namespace") ?? headerNamespace(request);
194
+ const health = () => {
195
+ return json(200, {
196
+ ...context.describe(),
197
+ status: "ok",
198
+ service: context.name,
199
+ uptimeMs: context.wallNow() - context.startedAt,
200
+ clock: context.clock.state(),
201
+ namespaces: context.namespaces().length,
202
+ adminPrefix,
203
+ adminUi: `${adminPrefix}/ui`
204
+ });
205
+ };
175
206
  const builtin = {
207
+ "GET /health": health,
176
208
  "GET /": () => json(200, {
177
209
  service: context.name,
178
210
  routes: [...Object.keys(builtin), ...Object.keys(context.routes)].sort()
@@ -183,29 +215,30 @@ var createControlPlane = (context) => {
183
215
  return json(200, { status: "ok", reset: target === "*" ? context.namespaces() : [target] });
184
216
  },
185
217
  "GET /namespaces": () => json(200, { default: context.defaultNamespace, namespaces: context.namespaces() }),
186
- "GET /clock": () => json(200, context.clock.state()),
187
- "POST /clock": ({ body }) => {
218
+ "GET /clock": ({ namespace }) => json(200, (context.namespaceClock?.(namespace) ?? context.clock).state()),
219
+ "POST /clock": ({ body, namespace }) => {
220
+ const clock = context.namespaceClock?.(namespace) ?? context.clock;
188
221
  if (!isRecord(body))
189
222
  return adminError(400, "expected a JSON object");
190
223
  if (body.reset === true)
191
- context.clock.reset();
224
+ clock.reset();
192
225
  if (body.freeze === true)
193
- context.clock.freeze();
226
+ clock.freeze();
194
227
  if (body.set !== void 0) {
195
228
  const instant = parseInstant(body.set);
196
229
  if (instant === void 0)
197
230
  return adminError(400, "set: expected epoch ms or ISO-8601");
198
- context.clock.set(instant);
231
+ clock.set(instant);
199
232
  }
200
233
  if (body.advance !== void 0) {
201
234
  const delta = parseDuration(body.advance);
202
235
  if (delta === void 0)
203
236
  return adminError(400, 'advance: expected ms or "15m"-style');
204
- context.clock.advance(delta);
237
+ clock.advance(delta);
205
238
  }
206
239
  if (body.freeze === false)
207
- context.clock.unfreeze();
208
- return json(200, context.clock.state());
240
+ clock.unfreeze();
241
+ return json(200, clock.state());
209
242
  },
210
243
  "GET /faults": () => json(200, { faults: context.faults.list() }),
211
244
  "POST /faults": ({ body, namespace }) => {
@@ -338,7 +371,14 @@ var createControlPlane = (context) => {
338
371
  return json(200, { status: "ok" });
339
372
  }
340
373
  };
341
- const routes = [...Object.entries(context.routes), ...Object.entries(builtin)].map(([key, handler]) => {
374
+ for (const key of Object.keys(context.routes)) {
375
+ if (Object.hasOwn(builtin, key))
376
+ throw new Error(`admin route ${key} overrides a shared route`);
377
+ const path = key.slice(key.indexOf(" ") + 1);
378
+ if (isAdminPath(path, "/ns") || isAdminPath(path, "/blobs"))
379
+ throw new Error(`admin route ${key} overlaps an internal transport`);
380
+ }
381
+ const routes = [...Object.entries(builtin), ...Object.entries(context.routes)].map(([key, handler]) => {
342
382
  const space = key.indexOf(" ");
343
383
  return { method: key.slice(0, space), pattern: key.slice(space + 1), handler };
344
384
  });
@@ -346,21 +386,13 @@ var createControlPlane = (context) => {
346
386
  namespaceOf: headerNamespace,
347
387
  async handle(request) {
348
388
  const url = new URL(request.url);
349
- if (url.pathname === HEALTH_PATH && request.method === "GET") {
350
- return json(200, {
351
- status: "ok",
352
- service: context.name,
353
- uptimeMs: context.wallNow() - context.startedAt,
354
- clock: context.clock.state(),
355
- namespaces: context.namespaces().length,
356
- adminUi: `${ADMIN_PREFIX}/ui`,
357
- ...context.describe()
358
- });
389
+ if (url.pathname === `${adminPrefix}/health` && request.method === "GET") {
390
+ return health();
359
391
  }
360
- if (url.pathname !== ADMIN_PREFIX && !url.pathname.startsWith(`${ADMIN_PREFIX}/`)) {
392
+ if (!isAdminPath(url.pathname, adminPrefix)) {
361
393
  return void 0;
362
394
  }
363
- const path = url.pathname.slice(ADMIN_PREFIX.length) || "/";
395
+ const path = url.pathname.slice(adminPrefix.length) || "/";
364
396
  const shell = request.method === "GET" && (path === "/ui" || path === "/ui/");
365
397
  if (!shell && context.adminKey !== void 0 && request.headers.get(ADMIN_KEY_HEADER) !== context.adminKey) {
366
398
  return adminError(401, `missing or wrong ${ADMIN_KEY_HEADER}`);
@@ -368,7 +400,12 @@ var createControlPlane = (context) => {
368
400
  for (const route of routes) {
369
401
  if (route.method !== request.method)
370
402
  continue;
371
- const params = matchRoute(route.pattern, path);
403
+ let params;
404
+ try {
405
+ params = matchRoute(route.pattern, path);
406
+ } catch {
407
+ return adminError(400, "invalid path encoding");
408
+ }
372
409
  if (!params)
373
410
  continue;
374
411
  let body;
@@ -377,15 +414,18 @@ var createControlPlane = (context) => {
377
414
  } catch {
378
415
  return adminError(400, "request body is not valid JSON");
379
416
  }
417
+ const namespace = adminNamespace(request, url);
418
+ if (!/^[A-Za-z0-9_.-]{1,64}$/.test(namespace))
419
+ return adminError(400, "invalid namespace");
380
420
  return route.handler({
381
421
  request,
382
422
  url,
383
423
  params,
384
- namespace: adminNamespace(request, url),
424
+ namespace,
385
425
  body
386
426
  });
387
427
  }
388
- return adminError(404, `no admin route ${request.method} ${path}; GET ${ADMIN_PREFIX} lists them`);
428
+ return adminError(404, `no admin route ${request.method} ${path}; GET ${adminPrefix} lists them`);
389
429
  }
390
430
  };
391
431
  };
@@ -474,7 +514,33 @@ var faultResponse = (rule) => {
474
514
  };
475
515
  var createFaultRegistry = (rng = createRng(0), sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) => {
476
516
  const entries = [];
517
+ let isolated = false;
518
+ const scopedRngs = /* @__PURE__ */ new Map();
519
+ const random = (namespace) => {
520
+ if (!isolated || namespace === "default")
521
+ return rng;
522
+ let scoped = scopedRngs.get(namespace);
523
+ if (!scoped) {
524
+ scoped = createRng(rng.seed);
525
+ scopedRngs.set(namespace, scoped);
526
+ }
527
+ return scoped;
528
+ };
477
529
  return {
530
+ isolateNamespaces() {
531
+ isolated = true;
532
+ },
533
+ snapshot(namespace) {
534
+ return {
535
+ rules: structuredClone(entries.map((entry, position) => ({
536
+ ...entry.rule,
537
+ remaining: entry.remaining,
538
+ hits: entry.hits,
539
+ position
540
+ })).filter((entry) => entry.namespace === namespace)),
541
+ rngState: random(namespace).state()
542
+ };
543
+ },
478
544
  add(rule) {
479
545
  const existing = entries.findIndex((e) => e.rule.id === rule.id);
480
546
  const entry = { rule, remaining: rule.count ?? null, hits: 0 };
@@ -494,6 +560,16 @@ var createFaultRegistry = (rng = createRng(0), sleep = (ms) => new Promise((reso
494
560
  },
495
561
  clear() {
496
562
  entries.length = 0;
563
+ rng.reset();
564
+ scopedRngs.clear();
565
+ },
566
+ restore(namespace, state) {
567
+ for (let i = entries.length - 1; i >= 0; i--)
568
+ if (entries[i]?.rule.namespace === namespace)
569
+ entries.splice(i, 1);
570
+ for (const { remaining, hits, position, ...rule } of state.rules)
571
+ entries.splice(Math.min(position, entries.length), 0, { rule, remaining, hits });
572
+ random(namespace).setState(state.rngState);
497
573
  },
498
574
  async take(candidate) {
499
575
  const hits = [];
@@ -503,7 +579,7 @@ var createFaultRegistry = (rng = createRng(0), sleep = (ms) => new Promise((reso
503
579
  if (!matches(entry.rule, candidate))
504
580
  continue;
505
581
  const rate = entry.rule.rate ?? 1;
506
- if (rng.next() >= rate)
582
+ if (random(candidate.namespace).next() >= rate)
507
583
  continue;
508
584
  entry.hits++;
509
585
  if (entry.remaining !== null)
@@ -1071,7 +1147,8 @@ var annotateResponse = (response, extra) => {
1071
1147
  var responseNotes = (response) => notes.get(response);
1072
1148
 
1073
1149
  // ../core/dist/metrics.js
1074
- var createMetrics = () => {
1150
+ var createMetrics = (partition = true) => {
1151
+ const namespaces = /* @__PURE__ */ new Map();
1075
1152
  let requests = 0;
1076
1153
  let faults = 0;
1077
1154
  let totalDurationMs = 0;
@@ -1079,6 +1156,14 @@ var createMetrics = () => {
1079
1156
  const unmatched = /* @__PURE__ */ new Map();
1080
1157
  return {
1081
1158
  record(entry) {
1159
+ if (partition) {
1160
+ let scoped = namespaces.get(entry.namespace);
1161
+ if (!scoped) {
1162
+ scoped = createMetrics(false);
1163
+ namespaces.set(entry.namespace, scoped);
1164
+ }
1165
+ scoped.record(entry);
1166
+ }
1082
1167
  requests++;
1083
1168
  totalDurationMs += entry.durationMs;
1084
1169
  if (entry.faultId !== void 0)
@@ -1090,7 +1175,7 @@ var createMetrics = () => {
1090
1175
  unmatched.set(route, (unmatched.get(route) ?? 0) + 1);
1091
1176
  }
1092
1177
  },
1093
- report: () => ({
1178
+ report: (namespace) => namespace !== void 0 ? (namespaces.get(namespace) ?? createMetrics(false)).report() : {
1094
1179
  requests,
1095
1180
  byOperation: Object.fromEntries([...byOperation].sort(([a], [b]) => a.localeCompare(b))),
1096
1181
  unmatched: [...unmatched].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([route, count]) => {
@@ -1099,8 +1184,13 @@ var createMetrics = () => {
1099
1184
  }),
1100
1185
  faults,
1101
1186
  totalDurationMs
1102
- }),
1103
- reset() {
1187
+ },
1188
+ reset(namespace) {
1189
+ if (namespace !== void 0) {
1190
+ namespaces.delete(namespace);
1191
+ return;
1192
+ }
1193
+ namespaces.clear();
1104
1194
  requests = 0;
1105
1195
  faults = 0;
1106
1196
  totalDurationMs = 0;
@@ -1713,6 +1803,34 @@ dialog:not([open]) {
1713
1803
  function isRecord2(value) {
1714
1804
  return typeof value === "object" && value !== null && !Array.isArray(value);
1715
1805
  }
1806
+ function highlightJsonText(text) {
1807
+ const escapeToken = (value) => value.replace(/[&<>"']/g, (char) => {
1808
+ switch (char) {
1809
+ case "&":
1810
+ return "&amp;";
1811
+ case "<":
1812
+ return "&lt;";
1813
+ case ">":
1814
+ return "&gt;";
1815
+ case '"':
1816
+ return "&quot;";
1817
+ default:
1818
+ return "&#39;";
1819
+ }
1820
+ });
1821
+ 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;
1822
+ let cursor = 0;
1823
+ let html = "";
1824
+ for (const match of text.matchAll(token)) {
1825
+ const index = match.index ?? 0;
1826
+ const value = match[0];
1827
+ html += escapeToken(text.slice(cursor, index));
1828
+ const kind = value.startsWith('"') ? value.trimEnd().endsWith(":") ? "key" : "string" : value === "true" || value === "false" ? "boolean" : value === "null" ? "null" : "number";
1829
+ html += `<span class="json-${kind}">${escapeToken(value)}</span>`;
1830
+ cursor = index + value.length;
1831
+ }
1832
+ return html + escapeToken(text.slice(cursor));
1833
+ }
1716
1834
  function mountAdminBrand(doc, host, service, catalog) {
1717
1835
  const clip = (value, max) => {
1718
1836
  if (typeof value !== "string")
@@ -2074,14 +2192,16 @@ function bootAdmin(config) {
2074
2192
  const state = {
2075
2193
  namespace: "default",
2076
2194
  key: sessionStorage.getItem("mockingbird-admin-key") || "",
2077
- view: "overview",
2195
+ view: ["overview", "state", "clock", "faults", "journal", "routes"].includes(location.hash.slice(1)) ? location.hash.slice(1) : "overview",
2078
2196
  collection: "",
2079
2197
  shape: null,
2080
2198
  route: null,
2199
+ routes: [],
2081
2200
  editing: null
2082
2201
  };
2083
2202
  const standard = new Set(config.standardRoutes);
2084
2203
  const banner = el("banner", HTMLElement);
2204
+ const bannerMessage = el("banner-message", HTMLElement);
2085
2205
  const keyInput = el("admin-key", HTMLInputElement);
2086
2206
  keyInput.value = state.key;
2087
2207
  const params = new URLSearchParams(location.search);
@@ -2092,18 +2212,21 @@ function bootAdmin(config) {
2092
2212
  sessionStorage.setItem("mockingbird-admin-key", state.key);
2093
2213
  params.delete("key");
2094
2214
  const next = params.toString();
2095
- history.replaceState(null, "", location.pathname + (next ? `?${next}` : ""));
2215
+ history.replaceState(null, "", location.pathname + (next ? `?${next}` : "") + location.hash);
2096
2216
  }
2097
2217
  const queryNamespace = params.get("namespace");
2098
2218
  if (queryNamespace)
2099
2219
  state.namespace = queryNamespace;
2100
2220
  const showError = (error) => {
2101
- banner.textContent = error instanceof Error ? error.message : String(error);
2221
+ bannerMessage.textContent = error instanceof Error ? error.message : String(error);
2102
2222
  banner.classList.add("show");
2223
+ banner.scrollIntoView({ behavior: "smooth", block: "nearest" });
2103
2224
  };
2104
2225
  const clearError = () => {
2105
2226
  banner.classList.remove("show");
2227
+ bannerMessage.textContent = "";
2106
2228
  };
2229
+ el("banner-dismiss", HTMLButtonElement).addEventListener("click", clearError);
2107
2230
  const api = async (method, path, body) => {
2108
2231
  const headers = { accept: "application/json" };
2109
2232
  if (state.key !== "")
@@ -2147,16 +2270,21 @@ function bootAdmin(config) {
2147
2270
  namespace: () => state.namespace
2148
2271
  };
2149
2272
  const isPanelScript = (value) => typeof value === "function";
2273
+ const themeButton = el("theme", HTMLButtonElement);
2150
2274
  const setTheme = (theme) => {
2151
2275
  if (theme)
2152
2276
  document.documentElement.dataset.theme = theme;
2153
2277
  else
2154
2278
  delete document.documentElement.dataset.theme;
2279
+ const dark = theme ? theme === "dark" : matchMedia("(prefers-color-scheme: dark)").matches;
2280
+ 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>';
2281
+ const label = dark ? "Use light theme" : "Use dark theme";
2282
+ themeButton.setAttribute("aria-label", label);
2283
+ themeButton.title = label;
2155
2284
  };
2156
2285
  const storedTheme = localStorage.getItem("mockingbird-admin-theme");
2157
- if (storedTheme)
2158
- setTheme(storedTheme);
2159
- el("theme", HTMLButtonElement).addEventListener("click", () => {
2286
+ setTheme(storedTheme === "dark" || storedTheme === "light" ? storedTheme : null);
2287
+ themeButton.addEventListener("click", () => {
2160
2288
  const current = document.documentElement.dataset.theme;
2161
2289
  const dark = current ? current === "dark" : matchMedia("(prefers-color-scheme: dark)").matches;
2162
2290
  const next = dark ? "light" : "dark";
@@ -2175,6 +2303,7 @@ function bootAdmin(config) {
2175
2303
  continue;
2176
2304
  section.hidden = section.id !== `view-${view}`;
2177
2305
  }
2306
+ history.replaceState(null, "", `${location.pathname}${location.search}#${encodeURIComponent(view)}`);
2178
2307
  };
2179
2308
  el("nav", HTMLElement).addEventListener("click", (event) => {
2180
2309
  const target = event.target;
@@ -2189,7 +2318,7 @@ function bootAdmin(config) {
2189
2318
  show(view);
2190
2319
  refresh().catch(showError);
2191
2320
  });
2192
- 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>`;
2321
+ 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>`;
2193
2322
  const table = (columns, rows) => {
2194
2323
  if (rows.length === 0)
2195
2324
  return '<p class="empty">Nothing here yet.</p>';
@@ -2199,16 +2328,22 @@ function bootAdmin(config) {
2199
2328
  const attrs = "attrs" in row && typeof row.attrs === "string" ? row.attrs : "";
2200
2329
  return `<tr${attrs}>${cells}</tr>`;
2201
2330
  }).join("");
2202
- return `<table><thead>${head}</thead><tbody>${body}</tbody></table>`;
2331
+ return `<div class="table-shell"><table><thead>${head}</thead><tbody>${body}</tbody></table></div>`;
2203
2332
  };
2204
2333
  const preview = (value) => {
2205
2334
  const text = JSON.stringify(value) ?? "null";
2206
- return esc(text.length > 180 ? `${text.slice(0, 180)}\u2026` : text);
2335
+ const clipped = text.length > 180 ? `${text.slice(0, 180)}\u2026` : text;
2336
+ return `<code class="json-inline">${highlightJsonText(clipped)}</code>`;
2207
2337
  };
2208
2338
  const when = (epoch) => {
2209
2339
  const date = new Date(epoch);
2210
2340
  return Number.isNaN(date.getTime()) ? String(epoch) : date.toISOString();
2211
2341
  };
2342
+ const journalWhen = (value) => {
2343
+ const numeric = value.trim() === "" ? Number.NaN : Number(value);
2344
+ const epoch = Number.isFinite(numeric) ? numeric : Date.parse(value);
2345
+ return Number.isFinite(epoch) ? when(epoch) : value;
2346
+ };
2212
2347
  const clockCard = (epoch) => {
2213
2348
  const date = new Date(epoch);
2214
2349
  if (Number.isNaN(date.getTime()))
@@ -2328,7 +2463,7 @@ function bootAdmin(config) {
2328
2463
  };
2329
2464
  const renderOverview = async () => {
2330
2465
  const [health, shape, clock, journal] = await Promise.all([
2331
- originGet("/health"),
2466
+ originGet(`${config.adminPrefix}/health`),
2332
2467
  api("GET", "/state"),
2333
2468
  api("GET", "/clock"),
2334
2469
  api("GET", "/requests?limit=8")
@@ -2345,7 +2480,7 @@ function bootAdmin(config) {
2345
2480
  },
2346
2481
  {
2347
2482
  k: "Status",
2348
- v: esc(typeof health.status === "string" && health.status !== "" ? health.status : "ok")
2483
+ v: `<span class="badge ok">${esc(typeof health.status === "string" && health.status !== "" ? health.status : "ok")}</span>`
2349
2484
  },
2350
2485
  { k: "Namespace", v: esc(state.namespace) },
2351
2486
  { k: "Collections", v: String(decoded.collections.length) },
@@ -2432,26 +2567,46 @@ function bootAdmin(config) {
2432
2567
  const renderJournal = async () => {
2433
2568
  const journal = await api("GET", "/requests?limit=100");
2434
2569
  el("journal", HTMLElement).innerHTML = table([
2435
- { label: "When", cell: (row) => esc(row.at) },
2436
- { label: "Method", cell: (row) => esc(row.method) },
2570
+ { label: "When", cell: (row) => esc(journalWhen(row.at)) },
2571
+ {
2572
+ label: "Method",
2573
+ cell: (row) => `<span class="method">${esc(row.method)}</span>`
2574
+ },
2437
2575
  { label: "Path", cell: (row) => esc(row.path) },
2438
- { label: "Status", cell: (row) => esc(row.status) },
2576
+ {
2577
+ label: "Status",
2578
+ cell: (row) => `<span class="badge ${row.status.startsWith("2") ? "ok" : row.status.startsWith("4") || row.status.startsWith("5") ? "warn" : ""}">${esc(row.status)}</span>`
2579
+ },
2439
2580
  { label: "ms", cell: (row) => esc(row.durationMs) }
2440
2581
  ], decodeJournal(journal));
2441
2582
  };
2442
- const renderRoutes = async () => {
2443
- const data = await api("GET", "/");
2444
- if (!isRecord2(data))
2445
- throw new Error("Route list was not an object");
2446
- const routes = requireList(data.routes, "Routes").map((route) => String(route));
2583
+ const paintRoutes = () => {
2584
+ const query = el("route-search", HTMLInputElement).value.trim().toLocaleLowerCase();
2585
+ const routes = state.routes.filter((route) => route.toLocaleLowerCase().includes(query));
2586
+ el("route-count", HTMLElement).textContent = `${routes.length} of ${state.routes.length}`;
2587
+ if (state.routes.length === 0) {
2588
+ el("route-list", HTMLElement).innerHTML = '<p class="empty">No admin routes are available.</p>';
2589
+ return;
2590
+ }
2591
+ if (routes.length === 0) {
2592
+ el("route-list", HTMLElement).innerHTML = '<p class="empty">No routes match this filter.</p>';
2593
+ return;
2594
+ }
2447
2595
  el("route-list", HTMLElement).innerHTML = routes.map((route) => {
2448
2596
  const extra = standard.has(route) ? "" : " <span class='badge'>extension</span>";
2449
2597
  const parts = route.split(" ");
2450
2598
  const method = parts[0] ?? "";
2451
2599
  const path = parts.slice(1).join(" ");
2452
- 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>`;
2600
+ 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>`;
2453
2601
  }).join("");
2454
2602
  };
2603
+ const renderRoutes = async () => {
2604
+ const data = await api("GET", "/");
2605
+ if (!isRecord2(data))
2606
+ throw new Error("Route list was not an object");
2607
+ state.routes = requireList(data.routes, "Routes").map((route) => String(route));
2608
+ paintRoutes();
2609
+ };
2455
2610
  const renderers = {
2456
2611
  overview: renderOverview,
2457
2612
  state: renderState,
@@ -2460,19 +2615,50 @@ function bootAdmin(config) {
2460
2615
  journal: renderJournal,
2461
2616
  routes: renderRoutes
2462
2617
  };
2618
+ let refreshToken = 0;
2463
2619
  async function refresh() {
2620
+ const token = ++refreshToken;
2464
2621
  clearError();
2622
+ const main = el("views", HTMLElement);
2623
+ const sync = el("sync-status", HTMLElement);
2624
+ main.setAttribute("aria-busy", "true");
2625
+ sync.classList.add("busy");
2626
+ sync.textContent = "Updating";
2465
2627
  const render = renderers[state.view];
2466
- if (render)
2467
- await render();
2628
+ try {
2629
+ if (render)
2630
+ await render();
2631
+ if (token === refreshToken)
2632
+ sync.textContent = "Up to date";
2633
+ } finally {
2634
+ if (token === refreshToken) {
2635
+ main.removeAttribute("aria-busy");
2636
+ sync.classList.remove("busy");
2637
+ }
2638
+ }
2468
2639
  }
2469
2640
  const delay = (ms) => new Promise((resolve) => {
2470
2641
  window.setTimeout(resolve, ms);
2471
2642
  });
2472
- const confirmAction = async (button, pending, done, work) => {
2643
+ const confirmAction = async (button, pending, done, work, confirmLabel) => {
2473
2644
  if (button.dataset.pending === "1")
2474
2645
  return;
2475
2646
  const label = button.textContent ?? "";
2647
+ if (confirmLabel !== void 0 && button.dataset.confirming !== "1") {
2648
+ button.dataset.confirming = "1";
2649
+ button.classList.add("confirming");
2650
+ button.textContent = confirmLabel;
2651
+ window.setTimeout(() => {
2652
+ if (button.dataset.confirming !== "1")
2653
+ return;
2654
+ button.textContent = label;
2655
+ button.classList.remove("confirming");
2656
+ delete button.dataset.confirming;
2657
+ }, 3e3);
2658
+ return;
2659
+ }
2660
+ button.classList.remove("confirming");
2661
+ delete button.dataset.confirming;
2476
2662
  const width = button.offsetWidth;
2477
2663
  button.dataset.pending = "1";
2478
2664
  button.classList.add("pressed");
@@ -2493,6 +2679,65 @@ function bootAdmin(config) {
2493
2679
  delete button.dataset.pending;
2494
2680
  }
2495
2681
  };
2682
+ const wireJsonEditor = (textareaId, highlightId, statusId, shellId, allowEmpty) => {
2683
+ const textarea = el(textareaId, HTMLTextAreaElement);
2684
+ const highlight = el(highlightId, HTMLElement);
2685
+ const status = el(statusId, HTMLElement);
2686
+ const shell = el(shellId, HTMLElement);
2687
+ const sync = () => {
2688
+ const text = textarea.value;
2689
+ highlight.innerHTML = `${highlightJsonText(text)}
2690
+ `;
2691
+ let valid = true;
2692
+ let message = "Valid JSON";
2693
+ if (allowEmpty && text.trim() === "")
2694
+ message = "No body";
2695
+ else {
2696
+ try {
2697
+ readJson2(text);
2698
+ } catch {
2699
+ valid = false;
2700
+ message = "Invalid JSON";
2701
+ }
2702
+ }
2703
+ shell.classList.toggle("invalid", !valid);
2704
+ status.classList.toggle("invalid", !valid);
2705
+ status.textContent = message;
2706
+ textarea.setAttribute("aria-invalid", valid ? "false" : "true");
2707
+ };
2708
+ const syncScroll = () => {
2709
+ const preview2 = highlight.parentElement;
2710
+ if (!(preview2 instanceof HTMLElement))
2711
+ return;
2712
+ preview2.scrollTop = textarea.scrollTop;
2713
+ preview2.scrollLeft = textarea.scrollLeft;
2714
+ };
2715
+ textarea.addEventListener("input", sync);
2716
+ textarea.addEventListener("scroll", syncScroll);
2717
+ sync();
2718
+ return sync;
2719
+ };
2720
+ const syncRouteEditor = wireJsonEditor("route-body", "route-highlight", "route-json-status", "route-editor", true);
2721
+ const syncRecordEditor = wireJsonEditor("editor-value", "editor-highlight", "editor-json-status", "record-editor", false);
2722
+ for (const button of document.querySelectorAll("[data-format]")) {
2723
+ if (!(button instanceof HTMLButtonElement))
2724
+ continue;
2725
+ button.addEventListener("click", () => {
2726
+ const target = button.dataset.format;
2727
+ if (target === void 0)
2728
+ return;
2729
+ const textarea = document.getElementById(target);
2730
+ if (!(textarea instanceof HTMLTextAreaElement) || textarea.value.trim() === "")
2731
+ return;
2732
+ try {
2733
+ textarea.value = JSON.stringify(readJson2(textarea.value), null, 2) ?? "";
2734
+ textarea.dispatchEvent(new Event("input"));
2735
+ textarea.focus();
2736
+ } catch {
2737
+ showError(new Error("Fix the JSON before formatting it"));
2738
+ }
2739
+ });
2740
+ }
2496
2741
  document.addEventListener("click", (event) => {
2497
2742
  const target = event.target;
2498
2743
  if (!(target instanceof Element))
@@ -2565,6 +2810,7 @@ function bootAdmin(config) {
2565
2810
  } else {
2566
2811
  editorValue.value = "{\n \n}";
2567
2812
  }
2813
+ syncRecordEditor();
2568
2814
  editor.showModal();
2569
2815
  }
2570
2816
  el("new-record", HTMLButtonElement).addEventListener("click", () => {
@@ -2594,10 +2840,10 @@ function bootAdmin(config) {
2594
2840
  const id = state.editing;
2595
2841
  if (!id)
2596
2842
  return;
2597
- api("DELETE", `/state/${encodeURIComponent(state.collection)}/${encodeURIComponent(id)}`).then(() => {
2843
+ confirmAction(editorDelete, "Deleting...", "Deleted", () => api("DELETE", `/state/${encodeURIComponent(state.collection)}/${encodeURIComponent(id)}`).then(() => {
2598
2844
  editor.close();
2599
2845
  return renderState();
2600
- }).catch(showError);
2846
+ }), "Confirm delete").catch(showError);
2601
2847
  });
2602
2848
  for (const button of document.querySelectorAll("[data-advance]")) {
2603
2849
  if (!(button instanceof HTMLButtonElement))
@@ -2643,10 +2889,10 @@ function bootAdmin(config) {
2643
2889
  }).then(() => renderFaults()).catch(showError);
2644
2890
  });
2645
2891
  el("fault-clear", HTMLButtonElement).addEventListener("click", () => {
2646
- confirmAction(el("fault-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/faults").then(() => renderFaults())).catch(showError);
2892
+ confirmAction(el("fault-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/faults").then(() => renderFaults()), "Confirm clear").catch(showError);
2647
2893
  });
2648
2894
  el("journal-clear", HTMLButtonElement).addEventListener("click", () => {
2649
- confirmAction(el("journal-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/requests").then(() => renderJournal())).catch(showError);
2895
+ confirmAction(el("journal-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/requests").then(() => renderJournal()), "Confirm clear").catch(showError);
2650
2896
  });
2651
2897
  el("route-list", HTMLElement).addEventListener("click", (event) => {
2652
2898
  const target = event.target;
@@ -2660,9 +2906,15 @@ function bootAdmin(config) {
2660
2906
  return;
2661
2907
  state.route = route;
2662
2908
  el("route-title", HTMLElement).textContent = route;
2909
+ el("route-send", HTMLButtonElement).disabled = false;
2663
2910
  const method = route.split(" ")[0] ?? "";
2664
- el("route-body", HTMLTextAreaElement).disabled = method === "GET" || method === "DELETE";
2911
+ const routeBody = el("route-body", HTMLTextAreaElement);
2912
+ routeBody.disabled = method === "GET" || method === "DELETE";
2913
+ el("route-editor", HTMLElement).hidden = routeBody.disabled;
2914
+ el("route-json-status", HTMLElement).parentElement?.toggleAttribute("hidden", routeBody.disabled);
2915
+ syncRouteEditor();
2665
2916
  });
2917
+ el("route-search", HTMLInputElement).addEventListener("input", paintRoutes);
2666
2918
  el("route-form", HTMLFormElement).addEventListener("submit", (event) => {
2667
2919
  event.preventDefault();
2668
2920
  const send = async () => {
@@ -2684,7 +2936,8 @@ function bootAdmin(config) {
2684
2936
  }
2685
2937
  }
2686
2938
  const data = await api(method, concrete, body);
2687
- el("route-result", HTMLElement).textContent = JSON.stringify(data, null, 2);
2939
+ const text = JSON.stringify(data, null, 2) ?? "null";
2940
+ el("route-result", HTMLElement).innerHTML = `<code>${highlightJsonText(text)}</code>`;
2688
2941
  };
2689
2942
  send().catch(showError);
2690
2943
  });
@@ -2802,11 +3055,13 @@ function bootAdmin(config) {
2802
3055
  views.append(section);
2803
3056
  mountSqlExplorer(body, panelApi);
2804
3057
  }
3058
+ show(document.getElementById(`view-${state.view}`) === null ? "overview" : state.view);
2805
3059
  return refresh();
2806
3060
  }).catch(showError);
2807
3061
  }
2808
3062
  var clientPrelude = [
2809
3063
  isRecord2,
3064
+ highlightJsonText,
2810
3065
  mountAdminBrand,
2811
3066
  startAdminBrand,
2812
3067
  faultPresetList,
@@ -2829,6 +3084,7 @@ ${entry}(${payload});
2829
3084
  var defineMock = (runtime) => runtime;
2830
3085
  var STANDARD_ADMIN_ROUTES = [
2831
3086
  "GET /",
3087
+ "GET /health",
2832
3088
  "POST /reset",
2833
3089
  "GET /namespaces",
2834
3090
  "GET /clock",
@@ -2908,11 +3164,11 @@ var escapeHtml = (value) => value.replace(/[&<>"']/g, (char) => {
2908
3164
  }
2909
3165
  });
2910
3166
  var ADMIN_BRANDS_URL = "https://mockingbird.chrisvouga.dev/brands.json";
2911
- var renderAdminDocument = (service) => {
3167
+ var renderAdminDocument = (service, adminPrefix = ADMIN_PREFIX) => {
2912
3168
  const name = escapeHtml(service);
2913
3169
  const script = adminClientSource({
2914
3170
  standardRoutes: STANDARD_ADMIN_ROUTES,
2915
- adminPrefix: ADMIN_PREFIX,
3171
+ adminPrefix,
2916
3172
  adminKeyHeader: ADMIN_KEY_HEADER,
2917
3173
  brandsUrl: ADMIN_BRANDS_URL,
2918
3174
  service
@@ -2929,75 +3185,99 @@ var renderAdminDocument = (service) => {
2929
3185
  ${CSS_RESET}
2930
3186
  :root {
2931
3187
  color-scheme: light;
2932
- --bg: #fafafa;
3188
+ --bg: #f7f8fb;
2933
3189
  --bg-elev: #ffffff;
2934
- --bg-muted: #f4f4f5;
2935
- --bg-inset: #f4f4f5;
2936
- --line: #dedee3;
2937
- --line-strong: #c4c4cc;
2938
- --ink: #18181b;
2939
- --muted: #62626b;
2940
- --faint: #71717a;
2941
- --accent: #27272a;
3190
+ --bg-muted: #f1f3f7;
3191
+ --bg-inset: #f8f9fc;
3192
+ --line: #e4e7ec;
3193
+ --line-strong: #cfd4dc;
3194
+ --ink: #101828;
3195
+ --muted: #475467;
3196
+ --faint: #667085;
3197
+ --accent: #6941c6;
3198
+ --accent-hover: #5933b4;
2942
3199
  --accent-ink: #ffffff;
2943
- --accent-soft: #f4f4f5;
2944
- --ok: #166534;
2945
- --ok-soft: #f0fdf4;
2946
- --warn: #a16207;
2947
- --warn-soft: #fefce8;
2948
- --err: #a82d32;
2949
- --err-soft: #fef2f2;
2950
- --shadow: none;
3200
+ --accent-soft: #f4f0ff;
3201
+ --ok: #067647;
3202
+ --ok-soft: #ecfdf3;
3203
+ --warn: #b54708;
3204
+ --warn-soft: #fffaeb;
3205
+ --err: #b42318;
3206
+ --err-soft: #fef3f2;
3207
+ --code-key: #6941c6;
3208
+ --code-string: #067647;
3209
+ --code-number: #175cd3;
3210
+ --code-boolean: #c11574;
3211
+ --code-null: #667085;
3212
+ --shadow-xs: 0 1px 2px rgb(16 24 40 / 0.05);
3213
+ --shadow-sm: 0 1px 3px rgb(16 24 40 / 0.08), 0 1px 2px rgb(16 24 40 / 0.04);
3214
+ --shadow-lg: 0 18px 48px rgb(16 24 40 / 0.16), 0 4px 12px rgb(16 24 40 / 0.08);
2951
3215
  --sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
2952
3216
  --mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;
2953
- --radius: 10px;
2954
- --header: 56px;
2955
- --nav: 200px;
3217
+ --radius: 12px;
3218
+ --header: 72px;
3219
+ --nav: 224px;
2956
3220
  }
2957
3221
  :root[data-theme="dark"] {
2958
3222
  color-scheme: dark;
2959
- --bg: #111113;
2960
- --bg-elev: #19191c;
2961
- --bg-muted: #242428;
2962
- --bg-inset: #111113;
2963
- --line: #36363c;
2964
- --line-strong: #52525b;
2965
- --ink: #f4f4f5;
2966
- --muted: #a9a9b2;
2967
- --faint: #71717a;
2968
- --accent: #e4e4e7;
2969
- --accent-ink: #18181b;
2970
- --accent-soft: #242428;
2971
- --ok: #86efac;
2972
- --ok-soft: #14241b;
2973
- --warn: #fde68a;
2974
- --warn-soft: #2a2410;
2975
- --err: #ffaaaa;
2976
- --err-soft: #2c1516;
2977
- --shadow: none;
3223
+ --bg: #0c0e14;
3224
+ --bg-elev: #151821;
3225
+ --bg-muted: #20232e;
3226
+ --bg-inset: #11141c;
3227
+ --line: #2b303d;
3228
+ --line-strong: #414858;
3229
+ --ink: #f5f7fa;
3230
+ --muted: #b6bdc9;
3231
+ --faint: #8d96a7;
3232
+ --accent: #9e77ed;
3233
+ --accent-hover: #b692f6;
3234
+ --accent-ink: #ffffff;
3235
+ --accent-soft: #2b2142;
3236
+ --ok: #75e0a7;
3237
+ --ok-soft: #102a20;
3238
+ --warn: #fec84b;
3239
+ --warn-soft: #302611;
3240
+ --err: #fda29b;
3241
+ --err-soft: #321817;
3242
+ --code-key: #c3a6ff;
3243
+ --code-string: #75e0a7;
3244
+ --code-number: #84adff;
3245
+ --code-boolean: #f9a8d4;
3246
+ --code-null: #98a2b3;
3247
+ --shadow-xs: 0 1px 2px rgb(0 0 0 / 0.24);
3248
+ --shadow-sm: 0 4px 12px rgb(0 0 0 / 0.2);
3249
+ --shadow-lg: 0 20px 56px rgb(0 0 0 / 0.45);
2978
3250
  }
2979
3251
  @media (prefers-color-scheme: dark) {
2980
3252
  :root:not([data-theme="light"]) {
2981
3253
  color-scheme: dark;
2982
- --bg: #111113;
2983
- --bg-elev: #19191c;
2984
- --bg-muted: #242428;
2985
- --bg-inset: #111113;
2986
- --line: #36363c;
2987
- --line-strong: #52525b;
2988
- --ink: #f4f4f5;
2989
- --muted: #a9a9b2;
2990
- --faint: #71717a;
2991
- --accent: #e4e4e7;
2992
- --accent-ink: #18181b;
2993
- --accent-soft: #242428;
2994
- --ok: #86efac;
2995
- --ok-soft: #14241b;
2996
- --warn: #fde68a;
2997
- --warn-soft: #2a2410;
2998
- --err: #ffaaaa;
2999
- --err-soft: #2c1516;
3000
- --shadow: none;
3254
+ --bg: #0c0e14;
3255
+ --bg-elev: #151821;
3256
+ --bg-muted: #20232e;
3257
+ --bg-inset: #11141c;
3258
+ --line: #2b303d;
3259
+ --line-strong: #414858;
3260
+ --ink: #f5f7fa;
3261
+ --muted: #b6bdc9;
3262
+ --faint: #8d96a7;
3263
+ --accent: #9e77ed;
3264
+ --accent-hover: #b692f6;
3265
+ --accent-ink: #ffffff;
3266
+ --accent-soft: #2b2142;
3267
+ --ok: #75e0a7;
3268
+ --ok-soft: #102a20;
3269
+ --warn: #fec84b;
3270
+ --warn-soft: #302611;
3271
+ --err: #fda29b;
3272
+ --err-soft: #321817;
3273
+ --code-key: #c3a6ff;
3274
+ --code-string: #75e0a7;
3275
+ --code-number: #84adff;
3276
+ --code-boolean: #f9a8d4;
3277
+ --code-null: #98a2b3;
3278
+ --shadow-xs: 0 1px 2px rgb(0 0 0 / 0.24);
3279
+ --shadow-sm: 0 4px 12px rgb(0 0 0 / 0.2);
3280
+ --shadow-lg: 0 20px 56px rgb(0 0 0 / 0.45);
3001
3281
  }
3002
3282
  }
3003
3283
  /* Fill the viewport, then grow with the content so the background covers the scroll. */
@@ -3015,13 +3295,17 @@ var renderAdminDocument = (service) => {
3015
3295
  padding-right: env(safe-area-inset-right);
3016
3296
  -webkit-text-size-adjust: 100%;
3017
3297
  }
3018
- :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
3298
+ ::selection { background: color-mix(in srgb, var(--accent) 28%, transparent); }
3299
+ :focus-visible { outline: 3px solid color-mix(in srgb, var(--accent) 45%, transparent); outline-offset: 2px; }
3300
+ button, input, select, textarea { transition: border-color 140ms ease, box-shadow 140ms ease, transform 100ms ease; }
3301
+ button:disabled, input:disabled, select:disabled, textarea:disabled { cursor: not-allowed; opacity: 0.55; }
3019
3302
  .app { flex: 1 0 auto; display: flex; flex-direction: column; min-width: 0; width: 100%; }
3020
3303
  header.top {
3021
- position: sticky; top: 0; z-index: 5;
3304
+ position: sticky; top: 0; z-index: 20;
3022
3305
  display: flex; align-items: center; gap: 12px;
3023
- min-height: var(--header); padding: 10px 16px;
3024
- background: var(--bg-elev);
3306
+ min-height: var(--header); padding: 12px 20px;
3307
+ background: color-mix(in srgb, var(--bg-elev) 92%, transparent);
3308
+ backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
3025
3309
  border-bottom: 1px solid var(--line);
3026
3310
  min-width: 0; max-width: 100%;
3027
3311
  }
@@ -3029,12 +3313,21 @@ var renderAdminDocument = (service) => {
3029
3313
  display: flex; flex-flow: row wrap; align-items: center; gap: 14px;
3030
3314
  min-width: 0; max-width: 100%; margin-right: auto;
3031
3315
  }
3032
- .brand-id { display: flex; flex-direction: column; min-width: 0; flex: none; }
3033
- .brand-id strong { font-size: 15px; letter-spacing: -0.01em; }
3316
+ .brand-lockup { display: flex; align-items: center; gap: 10px; min-width: 0; }
3317
+ .brand-mark {
3318
+ width: 38px; height: 38px; display: grid; place-items: center; flex: none;
3319
+ border-radius: 11px; color: #fff;
3320
+ background: linear-gradient(145deg, #7f56d9 0%, #53389e 100%);
3321
+ box-shadow: 0 6px 16px rgb(105 65 198 / 0.24), inset 0 1px 0 rgb(255 255 255 / 0.25);
3322
+ }
3323
+ .brand-mark svg { width: 22px; height: 22px; }
3324
+ .brand-id { display: flex; flex-direction: column; min-width: 0; flex: none; line-height: 1.25; }
3325
+ .brand-id strong { font-size: 15px; letter-spacing: -0.015em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
3034
3326
  .brand-id span { color: var(--faint); font-size: 12px; }
3035
3327
  .vendor {
3036
3328
  display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
3037
3329
  min-width: 0; max-width: 100%; font-size: 12px; color: var(--muted);
3330
+ padding: 5px 8px; border: 1px solid var(--line); border-radius: 999px; background: var(--bg-inset);
3038
3331
  }
3039
3332
  .vendor[hidden] { display: none; }
3040
3333
  /* Logos are drawn for a light tile, same as the docs site, so the plate stays white. */
@@ -3046,12 +3339,17 @@ var renderAdminDocument = (service) => {
3046
3339
  .vendor-sep { color: var(--faint); }
3047
3340
  .vendor-link { color: inherit; text-decoration: none; }
3048
3341
  .vendor-link:hover { color: var(--ink); text-decoration: underline; }
3049
- .controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
3050
- label.field { display: flex; flex-direction: column; gap: 2px; font-size: 11px; color: var(--faint); }
3342
+ .controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: flex-end; }
3343
+ .sync-status { align-self: center; display: flex; align-items: center; gap: 6px; color: var(--faint); font-size: 12px; white-space: nowrap; }
3344
+ .sync-status::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--ok); box-shadow: 0 0 0 3px var(--ok-soft); }
3345
+ .sync-status.busy::before { background: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); animation: pulse 1.2s ease-in-out infinite; }
3346
+ label.field { display: flex; flex-direction: column; gap: 4px; font-size: 11px; font-weight: 600; color: var(--faint); }
3051
3347
  label.field input, label.field select, .controls input[type="password"] {
3052
- background-color: var(--bg-inset); border: 1px solid var(--line); border-radius: 8px;
3053
- padding: 7px 8px; min-height: 36px; line-height: 1.2;
3348
+ background-color: var(--bg-elev); border: 1px solid var(--line-strong); border-radius: 9px;
3349
+ padding: 8px 10px; min-height: 38px; line-height: 1.2; box-shadow: var(--shadow-xs);
3054
3350
  }
3351
+ label.field input:hover, label.field select:hover, textarea:hover { border-color: color-mix(in srgb, var(--accent) 38%, var(--line-strong)); }
3352
+ 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; }
3055
3353
  /* A native menu ignores min-height, so Preset rendered about half as tall as
3056
3354
  the text fields in the same form. Paint it like those fields. */
3057
3355
  label.field select {
@@ -3068,12 +3366,20 @@ var renderAdminDocument = (service) => {
3068
3366
  dialog .field > textarea { width: 100%; }
3069
3367
  .icon-btn, .btn, .btn-primary, .btn-quiet, .btn-danger, nav.side button { cursor: pointer; }
3070
3368
  .icon-btn, .btn, .btn-primary, .btn-quiet, .btn-danger {
3071
- border-radius: 9px; min-height: 36px; padding: 0 12px; border: 1px solid var(--line);
3072
- background: var(--bg-elev);
3073
- }
3074
- .btn-primary { background: var(--accent); color: var(--accent-ink); border-color: transparent; }
3075
- .btn-quiet { background: transparent; }
3076
- .btn-danger { color: var(--err); }
3369
+ display: inline-flex; align-items: center; justify-content: center; gap: 7px;
3370
+ border-radius: 9px; min-height: 38px; padding: 0 13px; border: 1px solid var(--line-strong);
3371
+ background: var(--bg-elev); color: var(--ink); font-size: 13px; font-weight: 600; line-height: 1;
3372
+ box-shadow: var(--shadow-xs);
3373
+ }
3374
+ .icon-btn { width: 38px; padding: 0; }
3375
+ .icon-btn svg, .btn svg, .btn-primary svg { width: 16px; height: 16px; }
3376
+ .icon-btn:hover, .btn:hover { background: var(--bg-muted); border-color: var(--line-strong); }
3377
+ .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; }
3378
+ .btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
3379
+ .btn-quiet { background: transparent; border-color: transparent; box-shadow: none; }
3380
+ .btn-quiet:hover { background: var(--bg-muted); }
3381
+ .btn-danger { color: var(--err); border-color: color-mix(in srgb, var(--err) 25%, var(--line)); }
3382
+ .btn-danger:hover, .btn-danger.confirming { background: var(--err-soft); border-color: color-mix(in srgb, var(--err) 45%, var(--line)); }
3077
3383
  /* Held after mouseup. Clearing an empty journal changes nothing on screen,
3078
3384
  so the press itself has to stay visible. */
3079
3385
  .icon-btn:active, .btn:active, .btn-quiet:active, .btn-danger:active, nav.side button:active,
@@ -3089,93 +3395,159 @@ var renderAdminDocument = (service) => {
3089
3395
  flex: 1 0 auto; min-width: 0;
3090
3396
  }
3091
3397
  nav.side {
3092
- border-right: 1px solid var(--line); padding: 12px;
3398
+ position: sticky; top: var(--header); align-self: start; height: calc(100vh - var(--header));
3399
+ border-right: 1px solid var(--line); padding: 18px 12px;
3093
3400
  display: flex; flex-direction: column; gap: 4px;
3094
- background: var(--bg-elev);
3401
+ background: var(--bg-elev); overflow-y: auto;
3095
3402
  }
3403
+ .nav-label { padding: 0 10px 8px; color: var(--faint); font-size: 10px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; }
3096
3404
  nav.side button {
3405
+ position: relative; display: flex; align-items: center; gap: 10px; width: 100%;
3097
3406
  text-align: left; border: 0; background: transparent; border-radius: 9px;
3098
- padding: 9px 10px; min-height: 40px; color: var(--muted);
3099
- }
3100
- nav.side button[aria-current="true"] { background: var(--accent-soft); color: var(--ink); font-weight: 600; }
3101
- main { padding: 20px; max-width: 1100px; width: 100%; min-width: 0; }
3102
- .view h2 { margin: 0 0 4px; font-size: 22px; letter-spacing: -0.02em; }
3103
- .lede { margin: 0 0 16px; color: var(--muted); }
3104
- .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr)); gap: 12px; }
3407
+ padding: 9px 10px; min-height: 42px; color: var(--muted); font-size: 13px; font-weight: 550;
3408
+ }
3409
+ nav.side button:hover { background: var(--bg-muted); color: var(--ink); }
3410
+ nav.side button[aria-current="true"] { background: var(--accent-soft); color: var(--accent); font-weight: 650; }
3411
+ 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); }
3412
+ .nav-icon { width: 18px; height: 18px; display: grid; place-items: center; flex: none; }
3413
+ .nav-icon svg { width: 18px; height: 18px; }
3414
+ main { padding: 32px clamp(20px, 4vw, 48px) 64px; max-width: 1320px; width: 100%; min-width: 0; }
3415
+ main[aria-busy="true"] .view { opacity: 0.62; }
3416
+ .view { animation: view-in 180ms ease-out; }
3417
+ .view[hidden] { display: none; }
3418
+ .view-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 22px; }
3419
+ .view h2 { margin: 0 0 5px; font-size: clamp(24px, 3vw, 30px); line-height: 1.2; letter-spacing: -0.035em; }
3420
+ .view h3 { margin: 24px 0 10px; font-size: 15px; letter-spacing: -0.01em; }
3421
+ .lede { margin: 0; color: var(--muted); max-width: 72ch; font-size: 14px; }
3422
+ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 175px), 1fr)); gap: 14px; }
3105
3423
  .card {
3106
3424
  background: var(--bg-elev); border: 1px solid var(--line); border-radius: var(--radius);
3107
- padding: 14px 16px; box-shadow: var(--shadow);
3425
+ padding: 18px; box-shadow: var(--shadow-sm);
3108
3426
  }
3109
- .card .k { color: var(--faint); font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; }
3110
- .card .v { display: flex; flex-direction: column; gap: 2px; font-size: 22px; margin-top: 4px; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
3427
+ .stat-card { position: relative; overflow: hidden; }
3428
+ .stat-card::after { content: ""; position: absolute; inset: 0 0 auto; height: 2px; background: linear-gradient(90deg, var(--accent), transparent 78%); opacity: 0.7; }
3429
+ .card .k { color: var(--faint); font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; }
3430
+ .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; }
3111
3431
  .card .v .clock-time { font-size: 14px; color: var(--muted); }
3112
- .split { display: grid; grid-template-columns: minmax(0, 240px) minmax(0, 1fr); gap: 16px; align-items: start; }
3432
+ .section-card { background: var(--bg-elev); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow-xs); overflow: hidden; }
3433
+ .section-title { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 16px 18px; border-bottom: 1px solid var(--line); }
3434
+ .section-title h3 { margin: 0; }
3435
+ .split { display: grid; grid-template-columns: minmax(0, 250px) minmax(0, 1fr); gap: 18px; align-items: start; }
3436
+ #collections { padding: 6px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg-elev); box-shadow: var(--shadow-xs); }
3113
3437
  .collection {
3114
3438
  width: 100%; text-align: left; border: 1px solid transparent; background: transparent;
3115
- border-radius: 10px; padding: 8px 10px; min-height: 44px;
3439
+ border-radius: 9px; padding: 10px 11px; min-height: 48px; color: var(--ink); cursor: pointer;
3116
3440
  }
3117
- .collection[aria-current="true"] { background: var(--bg-elev); border-color: var(--line); }
3441
+ .collection:hover { background: var(--bg-muted); }
3442
+ .collection[aria-current="true"] { background: var(--accent-soft); border-color: color-mix(in srgb, var(--accent) 18%, var(--line)); color: var(--accent); }
3118
3443
  .collection small { display: block; color: var(--faint); }
3119
3444
  .chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 14px; }
3120
- .chip {
3121
- font-family: var(--mono); font-size: 12px; background: var(--bg-muted);
3122
- border-radius: 999px; padding: 4px 8px;
3123
- }
3445
+ .chip { font-family: var(--mono); font-size: 11px; background: var(--bg-muted); border: 1px solid var(--line); border-radius: 999px; padding: 4px 8px; }
3124
3446
  .chip i { color: var(--faint); font-style: normal; }
3125
3447
  .sql-layout {
3126
3448
  display: grid; grid-template-columns: minmax(10rem, 16rem) minmax(0, 1fr); gap: 16px;
3127
3449
  }
3128
- .sql-tables { display: flex; flex-direction: column; gap: 4px; max-height: 28rem; overflow: auto; }
3129
- .sql-tables button { text-align: left; }
3450
+ .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); }
3451
+ .sql-tables button { text-align: left; border: 0; border-radius: 8px; padding: 9px 10px; background: transparent; color: var(--muted); cursor: pointer; }
3452
+ .sql-tables button:hover, .sql-tables button[aria-current="true"] { background: var(--accent-soft); color: var(--accent); }
3130
3453
  .sql-input { font-family: var(--mono); min-height: 7rem; width: 100%; }
3131
- table { width: 100%; border-collapse: collapse; font-size: 14px; }
3132
- th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--line); vertical-align: top; }
3133
- th { color: var(--faint); font-weight: 600; font-size: 12px; }
3454
+ .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); }
3455
+ table { width: 100%; border-collapse: collapse; font-size: 13px; }
3456
+ th, td { text-align: left; padding: 11px 13px; border-bottom: 1px solid var(--line); vertical-align: top; }
3457
+ th { color: var(--faint); background: var(--bg-inset); font-weight: 650; font-size: 11px; text-transform: uppercase; letter-spacing: 0.045em; }
3458
+ tbody tr:last-child td { border-bottom: 0; }
3134
3459
  tr.clickable { cursor: pointer; }
3135
- tr.clickable:hover { background: var(--bg-muted); }
3460
+ tr.clickable:hover { background: var(--accent-soft); }
3136
3461
  pre, code, .mono { font-family: var(--mono); overflow-wrap: anywhere; }
3137
3462
  pre {
3138
3463
  margin: 0; white-space: pre-wrap; word-break: break-word;
3139
- background: var(--bg-inset); border-radius: 10px; padding: 12px; font-size: 12px;
3140
- }
3141
- .row-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0; }
3464
+ background: var(--bg-inset); border: 1px solid var(--line); border-radius: 10px; padding: 14px; font-size: 12px; line-height: 1.65;
3465
+ }
3466
+ .json-key { color: var(--code-key); }
3467
+ .json-string { color: var(--code-string); }
3468
+ .json-number { color: var(--code-number); }
3469
+ .json-boolean { color: var(--code-boolean); }
3470
+ .json-null { color: var(--code-null); font-style: italic; }
3471
+ .json-inline { display: block; max-width: 44rem; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
3472
+ .json-view { position: relative; max-height: 26rem; overflow: auto; }
3473
+ .json-editor { position: relative; min-height: 190px; border: 1px solid var(--line-strong); border-radius: 10px; background: var(--bg-inset); overflow: hidden; }
3474
+ .json-editor:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 15%, transparent); }
3475
+ .json-editor.invalid { border-color: var(--err); }
3476
+ .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; }
3477
+ .json-editor pre { position: absolute; inset: 0; pointer-events: none; background: transparent; }
3478
+ .json-editor textarea { position: relative; z-index: 1; resize: vertical; background: transparent; color: transparent; caret-color: var(--ink); -webkit-text-fill-color: transparent; }
3479
+ .json-editor textarea::selection { background: color-mix(in srgb, var(--accent) 32%, transparent); }
3480
+ .editor-meta { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 7px; }
3481
+ .json-status { display: inline-flex; align-items: center; gap: 6px; color: var(--ok); font-size: 11px; }
3482
+ .json-status::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
3483
+ .json-status.invalid { color: var(--err); }
3484
+ .format-json { border: 0; padding: 3px 5px; background: transparent; color: var(--accent); font-size: 11px; font-weight: 650; cursor: pointer; }
3485
+ .row-actions { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 8px; margin: 14px 0; }
3142
3486
  .banner {
3143
- display: none; margin-bottom: 12px; padding: 10px 12px; border-radius: 10px;
3144
- background: var(--err-soft); color: var(--err);
3145
- }
3146
- .banner.show { display: block; }
3147
- .empty { color: var(--muted); padding: 24px 8px; }
3487
+ display: none; position: sticky; top: calc(var(--header) + 12px); z-index: 15;
3488
+ align-items: center; gap: 10px; margin: 0 0 16px; padding: 11px 12px 11px 14px;
3489
+ border: 1px solid color-mix(in srgb, var(--err) 30%, var(--line)); border-radius: 10px;
3490
+ background: var(--err-soft); color: var(--err); box-shadow: var(--shadow-sm); font-size: 13px;
3491
+ }
3492
+ .banner.show { display: flex; }
3493
+ .banner span { flex: 1; }
3494
+ .banner button { width: 28px; height: 28px; display: grid; place-items: center; border: 0; border-radius: 7px; background: transparent; color: currentColor; cursor: pointer; }
3495
+ .banner button:hover { background: color-mix(in srgb, var(--err) 10%, transparent); }
3496
+ .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); }
3148
3497
  dialog {
3149
- border: 1px solid var(--line); border-radius: var(--radius); padding: 0; background: var(--bg-elev);
3150
- color: var(--ink); width: min(640px, calc(100% - 24px)); max-width: calc(100vw - 24px); box-shadow: none;
3151
- }
3152
- dialog::backdrop { background: rgb(20 19 17 / 0.45); }
3153
- dialog form, .dialog-body { padding: 16px; display: flex; flex-direction: column; gap: 10px; }
3498
+ border: 1px solid var(--line); border-radius: 16px; padding: 0; background: var(--bg-elev);
3499
+ color: var(--ink); width: min(720px, calc(100% - 24px)); max-width: calc(100vw - 24px); box-shadow: var(--shadow-lg);
3500
+ }
3501
+ dialog[open] { animation: dialog-in 160ms ease-out; }
3502
+ dialog::backdrop { background: rgb(12 14 20 / 0.58); backdrop-filter: blur(3px); }
3503
+ dialog form, .dialog-body { padding: 22px; display: flex; flex-direction: column; gap: 14px; }
3504
+ .dialog-heading { display: flex; justify-content: space-between; gap: 16px; padding-bottom: 12px; border-bottom: 1px solid var(--line); }
3505
+ .dialog-heading strong { font-size: 18px; letter-spacing: -0.02em; }
3506
+ .dialog-heading span { color: var(--muted); font-size: 12px; }
3154
3507
  textarea {
3155
3508
  width: 100%; min-height: 180px; resize: vertical; border-radius: 10px;
3156
3509
  border: 1px solid var(--line); background: var(--bg-inset); padding: 10px;
3157
3510
  font-family: var(--mono); font-size: 12px;
3158
3511
  }
3159
- .dialog-actions { display: flex; justify-content: flex-end; gap: 8px; }
3160
- .badge { font-size: 11px; border-radius: 999px; padding: 2px 7px; background: var(--bg-muted); color: var(--muted); }
3512
+ .dialog-actions { display: flex; justify-content: flex-end; align-items: center; gap: 8px; padding-top: 4px; }
3513
+ .dialog-actions .btn-danger { margin-right: auto; }
3514
+ .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); }
3161
3515
  .badge.ok { background: var(--ok-soft); color: var(--ok); }
3162
3516
  .badge.warn { background: var(--warn-soft); color: var(--warn); }
3163
3517
  .status-2 { color: var(--ok); }
3164
3518
  .status-4, .status-5, .status-0 { color: var(--err); }
3165
3519
  .stack { display: flex; flex-direction: column; gap: 12px; }
3520
+ .form-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
3521
+ .form-grid .wide { grid-column: span 2; }
3166
3522
  .route {
3167
- display: grid; grid-template-columns: 88px 1fr auto; gap: 8px; align-items: center;
3168
- padding: 8px 0; border-bottom: 1px solid var(--line);
3169
- }
3170
- .method { font-family: var(--mono); font-size: 12px; font-weight: 700; }
3523
+ display: grid; grid-template-columns: 84px minmax(0, 1fr) auto; gap: 10px; align-items: center;
3524
+ padding: 10px 12px; border-bottom: 1px solid var(--line); background: var(--bg-elev);
3525
+ }
3526
+ .route:first-child { border-radius: var(--radius) var(--radius) 0 0; }
3527
+ .route:last-child { border-bottom: 0; border-radius: 0 0 var(--radius) var(--radius); }
3528
+ .route-tools { display: flex; align-items: flex-end; gap: 12px; margin-bottom: 12px; }
3529
+ .route-tools .field { flex: 1; }
3530
+ .route-tools input { width: 100%; }
3531
+ .route-count { padding-bottom: 10px; color: var(--faint); font-size: 12px; white-space: nowrap; }
3532
+ #route-list { max-height: 34rem; border: 1px solid var(--line); border-radius: var(--radius); overflow: auto; box-shadow: var(--shadow-xs); }
3533
+ .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; }
3534
+ @keyframes view-in { from { transform: translateY(3px); } to { transform: translateY(0); } }
3535
+ @keyframes dialog-in { from { transform: translateY(8px) scale(0.99); } to { transform: translateY(0) scale(1); } }
3536
+ @keyframes pulse { 50% { opacity: 0.45; } }
3537
+ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } }
3538
+ @media (forced-colors: active) { .json-editor pre { display: none; } .json-editor textarea { color: CanvasText; -webkit-text-fill-color: CanvasText; } }
3171
3539
  @media (max-width: 800px) {
3540
+ :root { --header: 0px; }
3172
3541
  header.top {
3173
- align-items: stretch; flex-direction: column;
3174
- padding-top: max(10px, env(safe-area-inset-top));
3542
+ position: relative; align-items: stretch; flex-direction: column;
3543
+ padding: max(12px, env(safe-area-inset-top)) 14px 12px;
3175
3544
  }
3176
3545
  input, select, textarea { font-size: 16px; }
3177
3546
  .brand, .vendor, .controls { width: 100%; }
3547
+ .brand { gap: 10px; }
3548
+ .vendor { order: 2; border-radius: 9px; }
3178
3549
  label.field, label.field select, label.field input { flex: 1; min-width: 0; }
3550
+ .sync-status { display: none; }
3179
3551
  /* The layout fills the screen. A single-column grid stretches every auto
3180
3552
  row, so the tab bar grew with the leftover height and the buttons floated
3181
3553
  in the middle of it. The bar stays one row; the page below takes the rest. */
@@ -3185,14 +3557,23 @@ var renderAdminDocument = (service) => {
3185
3557
  align-content: start;
3186
3558
  }
3187
3559
  nav.side {
3188
- align-self: start; height: auto;
3560
+ position: sticky; top: 0; z-index: 12; align-self: start; height: auto;
3189
3561
  flex-direction: row; align-items: center; overflow-x: auto; min-width: 0;
3190
3562
  border-right: 0; border-bottom: 1px solid var(--line);
3191
- padding: 8px;
3563
+ padding: 8px; box-shadow: var(--shadow-xs);
3192
3564
  }
3193
- nav.side button { white-space: nowrap; flex: none; }
3565
+ .nav-label { display: none; }
3566
+ nav.side button { width: auto; white-space: nowrap; flex: none; }
3567
+ nav.side button[aria-current="true"]::before { inset: auto 9px -8px; width: auto; height: 3px; border-radius: 3px 3px 0 0; }
3194
3568
  .split, .sql-layout { grid-template-columns: minmax(0, 1fr); }
3195
- main { padding: 14px; }
3569
+ .form-grid { grid-template-columns: minmax(0, 1fr); }
3570
+ .form-grid .wide { grid-column: auto; }
3571
+ main { padding: 22px 14px 48px; }
3572
+ .view-head { margin-bottom: 18px; }
3573
+ .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
3574
+ .card { padding: 14px; }
3575
+ .card .v { font-size: 20px; }
3576
+ .table-shell { border: 0; background: transparent; box-shadow: none; overflow: visible; }
3196
3577
  table, thead, tbody, tr, th, td { display: block; }
3197
3578
  thead { display: none; }
3198
3579
  tr {
@@ -3207,50 +3588,63 @@ var renderAdminDocument = (service) => {
3207
3588
  .route { grid-template-columns: 72px 1fr; }
3208
3589
  .route button { grid-column: 1 / -1; }
3209
3590
  }
3591
+ @media (max-width: 460px) {
3592
+ .controls label.field { min-width: calc(50% - 4px); }
3593
+ .controls .icon-btn, .controls .btn { flex: 1; }
3594
+ .grid { grid-template-columns: minmax(0, 1fr); }
3595
+ .dialog-actions { flex-wrap: wrap; }
3596
+ .dialog-actions button { flex: 1; }
3597
+ .dialog-actions .btn-danger { flex-basis: 100%; margin-right: 0; }
3598
+ }
3210
3599
  </style>
3211
3600
  </head>
3212
3601
  <body>
3213
3602
  <div class="app" data-service="${name}" data-mockingbird-admin>
3214
3603
  <header class="top">
3215
3604
  <div class="brand">
3216
- <div class="brand-id">
3217
- <strong>${name}</strong>
3218
- <span>Admin</span>
3605
+ <div class="brand-lockup">
3606
+ <div class="brand-mark" aria-hidden="true">
3607
+ <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>
3608
+ </div>
3609
+ <div class="brand-id">
3610
+ <strong>${name}</strong>
3611
+ <span>Mock admin</span>
3612
+ </div>
3219
3613
  </div>
3220
3614
  <div class="vendor" id="vendor" hidden></div>
3221
3615
  </div>
3222
3616
  <div class="controls">
3617
+ <span class="sync-status" id="sync-status" role="status" aria-live="polite">Ready</span>
3223
3618
  <label class="field">Namespace
3224
3619
  <select id="namespace" aria-label="Namespace"></select>
3225
3620
  </label>
3226
3621
  <label class="field">Admin key
3227
3622
  <input id="admin-key" type="password" autocomplete="off" spellcheck="false" aria-label="Admin key">
3228
3623
  </label>
3229
- <button class="icon-btn" id="theme" type="button" aria-label="Toggle color theme">Theme</button>
3230
- <button class="btn" id="refresh" type="button">Refresh</button>
3624
+ <button class="icon-btn" id="theme" type="button" aria-label="Use dark theme" title="Use dark theme"></button>
3625
+ <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>
3231
3626
  </div>
3232
3627
  </header>
3233
3628
  <div class="layout">
3234
3629
  <nav class="side" id="nav" aria-label="Admin sections">
3235
- <button type="button" data-view="overview" aria-current="true">Overview</button>
3236
- <button type="button" data-view="state">State</button>
3237
- <button type="button" data-view="clock">Clock</button>
3238
- <button type="button" data-view="faults">Faults</button>
3239
- <button type="button" data-view="journal">Journal</button>
3240
- <button type="button" data-view="routes">Routes</button>
3630
+ <span class="nav-label">Workspace</span>
3631
+ <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>
3632
+ <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>
3633
+ <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>
3634
+ <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>
3635
+ <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>
3636
+ <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>
3241
3637
  </nav>
3242
3638
  <main id="views">
3243
- <p class="banner" id="banner" role="alert"></p>
3639
+ <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>
3244
3640
  <section class="view" id="view-overview">
3245
- <h2>Overview</h2>
3246
- <p class="lede">Health, clock, and the collections this namespace is holding.</p>
3641
+ <div class="view-head"><div><h2>Overview</h2><p class="lede">Health, clock, and the collections this namespace is holding.</p></div></div>
3247
3642
  <div class="grid" id="overview-cards"></div>
3248
3643
  <h3>Recent requests</h3>
3249
3644
  <div id="overview-requests"></div>
3250
3645
  </section>
3251
3646
  <section class="view" id="view-state" hidden>
3252
- <h2>State</h2>
3253
- <p class="lede">Every collection in this namespace. Declared fields stay visible before any row exists; stored rows add whatever else they contain.</p>
3647
+ <div class="view-head"><div><h2>State</h2><p class="lede">Inspect and edit every collection in this namespace.</p></div></div>
3254
3648
  <div class="split">
3255
3649
  <div id="collections"></div>
3256
3650
  <div>
@@ -3263,8 +3657,7 @@ var renderAdminDocument = (service) => {
3263
3657
  </div>
3264
3658
  </section>
3265
3659
  <section class="view" id="view-clock" hidden>
3266
- <h2>Clock</h2>
3267
- <p class="lede">The clock every timestamp in this mock reads.</p>
3660
+ <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>
3268
3661
  <div class="card" id="clock-readout"></div>
3269
3662
  <div class="row-actions">
3270
3663
  <button class="btn" type="button" data-advance="60000">+1m</button>
@@ -3283,21 +3676,22 @@ var renderAdminDocument = (service) => {
3283
3676
  </form>
3284
3677
  </section>
3285
3678
  <section class="view" id="view-faults" hidden>
3286
- <h2>Faults</h2>
3287
- <p class="lede">Inject a response, a delay, or a named preset. Rules apply to this namespace.</p>
3679
+ <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>
3288
3680
  <form id="fault-form" class="card stack">
3289
- <label class="field">Preset
3290
- <select id="fault-preset"><option value="">Custom rule</option></select>
3291
- </label>
3292
- <label class="field">Status
3293
- <input id="fault-status" inputmode="numeric" placeholder="503">
3294
- </label>
3295
- <label class="field">Path prefix
3296
- <input id="fault-path" placeholder="/v1">
3297
- </label>
3298
- <label class="field">Method
3299
- <input id="fault-method" placeholder="GET">
3300
- </label>
3681
+ <div class="form-grid">
3682
+ <label class="field wide">Preset
3683
+ <select id="fault-preset"><option value="">Custom rule</option></select>
3684
+ </label>
3685
+ <label class="field">Status
3686
+ <input id="fault-status" inputmode="numeric" placeholder="503">
3687
+ </label>
3688
+ <label class="field wide">Path prefix
3689
+ <input id="fault-path" placeholder="/v1">
3690
+ </label>
3691
+ <label class="field">Method
3692
+ <input id="fault-method" placeholder="GET">
3693
+ </label>
3694
+ </div>
3301
3695
  <div class="dialog-actions">
3302
3696
  <button class="btn-primary" type="submit">Add fault</button>
3303
3697
  <button class="btn-danger" id="fault-clear" type="button">Clear all</button>
@@ -3306,26 +3700,31 @@ var renderAdminDocument = (service) => {
3306
3700
  <div id="fault-list"></div>
3307
3701
  </section>
3308
3702
  <section class="view" id="view-journal" hidden>
3309
- <h2>Journal</h2>
3310
- <p class="lede">Requests this namespace has seen. Bodies are not stored.</p>
3311
- <div class="row-actions">
3312
- <button class="btn-danger" id="journal-clear" type="button">Clear journal</button>
3313
- </div>
3703
+ <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>
3314
3704
  <div id="journal"></div>
3315
3705
  </section>
3316
3706
  <section class="view" id="view-routes" hidden>
3317
- <h2>Routes</h2>
3318
- <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>
3707
+ <div class="view-head"><div><h2>Routes</h2><p class="lede">Explore shared controls and service-specific admin endpoints.</p></div></div>
3708
+ <div class="route-tools">
3709
+ <label class="field">Filter routes
3710
+ <input id="route-search" type="search" placeholder="Method, path, or extension" autocomplete="off">
3711
+ </label>
3712
+ <span class="route-count" id="route-count"></span>
3713
+ </div>
3319
3714
  <div id="route-list"></div>
3320
- <form id="route-form" class="card stack">
3715
+ <form id="route-form" class="card stack" style="margin-top: 18px">
3321
3716
  <strong id="route-title">Select a route</strong>
3322
3717
  <label class="field">JSON body
3323
- <textarea id="route-body" placeholder="{ }"></textarea>
3718
+ <div class="json-editor" id="route-editor" hidden>
3719
+ <pre aria-hidden="true"><code id="route-highlight"></code></pre>
3720
+ <textarea id="route-body" placeholder="{ }" spellcheck="false" aria-describedby="route-json-status" disabled></textarea>
3721
+ </div>
3722
+ <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>
3324
3723
  </label>
3325
3724
  <div class="dialog-actions">
3326
- <button class="btn-primary" type="submit">Send</button>
3725
+ <button class="btn-primary" id="route-send" type="submit" disabled>Send request</button>
3327
3726
  </div>
3328
- <pre id="route-result">Response will show here.</pre>
3727
+ <pre class="json-view" id="route-result"><code>Response will show here.</code></pre>
3329
3728
  </form>
3330
3729
  </section>
3331
3730
  </main>
@@ -3333,12 +3732,16 @@ var renderAdminDocument = (service) => {
3333
3732
  </div>
3334
3733
  <dialog id="editor">
3335
3734
  <form method="dialog" id="editor-form">
3336
- <strong id="editor-title">Record</strong>
3735
+ <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>
3337
3736
  <label class="field">Id
3338
3737
  <input id="editor-id" required>
3339
3738
  </label>
3340
3739
  <label class="field">JSON value
3341
- <textarea id="editor-value"></textarea>
3740
+ <div class="json-editor" id="record-editor">
3741
+ <pre aria-hidden="true"><code id="editor-highlight"></code></pre>
3742
+ <textarea id="editor-value" spellcheck="false" aria-describedby="editor-json-status"></textarea>
3743
+ </div>
3744
+ <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>
3342
3745
  </label>
3343
3746
  <div class="dialog-actions">
3344
3747
  <button class="btn-danger" id="editor-delete" type="button" value="delete">Delete</button>
@@ -3380,8 +3783,8 @@ var describeExtension = (extension) => {
3380
3783
  ...extension.script !== void 0 ? { script: extension.script } : {}
3381
3784
  };
3382
3785
  };
3383
- var adminUiRoutes = (service, ui) => {
3384
- const shell = () => renderAdminDocument(service);
3786
+ var adminUiRoutes = (service, ui, adminPrefix = ADMIN_PREFIX) => {
3787
+ const shell = () => renderAdminDocument(service, adminPrefix);
3385
3788
  const document3 = () => ui?.render ? ui.render({ service, defaultHtml: shell }) : shell();
3386
3789
  return {
3387
3790
  "GET /ui": () => htmlResponse(document3()),
@@ -4072,10 +4475,13 @@ var stateAdminRoutes = (routes) => {
4072
4475
  };
4073
4476
 
4074
4477
  // ../core/dist/version.js
4075
- var PACKAGE_VERSION = true ? "1.7.1" : UNRELEASED_VERSION;
4478
+ var PACKAGE_VERSION = true ? "2.0.0" : UNRELEASED_VERSION;
4076
4479
 
4077
4480
  // ../core/dist/webhooks.js
4078
- var json4 = (status, body) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
4481
+ var json4 = (status, body) => new Response(JSON.stringify(body), {
4482
+ status,
4483
+ headers: { "content-type": "application/json; charset=utf-8" }
4484
+ });
4079
4485
  var adminError3 = (status, message) => json4(status, { error: { type: "mockingbird_admin", message } });
4080
4486
  var isRecord5 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4081
4487
  var parseEndpoint = (value) => {
@@ -4183,7 +4589,6 @@ var AT_HEADER = "x-mockingbird-at";
4183
4589
  var CHECKPOINT_HEADER = "x-mockingbird-checkpoint";
4184
4590
  var DEFAULT_NAMESPACE = "default";
4185
4591
  var NAMESPACE_PATTERN = /^[A-Za-z0-9_.-]{1,64}$/;
4186
- var PATH_PREFIX = /^\/ns\/([^/]+)(\/.*)?$/;
4187
4592
  var BRANCH_PATTERN2 = /^[A-Za-z0-9_.-]{1,64}$/;
4188
4593
  var MUTATING_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
4189
4594
  var effects = /* @__PURE__ */ new WeakMap();
@@ -4224,6 +4629,22 @@ var operationMatcher = (document3) => {
4224
4629
  return (request, path) => matchers.find((m) => m.method === request.method && m.pattern.test(path))?.operationId;
4225
4630
  };
4226
4631
  var createRuntime = (options) => {
4632
+ const adminPrefix = resolveAdminPrefix(options.adminPrefix);
4633
+ const internalPaths = Object.entries(options.document?.paths ?? {}).filter(([, item]) => item["x-mockingbird-internal"] === true).map(([path]) => path);
4634
+ for (const path of internalPaths) {
4635
+ if (!path.startsWith(`${ADMIN_PREFIX}/blobs/`))
4636
+ throw new Error(`internal transport ${path} must live under ${ADMIN_PREFIX}/blobs/`);
4637
+ }
4638
+ assertAdminPrefixAvailable(adminPrefix, Object.keys(options.document?.paths ?? {}).filter((path) => !internalPaths.includes(path)));
4639
+ const internalMatch = (path) => {
4640
+ for (const pattern of internalPaths) {
4641
+ const actual = path.split("/");
4642
+ const expected = pattern.split("/");
4643
+ if (actual.length === expected.length && expected.every((part, i) => part.startsWith("{") || part === actual[i]))
4644
+ return pattern;
4645
+ }
4646
+ return void 0;
4647
+ };
4227
4648
  assertAdminUi(options.adminUi);
4228
4649
  const sqlite = bootSqlite(options.sqlite);
4229
4650
  const clock = options.clock ?? createClock();
@@ -4238,6 +4659,20 @@ var createRuntime = (options) => {
4238
4659
  const instances = /* @__PURE__ */ new Map();
4239
4660
  const publicNamespaces = /* @__PURE__ */ new Set();
4240
4661
  const branchRngs = /* @__PURE__ */ new Map();
4662
+ const namespaceClocks = /* @__PURE__ */ new Map();
4663
+ const namespaceForStorage = /* @__PURE__ */ new Map();
4664
+ let isolatedNamespaces = false;
4665
+ const namespaceClock = (name = DEFAULT_NAMESPACE) => {
4666
+ if (!isolatedNamespaces || name === DEFAULT_NAMESPACE)
4667
+ return clock;
4668
+ let found = namespaceClocks.get(name);
4669
+ if (!found) {
4670
+ found = createClock();
4671
+ namespaceClocks.set(name, found);
4672
+ }
4673
+ return found;
4674
+ };
4675
+ const fleetPoints = /* @__PURE__ */ new WeakMap();
4241
4676
  const timelines = /* @__PURE__ */ new Map();
4242
4677
  const branchStorage = /* @__PURE__ */ new Map();
4243
4678
  const captured = /* @__PURE__ */ new Map();
@@ -4251,17 +4686,22 @@ var createRuntime = (options) => {
4251
4686
  if (!NAMESPACE_PATTERN.test(key) || !NAMESPACE_PATTERN.test(publicNamespace)) {
4252
4687
  throw new RangeError(`namespace must match ${NAMESPACE_PATTERN}: ${JSON.stringify(publicNamespace)}`);
4253
4688
  }
4689
+ const createdContextRng = isolatedRng ?? (isolatedNamespaces && key !== DEFAULT_NAMESPACE ? createRng(options.seed ?? 0) : rng);
4254
4690
  const created = options.create({
4255
4691
  namespace: storageNamespace(key),
4256
4692
  publicNamespace,
4693
+ adminPrefix,
4257
4694
  sqlite,
4258
- clock,
4259
- rng: isolatedRng ?? rng
4695
+ clock: namespaceClock(publicNamespace),
4696
+ rng: createdContextRng
4260
4697
  });
4261
4698
  instances.set(key, created);
4699
+ namespaceForStorage.set(key, publicNamespace);
4262
4700
  publicNamespaces.add(publicNamespace);
4263
4701
  if (isolatedRng)
4264
4702
  branchRngs.set(key, isolatedRng);
4703
+ else if (isolatedNamespaces && key !== DEFAULT_NAMESPACE)
4704
+ branchRngs.set(key, createdContextRng);
4265
4705
  return created;
4266
4706
  };
4267
4707
  const instance = (name = DEFAULT_NAMESPACE) => instanceFor(name);
@@ -4279,7 +4719,7 @@ var createRuntime = (options) => {
4279
4719
  captured.set(storage, snapshot2);
4280
4720
  return Object.freeze({
4281
4721
  snapshot: snapshot2,
4282
- clock: Object.freeze(clock.state()),
4722
+ clock: Object.freeze(namespaceClock(namespaceForStorage.get(storage) ?? storage).state()),
4283
4723
  rngState: (branchRngs.get(storage) ?? rng).state()
4284
4724
  });
4285
4725
  };
@@ -4289,7 +4729,7 @@ var createRuntime = (options) => {
4289
4729
  return found;
4290
4730
  instance(name);
4291
4731
  found = new Timeline({
4292
- now: clock.now,
4732
+ now: namespaceClock(name).now,
4293
4733
  ...options.maxCheckpoints !== void 0 ? { maxCheckpoints: options.maxCheckpoints } : {}
4294
4734
  });
4295
4735
  found.commit(capture(name));
@@ -4316,12 +4756,13 @@ var createRuntime = (options) => {
4316
4756
  const point = history2.checkout("main", at);
4317
4757
  restoreNamespace(sqlite, storageNamespace(namespace), point.value.snapshot);
4318
4758
  captured.set(namespace, point.value.snapshot);
4319
- rng.setState(point.value.rngState);
4320
- clock.set(point.value.clock.now);
4759
+ (branchRngs.get(namespace) ?? rng).setState(point.value.rngState);
4760
+ const selectedClock = namespaceClock(namespace);
4761
+ selectedClock.set(point.value.clock.now);
4321
4762
  if (point.value.clock.frozen)
4322
- clock.freeze();
4763
+ selectedClock.freeze();
4323
4764
  else
4324
- clock.unfreeze();
4765
+ selectedClock.unfreeze();
4325
4766
  }
4326
4767
  return namespace;
4327
4768
  }
@@ -4379,11 +4820,12 @@ var createRuntime = (options) => {
4379
4820
  const storage = ensureBranch(namespace, branchName);
4380
4821
  restoreNamespace(sqlite, storageNamespace(storage), point.value.snapshot);
4381
4822
  captured.set(storage, point.value.snapshot);
4382
- clock.set(point.value.clock.now);
4823
+ const selectedClock = namespaceClock(namespace);
4824
+ selectedClock.set(point.value.clock.now);
4383
4825
  if (point.value.clock.frozen)
4384
- clock.freeze();
4826
+ selectedClock.freeze();
4385
4827
  else
4386
- clock.unfreeze();
4828
+ selectedClock.unfreeze();
4387
4829
  (branchRngs.get(storage) ?? rng).setState(point.value.rngState);
4388
4830
  };
4389
4831
  const reset = async (name = DEFAULT_NAMESPACE) => {
@@ -4391,10 +4833,21 @@ var createRuntime = (options) => {
4391
4833
  options.webhooks?.clear();
4392
4834
  for (const each of instances.values())
4393
4835
  await each.reset();
4836
+ for (const each of branchRngs.values())
4837
+ each.reset();
4394
4838
  timelines.clear();
4839
+ for (const storage of branchStorage.values()) {
4840
+ instances.delete(storage);
4841
+ branchRngs.delete(storage);
4842
+ namespaceForStorage.delete(storage);
4843
+ }
4395
4844
  branchStorage.clear();
4396
- branchRngs.clear();
4397
4845
  captured.clear();
4846
+ faults.clear();
4847
+ clock.reset();
4848
+ rng.reset();
4849
+ for (const each of namespaceClocks.values())
4850
+ each.reset();
4398
4851
  return;
4399
4852
  }
4400
4853
  options.webhooks?.clear(name);
@@ -4412,11 +4865,18 @@ var createRuntime = (options) => {
4412
4865
  else
4413
4866
  clearNamespace(sqlite, storageNamespace(storage));
4414
4867
  branchStorage.delete(mapping);
4868
+ instances.delete(storage);
4869
+ namespaceForStorage.delete(storage);
4415
4870
  branchRngs.delete(storage);
4416
4871
  captured.delete(storage);
4417
4872
  }
4418
4873
  timelines.delete(name);
4419
4874
  captured.delete(name);
4875
+ faults.restore(name, { rules: [], rngState: createRng(options.seed ?? 0).state() });
4876
+ namespaceClock(name).reset();
4877
+ (branchRngs.get(name) ?? rng).reset();
4878
+ journal.clear(name);
4879
+ metrics.reset(name);
4420
4880
  };
4421
4881
  const snapshot = (name = DEFAULT_NAMESPACE) => {
4422
4882
  return checkpoint(name, "main").value.snapshot;
@@ -4454,6 +4914,53 @@ var createRuntime = (options) => {
4454
4914
  rng,
4455
4915
  credentials,
4456
4916
  webhooks: options.webhooks,
4917
+ isolateNamespaces() {
4918
+ if (publicNamespaces.size > 1)
4919
+ throw new Error("enable namespace isolation before creating named namespaces");
4920
+ isolatedNamespaces = true;
4921
+ faults.isolateNamespaces();
4922
+ },
4923
+ namespaceClock,
4924
+ namespaceOf(request) {
4925
+ const url = new URL(request.url);
4926
+ const prefixed = matchNamespacePath(url.pathname, adminPrefix);
4927
+ const selected = request.headers.get(NAMESPACE_HEADER) ?? (prefixed ? decodeURIComponent(prefixed[1]) : void 0);
4928
+ const path = prefixed ? prefixed[2] ?? "/" : url.pathname;
4929
+ if (isAdminPath(path, adminPrefix))
4930
+ return url.searchParams.get("namespace") ?? selected ?? DEFAULT_NAMESPACE;
4931
+ if (selected !== void 0)
4932
+ return selected;
4933
+ const credential = options.credential?.(request);
4934
+ return (credential !== void 0 ? credentials.get(credential) : void 0) ?? DEFAULT_NAMESPACE;
4935
+ },
4936
+ fleetSnapshot(namespace) {
4937
+ instance(namespace);
4938
+ const handle = {};
4939
+ fleetPoints.set(handle, {
4940
+ namespace,
4941
+ state: capture(namespace),
4942
+ faults: faults.snapshot(namespace),
4943
+ webhooks: options.webhooks?.snapshot(namespace)
4944
+ });
4945
+ return handle;
4946
+ },
4947
+ fleetRestore(handle, namespace) {
4948
+ const point = typeof handle === "object" && handle !== null ? fleetPoints.get(handle) : void 0;
4949
+ if (!point || point.namespace !== namespace)
4950
+ throw new Error("invalid fleet checkpoint");
4951
+ restoreNamespace(sqlite, storageNamespace(namespace), point.state.snapshot);
4952
+ captured.set(namespace, point.state.snapshot);
4953
+ const selectedClock = namespaceClock(namespace);
4954
+ selectedClock.freeze();
4955
+ selectedClock.set(point.state.clock.now);
4956
+ if (!point.state.clock.frozen)
4957
+ selectedClock.unfreeze();
4958
+ (branchRngs.get(namespace) ?? rng).setState(point.state.rngState);
4959
+ faults.restore(namespace, point.faults);
4960
+ if (point.webhooks !== void 0)
4961
+ options.webhooks?.restore(namespace, point.webhooks);
4962
+ timelines.delete(namespace);
4963
+ },
4457
4964
  applyPreset: (name, namespace = DEFAULT_NAMESPACE, overrides = {}) => {
4458
4965
  const preset = options.presets?.[name];
4459
4966
  if (!preset)
@@ -4485,7 +4992,7 @@ var createRuntime = (options) => {
4485
4992
  state: (namespace = DEFAULT_NAMESPACE) => inspectState(stateScope(namespace)),
4486
4993
  fetch: async (incoming) => {
4487
4994
  let request = incoming;
4488
- const prefixed = PATH_PREFIX.exec(new URL(request.url).pathname);
4995
+ const prefixed = matchNamespacePath(new URL(request.url).pathname, adminPrefix);
4489
4996
  if (prefixed) {
4490
4997
  const url2 = new URL(request.url);
4491
4998
  url2.pathname = prefixed[2] ?? "/";
@@ -4501,7 +5008,15 @@ var createRuntime = (options) => {
4501
5008
  signal: request.signal
4502
5009
  });
4503
5010
  }
4504
- let namespace = control.namespaceOf(request);
5011
+ const internalUrl = new URL(request.url);
5012
+ const internalPath = isAdminPath(internalUrl.pathname, adminPrefix) ? `${ADMIN_PREFIX}${internalUrl.pathname.slice(adminPrefix.length)}` : "";
5013
+ const internal = internalMatch(internalPath);
5014
+ const obsoleteInternalPath = adminPrefix !== ADMIN_PREFIX && internalMatch(internalUrl.pathname) && !internal;
5015
+ if (internal) {
5016
+ internalUrl.pathname = internalPath;
5017
+ request = new Request(internalUrl, request);
5018
+ }
5019
+ let namespace = internal ? internalUrl.searchParams.get("namespace") ?? control.namespaceOf(request) : control.namespaceOf(request);
4505
5020
  if (!request.headers.has(NAMESPACE_HEADER) && options.credential) {
4506
5021
  const credential = options.credential(request);
4507
5022
  const mapped = credential !== void 0 ? credentials.get(credential) : void 0;
@@ -4521,7 +5036,9 @@ var createRuntime = (options) => {
4521
5036
  return copy;
4522
5037
  }
4523
5038
  };
4524
- const handled = await control.handle(request);
5039
+ if (obsoleteInternalPath)
5040
+ return stamp(adminFail(404, "internal transport is only available under adminPrefix"));
5041
+ const handled = internal ? void 0 : await control.handle(request);
4525
5042
  if (handled)
4526
5043
  return stamp(handled);
4527
5044
  const started = monotonicNow();
@@ -4562,7 +5079,7 @@ var createRuntime = (options) => {
4562
5079
  type: "mockingbird_admin",
4563
5080
  message: `${NAMESPACE_HEADER} must match ${NAMESPACE_PATTERN}`
4564
5081
  }
4565
- }), { status: 400, headers: { "content-type": "application/json" } }));
5082
+ }), { status: 400, headers: { "content-type": "application/json; charset=utf-8" } }));
4566
5083
  }
4567
5084
  if (!BRANCH_PATTERN2.test(selectedBranch)) {
4568
5085
  log(400);
@@ -4629,6 +5146,7 @@ var createRuntime = (options) => {
4629
5146
  startedAt: wallNow(),
4630
5147
  wallNow,
4631
5148
  clock,
5149
+ namespaceClock,
4632
5150
  faults,
4633
5151
  metrics,
4634
5152
  journal,
@@ -4678,15 +5196,16 @@ var createRuntime = (options) => {
4678
5196
  ...options.admin?.(runtime) ?? {},
4679
5197
  // Shell routes win a colliding key. Fault presets are one of them: the admin page
4680
5198
  // maps `presets`, and a service route must not replace that list with a record.
4681
- ...options.presets ? presetRoutes(options.presets, runtime) : {},
5199
+ ...options.presets ? presetRoutes(options.presets, runtime, adminPrefix) : {},
4682
5200
  ...stateAdminRoutes({
4683
5201
  open: stateScope,
4684
5202
  afterWrite: (namespace) => {
4685
5203
  checkpoint(namespace, "main");
4686
5204
  }
4687
5205
  }),
4688
- ...adminUiRoutes(options.name, options.adminUi)
5206
+ ...adminUiRoutes(options.name, options.adminUi, adminPrefix)
4689
5207
  },
5208
+ adminPrefix,
4690
5209
  adminKey: options.adminKey
4691
5210
  });
4692
5211
  return defineMock(runtime);
@@ -4700,7 +5219,10 @@ var mutableResponse = (response) => {
4700
5219
  return new Response(response.body, response);
4701
5220
  }
4702
5221
  };
4703
- var adminJson = (status, body) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
5222
+ var adminJson = (status, body) => new Response(JSON.stringify(body), {
5223
+ status,
5224
+ headers: { "content-type": "application/json; charset=utf-8" }
5225
+ });
4704
5226
  var adminFail = (status, message) => adminJson(status, { error: { type: "mockingbird_admin", message } });
4705
5227
  var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4706
5228
  var credentialRoutes = (registry) => ({
@@ -4752,7 +5274,7 @@ var credentialRoutes = (registry) => ({
4752
5274
  return adminJson(200, { status: "ok" });
4753
5275
  }
4754
5276
  });
4755
- var presetRoutes = (presets, runtime) => ({
5277
+ var presetRoutes = (presets, runtime, adminPrefix) => ({
4756
5278
  "GET /faults/presets": () => {
4757
5279
  const body = {
4758
5280
  presets: Object.entries(presets).map(([name, preset]) => ({ name, ...preset }))
@@ -4762,7 +5284,7 @@ var presetRoutes = (presets, runtime) => ({
4762
5284
  "POST /faults/presets/:name": ({ params, body, namespace }) => {
4763
5285
  const name = params.name;
4764
5286
  if (!presets[name])
4765
- return adminFail(404, `no fault preset ${name}; GET /__admin/faults/presets`);
5287
+ return adminFail(404, `no fault preset ${name}; GET ${adminPrefix}/faults/presets`);
4766
5288
  const overrides = isObject(body) ? body : {};
4767
5289
  return adminJson(201, { preset: name, rules: runtime.applyPreset(name, namespace, overrides) });
4768
5290
  }
@@ -4922,6 +5444,7 @@ var createRuntime2 = (options = {}) => {
4922
5444
  ...options.sqlite ? { sqlite: options.sqlite } : {},
4923
5445
  ...options.clock ? { clock: options.clock } : {},
4924
5446
  ...options.seed !== void 0 ? { seed: options.seed } : {},
5447
+ ...options.adminPrefix !== void 0 ? { adminPrefix: options.adminPrefix } : {},
4925
5448
  ...options.adminKey !== void 0 ? { adminKey: options.adminKey } : {},
4926
5449
  ...options.onLog ? { onLog: options.onLog } : {},
4927
5450
  credential: bearerToken,
@@ -5034,7 +5557,7 @@ var PrismAPI = class {
5034
5557
  const sqlite = bootSqlite(options.sqlite);
5035
5558
  const namespace = options.namespace ?? PRISM_NAMESPACE;
5036
5559
  this.now = options.now ?? (() => Date.now());
5037
- this.prefix = options.publicNamespace && options.publicNamespace !== "default" ? `/ns/${encodeURIComponent(options.publicNamespace)}` : "";
5560
+ this.prefix = options.publicNamespace && options.publicNamespace !== "default" ? `/__admin/ns/${encodeURIComponent(options.publicNamespace)}` : "";
5038
5561
  this.state = new PrismState(sqlite, namespace, options.settings ?? {});
5039
5562
  const handlers = defineOperations({
5040
5563
  UpsertUser: (context) => this.upsertUser(context),
@@ -5351,6 +5874,9 @@ var PrismAPI = class {
5351
5874
  };
5352
5875
 
5353
5876
  export {
5877
+ createClock,
5878
+ resolveAdminPrefix,
5879
+ parseDuration,
5354
5880
  document2 as document,
5355
5881
  operationIds,
5356
5882
  supportedOperationIds,
@@ -5362,4 +5888,4 @@ export {
5362
5888
  scanResults,
5363
5889
  PrismAPI
5364
5890
  };
5365
- //# sourceMappingURL=chunk-OZBUWLPY.js.map
5891
+ //# sourceMappingURL=chunk-4RBQFCQP.js.map