@crvouga/mockingbird-service-junction 1.9.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -114,13 +114,31 @@ var Collection = class {
114
114
  };
115
115
 
116
116
  // ../core/dist/control.js
117
- var HEALTH_PATH = "/health";
118
117
  var ADMIN_PREFIX = "/__admin";
118
+ var resolveAdminPrefix = (prefix = ADMIN_PREFIX) => {
119
+ if (!/^\/[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_-]+)*$/.test(prefix)) {
120
+ throw new Error("adminPrefix must be an absolute non-root path of literal segments, without a trailing slash");
121
+ }
122
+ return prefix;
123
+ };
124
+ var isAdminPath = (path, prefix) => path === prefix || path.startsWith(`${prefix}/`);
125
+ var matchNamespacePath = (path, prefix) => /^\/ns\/([^/]+)(\/.*)?$/.exec(isAdminPath(path, prefix) ? path.slice(prefix.length) : "");
126
+ var assertAdminPrefixAvailable = (prefix, paths) => {
127
+ const reserved = prefix.split("/").slice(1);
128
+ for (const path of paths) {
129
+ const segments = path.split("/").slice(1);
130
+ if (segments.length < reserved.length || /^\{[^}]+\}$/.test(segments[0] ?? ""))
131
+ continue;
132
+ if (reserved.every((part, i) => segments[i] === part || /^\{[^}]+\}$/.test(segments[i] ?? ""))) {
133
+ throw new Error(`vendor route ${path} collides with adminPrefix ${prefix}; choose a different adminPrefix`);
134
+ }
135
+ }
136
+ };
119
137
  var ADMIN_KEY_HEADER = "x-mockingbird-admin-key";
120
138
  var NAMESPACE_HEADER = "x-mockingbird-namespace";
121
139
  var json = (status, body) => new Response(JSON.stringify(body), {
122
140
  status,
123
- headers: { "content-type": "application/json" }
141
+ headers: { "content-type": "application/json; charset=utf-8" }
124
142
  });
125
143
  var adminError = (status, message) => json(status, { error: { type: "mockingbird_admin", message } });
126
144
  var UNITS = {
@@ -172,11 +190,25 @@ var readJson = async (request2) => {
172
190
  };
173
191
  var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
174
192
  var createControlPlane = (context) => {
193
+ const adminPrefix = resolveAdminPrefix(context.adminPrefix);
175
194
  const snapshots = /* @__PURE__ */ new Map();
176
195
  let snapshotCounter = 0;
177
196
  const headerNamespace = (request2) => request2.headers.get(NAMESPACE_HEADER) ?? context.defaultNamespace;
178
197
  const adminNamespace = (request2, url) => url.searchParams.get("namespace") ?? headerNamespace(request2);
198
+ const health = () => {
199
+ return json(200, {
200
+ ...context.describe(),
201
+ status: "ok",
202
+ service: context.name,
203
+ uptimeMs: context.wallNow() - context.startedAt,
204
+ clock: context.clock.state(),
205
+ namespaces: context.namespaces().length,
206
+ adminPrefix,
207
+ adminUi: `${adminPrefix}/ui`
208
+ });
209
+ };
179
210
  const builtin = {
211
+ "GET /health": health,
180
212
  "GET /": () => json(200, {
181
213
  service: context.name,
182
214
  routes: [...Object.keys(builtin), ...Object.keys(context.routes)].sort()
@@ -343,7 +375,14 @@ var createControlPlane = (context) => {
343
375
  return json(200, { status: "ok" });
344
376
  }
345
377
  };
346
- const routes = [...Object.entries(context.routes), ...Object.entries(builtin)].map(([key, handler]) => {
378
+ for (const key of Object.keys(context.routes)) {
379
+ if (Object.hasOwn(builtin, key))
380
+ throw new Error(`admin route ${key} overrides a shared route`);
381
+ const path = key.slice(key.indexOf(" ") + 1);
382
+ if (isAdminPath(path, "/ns") || isAdminPath(path, "/blobs"))
383
+ throw new Error(`admin route ${key} overlaps an internal transport`);
384
+ }
385
+ const routes = [...Object.entries(builtin), ...Object.entries(context.routes)].map(([key, handler]) => {
347
386
  const space = key.indexOf(" ");
348
387
  return { method: key.slice(0, space), pattern: key.slice(space + 1), handler };
349
388
  });
@@ -351,21 +390,13 @@ var createControlPlane = (context) => {
351
390
  namespaceOf: headerNamespace,
352
391
  async handle(request2) {
353
392
  const url = new URL(request2.url);
354
- if (url.pathname === HEALTH_PATH && request2.method === "GET") {
355
- return json(200, {
356
- status: "ok",
357
- service: context.name,
358
- uptimeMs: context.wallNow() - context.startedAt,
359
- clock: context.clock.state(),
360
- namespaces: context.namespaces().length,
361
- adminUi: `${ADMIN_PREFIX}/ui`,
362
- ...context.describe()
363
- });
393
+ if (url.pathname === `${adminPrefix}/health` && request2.method === "GET") {
394
+ return health();
364
395
  }
365
- if (url.pathname !== ADMIN_PREFIX && !url.pathname.startsWith(`${ADMIN_PREFIX}/`)) {
396
+ if (!isAdminPath(url.pathname, adminPrefix)) {
366
397
  return void 0;
367
398
  }
368
- const path = url.pathname.slice(ADMIN_PREFIX.length) || "/";
399
+ const path = url.pathname.slice(adminPrefix.length) || "/";
369
400
  const shell = request2.method === "GET" && (path === "/ui" || path === "/ui/");
370
401
  if (!shell && context.adminKey !== void 0 && request2.headers.get(ADMIN_KEY_HEADER) !== context.adminKey) {
371
402
  return adminError(401, `missing or wrong ${ADMIN_KEY_HEADER}`);
@@ -373,7 +404,12 @@ var createControlPlane = (context) => {
373
404
  for (const route of routes) {
374
405
  if (route.method !== request2.method)
375
406
  continue;
376
- const params = matchRoute(route.pattern, path);
407
+ let params;
408
+ try {
409
+ params = matchRoute(route.pattern, path);
410
+ } catch {
411
+ return adminError(400, "invalid path encoding");
412
+ }
377
413
  if (!params)
378
414
  continue;
379
415
  let body;
@@ -382,15 +418,18 @@ var createControlPlane = (context) => {
382
418
  } catch {
383
419
  return adminError(400, "request body is not valid JSON");
384
420
  }
421
+ const namespace = adminNamespace(request2, url);
422
+ if (!/^[A-Za-z0-9_.-]{1,64}$/.test(namespace))
423
+ return adminError(400, "invalid namespace");
385
424
  return route.handler({
386
425
  request: request2,
387
426
  url,
388
427
  params,
389
- namespace: adminNamespace(request2, url),
428
+ namespace,
390
429
  body
391
430
  });
392
431
  }
393
- return adminError(404, `no admin route ${request2.method} ${path}; GET ${ADMIN_PREFIX} lists them`);
432
+ return adminError(404, `no admin route ${request2.method} ${path}; GET ${adminPrefix} lists them`);
394
433
  }
395
434
  };
396
435
  };
@@ -1570,6 +1609,34 @@ dialog:not([open]) {
1570
1609
  function isRecord2(value) {
1571
1610
  return typeof value === "object" && value !== null && !Array.isArray(value);
1572
1611
  }
1612
+ function highlightJsonText(text) {
1613
+ const escapeToken = (value) => value.replace(/[&<>"']/g, (char) => {
1614
+ switch (char) {
1615
+ case "&":
1616
+ return "&amp;";
1617
+ case "<":
1618
+ return "&lt;";
1619
+ case ">":
1620
+ return "&gt;";
1621
+ case '"':
1622
+ return "&quot;";
1623
+ default:
1624
+ return "&#39;";
1625
+ }
1626
+ });
1627
+ const token = /("(?:\\u[\da-fA-F]{4}|\\[^u]|[^\\"])*"\s*:|"(?:\\u[\da-fA-F]{4}|\\[^u]|[^\\"])*"|\b(?:true|false)\b|\bnull\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g;
1628
+ let cursor = 0;
1629
+ let html = "";
1630
+ for (const match of text.matchAll(token)) {
1631
+ const index = match.index ?? 0;
1632
+ const value = match[0];
1633
+ html += escapeToken(text.slice(cursor, index));
1634
+ const kind = value.startsWith('"') ? value.trimEnd().endsWith(":") ? "key" : "string" : value === "true" || value === "false" ? "boolean" : value === "null" ? "null" : "number";
1635
+ html += `<span class="json-${kind}">${escapeToken(value)}</span>`;
1636
+ cursor = index + value.length;
1637
+ }
1638
+ return html + escapeToken(text.slice(cursor));
1639
+ }
1573
1640
  function mountAdminBrand(doc, host, service, catalog) {
1574
1641
  const clip = (value, max) => {
1575
1642
  if (typeof value !== "string")
@@ -1931,14 +1998,16 @@ function bootAdmin(config) {
1931
1998
  const state = {
1932
1999
  namespace: "default",
1933
2000
  key: sessionStorage.getItem("mockingbird-admin-key") || "",
1934
- view: "overview",
2001
+ view: ["overview", "state", "clock", "faults", "journal", "routes"].includes(location.hash.slice(1)) ? location.hash.slice(1) : "overview",
1935
2002
  collection: "",
1936
2003
  shape: null,
1937
2004
  route: null,
2005
+ routes: [],
1938
2006
  editing: null
1939
2007
  };
1940
2008
  const standard = new Set(config.standardRoutes);
1941
2009
  const banner = el("banner", HTMLElement);
2010
+ const bannerMessage = el("banner-message", HTMLElement);
1942
2011
  const keyInput = el("admin-key", HTMLInputElement);
1943
2012
  keyInput.value = state.key;
1944
2013
  const params = new URLSearchParams(location.search);
@@ -1949,18 +2018,21 @@ function bootAdmin(config) {
1949
2018
  sessionStorage.setItem("mockingbird-admin-key", state.key);
1950
2019
  params.delete("key");
1951
2020
  const next = params.toString();
1952
- history.replaceState(null, "", location.pathname + (next ? `?${next}` : ""));
2021
+ history.replaceState(null, "", location.pathname + (next ? `?${next}` : "") + location.hash);
1953
2022
  }
1954
2023
  const queryNamespace = params.get("namespace");
1955
2024
  if (queryNamespace)
1956
2025
  state.namespace = queryNamespace;
1957
2026
  const showError = (error) => {
1958
- banner.textContent = error instanceof Error ? error.message : String(error);
2027
+ bannerMessage.textContent = error instanceof Error ? error.message : String(error);
1959
2028
  banner.classList.add("show");
2029
+ banner.scrollIntoView({ behavior: "smooth", block: "nearest" });
1960
2030
  };
1961
2031
  const clearError = () => {
1962
2032
  banner.classList.remove("show");
2033
+ bannerMessage.textContent = "";
1963
2034
  };
2035
+ el("banner-dismiss", HTMLButtonElement).addEventListener("click", clearError);
1964
2036
  const api = async (method, path, body) => {
1965
2037
  const headers = { accept: "application/json" };
1966
2038
  if (state.key !== "")
@@ -2004,16 +2076,21 @@ function bootAdmin(config) {
2004
2076
  namespace: () => state.namespace
2005
2077
  };
2006
2078
  const isPanelScript = (value) => typeof value === "function";
2079
+ const themeButton = el("theme", HTMLButtonElement);
2007
2080
  const setTheme = (theme) => {
2008
2081
  if (theme)
2009
2082
  document.documentElement.dataset.theme = theme;
2010
2083
  else
2011
2084
  delete document.documentElement.dataset.theme;
2085
+ const dark = theme ? theme === "dark" : matchMedia("(prefers-color-scheme: dark)").matches;
2086
+ themeButton.innerHTML = dark ? '<svg viewBox="0 0 20 20" fill="none" aria-hidden="true"><circle cx="10" cy="10" r="3.2" stroke="currentColor" stroke-width="1.5"/><path d="M10 2v1.5M10 16.5V18M18 10h-1.5M3.5 10H2M15.7 4.3l-1 1M5.3 14.7l-1 1M15.7 15.7l-1-1M5.3 5.3l-1-1" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>' : '<svg viewBox="0 0 20 20" fill="none" aria-hidden="true"><path d="M16.8 12.2A7 7 0 0 1 7.8 3.2a7 7 0 1 0 9 9Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></svg>';
2087
+ const label = dark ? "Use light theme" : "Use dark theme";
2088
+ themeButton.setAttribute("aria-label", label);
2089
+ themeButton.title = label;
2012
2090
  };
2013
2091
  const storedTheme = localStorage.getItem("mockingbird-admin-theme");
2014
- if (storedTheme)
2015
- setTheme(storedTheme);
2016
- el("theme", HTMLButtonElement).addEventListener("click", () => {
2092
+ setTheme(storedTheme === "dark" || storedTheme === "light" ? storedTheme : null);
2093
+ themeButton.addEventListener("click", () => {
2017
2094
  const current = document.documentElement.dataset.theme;
2018
2095
  const dark = current ? current === "dark" : matchMedia("(prefers-color-scheme: dark)").matches;
2019
2096
  const next = dark ? "light" : "dark";
@@ -2032,6 +2109,7 @@ function bootAdmin(config) {
2032
2109
  continue;
2033
2110
  section.hidden = section.id !== `view-${view}`;
2034
2111
  }
2112
+ history.replaceState(null, "", `${location.pathname}${location.search}#${encodeURIComponent(view)}`);
2035
2113
  };
2036
2114
  el("nav", HTMLElement).addEventListener("click", (event) => {
2037
2115
  const target = event.target;
@@ -2046,7 +2124,7 @@ function bootAdmin(config) {
2046
2124
  show(view);
2047
2125
  refresh().catch(showError);
2048
2126
  });
2049
- const cards = (items) => `<div class="grid">${items.map((item) => `<article class="card"><div class="k">${item.k}</div><div class="v">${item.v}</div></article>`).join("")}</div>`;
2127
+ const cards = (items) => `<div class="grid">${items.map((item) => `<article class="card stat-card"><div class="k">${item.k}</div><div class="v">${item.v}</div></article>`).join("")}</div>`;
2050
2128
  const table = (columns, rows) => {
2051
2129
  if (rows.length === 0)
2052
2130
  return '<p class="empty">Nothing here yet.</p>';
@@ -2056,16 +2134,22 @@ function bootAdmin(config) {
2056
2134
  const attrs = "attrs" in row && typeof row.attrs === "string" ? row.attrs : "";
2057
2135
  return `<tr${attrs}>${cells}</tr>`;
2058
2136
  }).join("");
2059
- return `<table><thead>${head}</thead><tbody>${body}</tbody></table>`;
2137
+ return `<div class="table-shell"><table><thead>${head}</thead><tbody>${body}</tbody></table></div>`;
2060
2138
  };
2061
2139
  const preview = (value) => {
2062
2140
  const text = JSON.stringify(value) ?? "null";
2063
- return esc(text.length > 180 ? `${text.slice(0, 180)}\u2026` : text);
2141
+ const clipped = text.length > 180 ? `${text.slice(0, 180)}\u2026` : text;
2142
+ return `<code class="json-inline">${highlightJsonText(clipped)}</code>`;
2064
2143
  };
2065
2144
  const when = (epoch) => {
2066
2145
  const date = new Date(epoch);
2067
2146
  return Number.isNaN(date.getTime()) ? String(epoch) : date.toISOString();
2068
2147
  };
2148
+ const journalWhen = (value) => {
2149
+ const numeric = value.trim() === "" ? Number.NaN : Number(value);
2150
+ const epoch = Number.isFinite(numeric) ? numeric : Date.parse(value);
2151
+ return Number.isFinite(epoch) ? when(epoch) : value;
2152
+ };
2069
2153
  const clockCard = (epoch) => {
2070
2154
  const date = new Date(epoch);
2071
2155
  if (Number.isNaN(date.getTime()))
@@ -2185,7 +2269,7 @@ function bootAdmin(config) {
2185
2269
  };
2186
2270
  const renderOverview = async () => {
2187
2271
  const [health, shape, clock, journal] = await Promise.all([
2188
- originGet("/health"),
2272
+ originGet(`${config.adminPrefix}/health`),
2189
2273
  api("GET", "/state"),
2190
2274
  api("GET", "/clock"),
2191
2275
  api("GET", "/requests?limit=8")
@@ -2202,7 +2286,7 @@ function bootAdmin(config) {
2202
2286
  },
2203
2287
  {
2204
2288
  k: "Status",
2205
- v: esc(typeof health.status === "string" && health.status !== "" ? health.status : "ok")
2289
+ v: `<span class="badge ok">${esc(typeof health.status === "string" && health.status !== "" ? health.status : "ok")}</span>`
2206
2290
  },
2207
2291
  { k: "Namespace", v: esc(state.namespace) },
2208
2292
  { k: "Collections", v: String(decoded.collections.length) },
@@ -2289,26 +2373,46 @@ function bootAdmin(config) {
2289
2373
  const renderJournal = async () => {
2290
2374
  const journal = await api("GET", "/requests?limit=100");
2291
2375
  el("journal", HTMLElement).innerHTML = table([
2292
- { label: "When", cell: (row) => esc(row.at) },
2293
- { label: "Method", cell: (row) => esc(row.method) },
2376
+ { label: "When", cell: (row) => esc(journalWhen(row.at)) },
2377
+ {
2378
+ label: "Method",
2379
+ cell: (row) => `<span class="method">${esc(row.method)}</span>`
2380
+ },
2294
2381
  { label: "Path", cell: (row) => esc(row.path) },
2295
- { label: "Status", cell: (row) => esc(row.status) },
2382
+ {
2383
+ label: "Status",
2384
+ cell: (row) => `<span class="badge ${row.status.startsWith("2") ? "ok" : row.status.startsWith("4") || row.status.startsWith("5") ? "warn" : ""}">${esc(row.status)}</span>`
2385
+ },
2296
2386
  { label: "ms", cell: (row) => esc(row.durationMs) }
2297
2387
  ], decodeJournal(journal));
2298
2388
  };
2299
- const renderRoutes = async () => {
2300
- const data = await api("GET", "/");
2301
- if (!isRecord2(data))
2302
- throw new Error("Route list was not an object");
2303
- const routes = requireList(data.routes, "Routes").map((route) => String(route));
2389
+ const paintRoutes = () => {
2390
+ const query = el("route-search", HTMLInputElement).value.trim().toLocaleLowerCase();
2391
+ const routes = state.routes.filter((route) => route.toLocaleLowerCase().includes(query));
2392
+ el("route-count", HTMLElement).textContent = `${routes.length} of ${state.routes.length}`;
2393
+ if (state.routes.length === 0) {
2394
+ el("route-list", HTMLElement).innerHTML = '<p class="empty">No admin routes are available.</p>';
2395
+ return;
2396
+ }
2397
+ if (routes.length === 0) {
2398
+ el("route-list", HTMLElement).innerHTML = '<p class="empty">No routes match this filter.</p>';
2399
+ return;
2400
+ }
2304
2401
  el("route-list", HTMLElement).innerHTML = routes.map((route) => {
2305
2402
  const extra = standard.has(route) ? "" : " <span class='badge'>extension</span>";
2306
2403
  const parts = route.split(" ");
2307
2404
  const method = parts[0] ?? "";
2308
2405
  const path = parts.slice(1).join(" ");
2309
- return `<div class="route"><span class="method">${esc(method)}</span><span>${esc(path)}${extra}</span><button class="btn" type="button" data-route="${esc(route)}">Use</button></div>`;
2406
+ return `<div class="route"><span class="method">${esc(method)}</span><span>${esc(path)}${extra}</span><button class="btn" type="button" data-route="${esc(route)}" aria-label="Use ${esc(route)}">Use</button></div>`;
2310
2407
  }).join("");
2311
2408
  };
2409
+ const renderRoutes = async () => {
2410
+ const data = await api("GET", "/");
2411
+ if (!isRecord2(data))
2412
+ throw new Error("Route list was not an object");
2413
+ state.routes = requireList(data.routes, "Routes").map((route) => String(route));
2414
+ paintRoutes();
2415
+ };
2312
2416
  const renderers = {
2313
2417
  overview: renderOverview,
2314
2418
  state: renderState,
@@ -2317,19 +2421,50 @@ function bootAdmin(config) {
2317
2421
  journal: renderJournal,
2318
2422
  routes: renderRoutes
2319
2423
  };
2424
+ let refreshToken = 0;
2320
2425
  async function refresh() {
2426
+ const token = ++refreshToken;
2321
2427
  clearError();
2428
+ const main = el("views", HTMLElement);
2429
+ const sync = el("sync-status", HTMLElement);
2430
+ main.setAttribute("aria-busy", "true");
2431
+ sync.classList.add("busy");
2432
+ sync.textContent = "Updating";
2322
2433
  const render2 = renderers[state.view];
2323
- if (render2)
2324
- await render2();
2434
+ try {
2435
+ if (render2)
2436
+ await render2();
2437
+ if (token === refreshToken)
2438
+ sync.textContent = "Up to date";
2439
+ } finally {
2440
+ if (token === refreshToken) {
2441
+ main.removeAttribute("aria-busy");
2442
+ sync.classList.remove("busy");
2443
+ }
2444
+ }
2325
2445
  }
2326
2446
  const delay = (ms) => new Promise((resolve) => {
2327
2447
  window.setTimeout(resolve, ms);
2328
2448
  });
2329
- const confirmAction = async (button, pending, done, work) => {
2449
+ const confirmAction = async (button, pending, done, work, confirmLabel) => {
2330
2450
  if (button.dataset.pending === "1")
2331
2451
  return;
2332
2452
  const label = button.textContent ?? "";
2453
+ if (confirmLabel !== void 0 && button.dataset.confirming !== "1") {
2454
+ button.dataset.confirming = "1";
2455
+ button.classList.add("confirming");
2456
+ button.textContent = confirmLabel;
2457
+ window.setTimeout(() => {
2458
+ if (button.dataset.confirming !== "1")
2459
+ return;
2460
+ button.textContent = label;
2461
+ button.classList.remove("confirming");
2462
+ delete button.dataset.confirming;
2463
+ }, 3e3);
2464
+ return;
2465
+ }
2466
+ button.classList.remove("confirming");
2467
+ delete button.dataset.confirming;
2333
2468
  const width = button.offsetWidth;
2334
2469
  button.dataset.pending = "1";
2335
2470
  button.classList.add("pressed");
@@ -2350,6 +2485,65 @@ function bootAdmin(config) {
2350
2485
  delete button.dataset.pending;
2351
2486
  }
2352
2487
  };
2488
+ const wireJsonEditor = (textareaId, highlightId, statusId, shellId, allowEmpty) => {
2489
+ const textarea = el(textareaId, HTMLTextAreaElement);
2490
+ const highlight = el(highlightId, HTMLElement);
2491
+ const status = el(statusId, HTMLElement);
2492
+ const shell = el(shellId, HTMLElement);
2493
+ const sync = () => {
2494
+ const text = textarea.value;
2495
+ highlight.innerHTML = `${highlightJsonText(text)}
2496
+ `;
2497
+ let valid = true;
2498
+ let message = "Valid JSON";
2499
+ if (allowEmpty && text.trim() === "")
2500
+ message = "No body";
2501
+ else {
2502
+ try {
2503
+ readJson3(text);
2504
+ } catch {
2505
+ valid = false;
2506
+ message = "Invalid JSON";
2507
+ }
2508
+ }
2509
+ shell.classList.toggle("invalid", !valid);
2510
+ status.classList.toggle("invalid", !valid);
2511
+ status.textContent = message;
2512
+ textarea.setAttribute("aria-invalid", valid ? "false" : "true");
2513
+ };
2514
+ const syncScroll = () => {
2515
+ const preview2 = highlight.parentElement;
2516
+ if (!(preview2 instanceof HTMLElement))
2517
+ return;
2518
+ preview2.scrollTop = textarea.scrollTop;
2519
+ preview2.scrollLeft = textarea.scrollLeft;
2520
+ };
2521
+ textarea.addEventListener("input", sync);
2522
+ textarea.addEventListener("scroll", syncScroll);
2523
+ sync();
2524
+ return sync;
2525
+ };
2526
+ const syncRouteEditor = wireJsonEditor("route-body", "route-highlight", "route-json-status", "route-editor", true);
2527
+ const syncRecordEditor = wireJsonEditor("editor-value", "editor-highlight", "editor-json-status", "record-editor", false);
2528
+ for (const button of document.querySelectorAll("[data-format]")) {
2529
+ if (!(button instanceof HTMLButtonElement))
2530
+ continue;
2531
+ button.addEventListener("click", () => {
2532
+ const target = button.dataset.format;
2533
+ if (target === void 0)
2534
+ return;
2535
+ const textarea = document.getElementById(target);
2536
+ if (!(textarea instanceof HTMLTextAreaElement) || textarea.value.trim() === "")
2537
+ return;
2538
+ try {
2539
+ textarea.value = JSON.stringify(readJson3(textarea.value), null, 2) ?? "";
2540
+ textarea.dispatchEvent(new Event("input"));
2541
+ textarea.focus();
2542
+ } catch {
2543
+ showError(new Error("Fix the JSON before formatting it"));
2544
+ }
2545
+ });
2546
+ }
2353
2547
  document.addEventListener("click", (event) => {
2354
2548
  const target = event.target;
2355
2549
  if (!(target instanceof Element))
@@ -2422,6 +2616,7 @@ function bootAdmin(config) {
2422
2616
  } else {
2423
2617
  editorValue.value = "{\n \n}";
2424
2618
  }
2619
+ syncRecordEditor();
2425
2620
  editor.showModal();
2426
2621
  }
2427
2622
  el("new-record", HTMLButtonElement).addEventListener("click", () => {
@@ -2451,10 +2646,10 @@ function bootAdmin(config) {
2451
2646
  const id = state.editing;
2452
2647
  if (!id)
2453
2648
  return;
2454
- api("DELETE", `/state/${encodeURIComponent(state.collection)}/${encodeURIComponent(id)}`).then(() => {
2649
+ confirmAction(editorDelete, "Deleting...", "Deleted", () => api("DELETE", `/state/${encodeURIComponent(state.collection)}/${encodeURIComponent(id)}`).then(() => {
2455
2650
  editor.close();
2456
2651
  return renderState();
2457
- }).catch(showError);
2652
+ }), "Confirm delete").catch(showError);
2458
2653
  });
2459
2654
  for (const button of document.querySelectorAll("[data-advance]")) {
2460
2655
  if (!(button instanceof HTMLButtonElement))
@@ -2500,10 +2695,10 @@ function bootAdmin(config) {
2500
2695
  }).then(() => renderFaults()).catch(showError);
2501
2696
  });
2502
2697
  el("fault-clear", HTMLButtonElement).addEventListener("click", () => {
2503
- confirmAction(el("fault-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/faults").then(() => renderFaults())).catch(showError);
2698
+ confirmAction(el("fault-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/faults").then(() => renderFaults()), "Confirm clear").catch(showError);
2504
2699
  });
2505
2700
  el("journal-clear", HTMLButtonElement).addEventListener("click", () => {
2506
- confirmAction(el("journal-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/requests").then(() => renderJournal())).catch(showError);
2701
+ confirmAction(el("journal-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/requests").then(() => renderJournal()), "Confirm clear").catch(showError);
2507
2702
  });
2508
2703
  el("route-list", HTMLElement).addEventListener("click", (event) => {
2509
2704
  const target = event.target;
@@ -2517,9 +2712,15 @@ function bootAdmin(config) {
2517
2712
  return;
2518
2713
  state.route = route;
2519
2714
  el("route-title", HTMLElement).textContent = route;
2715
+ el("route-send", HTMLButtonElement).disabled = false;
2520
2716
  const method = route.split(" ")[0] ?? "";
2521
- el("route-body", HTMLTextAreaElement).disabled = method === "GET" || method === "DELETE";
2717
+ const routeBody = el("route-body", HTMLTextAreaElement);
2718
+ routeBody.disabled = method === "GET" || method === "DELETE";
2719
+ el("route-editor", HTMLElement).hidden = routeBody.disabled;
2720
+ el("route-json-status", HTMLElement).parentElement?.toggleAttribute("hidden", routeBody.disabled);
2721
+ syncRouteEditor();
2522
2722
  });
2723
+ el("route-search", HTMLInputElement).addEventListener("input", paintRoutes);
2523
2724
  el("route-form", HTMLFormElement).addEventListener("submit", (event) => {
2524
2725
  event.preventDefault();
2525
2726
  const send = async () => {
@@ -2541,7 +2742,8 @@ function bootAdmin(config) {
2541
2742
  }
2542
2743
  }
2543
2744
  const data = await api(method, concrete, body);
2544
- el("route-result", HTMLElement).textContent = JSON.stringify(data, null, 2);
2745
+ const text = JSON.stringify(data, null, 2) ?? "null";
2746
+ el("route-result", HTMLElement).innerHTML = `<code>${highlightJsonText(text)}</code>`;
2545
2747
  };
2546
2748
  send().catch(showError);
2547
2749
  });
@@ -2659,11 +2861,13 @@ function bootAdmin(config) {
2659
2861
  views.append(section);
2660
2862
  mountSqlExplorer(body, panelApi);
2661
2863
  }
2864
+ show(document.getElementById(`view-${state.view}`) === null ? "overview" : state.view);
2662
2865
  return refresh();
2663
2866
  }).catch(showError);
2664
2867
  }
2665
2868
  var clientPrelude = [
2666
2869
  isRecord2,
2870
+ highlightJsonText,
2667
2871
  mountAdminBrand,
2668
2872
  startAdminBrand,
2669
2873
  faultPresetList,
@@ -2686,6 +2890,7 @@ ${entry}(${payload});
2686
2890
  var defineMock = (runtime) => runtime;
2687
2891
  var STANDARD_ADMIN_ROUTES = [
2688
2892
  "GET /",
2893
+ "GET /health",
2689
2894
  "POST /reset",
2690
2895
  "GET /namespaces",
2691
2896
  "GET /clock",
@@ -2765,11 +2970,11 @@ var escapeHtml = (value) => value.replace(/[&<>"']/g, (char) => {
2765
2970
  }
2766
2971
  });
2767
2972
  var ADMIN_BRANDS_URL = "https://mockingbird.chrisvouga.dev/brands.json";
2768
- var renderAdminDocument = (service) => {
2973
+ var renderAdminDocument = (service, adminPrefix = ADMIN_PREFIX) => {
2769
2974
  const name = escapeHtml(service);
2770
2975
  const script = adminClientSource({
2771
2976
  standardRoutes: STANDARD_ADMIN_ROUTES,
2772
- adminPrefix: ADMIN_PREFIX,
2977
+ adminPrefix,
2773
2978
  adminKeyHeader: ADMIN_KEY_HEADER,
2774
2979
  brandsUrl: ADMIN_BRANDS_URL,
2775
2980
  service
@@ -2786,75 +2991,99 @@ var renderAdminDocument = (service) => {
2786
2991
  ${CSS_RESET}
2787
2992
  :root {
2788
2993
  color-scheme: light;
2789
- --bg: #fafafa;
2994
+ --bg: #f7f8fb;
2790
2995
  --bg-elev: #ffffff;
2791
- --bg-muted: #f4f4f5;
2792
- --bg-inset: #f4f4f5;
2793
- --line: #dedee3;
2794
- --line-strong: #c4c4cc;
2795
- --ink: #18181b;
2796
- --muted: #62626b;
2797
- --faint: #71717a;
2798
- --accent: #27272a;
2996
+ --bg-muted: #f1f3f7;
2997
+ --bg-inset: #f8f9fc;
2998
+ --line: #e4e7ec;
2999
+ --line-strong: #cfd4dc;
3000
+ --ink: #101828;
3001
+ --muted: #475467;
3002
+ --faint: #667085;
3003
+ --accent: #6941c6;
3004
+ --accent-hover: #5933b4;
2799
3005
  --accent-ink: #ffffff;
2800
- --accent-soft: #f4f4f5;
2801
- --ok: #166534;
2802
- --ok-soft: #f0fdf4;
2803
- --warn: #a16207;
2804
- --warn-soft: #fefce8;
2805
- --err: #a82d32;
2806
- --err-soft: #fef2f2;
2807
- --shadow: none;
3006
+ --accent-soft: #f4f0ff;
3007
+ --ok: #067647;
3008
+ --ok-soft: #ecfdf3;
3009
+ --warn: #b54708;
3010
+ --warn-soft: #fffaeb;
3011
+ --err: #b42318;
3012
+ --err-soft: #fef3f2;
3013
+ --code-key: #6941c6;
3014
+ --code-string: #067647;
3015
+ --code-number: #175cd3;
3016
+ --code-boolean: #c11574;
3017
+ --code-null: #667085;
3018
+ --shadow-xs: 0 1px 2px rgb(16 24 40 / 0.05);
3019
+ --shadow-sm: 0 1px 3px rgb(16 24 40 / 0.08), 0 1px 2px rgb(16 24 40 / 0.04);
3020
+ --shadow-lg: 0 18px 48px rgb(16 24 40 / 0.16), 0 4px 12px rgb(16 24 40 / 0.08);
2808
3021
  --sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
2809
3022
  --mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;
2810
- --radius: 10px;
2811
- --header: 56px;
2812
- --nav: 200px;
3023
+ --radius: 12px;
3024
+ --header: 72px;
3025
+ --nav: 224px;
2813
3026
  }
2814
3027
  :root[data-theme="dark"] {
2815
3028
  color-scheme: dark;
2816
- --bg: #111113;
2817
- --bg-elev: #19191c;
2818
- --bg-muted: #242428;
2819
- --bg-inset: #111113;
2820
- --line: #36363c;
2821
- --line-strong: #52525b;
2822
- --ink: #f4f4f5;
2823
- --muted: #a9a9b2;
2824
- --faint: #71717a;
2825
- --accent: #e4e4e7;
2826
- --accent-ink: #18181b;
2827
- --accent-soft: #242428;
2828
- --ok: #86efac;
2829
- --ok-soft: #14241b;
2830
- --warn: #fde68a;
2831
- --warn-soft: #2a2410;
2832
- --err: #ffaaaa;
2833
- --err-soft: #2c1516;
2834
- --shadow: none;
3029
+ --bg: #0c0e14;
3030
+ --bg-elev: #151821;
3031
+ --bg-muted: #20232e;
3032
+ --bg-inset: #11141c;
3033
+ --line: #2b303d;
3034
+ --line-strong: #414858;
3035
+ --ink: #f5f7fa;
3036
+ --muted: #b6bdc9;
3037
+ --faint: #8d96a7;
3038
+ --accent: #9e77ed;
3039
+ --accent-hover: #b692f6;
3040
+ --accent-ink: #ffffff;
3041
+ --accent-soft: #2b2142;
3042
+ --ok: #75e0a7;
3043
+ --ok-soft: #102a20;
3044
+ --warn: #fec84b;
3045
+ --warn-soft: #302611;
3046
+ --err: #fda29b;
3047
+ --err-soft: #321817;
3048
+ --code-key: #c3a6ff;
3049
+ --code-string: #75e0a7;
3050
+ --code-number: #84adff;
3051
+ --code-boolean: #f9a8d4;
3052
+ --code-null: #98a2b3;
3053
+ --shadow-xs: 0 1px 2px rgb(0 0 0 / 0.24);
3054
+ --shadow-sm: 0 4px 12px rgb(0 0 0 / 0.2);
3055
+ --shadow-lg: 0 20px 56px rgb(0 0 0 / 0.45);
2835
3056
  }
2836
3057
  @media (prefers-color-scheme: dark) {
2837
3058
  :root:not([data-theme="light"]) {
2838
3059
  color-scheme: dark;
2839
- --bg: #111113;
2840
- --bg-elev: #19191c;
2841
- --bg-muted: #242428;
2842
- --bg-inset: #111113;
2843
- --line: #36363c;
2844
- --line-strong: #52525b;
2845
- --ink: #f4f4f5;
2846
- --muted: #a9a9b2;
2847
- --faint: #71717a;
2848
- --accent: #e4e4e7;
2849
- --accent-ink: #18181b;
2850
- --accent-soft: #242428;
2851
- --ok: #86efac;
2852
- --ok-soft: #14241b;
2853
- --warn: #fde68a;
2854
- --warn-soft: #2a2410;
2855
- --err: #ffaaaa;
2856
- --err-soft: #2c1516;
2857
- --shadow: none;
3060
+ --bg: #0c0e14;
3061
+ --bg-elev: #151821;
3062
+ --bg-muted: #20232e;
3063
+ --bg-inset: #11141c;
3064
+ --line: #2b303d;
3065
+ --line-strong: #414858;
3066
+ --ink: #f5f7fa;
3067
+ --muted: #b6bdc9;
3068
+ --faint: #8d96a7;
3069
+ --accent: #9e77ed;
3070
+ --accent-hover: #b692f6;
3071
+ --accent-ink: #ffffff;
3072
+ --accent-soft: #2b2142;
3073
+ --ok: #75e0a7;
3074
+ --ok-soft: #102a20;
3075
+ --warn: #fec84b;
3076
+ --warn-soft: #302611;
3077
+ --err: #fda29b;
3078
+ --err-soft: #321817;
3079
+ --code-key: #c3a6ff;
3080
+ --code-string: #75e0a7;
3081
+ --code-number: #84adff;
3082
+ --code-boolean: #f9a8d4;
3083
+ --code-null: #98a2b3;
3084
+ --shadow-xs: 0 1px 2px rgb(0 0 0 / 0.24);
3085
+ --shadow-sm: 0 4px 12px rgb(0 0 0 / 0.2);
3086
+ --shadow-lg: 0 20px 56px rgb(0 0 0 / 0.45);
2858
3087
  }
2859
3088
  }
2860
3089
  /* Fill the viewport, then grow with the content so the background covers the scroll. */
@@ -2872,13 +3101,17 @@ var renderAdminDocument = (service) => {
2872
3101
  padding-right: env(safe-area-inset-right);
2873
3102
  -webkit-text-size-adjust: 100%;
2874
3103
  }
2875
- :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
3104
+ ::selection { background: color-mix(in srgb, var(--accent) 28%, transparent); }
3105
+ :focus-visible { outline: 3px solid color-mix(in srgb, var(--accent) 45%, transparent); outline-offset: 2px; }
3106
+ button, input, select, textarea { transition: border-color 140ms ease, box-shadow 140ms ease, transform 100ms ease; }
3107
+ button:disabled, input:disabled, select:disabled, textarea:disabled { cursor: not-allowed; opacity: 0.55; }
2876
3108
  .app { flex: 1 0 auto; display: flex; flex-direction: column; min-width: 0; width: 100%; }
2877
3109
  header.top {
2878
- position: sticky; top: 0; z-index: 5;
3110
+ position: sticky; top: 0; z-index: 20;
2879
3111
  display: flex; align-items: center; gap: 12px;
2880
- min-height: var(--header); padding: 10px 16px;
2881
- background: var(--bg-elev);
3112
+ min-height: var(--header); padding: 12px 20px;
3113
+ background: color-mix(in srgb, var(--bg-elev) 92%, transparent);
3114
+ backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
2882
3115
  border-bottom: 1px solid var(--line);
2883
3116
  min-width: 0; max-width: 100%;
2884
3117
  }
@@ -2886,12 +3119,21 @@ var renderAdminDocument = (service) => {
2886
3119
  display: flex; flex-flow: row wrap; align-items: center; gap: 14px;
2887
3120
  min-width: 0; max-width: 100%; margin-right: auto;
2888
3121
  }
2889
- .brand-id { display: flex; flex-direction: column; min-width: 0; flex: none; }
2890
- .brand-id strong { font-size: 15px; letter-spacing: -0.01em; }
3122
+ .brand-lockup { display: flex; align-items: center; gap: 10px; min-width: 0; }
3123
+ .brand-mark {
3124
+ width: 38px; height: 38px; display: grid; place-items: center; flex: none;
3125
+ border-radius: 11px; color: #fff;
3126
+ background: linear-gradient(145deg, #7f56d9 0%, #53389e 100%);
3127
+ box-shadow: 0 6px 16px rgb(105 65 198 / 0.24), inset 0 1px 0 rgb(255 255 255 / 0.25);
3128
+ }
3129
+ .brand-mark svg { width: 22px; height: 22px; }
3130
+ .brand-id { display: flex; flex-direction: column; min-width: 0; flex: none; line-height: 1.25; }
3131
+ .brand-id strong { font-size: 15px; letter-spacing: -0.015em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
2891
3132
  .brand-id span { color: var(--faint); font-size: 12px; }
2892
3133
  .vendor {
2893
3134
  display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
2894
3135
  min-width: 0; max-width: 100%; font-size: 12px; color: var(--muted);
3136
+ padding: 5px 8px; border: 1px solid var(--line); border-radius: 999px; background: var(--bg-inset);
2895
3137
  }
2896
3138
  .vendor[hidden] { display: none; }
2897
3139
  /* Logos are drawn for a light tile, same as the docs site, so the plate stays white. */
@@ -2903,12 +3145,17 @@ var renderAdminDocument = (service) => {
2903
3145
  .vendor-sep { color: var(--faint); }
2904
3146
  .vendor-link { color: inherit; text-decoration: none; }
2905
3147
  .vendor-link:hover { color: var(--ink); text-decoration: underline; }
2906
- .controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
2907
- label.field { display: flex; flex-direction: column; gap: 2px; font-size: 11px; color: var(--faint); }
3148
+ .controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: flex-end; }
3149
+ .sync-status { align-self: center; display: flex; align-items: center; gap: 6px; color: var(--faint); font-size: 12px; white-space: nowrap; }
3150
+ .sync-status::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--ok); box-shadow: 0 0 0 3px var(--ok-soft); }
3151
+ .sync-status.busy::before { background: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); animation: pulse 1.2s ease-in-out infinite; }
3152
+ label.field { display: flex; flex-direction: column; gap: 4px; font-size: 11px; font-weight: 600; color: var(--faint); }
2908
3153
  label.field input, label.field select, .controls input[type="password"] {
2909
- background-color: var(--bg-inset); border: 1px solid var(--line); border-radius: 8px;
2910
- padding: 7px 8px; min-height: 36px; line-height: 1.2;
3154
+ background-color: var(--bg-elev); border: 1px solid var(--line-strong); border-radius: 9px;
3155
+ padding: 8px 10px; min-height: 38px; line-height: 1.2; box-shadow: var(--shadow-xs);
2911
3156
  }
3157
+ label.field input:hover, label.field select:hover, textarea:hover { border-color: color-mix(in srgb, var(--accent) 38%, var(--line-strong)); }
3158
+ label.field input:focus, label.field select:focus, textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 15%, transparent); outline: none; }
2912
3159
  /* A native menu ignores min-height, so Preset rendered about half as tall as
2913
3160
  the text fields in the same form. Paint it like those fields. */
2914
3161
  label.field select {
@@ -2925,12 +3172,20 @@ var renderAdminDocument = (service) => {
2925
3172
  dialog .field > textarea { width: 100%; }
2926
3173
  .icon-btn, .btn, .btn-primary, .btn-quiet, .btn-danger, nav.side button { cursor: pointer; }
2927
3174
  .icon-btn, .btn, .btn-primary, .btn-quiet, .btn-danger {
2928
- border-radius: 9px; min-height: 36px; padding: 0 12px; border: 1px solid var(--line);
2929
- background: var(--bg-elev);
2930
- }
2931
- .btn-primary { background: var(--accent); color: var(--accent-ink); border-color: transparent; }
2932
- .btn-quiet { background: transparent; }
2933
- .btn-danger { color: var(--err); }
3175
+ display: inline-flex; align-items: center; justify-content: center; gap: 7px;
3176
+ border-radius: 9px; min-height: 38px; padding: 0 13px; border: 1px solid var(--line-strong);
3177
+ background: var(--bg-elev); color: var(--ink); font-size: 13px; font-weight: 600; line-height: 1;
3178
+ box-shadow: var(--shadow-xs);
3179
+ }
3180
+ .icon-btn { width: 38px; padding: 0; }
3181
+ .icon-btn svg, .btn svg, .btn-primary svg { width: 16px; height: 16px; }
3182
+ .icon-btn:hover, .btn:hover { background: var(--bg-muted); border-color: var(--line-strong); }
3183
+ .btn-primary { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); box-shadow: 0 1px 2px rgb(16 24 40 / 0.08), 0 0 0 1px rgb(255 255 255 / 0.08) inset; }
3184
+ .btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
3185
+ .btn-quiet { background: transparent; border-color: transparent; box-shadow: none; }
3186
+ .btn-quiet:hover { background: var(--bg-muted); }
3187
+ .btn-danger { color: var(--err); border-color: color-mix(in srgb, var(--err) 25%, var(--line)); }
3188
+ .btn-danger:hover, .btn-danger.confirming { background: var(--err-soft); border-color: color-mix(in srgb, var(--err) 45%, var(--line)); }
2934
3189
  /* Held after mouseup. Clearing an empty journal changes nothing on screen,
2935
3190
  so the press itself has to stay visible. */
2936
3191
  .icon-btn:active, .btn:active, .btn-quiet:active, .btn-danger:active, nav.side button:active,
@@ -2946,93 +3201,159 @@ var renderAdminDocument = (service) => {
2946
3201
  flex: 1 0 auto; min-width: 0;
2947
3202
  }
2948
3203
  nav.side {
2949
- border-right: 1px solid var(--line); padding: 12px;
3204
+ position: sticky; top: var(--header); align-self: start; height: calc(100vh - var(--header));
3205
+ border-right: 1px solid var(--line); padding: 18px 12px;
2950
3206
  display: flex; flex-direction: column; gap: 4px;
2951
- background: var(--bg-elev);
3207
+ background: var(--bg-elev); overflow-y: auto;
2952
3208
  }
3209
+ .nav-label { padding: 0 10px 8px; color: var(--faint); font-size: 10px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; }
2953
3210
  nav.side button {
3211
+ position: relative; display: flex; align-items: center; gap: 10px; width: 100%;
2954
3212
  text-align: left; border: 0; background: transparent; border-radius: 9px;
2955
- padding: 9px 10px; min-height: 40px; color: var(--muted);
2956
- }
2957
- nav.side button[aria-current="true"] { background: var(--accent-soft); color: var(--ink); font-weight: 600; }
2958
- main { padding: 20px; max-width: 1100px; width: 100%; min-width: 0; }
2959
- .view h2 { margin: 0 0 4px; font-size: 22px; letter-spacing: -0.02em; }
2960
- .lede { margin: 0 0 16px; color: var(--muted); }
2961
- .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr)); gap: 12px; }
3213
+ padding: 9px 10px; min-height: 42px; color: var(--muted); font-size: 13px; font-weight: 550;
3214
+ }
3215
+ nav.side button:hover { background: var(--bg-muted); color: var(--ink); }
3216
+ nav.side button[aria-current="true"] { background: var(--accent-soft); color: var(--accent); font-weight: 650; }
3217
+ nav.side button[aria-current="true"]::before { content: ""; position: absolute; left: -12px; width: 3px; height: 22px; border-radius: 0 4px 4px 0; background: var(--accent); }
3218
+ .nav-icon { width: 18px; height: 18px; display: grid; place-items: center; flex: none; }
3219
+ .nav-icon svg { width: 18px; height: 18px; }
3220
+ main { padding: 32px clamp(20px, 4vw, 48px) 64px; max-width: 1320px; width: 100%; min-width: 0; }
3221
+ main[aria-busy="true"] .view { opacity: 0.62; }
3222
+ .view { animation: view-in 180ms ease-out; }
3223
+ .view[hidden] { display: none; }
3224
+ .view-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 22px; }
3225
+ .view h2 { margin: 0 0 5px; font-size: clamp(24px, 3vw, 30px); line-height: 1.2; letter-spacing: -0.035em; }
3226
+ .view h3 { margin: 24px 0 10px; font-size: 15px; letter-spacing: -0.01em; }
3227
+ .lede { margin: 0; color: var(--muted); max-width: 72ch; font-size: 14px; }
3228
+ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 175px), 1fr)); gap: 14px; }
2962
3229
  .card {
2963
3230
  background: var(--bg-elev); border: 1px solid var(--line); border-radius: var(--radius);
2964
- padding: 14px 16px; box-shadow: var(--shadow);
3231
+ padding: 18px; box-shadow: var(--shadow-sm);
2965
3232
  }
2966
- .card .k { color: var(--faint); font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; }
2967
- .card .v { display: flex; flex-direction: column; gap: 2px; font-size: 22px; margin-top: 4px; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
3233
+ .stat-card { position: relative; overflow: hidden; }
3234
+ .stat-card::after { content: ""; position: absolute; inset: 0 0 auto; height: 2px; background: linear-gradient(90deg, var(--accent), transparent 78%); opacity: 0.7; }
3235
+ .card .k { color: var(--faint); font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; }
3236
+ .card .v { display: flex; flex-direction: column; gap: 2px; font-size: 24px; font-weight: 650; margin-top: 7px; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; letter-spacing: -0.025em; }
2968
3237
  .card .v .clock-time { font-size: 14px; color: var(--muted); }
2969
- .split { display: grid; grid-template-columns: minmax(0, 240px) minmax(0, 1fr); gap: 16px; align-items: start; }
3238
+ .section-card { background: var(--bg-elev); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow-xs); overflow: hidden; }
3239
+ .section-title { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 16px 18px; border-bottom: 1px solid var(--line); }
3240
+ .section-title h3 { margin: 0; }
3241
+ .split { display: grid; grid-template-columns: minmax(0, 250px) minmax(0, 1fr); gap: 18px; align-items: start; }
3242
+ #collections { padding: 6px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg-elev); box-shadow: var(--shadow-xs); }
2970
3243
  .collection {
2971
3244
  width: 100%; text-align: left; border: 1px solid transparent; background: transparent;
2972
- border-radius: 10px; padding: 8px 10px; min-height: 44px;
3245
+ border-radius: 9px; padding: 10px 11px; min-height: 48px; color: var(--ink); cursor: pointer;
2973
3246
  }
2974
- .collection[aria-current="true"] { background: var(--bg-elev); border-color: var(--line); }
3247
+ .collection:hover { background: var(--bg-muted); }
3248
+ .collection[aria-current="true"] { background: var(--accent-soft); border-color: color-mix(in srgb, var(--accent) 18%, var(--line)); color: var(--accent); }
2975
3249
  .collection small { display: block; color: var(--faint); }
2976
3250
  .chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 14px; }
2977
- .chip {
2978
- font-family: var(--mono); font-size: 12px; background: var(--bg-muted);
2979
- border-radius: 999px; padding: 4px 8px;
2980
- }
3251
+ .chip { font-family: var(--mono); font-size: 11px; background: var(--bg-muted); border: 1px solid var(--line); border-radius: 999px; padding: 4px 8px; }
2981
3252
  .chip i { color: var(--faint); font-style: normal; }
2982
3253
  .sql-layout {
2983
3254
  display: grid; grid-template-columns: minmax(10rem, 16rem) minmax(0, 1fr); gap: 16px;
2984
3255
  }
2985
- .sql-tables { display: flex; flex-direction: column; gap: 4px; max-height: 28rem; overflow: auto; }
2986
- .sql-tables button { text-align: left; }
3256
+ .sql-tables { display: flex; flex-direction: column; gap: 4px; max-height: 28rem; overflow: auto; padding: 6px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg-elev); }
3257
+ .sql-tables button { text-align: left; border: 0; border-radius: 8px; padding: 9px 10px; background: transparent; color: var(--muted); cursor: pointer; }
3258
+ .sql-tables button:hover, .sql-tables button[aria-current="true"] { background: var(--accent-soft); color: var(--accent); }
2987
3259
  .sql-input { font-family: var(--mono); min-height: 7rem; width: 100%; }
2988
- table { width: 100%; border-collapse: collapse; font-size: 14px; }
2989
- th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--line); vertical-align: top; }
2990
- th { color: var(--faint); font-weight: 600; font-size: 12px; }
3260
+ .table-shell { width: 100%; overflow-x: auto; border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg-elev); box-shadow: var(--shadow-xs); }
3261
+ table { width: 100%; border-collapse: collapse; font-size: 13px; }
3262
+ th, td { text-align: left; padding: 11px 13px; border-bottom: 1px solid var(--line); vertical-align: top; }
3263
+ th { color: var(--faint); background: var(--bg-inset); font-weight: 650; font-size: 11px; text-transform: uppercase; letter-spacing: 0.045em; }
3264
+ tbody tr:last-child td { border-bottom: 0; }
2991
3265
  tr.clickable { cursor: pointer; }
2992
- tr.clickable:hover { background: var(--bg-muted); }
3266
+ tr.clickable:hover { background: var(--accent-soft); }
2993
3267
  pre, code, .mono { font-family: var(--mono); overflow-wrap: anywhere; }
2994
3268
  pre {
2995
3269
  margin: 0; white-space: pre-wrap; word-break: break-word;
2996
- background: var(--bg-inset); border-radius: 10px; padding: 12px; font-size: 12px;
2997
- }
2998
- .row-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0; }
3270
+ background: var(--bg-inset); border: 1px solid var(--line); border-radius: 10px; padding: 14px; font-size: 12px; line-height: 1.65;
3271
+ }
3272
+ .json-key { color: var(--code-key); }
3273
+ .json-string { color: var(--code-string); }
3274
+ .json-number { color: var(--code-number); }
3275
+ .json-boolean { color: var(--code-boolean); }
3276
+ .json-null { color: var(--code-null); font-style: italic; }
3277
+ .json-inline { display: block; max-width: 44rem; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
3278
+ .json-view { position: relative; max-height: 26rem; overflow: auto; }
3279
+ .json-editor { position: relative; min-height: 190px; border: 1px solid var(--line-strong); border-radius: 10px; background: var(--bg-inset); overflow: hidden; }
3280
+ .json-editor:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 15%, transparent); }
3281
+ .json-editor.invalid { border-color: var(--err); }
3282
+ .json-editor pre, .json-editor textarea { margin: 0; padding: 12px; border: 0; border-radius: 0; min-height: 190px; width: 100%; font: 12px/1.65 var(--mono); tab-size: 2; white-space: pre; overflow: auto; }
3283
+ .json-editor pre { position: absolute; inset: 0; pointer-events: none; background: transparent; }
3284
+ .json-editor textarea { position: relative; z-index: 1; resize: vertical; background: transparent; color: transparent; caret-color: var(--ink); -webkit-text-fill-color: transparent; }
3285
+ .json-editor textarea::selection { background: color-mix(in srgb, var(--accent) 32%, transparent); }
3286
+ .editor-meta { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 7px; }
3287
+ .json-status { display: inline-flex; align-items: center; gap: 6px; color: var(--ok); font-size: 11px; }
3288
+ .json-status::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
3289
+ .json-status.invalid { color: var(--err); }
3290
+ .format-json { border: 0; padding: 3px 5px; background: transparent; color: var(--accent); font-size: 11px; font-weight: 650; cursor: pointer; }
3291
+ .row-actions { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 8px; margin: 14px 0; }
2999
3292
  .banner {
3000
- display: none; margin-bottom: 12px; padding: 10px 12px; border-radius: 10px;
3001
- background: var(--err-soft); color: var(--err);
3002
- }
3003
- .banner.show { display: block; }
3004
- .empty { color: var(--muted); padding: 24px 8px; }
3293
+ display: none; position: sticky; top: calc(var(--header) + 12px); z-index: 15;
3294
+ align-items: center; gap: 10px; margin: 0 0 16px; padding: 11px 12px 11px 14px;
3295
+ border: 1px solid color-mix(in srgb, var(--err) 30%, var(--line)); border-radius: 10px;
3296
+ background: var(--err-soft); color: var(--err); box-shadow: var(--shadow-sm); font-size: 13px;
3297
+ }
3298
+ .banner.show { display: flex; }
3299
+ .banner span { flex: 1; }
3300
+ .banner button { width: 28px; height: 28px; display: grid; place-items: center; border: 0; border-radius: 7px; background: transparent; color: currentColor; cursor: pointer; }
3301
+ .banner button:hover { background: color-mix(in srgb, var(--err) 10%, transparent); }
3302
+ .empty { color: var(--muted); padding: 34px 18px; text-align: center; border: 1px dashed var(--line-strong); border-radius: var(--radius); background: color-mix(in srgb, var(--bg-elev) 55%, transparent); }
3005
3303
  dialog {
3006
- border: 1px solid var(--line); border-radius: var(--radius); padding: 0; background: var(--bg-elev);
3007
- color: var(--ink); width: min(640px, calc(100% - 24px)); max-width: calc(100vw - 24px); box-shadow: none;
3008
- }
3009
- dialog::backdrop { background: rgb(20 19 17 / 0.45); }
3010
- dialog form, .dialog-body { padding: 16px; display: flex; flex-direction: column; gap: 10px; }
3304
+ border: 1px solid var(--line); border-radius: 16px; padding: 0; background: var(--bg-elev);
3305
+ color: var(--ink); width: min(720px, calc(100% - 24px)); max-width: calc(100vw - 24px); box-shadow: var(--shadow-lg);
3306
+ }
3307
+ dialog[open] { animation: dialog-in 160ms ease-out; }
3308
+ dialog::backdrop { background: rgb(12 14 20 / 0.58); backdrop-filter: blur(3px); }
3309
+ dialog form, .dialog-body { padding: 22px; display: flex; flex-direction: column; gap: 14px; }
3310
+ .dialog-heading { display: flex; justify-content: space-between; gap: 16px; padding-bottom: 12px; border-bottom: 1px solid var(--line); }
3311
+ .dialog-heading strong { font-size: 18px; letter-spacing: -0.02em; }
3312
+ .dialog-heading span { color: var(--muted); font-size: 12px; }
3011
3313
  textarea {
3012
3314
  width: 100%; min-height: 180px; resize: vertical; border-radius: 10px;
3013
3315
  border: 1px solid var(--line); background: var(--bg-inset); padding: 10px;
3014
3316
  font-family: var(--mono); font-size: 12px;
3015
3317
  }
3016
- .dialog-actions { display: flex; justify-content: flex-end; gap: 8px; }
3017
- .badge { font-size: 11px; border-radius: 999px; padding: 2px 7px; background: var(--bg-muted); color: var(--muted); }
3318
+ .dialog-actions { display: flex; justify-content: flex-end; align-items: center; gap: 8px; padding-top: 4px; }
3319
+ .dialog-actions .btn-danger { margin-right: auto; }
3320
+ .badge { display: inline-flex; align-items: center; font-size: 10px; font-weight: 700; letter-spacing: 0.03em; border: 1px solid var(--line); border-radius: 999px; padding: 3px 7px; background: var(--bg-muted); color: var(--muted); }
3018
3321
  .badge.ok { background: var(--ok-soft); color: var(--ok); }
3019
3322
  .badge.warn { background: var(--warn-soft); color: var(--warn); }
3020
3323
  .status-2 { color: var(--ok); }
3021
3324
  .status-4, .status-5, .status-0 { color: var(--err); }
3022
3325
  .stack { display: flex; flex-direction: column; gap: 12px; }
3326
+ .form-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
3327
+ .form-grid .wide { grid-column: span 2; }
3023
3328
  .route {
3024
- display: grid; grid-template-columns: 88px 1fr auto; gap: 8px; align-items: center;
3025
- padding: 8px 0; border-bottom: 1px solid var(--line);
3026
- }
3027
- .method { font-family: var(--mono); font-size: 12px; font-weight: 700; }
3329
+ display: grid; grid-template-columns: 84px minmax(0, 1fr) auto; gap: 10px; align-items: center;
3330
+ padding: 10px 12px; border-bottom: 1px solid var(--line); background: var(--bg-elev);
3331
+ }
3332
+ .route:first-child { border-radius: var(--radius) var(--radius) 0 0; }
3333
+ .route:last-child { border-bottom: 0; border-radius: 0 0 var(--radius) var(--radius); }
3334
+ .route-tools { display: flex; align-items: flex-end; gap: 12px; margin-bottom: 12px; }
3335
+ .route-tools .field { flex: 1; }
3336
+ .route-tools input { width: 100%; }
3337
+ .route-count { padding-bottom: 10px; color: var(--faint); font-size: 12px; white-space: nowrap; }
3338
+ #route-list { max-height: 34rem; border: 1px solid var(--line); border-radius: var(--radius); overflow: auto; box-shadow: var(--shadow-xs); }
3339
+ .method { width: fit-content; font-family: var(--mono); font-size: 11px; font-weight: 750; color: var(--accent); background: var(--accent-soft); border-radius: 6px; padding: 4px 6px; }
3340
+ @keyframes view-in { from { transform: translateY(3px); } to { transform: translateY(0); } }
3341
+ @keyframes dialog-in { from { transform: translateY(8px) scale(0.99); } to { transform: translateY(0) scale(1); } }
3342
+ @keyframes pulse { 50% { opacity: 0.45; } }
3343
+ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } }
3344
+ @media (forced-colors: active) { .json-editor pre { display: none; } .json-editor textarea { color: CanvasText; -webkit-text-fill-color: CanvasText; } }
3028
3345
  @media (max-width: 800px) {
3346
+ :root { --header: 0px; }
3029
3347
  header.top {
3030
- align-items: stretch; flex-direction: column;
3031
- padding-top: max(10px, env(safe-area-inset-top));
3348
+ position: relative; align-items: stretch; flex-direction: column;
3349
+ padding: max(12px, env(safe-area-inset-top)) 14px 12px;
3032
3350
  }
3033
3351
  input, select, textarea { font-size: 16px; }
3034
3352
  .brand, .vendor, .controls { width: 100%; }
3353
+ .brand { gap: 10px; }
3354
+ .vendor { order: 2; border-radius: 9px; }
3035
3355
  label.field, label.field select, label.field input { flex: 1; min-width: 0; }
3356
+ .sync-status { display: none; }
3036
3357
  /* The layout fills the screen. A single-column grid stretches every auto
3037
3358
  row, so the tab bar grew with the leftover height and the buttons floated
3038
3359
  in the middle of it. The bar stays one row; the page below takes the rest. */
@@ -3042,14 +3363,23 @@ var renderAdminDocument = (service) => {
3042
3363
  align-content: start;
3043
3364
  }
3044
3365
  nav.side {
3045
- align-self: start; height: auto;
3366
+ position: sticky; top: 0; z-index: 12; align-self: start; height: auto;
3046
3367
  flex-direction: row; align-items: center; overflow-x: auto; min-width: 0;
3047
3368
  border-right: 0; border-bottom: 1px solid var(--line);
3048
- padding: 8px;
3369
+ padding: 8px; box-shadow: var(--shadow-xs);
3049
3370
  }
3050
- nav.side button { white-space: nowrap; flex: none; }
3371
+ .nav-label { display: none; }
3372
+ nav.side button { width: auto; white-space: nowrap; flex: none; }
3373
+ nav.side button[aria-current="true"]::before { inset: auto 9px -8px; width: auto; height: 3px; border-radius: 3px 3px 0 0; }
3051
3374
  .split, .sql-layout { grid-template-columns: minmax(0, 1fr); }
3052
- main { padding: 14px; }
3375
+ .form-grid { grid-template-columns: minmax(0, 1fr); }
3376
+ .form-grid .wide { grid-column: auto; }
3377
+ main { padding: 22px 14px 48px; }
3378
+ .view-head { margin-bottom: 18px; }
3379
+ .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
3380
+ .card { padding: 14px; }
3381
+ .card .v { font-size: 20px; }
3382
+ .table-shell { border: 0; background: transparent; box-shadow: none; overflow: visible; }
3053
3383
  table, thead, tbody, tr, th, td { display: block; }
3054
3384
  thead { display: none; }
3055
3385
  tr {
@@ -3064,50 +3394,63 @@ var renderAdminDocument = (service) => {
3064
3394
  .route { grid-template-columns: 72px 1fr; }
3065
3395
  .route button { grid-column: 1 / -1; }
3066
3396
  }
3397
+ @media (max-width: 460px) {
3398
+ .controls label.field { min-width: calc(50% - 4px); }
3399
+ .controls .icon-btn, .controls .btn { flex: 1; }
3400
+ .grid { grid-template-columns: minmax(0, 1fr); }
3401
+ .dialog-actions { flex-wrap: wrap; }
3402
+ .dialog-actions button { flex: 1; }
3403
+ .dialog-actions .btn-danger { flex-basis: 100%; margin-right: 0; }
3404
+ }
3067
3405
  </style>
3068
3406
  </head>
3069
3407
  <body>
3070
3408
  <div class="app" data-service="${name}" data-mockingbird-admin>
3071
3409
  <header class="top">
3072
3410
  <div class="brand">
3073
- <div class="brand-id">
3074
- <strong>${name}</strong>
3075
- <span>Admin</span>
3411
+ <div class="brand-lockup">
3412
+ <div class="brand-mark" aria-hidden="true">
3413
+ <svg viewBox="0 0 24 24" fill="none"><path d="M5 17.5V7l7 5 7-5v10.5" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/><path d="M8 15.5 12 18l4-2.5" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
3414
+ </div>
3415
+ <div class="brand-id">
3416
+ <strong>${name}</strong>
3417
+ <span>Mock admin</span>
3418
+ </div>
3076
3419
  </div>
3077
3420
  <div class="vendor" id="vendor" hidden></div>
3078
3421
  </div>
3079
3422
  <div class="controls">
3423
+ <span class="sync-status" id="sync-status" role="status" aria-live="polite">Ready</span>
3080
3424
  <label class="field">Namespace
3081
3425
  <select id="namespace" aria-label="Namespace"></select>
3082
3426
  </label>
3083
3427
  <label class="field">Admin key
3084
3428
  <input id="admin-key" type="password" autocomplete="off" spellcheck="false" aria-label="Admin key">
3085
3429
  </label>
3086
- <button class="icon-btn" id="theme" type="button" aria-label="Toggle color theme">Theme</button>
3087
- <button class="btn" id="refresh" type="button">Refresh</button>
3430
+ <button class="icon-btn" id="theme" type="button" aria-label="Use dark theme" title="Use dark theme"></button>
3431
+ <button class="btn" id="refresh" type="button"><svg viewBox="0 0 20 20" fill="none" aria-hidden="true"><path d="M15.8 7.2A6.2 6.2 0 1 0 16 12" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/><path d="M12.8 7.2h3v-3" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>Refresh</button>
3088
3432
  </div>
3089
3433
  </header>
3090
3434
  <div class="layout">
3091
3435
  <nav class="side" id="nav" aria-label="Admin sections">
3092
- <button type="button" data-view="overview" aria-current="true">Overview</button>
3093
- <button type="button" data-view="state">State</button>
3094
- <button type="button" data-view="clock">Clock</button>
3095
- <button type="button" data-view="faults">Faults</button>
3096
- <button type="button" data-view="journal">Journal</button>
3097
- <button type="button" data-view="routes">Routes</button>
3436
+ <span class="nav-label">Workspace</span>
3437
+ <button type="button" data-view="overview" aria-current="true"><span class="nav-icon" aria-hidden="true"><svg viewBox="0 0 20 20" fill="none"><rect x="2.5" y="2.5" width="6" height="6" rx="1.5" stroke="currentColor" stroke-width="1.5"/><rect x="11.5" y="2.5" width="6" height="6" rx="1.5" stroke="currentColor" stroke-width="1.5"/><rect x="2.5" y="11.5" width="6" height="6" rx="1.5" stroke="currentColor" stroke-width="1.5"/><rect x="11.5" y="11.5" width="6" height="6" rx="1.5" stroke="currentColor" stroke-width="1.5"/></svg></span>Overview</button>
3438
+ <button type="button" data-view="state"><span class="nav-icon" aria-hidden="true"><svg viewBox="0 0 20 20" fill="none"><ellipse cx="10" cy="5" rx="6.5" ry="2.5" stroke="currentColor" stroke-width="1.5"/><path d="M3.5 5v5c0 1.4 2.9 2.5 6.5 2.5s6.5-1.1 6.5-2.5V5M3.5 10v5c0 1.4 2.9 2.5 6.5 2.5s6.5-1.1 6.5-2.5v-5" stroke="currentColor" stroke-width="1.5"/></svg></span>State</button>
3439
+ <button type="button" data-view="clock"><span class="nav-icon" aria-hidden="true"><svg viewBox="0 0 20 20" fill="none"><circle cx="10" cy="10" r="7.5" stroke="currentColor" stroke-width="1.5"/><path d="M10 5.8v4.6l3 1.8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg></span>Clock</button>
3440
+ <button type="button" data-view="faults"><span class="nav-icon" aria-hidden="true"><svg viewBox="0 0 20 20" fill="none"><path d="m11.2 2.8-6.5 9h5.1l-1 5.4 6.5-9h-5.1l1-5.4Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></svg></span>Faults</button>
3441
+ <button type="button" data-view="journal"><span class="nav-icon" aria-hidden="true"><svg viewBox="0 0 20 20" fill="none"><path d="M5 3.5h10a1.5 1.5 0 0 1 1.5 1.5v12l-2.2-1.4-2.2 1.4-2.1-1.4L7.8 17l-2.2-1.4L3.5 17V5A1.5 1.5 0 0 1 5 3.5Z" stroke="currentColor" stroke-width="1.5"/><path d="M6.7 7h6.6M6.7 10h6.6M6.7 13h3.8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg></span>Journal</button>
3442
+ <button type="button" data-view="routes"><span class="nav-icon" aria-hidden="true"><svg viewBox="0 0 20 20" fill="none"><path d="M6.5 4.5h7a2 2 0 0 1 2 2v7M13 11l2.5 2.5L18 11M13.5 15.5h-7a2 2 0 0 1-2-2v-7M7 9 4.5 6.5 2 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg></span>Routes</button>
3098
3443
  </nav>
3099
3444
  <main id="views">
3100
- <p class="banner" id="banner" role="alert"></p>
3445
+ <div class="banner" id="banner" role="alert"><span id="banner-message"></span><button id="banner-dismiss" type="button" aria-label="Dismiss error">\xD7</button></div>
3101
3446
  <section class="view" id="view-overview">
3102
- <h2>Overview</h2>
3103
- <p class="lede">Health, clock, and the collections this namespace is holding.</p>
3447
+ <div class="view-head"><div><h2>Overview</h2><p class="lede">Health, clock, and the collections this namespace is holding.</p></div></div>
3104
3448
  <div class="grid" id="overview-cards"></div>
3105
3449
  <h3>Recent requests</h3>
3106
3450
  <div id="overview-requests"></div>
3107
3451
  </section>
3108
3452
  <section class="view" id="view-state" hidden>
3109
- <h2>State</h2>
3110
- <p class="lede">Every collection in this namespace. Declared fields stay visible before any row exists; stored rows add whatever else they contain.</p>
3453
+ <div class="view-head"><div><h2>State</h2><p class="lede">Inspect and edit every collection in this namespace.</p></div></div>
3111
3454
  <div class="split">
3112
3455
  <div id="collections"></div>
3113
3456
  <div>
@@ -3120,8 +3463,7 @@ var renderAdminDocument = (service) => {
3120
3463
  </div>
3121
3464
  </section>
3122
3465
  <section class="view" id="view-clock" hidden>
3123
- <h2>Clock</h2>
3124
- <p class="lede">The clock every timestamp in this mock reads.</p>
3466
+ <div class="view-head"><div><h2>Clock</h2><p class="lede">Control the source of time used by every timestamp in this mock.</p></div></div>
3125
3467
  <div class="card" id="clock-readout"></div>
3126
3468
  <div class="row-actions">
3127
3469
  <button class="btn" type="button" data-advance="60000">+1m</button>
@@ -3140,21 +3482,22 @@ var renderAdminDocument = (service) => {
3140
3482
  </form>
3141
3483
  </section>
3142
3484
  <section class="view" id="view-faults" hidden>
3143
- <h2>Faults</h2>
3144
- <p class="lede">Inject a response, a delay, or a named preset. Rules apply to this namespace.</p>
3485
+ <div class="view-head"><div><h2>Faults</h2><p class="lede">Test unhappy paths with a response, delay, or named preset scoped to this namespace.</p></div></div>
3145
3486
  <form id="fault-form" class="card stack">
3146
- <label class="field">Preset
3147
- <select id="fault-preset"><option value="">Custom rule</option></select>
3148
- </label>
3149
- <label class="field">Status
3150
- <input id="fault-status" inputmode="numeric" placeholder="503">
3151
- </label>
3152
- <label class="field">Path prefix
3153
- <input id="fault-path" placeholder="/v1">
3154
- </label>
3155
- <label class="field">Method
3156
- <input id="fault-method" placeholder="GET">
3157
- </label>
3487
+ <div class="form-grid">
3488
+ <label class="field wide">Preset
3489
+ <select id="fault-preset"><option value="">Custom rule</option></select>
3490
+ </label>
3491
+ <label class="field">Status
3492
+ <input id="fault-status" inputmode="numeric" placeholder="503">
3493
+ </label>
3494
+ <label class="field wide">Path prefix
3495
+ <input id="fault-path" placeholder="/v1">
3496
+ </label>
3497
+ <label class="field">Method
3498
+ <input id="fault-method" placeholder="GET">
3499
+ </label>
3500
+ </div>
3158
3501
  <div class="dialog-actions">
3159
3502
  <button class="btn-primary" type="submit">Add fault</button>
3160
3503
  <button class="btn-danger" id="fault-clear" type="button">Clear all</button>
@@ -3163,26 +3506,31 @@ var renderAdminDocument = (service) => {
3163
3506
  <div id="fault-list"></div>
3164
3507
  </section>
3165
3508
  <section class="view" id="view-journal" hidden>
3166
- <h2>Journal</h2>
3167
- <p class="lede">Requests this namespace has seen. Bodies are not stored.</p>
3168
- <div class="row-actions">
3169
- <button class="btn-danger" id="journal-clear" type="button">Clear journal</button>
3170
- </div>
3509
+ <div class="view-head"><div><h2>Journal</h2><p class="lede">Requests this namespace has seen. Bodies are never stored.</p></div><button class="btn-danger" id="journal-clear" type="button">Clear journal</button></div>
3171
3510
  <div id="journal"></div>
3172
3511
  </section>
3173
3512
  <section class="view" id="view-routes" hidden>
3174
- <h2>Routes</h2>
3175
- <p class="lede">Shared controls, plus any routes this mock adds. Extra routes are how a bespoke workflow shows up beside the default UI.</p>
3513
+ <div class="view-head"><div><h2>Routes</h2><p class="lede">Explore shared controls and service-specific admin endpoints.</p></div></div>
3514
+ <div class="route-tools">
3515
+ <label class="field">Filter routes
3516
+ <input id="route-search" type="search" placeholder="Method, path, or extension" autocomplete="off">
3517
+ </label>
3518
+ <span class="route-count" id="route-count"></span>
3519
+ </div>
3176
3520
  <div id="route-list"></div>
3177
- <form id="route-form" class="card stack">
3521
+ <form id="route-form" class="card stack" style="margin-top: 18px">
3178
3522
  <strong id="route-title">Select a route</strong>
3179
3523
  <label class="field">JSON body
3180
- <textarea id="route-body" placeholder="{ }"></textarea>
3524
+ <div class="json-editor" id="route-editor" hidden>
3525
+ <pre aria-hidden="true"><code id="route-highlight"></code></pre>
3526
+ <textarea id="route-body" placeholder="{ }" spellcheck="false" aria-describedby="route-json-status" disabled></textarea>
3527
+ </div>
3528
+ <span class="editor-meta" hidden><span class="json-status" id="route-json-status">Valid JSON</span><button class="format-json" type="button" data-format="route-body">Format JSON</button></span>
3181
3529
  </label>
3182
3530
  <div class="dialog-actions">
3183
- <button class="btn-primary" type="submit">Send</button>
3531
+ <button class="btn-primary" id="route-send" type="submit" disabled>Send request</button>
3184
3532
  </div>
3185
- <pre id="route-result">Response will show here.</pre>
3533
+ <pre class="json-view" id="route-result"><code>Response will show here.</code></pre>
3186
3534
  </form>
3187
3535
  </section>
3188
3536
  </main>
@@ -3190,12 +3538,16 @@ var renderAdminDocument = (service) => {
3190
3538
  </div>
3191
3539
  <dialog id="editor">
3192
3540
  <form method="dialog" id="editor-form">
3193
- <strong id="editor-title">Record</strong>
3541
+ <div class="dialog-heading"><div><strong id="editor-title">Record</strong><br><span>Edit the ID and JSON value stored in this collection.</span></div></div>
3194
3542
  <label class="field">Id
3195
3543
  <input id="editor-id" required>
3196
3544
  </label>
3197
3545
  <label class="field">JSON value
3198
- <textarea id="editor-value"></textarea>
3546
+ <div class="json-editor" id="record-editor">
3547
+ <pre aria-hidden="true"><code id="editor-highlight"></code></pre>
3548
+ <textarea id="editor-value" spellcheck="false" aria-describedby="editor-json-status"></textarea>
3549
+ </div>
3550
+ <span class="editor-meta"><span class="json-status" id="editor-json-status">Valid JSON</span><button class="format-json" type="button" data-format="editor-value">Format JSON</button></span>
3199
3551
  </label>
3200
3552
  <div class="dialog-actions">
3201
3553
  <button class="btn-danger" id="editor-delete" type="button" value="delete">Delete</button>
@@ -3237,8 +3589,8 @@ var describeExtension = (extension) => {
3237
3589
  ...extension.script !== void 0 ? { script: extension.script } : {}
3238
3590
  };
3239
3591
  };
3240
- var adminUiRoutes = (service, ui) => {
3241
- const shell = () => renderAdminDocument(service);
3592
+ var adminUiRoutes = (service, ui, adminPrefix = ADMIN_PREFIX) => {
3593
+ const shell = () => renderAdminDocument(service, adminPrefix);
3242
3594
  const document3 = () => ui?.render ? ui.render({ service, defaultHtml: shell }) : shell();
3243
3595
  return {
3244
3596
  "GET /ui": () => htmlResponse(document3()),
@@ -3855,10 +4207,13 @@ var stateAdminRoutes = (routes) => {
3855
4207
  };
3856
4208
 
3857
4209
  // ../core/dist/version.js
3858
- var PACKAGE_VERSION = true ? "1.9.0" : UNRELEASED_VERSION;
4210
+ var PACKAGE_VERSION = true ? "2.0.0" : UNRELEASED_VERSION;
3859
4211
 
3860
4212
  // ../core/dist/webhooks.js
3861
- var json4 = (status, body) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
4213
+ var json4 = (status, body) => new Response(JSON.stringify(body), {
4214
+ status,
4215
+ headers: { "content-type": "application/json; charset=utf-8" }
4216
+ });
3862
4217
  var adminError3 = (status, message) => json4(status, { error: { type: "mockingbird_admin", message } });
3863
4218
  var isRecord5 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3864
4219
  var parseEndpoint = (value) => {
@@ -3966,7 +4321,6 @@ var AT_HEADER = "x-mockingbird-at";
3966
4321
  var CHECKPOINT_HEADER = "x-mockingbird-checkpoint";
3967
4322
  var DEFAULT_NAMESPACE = "default";
3968
4323
  var NAMESPACE_PATTERN = /^[A-Za-z0-9_.-]{1,64}$/;
3969
- var PATH_PREFIX = /^\/ns\/([^/]+)(\/.*)?$/;
3970
4324
  var BRANCH_PATTERN2 = /^[A-Za-z0-9_.-]{1,64}$/;
3971
4325
  var MUTATING_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
3972
4326
  var effects = /* @__PURE__ */ new WeakMap();
@@ -4005,6 +4359,22 @@ var operationMatcher = (document3) => {
4005
4359
  return (request2, path) => matchers.find((m) => m.method === request2.method && m.pattern.test(path))?.operationId;
4006
4360
  };
4007
4361
  var createRuntime = (options) => {
4362
+ const adminPrefix = resolveAdminPrefix(options.adminPrefix);
4363
+ const internalPaths = Object.entries(options.document?.paths ?? {}).filter(([, item]) => item["x-mockingbird-internal"] === true).map(([path]) => path);
4364
+ for (const path of internalPaths) {
4365
+ if (!path.startsWith(`${ADMIN_PREFIX}/blobs/`))
4366
+ throw new Error(`internal transport ${path} must live under ${ADMIN_PREFIX}/blobs/`);
4367
+ }
4368
+ assertAdminPrefixAvailable(adminPrefix, Object.keys(options.document?.paths ?? {}).filter((path) => !internalPaths.includes(path)));
4369
+ const internalMatch = (path) => {
4370
+ for (const pattern of internalPaths) {
4371
+ const actual = path.split("/");
4372
+ const expected = pattern.split("/");
4373
+ if (actual.length === expected.length && expected.every((part, i) => part.startsWith("{") || part === actual[i]))
4374
+ return pattern;
4375
+ }
4376
+ return void 0;
4377
+ };
4008
4378
  assertAdminUi(options.adminUi);
4009
4379
  const sqlite = bootSqlite(options.sqlite);
4010
4380
  const clock = options.clock ?? createClock();
@@ -4050,6 +4420,7 @@ var createRuntime = (options) => {
4050
4420
  const created = options.create({
4051
4421
  namespace: storageNamespace(key),
4052
4422
  publicNamespace,
4423
+ adminPrefix,
4053
4424
  sqlite,
4054
4425
  clock: namespaceClock(publicNamespace),
4055
4426
  rng: createdContextRng
@@ -4282,10 +4653,10 @@ var createRuntime = (options) => {
4282
4653
  namespaceClock,
4283
4654
  namespaceOf(request2) {
4284
4655
  const url = new URL(request2.url);
4285
- const prefixed = PATH_PREFIX.exec(url.pathname);
4656
+ const prefixed = matchNamespacePath(url.pathname, adminPrefix);
4286
4657
  const selected = request2.headers.get(NAMESPACE_HEADER) ?? (prefixed ? decodeURIComponent(prefixed[1]) : void 0);
4287
4658
  const path = prefixed ? prefixed[2] ?? "/" : url.pathname;
4288
- if (path === "/__admin" || path.startsWith("/__admin/"))
4659
+ if (isAdminPath(path, adminPrefix))
4289
4660
  return url.searchParams.get("namespace") ?? selected ?? DEFAULT_NAMESPACE;
4290
4661
  if (selected !== void 0)
4291
4662
  return selected;
@@ -4351,7 +4722,7 @@ var createRuntime = (options) => {
4351
4722
  state: (namespace = DEFAULT_NAMESPACE) => inspectState(stateScope(namespace)),
4352
4723
  fetch: async (incoming) => {
4353
4724
  let request2 = incoming;
4354
- const prefixed = PATH_PREFIX.exec(new URL(request2.url).pathname);
4725
+ const prefixed = matchNamespacePath(new URL(request2.url).pathname, adminPrefix);
4355
4726
  if (prefixed) {
4356
4727
  const url2 = new URL(request2.url);
4357
4728
  url2.pathname = prefixed[2] ?? "/";
@@ -4367,7 +4738,15 @@ var createRuntime = (options) => {
4367
4738
  signal: request2.signal
4368
4739
  });
4369
4740
  }
4370
- let namespace = control.namespaceOf(request2);
4741
+ const internalUrl = new URL(request2.url);
4742
+ const internalPath = isAdminPath(internalUrl.pathname, adminPrefix) ? `${ADMIN_PREFIX}${internalUrl.pathname.slice(adminPrefix.length)}` : "";
4743
+ const internal = internalMatch(internalPath);
4744
+ const obsoleteInternalPath = adminPrefix !== ADMIN_PREFIX && internalMatch(internalUrl.pathname) && !internal;
4745
+ if (internal) {
4746
+ internalUrl.pathname = internalPath;
4747
+ request2 = new Request(internalUrl, request2);
4748
+ }
4749
+ let namespace = internal ? internalUrl.searchParams.get("namespace") ?? control.namespaceOf(request2) : control.namespaceOf(request2);
4371
4750
  if (!request2.headers.has(NAMESPACE_HEADER) && options.credential) {
4372
4751
  const credential = options.credential(request2);
4373
4752
  const mapped = credential !== void 0 ? credentials.get(credential) : void 0;
@@ -4387,7 +4766,9 @@ var createRuntime = (options) => {
4387
4766
  return copy;
4388
4767
  }
4389
4768
  };
4390
- const handled = await control.handle(request2);
4769
+ if (obsoleteInternalPath)
4770
+ return stamp(adminFail(404, "internal transport is only available under adminPrefix"));
4771
+ const handled = internal ? void 0 : await control.handle(request2);
4391
4772
  if (handled)
4392
4773
  return stamp(handled);
4393
4774
  const started = monotonicNow();
@@ -4428,7 +4809,7 @@ var createRuntime = (options) => {
4428
4809
  type: "mockingbird_admin",
4429
4810
  message: `${NAMESPACE_HEADER} must match ${NAMESPACE_PATTERN}`
4430
4811
  }
4431
- }), { status: 400, headers: { "content-type": "application/json" } }));
4812
+ }), { status: 400, headers: { "content-type": "application/json; charset=utf-8" } }));
4432
4813
  }
4433
4814
  if (!BRANCH_PATTERN2.test(selectedBranch)) {
4434
4815
  log(400);
@@ -4545,15 +4926,16 @@ var createRuntime = (options) => {
4545
4926
  ...options.admin?.(runtime) ?? {},
4546
4927
  // Shell routes win a colliding key. Fault presets are one of them: the admin page
4547
4928
  // maps `presets`, and a service route must not replace that list with a record.
4548
- ...options.presets ? presetRoutes(options.presets, runtime) : {},
4929
+ ...options.presets ? presetRoutes(options.presets, runtime, adminPrefix) : {},
4549
4930
  ...stateAdminRoutes({
4550
4931
  open: stateScope,
4551
4932
  afterWrite: (namespace) => {
4552
4933
  checkpoint(namespace, "main");
4553
4934
  }
4554
4935
  }),
4555
- ...adminUiRoutes(options.name, options.adminUi)
4936
+ ...adminUiRoutes(options.name, options.adminUi, adminPrefix)
4556
4937
  },
4938
+ adminPrefix,
4557
4939
  adminKey: options.adminKey
4558
4940
  });
4559
4941
  return defineMock(runtime);
@@ -4567,7 +4949,10 @@ var mutableResponse = (response) => {
4567
4949
  return new Response(response.body, response);
4568
4950
  }
4569
4951
  };
4570
- var adminJson = (status, body) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
4952
+ var adminJson = (status, body) => new Response(JSON.stringify(body), {
4953
+ status,
4954
+ headers: { "content-type": "application/json; charset=utf-8" }
4955
+ });
4571
4956
  var adminFail = (status, message) => adminJson(status, { error: { type: "mockingbird_admin", message } });
4572
4957
  var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4573
4958
  var credentialRoutes = (registry) => ({
@@ -4619,7 +5004,7 @@ var credentialRoutes = (registry) => ({
4619
5004
  return adminJson(200, { status: "ok" });
4620
5005
  }
4621
5006
  });
4622
- var presetRoutes = (presets, runtime) => ({
5007
+ var presetRoutes = (presets, runtime, adminPrefix) => ({
4623
5008
  "GET /faults/presets": () => {
4624
5009
  const body = {
4625
5010
  presets: Object.entries(presets).map(([name, preset]) => ({ name, ...preset }))
@@ -4629,7 +5014,7 @@ var presetRoutes = (presets, runtime) => ({
4629
5014
  "POST /faults/presets/:name": ({ params, body, namespace }) => {
4630
5015
  const name = params.name;
4631
5016
  if (!presets[name])
4632
- return adminFail(404, `no fault preset ${name}; GET /__admin/faults/presets`);
5017
+ return adminFail(404, `no fault preset ${name}; GET ${adminPrefix}/faults/presets`);
4633
5018
  const overrides = isObject(body) ? body : {};
4634
5019
  return adminJson(201, { preset: name, rules: runtime.applyPreset(name, namespace, overrides) });
4635
5020
  }
@@ -5516,7 +5901,7 @@ var JunctionState = class {
5516
5901
  adoptedRequests = /* @__PURE__ */ new WeakSet();
5517
5902
  /** ZIPs the loaded corpus has serviceability records for. */
5518
5903
  coveredZips = /* @__PURE__ */ new Set();
5519
- /** Identifies the loaded corpus in errors and `/health`. */
5904
+ /** Identifies the loaded corpus in errors and `/__admin/health`. */
5520
5905
  corpusLabel;
5521
5906
  /**
5522
5907
  * The loaded corpus's recorded reads. Immutable and shared by every namespace, so it
@@ -11639,6 +12024,7 @@ var createRuntime2 = (options = {}) => {
11639
12024
  ...options.sqlite ? { sqlite: options.sqlite } : {},
11640
12025
  ...options.clock ? { clock: options.clock } : {},
11641
12026
  ...options.seed !== void 0 ? { seed: options.seed } : {},
12027
+ ...options.adminPrefix !== void 0 ? { adminPrefix: options.adminPrefix } : {},
11642
12028
  ...options.adminKey !== void 0 ? { adminKey: options.adminKey } : {},
11643
12029
  ...options.onLog ? { onLog: options.onLog } : {},
11644
12030
  ...options.journalSize !== void 0 ? { journalSize: options.journalSize } : {},
@@ -12499,6 +12885,7 @@ var normalizePath = (request2) => {
12499
12885
 
12500
12886
  export {
12501
12887
  createClock,
12888
+ resolveAdminPrefix,
12502
12889
  parseDuration,
12503
12890
  SANDBOX_KEY_PREFIXES,
12504
12891
  isSandboxKey,
@@ -12556,4 +12943,4 @@ export {
12556
12943
  JunctionAPI,
12557
12944
  LabAccountConflict
12558
12945
  };
12559
- //# sourceMappingURL=chunk-WL4WPDHG.js.map
12946
+ //# sourceMappingURL=chunk-FZVFIPML.js.map