@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
package/src/dev/data.ts
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
// Every /_x panel reads from here, and every method is an introspection call — never a
|
|
2
|
+
// bespoke query. That is what makes the dashboard and the MCP dev server the same facts in
|
|
3
|
+
// two renderings: `--json` on a panel returns exactly what the panel drew.
|
|
4
|
+
//
|
|
5
|
+
// The framework introspection modules are reached through dynamic `import()` so the
|
|
6
|
+
// production graph never statically references them: /_x must not cost the app path a byte.
|
|
7
|
+
|
|
8
|
+
// Type-only, so it is erased: the introspection modules are still reached by dynamic
|
|
9
|
+
// `import()` below and /_x stays out of the production graph.
|
|
10
|
+
import type { JobState, StepStatus } from '@ultimat3/jobs';
|
|
11
|
+
import type { AdminActor, AdminAuthz } from '../authz';
|
|
12
|
+
import { DevSourceUnavailableError } from '../errors';
|
|
13
|
+
import type {
|
|
14
|
+
CacheEdgeFact,
|
|
15
|
+
DevSources,
|
|
16
|
+
DriftFact,
|
|
17
|
+
InvalidationFact,
|
|
18
|
+
JobDefFact,
|
|
19
|
+
JobRunFact,
|
|
20
|
+
JobStepFact,
|
|
21
|
+
LiveQueryFact,
|
|
22
|
+
LiveSubscriberFact,
|
|
23
|
+
MailFact,
|
|
24
|
+
ManifestFact,
|
|
25
|
+
PolicyFact,
|
|
26
|
+
QueueFact,
|
|
27
|
+
RequestTrace,
|
|
28
|
+
RouteFact,
|
|
29
|
+
SqlResult,
|
|
30
|
+
TableFact,
|
|
31
|
+
TaskFact,
|
|
32
|
+
} from './facts';
|
|
33
|
+
|
|
34
|
+
const empty = async <T>(value: T): Promise<T> => value;
|
|
35
|
+
|
|
36
|
+
/** Explicit fixtures. Tests and `x dev --offline` use this; it is not a fallback path. */
|
|
37
|
+
export function staticDevSources(facts: Partial<DevSources> = {}): DevSources {
|
|
38
|
+
return {
|
|
39
|
+
routes: facts.routes ?? ((): Promise<readonly RouteFact[]> => empty([])),
|
|
40
|
+
traces: facts.traces ?? ((): Promise<readonly RequestTrace[]> => empty([])),
|
|
41
|
+
liveQueries: facts.liveQueries ?? ((): Promise<readonly LiveQueryFact[]> => empty([])),
|
|
42
|
+
subscribers: facts.subscribers ?? ((): Promise<readonly LiveSubscriberFact[]> => empty([])),
|
|
43
|
+
jobDefs: facts.jobDefs ?? ((): Promise<readonly JobDefFact[]> => empty([])),
|
|
44
|
+
queues: facts.queues ?? ((): Promise<readonly QueueFact[]> => empty([])),
|
|
45
|
+
jobRuns: facts.jobRuns ?? ((): Promise<readonly JobRunFact[]> => empty([])),
|
|
46
|
+
tasks: facts.tasks ?? ((): Promise<readonly TaskFact[]> => empty([])),
|
|
47
|
+
tables: facts.tables ?? ((): Promise<readonly TableFact[]> => empty([])),
|
|
48
|
+
drift: facts.drift ?? ((): Promise<readonly DriftFact[]> => empty([])),
|
|
49
|
+
runSql:
|
|
50
|
+
facts.runSql ?? ((): Promise<SqlResult> => empty({ columns: [], rows: [], elapsedMs: 0 })),
|
|
51
|
+
mail: facts.mail ?? ((): Promise<readonly MailFact[]> => empty([])),
|
|
52
|
+
cacheGraph: facts.cacheGraph ?? ((): Promise<readonly CacheEdgeFact[]> => empty([])),
|
|
53
|
+
invalidations: facts.invalidations ?? ((): Promise<readonly InvalidationFact[]> => empty([])),
|
|
54
|
+
policyMatrix: facts.policyMatrix ?? ((): Promise<readonly PolicyFact[]> => empty([])),
|
|
55
|
+
manifest:
|
|
56
|
+
facts.manifest ??
|
|
57
|
+
((): Promise<ManifestFact> => empty({ emitted: null, committed: null, diff: [] })),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type Bag = Readonly<Record<string, unknown>>;
|
|
62
|
+
|
|
63
|
+
const bagOf = (value: unknown): Bag =>
|
|
64
|
+
typeof value === 'object' && value !== null ? (value as Bag) : {};
|
|
65
|
+
const str = (value: unknown, fallback = ''): string =>
|
|
66
|
+
typeof value === 'string' ? value : fallback;
|
|
67
|
+
const numOf = (value: unknown, fallback = 0): number =>
|
|
68
|
+
typeof value === 'number' ? value : fallback;
|
|
69
|
+
const listOf = (value: unknown): readonly unknown[] => (Array.isArray(value) ? value : []);
|
|
70
|
+
const strings = (value: unknown): readonly string[] => listOf(value).map((item) => String(item));
|
|
71
|
+
|
|
72
|
+
export interface DevSourceOptions {
|
|
73
|
+
/** The app's authz. The policy matrix is computed through it, never re-derived. */
|
|
74
|
+
readonly authz?: AdminAuthz;
|
|
75
|
+
/** Actors the matrix is computed for. `x dev --actor` supplies these. */
|
|
76
|
+
readonly actors?: readonly AdminActor[];
|
|
77
|
+
/**
|
|
78
|
+
* Facts no registry can produce on its own — request traces, caught mail, the read-only
|
|
79
|
+
* SQL tool, the committed manifest. Unwired ones throw X_NOT_IMPLEMENTED with the exact
|
|
80
|
+
* wiring line, rather than rendering an empty panel that reads as "nothing happened".
|
|
81
|
+
*/
|
|
82
|
+
readonly hooks?: Partial<DevSources>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const unavailable = (source: string, panel: string): DevSourceUnavailableError =>
|
|
86
|
+
new DevSourceUnavailableError({ source, panel });
|
|
87
|
+
|
|
88
|
+
const unwired = <T>(source: string, panel: string): (() => Promise<T>) => {
|
|
89
|
+
// A rejected promise, never a synchronous throw: every caller's declared return type is
|
|
90
|
+
// `Promise<T>`, and panels like `panel-cache.ts` degrade with `sources.invalidations().catch(…)`.
|
|
91
|
+
// A synchronous throw fires while that expression is still being evaluated, before there is a
|
|
92
|
+
// promise for `.catch` to attach to, so it escapes the panel's own degradation entirely.
|
|
93
|
+
return (): Promise<T> => Promise.reject(unavailable(source, panel));
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** How many recent runs the jobs panel traces. A dev panel reads, it does not page. */
|
|
97
|
+
const RUN_WINDOW = 50;
|
|
98
|
+
|
|
99
|
+
/** `JobState` and `StepStatus` are the queue's vocabulary; the panel renders its own. */
|
|
100
|
+
const RUN_STATUS: Readonly<Record<JobState, JobRunFact['status']>> = {
|
|
101
|
+
ready: 'running',
|
|
102
|
+
delayed: 'running',
|
|
103
|
+
running: 'running',
|
|
104
|
+
suspended: 'running',
|
|
105
|
+
done: 'ok',
|
|
106
|
+
failed: 'failed',
|
|
107
|
+
dead: 'dead',
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const STEP_STATUS: Readonly<Record<StepStatus, JobStepFact['status']>> = {
|
|
111
|
+
completed: 'ok',
|
|
112
|
+
sleeping: 'sleeping',
|
|
113
|
+
waiting: 'pending',
|
|
114
|
+
failed: 'failed',
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Registry output is read field by field: a registry that grows a field must not break the
|
|
119
|
+
* dev dashboard, and a registry that renames one should show a blank cell in /_x rather than
|
|
120
|
+
* crash the process an engineer is debugging with.
|
|
121
|
+
*/
|
|
122
|
+
export function defaultDevSources(opts: DevSourceOptions = {}): DevSources {
|
|
123
|
+
const hooks = opts.hooks ?? {};
|
|
124
|
+
|
|
125
|
+
const sources: DevSources = {
|
|
126
|
+
async routes(): Promise<readonly RouteFact[]> {
|
|
127
|
+
const { describeRoutes } = await import('@ultimat3/render');
|
|
128
|
+
return listOf(describeRoutes()).map((raw) => {
|
|
129
|
+
const route = bagOf(raw);
|
|
130
|
+
const budget = bagOf(route['budget']);
|
|
131
|
+
return {
|
|
132
|
+
path: str(route['path']),
|
|
133
|
+
render: str(route['render'], 'stream'),
|
|
134
|
+
offline: str(route['offline'], 'network-only'),
|
|
135
|
+
hydrate: str(route['hydrate'], 'idle'),
|
|
136
|
+
handler: str(route['handler'], str(route['file'])),
|
|
137
|
+
budget: {
|
|
138
|
+
...(typeof budget['js'] === 'string' ? { js: budget['js'] } : {}),
|
|
139
|
+
...(typeof budget['lcp'] === 'number' ? { lcp: budget['lcp'] } : {}),
|
|
140
|
+
},
|
|
141
|
+
revalidateTags: strings(bagOf(route['revalidate'])['tags']),
|
|
142
|
+
hasMeta: route['meta'] !== undefined,
|
|
143
|
+
};
|
|
144
|
+
});
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
traces: unwired<readonly RequestTrace[]>('traces', 'timeline'),
|
|
148
|
+
|
|
149
|
+
async liveQueries(): Promise<readonly LiveQueryFact[]> {
|
|
150
|
+
const { describeQueries } = await import('@ultimat3/query');
|
|
151
|
+
return listOf(describeQueries()).map((raw) => {
|
|
152
|
+
const query = bagOf(raw);
|
|
153
|
+
return {
|
|
154
|
+
name: str(query['name']),
|
|
155
|
+
live: query['live'] === true,
|
|
156
|
+
// `QueryDescriptor`'s permission field is named `capability`, not `policy` — this
|
|
157
|
+
// fact keeps its own field named `policy` (that is the /_x rendering, not the registry).
|
|
158
|
+
policy: str(query['capability']),
|
|
159
|
+
// `QueryDescriptor` carries no SQL text at all; this stays blank until the query
|
|
160
|
+
// registry actually describes one — never invent a value here.
|
|
161
|
+
sql: str(query['sql']),
|
|
162
|
+
};
|
|
163
|
+
});
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
subscribers: unwired<readonly LiveSubscriberFact[]>('subscribers', 'live'),
|
|
167
|
+
|
|
168
|
+
async jobDefs(): Promise<readonly JobDefFact[]> {
|
|
169
|
+
const { describeJobs } = await import('@ultimat3/jobs');
|
|
170
|
+
return listOf(describeJobs()).map((raw) => {
|
|
171
|
+
const job = bagOf(raw);
|
|
172
|
+
const retry = bagOf(job['retry']);
|
|
173
|
+
return {
|
|
174
|
+
name: str(job['name']),
|
|
175
|
+
queue: str(job['queue'], 'default'),
|
|
176
|
+
steps: strings(job['steps']),
|
|
177
|
+
retry: {
|
|
178
|
+
attempts: numOf(retry['attempts'], 1),
|
|
179
|
+
backoff: str(retry['backoff'], 'exponential'),
|
|
180
|
+
},
|
|
181
|
+
idempotent: job['idempotencyKey'] !== undefined,
|
|
182
|
+
};
|
|
183
|
+
});
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
async queues(): Promise<readonly QueueFact[]> {
|
|
187
|
+
const { inspectJobList, inspectQueues, jobDriver } = await import('@ultimat3/jobs');
|
|
188
|
+
const driver = jobDriver();
|
|
189
|
+
if (driver === undefined) throw unavailable('queues', 'jobs');
|
|
190
|
+
const report = await inspectQueues(driver);
|
|
191
|
+
// `stats()` counts states, and a failed job is one of them only until it is retried or
|
|
192
|
+
// dead-lettered; the honest count comes from the job list, which needs introspection.
|
|
193
|
+
const failed =
|
|
194
|
+
driver.introspect === undefined ? [] : await inspectJobList(driver, { state: 'failed' });
|
|
195
|
+
return report.queues.map((queue) => ({
|
|
196
|
+
name: queue.queue,
|
|
197
|
+
depth: queue.ready + queue.delayed,
|
|
198
|
+
running: queue.running,
|
|
199
|
+
failed: failed.filter((record) => record.queue === queue.queue).length,
|
|
200
|
+
deadLetter: queue.dead,
|
|
201
|
+
}));
|
|
202
|
+
},
|
|
203
|
+
|
|
204
|
+
async jobRuns(): Promise<readonly JobRunFact[]> {
|
|
205
|
+
const { inspectJob, inspectJobList, jobDriver } = await import('@ultimat3/jobs');
|
|
206
|
+
const driver = jobDriver();
|
|
207
|
+
if (driver === undefined) throw unavailable('jobRuns', 'jobs');
|
|
208
|
+
const records = await inspectJobList(driver, { limit: RUN_WINDOW });
|
|
209
|
+
// One trace per run, because the panel's whole question is "which step failed?" — a
|
|
210
|
+
// list row without its steps cannot answer it.
|
|
211
|
+
const traces = await Promise.all(records.map((record) => inspectJob(driver, record.id)));
|
|
212
|
+
return traces
|
|
213
|
+
.filter((trace): trace is NonNullable<typeof trace> => trace !== undefined)
|
|
214
|
+
.map((trace) => ({
|
|
215
|
+
id: trace.id,
|
|
216
|
+
job: trace.name,
|
|
217
|
+
queue: trace.queue,
|
|
218
|
+
status: RUN_STATUS[trace.state],
|
|
219
|
+
attempt: trace.attempt,
|
|
220
|
+
steps: trace.steps.map((step) => ({
|
|
221
|
+
name: step.name,
|
|
222
|
+
status: STEP_STATUS[step.status],
|
|
223
|
+
attempt: step.attempts,
|
|
224
|
+
durationMs: step.durationMs ?? 0,
|
|
225
|
+
error: step.error,
|
|
226
|
+
})),
|
|
227
|
+
}));
|
|
228
|
+
},
|
|
229
|
+
|
|
230
|
+
async tasks(): Promise<readonly TaskFact[]> {
|
|
231
|
+
const { inspectManifest } = await import('@ultimat3/jobs');
|
|
232
|
+
return inspectManifest().tasks.map((task) => ({
|
|
233
|
+
name: task.name,
|
|
234
|
+
cron: task.cron,
|
|
235
|
+
tz: task.tz,
|
|
236
|
+
nextRunAt: task.nextRun,
|
|
237
|
+
}));
|
|
238
|
+
},
|
|
239
|
+
|
|
240
|
+
async tables(): Promise<readonly TableFact[]> {
|
|
241
|
+
const { describeEntities } = await import('@ultimat3/entity');
|
|
242
|
+
return listOf(describeEntities()).map((raw) => {
|
|
243
|
+
const entity = bagOf(raw);
|
|
244
|
+
return {
|
|
245
|
+
name: str(entity['table'], str(entity['name'])),
|
|
246
|
+
// `EntityDescription.columns` is a LIST of physical columns — money is already two
|
|
247
|
+
// of them here. Reading it as a record produced a table whose columns were "0", "1".
|
|
248
|
+
columns: listOf(entity['columns']).map((rawColumn) => {
|
|
249
|
+
const column = bagOf(rawColumn);
|
|
250
|
+
return {
|
|
251
|
+
name: str(column['column'], str(column['property'], 'unknown')),
|
|
252
|
+
type: str(column['kind'], 'unknown'),
|
|
253
|
+
nullable: column['notNull'] !== true,
|
|
254
|
+
};
|
|
255
|
+
}),
|
|
256
|
+
};
|
|
257
|
+
});
|
|
258
|
+
},
|
|
259
|
+
|
|
260
|
+
async drift(): Promise<readonly DriftFact[]> {
|
|
261
|
+
const { describeEntities } = await import('@ultimat3/entity');
|
|
262
|
+
return listOf(describeEntities()).flatMap((raw) => {
|
|
263
|
+
const entity = bagOf(raw);
|
|
264
|
+
return listOf(entity['drift']).map((rawIssue) => {
|
|
265
|
+
const issue = bagOf(rawIssue);
|
|
266
|
+
return {
|
|
267
|
+
table: str(entity['table'], str(entity['name'])),
|
|
268
|
+
column: typeof issue['column'] === 'string' ? issue['column'] : null,
|
|
269
|
+
issue: str(issue['issue'], 'unknown'),
|
|
270
|
+
};
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
},
|
|
274
|
+
|
|
275
|
+
runSql: unwired<SqlResult>('runSql', 'db'),
|
|
276
|
+
mail: unwired<readonly MailFact[]>('mail', 'mail'),
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* The tag graph is read through `dependentsOf`, one entity tag at a time: the cache owns
|
|
280
|
+
* the graph, and /_x asking it per tag keeps the panel honest about what a real
|
|
281
|
+
* invalidation would reach.
|
|
282
|
+
*/
|
|
283
|
+
async cacheGraph(): Promise<readonly CacheEdgeFact[]> {
|
|
284
|
+
const [{ dependentsOf }, { describeEntities }] = await Promise.all([
|
|
285
|
+
import('@ultimat3/cache'),
|
|
286
|
+
import('@ultimat3/entity'),
|
|
287
|
+
]);
|
|
288
|
+
return listOf(describeEntities()).map((raw) => {
|
|
289
|
+
const name = str(bagOf(raw)['name']);
|
|
290
|
+
return {
|
|
291
|
+
tag: name,
|
|
292
|
+
dependents: listOf(dependentsOf([{ entity: name }])).map((rawDep) => {
|
|
293
|
+
const dep = bagOf(rawDep);
|
|
294
|
+
return { kind: str(dep['kind']), id: str(dep['id']) };
|
|
295
|
+
}),
|
|
296
|
+
};
|
|
297
|
+
});
|
|
298
|
+
},
|
|
299
|
+
|
|
300
|
+
invalidations: unwired<readonly InvalidationFact[]>('invalidations', 'cache'),
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* The matrix is the app's own authz answering, actor by actor and permission by
|
|
304
|
+
* permission. A panel that re-derived permissions would be a second authz system.
|
|
305
|
+
*/
|
|
306
|
+
async policyMatrix(): Promise<readonly PolicyFact[]> {
|
|
307
|
+
const authz = opts.authz;
|
|
308
|
+
const actors = opts.actors ?? [];
|
|
309
|
+
if (authz === undefined || actors.length === 0) {
|
|
310
|
+
// Neither is a `hooks` entry — both are `DevSourceOptions` fields — so the rendered fix
|
|
311
|
+
// has to spell a real `defaultDevSources({ authz, actors })` call, not the default
|
|
312
|
+
// `hooks: { <source> }` phrasing (`{ authz + actors }` is not valid syntax).
|
|
313
|
+
throw new DevSourceUnavailableError({
|
|
314
|
+
source: 'authz + actors',
|
|
315
|
+
panel: 'policy',
|
|
316
|
+
wiring: '{ authz, actors }',
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
const { describeActions } = await import('@ultimat3/action');
|
|
320
|
+
// `ActionDescriptor`'s permission field is named `capability`, not `policy` — reading the
|
|
321
|
+
// latter answered '' for every action, so the matrix came back empty even when both
|
|
322
|
+
// `authz` and `actors` were wired correctly.
|
|
323
|
+
const permissions = [
|
|
324
|
+
...new Set(listOf(describeActions()).map((raw) => str(bagOf(raw)['capability']))),
|
|
325
|
+
].filter((permission) => permission !== '');
|
|
326
|
+
|
|
327
|
+
return actors.flatMap((actor) =>
|
|
328
|
+
permissions.map((permission) => {
|
|
329
|
+
const decision = authz.decide({ permission, actor });
|
|
330
|
+
return {
|
|
331
|
+
permission,
|
|
332
|
+
actorId: actor.id,
|
|
333
|
+
allowed: decision.allowed,
|
|
334
|
+
trace: decision.trace,
|
|
335
|
+
};
|
|
336
|
+
}),
|
|
337
|
+
);
|
|
338
|
+
},
|
|
339
|
+
|
|
340
|
+
manifest: unwired<ManifestFact>('manifest', 'manifest'),
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
return { ...sources, ...hooks };
|
|
344
|
+
}
|
package/src/dev/facts.ts
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// The facts a /_x panel may read: one shape per introspection call. Kept apart from the
|
|
2
|
+
// sources that produce them so a panel imports the shape it renders and nothing else.
|
|
3
|
+
|
|
4
|
+
export interface RouteFact {
|
|
5
|
+
readonly path: string;
|
|
6
|
+
readonly render: string;
|
|
7
|
+
readonly offline: string;
|
|
8
|
+
readonly hydrate: string;
|
|
9
|
+
readonly handler: string;
|
|
10
|
+
readonly budget: { readonly js?: string; readonly lcp?: number };
|
|
11
|
+
readonly revalidateTags: readonly string[];
|
|
12
|
+
readonly hasMeta: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `http` is the request span itself — the root every other span hangs off. Without it the flame
|
|
17
|
+
* has no depth-0 row to nest under, and a host would have to file the request under one of the
|
|
18
|
+
* things it contains.
|
|
19
|
+
*/
|
|
20
|
+
export type SpanKind = 'http' | 'sql' | 'cache' | 'action' | 'policy' | 'job' | 'render';
|
|
21
|
+
|
|
22
|
+
export interface TimelineSpan {
|
|
23
|
+
readonly id: string;
|
|
24
|
+
readonly parentId: string | null;
|
|
25
|
+
readonly kind: SpanKind;
|
|
26
|
+
readonly name: string;
|
|
27
|
+
readonly startMs: number;
|
|
28
|
+
readonly durationMs: number;
|
|
29
|
+
readonly detail: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface RequestTrace {
|
|
33
|
+
readonly requestId: string;
|
|
34
|
+
readonly method: string;
|
|
35
|
+
readonly path: string;
|
|
36
|
+
readonly status: number;
|
|
37
|
+
readonly startedAt: string;
|
|
38
|
+
readonly totalMs: number;
|
|
39
|
+
readonly spans: readonly TimelineSpan[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface LiveSubscriberFact {
|
|
43
|
+
readonly id: string;
|
|
44
|
+
readonly query: string;
|
|
45
|
+
readonly actorId: string;
|
|
46
|
+
readonly matched: boolean;
|
|
47
|
+
/** The matcher's decision, line by line. The whole point of the panel. */
|
|
48
|
+
readonly trace: readonly string[];
|
|
49
|
+
readonly rows: number;
|
|
50
|
+
readonly lastDeliveryAt: string | null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface LiveQueryFact {
|
|
54
|
+
readonly name: string;
|
|
55
|
+
readonly live: boolean;
|
|
56
|
+
readonly policy: string;
|
|
57
|
+
readonly sql: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface JobDefFact {
|
|
61
|
+
readonly name: string;
|
|
62
|
+
readonly queue: string;
|
|
63
|
+
readonly steps: readonly string[];
|
|
64
|
+
readonly retry: { readonly attempts: number; readonly backoff: string };
|
|
65
|
+
readonly idempotent: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface QueueFact {
|
|
69
|
+
readonly name: string;
|
|
70
|
+
readonly depth: number;
|
|
71
|
+
readonly running: number;
|
|
72
|
+
readonly failed: number;
|
|
73
|
+
readonly deadLetter: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface JobStepFact {
|
|
77
|
+
readonly name: string;
|
|
78
|
+
readonly status: 'ok' | 'running' | 'failed' | 'sleeping' | 'pending';
|
|
79
|
+
readonly attempt: number;
|
|
80
|
+
readonly durationMs: number;
|
|
81
|
+
readonly error: string | null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface JobRunFact {
|
|
85
|
+
readonly id: string;
|
|
86
|
+
readonly job: string;
|
|
87
|
+
readonly queue: string;
|
|
88
|
+
readonly status: 'ok' | 'running' | 'failed' | 'dead';
|
|
89
|
+
readonly attempt: number;
|
|
90
|
+
readonly steps: readonly JobStepFact[];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface TaskFact {
|
|
94
|
+
readonly name: string;
|
|
95
|
+
readonly cron: string;
|
|
96
|
+
readonly tz: string;
|
|
97
|
+
readonly nextRunAt: string | null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface ColumnFact {
|
|
101
|
+
readonly name: string;
|
|
102
|
+
readonly type: string;
|
|
103
|
+
readonly nullable: boolean;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface TableFact {
|
|
107
|
+
readonly name: string;
|
|
108
|
+
readonly columns: readonly ColumnFact[];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface DriftFact {
|
|
112
|
+
readonly table: string;
|
|
113
|
+
readonly column: string | null;
|
|
114
|
+
readonly issue: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface SqlResult {
|
|
118
|
+
readonly columns: readonly string[];
|
|
119
|
+
readonly rows: readonly (readonly unknown[])[];
|
|
120
|
+
readonly elapsedMs: number;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface MailFact {
|
|
124
|
+
readonly id: string;
|
|
125
|
+
readonly to: string;
|
|
126
|
+
readonly subject: string;
|
|
127
|
+
readonly locale: string;
|
|
128
|
+
readonly html: string;
|
|
129
|
+
readonly text: string;
|
|
130
|
+
readonly sentAt: string;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface CacheEdgeFact {
|
|
134
|
+
readonly tag: string;
|
|
135
|
+
readonly dependents: readonly { readonly kind: string; readonly id: string }[];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface InvalidationFact {
|
|
139
|
+
readonly at: string;
|
|
140
|
+
readonly tags: readonly string[];
|
|
141
|
+
readonly busted: readonly string[];
|
|
142
|
+
readonly source: string;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface PolicyFact {
|
|
146
|
+
readonly permission: string;
|
|
147
|
+
readonly actorId: string;
|
|
148
|
+
readonly allowed: boolean;
|
|
149
|
+
readonly trace: readonly string[];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export interface ManifestFact {
|
|
153
|
+
readonly emitted: unknown;
|
|
154
|
+
readonly committed: unknown;
|
|
155
|
+
readonly diff: readonly {
|
|
156
|
+
readonly path: string;
|
|
157
|
+
readonly emitted: unknown;
|
|
158
|
+
readonly committed: unknown;
|
|
159
|
+
}[];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The whole introspection surface /_x is allowed to read. Nothing else is in scope. */
|
|
163
|
+
export interface DevSources {
|
|
164
|
+
routes(): Promise<readonly RouteFact[]>;
|
|
165
|
+
traces(): Promise<readonly RequestTrace[]>;
|
|
166
|
+
liveQueries(): Promise<readonly LiveQueryFact[]>;
|
|
167
|
+
subscribers(): Promise<readonly LiveSubscriberFact[]>;
|
|
168
|
+
jobDefs(): Promise<readonly JobDefFact[]>;
|
|
169
|
+
queues(): Promise<readonly QueueFact[]>;
|
|
170
|
+
jobRuns(): Promise<readonly JobRunFact[]>;
|
|
171
|
+
tasks(): Promise<readonly TaskFact[]>;
|
|
172
|
+
tables(): Promise<readonly TableFact[]>;
|
|
173
|
+
drift(): Promise<readonly DriftFact[]>;
|
|
174
|
+
runSql(sql: string): Promise<SqlResult>;
|
|
175
|
+
mail(): Promise<readonly MailFact[]>;
|
|
176
|
+
cacheGraph(): Promise<readonly CacheEdgeFact[]>;
|
|
177
|
+
invalidations(): Promise<readonly InvalidationFact[]>;
|
|
178
|
+
policyMatrix(): Promise<readonly PolicyFact[]>;
|
|
179
|
+
manifest(): Promise<ManifestFact>;
|
|
180
|
+
}
|
package/src/dev/index.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// The `/_x` dev dashboard's own door, reached as `@ultimat3/admin/dev`.
|
|
2
|
+
//
|
|
3
|
+
// A separate door from the package root on purpose: the CLI's only job here is to MOUNT `/_x`,
|
|
4
|
+
// so it must never pull `src/*`'s production Solid component tree into every `x dev` process.
|
|
5
|
+
|
|
6
|
+
export { type DevSourceOptions, defaultDevSources, staticDevSources } from './data';
|
|
7
|
+
export type {
|
|
8
|
+
CacheEdgeFact,
|
|
9
|
+
ColumnFact,
|
|
10
|
+
DevSources,
|
|
11
|
+
DriftFact,
|
|
12
|
+
InvalidationFact,
|
|
13
|
+
JobDefFact,
|
|
14
|
+
JobRunFact,
|
|
15
|
+
JobStepFact,
|
|
16
|
+
LiveQueryFact,
|
|
17
|
+
LiveSubscriberFact,
|
|
18
|
+
MailFact,
|
|
19
|
+
ManifestFact,
|
|
20
|
+
PolicyFact,
|
|
21
|
+
QueueFact,
|
|
22
|
+
RequestTrace,
|
|
23
|
+
RouteFact,
|
|
24
|
+
SpanKind,
|
|
25
|
+
SqlResult,
|
|
26
|
+
TableFact,
|
|
27
|
+
TaskFact,
|
|
28
|
+
TimelineSpan,
|
|
29
|
+
} from './facts';
|
|
30
|
+
export { type DevPanel, type PanelPayload, panelPayload } from './panel';
|
|
31
|
+
export { type CachePanelData, cachePanel } from './panel-cache';
|
|
32
|
+
export { assertReadOnly, type DbPanelData, dbPanel } from './panel-db';
|
|
33
|
+
export { type JobsPanelData, jobsPanel } from './panel-jobs';
|
|
34
|
+
export { type LivePanelData, livePanel } from './panel-live';
|
|
35
|
+
export { type MailPanelData, mailPanel } from './panel-mail';
|
|
36
|
+
export { type ManifestPanelData, manifestPanel } from './panel-manifest';
|
|
37
|
+
export { type PolicyPanelData, policyPanel } from './panel-policy';
|
|
38
|
+
export { type RoutesPanelData, routesPanel } from './panel-routes';
|
|
39
|
+
export { type TimelinePanelData, timelinePanel } from './panel-timeline';
|
|
40
|
+
export {
|
|
41
|
+
assertDevOnly,
|
|
42
|
+
DEV_BASE_PATH,
|
|
43
|
+
DEV_PANELS,
|
|
44
|
+
type DevDashboard,
|
|
45
|
+
type DevDashboardOptions,
|
|
46
|
+
devDashboard,
|
|
47
|
+
} from './server';
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Panel: Cache.
|
|
2
|
+
// Kills: "why is this page still stale?" — the tag graph, and the invalidation log showing
|
|
3
|
+
// what busted what.
|
|
4
|
+
|
|
5
|
+
import type { CacheEdgeFact, InvalidationFact } from './facts';
|
|
6
|
+
import type { DevPanel } from './panel';
|
|
7
|
+
|
|
8
|
+
export interface CachePanelData {
|
|
9
|
+
readonly graph: readonly CacheEdgeFact[];
|
|
10
|
+
readonly invalidations: readonly InvalidationFact[];
|
|
11
|
+
/** Tags nothing depends on: an `invalidates: [tag.x]` that can never bust anything. */
|
|
12
|
+
readonly orphanTags: readonly string[];
|
|
13
|
+
/** Dependents per kind — cache keys vs ISR routes vs CDN paths vs live queries. */
|
|
14
|
+
readonly byKind: Readonly<Record<string, number>>;
|
|
15
|
+
/** Dependents the log shows actually being busted. The rest are still holding. */
|
|
16
|
+
readonly bustedRecently: readonly string[];
|
|
17
|
+
readonly note: string | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const cachePanel: DevPanel<CachePanelData> = {
|
|
21
|
+
key: 'cache',
|
|
22
|
+
titleKey: 'dev.panel.cache',
|
|
23
|
+
question: 'what invalidated what — and why is this still stale?',
|
|
24
|
+
async data(sources): Promise<CachePanelData> {
|
|
25
|
+
const graph = await sources.cacheGraph();
|
|
26
|
+
// The invalidation log needs a running process that has served a write; the graph alone
|
|
27
|
+
// is still worth showing.
|
|
28
|
+
const invalidations = await sources
|
|
29
|
+
.invalidations()
|
|
30
|
+
.catch((): readonly InvalidationFact[] => []);
|
|
31
|
+
|
|
32
|
+
const busted = new Set(invalidations.flatMap((event) => event.busted));
|
|
33
|
+
const byKind: Record<string, number> = {};
|
|
34
|
+
for (const edge of graph) {
|
|
35
|
+
for (const dep of edge.dependents) byKind[dep.kind] = (byKind[dep.kind] ?? 0) + 1;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
graph,
|
|
40
|
+
invalidations,
|
|
41
|
+
orphanTags: graph.filter((edge) => edge.dependents.length === 0).map((edge) => edge.tag),
|
|
42
|
+
byKind,
|
|
43
|
+
bustedRecently: [...busted].sort(),
|
|
44
|
+
note: invalidations.length === 0 ? 'dev.cache.no-invalidations-yet' : null,
|
|
45
|
+
};
|
|
46
|
+
},
|
|
47
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Panel: DB.
|
|
2
|
+
// Kills: "what is actually in the table, and does it match the migrations?" — psql in a tab,
|
|
3
|
+
// read-only by default, plus the schema diff against the migration history.
|
|
4
|
+
|
|
5
|
+
import type { DriftFact, SqlResult, TableFact } from './facts';
|
|
6
|
+
import type { DevPanel } from './panel';
|
|
7
|
+
|
|
8
|
+
export interface DbPanelData {
|
|
9
|
+
readonly tables: readonly TableFact[];
|
|
10
|
+
readonly drift: readonly DriftFact[];
|
|
11
|
+
readonly sql: string | null;
|
|
12
|
+
readonly result: SqlResult | null;
|
|
13
|
+
/** Set when a statement was refused; the panel shows it instead of a result grid. */
|
|
14
|
+
readonly refused: string | null;
|
|
15
|
+
readonly readOnly: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const WRITE_STATEMENT =
|
|
19
|
+
/\b(insert|update|delete|drop|alter|truncate|create|grant|revoke|copy|vacuum)\b/i;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Read-only is enforced here, not just in the UI: /_x runs with the developer's own DB
|
|
23
|
+
* credentials, so "the textarea only sends SELECTs" would be the whole safety story.
|
|
24
|
+
* `x db psql --write` is the deliberate way out.
|
|
25
|
+
*/
|
|
26
|
+
export function assertReadOnly(sql: string): string | null {
|
|
27
|
+
const stripped = sql.replace(/--[^\n]*/g, ' ').trim();
|
|
28
|
+
if (stripped === '') return null;
|
|
29
|
+
if (WRITE_STATEMENT.test(stripped)) {
|
|
30
|
+
return `refused: the /_x DB panel is read-only. Run it with: x db psql --write`;
|
|
31
|
+
}
|
|
32
|
+
if (!/^(select|with|explain|show|table)\b/i.test(stripped)) {
|
|
33
|
+
return 'refused: only SELECT / WITH / EXPLAIN / SHOW / TABLE statements run here';
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const dbPanel: DevPanel<DbPanelData> = {
|
|
39
|
+
key: 'db',
|
|
40
|
+
titleKey: 'dev.panel.db',
|
|
41
|
+
question: 'what is in the table, and does the schema match the migrations?',
|
|
42
|
+
async data(sources, params): Promise<DbPanelData> {
|
|
43
|
+
const [tables, drift] = await Promise.all([sources.tables(), sources.drift()]);
|
|
44
|
+
const sql = params.get('sql');
|
|
45
|
+
if (sql === null || sql.trim() === '') {
|
|
46
|
+
return { tables, drift, sql: null, result: null, refused: null, readOnly: true };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const refused = assertReadOnly(sql);
|
|
50
|
+
return {
|
|
51
|
+
tables,
|
|
52
|
+
drift,
|
|
53
|
+
sql,
|
|
54
|
+
result: refused === null ? await sources.runSql(sql) : null,
|
|
55
|
+
refused,
|
|
56
|
+
readOnly: true,
|
|
57
|
+
};
|
|
58
|
+
},
|
|
59
|
+
};
|