@crvouga/mockingbird-service-oauth 1.10.2 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +37 -0
- package/DISCOVERY.md +55 -0
- package/README.md +3 -3
- package/SUPPORT.md +42 -0
- package/dist/chunk-2BINFVVC.js +870 -0
- package/dist/chunk-2BINFVVC.js.map +7 -0
- package/dist/{chunk-UPHZJZII.js → chunk-I76SEED6.js} +803 -269
- package/dist/chunk-I76SEED6.js.map +7 -0
- package/dist/cli.js +2 -2
- package/dist/index.d.ts +34 -2
- package/dist/index.js +1 -1
- package/dist/server.d.ts +66 -4
- package/dist/server.js +2 -2
- package/openapi.yaml +2885 -0
- package/package.json +29 -4
- package/dist/chunk-MIWUE2CX.js +0 -382
- package/dist/chunk-MIWUE2CX.js.map +0 -7
- package/dist/chunk-UPHZJZII.js.map +0 -7
|
@@ -110,13 +110,31 @@ var Collection = class {
|
|
|
110
110
|
};
|
|
111
111
|
|
|
112
112
|
// ../core/dist/control.js
|
|
113
|
-
var HEALTH_PATH = "/health";
|
|
114
113
|
var ADMIN_PREFIX = "/__admin";
|
|
114
|
+
var resolveAdminPrefix = (prefix = ADMIN_PREFIX) => {
|
|
115
|
+
if (!/^\/[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_-]+)*$/.test(prefix)) {
|
|
116
|
+
throw new Error("adminPrefix must be an absolute non-root path of literal segments, without a trailing slash");
|
|
117
|
+
}
|
|
118
|
+
return prefix;
|
|
119
|
+
};
|
|
120
|
+
var isAdminPath = (path, prefix) => path === prefix || path.startsWith(`${prefix}/`);
|
|
121
|
+
var matchNamespacePath = (path, prefix) => /^\/ns\/([^/]+)(\/.*)?$/.exec(isAdminPath(path, prefix) ? path.slice(prefix.length) : "");
|
|
122
|
+
var assertAdminPrefixAvailable = (prefix, paths) => {
|
|
123
|
+
const reserved = prefix.split("/").slice(1);
|
|
124
|
+
for (const path of paths) {
|
|
125
|
+
const segments = path.split("/").slice(1);
|
|
126
|
+
if (segments.length < reserved.length || /^\{[^}]+\}$/.test(segments[0] ?? ""))
|
|
127
|
+
continue;
|
|
128
|
+
if (reserved.every((part, i) => segments[i] === part || /^\{[^}]+\}$/.test(segments[i] ?? ""))) {
|
|
129
|
+
throw new Error(`vendor route ${path} collides with adminPrefix ${prefix}; choose a different adminPrefix`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
};
|
|
115
133
|
var ADMIN_KEY_HEADER = "x-mockingbird-admin-key";
|
|
116
134
|
var NAMESPACE_HEADER = "x-mockingbird-namespace";
|
|
117
135
|
var json = (status, body) => new Response(JSON.stringify(body), {
|
|
118
136
|
status,
|
|
119
|
-
headers: { "content-type": "application/json" }
|
|
137
|
+
headers: { "content-type": "application/json; charset=utf-8" }
|
|
120
138
|
});
|
|
121
139
|
var adminError = (status, message) => json(status, { error: { type: "mockingbird_admin", message } });
|
|
122
140
|
var UNITS = {
|
|
@@ -168,11 +186,25 @@ var readJson = async (request) => {
|
|
|
168
186
|
};
|
|
169
187
|
var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
170
188
|
var createControlPlane = (context) => {
|
|
189
|
+
const adminPrefix = resolveAdminPrefix(context.adminPrefix);
|
|
171
190
|
const snapshots = /* @__PURE__ */ new Map();
|
|
172
191
|
let snapshotCounter = 0;
|
|
173
192
|
const headerNamespace = (request) => request.headers.get(NAMESPACE_HEADER) ?? context.defaultNamespace;
|
|
174
193
|
const adminNamespace = (request, url) => url.searchParams.get("namespace") ?? headerNamespace(request);
|
|
194
|
+
const health = () => {
|
|
195
|
+
return json(200, {
|
|
196
|
+
...context.describe(),
|
|
197
|
+
status: "ok",
|
|
198
|
+
service: context.name,
|
|
199
|
+
uptimeMs: context.wallNow() - context.startedAt,
|
|
200
|
+
clock: context.clock.state(),
|
|
201
|
+
namespaces: context.namespaces().length,
|
|
202
|
+
adminPrefix,
|
|
203
|
+
adminUi: `${adminPrefix}/ui`
|
|
204
|
+
});
|
|
205
|
+
};
|
|
175
206
|
const builtin = {
|
|
207
|
+
"GET /health": health,
|
|
176
208
|
"GET /": () => json(200, {
|
|
177
209
|
service: context.name,
|
|
178
210
|
routes: [...Object.keys(builtin), ...Object.keys(context.routes)].sort()
|
|
@@ -183,29 +215,30 @@ var createControlPlane = (context) => {
|
|
|
183
215
|
return json(200, { status: "ok", reset: target === "*" ? context.namespaces() : [target] });
|
|
184
216
|
},
|
|
185
217
|
"GET /namespaces": () => json(200, { default: context.defaultNamespace, namespaces: context.namespaces() }),
|
|
186
|
-
"GET /clock": () => json(200, context.clock.state()),
|
|
187
|
-
"POST /clock": ({ body }) => {
|
|
218
|
+
"GET /clock": ({ namespace }) => json(200, (context.namespaceClock?.(namespace) ?? context.clock).state()),
|
|
219
|
+
"POST /clock": ({ body, namespace }) => {
|
|
220
|
+
const clock = context.namespaceClock?.(namespace) ?? context.clock;
|
|
188
221
|
if (!isRecord(body))
|
|
189
222
|
return adminError(400, "expected a JSON object");
|
|
190
223
|
if (body.reset === true)
|
|
191
|
-
|
|
224
|
+
clock.reset();
|
|
192
225
|
if (body.freeze === true)
|
|
193
|
-
|
|
226
|
+
clock.freeze();
|
|
194
227
|
if (body.set !== void 0) {
|
|
195
228
|
const instant = parseInstant(body.set);
|
|
196
229
|
if (instant === void 0)
|
|
197
230
|
return adminError(400, "set: expected epoch ms or ISO-8601");
|
|
198
|
-
|
|
231
|
+
clock.set(instant);
|
|
199
232
|
}
|
|
200
233
|
if (body.advance !== void 0) {
|
|
201
234
|
const delta = parseDuration(body.advance);
|
|
202
235
|
if (delta === void 0)
|
|
203
236
|
return adminError(400, 'advance: expected ms or "15m"-style');
|
|
204
|
-
|
|
237
|
+
clock.advance(delta);
|
|
205
238
|
}
|
|
206
239
|
if (body.freeze === false)
|
|
207
|
-
|
|
208
|
-
return json(200,
|
|
240
|
+
clock.unfreeze();
|
|
241
|
+
return json(200, clock.state());
|
|
209
242
|
},
|
|
210
243
|
"GET /faults": () => json(200, { faults: context.faults.list() }),
|
|
211
244
|
"POST /faults": ({ body, namespace }) => {
|
|
@@ -338,7 +371,14 @@ var createControlPlane = (context) => {
|
|
|
338
371
|
return json(200, { status: "ok" });
|
|
339
372
|
}
|
|
340
373
|
};
|
|
341
|
-
const
|
|
374
|
+
for (const key of Object.keys(context.routes)) {
|
|
375
|
+
if (Object.hasOwn(builtin, key))
|
|
376
|
+
throw new Error(`admin route ${key} overrides a shared route`);
|
|
377
|
+
const path = key.slice(key.indexOf(" ") + 1);
|
|
378
|
+
if (isAdminPath(path, "/ns") || isAdminPath(path, "/blobs"))
|
|
379
|
+
throw new Error(`admin route ${key} overlaps an internal transport`);
|
|
380
|
+
}
|
|
381
|
+
const routes = [...Object.entries(builtin), ...Object.entries(context.routes)].map(([key, handler]) => {
|
|
342
382
|
const space = key.indexOf(" ");
|
|
343
383
|
return { method: key.slice(0, space), pattern: key.slice(space + 1), handler };
|
|
344
384
|
});
|
|
@@ -346,21 +386,13 @@ var createControlPlane = (context) => {
|
|
|
346
386
|
namespaceOf: headerNamespace,
|
|
347
387
|
async handle(request) {
|
|
348
388
|
const url = new URL(request.url);
|
|
349
|
-
if (url.pathname ===
|
|
350
|
-
return
|
|
351
|
-
status: "ok",
|
|
352
|
-
service: context.name,
|
|
353
|
-
uptimeMs: context.wallNow() - context.startedAt,
|
|
354
|
-
clock: context.clock.state(),
|
|
355
|
-
namespaces: context.namespaces().length,
|
|
356
|
-
adminUi: `${ADMIN_PREFIX}/ui`,
|
|
357
|
-
...context.describe()
|
|
358
|
-
});
|
|
389
|
+
if (url.pathname === `${adminPrefix}/health` && request.method === "GET") {
|
|
390
|
+
return health();
|
|
359
391
|
}
|
|
360
|
-
if (url.pathname
|
|
392
|
+
if (!isAdminPath(url.pathname, adminPrefix)) {
|
|
361
393
|
return void 0;
|
|
362
394
|
}
|
|
363
|
-
const path = url.pathname.slice(
|
|
395
|
+
const path = url.pathname.slice(adminPrefix.length) || "/";
|
|
364
396
|
const shell = request.method === "GET" && (path === "/ui" || path === "/ui/");
|
|
365
397
|
if (!shell && context.adminKey !== void 0 && request.headers.get(ADMIN_KEY_HEADER) !== context.adminKey) {
|
|
366
398
|
return adminError(401, `missing or wrong ${ADMIN_KEY_HEADER}`);
|
|
@@ -368,7 +400,12 @@ var createControlPlane = (context) => {
|
|
|
368
400
|
for (const route of routes) {
|
|
369
401
|
if (route.method !== request.method)
|
|
370
402
|
continue;
|
|
371
|
-
|
|
403
|
+
let params;
|
|
404
|
+
try {
|
|
405
|
+
params = matchRoute(route.pattern, path);
|
|
406
|
+
} catch {
|
|
407
|
+
return adminError(400, "invalid path encoding");
|
|
408
|
+
}
|
|
372
409
|
if (!params)
|
|
373
410
|
continue;
|
|
374
411
|
let body;
|
|
@@ -377,15 +414,18 @@ var createControlPlane = (context) => {
|
|
|
377
414
|
} catch {
|
|
378
415
|
return adminError(400, "request body is not valid JSON");
|
|
379
416
|
}
|
|
417
|
+
const namespace = adminNamespace(request, url);
|
|
418
|
+
if (!/^[A-Za-z0-9_.-]{1,64}$/.test(namespace))
|
|
419
|
+
return adminError(400, "invalid namespace");
|
|
380
420
|
return route.handler({
|
|
381
421
|
request,
|
|
382
422
|
url,
|
|
383
423
|
params,
|
|
384
|
-
namespace
|
|
424
|
+
namespace,
|
|
385
425
|
body
|
|
386
426
|
});
|
|
387
427
|
}
|
|
388
|
-
return adminError(404, `no admin route ${request.method} ${path}; GET ${
|
|
428
|
+
return adminError(404, `no admin route ${request.method} ${path}; GET ${adminPrefix} lists them`);
|
|
389
429
|
}
|
|
390
430
|
};
|
|
391
431
|
};
|
|
@@ -467,7 +507,33 @@ var faultResponse = (rule) => {
|
|
|
467
507
|
};
|
|
468
508
|
var createFaultRegistry = (rng = createRng(0), sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) => {
|
|
469
509
|
const entries = [];
|
|
510
|
+
let isolated = false;
|
|
511
|
+
const scopedRngs = /* @__PURE__ */ new Map();
|
|
512
|
+
const random2 = (namespace) => {
|
|
513
|
+
if (!isolated || namespace === "default")
|
|
514
|
+
return rng;
|
|
515
|
+
let scoped = scopedRngs.get(namespace);
|
|
516
|
+
if (!scoped) {
|
|
517
|
+
scoped = createRng(rng.seed);
|
|
518
|
+
scopedRngs.set(namespace, scoped);
|
|
519
|
+
}
|
|
520
|
+
return scoped;
|
|
521
|
+
};
|
|
470
522
|
return {
|
|
523
|
+
isolateNamespaces() {
|
|
524
|
+
isolated = true;
|
|
525
|
+
},
|
|
526
|
+
snapshot(namespace) {
|
|
527
|
+
return {
|
|
528
|
+
rules: structuredClone(entries.map((entry, position) => ({
|
|
529
|
+
...entry.rule,
|
|
530
|
+
remaining: entry.remaining,
|
|
531
|
+
hits: entry.hits,
|
|
532
|
+
position
|
|
533
|
+
})).filter((entry) => entry.namespace === namespace)),
|
|
534
|
+
rngState: random2(namespace).state()
|
|
535
|
+
};
|
|
536
|
+
},
|
|
471
537
|
add(rule) {
|
|
472
538
|
const existing = entries.findIndex((e) => e.rule.id === rule.id);
|
|
473
539
|
const entry = { rule, remaining: rule.count ?? null, hits: 0 };
|
|
@@ -487,6 +553,16 @@ var createFaultRegistry = (rng = createRng(0), sleep = (ms) => new Promise((reso
|
|
|
487
553
|
},
|
|
488
554
|
clear() {
|
|
489
555
|
entries.length = 0;
|
|
556
|
+
rng.reset();
|
|
557
|
+
scopedRngs.clear();
|
|
558
|
+
},
|
|
559
|
+
restore(namespace, state) {
|
|
560
|
+
for (let i = entries.length - 1; i >= 0; i--)
|
|
561
|
+
if (entries[i]?.rule.namespace === namespace)
|
|
562
|
+
entries.splice(i, 1);
|
|
563
|
+
for (const { remaining, hits, position, ...rule } of state.rules)
|
|
564
|
+
entries.splice(Math.min(position, entries.length), 0, { rule, remaining, hits });
|
|
565
|
+
random2(namespace).setState(state.rngState);
|
|
490
566
|
},
|
|
491
567
|
async take(candidate) {
|
|
492
568
|
const hits = [];
|
|
@@ -496,7 +572,7 @@ var createFaultRegistry = (rng = createRng(0), sleep = (ms) => new Promise((reso
|
|
|
496
572
|
if (!matches(entry.rule, candidate))
|
|
497
573
|
continue;
|
|
498
574
|
const rate = entry.rule.rate ?? 1;
|
|
499
|
-
if (
|
|
575
|
+
if (random2(candidate.namespace).next() >= rate)
|
|
500
576
|
continue;
|
|
501
577
|
entry.hits++;
|
|
502
578
|
if (entry.remaining !== null)
|
|
@@ -653,7 +729,8 @@ var notes = /* @__PURE__ */ new WeakMap();
|
|
|
653
729
|
var responseNotes = (response) => notes.get(response);
|
|
654
730
|
|
|
655
731
|
// ../core/dist/metrics.js
|
|
656
|
-
var createMetrics = () => {
|
|
732
|
+
var createMetrics = (partition = true) => {
|
|
733
|
+
const namespaces = /* @__PURE__ */ new Map();
|
|
657
734
|
let requests = 0;
|
|
658
735
|
let faults = 0;
|
|
659
736
|
let totalDurationMs = 0;
|
|
@@ -661,6 +738,14 @@ var createMetrics = () => {
|
|
|
661
738
|
const unmatched = /* @__PURE__ */ new Map();
|
|
662
739
|
return {
|
|
663
740
|
record(entry) {
|
|
741
|
+
if (partition) {
|
|
742
|
+
let scoped = namespaces.get(entry.namespace);
|
|
743
|
+
if (!scoped) {
|
|
744
|
+
scoped = createMetrics(false);
|
|
745
|
+
namespaces.set(entry.namespace, scoped);
|
|
746
|
+
}
|
|
747
|
+
scoped.record(entry);
|
|
748
|
+
}
|
|
664
749
|
requests++;
|
|
665
750
|
totalDurationMs += entry.durationMs;
|
|
666
751
|
if (entry.faultId !== void 0)
|
|
@@ -672,7 +757,7 @@ var createMetrics = () => {
|
|
|
672
757
|
unmatched.set(route, (unmatched.get(route) ?? 0) + 1);
|
|
673
758
|
}
|
|
674
759
|
},
|
|
675
|
-
report: () => ({
|
|
760
|
+
report: (namespace) => namespace !== void 0 ? (namespaces.get(namespace) ?? createMetrics(false)).report() : {
|
|
676
761
|
requests,
|
|
677
762
|
byOperation: Object.fromEntries([...byOperation].sort(([a], [b]) => a.localeCompare(b))),
|
|
678
763
|
unmatched: [...unmatched].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([route, count]) => {
|
|
@@ -681,8 +766,13 @@ var createMetrics = () => {
|
|
|
681
766
|
}),
|
|
682
767
|
faults,
|
|
683
768
|
totalDurationMs
|
|
684
|
-
}
|
|
685
|
-
reset() {
|
|
769
|
+
},
|
|
770
|
+
reset(namespace) {
|
|
771
|
+
if (namespace !== void 0) {
|
|
772
|
+
namespaces.delete(namespace);
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
namespaces.clear();
|
|
686
776
|
requests = 0;
|
|
687
777
|
faults = 0;
|
|
688
778
|
totalDurationMs = 0;
|
|
@@ -1295,6 +1385,34 @@ dialog:not([open]) {
|
|
|
1295
1385
|
function isRecord2(value) {
|
|
1296
1386
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1297
1387
|
}
|
|
1388
|
+
function highlightJsonText(text) {
|
|
1389
|
+
const escapeToken = (value) => value.replace(/[&<>"']/g, (char) => {
|
|
1390
|
+
switch (char) {
|
|
1391
|
+
case "&":
|
|
1392
|
+
return "&";
|
|
1393
|
+
case "<":
|
|
1394
|
+
return "<";
|
|
1395
|
+
case ">":
|
|
1396
|
+
return ">";
|
|
1397
|
+
case '"':
|
|
1398
|
+
return """;
|
|
1399
|
+
default:
|
|
1400
|
+
return "'";
|
|
1401
|
+
}
|
|
1402
|
+
});
|
|
1403
|
+
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;
|
|
1404
|
+
let cursor = 0;
|
|
1405
|
+
let html = "";
|
|
1406
|
+
for (const match2 of text.matchAll(token)) {
|
|
1407
|
+
const index = match2.index ?? 0;
|
|
1408
|
+
const value = match2[0];
|
|
1409
|
+
html += escapeToken(text.slice(cursor, index));
|
|
1410
|
+
const kind = value.startsWith('"') ? value.trimEnd().endsWith(":") ? "key" : "string" : value === "true" || value === "false" ? "boolean" : value === "null" ? "null" : "number";
|
|
1411
|
+
html += `<span class="json-${kind}">${escapeToken(value)}</span>`;
|
|
1412
|
+
cursor = index + value.length;
|
|
1413
|
+
}
|
|
1414
|
+
return html + escapeToken(text.slice(cursor));
|
|
1415
|
+
}
|
|
1298
1416
|
function mountAdminBrand(doc, host, service, catalog) {
|
|
1299
1417
|
const clip = (value, max) => {
|
|
1300
1418
|
if (typeof value !== "string")
|
|
@@ -1656,14 +1774,16 @@ function bootAdmin(config) {
|
|
|
1656
1774
|
const state = {
|
|
1657
1775
|
namespace: "default",
|
|
1658
1776
|
key: sessionStorage.getItem("mockingbird-admin-key") || "",
|
|
1659
|
-
view: "overview",
|
|
1777
|
+
view: ["overview", "state", "clock", "faults", "journal", "routes"].includes(location.hash.slice(1)) ? location.hash.slice(1) : "overview",
|
|
1660
1778
|
collection: "",
|
|
1661
1779
|
shape: null,
|
|
1662
1780
|
route: null,
|
|
1781
|
+
routes: [],
|
|
1663
1782
|
editing: null
|
|
1664
1783
|
};
|
|
1665
1784
|
const standard = new Set(config.standardRoutes);
|
|
1666
1785
|
const banner = el("banner", HTMLElement);
|
|
1786
|
+
const bannerMessage = el("banner-message", HTMLElement);
|
|
1667
1787
|
const keyInput = el("admin-key", HTMLInputElement);
|
|
1668
1788
|
keyInput.value = state.key;
|
|
1669
1789
|
const params = new URLSearchParams(location.search);
|
|
@@ -1674,18 +1794,21 @@ function bootAdmin(config) {
|
|
|
1674
1794
|
sessionStorage.setItem("mockingbird-admin-key", state.key);
|
|
1675
1795
|
params.delete("key");
|
|
1676
1796
|
const next = params.toString();
|
|
1677
|
-
history.replaceState(null, "", location.pathname + (next ? `?${next}` : ""));
|
|
1797
|
+
history.replaceState(null, "", location.pathname + (next ? `?${next}` : "") + location.hash);
|
|
1678
1798
|
}
|
|
1679
1799
|
const queryNamespace = params.get("namespace");
|
|
1680
1800
|
if (queryNamespace)
|
|
1681
1801
|
state.namespace = queryNamespace;
|
|
1682
1802
|
const showError = (error) => {
|
|
1683
|
-
|
|
1803
|
+
bannerMessage.textContent = error instanceof Error ? error.message : String(error);
|
|
1684
1804
|
banner.classList.add("show");
|
|
1805
|
+
banner.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
|
1685
1806
|
};
|
|
1686
1807
|
const clearError = () => {
|
|
1687
1808
|
banner.classList.remove("show");
|
|
1809
|
+
bannerMessage.textContent = "";
|
|
1688
1810
|
};
|
|
1811
|
+
el("banner-dismiss", HTMLButtonElement).addEventListener("click", clearError);
|
|
1689
1812
|
const api = async (method, path, body) => {
|
|
1690
1813
|
const headers = { accept: "application/json" };
|
|
1691
1814
|
if (state.key !== "")
|
|
@@ -1729,16 +1852,21 @@ function bootAdmin(config) {
|
|
|
1729
1852
|
namespace: () => state.namespace
|
|
1730
1853
|
};
|
|
1731
1854
|
const isPanelScript = (value) => typeof value === "function";
|
|
1855
|
+
const themeButton = el("theme", HTMLButtonElement);
|
|
1732
1856
|
const setTheme = (theme) => {
|
|
1733
1857
|
if (theme)
|
|
1734
1858
|
document.documentElement.dataset.theme = theme;
|
|
1735
1859
|
else
|
|
1736
1860
|
delete document.documentElement.dataset.theme;
|
|
1861
|
+
const dark = theme ? theme === "dark" : matchMedia("(prefers-color-scheme: dark)").matches;
|
|
1862
|
+
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>';
|
|
1863
|
+
const label = dark ? "Use light theme" : "Use dark theme";
|
|
1864
|
+
themeButton.setAttribute("aria-label", label);
|
|
1865
|
+
themeButton.title = label;
|
|
1737
1866
|
};
|
|
1738
1867
|
const storedTheme = localStorage.getItem("mockingbird-admin-theme");
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
el("theme", HTMLButtonElement).addEventListener("click", () => {
|
|
1868
|
+
setTheme(storedTheme === "dark" || storedTheme === "light" ? storedTheme : null);
|
|
1869
|
+
themeButton.addEventListener("click", () => {
|
|
1742
1870
|
const current = document.documentElement.dataset.theme;
|
|
1743
1871
|
const dark = current ? current === "dark" : matchMedia("(prefers-color-scheme: dark)").matches;
|
|
1744
1872
|
const next = dark ? "light" : "dark";
|
|
@@ -1757,6 +1885,7 @@ function bootAdmin(config) {
|
|
|
1757
1885
|
continue;
|
|
1758
1886
|
section.hidden = section.id !== `view-${view}`;
|
|
1759
1887
|
}
|
|
1888
|
+
history.replaceState(null, "", `${location.pathname}${location.search}#${encodeURIComponent(view)}`);
|
|
1760
1889
|
};
|
|
1761
1890
|
el("nav", HTMLElement).addEventListener("click", (event) => {
|
|
1762
1891
|
const target = event.target;
|
|
@@ -1771,7 +1900,7 @@ function bootAdmin(config) {
|
|
|
1771
1900
|
show(view);
|
|
1772
1901
|
refresh().catch(showError);
|
|
1773
1902
|
});
|
|
1774
|
-
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>`;
|
|
1903
|
+
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>`;
|
|
1775
1904
|
const table = (columns, rows) => {
|
|
1776
1905
|
if (rows.length === 0)
|
|
1777
1906
|
return '<p class="empty">Nothing here yet.</p>';
|
|
@@ -1781,16 +1910,22 @@ function bootAdmin(config) {
|
|
|
1781
1910
|
const attrs = "attrs" in row && typeof row.attrs === "string" ? row.attrs : "";
|
|
1782
1911
|
return `<tr${attrs}>${cells}</tr>`;
|
|
1783
1912
|
}).join("");
|
|
1784
|
-
return `<table><thead>${head}</thead><tbody>${body}</tbody></table>`;
|
|
1913
|
+
return `<div class="table-shell"><table><thead>${head}</thead><tbody>${body}</tbody></table></div>`;
|
|
1785
1914
|
};
|
|
1786
1915
|
const preview = (value) => {
|
|
1787
1916
|
const text = JSON.stringify(value) ?? "null";
|
|
1788
|
-
|
|
1917
|
+
const clipped = text.length > 180 ? `${text.slice(0, 180)}\u2026` : text;
|
|
1918
|
+
return `<code class="json-inline">${highlightJsonText(clipped)}</code>`;
|
|
1789
1919
|
};
|
|
1790
1920
|
const when = (epoch) => {
|
|
1791
1921
|
const date = new Date(epoch);
|
|
1792
1922
|
return Number.isNaN(date.getTime()) ? String(epoch) : date.toISOString();
|
|
1793
1923
|
};
|
|
1924
|
+
const journalWhen = (value) => {
|
|
1925
|
+
const numeric = value.trim() === "" ? Number.NaN : Number(value);
|
|
1926
|
+
const epoch = Number.isFinite(numeric) ? numeric : Date.parse(value);
|
|
1927
|
+
return Number.isFinite(epoch) ? when(epoch) : value;
|
|
1928
|
+
};
|
|
1794
1929
|
const clockCard = (epoch) => {
|
|
1795
1930
|
const date = new Date(epoch);
|
|
1796
1931
|
if (Number.isNaN(date.getTime()))
|
|
@@ -1910,7 +2045,7 @@ function bootAdmin(config) {
|
|
|
1910
2045
|
};
|
|
1911
2046
|
const renderOverview = async () => {
|
|
1912
2047
|
const [health, shape, clock, journal] = await Promise.all([
|
|
1913
|
-
originGet(
|
|
2048
|
+
originGet(`${config.adminPrefix}/health`),
|
|
1914
2049
|
api("GET", "/state"),
|
|
1915
2050
|
api("GET", "/clock"),
|
|
1916
2051
|
api("GET", "/requests?limit=8")
|
|
@@ -1927,7 +2062,7 @@ function bootAdmin(config) {
|
|
|
1927
2062
|
},
|
|
1928
2063
|
{
|
|
1929
2064
|
k: "Status",
|
|
1930
|
-
v: esc(typeof health.status === "string" && health.status !== "" ? health.status : "ok")
|
|
2065
|
+
v: `<span class="badge ok">${esc(typeof health.status === "string" && health.status !== "" ? health.status : "ok")}</span>`
|
|
1931
2066
|
},
|
|
1932
2067
|
{ k: "Namespace", v: esc(state.namespace) },
|
|
1933
2068
|
{ k: "Collections", v: String(decoded.collections.length) },
|
|
@@ -2014,26 +2149,46 @@ function bootAdmin(config) {
|
|
|
2014
2149
|
const renderJournal = async () => {
|
|
2015
2150
|
const journal = await api("GET", "/requests?limit=100");
|
|
2016
2151
|
el("journal", HTMLElement).innerHTML = table([
|
|
2017
|
-
{ label: "When", cell: (row) => esc(row.at) },
|
|
2018
|
-
{
|
|
2152
|
+
{ label: "When", cell: (row) => esc(journalWhen(row.at)) },
|
|
2153
|
+
{
|
|
2154
|
+
label: "Method",
|
|
2155
|
+
cell: (row) => `<span class="method">${esc(row.method)}</span>`
|
|
2156
|
+
},
|
|
2019
2157
|
{ label: "Path", cell: (row) => esc(row.path) },
|
|
2020
|
-
{
|
|
2158
|
+
{
|
|
2159
|
+
label: "Status",
|
|
2160
|
+
cell: (row) => `<span class="badge ${row.status.startsWith("2") ? "ok" : row.status.startsWith("4") || row.status.startsWith("5") ? "warn" : ""}">${esc(row.status)}</span>`
|
|
2161
|
+
},
|
|
2021
2162
|
{ label: "ms", cell: (row) => esc(row.durationMs) }
|
|
2022
2163
|
], decodeJournal(journal));
|
|
2023
2164
|
};
|
|
2024
|
-
const
|
|
2025
|
-
const
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2165
|
+
const paintRoutes = () => {
|
|
2166
|
+
const query = el("route-search", HTMLInputElement).value.trim().toLocaleLowerCase();
|
|
2167
|
+
const routes = state.routes.filter((route) => route.toLocaleLowerCase().includes(query));
|
|
2168
|
+
el("route-count", HTMLElement).textContent = `${routes.length} of ${state.routes.length}`;
|
|
2169
|
+
if (state.routes.length === 0) {
|
|
2170
|
+
el("route-list", HTMLElement).innerHTML = '<p class="empty">No admin routes are available.</p>';
|
|
2171
|
+
return;
|
|
2172
|
+
}
|
|
2173
|
+
if (routes.length === 0) {
|
|
2174
|
+
el("route-list", HTMLElement).innerHTML = '<p class="empty">No routes match this filter.</p>';
|
|
2175
|
+
return;
|
|
2176
|
+
}
|
|
2029
2177
|
el("route-list", HTMLElement).innerHTML = routes.map((route) => {
|
|
2030
2178
|
const extra = standard.has(route) ? "" : " <span class='badge'>extension</span>";
|
|
2031
2179
|
const parts = route.split(" ");
|
|
2032
2180
|
const method = parts[0] ?? "";
|
|
2033
2181
|
const path = parts.slice(1).join(" ");
|
|
2034
|
-
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>`;
|
|
2182
|
+
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>`;
|
|
2035
2183
|
}).join("");
|
|
2036
2184
|
};
|
|
2185
|
+
const renderRoutes = async () => {
|
|
2186
|
+
const data = await api("GET", "/");
|
|
2187
|
+
if (!isRecord2(data))
|
|
2188
|
+
throw new Error("Route list was not an object");
|
|
2189
|
+
state.routes = requireList(data.routes, "Routes").map((route) => String(route));
|
|
2190
|
+
paintRoutes();
|
|
2191
|
+
};
|
|
2037
2192
|
const renderers = {
|
|
2038
2193
|
overview: renderOverview,
|
|
2039
2194
|
state: renderState,
|
|
@@ -2042,19 +2197,50 @@ function bootAdmin(config) {
|
|
|
2042
2197
|
journal: renderJournal,
|
|
2043
2198
|
routes: renderRoutes
|
|
2044
2199
|
};
|
|
2200
|
+
let refreshToken = 0;
|
|
2045
2201
|
async function refresh() {
|
|
2202
|
+
const token = ++refreshToken;
|
|
2046
2203
|
clearError();
|
|
2204
|
+
const main = el("views", HTMLElement);
|
|
2205
|
+
const sync = el("sync-status", HTMLElement);
|
|
2206
|
+
main.setAttribute("aria-busy", "true");
|
|
2207
|
+
sync.classList.add("busy");
|
|
2208
|
+
sync.textContent = "Updating";
|
|
2047
2209
|
const render = renderers[state.view];
|
|
2048
|
-
|
|
2049
|
-
|
|
2210
|
+
try {
|
|
2211
|
+
if (render)
|
|
2212
|
+
await render();
|
|
2213
|
+
if (token === refreshToken)
|
|
2214
|
+
sync.textContent = "Up to date";
|
|
2215
|
+
} finally {
|
|
2216
|
+
if (token === refreshToken) {
|
|
2217
|
+
main.removeAttribute("aria-busy");
|
|
2218
|
+
sync.classList.remove("busy");
|
|
2219
|
+
}
|
|
2220
|
+
}
|
|
2050
2221
|
}
|
|
2051
2222
|
const delay = (ms) => new Promise((resolve) => {
|
|
2052
2223
|
window.setTimeout(resolve, ms);
|
|
2053
2224
|
});
|
|
2054
|
-
const confirmAction = async (button, pending, done, work) => {
|
|
2225
|
+
const confirmAction = async (button, pending, done, work, confirmLabel) => {
|
|
2055
2226
|
if (button.dataset.pending === "1")
|
|
2056
2227
|
return;
|
|
2057
2228
|
const label = button.textContent ?? "";
|
|
2229
|
+
if (confirmLabel !== void 0 && button.dataset.confirming !== "1") {
|
|
2230
|
+
button.dataset.confirming = "1";
|
|
2231
|
+
button.classList.add("confirming");
|
|
2232
|
+
button.textContent = confirmLabel;
|
|
2233
|
+
window.setTimeout(() => {
|
|
2234
|
+
if (button.dataset.confirming !== "1")
|
|
2235
|
+
return;
|
|
2236
|
+
button.textContent = label;
|
|
2237
|
+
button.classList.remove("confirming");
|
|
2238
|
+
delete button.dataset.confirming;
|
|
2239
|
+
}, 3e3);
|
|
2240
|
+
return;
|
|
2241
|
+
}
|
|
2242
|
+
button.classList.remove("confirming");
|
|
2243
|
+
delete button.dataset.confirming;
|
|
2058
2244
|
const width = button.offsetWidth;
|
|
2059
2245
|
button.dataset.pending = "1";
|
|
2060
2246
|
button.classList.add("pressed");
|
|
@@ -2075,6 +2261,65 @@ function bootAdmin(config) {
|
|
|
2075
2261
|
delete button.dataset.pending;
|
|
2076
2262
|
}
|
|
2077
2263
|
};
|
|
2264
|
+
const wireJsonEditor = (textareaId, highlightId, statusId, shellId, allowEmpty) => {
|
|
2265
|
+
const textarea = el(textareaId, HTMLTextAreaElement);
|
|
2266
|
+
const highlight = el(highlightId, HTMLElement);
|
|
2267
|
+
const status = el(statusId, HTMLElement);
|
|
2268
|
+
const shell = el(shellId, HTMLElement);
|
|
2269
|
+
const sync = () => {
|
|
2270
|
+
const text = textarea.value;
|
|
2271
|
+
highlight.innerHTML = `${highlightJsonText(text)}
|
|
2272
|
+
`;
|
|
2273
|
+
let valid = true;
|
|
2274
|
+
let message = "Valid JSON";
|
|
2275
|
+
if (allowEmpty && text.trim() === "")
|
|
2276
|
+
message = "No body";
|
|
2277
|
+
else {
|
|
2278
|
+
try {
|
|
2279
|
+
readJson2(text);
|
|
2280
|
+
} catch {
|
|
2281
|
+
valid = false;
|
|
2282
|
+
message = "Invalid JSON";
|
|
2283
|
+
}
|
|
2284
|
+
}
|
|
2285
|
+
shell.classList.toggle("invalid", !valid);
|
|
2286
|
+
status.classList.toggle("invalid", !valid);
|
|
2287
|
+
status.textContent = message;
|
|
2288
|
+
textarea.setAttribute("aria-invalid", valid ? "false" : "true");
|
|
2289
|
+
};
|
|
2290
|
+
const syncScroll = () => {
|
|
2291
|
+
const preview2 = highlight.parentElement;
|
|
2292
|
+
if (!(preview2 instanceof HTMLElement))
|
|
2293
|
+
return;
|
|
2294
|
+
preview2.scrollTop = textarea.scrollTop;
|
|
2295
|
+
preview2.scrollLeft = textarea.scrollLeft;
|
|
2296
|
+
};
|
|
2297
|
+
textarea.addEventListener("input", sync);
|
|
2298
|
+
textarea.addEventListener("scroll", syncScroll);
|
|
2299
|
+
sync();
|
|
2300
|
+
return sync;
|
|
2301
|
+
};
|
|
2302
|
+
const syncRouteEditor = wireJsonEditor("route-body", "route-highlight", "route-json-status", "route-editor", true);
|
|
2303
|
+
const syncRecordEditor = wireJsonEditor("editor-value", "editor-highlight", "editor-json-status", "record-editor", false);
|
|
2304
|
+
for (const button of document.querySelectorAll("[data-format]")) {
|
|
2305
|
+
if (!(button instanceof HTMLButtonElement))
|
|
2306
|
+
continue;
|
|
2307
|
+
button.addEventListener("click", () => {
|
|
2308
|
+
const target = button.dataset.format;
|
|
2309
|
+
if (target === void 0)
|
|
2310
|
+
return;
|
|
2311
|
+
const textarea = document.getElementById(target);
|
|
2312
|
+
if (!(textarea instanceof HTMLTextAreaElement) || textarea.value.trim() === "")
|
|
2313
|
+
return;
|
|
2314
|
+
try {
|
|
2315
|
+
textarea.value = JSON.stringify(readJson2(textarea.value), null, 2) ?? "";
|
|
2316
|
+
textarea.dispatchEvent(new Event("input"));
|
|
2317
|
+
textarea.focus();
|
|
2318
|
+
} catch {
|
|
2319
|
+
showError(new Error("Fix the JSON before formatting it"));
|
|
2320
|
+
}
|
|
2321
|
+
});
|
|
2322
|
+
}
|
|
2078
2323
|
document.addEventListener("click", (event) => {
|
|
2079
2324
|
const target = event.target;
|
|
2080
2325
|
if (!(target instanceof Element))
|
|
@@ -2147,6 +2392,7 @@ function bootAdmin(config) {
|
|
|
2147
2392
|
} else {
|
|
2148
2393
|
editorValue.value = "{\n \n}";
|
|
2149
2394
|
}
|
|
2395
|
+
syncRecordEditor();
|
|
2150
2396
|
editor.showModal();
|
|
2151
2397
|
}
|
|
2152
2398
|
el("new-record", HTMLButtonElement).addEventListener("click", () => {
|
|
@@ -2176,10 +2422,10 @@ function bootAdmin(config) {
|
|
|
2176
2422
|
const id = state.editing;
|
|
2177
2423
|
if (!id)
|
|
2178
2424
|
return;
|
|
2179
|
-
api("DELETE", `/state/${encodeURIComponent(state.collection)}/${encodeURIComponent(id)}`).then(() => {
|
|
2425
|
+
confirmAction(editorDelete, "Deleting...", "Deleted", () => api("DELETE", `/state/${encodeURIComponent(state.collection)}/${encodeURIComponent(id)}`).then(() => {
|
|
2180
2426
|
editor.close();
|
|
2181
2427
|
return renderState();
|
|
2182
|
-
}).catch(showError);
|
|
2428
|
+
}), "Confirm delete").catch(showError);
|
|
2183
2429
|
});
|
|
2184
2430
|
for (const button of document.querySelectorAll("[data-advance]")) {
|
|
2185
2431
|
if (!(button instanceof HTMLButtonElement))
|
|
@@ -2225,10 +2471,10 @@ function bootAdmin(config) {
|
|
|
2225
2471
|
}).then(() => renderFaults()).catch(showError);
|
|
2226
2472
|
});
|
|
2227
2473
|
el("fault-clear", HTMLButtonElement).addEventListener("click", () => {
|
|
2228
|
-
confirmAction(el("fault-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/faults").then(() => renderFaults())).catch(showError);
|
|
2474
|
+
confirmAction(el("fault-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/faults").then(() => renderFaults()), "Confirm clear").catch(showError);
|
|
2229
2475
|
});
|
|
2230
2476
|
el("journal-clear", HTMLButtonElement).addEventListener("click", () => {
|
|
2231
|
-
confirmAction(el("journal-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/requests").then(() => renderJournal())).catch(showError);
|
|
2477
|
+
confirmAction(el("journal-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/requests").then(() => renderJournal()), "Confirm clear").catch(showError);
|
|
2232
2478
|
});
|
|
2233
2479
|
el("route-list", HTMLElement).addEventListener("click", (event) => {
|
|
2234
2480
|
const target = event.target;
|
|
@@ -2242,9 +2488,15 @@ function bootAdmin(config) {
|
|
|
2242
2488
|
return;
|
|
2243
2489
|
state.route = route;
|
|
2244
2490
|
el("route-title", HTMLElement).textContent = route;
|
|
2491
|
+
el("route-send", HTMLButtonElement).disabled = false;
|
|
2245
2492
|
const method = route.split(" ")[0] ?? "";
|
|
2246
|
-
el("route-body", HTMLTextAreaElement)
|
|
2493
|
+
const routeBody = el("route-body", HTMLTextAreaElement);
|
|
2494
|
+
routeBody.disabled = method === "GET" || method === "DELETE";
|
|
2495
|
+
el("route-editor", HTMLElement).hidden = routeBody.disabled;
|
|
2496
|
+
el("route-json-status", HTMLElement).parentElement?.toggleAttribute("hidden", routeBody.disabled);
|
|
2497
|
+
syncRouteEditor();
|
|
2247
2498
|
});
|
|
2499
|
+
el("route-search", HTMLInputElement).addEventListener("input", paintRoutes);
|
|
2248
2500
|
el("route-form", HTMLFormElement).addEventListener("submit", (event) => {
|
|
2249
2501
|
event.preventDefault();
|
|
2250
2502
|
const send = async () => {
|
|
@@ -2266,7 +2518,8 @@ function bootAdmin(config) {
|
|
|
2266
2518
|
}
|
|
2267
2519
|
}
|
|
2268
2520
|
const data = await api(method, concrete, body);
|
|
2269
|
-
|
|
2521
|
+
const text = JSON.stringify(data, null, 2) ?? "null";
|
|
2522
|
+
el("route-result", HTMLElement).innerHTML = `<code>${highlightJsonText(text)}</code>`;
|
|
2270
2523
|
};
|
|
2271
2524
|
send().catch(showError);
|
|
2272
2525
|
});
|
|
@@ -2384,11 +2637,13 @@ function bootAdmin(config) {
|
|
|
2384
2637
|
views.append(section);
|
|
2385
2638
|
mountSqlExplorer(body, panelApi);
|
|
2386
2639
|
}
|
|
2640
|
+
show(document.getElementById(`view-${state.view}`) === null ? "overview" : state.view);
|
|
2387
2641
|
return refresh();
|
|
2388
2642
|
}).catch(showError);
|
|
2389
2643
|
}
|
|
2390
2644
|
var clientPrelude = [
|
|
2391
2645
|
isRecord2,
|
|
2646
|
+
highlightJsonText,
|
|
2392
2647
|
mountAdminBrand,
|
|
2393
2648
|
startAdminBrand,
|
|
2394
2649
|
faultPresetList,
|
|
@@ -2411,6 +2666,7 @@ ${entry}(${payload});
|
|
|
2411
2666
|
var defineMock = (runtime) => runtime;
|
|
2412
2667
|
var STANDARD_ADMIN_ROUTES = [
|
|
2413
2668
|
"GET /",
|
|
2669
|
+
"GET /health",
|
|
2414
2670
|
"POST /reset",
|
|
2415
2671
|
"GET /namespaces",
|
|
2416
2672
|
"GET /clock",
|
|
@@ -2490,11 +2746,11 @@ var escapeHtml = (value) => value.replace(/[&<>"']/g, (char) => {
|
|
|
2490
2746
|
}
|
|
2491
2747
|
});
|
|
2492
2748
|
var ADMIN_BRANDS_URL = "https://mockingbird.chrisvouga.dev/brands.json";
|
|
2493
|
-
var renderAdminDocument = (service) => {
|
|
2749
|
+
var renderAdminDocument = (service, adminPrefix = ADMIN_PREFIX) => {
|
|
2494
2750
|
const name = escapeHtml(service);
|
|
2495
2751
|
const script = adminClientSource({
|
|
2496
2752
|
standardRoutes: STANDARD_ADMIN_ROUTES,
|
|
2497
|
-
adminPrefix
|
|
2753
|
+
adminPrefix,
|
|
2498
2754
|
adminKeyHeader: ADMIN_KEY_HEADER,
|
|
2499
2755
|
brandsUrl: ADMIN_BRANDS_URL,
|
|
2500
2756
|
service
|
|
@@ -2511,75 +2767,99 @@ var renderAdminDocument = (service) => {
|
|
|
2511
2767
|
${CSS_RESET}
|
|
2512
2768
|
:root {
|
|
2513
2769
|
color-scheme: light;
|
|
2514
|
-
--bg: #
|
|
2770
|
+
--bg: #f7f8fb;
|
|
2515
2771
|
--bg-elev: #ffffff;
|
|
2516
|
-
--bg-muted: #
|
|
2517
|
-
--bg-inset: #
|
|
2518
|
-
--line: #
|
|
2519
|
-
--line-strong: #
|
|
2520
|
-
--ink: #
|
|
2521
|
-
--muted: #
|
|
2522
|
-
--faint: #
|
|
2523
|
-
--accent: #
|
|
2772
|
+
--bg-muted: #f1f3f7;
|
|
2773
|
+
--bg-inset: #f8f9fc;
|
|
2774
|
+
--line: #e4e7ec;
|
|
2775
|
+
--line-strong: #cfd4dc;
|
|
2776
|
+
--ink: #101828;
|
|
2777
|
+
--muted: #475467;
|
|
2778
|
+
--faint: #667085;
|
|
2779
|
+
--accent: #6941c6;
|
|
2780
|
+
--accent-hover: #5933b4;
|
|
2524
2781
|
--accent-ink: #ffffff;
|
|
2525
|
-
--accent-soft: #
|
|
2526
|
-
--ok: #
|
|
2527
|
-
--ok-soft: #
|
|
2528
|
-
--warn: #
|
|
2529
|
-
--warn-soft: #
|
|
2530
|
-
--err: #
|
|
2531
|
-
--err-soft: #
|
|
2532
|
-
--
|
|
2782
|
+
--accent-soft: #f4f0ff;
|
|
2783
|
+
--ok: #067647;
|
|
2784
|
+
--ok-soft: #ecfdf3;
|
|
2785
|
+
--warn: #b54708;
|
|
2786
|
+
--warn-soft: #fffaeb;
|
|
2787
|
+
--err: #b42318;
|
|
2788
|
+
--err-soft: #fef3f2;
|
|
2789
|
+
--code-key: #6941c6;
|
|
2790
|
+
--code-string: #067647;
|
|
2791
|
+
--code-number: #175cd3;
|
|
2792
|
+
--code-boolean: #c11574;
|
|
2793
|
+
--code-null: #667085;
|
|
2794
|
+
--shadow-xs: 0 1px 2px rgb(16 24 40 / 0.05);
|
|
2795
|
+
--shadow-sm: 0 1px 3px rgb(16 24 40 / 0.08), 0 1px 2px rgb(16 24 40 / 0.04);
|
|
2796
|
+
--shadow-lg: 0 18px 48px rgb(16 24 40 / 0.16), 0 4px 12px rgb(16 24 40 / 0.08);
|
|
2533
2797
|
--sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
2534
2798
|
--mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;
|
|
2535
|
-
--radius:
|
|
2536
|
-
--header:
|
|
2537
|
-
--nav:
|
|
2799
|
+
--radius: 12px;
|
|
2800
|
+
--header: 72px;
|
|
2801
|
+
--nav: 224px;
|
|
2538
2802
|
}
|
|
2539
2803
|
:root[data-theme="dark"] {
|
|
2540
2804
|
color-scheme: dark;
|
|
2541
|
-
--bg: #
|
|
2542
|
-
--bg-elev: #
|
|
2543
|
-
--bg-muted: #
|
|
2544
|
-
--bg-inset: #
|
|
2545
|
-
--line: #
|
|
2546
|
-
--line-strong: #
|
|
2547
|
-
--ink: #
|
|
2548
|
-
--muted: #
|
|
2549
|
-
--faint: #
|
|
2550
|
-
--accent: #
|
|
2551
|
-
--accent-
|
|
2552
|
-
--accent-
|
|
2553
|
-
--
|
|
2554
|
-
--ok
|
|
2555
|
-
--
|
|
2556
|
-
--warn
|
|
2557
|
-
--
|
|
2558
|
-
--err
|
|
2559
|
-
--
|
|
2805
|
+
--bg: #0c0e14;
|
|
2806
|
+
--bg-elev: #151821;
|
|
2807
|
+
--bg-muted: #20232e;
|
|
2808
|
+
--bg-inset: #11141c;
|
|
2809
|
+
--line: #2b303d;
|
|
2810
|
+
--line-strong: #414858;
|
|
2811
|
+
--ink: #f5f7fa;
|
|
2812
|
+
--muted: #b6bdc9;
|
|
2813
|
+
--faint: #8d96a7;
|
|
2814
|
+
--accent: #9e77ed;
|
|
2815
|
+
--accent-hover: #b692f6;
|
|
2816
|
+
--accent-ink: #ffffff;
|
|
2817
|
+
--accent-soft: #2b2142;
|
|
2818
|
+
--ok: #75e0a7;
|
|
2819
|
+
--ok-soft: #102a20;
|
|
2820
|
+
--warn: #fec84b;
|
|
2821
|
+
--warn-soft: #302611;
|
|
2822
|
+
--err: #fda29b;
|
|
2823
|
+
--err-soft: #321817;
|
|
2824
|
+
--code-key: #c3a6ff;
|
|
2825
|
+
--code-string: #75e0a7;
|
|
2826
|
+
--code-number: #84adff;
|
|
2827
|
+
--code-boolean: #f9a8d4;
|
|
2828
|
+
--code-null: #98a2b3;
|
|
2829
|
+
--shadow-xs: 0 1px 2px rgb(0 0 0 / 0.24);
|
|
2830
|
+
--shadow-sm: 0 4px 12px rgb(0 0 0 / 0.2);
|
|
2831
|
+
--shadow-lg: 0 20px 56px rgb(0 0 0 / 0.45);
|
|
2560
2832
|
}
|
|
2561
2833
|
@media (prefers-color-scheme: dark) {
|
|
2562
2834
|
:root:not([data-theme="light"]) {
|
|
2563
2835
|
color-scheme: dark;
|
|
2564
|
-
--bg: #
|
|
2565
|
-
--bg-elev: #
|
|
2566
|
-
--bg-muted: #
|
|
2567
|
-
--bg-inset: #
|
|
2568
|
-
--line: #
|
|
2569
|
-
--line-strong: #
|
|
2570
|
-
--ink: #
|
|
2571
|
-
--muted: #
|
|
2572
|
-
--faint: #
|
|
2573
|
-
--accent: #
|
|
2574
|
-
--accent-
|
|
2575
|
-
--accent-
|
|
2576
|
-
--
|
|
2577
|
-
--ok
|
|
2578
|
-
--
|
|
2579
|
-
--warn
|
|
2580
|
-
--
|
|
2581
|
-
--err
|
|
2582
|
-
--
|
|
2836
|
+
--bg: #0c0e14;
|
|
2837
|
+
--bg-elev: #151821;
|
|
2838
|
+
--bg-muted: #20232e;
|
|
2839
|
+
--bg-inset: #11141c;
|
|
2840
|
+
--line: #2b303d;
|
|
2841
|
+
--line-strong: #414858;
|
|
2842
|
+
--ink: #f5f7fa;
|
|
2843
|
+
--muted: #b6bdc9;
|
|
2844
|
+
--faint: #8d96a7;
|
|
2845
|
+
--accent: #9e77ed;
|
|
2846
|
+
--accent-hover: #b692f6;
|
|
2847
|
+
--accent-ink: #ffffff;
|
|
2848
|
+
--accent-soft: #2b2142;
|
|
2849
|
+
--ok: #75e0a7;
|
|
2850
|
+
--ok-soft: #102a20;
|
|
2851
|
+
--warn: #fec84b;
|
|
2852
|
+
--warn-soft: #302611;
|
|
2853
|
+
--err: #fda29b;
|
|
2854
|
+
--err-soft: #321817;
|
|
2855
|
+
--code-key: #c3a6ff;
|
|
2856
|
+
--code-string: #75e0a7;
|
|
2857
|
+
--code-number: #84adff;
|
|
2858
|
+
--code-boolean: #f9a8d4;
|
|
2859
|
+
--code-null: #98a2b3;
|
|
2860
|
+
--shadow-xs: 0 1px 2px rgb(0 0 0 / 0.24);
|
|
2861
|
+
--shadow-sm: 0 4px 12px rgb(0 0 0 / 0.2);
|
|
2862
|
+
--shadow-lg: 0 20px 56px rgb(0 0 0 / 0.45);
|
|
2583
2863
|
}
|
|
2584
2864
|
}
|
|
2585
2865
|
/* Fill the viewport, then grow with the content so the background covers the scroll. */
|
|
@@ -2597,13 +2877,17 @@ var renderAdminDocument = (service) => {
|
|
|
2597
2877
|
padding-right: env(safe-area-inset-right);
|
|
2598
2878
|
-webkit-text-size-adjust: 100%;
|
|
2599
2879
|
}
|
|
2600
|
-
|
|
2880
|
+
::selection { background: color-mix(in srgb, var(--accent) 28%, transparent); }
|
|
2881
|
+
:focus-visible { outline: 3px solid color-mix(in srgb, var(--accent) 45%, transparent); outline-offset: 2px; }
|
|
2882
|
+
button, input, select, textarea { transition: border-color 140ms ease, box-shadow 140ms ease, transform 100ms ease; }
|
|
2883
|
+
button:disabled, input:disabled, select:disabled, textarea:disabled { cursor: not-allowed; opacity: 0.55; }
|
|
2601
2884
|
.app { flex: 1 0 auto; display: flex; flex-direction: column; min-width: 0; width: 100%; }
|
|
2602
2885
|
header.top {
|
|
2603
|
-
position: sticky; top: 0; z-index:
|
|
2886
|
+
position: sticky; top: 0; z-index: 20;
|
|
2604
2887
|
display: flex; align-items: center; gap: 12px;
|
|
2605
|
-
min-height: var(--header); padding:
|
|
2606
|
-
background: var(--bg-elev);
|
|
2888
|
+
min-height: var(--header); padding: 12px 20px;
|
|
2889
|
+
background: color-mix(in srgb, var(--bg-elev) 92%, transparent);
|
|
2890
|
+
backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
|
|
2607
2891
|
border-bottom: 1px solid var(--line);
|
|
2608
2892
|
min-width: 0; max-width: 100%;
|
|
2609
2893
|
}
|
|
@@ -2611,12 +2895,21 @@ var renderAdminDocument = (service) => {
|
|
|
2611
2895
|
display: flex; flex-flow: row wrap; align-items: center; gap: 14px;
|
|
2612
2896
|
min-width: 0; max-width: 100%; margin-right: auto;
|
|
2613
2897
|
}
|
|
2614
|
-
.brand-
|
|
2615
|
-
.brand-
|
|
2898
|
+
.brand-lockup { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
|
2899
|
+
.brand-mark {
|
|
2900
|
+
width: 38px; height: 38px; display: grid; place-items: center; flex: none;
|
|
2901
|
+
border-radius: 11px; color: #fff;
|
|
2902
|
+
background: linear-gradient(145deg, #7f56d9 0%, #53389e 100%);
|
|
2903
|
+
box-shadow: 0 6px 16px rgb(105 65 198 / 0.24), inset 0 1px 0 rgb(255 255 255 / 0.25);
|
|
2904
|
+
}
|
|
2905
|
+
.brand-mark svg { width: 22px; height: 22px; }
|
|
2906
|
+
.brand-id { display: flex; flex-direction: column; min-width: 0; flex: none; line-height: 1.25; }
|
|
2907
|
+
.brand-id strong { font-size: 15px; letter-spacing: -0.015em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
2616
2908
|
.brand-id span { color: var(--faint); font-size: 12px; }
|
|
2617
2909
|
.vendor {
|
|
2618
2910
|
display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
|
|
2619
2911
|
min-width: 0; max-width: 100%; font-size: 12px; color: var(--muted);
|
|
2912
|
+
padding: 5px 8px; border: 1px solid var(--line); border-radius: 999px; background: var(--bg-inset);
|
|
2620
2913
|
}
|
|
2621
2914
|
.vendor[hidden] { display: none; }
|
|
2622
2915
|
/* Logos are drawn for a light tile, same as the docs site, so the plate stays white. */
|
|
@@ -2628,12 +2921,17 @@ var renderAdminDocument = (service) => {
|
|
|
2628
2921
|
.vendor-sep { color: var(--faint); }
|
|
2629
2922
|
.vendor-link { color: inherit; text-decoration: none; }
|
|
2630
2923
|
.vendor-link:hover { color: var(--ink); text-decoration: underline; }
|
|
2631
|
-
.controls { display: flex; flex-wrap: wrap; gap: 8px; align-items:
|
|
2632
|
-
|
|
2924
|
+
.controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: flex-end; }
|
|
2925
|
+
.sync-status { align-self: center; display: flex; align-items: center; gap: 6px; color: var(--faint); font-size: 12px; white-space: nowrap; }
|
|
2926
|
+
.sync-status::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--ok); box-shadow: 0 0 0 3px var(--ok-soft); }
|
|
2927
|
+
.sync-status.busy::before { background: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); animation: pulse 1.2s ease-in-out infinite; }
|
|
2928
|
+
label.field { display: flex; flex-direction: column; gap: 4px; font-size: 11px; font-weight: 600; color: var(--faint); }
|
|
2633
2929
|
label.field input, label.field select, .controls input[type="password"] {
|
|
2634
|
-
background-color: var(--bg-
|
|
2635
|
-
padding:
|
|
2930
|
+
background-color: var(--bg-elev); border: 1px solid var(--line-strong); border-radius: 9px;
|
|
2931
|
+
padding: 8px 10px; min-height: 38px; line-height: 1.2; box-shadow: var(--shadow-xs);
|
|
2636
2932
|
}
|
|
2933
|
+
label.field input:hover, label.field select:hover, textarea:hover { border-color: color-mix(in srgb, var(--accent) 38%, var(--line-strong)); }
|
|
2934
|
+
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; }
|
|
2637
2935
|
/* A native menu ignores min-height, so Preset rendered about half as tall as
|
|
2638
2936
|
the text fields in the same form. Paint it like those fields. */
|
|
2639
2937
|
label.field select {
|
|
@@ -2650,12 +2948,20 @@ var renderAdminDocument = (service) => {
|
|
|
2650
2948
|
dialog .field > textarea { width: 100%; }
|
|
2651
2949
|
.icon-btn, .btn, .btn-primary, .btn-quiet, .btn-danger, nav.side button { cursor: pointer; }
|
|
2652
2950
|
.icon-btn, .btn, .btn-primary, .btn-quiet, .btn-danger {
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
.btn
|
|
2951
|
+
display: inline-flex; align-items: center; justify-content: center; gap: 7px;
|
|
2952
|
+
border-radius: 9px; min-height: 38px; padding: 0 13px; border: 1px solid var(--line-strong);
|
|
2953
|
+
background: var(--bg-elev); color: var(--ink); font-size: 13px; font-weight: 600; line-height: 1;
|
|
2954
|
+
box-shadow: var(--shadow-xs);
|
|
2955
|
+
}
|
|
2956
|
+
.icon-btn { width: 38px; padding: 0; }
|
|
2957
|
+
.icon-btn svg, .btn svg, .btn-primary svg { width: 16px; height: 16px; }
|
|
2958
|
+
.icon-btn:hover, .btn:hover { background: var(--bg-muted); border-color: var(--line-strong); }
|
|
2959
|
+
.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; }
|
|
2960
|
+
.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
|
|
2961
|
+
.btn-quiet { background: transparent; border-color: transparent; box-shadow: none; }
|
|
2962
|
+
.btn-quiet:hover { background: var(--bg-muted); }
|
|
2963
|
+
.btn-danger { color: var(--err); border-color: color-mix(in srgb, var(--err) 25%, var(--line)); }
|
|
2964
|
+
.btn-danger:hover, .btn-danger.confirming { background: var(--err-soft); border-color: color-mix(in srgb, var(--err) 45%, var(--line)); }
|
|
2659
2965
|
/* Held after mouseup. Clearing an empty journal changes nothing on screen,
|
|
2660
2966
|
so the press itself has to stay visible. */
|
|
2661
2967
|
.icon-btn:active, .btn:active, .btn-quiet:active, .btn-danger:active, nav.side button:active,
|
|
@@ -2671,93 +2977,159 @@ var renderAdminDocument = (service) => {
|
|
|
2671
2977
|
flex: 1 0 auto; min-width: 0;
|
|
2672
2978
|
}
|
|
2673
2979
|
nav.side {
|
|
2674
|
-
|
|
2980
|
+
position: sticky; top: var(--header); align-self: start; height: calc(100vh - var(--header));
|
|
2981
|
+
border-right: 1px solid var(--line); padding: 18px 12px;
|
|
2675
2982
|
display: flex; flex-direction: column; gap: 4px;
|
|
2676
|
-
background: var(--bg-elev);
|
|
2983
|
+
background: var(--bg-elev); overflow-y: auto;
|
|
2677
2984
|
}
|
|
2985
|
+
.nav-label { padding: 0 10px 8px; color: var(--faint); font-size: 10px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; }
|
|
2678
2986
|
nav.side button {
|
|
2987
|
+
position: relative; display: flex; align-items: center; gap: 10px; width: 100%;
|
|
2679
2988
|
text-align: left; border: 0; background: transparent; border-radius: 9px;
|
|
2680
|
-
padding: 9px 10px; min-height:
|
|
2681
|
-
}
|
|
2682
|
-
nav.side button
|
|
2683
|
-
|
|
2684
|
-
.
|
|
2685
|
-
.
|
|
2686
|
-
.
|
|
2989
|
+
padding: 9px 10px; min-height: 42px; color: var(--muted); font-size: 13px; font-weight: 550;
|
|
2990
|
+
}
|
|
2991
|
+
nav.side button:hover { background: var(--bg-muted); color: var(--ink); }
|
|
2992
|
+
nav.side button[aria-current="true"] { background: var(--accent-soft); color: var(--accent); font-weight: 650; }
|
|
2993
|
+
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); }
|
|
2994
|
+
.nav-icon { width: 18px; height: 18px; display: grid; place-items: center; flex: none; }
|
|
2995
|
+
.nav-icon svg { width: 18px; height: 18px; }
|
|
2996
|
+
main { padding: 32px clamp(20px, 4vw, 48px) 64px; max-width: 1320px; width: 100%; min-width: 0; }
|
|
2997
|
+
main[aria-busy="true"] .view { opacity: 0.62; }
|
|
2998
|
+
.view { animation: view-in 180ms ease-out; }
|
|
2999
|
+
.view[hidden] { display: none; }
|
|
3000
|
+
.view-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 22px; }
|
|
3001
|
+
.view h2 { margin: 0 0 5px; font-size: clamp(24px, 3vw, 30px); line-height: 1.2; letter-spacing: -0.035em; }
|
|
3002
|
+
.view h3 { margin: 24px 0 10px; font-size: 15px; letter-spacing: -0.01em; }
|
|
3003
|
+
.lede { margin: 0; color: var(--muted); max-width: 72ch; font-size: 14px; }
|
|
3004
|
+
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 175px), 1fr)); gap: 14px; }
|
|
2687
3005
|
.card {
|
|
2688
3006
|
background: var(--bg-elev); border: 1px solid var(--line); border-radius: var(--radius);
|
|
2689
|
-
padding:
|
|
3007
|
+
padding: 18px; box-shadow: var(--shadow-sm);
|
|
2690
3008
|
}
|
|
2691
|
-
.card
|
|
2692
|
-
.card
|
|
3009
|
+
.stat-card { position: relative; overflow: hidden; }
|
|
3010
|
+
.stat-card::after { content: ""; position: absolute; inset: 0 0 auto; height: 2px; background: linear-gradient(90deg, var(--accent), transparent 78%); opacity: 0.7; }
|
|
3011
|
+
.card .k { color: var(--faint); font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; }
|
|
3012
|
+
.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; }
|
|
2693
3013
|
.card .v .clock-time { font-size: 14px; color: var(--muted); }
|
|
2694
|
-
.
|
|
3014
|
+
.section-card { background: var(--bg-elev); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow-xs); overflow: hidden; }
|
|
3015
|
+
.section-title { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 16px 18px; border-bottom: 1px solid var(--line); }
|
|
3016
|
+
.section-title h3 { margin: 0; }
|
|
3017
|
+
.split { display: grid; grid-template-columns: minmax(0, 250px) minmax(0, 1fr); gap: 18px; align-items: start; }
|
|
3018
|
+
#collections { padding: 6px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg-elev); box-shadow: var(--shadow-xs); }
|
|
2695
3019
|
.collection {
|
|
2696
3020
|
width: 100%; text-align: left; border: 1px solid transparent; background: transparent;
|
|
2697
|
-
border-radius:
|
|
3021
|
+
border-radius: 9px; padding: 10px 11px; min-height: 48px; color: var(--ink); cursor: pointer;
|
|
2698
3022
|
}
|
|
2699
|
-
.collection
|
|
3023
|
+
.collection:hover { background: var(--bg-muted); }
|
|
3024
|
+
.collection[aria-current="true"] { background: var(--accent-soft); border-color: color-mix(in srgb, var(--accent) 18%, var(--line)); color: var(--accent); }
|
|
2700
3025
|
.collection small { display: block; color: var(--faint); }
|
|
2701
3026
|
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 14px; }
|
|
2702
|
-
.chip {
|
|
2703
|
-
font-family: var(--mono); font-size: 12px; background: var(--bg-muted);
|
|
2704
|
-
border-radius: 999px; padding: 4px 8px;
|
|
2705
|
-
}
|
|
3027
|
+
.chip { font-family: var(--mono); font-size: 11px; background: var(--bg-muted); border: 1px solid var(--line); border-radius: 999px; padding: 4px 8px; }
|
|
2706
3028
|
.chip i { color: var(--faint); font-style: normal; }
|
|
2707
3029
|
.sql-layout {
|
|
2708
3030
|
display: grid; grid-template-columns: minmax(10rem, 16rem) minmax(0, 1fr); gap: 16px;
|
|
2709
3031
|
}
|
|
2710
|
-
.sql-tables { display: flex; flex-direction: column; gap: 4px; max-height: 28rem; overflow: auto; }
|
|
2711
|
-
.sql-tables button { text-align: left; }
|
|
3032
|
+
.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); }
|
|
3033
|
+
.sql-tables button { text-align: left; border: 0; border-radius: 8px; padding: 9px 10px; background: transparent; color: var(--muted); cursor: pointer; }
|
|
3034
|
+
.sql-tables button:hover, .sql-tables button[aria-current="true"] { background: var(--accent-soft); color: var(--accent); }
|
|
2712
3035
|
.sql-input { font-family: var(--mono); min-height: 7rem; width: 100%; }
|
|
2713
|
-
table { width: 100%; border-
|
|
2714
|
-
|
|
2715
|
-
th {
|
|
3036
|
+
.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); }
|
|
3037
|
+
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
3038
|
+
th, td { text-align: left; padding: 11px 13px; border-bottom: 1px solid var(--line); vertical-align: top; }
|
|
3039
|
+
th { color: var(--faint); background: var(--bg-inset); font-weight: 650; font-size: 11px; text-transform: uppercase; letter-spacing: 0.045em; }
|
|
3040
|
+
tbody tr:last-child td { border-bottom: 0; }
|
|
2716
3041
|
tr.clickable { cursor: pointer; }
|
|
2717
|
-
tr.clickable:hover { background: var(--
|
|
3042
|
+
tr.clickable:hover { background: var(--accent-soft); }
|
|
2718
3043
|
pre, code, .mono { font-family: var(--mono); overflow-wrap: anywhere; }
|
|
2719
3044
|
pre {
|
|
2720
3045
|
margin: 0; white-space: pre-wrap; word-break: break-word;
|
|
2721
|
-
background: var(--bg-inset); border-radius: 10px; padding:
|
|
2722
|
-
}
|
|
2723
|
-
.
|
|
3046
|
+
background: var(--bg-inset); border: 1px solid var(--line); border-radius: 10px; padding: 14px; font-size: 12px; line-height: 1.65;
|
|
3047
|
+
}
|
|
3048
|
+
.json-key { color: var(--code-key); }
|
|
3049
|
+
.json-string { color: var(--code-string); }
|
|
3050
|
+
.json-number { color: var(--code-number); }
|
|
3051
|
+
.json-boolean { color: var(--code-boolean); }
|
|
3052
|
+
.json-null { color: var(--code-null); font-style: italic; }
|
|
3053
|
+
.json-inline { display: block; max-width: 44rem; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
|
3054
|
+
.json-view { position: relative; max-height: 26rem; overflow: auto; }
|
|
3055
|
+
.json-editor { position: relative; min-height: 190px; border: 1px solid var(--line-strong); border-radius: 10px; background: var(--bg-inset); overflow: hidden; }
|
|
3056
|
+
.json-editor:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 15%, transparent); }
|
|
3057
|
+
.json-editor.invalid { border-color: var(--err); }
|
|
3058
|
+
.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; }
|
|
3059
|
+
.json-editor pre { position: absolute; inset: 0; pointer-events: none; background: transparent; }
|
|
3060
|
+
.json-editor textarea { position: relative; z-index: 1; resize: vertical; background: transparent; color: transparent; caret-color: var(--ink); -webkit-text-fill-color: transparent; }
|
|
3061
|
+
.json-editor textarea::selection { background: color-mix(in srgb, var(--accent) 32%, transparent); }
|
|
3062
|
+
.editor-meta { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 7px; }
|
|
3063
|
+
.json-status { display: inline-flex; align-items: center; gap: 6px; color: var(--ok); font-size: 11px; }
|
|
3064
|
+
.json-status::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
|
3065
|
+
.json-status.invalid { color: var(--err); }
|
|
3066
|
+
.format-json { border: 0; padding: 3px 5px; background: transparent; color: var(--accent); font-size: 11px; font-weight: 650; cursor: pointer; }
|
|
3067
|
+
.row-actions { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 8px; margin: 14px 0; }
|
|
2724
3068
|
.banner {
|
|
2725
|
-
display: none;
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
3069
|
+
display: none; position: sticky; top: calc(var(--header) + 12px); z-index: 15;
|
|
3070
|
+
align-items: center; gap: 10px; margin: 0 0 16px; padding: 11px 12px 11px 14px;
|
|
3071
|
+
border: 1px solid color-mix(in srgb, var(--err) 30%, var(--line)); border-radius: 10px;
|
|
3072
|
+
background: var(--err-soft); color: var(--err); box-shadow: var(--shadow-sm); font-size: 13px;
|
|
3073
|
+
}
|
|
3074
|
+
.banner.show { display: flex; }
|
|
3075
|
+
.banner span { flex: 1; }
|
|
3076
|
+
.banner button { width: 28px; height: 28px; display: grid; place-items: center; border: 0; border-radius: 7px; background: transparent; color: currentColor; cursor: pointer; }
|
|
3077
|
+
.banner button:hover { background: color-mix(in srgb, var(--err) 10%, transparent); }
|
|
3078
|
+
.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); }
|
|
2730
3079
|
dialog {
|
|
2731
|
-
border: 1px solid var(--line); border-radius:
|
|
2732
|
-
color: var(--ink); width: min(
|
|
2733
|
-
}
|
|
2734
|
-
dialog
|
|
2735
|
-
dialog
|
|
3080
|
+
border: 1px solid var(--line); border-radius: 16px; padding: 0; background: var(--bg-elev);
|
|
3081
|
+
color: var(--ink); width: min(720px, calc(100% - 24px)); max-width: calc(100vw - 24px); box-shadow: var(--shadow-lg);
|
|
3082
|
+
}
|
|
3083
|
+
dialog[open] { animation: dialog-in 160ms ease-out; }
|
|
3084
|
+
dialog::backdrop { background: rgb(12 14 20 / 0.58); backdrop-filter: blur(3px); }
|
|
3085
|
+
dialog form, .dialog-body { padding: 22px; display: flex; flex-direction: column; gap: 14px; }
|
|
3086
|
+
.dialog-heading { display: flex; justify-content: space-between; gap: 16px; padding-bottom: 12px; border-bottom: 1px solid var(--line); }
|
|
3087
|
+
.dialog-heading strong { font-size: 18px; letter-spacing: -0.02em; }
|
|
3088
|
+
.dialog-heading span { color: var(--muted); font-size: 12px; }
|
|
2736
3089
|
textarea {
|
|
2737
3090
|
width: 100%; min-height: 180px; resize: vertical; border-radius: 10px;
|
|
2738
3091
|
border: 1px solid var(--line); background: var(--bg-inset); padding: 10px;
|
|
2739
3092
|
font-family: var(--mono); font-size: 12px;
|
|
2740
3093
|
}
|
|
2741
|
-
.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
|
2742
|
-
.
|
|
3094
|
+
.dialog-actions { display: flex; justify-content: flex-end; align-items: center; gap: 8px; padding-top: 4px; }
|
|
3095
|
+
.dialog-actions .btn-danger { margin-right: auto; }
|
|
3096
|
+
.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); }
|
|
2743
3097
|
.badge.ok { background: var(--ok-soft); color: var(--ok); }
|
|
2744
3098
|
.badge.warn { background: var(--warn-soft); color: var(--warn); }
|
|
2745
3099
|
.status-2 { color: var(--ok); }
|
|
2746
3100
|
.status-4, .status-5, .status-0 { color: var(--err); }
|
|
2747
3101
|
.stack { display: flex; flex-direction: column; gap: 12px; }
|
|
3102
|
+
.form-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
|
|
3103
|
+
.form-grid .wide { grid-column: span 2; }
|
|
2748
3104
|
.route {
|
|
2749
|
-
display: grid; grid-template-columns:
|
|
2750
|
-
padding:
|
|
2751
|
-
}
|
|
2752
|
-
.
|
|
3105
|
+
display: grid; grid-template-columns: 84px minmax(0, 1fr) auto; gap: 10px; align-items: center;
|
|
3106
|
+
padding: 10px 12px; border-bottom: 1px solid var(--line); background: var(--bg-elev);
|
|
3107
|
+
}
|
|
3108
|
+
.route:first-child { border-radius: var(--radius) var(--radius) 0 0; }
|
|
3109
|
+
.route:last-child { border-bottom: 0; border-radius: 0 0 var(--radius) var(--radius); }
|
|
3110
|
+
.route-tools { display: flex; align-items: flex-end; gap: 12px; margin-bottom: 12px; }
|
|
3111
|
+
.route-tools .field { flex: 1; }
|
|
3112
|
+
.route-tools input { width: 100%; }
|
|
3113
|
+
.route-count { padding-bottom: 10px; color: var(--faint); font-size: 12px; white-space: nowrap; }
|
|
3114
|
+
#route-list { max-height: 34rem; border: 1px solid var(--line); border-radius: var(--radius); overflow: auto; box-shadow: var(--shadow-xs); }
|
|
3115
|
+
.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; }
|
|
3116
|
+
@keyframes view-in { from { transform: translateY(3px); } to { transform: translateY(0); } }
|
|
3117
|
+
@keyframes dialog-in { from { transform: translateY(8px) scale(0.99); } to { transform: translateY(0) scale(1); } }
|
|
3118
|
+
@keyframes pulse { 50% { opacity: 0.45; } }
|
|
3119
|
+
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } }
|
|
3120
|
+
@media (forced-colors: active) { .json-editor pre { display: none; } .json-editor textarea { color: CanvasText; -webkit-text-fill-color: CanvasText; } }
|
|
2753
3121
|
@media (max-width: 800px) {
|
|
3122
|
+
:root { --header: 0px; }
|
|
2754
3123
|
header.top {
|
|
2755
|
-
align-items: stretch; flex-direction: column;
|
|
2756
|
-
padding
|
|
3124
|
+
position: relative; align-items: stretch; flex-direction: column;
|
|
3125
|
+
padding: max(12px, env(safe-area-inset-top)) 14px 12px;
|
|
2757
3126
|
}
|
|
2758
3127
|
input, select, textarea { font-size: 16px; }
|
|
2759
3128
|
.brand, .vendor, .controls { width: 100%; }
|
|
3129
|
+
.brand { gap: 10px; }
|
|
3130
|
+
.vendor { order: 2; border-radius: 9px; }
|
|
2760
3131
|
label.field, label.field select, label.field input { flex: 1; min-width: 0; }
|
|
3132
|
+
.sync-status { display: none; }
|
|
2761
3133
|
/* The layout fills the screen. A single-column grid stretches every auto
|
|
2762
3134
|
row, so the tab bar grew with the leftover height and the buttons floated
|
|
2763
3135
|
in the middle of it. The bar stays one row; the page below takes the rest. */
|
|
@@ -2767,14 +3139,23 @@ var renderAdminDocument = (service) => {
|
|
|
2767
3139
|
align-content: start;
|
|
2768
3140
|
}
|
|
2769
3141
|
nav.side {
|
|
2770
|
-
align-self: start; height: auto;
|
|
3142
|
+
position: sticky; top: 0; z-index: 12; align-self: start; height: auto;
|
|
2771
3143
|
flex-direction: row; align-items: center; overflow-x: auto; min-width: 0;
|
|
2772
3144
|
border-right: 0; border-bottom: 1px solid var(--line);
|
|
2773
|
-
padding: 8px;
|
|
3145
|
+
padding: 8px; box-shadow: var(--shadow-xs);
|
|
2774
3146
|
}
|
|
2775
|
-
nav
|
|
3147
|
+
.nav-label { display: none; }
|
|
3148
|
+
nav.side button { width: auto; white-space: nowrap; flex: none; }
|
|
3149
|
+
nav.side button[aria-current="true"]::before { inset: auto 9px -8px; width: auto; height: 3px; border-radius: 3px 3px 0 0; }
|
|
2776
3150
|
.split, .sql-layout { grid-template-columns: minmax(0, 1fr); }
|
|
2777
|
-
|
|
3151
|
+
.form-grid { grid-template-columns: minmax(0, 1fr); }
|
|
3152
|
+
.form-grid .wide { grid-column: auto; }
|
|
3153
|
+
main { padding: 22px 14px 48px; }
|
|
3154
|
+
.view-head { margin-bottom: 18px; }
|
|
3155
|
+
.grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
|
3156
|
+
.card { padding: 14px; }
|
|
3157
|
+
.card .v { font-size: 20px; }
|
|
3158
|
+
.table-shell { border: 0; background: transparent; box-shadow: none; overflow: visible; }
|
|
2778
3159
|
table, thead, tbody, tr, th, td { display: block; }
|
|
2779
3160
|
thead { display: none; }
|
|
2780
3161
|
tr {
|
|
@@ -2789,50 +3170,63 @@ var renderAdminDocument = (service) => {
|
|
|
2789
3170
|
.route { grid-template-columns: 72px 1fr; }
|
|
2790
3171
|
.route button { grid-column: 1 / -1; }
|
|
2791
3172
|
}
|
|
3173
|
+
@media (max-width: 460px) {
|
|
3174
|
+
.controls label.field { min-width: calc(50% - 4px); }
|
|
3175
|
+
.controls .icon-btn, .controls .btn { flex: 1; }
|
|
3176
|
+
.grid { grid-template-columns: minmax(0, 1fr); }
|
|
3177
|
+
.dialog-actions { flex-wrap: wrap; }
|
|
3178
|
+
.dialog-actions button { flex: 1; }
|
|
3179
|
+
.dialog-actions .btn-danger { flex-basis: 100%; margin-right: 0; }
|
|
3180
|
+
}
|
|
2792
3181
|
</style>
|
|
2793
3182
|
</head>
|
|
2794
3183
|
<body>
|
|
2795
3184
|
<div class="app" data-service="${name}" data-mockingbird-admin>
|
|
2796
3185
|
<header class="top">
|
|
2797
3186
|
<div class="brand">
|
|
2798
|
-
<div class="brand-
|
|
2799
|
-
<
|
|
2800
|
-
|
|
3187
|
+
<div class="brand-lockup">
|
|
3188
|
+
<div class="brand-mark" aria-hidden="true">
|
|
3189
|
+
<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>
|
|
3190
|
+
</div>
|
|
3191
|
+
<div class="brand-id">
|
|
3192
|
+
<strong>${name}</strong>
|
|
3193
|
+
<span>Mock admin</span>
|
|
3194
|
+
</div>
|
|
2801
3195
|
</div>
|
|
2802
3196
|
<div class="vendor" id="vendor" hidden></div>
|
|
2803
3197
|
</div>
|
|
2804
3198
|
<div class="controls">
|
|
3199
|
+
<span class="sync-status" id="sync-status" role="status" aria-live="polite">Ready</span>
|
|
2805
3200
|
<label class="field">Namespace
|
|
2806
3201
|
<select id="namespace" aria-label="Namespace"></select>
|
|
2807
3202
|
</label>
|
|
2808
3203
|
<label class="field">Admin key
|
|
2809
3204
|
<input id="admin-key" type="password" autocomplete="off" spellcheck="false" aria-label="Admin key">
|
|
2810
3205
|
</label>
|
|
2811
|
-
<button class="icon-btn" id="theme" type="button" aria-label="
|
|
2812
|
-
<button class="btn" id="refresh" type="button">Refresh</button>
|
|
3206
|
+
<button class="icon-btn" id="theme" type="button" aria-label="Use dark theme" title="Use dark theme"></button>
|
|
3207
|
+
<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>
|
|
2813
3208
|
</div>
|
|
2814
3209
|
</header>
|
|
2815
3210
|
<div class="layout">
|
|
2816
3211
|
<nav class="side" id="nav" aria-label="Admin sections">
|
|
2817
|
-
<
|
|
2818
|
-
<button type="button" data-view="
|
|
2819
|
-
<button type="button" data-view="
|
|
2820
|
-
<button type="button" data-view="
|
|
2821
|
-
<button type="button" data-view="
|
|
2822
|
-
<button type="button" data-view="
|
|
3212
|
+
<span class="nav-label">Workspace</span>
|
|
3213
|
+
<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>
|
|
3214
|
+
<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>
|
|
3215
|
+
<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>
|
|
3216
|
+
<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>
|
|
3217
|
+
<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>
|
|
3218
|
+
<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>
|
|
2823
3219
|
</nav>
|
|
2824
3220
|
<main id="views">
|
|
2825
|
-
<
|
|
3221
|
+
<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>
|
|
2826
3222
|
<section class="view" id="view-overview">
|
|
2827
|
-
<h2>Overview</h2>
|
|
2828
|
-
<p class="lede">Health, clock, and the collections this namespace is holding.</p>
|
|
3223
|
+
<div class="view-head"><div><h2>Overview</h2><p class="lede">Health, clock, and the collections this namespace is holding.</p></div></div>
|
|
2829
3224
|
<div class="grid" id="overview-cards"></div>
|
|
2830
3225
|
<h3>Recent requests</h3>
|
|
2831
3226
|
<div id="overview-requests"></div>
|
|
2832
3227
|
</section>
|
|
2833
3228
|
<section class="view" id="view-state" hidden>
|
|
2834
|
-
<h2>State</h2>
|
|
2835
|
-
<p class="lede">Every collection in this namespace. Declared fields stay visible before any row exists; stored rows add whatever else they contain.</p>
|
|
3229
|
+
<div class="view-head"><div><h2>State</h2><p class="lede">Inspect and edit every collection in this namespace.</p></div></div>
|
|
2836
3230
|
<div class="split">
|
|
2837
3231
|
<div id="collections"></div>
|
|
2838
3232
|
<div>
|
|
@@ -2845,8 +3239,7 @@ var renderAdminDocument = (service) => {
|
|
|
2845
3239
|
</div>
|
|
2846
3240
|
</section>
|
|
2847
3241
|
<section class="view" id="view-clock" hidden>
|
|
2848
|
-
<h2>Clock</h2>
|
|
2849
|
-
<p class="lede">The clock every timestamp in this mock reads.</p>
|
|
3242
|
+
<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>
|
|
2850
3243
|
<div class="card" id="clock-readout"></div>
|
|
2851
3244
|
<div class="row-actions">
|
|
2852
3245
|
<button class="btn" type="button" data-advance="60000">+1m</button>
|
|
@@ -2865,21 +3258,22 @@ var renderAdminDocument = (service) => {
|
|
|
2865
3258
|
</form>
|
|
2866
3259
|
</section>
|
|
2867
3260
|
<section class="view" id="view-faults" hidden>
|
|
2868
|
-
<h2>Faults</h2>
|
|
2869
|
-
<p class="lede">Inject a response, a delay, or a named preset. Rules apply to this namespace.</p>
|
|
3261
|
+
<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>
|
|
2870
3262
|
<form id="fault-form" class="card stack">
|
|
2871
|
-
<
|
|
2872
|
-
<
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
<
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
<
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
<
|
|
2882
|
-
|
|
3263
|
+
<div class="form-grid">
|
|
3264
|
+
<label class="field wide">Preset
|
|
3265
|
+
<select id="fault-preset"><option value="">Custom rule</option></select>
|
|
3266
|
+
</label>
|
|
3267
|
+
<label class="field">Status
|
|
3268
|
+
<input id="fault-status" inputmode="numeric" placeholder="503">
|
|
3269
|
+
</label>
|
|
3270
|
+
<label class="field wide">Path prefix
|
|
3271
|
+
<input id="fault-path" placeholder="/v1">
|
|
3272
|
+
</label>
|
|
3273
|
+
<label class="field">Method
|
|
3274
|
+
<input id="fault-method" placeholder="GET">
|
|
3275
|
+
</label>
|
|
3276
|
+
</div>
|
|
2883
3277
|
<div class="dialog-actions">
|
|
2884
3278
|
<button class="btn-primary" type="submit">Add fault</button>
|
|
2885
3279
|
<button class="btn-danger" id="fault-clear" type="button">Clear all</button>
|
|
@@ -2888,26 +3282,31 @@ var renderAdminDocument = (service) => {
|
|
|
2888
3282
|
<div id="fault-list"></div>
|
|
2889
3283
|
</section>
|
|
2890
3284
|
<section class="view" id="view-journal" hidden>
|
|
2891
|
-
<h2>Journal</h2>
|
|
2892
|
-
<p class="lede">Requests this namespace has seen. Bodies are not stored.</p>
|
|
2893
|
-
<div class="row-actions">
|
|
2894
|
-
<button class="btn-danger" id="journal-clear" type="button">Clear journal</button>
|
|
2895
|
-
</div>
|
|
3285
|
+
<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>
|
|
2896
3286
|
<div id="journal"></div>
|
|
2897
3287
|
</section>
|
|
2898
3288
|
<section class="view" id="view-routes" hidden>
|
|
2899
|
-
<h2>Routes</h2>
|
|
2900
|
-
<
|
|
3289
|
+
<div class="view-head"><div><h2>Routes</h2><p class="lede">Explore shared controls and service-specific admin endpoints.</p></div></div>
|
|
3290
|
+
<div class="route-tools">
|
|
3291
|
+
<label class="field">Filter routes
|
|
3292
|
+
<input id="route-search" type="search" placeholder="Method, path, or extension" autocomplete="off">
|
|
3293
|
+
</label>
|
|
3294
|
+
<span class="route-count" id="route-count"></span>
|
|
3295
|
+
</div>
|
|
2901
3296
|
<div id="route-list"></div>
|
|
2902
|
-
<form id="route-form" class="card stack">
|
|
3297
|
+
<form id="route-form" class="card stack" style="margin-top: 18px">
|
|
2903
3298
|
<strong id="route-title">Select a route</strong>
|
|
2904
3299
|
<label class="field">JSON body
|
|
2905
|
-
<
|
|
3300
|
+
<div class="json-editor" id="route-editor" hidden>
|
|
3301
|
+
<pre aria-hidden="true"><code id="route-highlight"></code></pre>
|
|
3302
|
+
<textarea id="route-body" placeholder="{ }" spellcheck="false" aria-describedby="route-json-status" disabled></textarea>
|
|
3303
|
+
</div>
|
|
3304
|
+
<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>
|
|
2906
3305
|
</label>
|
|
2907
3306
|
<div class="dialog-actions">
|
|
2908
|
-
<button class="btn-primary" type="submit">Send</button>
|
|
3307
|
+
<button class="btn-primary" id="route-send" type="submit" disabled>Send request</button>
|
|
2909
3308
|
</div>
|
|
2910
|
-
<pre id="route-result">Response will show here.</pre>
|
|
3309
|
+
<pre class="json-view" id="route-result"><code>Response will show here.</code></pre>
|
|
2911
3310
|
</form>
|
|
2912
3311
|
</section>
|
|
2913
3312
|
</main>
|
|
@@ -2915,12 +3314,16 @@ var renderAdminDocument = (service) => {
|
|
|
2915
3314
|
</div>
|
|
2916
3315
|
<dialog id="editor">
|
|
2917
3316
|
<form method="dialog" id="editor-form">
|
|
2918
|
-
<strong id="editor-title">Record</strong>
|
|
3317
|
+
<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>
|
|
2919
3318
|
<label class="field">Id
|
|
2920
3319
|
<input id="editor-id" required>
|
|
2921
3320
|
</label>
|
|
2922
3321
|
<label class="field">JSON value
|
|
2923
|
-
<
|
|
3322
|
+
<div class="json-editor" id="record-editor">
|
|
3323
|
+
<pre aria-hidden="true"><code id="editor-highlight"></code></pre>
|
|
3324
|
+
<textarea id="editor-value" spellcheck="false" aria-describedby="editor-json-status"></textarea>
|
|
3325
|
+
</div>
|
|
3326
|
+
<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>
|
|
2924
3327
|
</label>
|
|
2925
3328
|
<div class="dialog-actions">
|
|
2926
3329
|
<button class="btn-danger" id="editor-delete" type="button" value="delete">Delete</button>
|
|
@@ -2962,8 +3365,8 @@ var describeExtension = (extension) => {
|
|
|
2962
3365
|
...extension.script !== void 0 ? { script: extension.script } : {}
|
|
2963
3366
|
};
|
|
2964
3367
|
};
|
|
2965
|
-
var adminUiRoutes = (service, ui) => {
|
|
2966
|
-
const shell = () => renderAdminDocument(service);
|
|
3368
|
+
var adminUiRoutes = (service, ui, adminPrefix = ADMIN_PREFIX) => {
|
|
3369
|
+
const shell = () => renderAdminDocument(service, adminPrefix);
|
|
2967
3370
|
const document3 = () => ui?.render ? ui.render({ service, defaultHtml: shell }) : shell();
|
|
2968
3371
|
return {
|
|
2969
3372
|
"GET /ui": () => htmlResponse(document3()),
|
|
@@ -3884,10 +4287,13 @@ var stateAdminRoutes = (routes) => {
|
|
|
3884
4287
|
};
|
|
3885
4288
|
|
|
3886
4289
|
// ../core/dist/version.js
|
|
3887
|
-
var PACKAGE_VERSION = true ? "
|
|
4290
|
+
var PACKAGE_VERSION = true ? "2.0.0" : UNRELEASED_VERSION;
|
|
3888
4291
|
|
|
3889
4292
|
// ../core/dist/webhooks.js
|
|
3890
|
-
var json4 = (status, body) => new Response(JSON.stringify(body), {
|
|
4293
|
+
var json4 = (status, body) => new Response(JSON.stringify(body), {
|
|
4294
|
+
status,
|
|
4295
|
+
headers: { "content-type": "application/json; charset=utf-8" }
|
|
4296
|
+
});
|
|
3891
4297
|
var adminError3 = (status, message) => json4(status, { error: { type: "mockingbird_admin", message } });
|
|
3892
4298
|
var isRecord4 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3893
4299
|
var parseEndpoint = (value) => {
|
|
@@ -3995,7 +4401,6 @@ var AT_HEADER = "x-mockingbird-at";
|
|
|
3995
4401
|
var CHECKPOINT_HEADER = "x-mockingbird-checkpoint";
|
|
3996
4402
|
var DEFAULT_NAMESPACE = "default";
|
|
3997
4403
|
var NAMESPACE_PATTERN = /^[A-Za-z0-9_.-]{1,64}$/;
|
|
3998
|
-
var PATH_PREFIX = /^\/ns\/([^/]+)(\/.*)?$/;
|
|
3999
4404
|
var BRANCH_PATTERN2 = /^[A-Za-z0-9_.-]{1,64}$/;
|
|
4000
4405
|
var MUTATING_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
4001
4406
|
var effects = /* @__PURE__ */ new WeakMap();
|
|
@@ -4034,6 +4439,22 @@ var operationMatcher = (document3) => {
|
|
|
4034
4439
|
return (request, path) => matchers.find((m) => m.method === request.method && m.pattern.test(path))?.operationId;
|
|
4035
4440
|
};
|
|
4036
4441
|
var createRuntime = (options) => {
|
|
4442
|
+
const adminPrefix = resolveAdminPrefix(options.adminPrefix);
|
|
4443
|
+
const internalPaths = Object.entries(options.document?.paths ?? {}).filter(([, item]) => item["x-mockingbird-internal"] === true).map(([path]) => path);
|
|
4444
|
+
for (const path of internalPaths) {
|
|
4445
|
+
if (!path.startsWith(`${ADMIN_PREFIX}/blobs/`))
|
|
4446
|
+
throw new Error(`internal transport ${path} must live under ${ADMIN_PREFIX}/blobs/`);
|
|
4447
|
+
}
|
|
4448
|
+
assertAdminPrefixAvailable(adminPrefix, Object.keys(options.document?.paths ?? {}).filter((path) => !internalPaths.includes(path)));
|
|
4449
|
+
const internalMatch = (path) => {
|
|
4450
|
+
for (const pattern of internalPaths) {
|
|
4451
|
+
const actual = path.split("/");
|
|
4452
|
+
const expected = pattern.split("/");
|
|
4453
|
+
if (actual.length === expected.length && expected.every((part, i) => part.startsWith("{") || part === actual[i]))
|
|
4454
|
+
return pattern;
|
|
4455
|
+
}
|
|
4456
|
+
return void 0;
|
|
4457
|
+
};
|
|
4037
4458
|
assertAdminUi(options.adminUi);
|
|
4038
4459
|
const sqlite = bootSqlite(options.sqlite);
|
|
4039
4460
|
const clock = options.clock ?? createClock();
|
|
@@ -4048,6 +4469,20 @@ var createRuntime = (options) => {
|
|
|
4048
4469
|
const instances = /* @__PURE__ */ new Map();
|
|
4049
4470
|
const publicNamespaces = /* @__PURE__ */ new Set();
|
|
4050
4471
|
const branchRngs = /* @__PURE__ */ new Map();
|
|
4472
|
+
const namespaceClocks = /* @__PURE__ */ new Map();
|
|
4473
|
+
const namespaceForStorage = /* @__PURE__ */ new Map();
|
|
4474
|
+
let isolatedNamespaces = false;
|
|
4475
|
+
const namespaceClock = (name = DEFAULT_NAMESPACE) => {
|
|
4476
|
+
if (!isolatedNamespaces || name === DEFAULT_NAMESPACE)
|
|
4477
|
+
return clock;
|
|
4478
|
+
let found = namespaceClocks.get(name);
|
|
4479
|
+
if (!found) {
|
|
4480
|
+
found = createClock();
|
|
4481
|
+
namespaceClocks.set(name, found);
|
|
4482
|
+
}
|
|
4483
|
+
return found;
|
|
4484
|
+
};
|
|
4485
|
+
const fleetPoints = /* @__PURE__ */ new WeakMap();
|
|
4051
4486
|
const timelines = /* @__PURE__ */ new Map();
|
|
4052
4487
|
const branchStorage = /* @__PURE__ */ new Map();
|
|
4053
4488
|
const captured = /* @__PURE__ */ new Map();
|
|
@@ -4061,17 +4496,22 @@ var createRuntime = (options) => {
|
|
|
4061
4496
|
if (!NAMESPACE_PATTERN.test(key) || !NAMESPACE_PATTERN.test(publicNamespace)) {
|
|
4062
4497
|
throw new RangeError(`namespace must match ${NAMESPACE_PATTERN}: ${JSON.stringify(publicNamespace)}`);
|
|
4063
4498
|
}
|
|
4499
|
+
const createdContextRng = isolatedRng ?? (isolatedNamespaces && key !== DEFAULT_NAMESPACE ? createRng(options.seed ?? 0) : rng);
|
|
4064
4500
|
const created = options.create({
|
|
4065
4501
|
namespace: storageNamespace(key),
|
|
4066
4502
|
publicNamespace,
|
|
4503
|
+
adminPrefix,
|
|
4067
4504
|
sqlite,
|
|
4068
|
-
clock,
|
|
4069
|
-
rng:
|
|
4505
|
+
clock: namespaceClock(publicNamespace),
|
|
4506
|
+
rng: createdContextRng
|
|
4070
4507
|
});
|
|
4071
4508
|
instances.set(key, created);
|
|
4509
|
+
namespaceForStorage.set(key, publicNamespace);
|
|
4072
4510
|
publicNamespaces.add(publicNamespace);
|
|
4073
4511
|
if (isolatedRng)
|
|
4074
4512
|
branchRngs.set(key, isolatedRng);
|
|
4513
|
+
else if (isolatedNamespaces && key !== DEFAULT_NAMESPACE)
|
|
4514
|
+
branchRngs.set(key, createdContextRng);
|
|
4075
4515
|
return created;
|
|
4076
4516
|
};
|
|
4077
4517
|
const instance = (name = DEFAULT_NAMESPACE) => instanceFor(name);
|
|
@@ -4089,7 +4529,7 @@ var createRuntime = (options) => {
|
|
|
4089
4529
|
captured.set(storage, snapshot2);
|
|
4090
4530
|
return Object.freeze({
|
|
4091
4531
|
snapshot: snapshot2,
|
|
4092
|
-
clock: Object.freeze(
|
|
4532
|
+
clock: Object.freeze(namespaceClock(namespaceForStorage.get(storage) ?? storage).state()),
|
|
4093
4533
|
rngState: (branchRngs.get(storage) ?? rng).state()
|
|
4094
4534
|
});
|
|
4095
4535
|
};
|
|
@@ -4099,7 +4539,7 @@ var createRuntime = (options) => {
|
|
|
4099
4539
|
return found;
|
|
4100
4540
|
instance(name);
|
|
4101
4541
|
found = new Timeline({
|
|
4102
|
-
now:
|
|
4542
|
+
now: namespaceClock(name).now,
|
|
4103
4543
|
...options.maxCheckpoints !== void 0 ? { maxCheckpoints: options.maxCheckpoints } : {}
|
|
4104
4544
|
});
|
|
4105
4545
|
found.commit(capture(name));
|
|
@@ -4126,12 +4566,13 @@ var createRuntime = (options) => {
|
|
|
4126
4566
|
const point = history2.checkout("main", at);
|
|
4127
4567
|
restoreNamespace(sqlite, storageNamespace(namespace), point.value.snapshot);
|
|
4128
4568
|
captured.set(namespace, point.value.snapshot);
|
|
4129
|
-
rng.setState(point.value.rngState);
|
|
4130
|
-
|
|
4569
|
+
(branchRngs.get(namespace) ?? rng).setState(point.value.rngState);
|
|
4570
|
+
const selectedClock = namespaceClock(namespace);
|
|
4571
|
+
selectedClock.set(point.value.clock.now);
|
|
4131
4572
|
if (point.value.clock.frozen)
|
|
4132
|
-
|
|
4573
|
+
selectedClock.freeze();
|
|
4133
4574
|
else
|
|
4134
|
-
|
|
4575
|
+
selectedClock.unfreeze();
|
|
4135
4576
|
}
|
|
4136
4577
|
return namespace;
|
|
4137
4578
|
}
|
|
@@ -4189,11 +4630,12 @@ var createRuntime = (options) => {
|
|
|
4189
4630
|
const storage = ensureBranch(namespace, branchName);
|
|
4190
4631
|
restoreNamespace(sqlite, storageNamespace(storage), point.value.snapshot);
|
|
4191
4632
|
captured.set(storage, point.value.snapshot);
|
|
4192
|
-
|
|
4633
|
+
const selectedClock = namespaceClock(namespace);
|
|
4634
|
+
selectedClock.set(point.value.clock.now);
|
|
4193
4635
|
if (point.value.clock.frozen)
|
|
4194
|
-
|
|
4636
|
+
selectedClock.freeze();
|
|
4195
4637
|
else
|
|
4196
|
-
|
|
4638
|
+
selectedClock.unfreeze();
|
|
4197
4639
|
(branchRngs.get(storage) ?? rng).setState(point.value.rngState);
|
|
4198
4640
|
};
|
|
4199
4641
|
const reset = async (name = DEFAULT_NAMESPACE) => {
|
|
@@ -4201,10 +4643,21 @@ var createRuntime = (options) => {
|
|
|
4201
4643
|
options.webhooks?.clear();
|
|
4202
4644
|
for (const each of instances.values())
|
|
4203
4645
|
await each.reset();
|
|
4646
|
+
for (const each of branchRngs.values())
|
|
4647
|
+
each.reset();
|
|
4204
4648
|
timelines.clear();
|
|
4649
|
+
for (const storage of branchStorage.values()) {
|
|
4650
|
+
instances.delete(storage);
|
|
4651
|
+
branchRngs.delete(storage);
|
|
4652
|
+
namespaceForStorage.delete(storage);
|
|
4653
|
+
}
|
|
4205
4654
|
branchStorage.clear();
|
|
4206
|
-
branchRngs.clear();
|
|
4207
4655
|
captured.clear();
|
|
4656
|
+
faults.clear();
|
|
4657
|
+
clock.reset();
|
|
4658
|
+
rng.reset();
|
|
4659
|
+
for (const each of namespaceClocks.values())
|
|
4660
|
+
each.reset();
|
|
4208
4661
|
return;
|
|
4209
4662
|
}
|
|
4210
4663
|
options.webhooks?.clear(name);
|
|
@@ -4222,11 +4675,18 @@ var createRuntime = (options) => {
|
|
|
4222
4675
|
else
|
|
4223
4676
|
clearNamespace(sqlite, storageNamespace(storage));
|
|
4224
4677
|
branchStorage.delete(mapping);
|
|
4678
|
+
instances.delete(storage);
|
|
4679
|
+
namespaceForStorage.delete(storage);
|
|
4225
4680
|
branchRngs.delete(storage);
|
|
4226
4681
|
captured.delete(storage);
|
|
4227
4682
|
}
|
|
4228
4683
|
timelines.delete(name);
|
|
4229
4684
|
captured.delete(name);
|
|
4685
|
+
faults.restore(name, { rules: [], rngState: createRng(options.seed ?? 0).state() });
|
|
4686
|
+
namespaceClock(name).reset();
|
|
4687
|
+
(branchRngs.get(name) ?? rng).reset();
|
|
4688
|
+
journal.clear(name);
|
|
4689
|
+
metrics.reset(name);
|
|
4230
4690
|
};
|
|
4231
4691
|
const snapshot = (name = DEFAULT_NAMESPACE) => {
|
|
4232
4692
|
return checkpoint(name, "main").value.snapshot;
|
|
@@ -4264,6 +4724,53 @@ var createRuntime = (options) => {
|
|
|
4264
4724
|
rng,
|
|
4265
4725
|
credentials,
|
|
4266
4726
|
webhooks: options.webhooks,
|
|
4727
|
+
isolateNamespaces() {
|
|
4728
|
+
if (publicNamespaces.size > 1)
|
|
4729
|
+
throw new Error("enable namespace isolation before creating named namespaces");
|
|
4730
|
+
isolatedNamespaces = true;
|
|
4731
|
+
faults.isolateNamespaces();
|
|
4732
|
+
},
|
|
4733
|
+
namespaceClock,
|
|
4734
|
+
namespaceOf(request) {
|
|
4735
|
+
const url = new URL(request.url);
|
|
4736
|
+
const prefixed = matchNamespacePath(url.pathname, adminPrefix);
|
|
4737
|
+
const selected = request.headers.get(NAMESPACE_HEADER) ?? (prefixed ? decodeURIComponent(prefixed[1]) : void 0);
|
|
4738
|
+
const path = prefixed ? prefixed[2] ?? "/" : url.pathname;
|
|
4739
|
+
if (isAdminPath(path, adminPrefix))
|
|
4740
|
+
return url.searchParams.get("namespace") ?? selected ?? DEFAULT_NAMESPACE;
|
|
4741
|
+
if (selected !== void 0)
|
|
4742
|
+
return selected;
|
|
4743
|
+
const credential = options.credential?.(request);
|
|
4744
|
+
return (credential !== void 0 ? credentials.get(credential) : void 0) ?? DEFAULT_NAMESPACE;
|
|
4745
|
+
},
|
|
4746
|
+
fleetSnapshot(namespace) {
|
|
4747
|
+
instance(namespace);
|
|
4748
|
+
const handle = {};
|
|
4749
|
+
fleetPoints.set(handle, {
|
|
4750
|
+
namespace,
|
|
4751
|
+
state: capture(namespace),
|
|
4752
|
+
faults: faults.snapshot(namespace),
|
|
4753
|
+
webhooks: options.webhooks?.snapshot(namespace)
|
|
4754
|
+
});
|
|
4755
|
+
return handle;
|
|
4756
|
+
},
|
|
4757
|
+
fleetRestore(handle, namespace) {
|
|
4758
|
+
const point = typeof handle === "object" && handle !== null ? fleetPoints.get(handle) : void 0;
|
|
4759
|
+
if (!point || point.namespace !== namespace)
|
|
4760
|
+
throw new Error("invalid fleet checkpoint");
|
|
4761
|
+
restoreNamespace(sqlite, storageNamespace(namespace), point.state.snapshot);
|
|
4762
|
+
captured.set(namespace, point.state.snapshot);
|
|
4763
|
+
const selectedClock = namespaceClock(namespace);
|
|
4764
|
+
selectedClock.freeze();
|
|
4765
|
+
selectedClock.set(point.state.clock.now);
|
|
4766
|
+
if (!point.state.clock.frozen)
|
|
4767
|
+
selectedClock.unfreeze();
|
|
4768
|
+
(branchRngs.get(namespace) ?? rng).setState(point.state.rngState);
|
|
4769
|
+
faults.restore(namespace, point.faults);
|
|
4770
|
+
if (point.webhooks !== void 0)
|
|
4771
|
+
options.webhooks?.restore(namespace, point.webhooks);
|
|
4772
|
+
timelines.delete(namespace);
|
|
4773
|
+
},
|
|
4267
4774
|
applyPreset: (name, namespace = DEFAULT_NAMESPACE, overrides = {}) => {
|
|
4268
4775
|
const preset = options.presets?.[name];
|
|
4269
4776
|
if (!preset)
|
|
@@ -4295,7 +4802,7 @@ var createRuntime = (options) => {
|
|
|
4295
4802
|
state: (namespace = DEFAULT_NAMESPACE) => inspectState(stateScope(namespace)),
|
|
4296
4803
|
fetch: async (incoming) => {
|
|
4297
4804
|
let request = incoming;
|
|
4298
|
-
const prefixed =
|
|
4805
|
+
const prefixed = matchNamespacePath(new URL(request.url).pathname, adminPrefix);
|
|
4299
4806
|
if (prefixed) {
|
|
4300
4807
|
const url2 = new URL(request.url);
|
|
4301
4808
|
url2.pathname = prefixed[2] ?? "/";
|
|
@@ -4311,7 +4818,15 @@ var createRuntime = (options) => {
|
|
|
4311
4818
|
signal: request.signal
|
|
4312
4819
|
});
|
|
4313
4820
|
}
|
|
4314
|
-
|
|
4821
|
+
const internalUrl = new URL(request.url);
|
|
4822
|
+
const internalPath = isAdminPath(internalUrl.pathname, adminPrefix) ? `${ADMIN_PREFIX}${internalUrl.pathname.slice(adminPrefix.length)}` : "";
|
|
4823
|
+
const internal = internalMatch(internalPath);
|
|
4824
|
+
const obsoleteInternalPath = adminPrefix !== ADMIN_PREFIX && internalMatch(internalUrl.pathname) && !internal;
|
|
4825
|
+
if (internal) {
|
|
4826
|
+
internalUrl.pathname = internalPath;
|
|
4827
|
+
request = new Request(internalUrl, request);
|
|
4828
|
+
}
|
|
4829
|
+
let namespace = internal ? internalUrl.searchParams.get("namespace") ?? control.namespaceOf(request) : control.namespaceOf(request);
|
|
4315
4830
|
if (!request.headers.has(NAMESPACE_HEADER) && options.credential) {
|
|
4316
4831
|
const credential = options.credential(request);
|
|
4317
4832
|
const mapped = credential !== void 0 ? credentials.get(credential) : void 0;
|
|
@@ -4331,7 +4846,9 @@ var createRuntime = (options) => {
|
|
|
4331
4846
|
return copy;
|
|
4332
4847
|
}
|
|
4333
4848
|
};
|
|
4334
|
-
|
|
4849
|
+
if (obsoleteInternalPath)
|
|
4850
|
+
return stamp(adminFail(404, "internal transport is only available under adminPrefix"));
|
|
4851
|
+
const handled = internal ? void 0 : await control.handle(request);
|
|
4335
4852
|
if (handled)
|
|
4336
4853
|
return stamp(handled);
|
|
4337
4854
|
const started = monotonicNow();
|
|
@@ -4372,7 +4889,7 @@ var createRuntime = (options) => {
|
|
|
4372
4889
|
type: "mockingbird_admin",
|
|
4373
4890
|
message: `${NAMESPACE_HEADER} must match ${NAMESPACE_PATTERN}`
|
|
4374
4891
|
}
|
|
4375
|
-
}), { status: 400, headers: { "content-type": "application/json" } }));
|
|
4892
|
+
}), { status: 400, headers: { "content-type": "application/json; charset=utf-8" } }));
|
|
4376
4893
|
}
|
|
4377
4894
|
if (!BRANCH_PATTERN2.test(selectedBranch)) {
|
|
4378
4895
|
log(400);
|
|
@@ -4439,6 +4956,7 @@ var createRuntime = (options) => {
|
|
|
4439
4956
|
startedAt: wallNow(),
|
|
4440
4957
|
wallNow,
|
|
4441
4958
|
clock,
|
|
4959
|
+
namespaceClock,
|
|
4442
4960
|
faults,
|
|
4443
4961
|
metrics,
|
|
4444
4962
|
journal,
|
|
@@ -4488,15 +5006,16 @@ var createRuntime = (options) => {
|
|
|
4488
5006
|
...options.admin?.(runtime) ?? {},
|
|
4489
5007
|
// Shell routes win a colliding key. Fault presets are one of them: the admin page
|
|
4490
5008
|
// maps `presets`, and a service route must not replace that list with a record.
|
|
4491
|
-
...options.presets ? presetRoutes(options.presets, runtime) : {},
|
|
5009
|
+
...options.presets ? presetRoutes(options.presets, runtime, adminPrefix) : {},
|
|
4492
5010
|
...stateAdminRoutes({
|
|
4493
5011
|
open: stateScope,
|
|
4494
5012
|
afterWrite: (namespace) => {
|
|
4495
5013
|
checkpoint(namespace, "main");
|
|
4496
5014
|
}
|
|
4497
5015
|
}),
|
|
4498
|
-
...adminUiRoutes(options.name, options.adminUi)
|
|
5016
|
+
...adminUiRoutes(options.name, options.adminUi, adminPrefix)
|
|
4499
5017
|
},
|
|
5018
|
+
adminPrefix,
|
|
4500
5019
|
adminKey: options.adminKey
|
|
4501
5020
|
});
|
|
4502
5021
|
return defineMock(runtime);
|
|
@@ -4510,7 +5029,10 @@ var mutableResponse = (response) => {
|
|
|
4510
5029
|
return new Response(response.body, response);
|
|
4511
5030
|
}
|
|
4512
5031
|
};
|
|
4513
|
-
var adminJson = (status, body) => new Response(JSON.stringify(body), {
|
|
5032
|
+
var adminJson = (status, body) => new Response(JSON.stringify(body), {
|
|
5033
|
+
status,
|
|
5034
|
+
headers: { "content-type": "application/json; charset=utf-8" }
|
|
5035
|
+
});
|
|
4514
5036
|
var adminFail = (status, message) => adminJson(status, { error: { type: "mockingbird_admin", message } });
|
|
4515
5037
|
var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4516
5038
|
var credentialRoutes = (registry) => ({
|
|
@@ -4562,7 +5084,7 @@ var credentialRoutes = (registry) => ({
|
|
|
4562
5084
|
return adminJson(200, { status: "ok" });
|
|
4563
5085
|
}
|
|
4564
5086
|
});
|
|
4565
|
-
var presetRoutes = (presets, runtime) => ({
|
|
5087
|
+
var presetRoutes = (presets, runtime, adminPrefix) => ({
|
|
4566
5088
|
"GET /faults/presets": () => {
|
|
4567
5089
|
const body = {
|
|
4568
5090
|
presets: Object.entries(presets).map(([name, preset]) => ({ name, ...preset }))
|
|
@@ -4572,7 +5094,7 @@ var presetRoutes = (presets, runtime) => ({
|
|
|
4572
5094
|
"POST /faults/presets/:name": ({ params, body, namespace }) => {
|
|
4573
5095
|
const name = params.name;
|
|
4574
5096
|
if (!presets[name])
|
|
4575
|
-
return adminFail(404, `no fault preset ${name}; GET /
|
|
5097
|
+
return adminFail(404, `no fault preset ${name}; GET ${adminPrefix}/faults/presets`);
|
|
4576
5098
|
const overrides = isObject(body) ? body : {};
|
|
4577
5099
|
return adminJson(201, { preset: name, rules: runtime.applyPreset(name, namespace, overrides) });
|
|
4578
5100
|
}
|
|
@@ -5109,9 +5631,10 @@ function createRuntime2(options = {}) {
|
|
|
5109
5631
|
presets: OAUTH_PRESETS,
|
|
5110
5632
|
...options.sqlite ? { sqlite: options.sqlite } : {},
|
|
5111
5633
|
...options.clock ? { clock: options.clock } : {},
|
|
5634
|
+
...options.adminPrefix !== void 0 ? { adminPrefix: options.adminPrefix } : {},
|
|
5112
5635
|
...options.adminKey !== void 0 ? { adminKey: options.adminKey } : {},
|
|
5113
5636
|
...options.seed !== void 0 ? { seed: options.seed } : {},
|
|
5114
|
-
create: ({ sqlite, namespace, publicNamespace, clock }) => new OAuthAPI({ ...options, sqlite, namespace, publicNamespace, now: clock.now }),
|
|
5637
|
+
create: ({ sqlite, namespace, publicNamespace, adminPrefix, clock }) => new OAuthAPI({ ...options, sqlite, namespace, publicNamespace, adminPrefix, now: clock.now }),
|
|
5115
5638
|
admin: (runtime) => ({
|
|
5116
5639
|
"GET /scenarios": () => Response.json(OAUTH_SCENARIOS),
|
|
5117
5640
|
"GET /behavior": ({ namespace }) => Response.json({
|
|
@@ -5227,7 +5750,6 @@ function createRuntime2(options = {}) {
|
|
|
5227
5750
|
|
|
5228
5751
|
// src/multi.ts
|
|
5229
5752
|
var MOUNT_PATTERN = /^\/[A-Za-z0-9](?:[A-Za-z0-9._~-]*)(?:\/[A-Za-z0-9](?:[A-Za-z0-9._~-]*))*$/;
|
|
5230
|
-
var NS_PREFIX = /^\/ns\/([^/]+)(\/.*)?$/;
|
|
5231
5753
|
var json5 = (body, status = 200) => Response.json(body, { status, headers: { "cache-control": "no-store" } });
|
|
5232
5754
|
var validateMounts = (mounts) => {
|
|
5233
5755
|
if (mounts.length === 0) throw new Error("at least one OAuth mount is required");
|
|
@@ -5265,6 +5787,14 @@ var withoutMount = (request, namespacePrefix, rest) => {
|
|
|
5265
5787
|
return new Request(url, request);
|
|
5266
5788
|
};
|
|
5267
5789
|
function createMultiRuntime(options) {
|
|
5790
|
+
const adminPrefix = resolveAdminPrefix(options.adminPrefix);
|
|
5791
|
+
assertAdminPrefixAvailable(
|
|
5792
|
+
adminPrefix,
|
|
5793
|
+
options.mounts.map((mount) => mount.path)
|
|
5794
|
+
);
|
|
5795
|
+
for (const mount of options.mounts) {
|
|
5796
|
+
if (isAdminPath(adminPrefix, mount.path)) throw new Error("OAuth mount overlaps adminPrefix");
|
|
5797
|
+
}
|
|
5268
5798
|
validateMounts(options.mounts);
|
|
5269
5799
|
const clock = options.clock ?? createClock();
|
|
5270
5800
|
const runtimes = /* @__PURE__ */ new Map();
|
|
@@ -5272,6 +5802,7 @@ function createMultiRuntime(options) {
|
|
|
5272
5802
|
const { path, ...runtimeOptions } = mount;
|
|
5273
5803
|
const runtime = createRuntime2({
|
|
5274
5804
|
...runtimeOptions,
|
|
5805
|
+
adminPrefix,
|
|
5275
5806
|
clock,
|
|
5276
5807
|
...options.sqlite ? { sqlite: options.sqlite } : {},
|
|
5277
5808
|
...runtimeOptions.adminKey ?? options.adminKey ? { adminKey: runtimeOptions.adminKey ?? options.adminKey } : {},
|
|
@@ -5292,27 +5823,27 @@ function createMultiRuntime(options) {
|
|
|
5292
5823
|
reset,
|
|
5293
5824
|
async fetch(request) {
|
|
5294
5825
|
const url = new URL(request.url);
|
|
5295
|
-
if (request.method === "GET" && url.pathname ===
|
|
5826
|
+
if (request.method === "GET" && url.pathname === `${adminPrefix}/health`) {
|
|
5296
5827
|
const health = await Promise.all(
|
|
5297
5828
|
[...runtimes.entries()].map(async ([path2, runtime]) => [
|
|
5298
5829
|
path2,
|
|
5299
|
-
await (await runtime.fetch(new Request(new URL(
|
|
5830
|
+
await (await runtime.fetch(new Request(new URL(`${adminPrefix}/health`, url), request))).json()
|
|
5300
5831
|
])
|
|
5301
5832
|
);
|
|
5302
5833
|
return json5({ status: "ok", service: "oauth", mounts: Object.fromEntries(health) });
|
|
5303
5834
|
}
|
|
5304
|
-
if (url.pathname.
|
|
5835
|
+
if (isAdminPath(url.pathname, adminPrefix) && !matchNamespacePath(url.pathname, adminPrefix) && options.adminKey !== void 0 && request.headers.get("x-mockingbird-admin-key") !== options.adminKey)
|
|
5305
5836
|
return json5({ error: { type: "mockingbird_admin", message: "invalid admin key" } }, 401);
|
|
5306
|
-
if (url.pathname ===
|
|
5837
|
+
if (url.pathname === `${adminPrefix}/mounts` && request.method === "GET")
|
|
5307
5838
|
return json5({
|
|
5308
5839
|
mounts: options.mounts.map(({ path: path2, provider, issuer }) => ({ path: path2, provider, issuer }))
|
|
5309
5840
|
});
|
|
5310
|
-
if (url.pathname ===
|
|
5841
|
+
if (url.pathname === `${adminPrefix}/reset` && request.method === "POST") {
|
|
5311
5842
|
const namespace2 = url.searchParams.get("all") === "1" ? "*" : url.searchParams.get("namespace") ?? request.headers.get("x-mockingbird-namespace") ?? "default";
|
|
5312
5843
|
await reset(namespace2);
|
|
5313
5844
|
return json5({ status: "ok", mounts: [...runtimes.keys()], namespace: namespace2 });
|
|
5314
5845
|
}
|
|
5315
|
-
if (url.pathname.
|
|
5846
|
+
if (isAdminPath(url.pathname, adminPrefix) && !matchNamespacePath(url.pathname, adminPrefix)) {
|
|
5316
5847
|
const selected = url.searchParams.get("mount");
|
|
5317
5848
|
const runtime = selected && runtimes.get(selected);
|
|
5318
5849
|
if (!runtime)
|
|
@@ -5320,8 +5851,8 @@ function createMultiRuntime(options) {
|
|
|
5320
5851
|
url.searchParams.delete("mount");
|
|
5321
5852
|
return runtime.fetch(new Request(url, request));
|
|
5322
5853
|
}
|
|
5323
|
-
const namespace =
|
|
5324
|
-
const namespacePrefix = namespace ?
|
|
5854
|
+
const namespace = matchNamespacePath(url.pathname, adminPrefix);
|
|
5855
|
+
const namespacePrefix = namespace ? `${adminPrefix}/ns/${namespace[1]}` : "";
|
|
5325
5856
|
const path = namespace ? namespace[2] ?? "/" : url.pathname;
|
|
5326
5857
|
for (const [mount, runtime] of runtimes) {
|
|
5327
5858
|
if (path !== mount && !path.startsWith(`${mount}/`)) continue;
|
|
@@ -5459,7 +5990,7 @@ var OAuthAPI = class {
|
|
|
5459
5990
|
this.seed();
|
|
5460
5991
|
}
|
|
5461
5992
|
issuer(request) {
|
|
5462
|
-
const prefix = this.options.publicNamespace && this.options.publicNamespace !== "default" ?
|
|
5993
|
+
const prefix = this.options.publicNamespace && this.options.publicNamespace !== "default" ? `${this.options.adminPrefix ?? "/__admin"}/ns/${encodeURIComponent(this.options.publicNamespace)}` : "";
|
|
5463
5994
|
return (this.options.issuer ?? `${new URL(request.url).origin}${prefix}${this.options.mountPath ?? ""}`).replace(/\/$/, "");
|
|
5464
5995
|
}
|
|
5465
5996
|
configureBehavior(input) {
|
|
@@ -6516,6 +7047,9 @@ var OAuthAPI = class {
|
|
|
6516
7047
|
};
|
|
6517
7048
|
|
|
6518
7049
|
export {
|
|
7050
|
+
createClock,
|
|
7051
|
+
resolveAdminPrefix,
|
|
7052
|
+
parseDuration,
|
|
6519
7053
|
OAUTH_SCENARIOS,
|
|
6520
7054
|
document2 as document,
|
|
6521
7055
|
operationIds,
|
|
@@ -6525,4 +7059,4 @@ export {
|
|
|
6525
7059
|
createMultiRuntime,
|
|
6526
7060
|
OAuthAPI
|
|
6527
7061
|
};
|
|
6528
|
-
//# sourceMappingURL=chunk-
|
|
7062
|
+
//# sourceMappingURL=chunk-I76SEED6.js.map
|