@ultimat3/admin 22.15.0 → 24.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/CLAUDE.md +83 -91
- package/README.md +282 -89
- package/package.json +19 -17
- package/src/action-form.tsx +139 -0
- package/src/action-gate.ts +141 -68
- package/src/action-input.ts +119 -0
- package/src/action-label.ts +9 -0
- package/src/action-row.ts +37 -0
- package/src/actions.tsx +89 -66
- package/src/actor.ts +53 -0
- package/src/admin.module.scss +298 -0
- package/src/admin.ts +187 -23
- package/src/audit-pg.ts +207 -0
- package/src/audit-schema.ts +47 -0
- package/src/audit.ts +109 -22
- package/src/authz.ts +11 -0
- package/src/batch-bar.tsx +66 -0
- package/src/batch-job.ts +152 -0
- package/src/batch-matching.ts +87 -0
- package/src/batch-queue.ts +90 -0
- package/src/batch.ts +396 -0
- package/src/catalog-keys.ts +55 -0
- package/src/crud-input.ts +104 -0
- package/src/crud-outcome.ts +224 -0
- package/src/crud.ts +172 -176
- package/src/describe.ts +131 -0
- package/src/detail.tsx +115 -43
- package/src/dev/data.ts +27 -12
- package/src/dev/facts.ts +28 -1
- package/src/dev/index.ts +1 -0
- package/src/dev/panel-cache.ts +8 -3
- package/src/dev/panel-db.ts +37 -5
- package/src/dev/panel-jobs.ts +8 -0
- package/src/dev/panel-routes.ts +10 -0
- package/src/dev/panel-timeline.ts +7 -2
- package/src/dev/panel.ts +10 -2
- package/src/dev/server.ts +16 -11
- package/src/dev/sql-runner.ts +31 -0
- package/src/entity-columns.ts +24 -5
- package/src/errors.ts +92 -5
- package/src/fields.ts +63 -4
- package/src/form-decode.ts +132 -0
- package/src/form.tsx +96 -49
- package/src/index.ts +199 -9
- package/src/jobs/fleet-repo.ts +155 -0
- package/src/jobs/job-actions.ts +172 -0
- package/src/jobs/job-entities.ts +118 -0
- package/src/jobs/job-labels.ts +108 -0
- package/src/jobs/job-repo.ts +137 -0
- package/src/jobs/job-resources.ts +211 -0
- package/src/jobs/job-where.ts +170 -0
- package/src/jobs/operator.ts +34 -0
- package/src/jobs/overview-data.ts +129 -0
- package/src/jobs/overview-tab.tsx +61 -0
- package/src/jobs/overview.tsx +189 -0
- package/src/json-text.ts +52 -0
- package/src/keyset-seek.ts +189 -0
- package/src/layout.tsx +55 -43
- package/src/list-columns.tsx +53 -0
- package/src/list-filter-bar.tsx +207 -0
- package/src/list-filters.ts +242 -0
- package/src/list-request.ts +129 -0
- package/src/list-scope-tabs.tsx +60 -0
- package/src/list-scope.ts +113 -0
- package/src/list.tsx +111 -72
- package/src/mcp-tools.ts +57 -13
- package/src/mcp.ts +112 -38
- package/src/mounts.ts +44 -0
- package/src/page-guard.tsx +26 -29
- package/src/pagination.ts +10 -4
- package/src/policy-bridge.ts +115 -18
- package/src/registry.ts +140 -9
- package/src/related.ts +105 -0
- package/src/relations.ts +240 -0
- package/src/renderers.tsx +89 -0
- package/src/repo-entity.ts +195 -0
- package/src/resource-fields.ts +116 -0
- package/src/resource-layout.ts +89 -0
- package/src/resource-list.ts +194 -0
- package/src/resource.ts +129 -79
- package/src/route-config.ts +71 -0
- package/src/routes.ts +55 -54
- package/src/row-scope-write.ts +130 -0
- package/src/row-version.ts +20 -0
- package/src/screen-action.tsx +333 -0
- package/src/screen-frame.tsx +167 -0
- package/src/screen-home.tsx +201 -0
- package/src/screen-lookup.tsx +157 -0
- package/src/screen-resource.tsx +441 -0
- package/src/screen-system.tsx +75 -0
- package/src/screens.ts +56 -0
- package/src/search.ts +22 -10
- package/src/stylesheet-scope.ts +8 -0
- package/src/test-ctx.ts +31 -0
- package/src/validate.ts +3 -0
- package/src/widget-value.ts +36 -8
- package/src/widgets.tsx +84 -16
package/src/detail.tsx
CHANGED
|
@@ -1,16 +1,21 @@
|
|
|
1
|
-
// The detail view:
|
|
2
|
-
// and the actions this actor may run on it
|
|
3
|
-
//
|
|
1
|
+
// The detail view: the row's fields in their sections, the related resources' own lists filtered
|
|
2
|
+
// to this row, the row's history, and the actions this actor may run on it — each a link or a
|
|
3
|
+
// native form, so the screen ships no script. A detail page with no row is a state, not a card.
|
|
4
4
|
|
|
5
5
|
import { t } from '@ultimat3/i18n';
|
|
6
|
-
import { Card, ErrorState } from '@ultimat3/ui';
|
|
6
|
+
import { Button, Card, DataTable, ErrorState, Link } from '@ultimat3/ui';
|
|
7
7
|
import type { JSX } from 'solid-js';
|
|
8
|
-
import
|
|
9
|
-
import
|
|
10
|
-
import type { AuditEntry } from './audit';
|
|
8
|
+
import { AdminActions, ConfirmationField, DELETE_OPERATION, OPERATION_FIELD } from './actions';
|
|
9
|
+
import styles from './admin.module.scss';
|
|
10
|
+
import type { AuditCursor, AuditEntry } from './audit';
|
|
11
11
|
import type { AdminActor, AdminAuthz } from './authz';
|
|
12
12
|
import { type AdminErrorParts, adminErrorFrom } from './errors';
|
|
13
|
+
import { valueText } from './json-text';
|
|
14
|
+
import { resourceColumns } from './list-columns';
|
|
15
|
+
import { confirmationToken } from './permissions';
|
|
13
16
|
import type { AdminRow } from './registry';
|
|
17
|
+
import { rowId } from './registry';
|
|
18
|
+
import type { AdminRelatedList } from './related';
|
|
14
19
|
import type { AdminResource } from './resource';
|
|
15
20
|
import type { WidgetContext } from './widget-value';
|
|
16
21
|
import { Widget } from './widgets';
|
|
@@ -18,19 +23,24 @@ import { Widget } from './widgets';
|
|
|
18
23
|
export interface AdminDetailProps<Row extends AdminRow> {
|
|
19
24
|
readonly resource: AdminResource<Row>;
|
|
20
25
|
readonly row: Row | null;
|
|
21
|
-
readonly loading: boolean;
|
|
22
26
|
readonly error: AdminErrorParts | null;
|
|
23
27
|
readonly ctx: WidgetContext;
|
|
24
28
|
readonly actor: AdminActor;
|
|
25
29
|
readonly authz: AdminAuthz;
|
|
30
|
+
/** This row's history, newest first: one keyset page of what changed it or was refused. */
|
|
26
31
|
readonly audit: readonly AuditEntry[];
|
|
32
|
+
/** The URL of the next, older page of the history, or `null` when this page is the last. */
|
|
33
|
+
readonly olderHref?: string | null;
|
|
34
|
+
/** Each declared related resource's own list, filtered to this row. */
|
|
35
|
+
readonly related?: readonly AdminRelatedList[];
|
|
36
|
+
/** The URL of a related resource's list, filtered to this row — "everything", one link away. */
|
|
37
|
+
readonly relatedHref?: (list: AdminRelatedList) => string;
|
|
27
38
|
readonly basePath: string;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
readonly
|
|
33
|
-
readonly onCancel?: () => void;
|
|
39
|
+
/**
|
|
40
|
+
* What the gate already decided about THIS row, so the view renders a control only for an
|
|
41
|
+
* operation the POST behind it would allow. Absent reads as "not allowed".
|
|
42
|
+
*/
|
|
43
|
+
readonly may?: { readonly update: boolean; readonly delete: boolean };
|
|
34
44
|
}
|
|
35
45
|
|
|
36
46
|
/**
|
|
@@ -49,13 +59,6 @@ export function AdminDetail<Row extends AdminRow>(props: AdminDetailProps<Row>):
|
|
|
49
59
|
if (props.error !== null) {
|
|
50
60
|
return <ErrorState error={adminErrorFrom(props.error)} />;
|
|
51
61
|
}
|
|
52
|
-
if (props.loading) {
|
|
53
|
-
return (
|
|
54
|
-
<Card header={<h2>{t(props.resource.titleKey)}</h2>}>
|
|
55
|
-
<p aria-busy="true">{t('admin.detail.loading')}</p>
|
|
56
|
-
</Card>
|
|
57
|
-
);
|
|
58
|
-
}
|
|
59
62
|
if (props.row === null) {
|
|
60
63
|
return (
|
|
61
64
|
<ErrorState
|
|
@@ -73,6 +76,7 @@ export function AdminDetail<Row extends AdminRow>(props: AdminDetailProps<Row>):
|
|
|
73
76
|
|
|
74
77
|
const row = props.row;
|
|
75
78
|
const id = String(row[props.resource.idField] ?? '');
|
|
79
|
+
const href = `${props.basePath}${props.resource.path}/${id}`;
|
|
76
80
|
|
|
77
81
|
return (
|
|
78
82
|
<>
|
|
@@ -81,31 +85,76 @@ export function AdminDetail<Row extends AdminRow>(props: AdminDetailProps<Row>):
|
|
|
81
85
|
actions={props.resource.actions}
|
|
82
86
|
actor={props.actor}
|
|
83
87
|
authz={props.authz}
|
|
84
|
-
subject={{ entity: props.resource.name, id }}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
onRun={props.onRun}
|
|
88
|
-
onRequestConfirm={props.onRequestConfirm}
|
|
89
|
-
onConfirmationInput={props.onConfirmationInput}
|
|
90
|
-
onCancel={props.onCancel}
|
|
88
|
+
subject={{ entity: props.resource.name, id, row }}
|
|
89
|
+
sealed={props.resource.secretFields.map((field) => field.name)}
|
|
90
|
+
href={href}
|
|
91
91
|
/>
|
|
92
92
|
|
|
93
|
-
|
|
94
|
-
{
|
|
95
|
-
.
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
<
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
93
|
+
{props.resource.sections.map((section) => (
|
|
94
|
+
<section class={styles['section']}>
|
|
95
|
+
{section.titleKey === null ? null : <h3>{t(section.titleKey)}</h3>}
|
|
96
|
+
<dl class={styles['fields']}>
|
|
97
|
+
{section.fields.map((field) => (
|
|
98
|
+
<>
|
|
99
|
+
<dt>{t(field.labelKey)}</dt>
|
|
100
|
+
<dd>
|
|
101
|
+
<Widget field={field} value={row[field.name]} ctx={props.ctx} mode="read" />
|
|
102
|
+
</dd>
|
|
103
|
+
</>
|
|
104
|
+
))}
|
|
105
|
+
</dl>
|
|
106
|
+
</section>
|
|
107
|
+
))}
|
|
105
108
|
|
|
106
|
-
<
|
|
109
|
+
<div class={styles['actions']}>
|
|
110
|
+
{props.may?.update === true ? (
|
|
111
|
+
<Link appearance="button" variant="secondary" tone="neutral" href={`${href}/edit`}>
|
|
112
|
+
{t('admin.detail.edit')}
|
|
113
|
+
</Link>
|
|
114
|
+
) : null}
|
|
115
|
+
{props.may?.delete === true ? (
|
|
116
|
+
// Destructive, so it re-confirms: the token is on screen to be copied, and typing it
|
|
117
|
+
// is the deliberate act. A native POST — the row's own URL is the subject.
|
|
118
|
+
<form class={styles['actionForm']} method="post" action={href}>
|
|
119
|
+
<input type="hidden" name={OPERATION_FIELD} value={DELETE_OPERATION} />
|
|
120
|
+
<ConfirmationField token={confirmationToken(props.resource.name, id)} />
|
|
121
|
+
<Button type="submit" size="sm" variant="secondary" tone="danger">
|
|
122
|
+
{t('admin.detail.delete')}
|
|
123
|
+
</Button>
|
|
124
|
+
</form>
|
|
125
|
+
) : null}
|
|
126
|
+
</div>
|
|
107
127
|
</Card>
|
|
108
128
|
|
|
129
|
+
{(props.related ?? []).map((list) => {
|
|
130
|
+
const target = list.related.resource;
|
|
131
|
+
const base = `${props.basePath}${target.path}`;
|
|
132
|
+
return (
|
|
133
|
+
<Card header={<h2>{t(target.titleKey)}</h2>}>
|
|
134
|
+
{list.page.rows.length === 0 ? (
|
|
135
|
+
<p class="x-admin-empty">{t('admin.list.empty')}</p>
|
|
136
|
+
) : (
|
|
137
|
+
<DataTable
|
|
138
|
+
caption={t(target.titleKey)}
|
|
139
|
+
// The related resource's OWN columns, minus the key every row here shares.
|
|
140
|
+
columns={resourceColumns({
|
|
141
|
+
resource: target,
|
|
142
|
+
ctx: props.ctx,
|
|
143
|
+
hrefOf: (rowKey) => `${base}/${rowKey}`,
|
|
144
|
+
omit: list.related.field,
|
|
145
|
+
})}
|
|
146
|
+
rows={list.page.rows}
|
|
147
|
+
rowKey={(one) => rowId(one, target.idField)}
|
|
148
|
+
hrefFor={() => props.relatedHref?.(list) ?? base}
|
|
149
|
+
/>
|
|
150
|
+
)}
|
|
151
|
+
{list.page.hasMore && props.relatedHref !== undefined ? (
|
|
152
|
+
<Link href={props.relatedHref(list)}>{t('admin.related.all')}</Link>
|
|
153
|
+
) : null}
|
|
154
|
+
</Card>
|
|
155
|
+
);
|
|
156
|
+
})}
|
|
157
|
+
|
|
109
158
|
<Card header={<h2>{t('admin.audit.title')}</h2>}>
|
|
110
159
|
{props.audit.length === 0 ? (
|
|
111
160
|
<p class="x-admin-empty">{t('admin.audit.empty')}</p>
|
|
@@ -121,8 +170,8 @@ export function AdminDetail<Row extends AdminRow>(props: AdminDetailProps<Row>):
|
|
|
121
170
|
<ul>
|
|
122
171
|
{entry.diff.map((change) => (
|
|
123
172
|
<li>
|
|
124
|
-
<code>{change.field}</code>: <del>{
|
|
125
|
-
<ins>{
|
|
173
|
+
<code>{change.field}</code>: <del>{valueText(change.before)}</del>{' '}
|
|
174
|
+
<ins>{valueText(change.after)}</ins>
|
|
126
175
|
</li>
|
|
127
176
|
))}
|
|
128
177
|
</ul>
|
|
@@ -130,7 +179,30 @@ export function AdminDetail<Row extends AdminRow>(props: AdminDetailProps<Row>):
|
|
|
130
179
|
))}
|
|
131
180
|
</ol>
|
|
132
181
|
)}
|
|
182
|
+
{props.olderHref === null || props.olderHref === undefined ? null : (
|
|
183
|
+
<Link href={props.olderHref}>{t('admin.audit.older')}</Link>
|
|
184
|
+
)}
|
|
133
185
|
</Card>
|
|
134
186
|
</>
|
|
135
187
|
);
|
|
136
188
|
}
|
|
189
|
+
|
|
190
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
191
|
+
|
|
192
|
+
/** The query parameter an older page of a row's history is asked for by: `?history=<at>~<id>`. */
|
|
193
|
+
export const HISTORY_PARAM = 'history';
|
|
194
|
+
|
|
195
|
+
/** The cursor as one parameter value — an ISO instant holds no `~`, a uuid neither. */
|
|
196
|
+
export const historyCursorText = (cursor: AuditCursor): string => `${cursor.at}~${cursor.id}`;
|
|
197
|
+
|
|
198
|
+
/** A posted-back cursor, or `undefined` for anything that is not one: page one, never an error. */
|
|
199
|
+
export function historyCursorOf(raw: string | null): AuditCursor | undefined {
|
|
200
|
+
if (raw === null) return undefined;
|
|
201
|
+
const at = raw.indexOf('~');
|
|
202
|
+
if (at <= 0) return undefined;
|
|
203
|
+
const instant = raw.slice(0, at);
|
|
204
|
+
const id = raw.slice(at + 1);
|
|
205
|
+
// A uuid, because the durable log compares it as one: anything else would be a cast error from
|
|
206
|
+
// the database where a hand-edited URL deserves page one.
|
|
207
|
+
return Number.isNaN(Date.parse(instant)) || !UUID.test(id) ? undefined : { at: instant, id };
|
|
208
|
+
}
|
package/src/dev/data.ts
CHANGED
|
@@ -155,15 +155,18 @@ export function defaultDevSources(opts: DevSourceOptions = {}): DevSources {
|
|
|
155
155
|
hydrate: route.hydrate,
|
|
156
156
|
// A descriptor has no `handler`: the FILE is what names the row.
|
|
157
157
|
handler: route.file,
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
budget: {
|
|
161
|
-
...(route.budgetJs === null ? {} : { js: route.budgetJs }),
|
|
162
|
-
...(route.budgetLcp === null ? {} : { lcp: route.budgetLcp }),
|
|
163
|
-
},
|
|
158
|
+
// A flat field on the descriptor, a nested bag on the fact — the panel's budget check
|
|
159
|
+
// reads `budget.js`. Spread, never `js: undefined`: `exactOptionalPropertyTypes`.
|
|
160
|
+
budget: route.budgetJs === null ? {} : { js: route.budgetJs },
|
|
164
161
|
// `revalidateTags`, already flattened to keys — never `revalidate.tags`, a shape the
|
|
165
162
|
// descriptor does not have and which answered `[]` for every ISR route in the app.
|
|
166
163
|
revalidateTags: route.revalidateTags,
|
|
164
|
+
// Present only on a route a package mounted. Copied, never aliased: the fact is a panel
|
|
165
|
+
// payload, and the registry's own list must not be reachable through it.
|
|
166
|
+
mount:
|
|
167
|
+
route.mount === undefined
|
|
168
|
+
? null
|
|
169
|
+
: { by: route.mount.by, permissions: [...route.mount.permissions] },
|
|
167
170
|
}));
|
|
168
171
|
},
|
|
169
172
|
|
|
@@ -211,24 +214,27 @@ export function defaultDevSources(opts: DevSourceOptions = {}): DevSources {
|
|
|
211
214
|
// field on a descriptor that never carried it, so every job on the panel reported
|
|
212
215
|
// non-idempotent — the opposite of what `job()` refuses to register without.
|
|
213
216
|
idempotent: job.idempotent,
|
|
217
|
+
concurrency: job.concurrency,
|
|
218
|
+
onSettled: job.onSettled,
|
|
214
219
|
}));
|
|
215
220
|
},
|
|
216
221
|
|
|
217
222
|
async queues(): Promise<readonly QueueFact[]> {
|
|
218
|
-
const {
|
|
223
|
+
const { inspectQueues, jobDriver } = await import('@ultimat3/jobs');
|
|
219
224
|
const driver = jobDriver();
|
|
220
225
|
if (driver === undefined) throw unavailable('queues', 'jobs');
|
|
221
226
|
const report = await inspectQueues(driver);
|
|
222
|
-
//
|
|
223
|
-
|
|
224
|
-
const failed =
|
|
225
|
-
driver.introspect === undefined ? [] : await inspectJobList(driver, { state: 'failed' });
|
|
227
|
+
// A driver with no introspection has nothing to pause a queue with.
|
|
228
|
+
const paused = new Set((await driver.introspect?.pausedQueues())?.map((entry) => entry.name));
|
|
226
229
|
return report.queues.map((queue) => ({
|
|
227
230
|
name: queue.queue,
|
|
228
231
|
depth: queue.ready + queue.delayed,
|
|
229
232
|
running: queue.running,
|
|
230
|
-
|
|
233
|
+
// `stats()` has its own `failed` bucket: it was counted off the job LIST, one page of it,
|
|
234
|
+
// so a queue with more failed rows than a page holds reported the page size.
|
|
235
|
+
failed: queue.failed,
|
|
231
236
|
deadLetter: queue.dead,
|
|
237
|
+
paused: paused.has(queue.queue),
|
|
232
238
|
}));
|
|
233
239
|
},
|
|
234
240
|
|
|
@@ -248,6 +254,15 @@ export function defaultDevSources(opts: DevSourceOptions = {}): DevSources {
|
|
|
248
254
|
queue: trace.queue,
|
|
249
255
|
status: RUN_STATUS[trace.state],
|
|
250
256
|
attempt: trace.attempt,
|
|
257
|
+
concurrencyKey: trace.concurrencyKey,
|
|
258
|
+
progress:
|
|
259
|
+
trace.progress === null
|
|
260
|
+
? null
|
|
261
|
+
: {
|
|
262
|
+
done: trace.progress.done,
|
|
263
|
+
total: trace.progress.total,
|
|
264
|
+
note: trace.progress.note ?? null,
|
|
265
|
+
},
|
|
251
266
|
steps: trace.steps.map((step) => ({
|
|
252
267
|
name: step.name,
|
|
253
268
|
status: STEP_STATUS[step.status],
|
package/src/dev/facts.ts
CHANGED
|
@@ -20,8 +20,14 @@ export interface RouteFact {
|
|
|
20
20
|
readonly hydrate: string;
|
|
21
21
|
/** The route's source file: what names the row. A descriptor publishes no handler. */
|
|
22
22
|
readonly handler: string;
|
|
23
|
-
readonly budget: { readonly js?: string
|
|
23
|
+
readonly budget: { readonly js?: string };
|
|
24
24
|
readonly revalidateTags: readonly string[];
|
|
25
|
+
/**
|
|
26
|
+
* `RouteDescriptor.mount` — who mounted a route no surface file declares (`defineAdmin`), and
|
|
27
|
+
* every permission that gates it. `null` for a file route: nobody mounted it, and `handler` is
|
|
28
|
+
* then a path an editor can open rather than a package name.
|
|
29
|
+
*/
|
|
30
|
+
readonly mount: { readonly by: string; readonly permissions: readonly string[] } | null;
|
|
25
31
|
}
|
|
26
32
|
|
|
27
33
|
/**
|
|
@@ -104,6 +110,17 @@ export interface JobDefFact {
|
|
|
104
110
|
* the app unsafe to retry.
|
|
105
111
|
*/
|
|
106
112
|
readonly idempotent: boolean;
|
|
113
|
+
/**
|
|
114
|
+
* `JobDescriptor.concurrency` — the declared fleet-wide cap, `null` for none. `keyed` says the
|
|
115
|
+
* limit holds per `concurrency.key(input)`; the key itself is a RUN's fact (`JobRunFact`).
|
|
116
|
+
*/
|
|
117
|
+
readonly concurrency: {
|
|
118
|
+
readonly limit: number;
|
|
119
|
+
readonly keyed: boolean;
|
|
120
|
+
readonly whenBusy: 'wait' | 'fail' | null;
|
|
121
|
+
} | null;
|
|
122
|
+
/** Whether the job declares an `onSettled` hook — what is told how each run ended. */
|
|
123
|
+
readonly onSettled: boolean;
|
|
107
124
|
}
|
|
108
125
|
|
|
109
126
|
export interface QueueFact {
|
|
@@ -112,6 +129,8 @@ export interface QueueFact {
|
|
|
112
129
|
readonly running: number;
|
|
113
130
|
readonly failed: number;
|
|
114
131
|
readonly deadLetter: number;
|
|
132
|
+
/** A paused queue is claimed by no worker; its depth is work waiting on an operator. */
|
|
133
|
+
readonly paused: boolean;
|
|
115
134
|
}
|
|
116
135
|
|
|
117
136
|
export interface JobStepFact {
|
|
@@ -128,6 +147,14 @@ export interface JobRunFact {
|
|
|
128
147
|
readonly queue: string;
|
|
129
148
|
readonly status: 'ok' | 'running' | 'failed' | 'dead';
|
|
130
149
|
readonly attempt: number;
|
|
150
|
+
/** The concurrency key this run counts under — `null` unless its job declares a keyed cap. */
|
|
151
|
+
readonly concurrencyKey: string | null;
|
|
152
|
+
/** What the body last reported through `progress()`; `null` until it reports. */
|
|
153
|
+
readonly progress: {
|
|
154
|
+
readonly done: number;
|
|
155
|
+
readonly total: number;
|
|
156
|
+
readonly note: string | null;
|
|
157
|
+
} | null;
|
|
131
158
|
readonly steps: readonly JobStepFact[];
|
|
132
159
|
}
|
|
133
160
|
|
package/src/dev/index.ts
CHANGED
package/src/dev/panel-cache.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Kills: "why is this page still stale?" — the tag graph, and the invalidation log showing
|
|
3
3
|
// what busted what.
|
|
4
4
|
|
|
5
|
+
import { DevSourceUnavailableError } from '../errors';
|
|
5
6
|
import type { CacheEdgeFact, InvalidationFact } from './facts';
|
|
6
7
|
import type { DevPanel } from './panel';
|
|
7
8
|
|
|
@@ -23,11 +24,15 @@ export const cachePanel: DevPanel<CachePanelData> = {
|
|
|
23
24
|
questionKey: 'dev.panel.cache.question',
|
|
24
25
|
async data(sources): Promise<CachePanelData> {
|
|
25
26
|
const graph = await sources.cacheGraph();
|
|
26
|
-
// The invalidation log needs a running process that has served a write; the graph alone
|
|
27
|
-
//
|
|
27
|
+
// The invalidation log needs a running process that has served a write; the graph alone is
|
|
28
|
+
// still worth showing when it is NOT WIRED — and only then. A log that is wired and failed is
|
|
29
|
+
// a diagnostic, not "no invalidations yet".
|
|
28
30
|
const invalidations = await sources
|
|
29
31
|
.invalidations()
|
|
30
|
-
.catch((): readonly InvalidationFact[] =>
|
|
32
|
+
.catch((error: unknown): readonly InvalidationFact[] => {
|
|
33
|
+
if (!(error instanceof DevSourceUnavailableError)) throw error;
|
|
34
|
+
return [];
|
|
35
|
+
});
|
|
31
36
|
|
|
32
37
|
const busted = new Set(invalidations.flatMap((event) => event.busted));
|
|
33
38
|
// A `Map`, then `Object.fromEntries` — never `count[key] = (count[key] ?? 0) + 1` on a plain
|
package/src/dev/panel-db.ts
CHANGED
|
@@ -8,8 +8,31 @@ import { DevSourceUnavailableError } from '../errors';
|
|
|
8
8
|
import type { DriftFact, SqlResult, TableFact } from './facts';
|
|
9
9
|
import type { DevPanel } from './panel';
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Where the whole schema can be READ, as SQL: the dump `x db gen` writes from the migrations, one
|
|
13
|
+
* file per table, with the framework's own tables under `framework/`. The table list above it is
|
|
14
|
+
* what THIS database holds; the dump is what the migrations say every database holds, and the
|
|
15
|
+
* gate keeps it true (`X_SCHEMA_DUMP_DRIFT`).
|
|
16
|
+
*
|
|
17
|
+
* A path and a command, not a URL: `/_x` serves no files, and the panel's job is to say where to
|
|
18
|
+
* look. The directory is `@ultimat3/cli`'s `SCHEMA_DUMP_DIR` repeated — this package sits below
|
|
19
|
+
* the CLI and cannot import it — and `packages/cli/src/db-schema-dump.test.ts` holds the two equal.
|
|
20
|
+
*/
|
|
21
|
+
export interface SchemaDumpLink {
|
|
22
|
+
/** App-root-relative, POSIX. */
|
|
23
|
+
readonly directory: string;
|
|
24
|
+
/** The one command that rewrites it. */
|
|
25
|
+
readonly regenerate: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const SCHEMA_DUMP: SchemaDumpLink = Object.freeze({
|
|
29
|
+
directory: 'packages/db/schema',
|
|
30
|
+
regenerate: 'x db gen',
|
|
31
|
+
});
|
|
32
|
+
|
|
11
33
|
export interface DbPanelData {
|
|
12
34
|
readonly tables: readonly TableFact[];
|
|
35
|
+
readonly schemaDump: SchemaDumpLink;
|
|
13
36
|
/**
|
|
14
37
|
* `null` when no host wired the check — NOT `[]`. Drift is the entities against the database,
|
|
15
38
|
* so it needs the connection this process may not have, and an empty list reads as "the schema
|
|
@@ -58,7 +81,7 @@ function sanitize(sql: string): string {
|
|
|
58
81
|
/**
|
|
59
82
|
* Read-only is enforced here, not just in the UI: /_x runs with the developer's own DB
|
|
60
83
|
* credentials, so "the textarea only sends SELECTs" would be the whole safety story.
|
|
61
|
-
*
|
|
84
|
+
* The developer's own client (`psql "$DATABASE_URL"`) is the deliberate way out.
|
|
62
85
|
*
|
|
63
86
|
* ONE implementation of "is this a read", and it is `@ultimat3/mcp`'s. That guard refuses four
|
|
64
87
|
* things this file's own keyword scan never did — a batch of statements, a call into the
|
|
@@ -106,11 +129,11 @@ export function assertReadOnly(sql: string): ReadOnlyVerdict {
|
|
|
106
129
|
if (!isUltimateError(error)) throw error;
|
|
107
130
|
// Two classes arrive through one code, and the panel cannot tell them apart without reading
|
|
108
131
|
// another package's prose — so the way out is phrased to be true for both. It used to assert
|
|
109
|
-
// `Run it with: x db psql --write`, which is the wrong instruction for a missing quote
|
|
110
|
-
//
|
|
132
|
+
// `Run it with: x db psql --write`, which is the wrong instruction for a missing quote — and
|
|
133
|
+
// named a command `x db` never had. The way out for a write is the developer's own client.
|
|
111
134
|
return {
|
|
112
135
|
kind: 'refused',
|
|
113
|
-
refused: `refused: ${error.cause}. Fix the statement, or — if it is meant to write — run it
|
|
136
|
+
refused: `refused: ${error.cause}. Fix the statement, or — if it is meant to write — run it in your own client: psql "$DATABASE_URL"`,
|
|
114
137
|
};
|
|
115
138
|
}
|
|
116
139
|
}
|
|
@@ -132,7 +155,15 @@ export const dbPanel: DevPanel<DbPanelData> = {
|
|
|
132
155
|
]);
|
|
133
156
|
const sql = params.get('sql');
|
|
134
157
|
if (sql === null || sql.trim() === '') {
|
|
135
|
-
return {
|
|
158
|
+
return {
|
|
159
|
+
tables,
|
|
160
|
+
schemaDump: SCHEMA_DUMP,
|
|
161
|
+
drift,
|
|
162
|
+
sql: null,
|
|
163
|
+
result: null,
|
|
164
|
+
refused: null,
|
|
165
|
+
readOnly: true,
|
|
166
|
+
};
|
|
136
167
|
}
|
|
137
168
|
|
|
138
169
|
// `verdict.sql`, never `sql`: what the guard proved read-only is what runs. `sql` below is
|
|
@@ -140,6 +171,7 @@ export const dbPanel: DevPanel<DbPanelData> = {
|
|
|
140
171
|
const verdict = assertReadOnly(sql);
|
|
141
172
|
return {
|
|
142
173
|
tables,
|
|
174
|
+
schemaDump: SCHEMA_DUMP,
|
|
143
175
|
drift,
|
|
144
176
|
sql,
|
|
145
177
|
result: verdict.kind === 'runnable' ? await sources.runSql(verdict.sql) : null,
|
package/src/dev/panel-jobs.ts
CHANGED
|
@@ -74,4 +74,12 @@ export const jobsPanel: DevPanel<JobsPanelData> = {
|
|
|
74
74
|
backfillsInFlight: backfills.filter((pass) => pass.status === 'running').length,
|
|
75
75
|
};
|
|
76
76
|
},
|
|
77
|
+
/**
|
|
78
|
+
* One jobs UI: the tab draws the admin's own overview component over this process's queue.
|
|
79
|
+
* Dynamic, so `/_x`'s mount graph loads no view until this tab is opened.
|
|
80
|
+
*/
|
|
81
|
+
async html(params, tabPath) {
|
|
82
|
+
const { jobsTabHtml } = await import('../jobs/overview-tab');
|
|
83
|
+
return jobsTabHtml(tabPath, params);
|
|
84
|
+
},
|
|
77
85
|
};
|
package/src/dev/panel-routes.ts
CHANGED
|
@@ -13,6 +13,11 @@ export interface RoutesPanelData {
|
|
|
13
13
|
// no member it could ever hold — and it was published from a `RouteFact.hasMeta` that read a
|
|
14
14
|
// key no descriptor has, which made it name EVERY route instead. See `RouteFact` in `facts.ts`.
|
|
15
15
|
readonly overBudget: readonly string[];
|
|
16
|
+
/**
|
|
17
|
+
* Routes no surface file declares, counted per mounting call — `{ defineAdmin: 14 }`. Each row's
|
|
18
|
+
* own `mount` names its permissions; this is the line that says an admin is mounted at all.
|
|
19
|
+
*/
|
|
20
|
+
readonly mountedBy: Readonly<Record<string, number>>;
|
|
16
21
|
}
|
|
17
22
|
|
|
18
23
|
const BUDGET_LIMIT_KB = 40;
|
|
@@ -35,6 +40,10 @@ export const routesPanel: DevPanel<RoutesPanelData> = {
|
|
|
35
40
|
const counts = new Map<string, number>();
|
|
36
41
|
for (const route of routes) counts.set(route.render, (counts.get(route.render) ?? 0) + 1);
|
|
37
42
|
const byRenderMode = Object.fromEntries(counts);
|
|
43
|
+
const mounts = new Map<string, number>();
|
|
44
|
+
for (const route of routes) {
|
|
45
|
+
if (route.mount !== null) mounts.set(route.mount.by, (mounts.get(route.mount.by) ?? 0) + 1);
|
|
46
|
+
}
|
|
38
47
|
return {
|
|
39
48
|
// Code units, never `localeCompare`: with no locale argument it answers from the runtime's
|
|
40
49
|
// ICU default locale and collation version, so the same route table reads in a different
|
|
@@ -46,6 +55,7 @@ export const routesPanel: DevPanel<RoutesPanelData> = {
|
|
|
46
55
|
overBudget: routes
|
|
47
56
|
.filter((route) => kb(route.budget.js) > BUDGET_LIMIT_KB)
|
|
48
57
|
.map((route) => route.path),
|
|
58
|
+
mountedBy: Object.fromEntries(mounts),
|
|
49
59
|
};
|
|
50
60
|
},
|
|
51
61
|
};
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// calls, and policy decisions on one axis, with the N+1 already counted for you — counted by
|
|
4
4
|
// `x dev`'s statement ledger and read here through `statementLoops()`, never re-derived.
|
|
5
5
|
|
|
6
|
+
import { DevSourceUnavailableError } from '../errors';
|
|
6
7
|
import type { RequestTrace, SpanKind, StatementLoopFact, TimelineSpan } from './facts';
|
|
7
8
|
import type { DevPanel } from './panel';
|
|
8
9
|
|
|
@@ -68,8 +69,12 @@ export const timelinePanel: DevPanel<TimelinePanelData> = {
|
|
|
68
69
|
(wanted === null ? traces[0] : traces.find((trace) => trace.requestId === wanted)) ?? null;
|
|
69
70
|
// Degrade rather than reject, as `panel-live.ts` does for `subscribers`: a host with traces
|
|
70
71
|
// but no detector installed must still get its flamegraph. `null` carries that difference —
|
|
71
|
-
// "nobody counted" is not "counted, and this request was clean".
|
|
72
|
-
|
|
72
|
+
// "nobody counted" is not "counted, and this request was clean". Only for the error that MEANS
|
|
73
|
+
// unwired: a detector that ran and failed is a diagnostic, never "no detector".
|
|
74
|
+
const loops = await sources.statementLoops().catch((error: unknown): null => {
|
|
75
|
+
if (!(error instanceof DevSourceUnavailableError)) throw error;
|
|
76
|
+
return null;
|
|
77
|
+
});
|
|
73
78
|
|
|
74
79
|
const totalsByKind: Record<string, number> = {};
|
|
75
80
|
const sqlCounts = new Map<string, number>();
|
package/src/dev/panel.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// draws — and because `data()` returns plain JSON, `--json` and the rendered tab are the
|
|
3
3
|
// same facts by construction.
|
|
4
4
|
|
|
5
|
-
import { renderThrowable, stringField } from '@ultimat3/core';
|
|
5
|
+
import { InternalError, renderThrowable, stringField } from '@ultimat3/core';
|
|
6
6
|
import type { DevSources } from './facts';
|
|
7
7
|
|
|
8
8
|
export interface DevPanel<Data = unknown> {
|
|
@@ -16,6 +16,12 @@ export interface DevPanel<Data = unknown> {
|
|
|
16
16
|
*/
|
|
17
17
|
readonly questionKey: string;
|
|
18
18
|
data(sources: DevSources, params: URLSearchParams): Promise<Data>;
|
|
19
|
+
/**
|
|
20
|
+
* The tab's body as HTML, when the panel draws one — the jobs tab renders the admin's own
|
|
21
|
+
* jobs overview. Absent: the tab is its `--json` payload in a `<pre>`. Asked only once `data`
|
|
22
|
+
* answered, so an unwired source is still the payload's own error with its fix.
|
|
23
|
+
*/
|
|
24
|
+
html?(params: URLSearchParams, tabPath: string): Promise<string>;
|
|
19
25
|
}
|
|
20
26
|
|
|
21
27
|
export type PanelPayload =
|
|
@@ -47,7 +53,9 @@ export async function panelPayload(
|
|
|
47
53
|
panel: panel.key,
|
|
48
54
|
ok: false,
|
|
49
55
|
error: {
|
|
50
|
-
|
|
56
|
+
// Core's code for a failure nothing classified. `X_NOT_IMPLEMENTED` was here, and it told
|
|
57
|
+
// the reader a feature was missing when a source had thrown something with no code at all.
|
|
58
|
+
code: stringField(error, 'code') ?? InternalError.code,
|
|
51
59
|
cause: stringField(error, 'cause') ?? renderThrowable(error),
|
|
52
60
|
fix: stringField(error, 'fix') ?? 'x dev --help',
|
|
53
61
|
},
|
package/src/dev/server.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
// Type-only, so it is erased and the 46-component barrel stays out of the mount graph — the
|
|
6
6
|
// values arrive through the dynamic `import()` in `devShellStyle()`, same reason as `data.ts`.
|
|
7
|
-
import { DEFAULT_ENVIRONMENT, tryResolveEnvironment } from '@ultimat3/core';
|
|
7
|
+
import { DEFAULT_ENVIRONMENT, escapeHtml, tryResolveEnvironment } from '@ultimat3/core';
|
|
8
8
|
import { FRAMEWORK_CATALOG_LOCALE, translatorFor } from '@ultimat3/i18n';
|
|
9
9
|
import type { ColorRole } from '@ultimat3/ui';
|
|
10
10
|
import { DevDashboardInProdError } from '../errors';
|
|
@@ -85,9 +85,6 @@ const jsonResponse = (body: unknown, status = 200): Response =>
|
|
|
85
85
|
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
|
|
86
86
|
});
|
|
87
87
|
|
|
88
|
-
const escapeHtml = (value: string): string =>
|
|
89
|
-
value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
90
|
-
|
|
91
88
|
/** The six roles /_x paints with. `--x-*` is the admin's namespace; the values are ui's. */
|
|
92
89
|
const SHELL_ROLES = [
|
|
93
90
|
'bg',
|
|
@@ -170,6 +167,8 @@ function shell(
|
|
|
170
167
|
panels: readonly DevPanel[],
|
|
171
168
|
active: DevPanel,
|
|
172
169
|
payload: PanelPayload,
|
|
170
|
+
/** The panel's own drawing, when it has one; the payload follows it, folded. */
|
|
171
|
+
drawn: string | null,
|
|
173
172
|
): string {
|
|
174
173
|
const tabs = panels
|
|
175
174
|
.map(
|
|
@@ -186,7 +185,11 @@ function shell(
|
|
|
186
185
|
<body><header><h1>_x</h1><nav>${tabs}</nav>
|
|
187
186
|
<a href="${basePath}/${active.key}?json=1">--json</a></header>
|
|
188
187
|
<main><p class="question">${escapeHtml(t(active.questionKey))}</p>
|
|
189
|
-
|
|
188
|
+
${
|
|
189
|
+
drawn === null
|
|
190
|
+
? `<pre>${escapeHtml(JSON.stringify(payload, null, 2))}</pre>`
|
|
191
|
+
: `${drawn}<details><summary>--json</summary><pre>${escapeHtml(JSON.stringify(payload, null, 2))}</pre></details>`
|
|
192
|
+
}</main></body></html>`;
|
|
190
193
|
}
|
|
191
194
|
|
|
192
195
|
/** Build the dashboard. Throws X_DEV_DASHBOARD_IN_PROD before anything else happens. */
|
|
@@ -232,12 +235,14 @@ export function devDashboard(opts: DevDashboardOptions = {}): DevDashboard {
|
|
|
232
235
|
url.searchParams.has('json') ||
|
|
233
236
|
(request.headers.get('accept') ?? '').includes('application/json');
|
|
234
237
|
|
|
235
|
-
return
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
238
|
+
if (wantsJson) return jsonResponse(payload, payload.ok ? 200 : 500);
|
|
239
|
+
const tabPath = `${basePath}/${panel.key}`;
|
|
240
|
+
const drawn =
|
|
241
|
+
payload.ok && panel.html !== undefined ? await panel.html(url.searchParams, tabPath) : null;
|
|
242
|
+
return new Response(shell(await devShellStyle(), basePath, panels, panel, payload, drawn), {
|
|
243
|
+
status: 200,
|
|
244
|
+
headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' },
|
|
245
|
+
});
|
|
241
246
|
},
|
|
242
247
|
};
|
|
243
248
|
}
|