@crvouga/mockingbird-service-step-functions 1.8.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.
@@ -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()
@@ -339,7 +371,14 @@ var createControlPlane = (context) => {
339
371
  return json(200, { status: "ok" });
340
372
  }
341
373
  };
342
- 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]) => {
343
382
  const space = key.indexOf(" ");
344
383
  return { method: key.slice(0, space), pattern: key.slice(space + 1), handler };
345
384
  });
@@ -347,21 +386,13 @@ var createControlPlane = (context) => {
347
386
  namespaceOf: headerNamespace,
348
387
  async handle(request) {
349
388
  const url = new URL(request.url);
350
- if (url.pathname === HEALTH_PATH && request.method === "GET") {
351
- return json(200, {
352
- status: "ok",
353
- service: context.name,
354
- uptimeMs: context.wallNow() - context.startedAt,
355
- clock: context.clock.state(),
356
- namespaces: context.namespaces().length,
357
- adminUi: `${ADMIN_PREFIX}/ui`,
358
- ...context.describe()
359
- });
389
+ if (url.pathname === `${adminPrefix}/health` && request.method === "GET") {
390
+ return health();
360
391
  }
361
- if (url.pathname !== ADMIN_PREFIX && !url.pathname.startsWith(`${ADMIN_PREFIX}/`)) {
392
+ if (!isAdminPath(url.pathname, adminPrefix)) {
362
393
  return void 0;
363
394
  }
364
- const path = url.pathname.slice(ADMIN_PREFIX.length) || "/";
395
+ const path = url.pathname.slice(adminPrefix.length) || "/";
365
396
  const shell = request.method === "GET" && (path === "/ui" || path === "/ui/");
366
397
  if (!shell && context.adminKey !== void 0 && request.headers.get(ADMIN_KEY_HEADER) !== context.adminKey) {
367
398
  return adminError(401, `missing or wrong ${ADMIN_KEY_HEADER}`);
@@ -369,7 +400,12 @@ var createControlPlane = (context) => {
369
400
  for (const route of routes) {
370
401
  if (route.method !== request.method)
371
402
  continue;
372
- 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
+ }
373
409
  if (!params)
374
410
  continue;
375
411
  let body;
@@ -378,15 +414,18 @@ var createControlPlane = (context) => {
378
414
  } catch {
379
415
  return adminError(400, "request body is not valid JSON");
380
416
  }
417
+ const namespace = adminNamespace(request, url);
418
+ if (!/^[A-Za-z0-9_.-]{1,64}$/.test(namespace))
419
+ return adminError(400, "invalid namespace");
381
420
  return route.handler({
382
421
  request,
383
422
  url,
384
423
  params,
385
- namespace: adminNamespace(request, url),
424
+ namespace,
386
425
  body
387
426
  });
388
427
  }
389
- 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`);
390
429
  }
391
430
  };
392
431
  };
@@ -1399,6 +1438,34 @@ dialog:not([open]) {
1399
1438
  function isRecord2(value) {
1400
1439
  return typeof value === "object" && value !== null && !Array.isArray(value);
1401
1440
  }
1441
+ function highlightJsonText(text) {
1442
+ const escapeToken = (value) => value.replace(/[&<>"']/g, (char) => {
1443
+ switch (char) {
1444
+ case "&":
1445
+ return "&amp;";
1446
+ case "<":
1447
+ return "&lt;";
1448
+ case ">":
1449
+ return "&gt;";
1450
+ case '"':
1451
+ return "&quot;";
1452
+ default:
1453
+ return "&#39;";
1454
+ }
1455
+ });
1456
+ const token = /("(?:\\u[\da-fA-F]{4}|\\[^u]|[^\\"])*"\s*:|"(?:\\u[\da-fA-F]{4}|\\[^u]|[^\\"])*"|\b(?:true|false)\b|\bnull\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g;
1457
+ let cursor = 0;
1458
+ let html = "";
1459
+ for (const match2 of text.matchAll(token)) {
1460
+ const index = match2.index ?? 0;
1461
+ const value = match2[0];
1462
+ html += escapeToken(text.slice(cursor, index));
1463
+ const kind = value.startsWith('"') ? value.trimEnd().endsWith(":") ? "key" : "string" : value === "true" || value === "false" ? "boolean" : value === "null" ? "null" : "number";
1464
+ html += `<span class="json-${kind}">${escapeToken(value)}</span>`;
1465
+ cursor = index + value.length;
1466
+ }
1467
+ return html + escapeToken(text.slice(cursor));
1468
+ }
1402
1469
  function mountAdminBrand(doc, host, service, catalog) {
1403
1470
  const clip = (value, max) => {
1404
1471
  if (typeof value !== "string")
@@ -1760,14 +1827,16 @@ function bootAdmin(config) {
1760
1827
  const state = {
1761
1828
  namespace: "default",
1762
1829
  key: sessionStorage.getItem("mockingbird-admin-key") || "",
1763
- view: "overview",
1830
+ view: ["overview", "state", "clock", "faults", "journal", "routes"].includes(location.hash.slice(1)) ? location.hash.slice(1) : "overview",
1764
1831
  collection: "",
1765
1832
  shape: null,
1766
1833
  route: null,
1834
+ routes: [],
1767
1835
  editing: null
1768
1836
  };
1769
1837
  const standard = new Set(config.standardRoutes);
1770
1838
  const banner = el("banner", HTMLElement);
1839
+ const bannerMessage = el("banner-message", HTMLElement);
1771
1840
  const keyInput = el("admin-key", HTMLInputElement);
1772
1841
  keyInput.value = state.key;
1773
1842
  const params = new URLSearchParams(location.search);
@@ -1778,18 +1847,21 @@ function bootAdmin(config) {
1778
1847
  sessionStorage.setItem("mockingbird-admin-key", state.key);
1779
1848
  params.delete("key");
1780
1849
  const next = params.toString();
1781
- history.replaceState(null, "", location.pathname + (next ? `?${next}` : ""));
1850
+ history.replaceState(null, "", location.pathname + (next ? `?${next}` : "") + location.hash);
1782
1851
  }
1783
1852
  const queryNamespace = params.get("namespace");
1784
1853
  if (queryNamespace)
1785
1854
  state.namespace = queryNamespace;
1786
1855
  const showError = (error) => {
1787
- banner.textContent = error instanceof Error ? error.message : String(error);
1856
+ bannerMessage.textContent = error instanceof Error ? error.message : String(error);
1788
1857
  banner.classList.add("show");
1858
+ banner.scrollIntoView({ behavior: "smooth", block: "nearest" });
1789
1859
  };
1790
1860
  const clearError = () => {
1791
1861
  banner.classList.remove("show");
1862
+ bannerMessage.textContent = "";
1792
1863
  };
1864
+ el("banner-dismiss", HTMLButtonElement).addEventListener("click", clearError);
1793
1865
  const api = async (method, path, body) => {
1794
1866
  const headers = { accept: "application/json" };
1795
1867
  if (state.key !== "")
@@ -1833,16 +1905,21 @@ function bootAdmin(config) {
1833
1905
  namespace: () => state.namespace
1834
1906
  };
1835
1907
  const isPanelScript = (value) => typeof value === "function";
1908
+ const themeButton = el("theme", HTMLButtonElement);
1836
1909
  const setTheme = (theme) => {
1837
1910
  if (theme)
1838
1911
  document.documentElement.dataset.theme = theme;
1839
1912
  else
1840
1913
  delete document.documentElement.dataset.theme;
1914
+ const dark = theme ? theme === "dark" : matchMedia("(prefers-color-scheme: dark)").matches;
1915
+ themeButton.innerHTML = dark ? '<svg viewBox="0 0 20 20" fill="none" aria-hidden="true"><circle cx="10" cy="10" r="3.2" stroke="currentColor" stroke-width="1.5"/><path d="M10 2v1.5M10 16.5V18M18 10h-1.5M3.5 10H2M15.7 4.3l-1 1M5.3 14.7l-1 1M15.7 15.7l-1-1M5.3 5.3l-1-1" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>' : '<svg viewBox="0 0 20 20" fill="none" aria-hidden="true"><path d="M16.8 12.2A7 7 0 0 1 7.8 3.2a7 7 0 1 0 9 9Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></svg>';
1916
+ const label = dark ? "Use light theme" : "Use dark theme";
1917
+ themeButton.setAttribute("aria-label", label);
1918
+ themeButton.title = label;
1841
1919
  };
1842
1920
  const storedTheme = localStorage.getItem("mockingbird-admin-theme");
1843
- if (storedTheme)
1844
- setTheme(storedTheme);
1845
- el("theme", HTMLButtonElement).addEventListener("click", () => {
1921
+ setTheme(storedTheme === "dark" || storedTheme === "light" ? storedTheme : null);
1922
+ themeButton.addEventListener("click", () => {
1846
1923
  const current = document.documentElement.dataset.theme;
1847
1924
  const dark = current ? current === "dark" : matchMedia("(prefers-color-scheme: dark)").matches;
1848
1925
  const next = dark ? "light" : "dark";
@@ -1861,6 +1938,7 @@ function bootAdmin(config) {
1861
1938
  continue;
1862
1939
  section.hidden = section.id !== `view-${view}`;
1863
1940
  }
1941
+ history.replaceState(null, "", `${location.pathname}${location.search}#${encodeURIComponent(view)}`);
1864
1942
  };
1865
1943
  el("nav", HTMLElement).addEventListener("click", (event) => {
1866
1944
  const target = event.target;
@@ -1875,7 +1953,7 @@ function bootAdmin(config) {
1875
1953
  show(view);
1876
1954
  refresh().catch(showError);
1877
1955
  });
1878
- const cards = (items) => `<div class="grid">${items.map((item) => `<article class="card"><div class="k">${item.k}</div><div class="v">${item.v}</div></article>`).join("")}</div>`;
1956
+ const cards = (items) => `<div class="grid">${items.map((item) => `<article class="card stat-card"><div class="k">${item.k}</div><div class="v">${item.v}</div></article>`).join("")}</div>`;
1879
1957
  const table = (columns, rows) => {
1880
1958
  if (rows.length === 0)
1881
1959
  return '<p class="empty">Nothing here yet.</p>';
@@ -1885,16 +1963,22 @@ function bootAdmin(config) {
1885
1963
  const attrs = "attrs" in row && typeof row.attrs === "string" ? row.attrs : "";
1886
1964
  return `<tr${attrs}>${cells}</tr>`;
1887
1965
  }).join("");
1888
- return `<table><thead>${head}</thead><tbody>${body}</tbody></table>`;
1966
+ return `<div class="table-shell"><table><thead>${head}</thead><tbody>${body}</tbody></table></div>`;
1889
1967
  };
1890
1968
  const preview = (value) => {
1891
1969
  const text = JSON.stringify(value) ?? "null";
1892
- return esc(text.length > 180 ? `${text.slice(0, 180)}\u2026` : text);
1970
+ const clipped = text.length > 180 ? `${text.slice(0, 180)}\u2026` : text;
1971
+ return `<code class="json-inline">${highlightJsonText(clipped)}</code>`;
1893
1972
  };
1894
1973
  const when = (epoch) => {
1895
1974
  const date = new Date(epoch);
1896
1975
  return Number.isNaN(date.getTime()) ? String(epoch) : date.toISOString();
1897
1976
  };
1977
+ const journalWhen = (value) => {
1978
+ const numeric = value.trim() === "" ? Number.NaN : Number(value);
1979
+ const epoch = Number.isFinite(numeric) ? numeric : Date.parse(value);
1980
+ return Number.isFinite(epoch) ? when(epoch) : value;
1981
+ };
1898
1982
  const clockCard = (epoch) => {
1899
1983
  const date = new Date(epoch);
1900
1984
  if (Number.isNaN(date.getTime()))
@@ -2014,7 +2098,7 @@ function bootAdmin(config) {
2014
2098
  };
2015
2099
  const renderOverview = async () => {
2016
2100
  const [health, shape, clock, journal] = await Promise.all([
2017
- originGet("/health"),
2101
+ originGet(`${config.adminPrefix}/health`),
2018
2102
  api("GET", "/state"),
2019
2103
  api("GET", "/clock"),
2020
2104
  api("GET", "/requests?limit=8")
@@ -2031,7 +2115,7 @@ function bootAdmin(config) {
2031
2115
  },
2032
2116
  {
2033
2117
  k: "Status",
2034
- v: esc(typeof health.status === "string" && health.status !== "" ? health.status : "ok")
2118
+ v: `<span class="badge ok">${esc(typeof health.status === "string" && health.status !== "" ? health.status : "ok")}</span>`
2035
2119
  },
2036
2120
  { k: "Namespace", v: esc(state.namespace) },
2037
2121
  { k: "Collections", v: String(decoded.collections.length) },
@@ -2118,26 +2202,46 @@ function bootAdmin(config) {
2118
2202
  const renderJournal = async () => {
2119
2203
  const journal = await api("GET", "/requests?limit=100");
2120
2204
  el("journal", HTMLElement).innerHTML = table([
2121
- { label: "When", cell: (row) => esc(row.at) },
2122
- { label: "Method", cell: (row) => esc(row.method) },
2205
+ { label: "When", cell: (row) => esc(journalWhen(row.at)) },
2206
+ {
2207
+ label: "Method",
2208
+ cell: (row) => `<span class="method">${esc(row.method)}</span>`
2209
+ },
2123
2210
  { label: "Path", cell: (row) => esc(row.path) },
2124
- { label: "Status", cell: (row) => esc(row.status) },
2211
+ {
2212
+ label: "Status",
2213
+ cell: (row) => `<span class="badge ${row.status.startsWith("2") ? "ok" : row.status.startsWith("4") || row.status.startsWith("5") ? "warn" : ""}">${esc(row.status)}</span>`
2214
+ },
2125
2215
  { label: "ms", cell: (row) => esc(row.durationMs) }
2126
2216
  ], decodeJournal(journal));
2127
2217
  };
2128
- const renderRoutes = async () => {
2129
- const data = await api("GET", "/");
2130
- if (!isRecord2(data))
2131
- throw new Error("Route list was not an object");
2132
- const routes = requireList(data.routes, "Routes").map((route) => String(route));
2218
+ const paintRoutes = () => {
2219
+ const query = el("route-search", HTMLInputElement).value.trim().toLocaleLowerCase();
2220
+ const routes = state.routes.filter((route) => route.toLocaleLowerCase().includes(query));
2221
+ el("route-count", HTMLElement).textContent = `${routes.length} of ${state.routes.length}`;
2222
+ if (state.routes.length === 0) {
2223
+ el("route-list", HTMLElement).innerHTML = '<p class="empty">No admin routes are available.</p>';
2224
+ return;
2225
+ }
2226
+ if (routes.length === 0) {
2227
+ el("route-list", HTMLElement).innerHTML = '<p class="empty">No routes match this filter.</p>';
2228
+ return;
2229
+ }
2133
2230
  el("route-list", HTMLElement).innerHTML = routes.map((route) => {
2134
2231
  const extra = standard.has(route) ? "" : " <span class='badge'>extension</span>";
2135
2232
  const parts = route.split(" ");
2136
2233
  const method = parts[0] ?? "";
2137
2234
  const path = parts.slice(1).join(" ");
2138
- return `<div class="route"><span class="method">${esc(method)}</span><span>${esc(path)}${extra}</span><button class="btn" type="button" data-route="${esc(route)}">Use</button></div>`;
2235
+ return `<div class="route"><span class="method">${esc(method)}</span><span>${esc(path)}${extra}</span><button class="btn" type="button" data-route="${esc(route)}" aria-label="Use ${esc(route)}">Use</button></div>`;
2139
2236
  }).join("");
2140
2237
  };
2238
+ const renderRoutes = async () => {
2239
+ const data = await api("GET", "/");
2240
+ if (!isRecord2(data))
2241
+ throw new Error("Route list was not an object");
2242
+ state.routes = requireList(data.routes, "Routes").map((route) => String(route));
2243
+ paintRoutes();
2244
+ };
2141
2245
  const renderers = {
2142
2246
  overview: renderOverview,
2143
2247
  state: renderState,
@@ -2146,19 +2250,50 @@ function bootAdmin(config) {
2146
2250
  journal: renderJournal,
2147
2251
  routes: renderRoutes
2148
2252
  };
2253
+ let refreshToken = 0;
2149
2254
  async function refresh() {
2255
+ const token = ++refreshToken;
2150
2256
  clearError();
2257
+ const main = el("views", HTMLElement);
2258
+ const sync = el("sync-status", HTMLElement);
2259
+ main.setAttribute("aria-busy", "true");
2260
+ sync.classList.add("busy");
2261
+ sync.textContent = "Updating";
2151
2262
  const render = renderers[state.view];
2152
- if (render)
2153
- await render();
2263
+ try {
2264
+ if (render)
2265
+ await render();
2266
+ if (token === refreshToken)
2267
+ sync.textContent = "Up to date";
2268
+ } finally {
2269
+ if (token === refreshToken) {
2270
+ main.removeAttribute("aria-busy");
2271
+ sync.classList.remove("busy");
2272
+ }
2273
+ }
2154
2274
  }
2155
2275
  const delay = (ms) => new Promise((resolve) => {
2156
2276
  window.setTimeout(resolve, ms);
2157
2277
  });
2158
- const confirmAction = async (button, pending, done, work) => {
2278
+ const confirmAction = async (button, pending, done, work, confirmLabel) => {
2159
2279
  if (button.dataset.pending === "1")
2160
2280
  return;
2161
2281
  const label = button.textContent ?? "";
2282
+ if (confirmLabel !== void 0 && button.dataset.confirming !== "1") {
2283
+ button.dataset.confirming = "1";
2284
+ button.classList.add("confirming");
2285
+ button.textContent = confirmLabel;
2286
+ window.setTimeout(() => {
2287
+ if (button.dataset.confirming !== "1")
2288
+ return;
2289
+ button.textContent = label;
2290
+ button.classList.remove("confirming");
2291
+ delete button.dataset.confirming;
2292
+ }, 3e3);
2293
+ return;
2294
+ }
2295
+ button.classList.remove("confirming");
2296
+ delete button.dataset.confirming;
2162
2297
  const width = button.offsetWidth;
2163
2298
  button.dataset.pending = "1";
2164
2299
  button.classList.add("pressed");
@@ -2179,6 +2314,65 @@ function bootAdmin(config) {
2179
2314
  delete button.dataset.pending;
2180
2315
  }
2181
2316
  };
2317
+ const wireJsonEditor = (textareaId, highlightId, statusId, shellId, allowEmpty) => {
2318
+ const textarea = el(textareaId, HTMLTextAreaElement);
2319
+ const highlight = el(highlightId, HTMLElement);
2320
+ const status = el(statusId, HTMLElement);
2321
+ const shell = el(shellId, HTMLElement);
2322
+ const sync = () => {
2323
+ const text = textarea.value;
2324
+ highlight.innerHTML = `${highlightJsonText(text)}
2325
+ `;
2326
+ let valid = true;
2327
+ let message = "Valid JSON";
2328
+ if (allowEmpty && text.trim() === "")
2329
+ message = "No body";
2330
+ else {
2331
+ try {
2332
+ readJson2(text);
2333
+ } catch {
2334
+ valid = false;
2335
+ message = "Invalid JSON";
2336
+ }
2337
+ }
2338
+ shell.classList.toggle("invalid", !valid);
2339
+ status.classList.toggle("invalid", !valid);
2340
+ status.textContent = message;
2341
+ textarea.setAttribute("aria-invalid", valid ? "false" : "true");
2342
+ };
2343
+ const syncScroll = () => {
2344
+ const preview2 = highlight.parentElement;
2345
+ if (!(preview2 instanceof HTMLElement))
2346
+ return;
2347
+ preview2.scrollTop = textarea.scrollTop;
2348
+ preview2.scrollLeft = textarea.scrollLeft;
2349
+ };
2350
+ textarea.addEventListener("input", sync);
2351
+ textarea.addEventListener("scroll", syncScroll);
2352
+ sync();
2353
+ return sync;
2354
+ };
2355
+ const syncRouteEditor = wireJsonEditor("route-body", "route-highlight", "route-json-status", "route-editor", true);
2356
+ const syncRecordEditor = wireJsonEditor("editor-value", "editor-highlight", "editor-json-status", "record-editor", false);
2357
+ for (const button of document.querySelectorAll("[data-format]")) {
2358
+ if (!(button instanceof HTMLButtonElement))
2359
+ continue;
2360
+ button.addEventListener("click", () => {
2361
+ const target = button.dataset.format;
2362
+ if (target === void 0)
2363
+ return;
2364
+ const textarea = document.getElementById(target);
2365
+ if (!(textarea instanceof HTMLTextAreaElement) || textarea.value.trim() === "")
2366
+ return;
2367
+ try {
2368
+ textarea.value = JSON.stringify(readJson2(textarea.value), null, 2) ?? "";
2369
+ textarea.dispatchEvent(new Event("input"));
2370
+ textarea.focus();
2371
+ } catch {
2372
+ showError(new Error("Fix the JSON before formatting it"));
2373
+ }
2374
+ });
2375
+ }
2182
2376
  document.addEventListener("click", (event) => {
2183
2377
  const target = event.target;
2184
2378
  if (!(target instanceof Element))
@@ -2251,6 +2445,7 @@ function bootAdmin(config) {
2251
2445
  } else {
2252
2446
  editorValue.value = "{\n \n}";
2253
2447
  }
2448
+ syncRecordEditor();
2254
2449
  editor.showModal();
2255
2450
  }
2256
2451
  el("new-record", HTMLButtonElement).addEventListener("click", () => {
@@ -2280,10 +2475,10 @@ function bootAdmin(config) {
2280
2475
  const id = state.editing;
2281
2476
  if (!id)
2282
2477
  return;
2283
- api("DELETE", `/state/${encodeURIComponent(state.collection)}/${encodeURIComponent(id)}`).then(() => {
2478
+ confirmAction(editorDelete, "Deleting...", "Deleted", () => api("DELETE", `/state/${encodeURIComponent(state.collection)}/${encodeURIComponent(id)}`).then(() => {
2284
2479
  editor.close();
2285
2480
  return renderState();
2286
- }).catch(showError);
2481
+ }), "Confirm delete").catch(showError);
2287
2482
  });
2288
2483
  for (const button of document.querySelectorAll("[data-advance]")) {
2289
2484
  if (!(button instanceof HTMLButtonElement))
@@ -2329,10 +2524,10 @@ function bootAdmin(config) {
2329
2524
  }).then(() => renderFaults()).catch(showError);
2330
2525
  });
2331
2526
  el("fault-clear", HTMLButtonElement).addEventListener("click", () => {
2332
- confirmAction(el("fault-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/faults").then(() => renderFaults())).catch(showError);
2527
+ confirmAction(el("fault-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/faults").then(() => renderFaults()), "Confirm clear").catch(showError);
2333
2528
  });
2334
2529
  el("journal-clear", HTMLButtonElement).addEventListener("click", () => {
2335
- confirmAction(el("journal-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/requests").then(() => renderJournal())).catch(showError);
2530
+ confirmAction(el("journal-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/requests").then(() => renderJournal()), "Confirm clear").catch(showError);
2336
2531
  });
2337
2532
  el("route-list", HTMLElement).addEventListener("click", (event) => {
2338
2533
  const target = event.target;
@@ -2346,9 +2541,15 @@ function bootAdmin(config) {
2346
2541
  return;
2347
2542
  state.route = route;
2348
2543
  el("route-title", HTMLElement).textContent = route;
2544
+ el("route-send", HTMLButtonElement).disabled = false;
2349
2545
  const method = route.split(" ")[0] ?? "";
2350
- el("route-body", HTMLTextAreaElement).disabled = method === "GET" || method === "DELETE";
2546
+ const routeBody = el("route-body", HTMLTextAreaElement);
2547
+ routeBody.disabled = method === "GET" || method === "DELETE";
2548
+ el("route-editor", HTMLElement).hidden = routeBody.disabled;
2549
+ el("route-json-status", HTMLElement).parentElement?.toggleAttribute("hidden", routeBody.disabled);
2550
+ syncRouteEditor();
2351
2551
  });
2552
+ el("route-search", HTMLInputElement).addEventListener("input", paintRoutes);
2352
2553
  el("route-form", HTMLFormElement).addEventListener("submit", (event) => {
2353
2554
  event.preventDefault();
2354
2555
  const send = async () => {
@@ -2370,7 +2571,8 @@ function bootAdmin(config) {
2370
2571
  }
2371
2572
  }
2372
2573
  const data = await api(method, concrete, body);
2373
- el("route-result", HTMLElement).textContent = JSON.stringify(data, null, 2);
2574
+ const text = JSON.stringify(data, null, 2) ?? "null";
2575
+ el("route-result", HTMLElement).innerHTML = `<code>${highlightJsonText(text)}</code>`;
2374
2576
  };
2375
2577
  send().catch(showError);
2376
2578
  });
@@ -2488,11 +2690,13 @@ function bootAdmin(config) {
2488
2690
  views.append(section);
2489
2691
  mountSqlExplorer(body, panelApi);
2490
2692
  }
2693
+ show(document.getElementById(`view-${state.view}`) === null ? "overview" : state.view);
2491
2694
  return refresh();
2492
2695
  }).catch(showError);
2493
2696
  }
2494
2697
  var clientPrelude = [
2495
2698
  isRecord2,
2699
+ highlightJsonText,
2496
2700
  mountAdminBrand,
2497
2701
  startAdminBrand,
2498
2702
  faultPresetList,
@@ -2515,6 +2719,7 @@ ${entry}(${payload});
2515
2719
  var defineMock = (runtime) => runtime;
2516
2720
  var STANDARD_ADMIN_ROUTES = [
2517
2721
  "GET /",
2722
+ "GET /health",
2518
2723
  "POST /reset",
2519
2724
  "GET /namespaces",
2520
2725
  "GET /clock",
@@ -2594,11 +2799,11 @@ var escapeHtml = (value) => value.replace(/[&<>"']/g, (char) => {
2594
2799
  }
2595
2800
  });
2596
2801
  var ADMIN_BRANDS_URL = "https://mockingbird.chrisvouga.dev/brands.json";
2597
- var renderAdminDocument = (service) => {
2802
+ var renderAdminDocument = (service, adminPrefix = ADMIN_PREFIX) => {
2598
2803
  const name = escapeHtml(service);
2599
2804
  const script = adminClientSource({
2600
2805
  standardRoutes: STANDARD_ADMIN_ROUTES,
2601
- adminPrefix: ADMIN_PREFIX,
2806
+ adminPrefix,
2602
2807
  adminKeyHeader: ADMIN_KEY_HEADER,
2603
2808
  brandsUrl: ADMIN_BRANDS_URL,
2604
2809
  service
@@ -2615,75 +2820,99 @@ var renderAdminDocument = (service) => {
2615
2820
  ${CSS_RESET}
2616
2821
  :root {
2617
2822
  color-scheme: light;
2618
- --bg: #fafafa;
2823
+ --bg: #f7f8fb;
2619
2824
  --bg-elev: #ffffff;
2620
- --bg-muted: #f4f4f5;
2621
- --bg-inset: #f4f4f5;
2622
- --line: #dedee3;
2623
- --line-strong: #c4c4cc;
2624
- --ink: #18181b;
2625
- --muted: #62626b;
2626
- --faint: #71717a;
2627
- --accent: #27272a;
2825
+ --bg-muted: #f1f3f7;
2826
+ --bg-inset: #f8f9fc;
2827
+ --line: #e4e7ec;
2828
+ --line-strong: #cfd4dc;
2829
+ --ink: #101828;
2830
+ --muted: #475467;
2831
+ --faint: #667085;
2832
+ --accent: #6941c6;
2833
+ --accent-hover: #5933b4;
2628
2834
  --accent-ink: #ffffff;
2629
- --accent-soft: #f4f4f5;
2630
- --ok: #166534;
2631
- --ok-soft: #f0fdf4;
2632
- --warn: #a16207;
2633
- --warn-soft: #fefce8;
2634
- --err: #a82d32;
2635
- --err-soft: #fef2f2;
2636
- --shadow: none;
2835
+ --accent-soft: #f4f0ff;
2836
+ --ok: #067647;
2837
+ --ok-soft: #ecfdf3;
2838
+ --warn: #b54708;
2839
+ --warn-soft: #fffaeb;
2840
+ --err: #b42318;
2841
+ --err-soft: #fef3f2;
2842
+ --code-key: #6941c6;
2843
+ --code-string: #067647;
2844
+ --code-number: #175cd3;
2845
+ --code-boolean: #c11574;
2846
+ --code-null: #667085;
2847
+ --shadow-xs: 0 1px 2px rgb(16 24 40 / 0.05);
2848
+ --shadow-sm: 0 1px 3px rgb(16 24 40 / 0.08), 0 1px 2px rgb(16 24 40 / 0.04);
2849
+ --shadow-lg: 0 18px 48px rgb(16 24 40 / 0.16), 0 4px 12px rgb(16 24 40 / 0.08);
2637
2850
  --sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
2638
2851
  --mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;
2639
- --radius: 10px;
2640
- --header: 56px;
2641
- --nav: 200px;
2852
+ --radius: 12px;
2853
+ --header: 72px;
2854
+ --nav: 224px;
2642
2855
  }
2643
2856
  :root[data-theme="dark"] {
2644
2857
  color-scheme: dark;
2645
- --bg: #111113;
2646
- --bg-elev: #19191c;
2647
- --bg-muted: #242428;
2648
- --bg-inset: #111113;
2649
- --line: #36363c;
2650
- --line-strong: #52525b;
2651
- --ink: #f4f4f5;
2652
- --muted: #a9a9b2;
2653
- --faint: #71717a;
2654
- --accent: #e4e4e7;
2655
- --accent-ink: #18181b;
2656
- --accent-soft: #242428;
2657
- --ok: #86efac;
2658
- --ok-soft: #14241b;
2659
- --warn: #fde68a;
2660
- --warn-soft: #2a2410;
2661
- --err: #ffaaaa;
2662
- --err-soft: #2c1516;
2663
- --shadow: none;
2858
+ --bg: #0c0e14;
2859
+ --bg-elev: #151821;
2860
+ --bg-muted: #20232e;
2861
+ --bg-inset: #11141c;
2862
+ --line: #2b303d;
2863
+ --line-strong: #414858;
2864
+ --ink: #f5f7fa;
2865
+ --muted: #b6bdc9;
2866
+ --faint: #8d96a7;
2867
+ --accent: #9e77ed;
2868
+ --accent-hover: #b692f6;
2869
+ --accent-ink: #ffffff;
2870
+ --accent-soft: #2b2142;
2871
+ --ok: #75e0a7;
2872
+ --ok-soft: #102a20;
2873
+ --warn: #fec84b;
2874
+ --warn-soft: #302611;
2875
+ --err: #fda29b;
2876
+ --err-soft: #321817;
2877
+ --code-key: #c3a6ff;
2878
+ --code-string: #75e0a7;
2879
+ --code-number: #84adff;
2880
+ --code-boolean: #f9a8d4;
2881
+ --code-null: #98a2b3;
2882
+ --shadow-xs: 0 1px 2px rgb(0 0 0 / 0.24);
2883
+ --shadow-sm: 0 4px 12px rgb(0 0 0 / 0.2);
2884
+ --shadow-lg: 0 20px 56px rgb(0 0 0 / 0.45);
2664
2885
  }
2665
2886
  @media (prefers-color-scheme: dark) {
2666
2887
  :root:not([data-theme="light"]) {
2667
2888
  color-scheme: dark;
2668
- --bg: #111113;
2669
- --bg-elev: #19191c;
2670
- --bg-muted: #242428;
2671
- --bg-inset: #111113;
2672
- --line: #36363c;
2673
- --line-strong: #52525b;
2674
- --ink: #f4f4f5;
2675
- --muted: #a9a9b2;
2676
- --faint: #71717a;
2677
- --accent: #e4e4e7;
2678
- --accent-ink: #18181b;
2679
- --accent-soft: #242428;
2680
- --ok: #86efac;
2681
- --ok-soft: #14241b;
2682
- --warn: #fde68a;
2683
- --warn-soft: #2a2410;
2684
- --err: #ffaaaa;
2685
- --err-soft: #2c1516;
2686
- --shadow: none;
2889
+ --bg: #0c0e14;
2890
+ --bg-elev: #151821;
2891
+ --bg-muted: #20232e;
2892
+ --bg-inset: #11141c;
2893
+ --line: #2b303d;
2894
+ --line-strong: #414858;
2895
+ --ink: #f5f7fa;
2896
+ --muted: #b6bdc9;
2897
+ --faint: #8d96a7;
2898
+ --accent: #9e77ed;
2899
+ --accent-hover: #b692f6;
2900
+ --accent-ink: #ffffff;
2901
+ --accent-soft: #2b2142;
2902
+ --ok: #75e0a7;
2903
+ --ok-soft: #102a20;
2904
+ --warn: #fec84b;
2905
+ --warn-soft: #302611;
2906
+ --err: #fda29b;
2907
+ --err-soft: #321817;
2908
+ --code-key: #c3a6ff;
2909
+ --code-string: #75e0a7;
2910
+ --code-number: #84adff;
2911
+ --code-boolean: #f9a8d4;
2912
+ --code-null: #98a2b3;
2913
+ --shadow-xs: 0 1px 2px rgb(0 0 0 / 0.24);
2914
+ --shadow-sm: 0 4px 12px rgb(0 0 0 / 0.2);
2915
+ --shadow-lg: 0 20px 56px rgb(0 0 0 / 0.45);
2687
2916
  }
2688
2917
  }
2689
2918
  /* Fill the viewport, then grow with the content so the background covers the scroll. */
@@ -2701,13 +2930,17 @@ var renderAdminDocument = (service) => {
2701
2930
  padding-right: env(safe-area-inset-right);
2702
2931
  -webkit-text-size-adjust: 100%;
2703
2932
  }
2704
- :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
2933
+ ::selection { background: color-mix(in srgb, var(--accent) 28%, transparent); }
2934
+ :focus-visible { outline: 3px solid color-mix(in srgb, var(--accent) 45%, transparent); outline-offset: 2px; }
2935
+ button, input, select, textarea { transition: border-color 140ms ease, box-shadow 140ms ease, transform 100ms ease; }
2936
+ button:disabled, input:disabled, select:disabled, textarea:disabled { cursor: not-allowed; opacity: 0.55; }
2705
2937
  .app { flex: 1 0 auto; display: flex; flex-direction: column; min-width: 0; width: 100%; }
2706
2938
  header.top {
2707
- position: sticky; top: 0; z-index: 5;
2939
+ position: sticky; top: 0; z-index: 20;
2708
2940
  display: flex; align-items: center; gap: 12px;
2709
- min-height: var(--header); padding: 10px 16px;
2710
- background: var(--bg-elev);
2941
+ min-height: var(--header); padding: 12px 20px;
2942
+ background: color-mix(in srgb, var(--bg-elev) 92%, transparent);
2943
+ backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
2711
2944
  border-bottom: 1px solid var(--line);
2712
2945
  min-width: 0; max-width: 100%;
2713
2946
  }
@@ -2715,12 +2948,21 @@ var renderAdminDocument = (service) => {
2715
2948
  display: flex; flex-flow: row wrap; align-items: center; gap: 14px;
2716
2949
  min-width: 0; max-width: 100%; margin-right: auto;
2717
2950
  }
2718
- .brand-id { display: flex; flex-direction: column; min-width: 0; flex: none; }
2719
- .brand-id strong { font-size: 15px; letter-spacing: -0.01em; }
2951
+ .brand-lockup { display: flex; align-items: center; gap: 10px; min-width: 0; }
2952
+ .brand-mark {
2953
+ width: 38px; height: 38px; display: grid; place-items: center; flex: none;
2954
+ border-radius: 11px; color: #fff;
2955
+ background: linear-gradient(145deg, #7f56d9 0%, #53389e 100%);
2956
+ box-shadow: 0 6px 16px rgb(105 65 198 / 0.24), inset 0 1px 0 rgb(255 255 255 / 0.25);
2957
+ }
2958
+ .brand-mark svg { width: 22px; height: 22px; }
2959
+ .brand-id { display: flex; flex-direction: column; min-width: 0; flex: none; line-height: 1.25; }
2960
+ .brand-id strong { font-size: 15px; letter-spacing: -0.015em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
2720
2961
  .brand-id span { color: var(--faint); font-size: 12px; }
2721
2962
  .vendor {
2722
2963
  display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
2723
2964
  min-width: 0; max-width: 100%; font-size: 12px; color: var(--muted);
2965
+ padding: 5px 8px; border: 1px solid var(--line); border-radius: 999px; background: var(--bg-inset);
2724
2966
  }
2725
2967
  .vendor[hidden] { display: none; }
2726
2968
  /* Logos are drawn for a light tile, same as the docs site, so the plate stays white. */
@@ -2732,12 +2974,17 @@ var renderAdminDocument = (service) => {
2732
2974
  .vendor-sep { color: var(--faint); }
2733
2975
  .vendor-link { color: inherit; text-decoration: none; }
2734
2976
  .vendor-link:hover { color: var(--ink); text-decoration: underline; }
2735
- .controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
2736
- label.field { display: flex; flex-direction: column; gap: 2px; font-size: 11px; color: var(--faint); }
2977
+ .controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: flex-end; }
2978
+ .sync-status { align-self: center; display: flex; align-items: center; gap: 6px; color: var(--faint); font-size: 12px; white-space: nowrap; }
2979
+ .sync-status::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--ok); box-shadow: 0 0 0 3px var(--ok-soft); }
2980
+ .sync-status.busy::before { background: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); animation: pulse 1.2s ease-in-out infinite; }
2981
+ label.field { display: flex; flex-direction: column; gap: 4px; font-size: 11px; font-weight: 600; color: var(--faint); }
2737
2982
  label.field input, label.field select, .controls input[type="password"] {
2738
- background-color: var(--bg-inset); border: 1px solid var(--line); border-radius: 8px;
2739
- padding: 7px 8px; min-height: 36px; line-height: 1.2;
2983
+ background-color: var(--bg-elev); border: 1px solid var(--line-strong); border-radius: 9px;
2984
+ padding: 8px 10px; min-height: 38px; line-height: 1.2; box-shadow: var(--shadow-xs);
2740
2985
  }
2986
+ label.field input:hover, label.field select:hover, textarea:hover { border-color: color-mix(in srgb, var(--accent) 38%, var(--line-strong)); }
2987
+ label.field input:focus, label.field select:focus, textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 15%, transparent); outline: none; }
2741
2988
  /* A native menu ignores min-height, so Preset rendered about half as tall as
2742
2989
  the text fields in the same form. Paint it like those fields. */
2743
2990
  label.field select {
@@ -2754,12 +3001,20 @@ var renderAdminDocument = (service) => {
2754
3001
  dialog .field > textarea { width: 100%; }
2755
3002
  .icon-btn, .btn, .btn-primary, .btn-quiet, .btn-danger, nav.side button { cursor: pointer; }
2756
3003
  .icon-btn, .btn, .btn-primary, .btn-quiet, .btn-danger {
2757
- border-radius: 9px; min-height: 36px; padding: 0 12px; border: 1px solid var(--line);
2758
- background: var(--bg-elev);
2759
- }
2760
- .btn-primary { background: var(--accent); color: var(--accent-ink); border-color: transparent; }
2761
- .btn-quiet { background: transparent; }
2762
- .btn-danger { color: var(--err); }
3004
+ display: inline-flex; align-items: center; justify-content: center; gap: 7px;
3005
+ border-radius: 9px; min-height: 38px; padding: 0 13px; border: 1px solid var(--line-strong);
3006
+ background: var(--bg-elev); color: var(--ink); font-size: 13px; font-weight: 600; line-height: 1;
3007
+ box-shadow: var(--shadow-xs);
3008
+ }
3009
+ .icon-btn { width: 38px; padding: 0; }
3010
+ .icon-btn svg, .btn svg, .btn-primary svg { width: 16px; height: 16px; }
3011
+ .icon-btn:hover, .btn:hover { background: var(--bg-muted); border-color: var(--line-strong); }
3012
+ .btn-primary { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); box-shadow: 0 1px 2px rgb(16 24 40 / 0.08), 0 0 0 1px rgb(255 255 255 / 0.08) inset; }
3013
+ .btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
3014
+ .btn-quiet { background: transparent; border-color: transparent; box-shadow: none; }
3015
+ .btn-quiet:hover { background: var(--bg-muted); }
3016
+ .btn-danger { color: var(--err); border-color: color-mix(in srgb, var(--err) 25%, var(--line)); }
3017
+ .btn-danger:hover, .btn-danger.confirming { background: var(--err-soft); border-color: color-mix(in srgb, var(--err) 45%, var(--line)); }
2763
3018
  /* Held after mouseup. Clearing an empty journal changes nothing on screen,
2764
3019
  so the press itself has to stay visible. */
2765
3020
  .icon-btn:active, .btn:active, .btn-quiet:active, .btn-danger:active, nav.side button:active,
@@ -2775,93 +3030,159 @@ var renderAdminDocument = (service) => {
2775
3030
  flex: 1 0 auto; min-width: 0;
2776
3031
  }
2777
3032
  nav.side {
2778
- border-right: 1px solid var(--line); padding: 12px;
3033
+ position: sticky; top: var(--header); align-self: start; height: calc(100vh - var(--header));
3034
+ border-right: 1px solid var(--line); padding: 18px 12px;
2779
3035
  display: flex; flex-direction: column; gap: 4px;
2780
- background: var(--bg-elev);
3036
+ background: var(--bg-elev); overflow-y: auto;
2781
3037
  }
3038
+ .nav-label { padding: 0 10px 8px; color: var(--faint); font-size: 10px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; }
2782
3039
  nav.side button {
3040
+ position: relative; display: flex; align-items: center; gap: 10px; width: 100%;
2783
3041
  text-align: left; border: 0; background: transparent; border-radius: 9px;
2784
- padding: 9px 10px; min-height: 40px; color: var(--muted);
2785
- }
2786
- nav.side button[aria-current="true"] { background: var(--accent-soft); color: var(--ink); font-weight: 600; }
2787
- main { padding: 20px; max-width: 1100px; width: 100%; min-width: 0; }
2788
- .view h2 { margin: 0 0 4px; font-size: 22px; letter-spacing: -0.02em; }
2789
- .lede { margin: 0 0 16px; color: var(--muted); }
2790
- .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr)); gap: 12px; }
3042
+ padding: 9px 10px; min-height: 42px; color: var(--muted); font-size: 13px; font-weight: 550;
3043
+ }
3044
+ nav.side button:hover { background: var(--bg-muted); color: var(--ink); }
3045
+ nav.side button[aria-current="true"] { background: var(--accent-soft); color: var(--accent); font-weight: 650; }
3046
+ nav.side button[aria-current="true"]::before { content: ""; position: absolute; left: -12px; width: 3px; height: 22px; border-radius: 0 4px 4px 0; background: var(--accent); }
3047
+ .nav-icon { width: 18px; height: 18px; display: grid; place-items: center; flex: none; }
3048
+ .nav-icon svg { width: 18px; height: 18px; }
3049
+ main { padding: 32px clamp(20px, 4vw, 48px) 64px; max-width: 1320px; width: 100%; min-width: 0; }
3050
+ main[aria-busy="true"] .view { opacity: 0.62; }
3051
+ .view { animation: view-in 180ms ease-out; }
3052
+ .view[hidden] { display: none; }
3053
+ .view-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 22px; }
3054
+ .view h2 { margin: 0 0 5px; font-size: clamp(24px, 3vw, 30px); line-height: 1.2; letter-spacing: -0.035em; }
3055
+ .view h3 { margin: 24px 0 10px; font-size: 15px; letter-spacing: -0.01em; }
3056
+ .lede { margin: 0; color: var(--muted); max-width: 72ch; font-size: 14px; }
3057
+ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 175px), 1fr)); gap: 14px; }
2791
3058
  .card {
2792
3059
  background: var(--bg-elev); border: 1px solid var(--line); border-radius: var(--radius);
2793
- padding: 14px 16px; box-shadow: var(--shadow);
3060
+ padding: 18px; box-shadow: var(--shadow-sm);
2794
3061
  }
2795
- .card .k { color: var(--faint); font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; }
2796
- .card .v { display: flex; flex-direction: column; gap: 2px; font-size: 22px; margin-top: 4px; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
3062
+ .stat-card { position: relative; overflow: hidden; }
3063
+ .stat-card::after { content: ""; position: absolute; inset: 0 0 auto; height: 2px; background: linear-gradient(90deg, var(--accent), transparent 78%); opacity: 0.7; }
3064
+ .card .k { color: var(--faint); font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; }
3065
+ .card .v { display: flex; flex-direction: column; gap: 2px; font-size: 24px; font-weight: 650; margin-top: 7px; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; letter-spacing: -0.025em; }
2797
3066
  .card .v .clock-time { font-size: 14px; color: var(--muted); }
2798
- .split { display: grid; grid-template-columns: minmax(0, 240px) minmax(0, 1fr); gap: 16px; align-items: start; }
3067
+ .section-card { background: var(--bg-elev); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow-xs); overflow: hidden; }
3068
+ .section-title { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 16px 18px; border-bottom: 1px solid var(--line); }
3069
+ .section-title h3 { margin: 0; }
3070
+ .split { display: grid; grid-template-columns: minmax(0, 250px) minmax(0, 1fr); gap: 18px; align-items: start; }
3071
+ #collections { padding: 6px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg-elev); box-shadow: var(--shadow-xs); }
2799
3072
  .collection {
2800
3073
  width: 100%; text-align: left; border: 1px solid transparent; background: transparent;
2801
- border-radius: 10px; padding: 8px 10px; min-height: 44px;
3074
+ border-radius: 9px; padding: 10px 11px; min-height: 48px; color: var(--ink); cursor: pointer;
2802
3075
  }
2803
- .collection[aria-current="true"] { background: var(--bg-elev); border-color: var(--line); }
3076
+ .collection:hover { background: var(--bg-muted); }
3077
+ .collection[aria-current="true"] { background: var(--accent-soft); border-color: color-mix(in srgb, var(--accent) 18%, var(--line)); color: var(--accent); }
2804
3078
  .collection small { display: block; color: var(--faint); }
2805
3079
  .chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 14px; }
2806
- .chip {
2807
- font-family: var(--mono); font-size: 12px; background: var(--bg-muted);
2808
- border-radius: 999px; padding: 4px 8px;
2809
- }
3080
+ .chip { font-family: var(--mono); font-size: 11px; background: var(--bg-muted); border: 1px solid var(--line); border-radius: 999px; padding: 4px 8px; }
2810
3081
  .chip i { color: var(--faint); font-style: normal; }
2811
3082
  .sql-layout {
2812
3083
  display: grid; grid-template-columns: minmax(10rem, 16rem) minmax(0, 1fr); gap: 16px;
2813
3084
  }
2814
- .sql-tables { display: flex; flex-direction: column; gap: 4px; max-height: 28rem; overflow: auto; }
2815
- .sql-tables button { text-align: left; }
3085
+ .sql-tables { display: flex; flex-direction: column; gap: 4px; max-height: 28rem; overflow: auto; padding: 6px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg-elev); }
3086
+ .sql-tables button { text-align: left; border: 0; border-radius: 8px; padding: 9px 10px; background: transparent; color: var(--muted); cursor: pointer; }
3087
+ .sql-tables button:hover, .sql-tables button[aria-current="true"] { background: var(--accent-soft); color: var(--accent); }
2816
3088
  .sql-input { font-family: var(--mono); min-height: 7rem; width: 100%; }
2817
- table { width: 100%; border-collapse: collapse; font-size: 14px; }
2818
- th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--line); vertical-align: top; }
2819
- th { color: var(--faint); font-weight: 600; font-size: 12px; }
3089
+ .table-shell { width: 100%; overflow-x: auto; border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg-elev); box-shadow: var(--shadow-xs); }
3090
+ table { width: 100%; border-collapse: collapse; font-size: 13px; }
3091
+ th, td { text-align: left; padding: 11px 13px; border-bottom: 1px solid var(--line); vertical-align: top; }
3092
+ th { color: var(--faint); background: var(--bg-inset); font-weight: 650; font-size: 11px; text-transform: uppercase; letter-spacing: 0.045em; }
3093
+ tbody tr:last-child td { border-bottom: 0; }
2820
3094
  tr.clickable { cursor: pointer; }
2821
- tr.clickable:hover { background: var(--bg-muted); }
3095
+ tr.clickable:hover { background: var(--accent-soft); }
2822
3096
  pre, code, .mono { font-family: var(--mono); overflow-wrap: anywhere; }
2823
3097
  pre {
2824
3098
  margin: 0; white-space: pre-wrap; word-break: break-word;
2825
- background: var(--bg-inset); border-radius: 10px; padding: 12px; font-size: 12px;
2826
- }
2827
- .row-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0; }
3099
+ background: var(--bg-inset); border: 1px solid var(--line); border-radius: 10px; padding: 14px; font-size: 12px; line-height: 1.65;
3100
+ }
3101
+ .json-key { color: var(--code-key); }
3102
+ .json-string { color: var(--code-string); }
3103
+ .json-number { color: var(--code-number); }
3104
+ .json-boolean { color: var(--code-boolean); }
3105
+ .json-null { color: var(--code-null); font-style: italic; }
3106
+ .json-inline { display: block; max-width: 44rem; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
3107
+ .json-view { position: relative; max-height: 26rem; overflow: auto; }
3108
+ .json-editor { position: relative; min-height: 190px; border: 1px solid var(--line-strong); border-radius: 10px; background: var(--bg-inset); overflow: hidden; }
3109
+ .json-editor:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 15%, transparent); }
3110
+ .json-editor.invalid { border-color: var(--err); }
3111
+ .json-editor pre, .json-editor textarea { margin: 0; padding: 12px; border: 0; border-radius: 0; min-height: 190px; width: 100%; font: 12px/1.65 var(--mono); tab-size: 2; white-space: pre; overflow: auto; }
3112
+ .json-editor pre { position: absolute; inset: 0; pointer-events: none; background: transparent; }
3113
+ .json-editor textarea { position: relative; z-index: 1; resize: vertical; background: transparent; color: transparent; caret-color: var(--ink); -webkit-text-fill-color: transparent; }
3114
+ .json-editor textarea::selection { background: color-mix(in srgb, var(--accent) 32%, transparent); }
3115
+ .editor-meta { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 7px; }
3116
+ .json-status { display: inline-flex; align-items: center; gap: 6px; color: var(--ok); font-size: 11px; }
3117
+ .json-status::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
3118
+ .json-status.invalid { color: var(--err); }
3119
+ .format-json { border: 0; padding: 3px 5px; background: transparent; color: var(--accent); font-size: 11px; font-weight: 650; cursor: pointer; }
3120
+ .row-actions { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 8px; margin: 14px 0; }
2828
3121
  .banner {
2829
- display: none; margin-bottom: 12px; padding: 10px 12px; border-radius: 10px;
2830
- background: var(--err-soft); color: var(--err);
2831
- }
2832
- .banner.show { display: block; }
2833
- .empty { color: var(--muted); padding: 24px 8px; }
3122
+ display: none; position: sticky; top: calc(var(--header) + 12px); z-index: 15;
3123
+ align-items: center; gap: 10px; margin: 0 0 16px; padding: 11px 12px 11px 14px;
3124
+ border: 1px solid color-mix(in srgb, var(--err) 30%, var(--line)); border-radius: 10px;
3125
+ background: var(--err-soft); color: var(--err); box-shadow: var(--shadow-sm); font-size: 13px;
3126
+ }
3127
+ .banner.show { display: flex; }
3128
+ .banner span { flex: 1; }
3129
+ .banner button { width: 28px; height: 28px; display: grid; place-items: center; border: 0; border-radius: 7px; background: transparent; color: currentColor; cursor: pointer; }
3130
+ .banner button:hover { background: color-mix(in srgb, var(--err) 10%, transparent); }
3131
+ .empty { color: var(--muted); padding: 34px 18px; text-align: center; border: 1px dashed var(--line-strong); border-radius: var(--radius); background: color-mix(in srgb, var(--bg-elev) 55%, transparent); }
2834
3132
  dialog {
2835
- border: 1px solid var(--line); border-radius: var(--radius); padding: 0; background: var(--bg-elev);
2836
- color: var(--ink); width: min(640px, calc(100% - 24px)); max-width: calc(100vw - 24px); box-shadow: none;
2837
- }
2838
- dialog::backdrop { background: rgb(20 19 17 / 0.45); }
2839
- dialog form, .dialog-body { padding: 16px; display: flex; flex-direction: column; gap: 10px; }
3133
+ border: 1px solid var(--line); border-radius: 16px; padding: 0; background: var(--bg-elev);
3134
+ color: var(--ink); width: min(720px, calc(100% - 24px)); max-width: calc(100vw - 24px); box-shadow: var(--shadow-lg);
3135
+ }
3136
+ dialog[open] { animation: dialog-in 160ms ease-out; }
3137
+ dialog::backdrop { background: rgb(12 14 20 / 0.58); backdrop-filter: blur(3px); }
3138
+ dialog form, .dialog-body { padding: 22px; display: flex; flex-direction: column; gap: 14px; }
3139
+ .dialog-heading { display: flex; justify-content: space-between; gap: 16px; padding-bottom: 12px; border-bottom: 1px solid var(--line); }
3140
+ .dialog-heading strong { font-size: 18px; letter-spacing: -0.02em; }
3141
+ .dialog-heading span { color: var(--muted); font-size: 12px; }
2840
3142
  textarea {
2841
3143
  width: 100%; min-height: 180px; resize: vertical; border-radius: 10px;
2842
3144
  border: 1px solid var(--line); background: var(--bg-inset); padding: 10px;
2843
3145
  font-family: var(--mono); font-size: 12px;
2844
3146
  }
2845
- .dialog-actions { display: flex; justify-content: flex-end; gap: 8px; }
2846
- .badge { font-size: 11px; border-radius: 999px; padding: 2px 7px; background: var(--bg-muted); color: var(--muted); }
3147
+ .dialog-actions { display: flex; justify-content: flex-end; align-items: center; gap: 8px; padding-top: 4px; }
3148
+ .dialog-actions .btn-danger { margin-right: auto; }
3149
+ .badge { display: inline-flex; align-items: center; font-size: 10px; font-weight: 700; letter-spacing: 0.03em; border: 1px solid var(--line); border-radius: 999px; padding: 3px 7px; background: var(--bg-muted); color: var(--muted); }
2847
3150
  .badge.ok { background: var(--ok-soft); color: var(--ok); }
2848
3151
  .badge.warn { background: var(--warn-soft); color: var(--warn); }
2849
3152
  .status-2 { color: var(--ok); }
2850
3153
  .status-4, .status-5, .status-0 { color: var(--err); }
2851
3154
  .stack { display: flex; flex-direction: column; gap: 12px; }
3155
+ .form-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
3156
+ .form-grid .wide { grid-column: span 2; }
2852
3157
  .route {
2853
- display: grid; grid-template-columns: 88px 1fr auto; gap: 8px; align-items: center;
2854
- padding: 8px 0; border-bottom: 1px solid var(--line);
2855
- }
2856
- .method { font-family: var(--mono); font-size: 12px; font-weight: 700; }
3158
+ display: grid; grid-template-columns: 84px minmax(0, 1fr) auto; gap: 10px; align-items: center;
3159
+ padding: 10px 12px; border-bottom: 1px solid var(--line); background: var(--bg-elev);
3160
+ }
3161
+ .route:first-child { border-radius: var(--radius) var(--radius) 0 0; }
3162
+ .route:last-child { border-bottom: 0; border-radius: 0 0 var(--radius) var(--radius); }
3163
+ .route-tools { display: flex; align-items: flex-end; gap: 12px; margin-bottom: 12px; }
3164
+ .route-tools .field { flex: 1; }
3165
+ .route-tools input { width: 100%; }
3166
+ .route-count { padding-bottom: 10px; color: var(--faint); font-size: 12px; white-space: nowrap; }
3167
+ #route-list { max-height: 34rem; border: 1px solid var(--line); border-radius: var(--radius); overflow: auto; box-shadow: var(--shadow-xs); }
3168
+ .method { width: fit-content; font-family: var(--mono); font-size: 11px; font-weight: 750; color: var(--accent); background: var(--accent-soft); border-radius: 6px; padding: 4px 6px; }
3169
+ @keyframes view-in { from { transform: translateY(3px); } to { transform: translateY(0); } }
3170
+ @keyframes dialog-in { from { transform: translateY(8px) scale(0.99); } to { transform: translateY(0) scale(1); } }
3171
+ @keyframes pulse { 50% { opacity: 0.45; } }
3172
+ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } }
3173
+ @media (forced-colors: active) { .json-editor pre { display: none; } .json-editor textarea { color: CanvasText; -webkit-text-fill-color: CanvasText; } }
2857
3174
  @media (max-width: 800px) {
3175
+ :root { --header: 0px; }
2858
3176
  header.top {
2859
- align-items: stretch; flex-direction: column;
2860
- padding-top: max(10px, env(safe-area-inset-top));
3177
+ position: relative; align-items: stretch; flex-direction: column;
3178
+ padding: max(12px, env(safe-area-inset-top)) 14px 12px;
2861
3179
  }
2862
3180
  input, select, textarea { font-size: 16px; }
2863
3181
  .brand, .vendor, .controls { width: 100%; }
3182
+ .brand { gap: 10px; }
3183
+ .vendor { order: 2; border-radius: 9px; }
2864
3184
  label.field, label.field select, label.field input { flex: 1; min-width: 0; }
3185
+ .sync-status { display: none; }
2865
3186
  /* The layout fills the screen. A single-column grid stretches every auto
2866
3187
  row, so the tab bar grew with the leftover height and the buttons floated
2867
3188
  in the middle of it. The bar stays one row; the page below takes the rest. */
@@ -2871,14 +3192,23 @@ var renderAdminDocument = (service) => {
2871
3192
  align-content: start;
2872
3193
  }
2873
3194
  nav.side {
2874
- align-self: start; height: auto;
3195
+ position: sticky; top: 0; z-index: 12; align-self: start; height: auto;
2875
3196
  flex-direction: row; align-items: center; overflow-x: auto; min-width: 0;
2876
3197
  border-right: 0; border-bottom: 1px solid var(--line);
2877
- padding: 8px;
3198
+ padding: 8px; box-shadow: var(--shadow-xs);
2878
3199
  }
2879
- nav.side button { white-space: nowrap; flex: none; }
3200
+ .nav-label { display: none; }
3201
+ nav.side button { width: auto; white-space: nowrap; flex: none; }
3202
+ nav.side button[aria-current="true"]::before { inset: auto 9px -8px; width: auto; height: 3px; border-radius: 3px 3px 0 0; }
2880
3203
  .split, .sql-layout { grid-template-columns: minmax(0, 1fr); }
2881
- main { padding: 14px; }
3204
+ .form-grid { grid-template-columns: minmax(0, 1fr); }
3205
+ .form-grid .wide { grid-column: auto; }
3206
+ main { padding: 22px 14px 48px; }
3207
+ .view-head { margin-bottom: 18px; }
3208
+ .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
3209
+ .card { padding: 14px; }
3210
+ .card .v { font-size: 20px; }
3211
+ .table-shell { border: 0; background: transparent; box-shadow: none; overflow: visible; }
2882
3212
  table, thead, tbody, tr, th, td { display: block; }
2883
3213
  thead { display: none; }
2884
3214
  tr {
@@ -2893,50 +3223,63 @@ var renderAdminDocument = (service) => {
2893
3223
  .route { grid-template-columns: 72px 1fr; }
2894
3224
  .route button { grid-column: 1 / -1; }
2895
3225
  }
3226
+ @media (max-width: 460px) {
3227
+ .controls label.field { min-width: calc(50% - 4px); }
3228
+ .controls .icon-btn, .controls .btn { flex: 1; }
3229
+ .grid { grid-template-columns: minmax(0, 1fr); }
3230
+ .dialog-actions { flex-wrap: wrap; }
3231
+ .dialog-actions button { flex: 1; }
3232
+ .dialog-actions .btn-danger { flex-basis: 100%; margin-right: 0; }
3233
+ }
2896
3234
  </style>
2897
3235
  </head>
2898
3236
  <body>
2899
3237
  <div class="app" data-service="${name}" data-mockingbird-admin>
2900
3238
  <header class="top">
2901
3239
  <div class="brand">
2902
- <div class="brand-id">
2903
- <strong>${name}</strong>
2904
- <span>Admin</span>
3240
+ <div class="brand-lockup">
3241
+ <div class="brand-mark" aria-hidden="true">
3242
+ <svg viewBox="0 0 24 24" fill="none"><path d="M5 17.5V7l7 5 7-5v10.5" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/><path d="M8 15.5 12 18l4-2.5" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
3243
+ </div>
3244
+ <div class="brand-id">
3245
+ <strong>${name}</strong>
3246
+ <span>Mock admin</span>
3247
+ </div>
2905
3248
  </div>
2906
3249
  <div class="vendor" id="vendor" hidden></div>
2907
3250
  </div>
2908
3251
  <div class="controls">
3252
+ <span class="sync-status" id="sync-status" role="status" aria-live="polite">Ready</span>
2909
3253
  <label class="field">Namespace
2910
3254
  <select id="namespace" aria-label="Namespace"></select>
2911
3255
  </label>
2912
3256
  <label class="field">Admin key
2913
3257
  <input id="admin-key" type="password" autocomplete="off" spellcheck="false" aria-label="Admin key">
2914
3258
  </label>
2915
- <button class="icon-btn" id="theme" type="button" aria-label="Toggle color theme">Theme</button>
2916
- <button class="btn" id="refresh" type="button">Refresh</button>
3259
+ <button class="icon-btn" id="theme" type="button" aria-label="Use dark theme" title="Use dark theme"></button>
3260
+ <button class="btn" id="refresh" type="button"><svg viewBox="0 0 20 20" fill="none" aria-hidden="true"><path d="M15.8 7.2A6.2 6.2 0 1 0 16 12" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/><path d="M12.8 7.2h3v-3" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>Refresh</button>
2917
3261
  </div>
2918
3262
  </header>
2919
3263
  <div class="layout">
2920
3264
  <nav class="side" id="nav" aria-label="Admin sections">
2921
- <button type="button" data-view="overview" aria-current="true">Overview</button>
2922
- <button type="button" data-view="state">State</button>
2923
- <button type="button" data-view="clock">Clock</button>
2924
- <button type="button" data-view="faults">Faults</button>
2925
- <button type="button" data-view="journal">Journal</button>
2926
- <button type="button" data-view="routes">Routes</button>
3265
+ <span class="nav-label">Workspace</span>
3266
+ <button type="button" data-view="overview" aria-current="true"><span class="nav-icon" aria-hidden="true"><svg viewBox="0 0 20 20" fill="none"><rect x="2.5" y="2.5" width="6" height="6" rx="1.5" stroke="currentColor" stroke-width="1.5"/><rect x="11.5" y="2.5" width="6" height="6" rx="1.5" stroke="currentColor" stroke-width="1.5"/><rect x="2.5" y="11.5" width="6" height="6" rx="1.5" stroke="currentColor" stroke-width="1.5"/><rect x="11.5" y="11.5" width="6" height="6" rx="1.5" stroke="currentColor" stroke-width="1.5"/></svg></span>Overview</button>
3267
+ <button type="button" data-view="state"><span class="nav-icon" aria-hidden="true"><svg viewBox="0 0 20 20" fill="none"><ellipse cx="10" cy="5" rx="6.5" ry="2.5" stroke="currentColor" stroke-width="1.5"/><path d="M3.5 5v5c0 1.4 2.9 2.5 6.5 2.5s6.5-1.1 6.5-2.5V5M3.5 10v5c0 1.4 2.9 2.5 6.5 2.5s6.5-1.1 6.5-2.5v-5" stroke="currentColor" stroke-width="1.5"/></svg></span>State</button>
3268
+ <button type="button" data-view="clock"><span class="nav-icon" aria-hidden="true"><svg viewBox="0 0 20 20" fill="none"><circle cx="10" cy="10" r="7.5" stroke="currentColor" stroke-width="1.5"/><path d="M10 5.8v4.6l3 1.8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg></span>Clock</button>
3269
+ <button type="button" data-view="faults"><span class="nav-icon" aria-hidden="true"><svg viewBox="0 0 20 20" fill="none"><path d="m11.2 2.8-6.5 9h5.1l-1 5.4 6.5-9h-5.1l1-5.4Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></svg></span>Faults</button>
3270
+ <button type="button" data-view="journal"><span class="nav-icon" aria-hidden="true"><svg viewBox="0 0 20 20" fill="none"><path d="M5 3.5h10a1.5 1.5 0 0 1 1.5 1.5v12l-2.2-1.4-2.2 1.4-2.1-1.4L7.8 17l-2.2-1.4L3.5 17V5A1.5 1.5 0 0 1 5 3.5Z" stroke="currentColor" stroke-width="1.5"/><path d="M6.7 7h6.6M6.7 10h6.6M6.7 13h3.8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg></span>Journal</button>
3271
+ <button type="button" data-view="routes"><span class="nav-icon" aria-hidden="true"><svg viewBox="0 0 20 20" fill="none"><path d="M6.5 4.5h7a2 2 0 0 1 2 2v7M13 11l2.5 2.5L18 11M13.5 15.5h-7a2 2 0 0 1-2-2v-7M7 9 4.5 6.5 2 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg></span>Routes</button>
2927
3272
  </nav>
2928
3273
  <main id="views">
2929
- <p class="banner" id="banner" role="alert"></p>
3274
+ <div class="banner" id="banner" role="alert"><span id="banner-message"></span><button id="banner-dismiss" type="button" aria-label="Dismiss error">\xD7</button></div>
2930
3275
  <section class="view" id="view-overview">
2931
- <h2>Overview</h2>
2932
- <p class="lede">Health, clock, and the collections this namespace is holding.</p>
3276
+ <div class="view-head"><div><h2>Overview</h2><p class="lede">Health, clock, and the collections this namespace is holding.</p></div></div>
2933
3277
  <div class="grid" id="overview-cards"></div>
2934
3278
  <h3>Recent requests</h3>
2935
3279
  <div id="overview-requests"></div>
2936
3280
  </section>
2937
3281
  <section class="view" id="view-state" hidden>
2938
- <h2>State</h2>
2939
- <p class="lede">Every collection in this namespace. Declared fields stay visible before any row exists; stored rows add whatever else they contain.</p>
3282
+ <div class="view-head"><div><h2>State</h2><p class="lede">Inspect and edit every collection in this namespace.</p></div></div>
2940
3283
  <div class="split">
2941
3284
  <div id="collections"></div>
2942
3285
  <div>
@@ -2949,8 +3292,7 @@ var renderAdminDocument = (service) => {
2949
3292
  </div>
2950
3293
  </section>
2951
3294
  <section class="view" id="view-clock" hidden>
2952
- <h2>Clock</h2>
2953
- <p class="lede">The clock every timestamp in this mock reads.</p>
3295
+ <div class="view-head"><div><h2>Clock</h2><p class="lede">Control the source of time used by every timestamp in this mock.</p></div></div>
2954
3296
  <div class="card" id="clock-readout"></div>
2955
3297
  <div class="row-actions">
2956
3298
  <button class="btn" type="button" data-advance="60000">+1m</button>
@@ -2969,21 +3311,22 @@ var renderAdminDocument = (service) => {
2969
3311
  </form>
2970
3312
  </section>
2971
3313
  <section class="view" id="view-faults" hidden>
2972
- <h2>Faults</h2>
2973
- <p class="lede">Inject a response, a delay, or a named preset. Rules apply to this namespace.</p>
3314
+ <div class="view-head"><div><h2>Faults</h2><p class="lede">Test unhappy paths with a response, delay, or named preset scoped to this namespace.</p></div></div>
2974
3315
  <form id="fault-form" class="card stack">
2975
- <label class="field">Preset
2976
- <select id="fault-preset"><option value="">Custom rule</option></select>
2977
- </label>
2978
- <label class="field">Status
2979
- <input id="fault-status" inputmode="numeric" placeholder="503">
2980
- </label>
2981
- <label class="field">Path prefix
2982
- <input id="fault-path" placeholder="/v1">
2983
- </label>
2984
- <label class="field">Method
2985
- <input id="fault-method" placeholder="GET">
2986
- </label>
3316
+ <div class="form-grid">
3317
+ <label class="field wide">Preset
3318
+ <select id="fault-preset"><option value="">Custom rule</option></select>
3319
+ </label>
3320
+ <label class="field">Status
3321
+ <input id="fault-status" inputmode="numeric" placeholder="503">
3322
+ </label>
3323
+ <label class="field wide">Path prefix
3324
+ <input id="fault-path" placeholder="/v1">
3325
+ </label>
3326
+ <label class="field">Method
3327
+ <input id="fault-method" placeholder="GET">
3328
+ </label>
3329
+ </div>
2987
3330
  <div class="dialog-actions">
2988
3331
  <button class="btn-primary" type="submit">Add fault</button>
2989
3332
  <button class="btn-danger" id="fault-clear" type="button">Clear all</button>
@@ -2992,26 +3335,31 @@ var renderAdminDocument = (service) => {
2992
3335
  <div id="fault-list"></div>
2993
3336
  </section>
2994
3337
  <section class="view" id="view-journal" hidden>
2995
- <h2>Journal</h2>
2996
- <p class="lede">Requests this namespace has seen. Bodies are not stored.</p>
2997
- <div class="row-actions">
2998
- <button class="btn-danger" id="journal-clear" type="button">Clear journal</button>
2999
- </div>
3338
+ <div class="view-head"><div><h2>Journal</h2><p class="lede">Requests this namespace has seen. Bodies are never stored.</p></div><button class="btn-danger" id="journal-clear" type="button">Clear journal</button></div>
3000
3339
  <div id="journal"></div>
3001
3340
  </section>
3002
3341
  <section class="view" id="view-routes" hidden>
3003
- <h2>Routes</h2>
3004
- <p class="lede">Shared controls, plus any routes this mock adds. Extra routes are how a bespoke workflow shows up beside the default UI.</p>
3342
+ <div class="view-head"><div><h2>Routes</h2><p class="lede">Explore shared controls and service-specific admin endpoints.</p></div></div>
3343
+ <div class="route-tools">
3344
+ <label class="field">Filter routes
3345
+ <input id="route-search" type="search" placeholder="Method, path, or extension" autocomplete="off">
3346
+ </label>
3347
+ <span class="route-count" id="route-count"></span>
3348
+ </div>
3005
3349
  <div id="route-list"></div>
3006
- <form id="route-form" class="card stack">
3350
+ <form id="route-form" class="card stack" style="margin-top: 18px">
3007
3351
  <strong id="route-title">Select a route</strong>
3008
3352
  <label class="field">JSON body
3009
- <textarea id="route-body" placeholder="{ }"></textarea>
3353
+ <div class="json-editor" id="route-editor" hidden>
3354
+ <pre aria-hidden="true"><code id="route-highlight"></code></pre>
3355
+ <textarea id="route-body" placeholder="{ }" spellcheck="false" aria-describedby="route-json-status" disabled></textarea>
3356
+ </div>
3357
+ <span class="editor-meta" hidden><span class="json-status" id="route-json-status">Valid JSON</span><button class="format-json" type="button" data-format="route-body">Format JSON</button></span>
3010
3358
  </label>
3011
3359
  <div class="dialog-actions">
3012
- <button class="btn-primary" type="submit">Send</button>
3360
+ <button class="btn-primary" id="route-send" type="submit" disabled>Send request</button>
3013
3361
  </div>
3014
- <pre id="route-result">Response will show here.</pre>
3362
+ <pre class="json-view" id="route-result"><code>Response will show here.</code></pre>
3015
3363
  </form>
3016
3364
  </section>
3017
3365
  </main>
@@ -3019,12 +3367,16 @@ var renderAdminDocument = (service) => {
3019
3367
  </div>
3020
3368
  <dialog id="editor">
3021
3369
  <form method="dialog" id="editor-form">
3022
- <strong id="editor-title">Record</strong>
3370
+ <div class="dialog-heading"><div><strong id="editor-title">Record</strong><br><span>Edit the ID and JSON value stored in this collection.</span></div></div>
3023
3371
  <label class="field">Id
3024
3372
  <input id="editor-id" required>
3025
3373
  </label>
3026
3374
  <label class="field">JSON value
3027
- <textarea id="editor-value"></textarea>
3375
+ <div class="json-editor" id="record-editor">
3376
+ <pre aria-hidden="true"><code id="editor-highlight"></code></pre>
3377
+ <textarea id="editor-value" spellcheck="false" aria-describedby="editor-json-status"></textarea>
3378
+ </div>
3379
+ <span class="editor-meta"><span class="json-status" id="editor-json-status">Valid JSON</span><button class="format-json" type="button" data-format="editor-value">Format JSON</button></span>
3028
3380
  </label>
3029
3381
  <div class="dialog-actions">
3030
3382
  <button class="btn-danger" id="editor-delete" type="button" value="delete">Delete</button>
@@ -3066,8 +3418,8 @@ var describeExtension = (extension) => {
3066
3418
  ...extension.script !== void 0 ? { script: extension.script } : {}
3067
3419
  };
3068
3420
  };
3069
- var adminUiRoutes = (service, ui) => {
3070
- const shell = () => renderAdminDocument(service);
3421
+ var adminUiRoutes = (service, ui, adminPrefix = ADMIN_PREFIX) => {
3422
+ const shell = () => renderAdminDocument(service, adminPrefix);
3071
3423
  const document3 = () => ui?.render ? ui.render({ service, defaultHtml: shell }) : shell();
3072
3424
  return {
3073
3425
  "GET /ui": () => htmlResponse(document3()),
@@ -3988,10 +4340,13 @@ var stateAdminRoutes = (routes) => {
3988
4340
  };
3989
4341
 
3990
4342
  // ../core/dist/version.js
3991
- var PACKAGE_VERSION = true ? "1.8.0" : UNRELEASED_VERSION;
4343
+ var PACKAGE_VERSION = true ? "2.0.0" : UNRELEASED_VERSION;
3992
4344
 
3993
4345
  // ../core/dist/webhooks.js
3994
- var json4 = (status, body) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
4346
+ var json4 = (status, body) => new Response(JSON.stringify(body), {
4347
+ status,
4348
+ headers: { "content-type": "application/json; charset=utf-8" }
4349
+ });
3995
4350
  var adminError3 = (status, message) => json4(status, { error: { type: "mockingbird_admin", message } });
3996
4351
  var isRecord4 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3997
4352
  var parseEndpoint = (value) => {
@@ -4099,7 +4454,6 @@ var AT_HEADER = "x-mockingbird-at";
4099
4454
  var CHECKPOINT_HEADER = "x-mockingbird-checkpoint";
4100
4455
  var DEFAULT_NAMESPACE = "default";
4101
4456
  var NAMESPACE_PATTERN = /^[A-Za-z0-9_.-]{1,64}$/;
4102
- var PATH_PREFIX = /^\/ns\/([^/]+)(\/.*)?$/;
4103
4457
  var BRANCH_PATTERN2 = /^[A-Za-z0-9_.-]{1,64}$/;
4104
4458
  var MUTATING_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
4105
4459
  var effects = /* @__PURE__ */ new WeakMap();
@@ -4138,6 +4492,22 @@ var operationMatcher = (document3) => {
4138
4492
  return (request, path) => matchers.find((m) => m.method === request.method && m.pattern.test(path))?.operationId;
4139
4493
  };
4140
4494
  var createRuntime = (options) => {
4495
+ const adminPrefix = resolveAdminPrefix(options.adminPrefix);
4496
+ const internalPaths = Object.entries(options.document?.paths ?? {}).filter(([, item]) => item["x-mockingbird-internal"] === true).map(([path]) => path);
4497
+ for (const path of internalPaths) {
4498
+ if (!path.startsWith(`${ADMIN_PREFIX}/blobs/`))
4499
+ throw new Error(`internal transport ${path} must live under ${ADMIN_PREFIX}/blobs/`);
4500
+ }
4501
+ assertAdminPrefixAvailable(adminPrefix, Object.keys(options.document?.paths ?? {}).filter((path) => !internalPaths.includes(path)));
4502
+ const internalMatch = (path) => {
4503
+ for (const pattern of internalPaths) {
4504
+ const actual = path.split("/");
4505
+ const expected = pattern.split("/");
4506
+ if (actual.length === expected.length && expected.every((part, i) => part.startsWith("{") || part === actual[i]))
4507
+ return pattern;
4508
+ }
4509
+ return void 0;
4510
+ };
4141
4511
  assertAdminUi(options.adminUi);
4142
4512
  const sqlite = bootSqlite(options.sqlite);
4143
4513
  const clock = options.clock ?? createClock();
@@ -4183,6 +4553,7 @@ var createRuntime = (options) => {
4183
4553
  const created = options.create({
4184
4554
  namespace: storageNamespace(key),
4185
4555
  publicNamespace,
4556
+ adminPrefix,
4186
4557
  sqlite,
4187
4558
  clock: namespaceClock(publicNamespace),
4188
4559
  rng: createdContextRng
@@ -4415,10 +4786,10 @@ var createRuntime = (options) => {
4415
4786
  namespaceClock,
4416
4787
  namespaceOf(request) {
4417
4788
  const url = new URL(request.url);
4418
- const prefixed = PATH_PREFIX.exec(url.pathname);
4789
+ const prefixed = matchNamespacePath(url.pathname, adminPrefix);
4419
4790
  const selected = request.headers.get(NAMESPACE_HEADER) ?? (prefixed ? decodeURIComponent(prefixed[1]) : void 0);
4420
4791
  const path = prefixed ? prefixed[2] ?? "/" : url.pathname;
4421
- if (path === "/__admin" || path.startsWith("/__admin/"))
4792
+ if (isAdminPath(path, adminPrefix))
4422
4793
  return url.searchParams.get("namespace") ?? selected ?? DEFAULT_NAMESPACE;
4423
4794
  if (selected !== void 0)
4424
4795
  return selected;
@@ -4484,7 +4855,7 @@ var createRuntime = (options) => {
4484
4855
  state: (namespace = DEFAULT_NAMESPACE) => inspectState(stateScope(namespace)),
4485
4856
  fetch: async (incoming) => {
4486
4857
  let request = incoming;
4487
- const prefixed = PATH_PREFIX.exec(new URL(request.url).pathname);
4858
+ const prefixed = matchNamespacePath(new URL(request.url).pathname, adminPrefix);
4488
4859
  if (prefixed) {
4489
4860
  const url2 = new URL(request.url);
4490
4861
  url2.pathname = prefixed[2] ?? "/";
@@ -4500,7 +4871,15 @@ var createRuntime = (options) => {
4500
4871
  signal: request.signal
4501
4872
  });
4502
4873
  }
4503
- let namespace = control.namespaceOf(request);
4874
+ const internalUrl = new URL(request.url);
4875
+ const internalPath = isAdminPath(internalUrl.pathname, adminPrefix) ? `${ADMIN_PREFIX}${internalUrl.pathname.slice(adminPrefix.length)}` : "";
4876
+ const internal = internalMatch(internalPath);
4877
+ const obsoleteInternalPath = adminPrefix !== ADMIN_PREFIX && internalMatch(internalUrl.pathname) && !internal;
4878
+ if (internal) {
4879
+ internalUrl.pathname = internalPath;
4880
+ request = new Request(internalUrl, request);
4881
+ }
4882
+ let namespace = internal ? internalUrl.searchParams.get("namespace") ?? control.namespaceOf(request) : control.namespaceOf(request);
4504
4883
  if (!request.headers.has(NAMESPACE_HEADER) && options.credential) {
4505
4884
  const credential = options.credential(request);
4506
4885
  const mapped = credential !== void 0 ? credentials.get(credential) : void 0;
@@ -4520,7 +4899,9 @@ var createRuntime = (options) => {
4520
4899
  return copy;
4521
4900
  }
4522
4901
  };
4523
- const handled = await control.handle(request);
4902
+ if (obsoleteInternalPath)
4903
+ return stamp(adminFail(404, "internal transport is only available under adminPrefix"));
4904
+ const handled = internal ? void 0 : await control.handle(request);
4524
4905
  if (handled)
4525
4906
  return stamp(handled);
4526
4907
  const started = monotonicNow();
@@ -4561,7 +4942,7 @@ var createRuntime = (options) => {
4561
4942
  type: "mockingbird_admin",
4562
4943
  message: `${NAMESPACE_HEADER} must match ${NAMESPACE_PATTERN}`
4563
4944
  }
4564
- }), { status: 400, headers: { "content-type": "application/json" } }));
4945
+ }), { status: 400, headers: { "content-type": "application/json; charset=utf-8" } }));
4565
4946
  }
4566
4947
  if (!BRANCH_PATTERN2.test(selectedBranch)) {
4567
4948
  log(400);
@@ -4678,15 +5059,16 @@ var createRuntime = (options) => {
4678
5059
  ...options.admin?.(runtime) ?? {},
4679
5060
  // Shell routes win a colliding key. Fault presets are one of them: the admin page
4680
5061
  // maps `presets`, and a service route must not replace that list with a record.
4681
- ...options.presets ? presetRoutes(options.presets, runtime) : {},
5062
+ ...options.presets ? presetRoutes(options.presets, runtime, adminPrefix) : {},
4682
5063
  ...stateAdminRoutes({
4683
5064
  open: stateScope,
4684
5065
  afterWrite: (namespace) => {
4685
5066
  checkpoint(namespace, "main");
4686
5067
  }
4687
5068
  }),
4688
- ...adminUiRoutes(options.name, options.adminUi)
5069
+ ...adminUiRoutes(options.name, options.adminUi, adminPrefix)
4689
5070
  },
5071
+ adminPrefix,
4690
5072
  adminKey: options.adminKey
4691
5073
  });
4692
5074
  return defineMock(runtime);
@@ -4700,7 +5082,10 @@ var mutableResponse = (response) => {
4700
5082
  return new Response(response.body, response);
4701
5083
  }
4702
5084
  };
4703
- var adminJson = (status, body) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
5085
+ var adminJson = (status, body) => new Response(JSON.stringify(body), {
5086
+ status,
5087
+ headers: { "content-type": "application/json; charset=utf-8" }
5088
+ });
4704
5089
  var adminFail = (status, message) => adminJson(status, { error: { type: "mockingbird_admin", message } });
4705
5090
  var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4706
5091
  var credentialRoutes = (registry) => ({
@@ -4752,7 +5137,7 @@ var credentialRoutes = (registry) => ({
4752
5137
  return adminJson(200, { status: "ok" });
4753
5138
  }
4754
5139
  });
4755
- var presetRoutes = (presets, runtime) => ({
5140
+ var presetRoutes = (presets, runtime, adminPrefix) => ({
4756
5141
  "GET /faults/presets": () => {
4757
5142
  const body = {
4758
5143
  presets: Object.entries(presets).map(([name, preset]) => ({ name, ...preset }))
@@ -4762,7 +5147,7 @@ var presetRoutes = (presets, runtime) => ({
4762
5147
  "POST /faults/presets/:name": ({ params, body, namespace }) => {
4763
5148
  const name = params.name;
4764
5149
  if (!presets[name])
4765
- return adminFail(404, `no fault preset ${name}; GET /__admin/faults/presets`);
5150
+ return adminFail(404, `no fault preset ${name}; GET ${adminPrefix}/faults/presets`);
4766
5151
  const overrides = isObject(body) ? body : {};
4767
5152
  return adminJson(201, { preset: name, rules: runtime.applyPreset(name, namespace, overrides) });
4768
5153
  }
@@ -4838,6 +5223,7 @@ var createRuntime2 = (options = {}) => createRuntime({
4838
5223
  ...options.sqlite ? { sqlite: options.sqlite } : {},
4839
5224
  ...options.clock ? { clock: options.clock } : {},
4840
5225
  ...options.seed !== void 0 ? { seed: options.seed } : {},
5226
+ ...options.adminPrefix !== void 0 ? { adminPrefix: options.adminPrefix } : {},
4841
5227
  ...options.adminKey !== void 0 ? { adminKey: options.adminKey } : {},
4842
5228
  ...options.onLog ? { onLog: options.onLog } : {},
4843
5229
  credential: accessKeyCredential,
@@ -5098,6 +5484,7 @@ var StepFunctionsAPI = class {
5098
5484
 
5099
5485
  export {
5100
5486
  createClock,
5487
+ resolveAdminPrefix,
5101
5488
  parseDuration,
5102
5489
  document2 as document,
5103
5490
  operationIds,
@@ -5108,4 +5495,4 @@ export {
5108
5495
  accessKeyCredential,
5109
5496
  StepFunctionsAPI
5110
5497
  };
5111
- //# sourceMappingURL=chunk-5ZU42EQK.js.map
5498
+ //# sourceMappingURL=chunk-EBPLA3FJ.js.map