@ultimat3/admin 1.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/LICENSE +21 -0
- package/README.md +115 -0
- package/package.json +48 -0
- package/src/action-gate.ts +202 -0
- package/src/actions.tsx +94 -0
- package/src/admin.ts +186 -0
- package/src/ai-panes.ts +139 -0
- package/src/audit.ts +183 -0
- package/src/authz.ts +129 -0
- package/src/crud.ts +278 -0
- package/src/detail.tsx +121 -0
- package/src/dev/data.ts +344 -0
- package/src/dev/facts.ts +180 -0
- package/src/dev/index.ts +47 -0
- package/src/dev/panel-cache.ts +47 -0
- package/src/dev/panel-db.ts +59 -0
- package/src/dev/panel-jobs.ts +66 -0
- package/src/dev/panel-live.ts +46 -0
- package/src/dev/panel-mail.ts +51 -0
- package/src/dev/panel-manifest.ts +39 -0
- package/src/dev/panel-policy.ts +61 -0
- package/src/dev/panel-routes.ts +43 -0
- package/src/dev/panel-timeline.ts +82 -0
- package/src/dev/panel.ts +58 -0
- package/src/dev/server.ts +189 -0
- package/src/entity-columns.ts +95 -0
- package/src/errors.ts +145 -0
- package/src/fields.ts +151 -0
- package/src/form.tsx +97 -0
- package/src/index.ts +214 -0
- package/src/layout.tsx +104 -0
- package/src/list.tsx +120 -0
- package/src/mcp-tools.ts +201 -0
- package/src/mcp.ts +304 -0
- package/src/nav.ts +97 -0
- package/src/pagination.ts +152 -0
- package/src/permissions.ts +92 -0
- package/src/policy-bridge.ts +64 -0
- package/src/registry.ts +181 -0
- package/src/resource.ts +321 -0
- package/src/routes.ts +35 -0
- package/src/search.ts +108 -0
- package/src/theme.ts +59 -0
- package/src/validate.ts +65 -0
- package/src/widget-value.ts +217 -0
- package/src/widgets.tsx +262 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Panel: Jobs.
|
|
2
|
+
// Kills: "is the queue moving, and which step failed?" — queue depth, per-run step traces,
|
|
3
|
+
// the retry-from-step target, and the dead letter.
|
|
4
|
+
|
|
5
|
+
import type { JobDefFact, JobRunFact, QueueFact, TaskFact } from './facts';
|
|
6
|
+
import type { DevPanel } from './panel';
|
|
7
|
+
|
|
8
|
+
export interface RetryTarget {
|
|
9
|
+
readonly runId: string;
|
|
10
|
+
readonly job: string;
|
|
11
|
+
/** The first failed step: retrying from here replays nothing that already succeeded. */
|
|
12
|
+
readonly fromStep: string;
|
|
13
|
+
readonly attempt: number;
|
|
14
|
+
readonly error: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface JobsPanelData {
|
|
18
|
+
readonly queues: readonly QueueFact[];
|
|
19
|
+
readonly jobs: readonly JobDefFact[];
|
|
20
|
+
readonly tasks: readonly TaskFact[];
|
|
21
|
+
readonly runs: readonly JobRunFact[];
|
|
22
|
+
readonly deadLetter: readonly JobRunFact[];
|
|
23
|
+
readonly retryTargets: readonly RetryTarget[];
|
|
24
|
+
readonly totalDepth: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const firstFailedStep = (run: JobRunFact): RetryTarget | null => {
|
|
28
|
+
const step = run.steps.find((candidate) => candidate.status === 'failed');
|
|
29
|
+
if (step === undefined) return null;
|
|
30
|
+
return {
|
|
31
|
+
runId: run.id,
|
|
32
|
+
job: run.job,
|
|
33
|
+
fromStep: step.name,
|
|
34
|
+
attempt: step.attempt,
|
|
35
|
+
error: step.error ?? '',
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const jobsPanel: DevPanel<JobsPanelData> = {
|
|
40
|
+
key: 'jobs',
|
|
41
|
+
titleKey: 'dev.panel.jobs',
|
|
42
|
+
question: 'is the queue moving, and which step failed?',
|
|
43
|
+
async data(sources, params): Promise<JobsPanelData> {
|
|
44
|
+
const [queues, jobs, tasks, runs] = await Promise.all([
|
|
45
|
+
sources.queues(),
|
|
46
|
+
sources.jobDefs(),
|
|
47
|
+
sources.tasks(),
|
|
48
|
+
sources.jobRuns(),
|
|
49
|
+
]);
|
|
50
|
+
const queueFilter = params.get('queue');
|
|
51
|
+
const scoped = queueFilter === null ? runs : runs.filter((run) => run.queue === queueFilter);
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
queues,
|
|
55
|
+
jobs,
|
|
56
|
+
tasks,
|
|
57
|
+
runs: scoped,
|
|
58
|
+
deadLetter: scoped.filter((run) => run.status === 'dead'),
|
|
59
|
+
retryTargets: scoped
|
|
60
|
+
.filter((run) => run.status === 'failed' || run.status === 'dead')
|
|
61
|
+
.map(firstFailedStep)
|
|
62
|
+
.filter((target): target is RetryTarget => target !== null),
|
|
63
|
+
totalDepth: queues.reduce((sum, queue) => sum + queue.depth, 0),
|
|
64
|
+
};
|
|
65
|
+
},
|
|
66
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Panel: Live query inspector.
|
|
2
|
+
// Kills: "why did (or didn't) this subscriber get that row?" — every subscriber, what it
|
|
3
|
+
// received, and the matcher's decision trace beside it.
|
|
4
|
+
|
|
5
|
+
import type { LiveQueryFact, LiveSubscriberFact } from './facts';
|
|
6
|
+
import type { DevPanel } from './panel';
|
|
7
|
+
|
|
8
|
+
export interface LivePanelData {
|
|
9
|
+
readonly queries: readonly LiveQueryFact[];
|
|
10
|
+
readonly subscribers: readonly LiveSubscriberFact[];
|
|
11
|
+
/** Registered live queries with nobody attached — usually a client that never subscribed. */
|
|
12
|
+
readonly idleQueries: readonly string[];
|
|
13
|
+
/** Subscribers the matcher rejected, with the reason kept next to them. */
|
|
14
|
+
readonly rejected: readonly {
|
|
15
|
+
readonly id: string;
|
|
16
|
+
readonly query: string;
|
|
17
|
+
readonly trace: readonly string[];
|
|
18
|
+
}[];
|
|
19
|
+
readonly note: string | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const livePanel: DevPanel<LivePanelData> = {
|
|
23
|
+
key: 'live',
|
|
24
|
+
titleKey: 'dev.panel.live',
|
|
25
|
+
question: 'what does each subscriber receive, and why?',
|
|
26
|
+
async data(sources, params): Promise<LivePanelData> {
|
|
27
|
+
const queries = (await sources.liveQueries()).filter((query) => query.live);
|
|
28
|
+
// The subscriber list needs a running sync node; without one the panel still shows the
|
|
29
|
+
// registered live queries rather than an empty tab.
|
|
30
|
+
const subscribers = await sources.subscribers().catch((): readonly LiveSubscriberFact[] => []);
|
|
31
|
+
const wanted = params.get('query');
|
|
32
|
+
const scoped =
|
|
33
|
+
wanted === null ? subscribers : subscribers.filter((sub) => sub.query === wanted);
|
|
34
|
+
const attached = new Set(subscribers.map((sub) => sub.query));
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
queries,
|
|
38
|
+
subscribers: scoped,
|
|
39
|
+
idleQueries: queries.filter((query) => !attached.has(query.name)).map((query) => query.name),
|
|
40
|
+
rejected: scoped
|
|
41
|
+
.filter((sub) => !sub.matched)
|
|
42
|
+
.map((sub) => ({ id: sub.id, query: sub.query, trace: sub.trace })),
|
|
43
|
+
note: subscribers.length === 0 ? 'dev.live.no-sync-node' : null,
|
|
44
|
+
};
|
|
45
|
+
},
|
|
46
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Panel: Mail.
|
|
2
|
+
// Kills: "what did that email actually look like, in that locale?" — every caught message,
|
|
3
|
+
// rendered, grouped per locale so a missing translation is visible instead of inferred.
|
|
4
|
+
|
|
5
|
+
import type { MailFact } from './facts';
|
|
6
|
+
import type { DevPanel } from './panel';
|
|
7
|
+
|
|
8
|
+
export interface MailPanelData {
|
|
9
|
+
readonly messages: readonly MailFact[];
|
|
10
|
+
readonly selected: MailFact | null;
|
|
11
|
+
readonly locales: readonly string[];
|
|
12
|
+
/** Subjects that exist in one locale but not another — a catalog gap, shown as one. */
|
|
13
|
+
readonly missingLocales: readonly {
|
|
14
|
+
readonly subject: string;
|
|
15
|
+
readonly missing: readonly string[];
|
|
16
|
+
}[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const mailPanel: DevPanel<MailPanelData> = {
|
|
20
|
+
key: 'mail',
|
|
21
|
+
titleKey: 'dev.panel.mail',
|
|
22
|
+
question: 'what did that email look like, in that locale?',
|
|
23
|
+
async data(sources, params): Promise<MailPanelData> {
|
|
24
|
+
const all = await sources.mail();
|
|
25
|
+
const locale = params.get('locale');
|
|
26
|
+
const messages = locale === null ? all : all.filter((mail) => mail.locale === locale);
|
|
27
|
+
const wanted = params.get('id');
|
|
28
|
+
const selected =
|
|
29
|
+
(wanted === null ? messages[0] : messages.find((mail) => mail.id === wanted)) ?? null;
|
|
30
|
+
|
|
31
|
+
const locales = [...new Set(all.map((mail) => mail.locale))].sort();
|
|
32
|
+
const bySubject = new Map<string, Set<string>>();
|
|
33
|
+
for (const mail of all) {
|
|
34
|
+
const seen = bySubject.get(mail.subject) ?? new Set<string>();
|
|
35
|
+
seen.add(mail.locale);
|
|
36
|
+
bySubject.set(mail.subject, seen);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
messages,
|
|
41
|
+
selected,
|
|
42
|
+
locales,
|
|
43
|
+
missingLocales: [...bySubject.entries()]
|
|
44
|
+
.map(([subject, seen]) => ({
|
|
45
|
+
subject,
|
|
46
|
+
missing: locales.filter((candidate) => !seen.has(candidate)),
|
|
47
|
+
}))
|
|
48
|
+
.filter((entry) => entry.missing.length > 0),
|
|
49
|
+
};
|
|
50
|
+
},
|
|
51
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Panel: Manifest.
|
|
2
|
+
// Kills: "is x.manifest.json current?" — the emitted manifest diffed against the committed
|
|
3
|
+
// one, so a forgotten `x manifest` is visible before CI says it.
|
|
4
|
+
|
|
5
|
+
import type { ManifestFact } from './facts';
|
|
6
|
+
import type { DevPanel } from './panel';
|
|
7
|
+
|
|
8
|
+
export interface ManifestPanelData {
|
|
9
|
+
readonly manifest: ManifestFact;
|
|
10
|
+
readonly drifted: boolean;
|
|
11
|
+
readonly added: readonly string[];
|
|
12
|
+
readonly removed: readonly string[];
|
|
13
|
+
readonly changed: readonly string[];
|
|
14
|
+
/** The command that makes the diff go away. Printed in the panel and in `--json`. */
|
|
15
|
+
readonly fix: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const manifestPanel: DevPanel<ManifestPanelData> = {
|
|
19
|
+
key: 'manifest',
|
|
20
|
+
titleKey: 'dev.panel.manifest',
|
|
21
|
+
question: 'is the committed x.manifest.json current?',
|
|
22
|
+
async data(sources): Promise<ManifestPanelData> {
|
|
23
|
+
const manifest = await sources.manifest();
|
|
24
|
+
return {
|
|
25
|
+
manifest,
|
|
26
|
+
drifted: manifest.diff.length > 0,
|
|
27
|
+
added: manifest.diff
|
|
28
|
+
.filter((entry) => entry.committed === undefined)
|
|
29
|
+
.map((entry) => entry.path),
|
|
30
|
+
removed: manifest.diff
|
|
31
|
+
.filter((entry) => entry.emitted === undefined)
|
|
32
|
+
.map((entry) => entry.path),
|
|
33
|
+
changed: manifest.diff
|
|
34
|
+
.filter((entry) => entry.emitted !== undefined && entry.committed !== undefined)
|
|
35
|
+
.map((entry) => entry.path),
|
|
36
|
+
fix: 'x manifest',
|
|
37
|
+
};
|
|
38
|
+
},
|
|
39
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Panel: Policy.
|
|
2
|
+
// Kills: "can this actor do that, and why?" — the permission matrix per actor, every cell
|
|
3
|
+
// carrying the decision trace that produced it.
|
|
4
|
+
|
|
5
|
+
import type { PolicyFact } from './facts';
|
|
6
|
+
import type { DevPanel } from './panel';
|
|
7
|
+
|
|
8
|
+
export interface PolicyRow {
|
|
9
|
+
readonly permission: string;
|
|
10
|
+
/** actorId → allowed. The matrix as the panel draws it. */
|
|
11
|
+
readonly byActor: Readonly<Record<string, boolean>>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface PolicyPanelData {
|
|
15
|
+
readonly actors: readonly string[];
|
|
16
|
+
readonly permissions: readonly string[];
|
|
17
|
+
readonly matrix: readonly PolicyRow[];
|
|
18
|
+
readonly facts: readonly PolicyFact[];
|
|
19
|
+
/** The selected cell's trace: `?permission=post:publish&actor=u_1`. */
|
|
20
|
+
readonly trace: readonly string[];
|
|
21
|
+
/** Permissions no actor holds — usually a policy nobody can satisfy. */
|
|
22
|
+
readonly unreachable: readonly string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const policyPanel: DevPanel<PolicyPanelData> = {
|
|
26
|
+
key: 'policy',
|
|
27
|
+
titleKey: 'dev.panel.policy',
|
|
28
|
+
question: 'can this actor do that, and why?',
|
|
29
|
+
async data(sources, params): Promise<PolicyPanelData> {
|
|
30
|
+
const facts = await sources.policyMatrix();
|
|
31
|
+
const actors = [...new Set(facts.map((fact) => fact.actorId))].sort();
|
|
32
|
+
const permissions = [...new Set(facts.map((fact) => fact.permission))].sort();
|
|
33
|
+
|
|
34
|
+
const matrix = permissions.map((permission) => {
|
|
35
|
+
const byActor: Record<string, boolean> = {};
|
|
36
|
+
for (const actor of actors) {
|
|
37
|
+
byActor[actor] =
|
|
38
|
+
facts.find((fact) => fact.permission === permission && fact.actorId === actor)?.allowed ??
|
|
39
|
+
false;
|
|
40
|
+
}
|
|
41
|
+
return { permission, byActor };
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const wantedPermission = params.get('permission');
|
|
45
|
+
const wantedActor = params.get('actor');
|
|
46
|
+
const selected = facts.find(
|
|
47
|
+
(fact) => fact.permission === wantedPermission && fact.actorId === wantedActor,
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
actors,
|
|
52
|
+
permissions,
|
|
53
|
+
matrix,
|
|
54
|
+
facts,
|
|
55
|
+
trace: selected?.trace ?? [],
|
|
56
|
+
unreachable: matrix
|
|
57
|
+
.filter((row) => Object.values(row.byActor).every((allowed) => !allowed))
|
|
58
|
+
.map((row) => row.permission),
|
|
59
|
+
};
|
|
60
|
+
},
|
|
61
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Panel: Routes.
|
|
2
|
+
// Kills: "which handler serves this?" — the route table with render mode, offline strategy,
|
|
3
|
+
// budget, and whether the route declares meta.
|
|
4
|
+
|
|
5
|
+
import type { RouteFact } from './facts';
|
|
6
|
+
import type { DevPanel } from './panel';
|
|
7
|
+
|
|
8
|
+
export interface RoutesPanelData {
|
|
9
|
+
readonly routes: readonly RouteFact[];
|
|
10
|
+
/** Counts per render mode: an app that is all `ssr` has a caching problem to find. */
|
|
11
|
+
readonly byRenderMode: Readonly<Record<string, number>>;
|
|
12
|
+
readonly missingMeta: readonly string[];
|
|
13
|
+
readonly overBudget: readonly string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const BUDGET_LIMIT_KB = 40;
|
|
17
|
+
|
|
18
|
+
const kb = (budget: string | undefined): number => {
|
|
19
|
+
if (budget === undefined) return 0;
|
|
20
|
+
const parsed = Number.parseFloat(budget);
|
|
21
|
+
return Number.isNaN(parsed) ? 0 : budget.toLowerCase().endsWith('mb') ? parsed * 1024 : parsed;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const routesPanel: DevPanel<RoutesPanelData> = {
|
|
25
|
+
key: 'routes',
|
|
26
|
+
titleKey: 'dev.panel.routes',
|
|
27
|
+
question: 'which handler serves this?',
|
|
28
|
+
async data(sources): Promise<RoutesPanelData> {
|
|
29
|
+
const routes = await sources.routes();
|
|
30
|
+
const byRenderMode: Record<string, number> = {};
|
|
31
|
+
for (const route of routes) {
|
|
32
|
+
byRenderMode[route.render] = (byRenderMode[route.render] ?? 0) + 1;
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
routes: [...routes].sort((a, b) => a.path.localeCompare(b.path)),
|
|
36
|
+
byRenderMode,
|
|
37
|
+
missingMeta: routes.filter((route) => !route.hasMeta).map((route) => route.path),
|
|
38
|
+
overBudget: routes
|
|
39
|
+
.filter((route) => kb(route.budget.js) > BUDGET_LIMIT_KB)
|
|
40
|
+
.map((route) => route.path),
|
|
41
|
+
};
|
|
42
|
+
},
|
|
43
|
+
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Panel: Request timeline.
|
|
2
|
+
// Kills: "where did the 800ms go?" — a flamegraph of one request: SQL, cache hits, action
|
|
3
|
+
// calls, and policy decisions on one axis, with the N+1 already counted for you.
|
|
4
|
+
|
|
5
|
+
import type { RequestTrace, SpanKind, TimelineSpan } from './facts';
|
|
6
|
+
import type { DevPanel } from './panel';
|
|
7
|
+
|
|
8
|
+
export interface FlameRow {
|
|
9
|
+
readonly span: TimelineSpan;
|
|
10
|
+
readonly depth: number;
|
|
11
|
+
/** 0..1 of the request's total duration — the bar's width, computed once, server-side. */
|
|
12
|
+
readonly offset: number;
|
|
13
|
+
readonly width: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface TimelinePanelData {
|
|
17
|
+
readonly requests: readonly {
|
|
18
|
+
readonly requestId: string;
|
|
19
|
+
readonly path: string;
|
|
20
|
+
readonly totalMs: number;
|
|
21
|
+
}[];
|
|
22
|
+
readonly selected: RequestTrace | null;
|
|
23
|
+
readonly flame: readonly FlameRow[];
|
|
24
|
+
readonly totalsByKind: Readonly<Record<string, number>>;
|
|
25
|
+
/** Same SQL text more than once in one request. The N+1 detector. */
|
|
26
|
+
readonly repeatedSql: readonly { readonly sql: string; readonly count: number }[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function flatten(trace: RequestTrace): readonly FlameRow[] {
|
|
30
|
+
const byParent = new Map<string | null, TimelineSpan[]>();
|
|
31
|
+
for (const span of trace.spans) {
|
|
32
|
+
const bucket = byParent.get(span.parentId) ?? [];
|
|
33
|
+
bucket.push(span);
|
|
34
|
+
byParent.set(span.parentId, bucket);
|
|
35
|
+
}
|
|
36
|
+
const total = trace.totalMs === 0 ? 1 : trace.totalMs;
|
|
37
|
+
const rows: FlameRow[] = [];
|
|
38
|
+
|
|
39
|
+
const walk = (parentId: string | null, depth: number): void => {
|
|
40
|
+
for (const span of (byParent.get(parentId) ?? []).sort((a, b) => a.startMs - b.startMs)) {
|
|
41
|
+
rows.push({ span, depth, offset: span.startMs / total, width: span.durationMs / total });
|
|
42
|
+
walk(span.id, depth + 1);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
walk(null, 0);
|
|
46
|
+
return rows;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const timelinePanel: DevPanel<TimelinePanelData> = {
|
|
50
|
+
key: 'timeline',
|
|
51
|
+
titleKey: 'dev.panel.timeline',
|
|
52
|
+
question: 'where did the time go in this request?',
|
|
53
|
+
async data(sources, params): Promise<TimelinePanelData> {
|
|
54
|
+
const traces = await sources.traces();
|
|
55
|
+
const wanted = params.get('requestId');
|
|
56
|
+
const selected =
|
|
57
|
+
(wanted === null ? traces[0] : traces.find((trace) => trace.requestId === wanted)) ?? null;
|
|
58
|
+
|
|
59
|
+
const totalsByKind: Record<string, number> = {};
|
|
60
|
+
const sqlCounts = new Map<string, number>();
|
|
61
|
+
for (const span of selected?.spans ?? []) {
|
|
62
|
+
const kind: SpanKind = span.kind;
|
|
63
|
+
totalsByKind[kind] = (totalsByKind[kind] ?? 0) + span.durationMs;
|
|
64
|
+
if (kind === 'sql') sqlCounts.set(span.detail, (sqlCounts.get(span.detail) ?? 0) + 1);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
requests: traces.map((trace) => ({
|
|
69
|
+
requestId: trace.requestId,
|
|
70
|
+
path: trace.path,
|
|
71
|
+
totalMs: trace.totalMs,
|
|
72
|
+
})),
|
|
73
|
+
selected,
|
|
74
|
+
flame: selected === null ? [] : flatten(selected),
|
|
75
|
+
totalsByKind,
|
|
76
|
+
repeatedSql: [...sqlCounts.entries()]
|
|
77
|
+
.filter(([, count]) => count > 1)
|
|
78
|
+
.map(([sql, count]) => ({ sql, count }))
|
|
79
|
+
.sort((a, b) => b.count - a.count),
|
|
80
|
+
};
|
|
81
|
+
},
|
|
82
|
+
};
|
package/src/dev/panel.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// What a /_x panel is. One panel per file, each one an introspection call plus the shape it
|
|
2
|
+
// draws — and because `data()` returns plain JSON, `--json` and the rendered tab are the
|
|
3
|
+
// same facts by construction.
|
|
4
|
+
|
|
5
|
+
import type { DevSources } from './facts';
|
|
6
|
+
|
|
7
|
+
export interface DevPanel<Data = unknown> {
|
|
8
|
+
/** URL segment and `--json` selector: `x dev --panel routes --json`. */
|
|
9
|
+
readonly key: string;
|
|
10
|
+
readonly titleKey: string;
|
|
11
|
+
/** The question this panel exists to kill. Rendered as the tab's subtitle. */
|
|
12
|
+
readonly question: string;
|
|
13
|
+
data(sources: DevSources, params: URLSearchParams): Promise<Data>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type PanelPayload =
|
|
17
|
+
| { readonly panel: string; readonly ok: true; readonly data: unknown }
|
|
18
|
+
| {
|
|
19
|
+
readonly panel: string;
|
|
20
|
+
readonly ok: false;
|
|
21
|
+
readonly error: { readonly code: string; readonly cause: string; readonly fix: string };
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
interface ErrorFields {
|
|
25
|
+
readonly code?: unknown;
|
|
26
|
+
readonly cause?: unknown;
|
|
27
|
+
readonly fix?: unknown;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A panel whose source is not wired must say so in the panel, with the fix line — the same
|
|
32
|
+
* payload the CLI prints. A blank tab would read as "nothing is happening".
|
|
33
|
+
*/
|
|
34
|
+
export async function panelPayload(
|
|
35
|
+
panel: DevPanel,
|
|
36
|
+
sources: DevSources,
|
|
37
|
+
params: URLSearchParams,
|
|
38
|
+
): Promise<PanelPayload> {
|
|
39
|
+
try {
|
|
40
|
+
return { panel: panel.key, ok: true, data: await panel.data(sources, params) };
|
|
41
|
+
} catch (error) {
|
|
42
|
+
const fields = (error ?? {}) as ErrorFields;
|
|
43
|
+
return {
|
|
44
|
+
panel: panel.key,
|
|
45
|
+
ok: false,
|
|
46
|
+
error: {
|
|
47
|
+
code: typeof fields.code === 'string' ? fields.code : 'X_NOT_IMPLEMENTED',
|
|
48
|
+
cause:
|
|
49
|
+
typeof fields.cause === 'string'
|
|
50
|
+
? fields.cause
|
|
51
|
+
: error instanceof Error
|
|
52
|
+
? error.message
|
|
53
|
+
: String(error),
|
|
54
|
+
fix: typeof fields.fix === 'string' ? fields.fix : 'x dev --help',
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// Mounts the /_x panels — and refuses to mount in production, because these panels print
|
|
2
|
+
// SQL, policy traces, and caught mail. The refusal is a throw at construction, not a 404 at
|
|
3
|
+
// request time: an app that boots with /_x mounted in prod has already lost.
|
|
4
|
+
|
|
5
|
+
import { DevDashboardInProdError } from '../errors';
|
|
6
|
+
import { defaultDevSources } from './data';
|
|
7
|
+
import type { DevSources } from './facts';
|
|
8
|
+
import { type DevPanel, type PanelPayload, panelPayload } from './panel';
|
|
9
|
+
import { cachePanel } from './panel-cache';
|
|
10
|
+
import { dbPanel } from './panel-db';
|
|
11
|
+
import { jobsPanel } from './panel-jobs';
|
|
12
|
+
import { livePanel } from './panel-live';
|
|
13
|
+
import { mailPanel } from './panel-mail';
|
|
14
|
+
import { manifestPanel } from './panel-manifest';
|
|
15
|
+
import { policyPanel } from './panel-policy';
|
|
16
|
+
import { routesPanel } from './panel-routes';
|
|
17
|
+
import { timelinePanel } from './panel-timeline';
|
|
18
|
+
|
|
19
|
+
export const DEV_PANELS: readonly DevPanel[] = [
|
|
20
|
+
routesPanel,
|
|
21
|
+
timelinePanel,
|
|
22
|
+
livePanel,
|
|
23
|
+
jobsPanel,
|
|
24
|
+
dbPanel,
|
|
25
|
+
mailPanel,
|
|
26
|
+
cachePanel,
|
|
27
|
+
policyPanel,
|
|
28
|
+
manifestPanel,
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
export const DEV_BASE_PATH = '/_x';
|
|
32
|
+
|
|
33
|
+
export interface DevDashboardOptions {
|
|
34
|
+
/** `ROLE` for this process. Defaults to `process.env.ROLE`. */
|
|
35
|
+
readonly role?: string;
|
|
36
|
+
/** `NODE_ENV` (or `X_ENV`). Defaults to the environment. */
|
|
37
|
+
readonly env?: string;
|
|
38
|
+
readonly basePath?: string;
|
|
39
|
+
readonly sources?: DevSources;
|
|
40
|
+
readonly panels?: readonly DevPanel[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const envOf = (name: string): string | undefined => {
|
|
44
|
+
const value = process.env[name];
|
|
45
|
+
return value === undefined || value === '' ? undefined : value;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* One rule: anything that says "production" refuses. Checked against both the framework's
|
|
50
|
+
* `ROLE`-adjacent env and `NODE_ENV`, since a container may set only one of them.
|
|
51
|
+
*/
|
|
52
|
+
export function assertDevOnly(input: {
|
|
53
|
+
role?: string | undefined;
|
|
54
|
+
env?: string | undefined;
|
|
55
|
+
}): void {
|
|
56
|
+
const role = input.role ?? envOf('ROLE') ?? 'dev';
|
|
57
|
+
const env = input.env ?? envOf('X_ENV') ?? envOf('NODE_ENV') ?? 'development';
|
|
58
|
+
if (env === 'production' || env === 'prod' || role === 'production' || role === 'prod') {
|
|
59
|
+
throw new DevDashboardInProdError({ role, env });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface DevDashboard {
|
|
64
|
+
readonly basePath: string;
|
|
65
|
+
readonly panels: readonly DevPanel[];
|
|
66
|
+
/** The `--json` payload for one panel: exactly what the tab renders. */
|
|
67
|
+
json(key: string, params?: URLSearchParams): Promise<PanelPayload>;
|
|
68
|
+
/** `null` when the request is not ours, so a host router can fall through. */
|
|
69
|
+
handle(request: Request): Promise<Response | null>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const jsonResponse = (body: unknown, status = 200): Response =>
|
|
73
|
+
new Response(JSON.stringify(body, null, 2), {
|
|
74
|
+
status,
|
|
75
|
+
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const escapeHtml = (value: string): string =>
|
|
79
|
+
value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
80
|
+
|
|
81
|
+
/** Tokens are defined inline: /_x is a standalone page and still owes both themes. */
|
|
82
|
+
const SHELL_STYLE = `
|
|
83
|
+
:root {
|
|
84
|
+
--x-color-bg: 253 246 240; --x-color-surface: 255 255 255; --x-color-fg: 38 34 31;
|
|
85
|
+
--x-color-fg-muted: 110 102 94; --x-color-line: 224 216 208; --x-color-accent: 34 122 197;
|
|
86
|
+
}
|
|
87
|
+
@media (prefers-color-scheme: dark) {
|
|
88
|
+
:root {
|
|
89
|
+
--x-color-bg: 18 18 20; --x-color-surface: 34 34 39; --x-color-fg: 228 226 222;
|
|
90
|
+
--x-color-fg-muted: 150 146 140; --x-color-line: 54 54 60; --x-color-accent: 96 170 240;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
html[data-theme="light"] {
|
|
94
|
+
--x-color-bg: 253 246 240; --x-color-surface: 255 255 255; --x-color-fg: 38 34 31;
|
|
95
|
+
--x-color-fg-muted: 110 102 94; --x-color-line: 224 216 208; --x-color-accent: 34 122 197;
|
|
96
|
+
}
|
|
97
|
+
html[data-theme="dark"] {
|
|
98
|
+
--x-color-bg: 18 18 20; --x-color-surface: 34 34 39; --x-color-fg: 228 226 222;
|
|
99
|
+
--x-color-fg-muted: 150 146 140; --x-color-line: 54 54 60; --x-color-accent: 96 170 240;
|
|
100
|
+
}
|
|
101
|
+
body { margin: 0; background: rgb(var(--x-color-bg)); color: rgb(var(--x-color-fg));
|
|
102
|
+
font: 14px/1.5 ui-monospace, monospace; }
|
|
103
|
+
header { display: flex; gap: 1rem; padding: .75rem 1rem;
|
|
104
|
+
border-bottom: 1px solid rgb(var(--x-color-line)); flex-wrap: wrap; }
|
|
105
|
+
a { color: rgb(var(--x-color-accent)); }
|
|
106
|
+
main { padding: 1rem; }
|
|
107
|
+
h1 { font-size: 1rem; margin: 0 1rem 0 0; }
|
|
108
|
+
p.question { color: rgb(var(--x-color-fg-muted)); margin: 0 0 1rem; }
|
|
109
|
+
pre { background: rgb(var(--x-color-surface)); border: 1px solid rgb(var(--x-color-line));
|
|
110
|
+
padding: 1rem; overflow: auto; }
|
|
111
|
+
:focus-visible { outline: 2px solid rgb(var(--x-color-accent)); outline-offset: 2px; }
|
|
112
|
+
`;
|
|
113
|
+
|
|
114
|
+
function shell(
|
|
115
|
+
basePath: string,
|
|
116
|
+
panels: readonly DevPanel[],
|
|
117
|
+
active: DevPanel,
|
|
118
|
+
payload: PanelPayload,
|
|
119
|
+
): string {
|
|
120
|
+
const tabs = panels
|
|
121
|
+
.map(
|
|
122
|
+
(panel) =>
|
|
123
|
+
`<a href="${basePath}/${panel.key}"${panel.key === active.key ? ' aria-current="page"' : ''}>${panel.key}</a>`,
|
|
124
|
+
)
|
|
125
|
+
.join(' ');
|
|
126
|
+
|
|
127
|
+
return `<!doctype html>
|
|
128
|
+
<html lang="en"><head><meta charset="utf-8">
|
|
129
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
130
|
+
<meta name="robots" content="noindex, nofollow">
|
|
131
|
+
<title>_x · ${escapeHtml(active.key)}</title><style>${SHELL_STYLE}</style></head>
|
|
132
|
+
<body><header><h1>_x</h1><nav>${tabs}</nav>
|
|
133
|
+
<a href="${basePath}/${active.key}?json=1">--json</a></header>
|
|
134
|
+
<main><p class="question">${escapeHtml(active.question)}</p>
|
|
135
|
+
<pre>${escapeHtml(JSON.stringify(payload, null, 2))}</pre></main></body></html>`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Build the dashboard. Throws X_DEV_DASHBOARD_IN_PROD before anything else happens. */
|
|
139
|
+
export function devDashboard(opts: DevDashboardOptions = {}): DevDashboard {
|
|
140
|
+
assertDevOnly({ role: opts.role, env: opts.env });
|
|
141
|
+
|
|
142
|
+
const basePath = opts.basePath ?? DEV_BASE_PATH;
|
|
143
|
+
const panels = opts.panels ?? DEV_PANELS;
|
|
144
|
+
const sources = opts.sources ?? defaultDevSources();
|
|
145
|
+
const byKey = new Map(panels.map((panel) => [panel.key, panel]));
|
|
146
|
+
|
|
147
|
+
const json = async (key: string, params = new URLSearchParams()): Promise<PanelPayload> => {
|
|
148
|
+
const panel = byKey.get(key);
|
|
149
|
+
if (panel === undefined) {
|
|
150
|
+
return {
|
|
151
|
+
panel: key,
|
|
152
|
+
ok: false,
|
|
153
|
+
error: {
|
|
154
|
+
code: 'X_ADMIN_ENTITY_UNKNOWN',
|
|
155
|
+
cause: `no /_x panel named "${key}" (have: ${[...byKey.keys()].join(', ')})`,
|
|
156
|
+
fix: `x dev --panel ${[...byKey.keys()][0] ?? 'routes'}`,
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
return panelPayload(panel, sources, params);
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
basePath,
|
|
165
|
+
panels,
|
|
166
|
+
json,
|
|
167
|
+
|
|
168
|
+
async handle(request: Request): Promise<Response | null> {
|
|
169
|
+
const url = new URL(request.url);
|
|
170
|
+
if (url.pathname !== basePath && !url.pathname.startsWith(`${basePath}/`)) return null;
|
|
171
|
+
|
|
172
|
+
const key = url.pathname.slice(basePath.length).replace(/^\//, '');
|
|
173
|
+
const panel = byKey.get(key === '' ? (panels[0]?.key ?? '') : key);
|
|
174
|
+
if (panel === undefined) return jsonResponse(await json(key, url.searchParams), 404);
|
|
175
|
+
|
|
176
|
+
const payload = await json(panel.key, url.searchParams);
|
|
177
|
+
const wantsJson =
|
|
178
|
+
url.searchParams.has('json') ||
|
|
179
|
+
(request.headers.get('accept') ?? '').includes('application/json');
|
|
180
|
+
|
|
181
|
+
return wantsJson
|
|
182
|
+
? jsonResponse(payload, payload.ok ? 200 : 500)
|
|
183
|
+
: new Response(shell(basePath, panels, panel, payload), {
|
|
184
|
+
status: 200,
|
|
185
|
+
headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' },
|
|
186
|
+
});
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
}
|