@ultimat3/admin 22.14.0 → 23.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.
Files changed (89) hide show
  1. package/CLAUDE.md +73 -80
  2. package/README.md +276 -87
  3. package/package.json +19 -17
  4. package/src/action-form.tsx +139 -0
  5. package/src/action-gate.ts +127 -28
  6. package/src/action-input.ts +119 -0
  7. package/src/action-label.ts +9 -0
  8. package/src/action-row.ts +37 -0
  9. package/src/actions.tsx +89 -66
  10. package/src/actor.ts +53 -0
  11. package/src/admin.module.scss +298 -0
  12. package/src/admin.ts +187 -23
  13. package/src/audit-pg.ts +207 -0
  14. package/src/audit-schema.ts +47 -0
  15. package/src/audit.ts +109 -22
  16. package/src/authz.ts +11 -0
  17. package/src/batch-bar.tsx +66 -0
  18. package/src/batch-job.ts +152 -0
  19. package/src/batch-matching.ts +92 -0
  20. package/src/batch.ts +431 -0
  21. package/src/catalog-keys.ts +55 -0
  22. package/src/crud-input.ts +104 -0
  23. package/src/crud-outcome.ts +166 -0
  24. package/src/crud.ts +129 -177
  25. package/src/describe.ts +131 -0
  26. package/src/detail.tsx +115 -43
  27. package/src/dev/data.ts +24 -6
  28. package/src/dev/facts.ts +27 -0
  29. package/src/dev/panel-db.ts +37 -5
  30. package/src/dev/panel-jobs.ts +8 -0
  31. package/src/dev/panel-routes.ts +10 -0
  32. package/src/dev/panel.ts +6 -0
  33. package/src/dev/server.ts +15 -7
  34. package/src/entity-columns.ts +24 -5
  35. package/src/errors.ts +83 -0
  36. package/src/fields.ts +63 -4
  37. package/src/form-decode.ts +91 -0
  38. package/src/form.tsx +78 -45
  39. package/src/index.ts +198 -9
  40. package/src/jobs/fleet-repo.ts +143 -0
  41. package/src/jobs/job-actions.ts +172 -0
  42. package/src/jobs/job-entities.ts +118 -0
  43. package/src/jobs/job-labels.ts +108 -0
  44. package/src/jobs/job-repo.ts +103 -0
  45. package/src/jobs/job-resources.ts +211 -0
  46. package/src/jobs/job-where.ts +170 -0
  47. package/src/jobs/operator.ts +34 -0
  48. package/src/jobs/overview-data.ts +129 -0
  49. package/src/jobs/overview-tab.tsx +61 -0
  50. package/src/jobs/overview.tsx +189 -0
  51. package/src/json-text.ts +52 -0
  52. package/src/layout.tsx +55 -43
  53. package/src/list-columns.tsx +53 -0
  54. package/src/list-filter-bar.tsx +207 -0
  55. package/src/list-filters.ts +217 -0
  56. package/src/list-request.ts +127 -0
  57. package/src/list-scope-tabs.tsx +60 -0
  58. package/src/list-scope.ts +113 -0
  59. package/src/list.tsx +111 -72
  60. package/src/mcp-tools.ts +57 -13
  61. package/src/mcp.ts +108 -38
  62. package/src/mounts.ts +44 -0
  63. package/src/page-guard.tsx +26 -29
  64. package/src/policy-bridge.ts +110 -15
  65. package/src/registry.ts +138 -8
  66. package/src/related.ts +105 -0
  67. package/src/relations.ts +224 -0
  68. package/src/renderers.tsx +89 -0
  69. package/src/repo-entity.ts +204 -0
  70. package/src/resource-fields.ts +116 -0
  71. package/src/resource-layout.ts +89 -0
  72. package/src/resource-list.ts +185 -0
  73. package/src/resource.ts +129 -79
  74. package/src/route-config.ts +71 -0
  75. package/src/routes.ts +55 -54
  76. package/src/row-scope-write.ts +130 -0
  77. package/src/screen-action.tsx +333 -0
  78. package/src/screen-frame.tsx +167 -0
  79. package/src/screen-home.tsx +201 -0
  80. package/src/screen-lookup.tsx +157 -0
  81. package/src/screen-resource.tsx +393 -0
  82. package/src/screen-system.tsx +69 -0
  83. package/src/screens.ts +56 -0
  84. package/src/search.ts +13 -9
  85. package/src/stylesheet-scope.ts +8 -0
  86. package/src/test-ctx.ts +31 -0
  87. package/src/validate.ts +3 -0
  88. package/src/widget-value.ts +36 -8
  89. package/src/widgets.tsx +84 -12
@@ -0,0 +1,131 @@
1
+ // One admin, as plain data: its resources with the filters, sorts and scopes each list answers,
2
+ // whether a row scope narrows it, how its detail and form are arranged, the related lists and the
3
+ // actions it offers — and its routes with the permissions that gate them.
4
+ // JSON-safe and deterministic — what `x manifest` records, read off `AdminApp.describe()` by a
5
+ // tier that may not import this package.
6
+
7
+ import type { AdminRoute } from './admin';
8
+ import { batchPlan } from './batch';
9
+ import type { AdminAction } from './registry';
10
+ import type { AdminResource } from './resource';
11
+ import type { AdminSection } from './resource-layout';
12
+
13
+ export interface AdminScopeDescription {
14
+ readonly name: string;
15
+ readonly default: boolean;
16
+ /** The tab reads a count — one extra query per list page. */
17
+ readonly count: boolean;
18
+ }
19
+
20
+ /** One titled group of a detail page or a form. `null`: the untitled default group. */
21
+ export interface AdminSectionDescription {
22
+ readonly title: string | null;
23
+ readonly fields: readonly string[];
24
+ }
25
+
26
+ /** One action as an agent, the batch bar and a reviewer need to know it. */
27
+ export interface AdminActionDescription {
28
+ readonly name: string;
29
+ readonly permission: string;
30
+ readonly destructive: boolean;
31
+ /** It declares an input schema: its button is a form. */
32
+ readonly input: boolean;
33
+ /** It declares `when`: it applies to some rows only. */
34
+ readonly when: boolean;
35
+ /** It is in the batch bar and its MCP tool takes `ids`. */
36
+ readonly batch: boolean;
37
+ /** Rows a batch runs inline before it is queued as `admin.batch` jobs. `null`: always inline. */
38
+ readonly threshold: number | null;
39
+ }
40
+
41
+ export interface AdminResourceDescription {
42
+ readonly entity: string;
43
+ /** Mount-relative: `/posts`. */
44
+ readonly path: string;
45
+ /** The fields a list URL's `f.<field>` and the MCP list tool's `where` may name. */
46
+ readonly filters: readonly string[];
47
+ /** The fields `?sort=` may name. */
48
+ readonly sorts: readonly string[];
49
+ readonly scopes: readonly AdminScopeDescription[];
50
+ /** `rows` is declared: every read of the resource is narrowed per actor. */
51
+ readonly rowScoped: boolean;
52
+ readonly sections: readonly AdminSectionDescription[];
53
+ readonly formGroups: readonly AdminSectionDescription[];
54
+ /** The `hasMany` relations drawn on the detail page, by name. */
55
+ readonly related: readonly string[];
56
+ /** Declaration order — the order the buttons are drawn in. */
57
+ readonly actions: readonly AdminActionDescription[];
58
+ }
59
+
60
+ export interface AdminRouteDescription {
61
+ readonly url: string;
62
+ readonly view: string;
63
+ /** The resource a generated screen belongs to; `null` for the dashboard, a page, a system screen. */
64
+ readonly entity: string | null;
65
+ readonly permissions: readonly string[];
66
+ }
67
+
68
+ export interface AdminDescription {
69
+ readonly basePath: string;
70
+ /** Which audit log the admin writes: `memory` forgets at a restart, `postgres` is the record. */
71
+ readonly audit: string;
72
+ readonly resources: readonly AdminResourceDescription[];
73
+ readonly routes: readonly AdminRouteDescription[];
74
+ }
75
+
76
+ const byCodeUnit = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
77
+
78
+ const sectionOf = (section: AdminSection): AdminSectionDescription => ({
79
+ title: section.titleKey,
80
+ fields: section.fields.map((field) => field.name),
81
+ });
82
+
83
+ const actionOf = (action: AdminAction): AdminActionDescription => ({
84
+ name: action.name,
85
+ permission: action.permission,
86
+ destructive: action.destructive === true,
87
+ input: action.input !== undefined,
88
+ when: action.when !== undefined,
89
+ batch: action.batch !== undefined,
90
+ threshold: batchPlan(action)?.threshold ?? null,
91
+ });
92
+
93
+ /** Sorted by name and by URL, so two boots of one app describe it in the same bytes. */
94
+ export function describeAdmin(
95
+ basePath: string,
96
+ resources: readonly AdminResource[],
97
+ routes: readonly AdminRoute[],
98
+ audit: string,
99
+ ): AdminDescription {
100
+ return {
101
+ basePath,
102
+ audit,
103
+ resources: resources
104
+ .map((resource) => ({
105
+ entity: resource.name,
106
+ path: resource.path,
107
+ // Declaration order for both: it is the order the filter bar and the tabs are drawn in.
108
+ filters: resource.filters.map((field) => field.name),
109
+ sorts: resource.fields.filter((field) => field.sortable).map((field) => field.name),
110
+ scopes: resource.scopes.map((scope) => ({
111
+ name: scope.name,
112
+ default: scope.default,
113
+ count: scope.count,
114
+ })),
115
+ rowScoped: resource.rowScope !== undefined,
116
+ sections: resource.sections.map(sectionOf),
117
+ formGroups: resource.formGroups.map(sectionOf),
118
+ related: [...resource.related],
119
+ actions: resource.actions.map(actionOf),
120
+ }))
121
+ .sort((a, b) => byCodeUnit(a.entity, b.entity)),
122
+ routes: routes
123
+ .map((route) => ({
124
+ url: route.path,
125
+ view: route.view,
126
+ entity: route.entity,
127
+ permissions: [...route.permissions],
128
+ }))
129
+ .sort((a, b) => byCodeUnit(a.url, b.url)),
130
+ };
131
+ }
package/src/detail.tsx CHANGED
@@ -1,16 +1,21 @@
1
- // The detail view: every field as a labelled read-only row, the audit trail for this row,
2
- // and the actions this actor may run on it. Loading and error states are first-class, not an
3
- // afterthought — a detail page with no row is a state, not a blank card.
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 type { AdminActionButton } from './action-gate';
9
- import { AdminActions } from './actions';
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
- readonly pending?: AdminActionButton | null;
29
- readonly confirmation?: string;
30
- readonly onRun?: (button: AdminActionButton, confirmation: string) => void;
31
- readonly onRequestConfirm?: (button: AdminActionButton) => void;
32
- readonly onConfirmationInput?: (value: string) => void;
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
- pending={props.pending ?? null}
86
- confirmation={props.confirmation ?? ''}
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
- <dl class="x-admin-detail">
94
- {props.resource.fields
95
- .filter((field) => !field.sensitive)
96
- .map((field) => (
97
- <>
98
- <dt>{t(field.labelKey)}</dt>
99
- <dd>
100
- <Widget field={field} value={row[field.name]} ctx={props.ctx} mode="read" />
101
- </dd>
102
- </>
103
- ))}
104
- </dl>
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
- <a href={`${props.basePath}${props.resource.path}/${id}/edit`}>{t('admin.detail.edit')}</a>
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>{String(change.before ?? '')}</del>{' '}
125
- <ins>{String(change.after ?? '')}</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
@@ -164,6 +164,12 @@ export function defaultDevSources(opts: DevSourceOptions = {}): DevSources {
164
164
  // `revalidateTags`, already flattened to keys — never `revalidate.tags`, a shape the
165
165
  // descriptor does not have and which answered `[]` for every ISR route in the app.
166
166
  revalidateTags: route.revalidateTags,
167
+ // Present only on a route a package mounted. Copied, never aliased: the fact is a panel
168
+ // payload, and the registry's own list must not be reachable through it.
169
+ mount:
170
+ route.mount === undefined
171
+ ? null
172
+ : { by: route.mount.by, permissions: [...route.mount.permissions] },
167
173
  }));
168
174
  },
169
175
 
@@ -211,24 +217,27 @@ export function defaultDevSources(opts: DevSourceOptions = {}): DevSources {
211
217
  // field on a descriptor that never carried it, so every job on the panel reported
212
218
  // non-idempotent — the opposite of what `job()` refuses to register without.
213
219
  idempotent: job.idempotent,
220
+ concurrency: job.concurrency,
221
+ onSettled: job.onSettled,
214
222
  }));
215
223
  },
216
224
 
217
225
  async queues(): Promise<readonly QueueFact[]> {
218
- const { inspectJobList, inspectQueues, jobDriver } = await import('@ultimat3/jobs');
226
+ const { inspectQueues, jobDriver } = await import('@ultimat3/jobs');
219
227
  const driver = jobDriver();
220
228
  if (driver === undefined) throw unavailable('queues', 'jobs');
221
229
  const report = await inspectQueues(driver);
222
- // `stats()` counts states, and a failed job is one of them only until it is retried or
223
- // dead-lettered; the honest count comes from the job list, which needs introspection.
224
- const failed =
225
- driver.introspect === undefined ? [] : await inspectJobList(driver, { state: 'failed' });
230
+ // A driver with no introspection has nothing to pause a queue with.
231
+ const paused = new Set((await driver.introspect?.pausedQueues())?.map((entry) => entry.name));
226
232
  return report.queues.map((queue) => ({
227
233
  name: queue.queue,
228
234
  depth: queue.ready + queue.delayed,
229
235
  running: queue.running,
230
- failed: failed.filter((record) => record.queue === queue.queue).length,
236
+ // `stats()` has its own `failed` bucket: it was counted off the job LIST, one page of it,
237
+ // so a queue with more failed rows than a page holds reported the page size.
238
+ failed: queue.failed,
231
239
  deadLetter: queue.dead,
240
+ paused: paused.has(queue.queue),
232
241
  }));
233
242
  },
234
243
 
@@ -248,6 +257,15 @@ export function defaultDevSources(opts: DevSourceOptions = {}): DevSources {
248
257
  queue: trace.queue,
249
258
  status: RUN_STATUS[trace.state],
250
259
  attempt: trace.attempt,
260
+ concurrencyKey: trace.concurrencyKey,
261
+ progress:
262
+ trace.progress === null
263
+ ? null
264
+ : {
265
+ done: trace.progress.done,
266
+ total: trace.progress.total,
267
+ note: trace.progress.note ?? null,
268
+ },
251
269
  steps: trace.steps.map((step) => ({
252
270
  name: step.name,
253
271
  status: STEP_STATUS[step.status],
package/src/dev/facts.ts CHANGED
@@ -22,6 +22,12 @@ export interface RouteFact {
22
22
  readonly handler: string;
23
23
  readonly budget: { readonly js?: string; readonly lcp?: number };
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
 
@@ -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
- * `x db psql --write` is the deliberate way out.
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: that
110
- // flag grants writes, it does not close a delimiter.
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 with: x db psql --write`,
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 { tables, drift, sql: null, result: null, refused: null, readOnly: true };
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,
@@ -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
  };
@@ -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
  };
package/src/dev/panel.ts CHANGED
@@ -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 =
package/src/dev/server.ts CHANGED
@@ -170,6 +170,8 @@ function shell(
170
170
  panels: readonly DevPanel[],
171
171
  active: DevPanel,
172
172
  payload: PanelPayload,
173
+ /** The panel's own drawing, when it has one; the payload follows it, folded. */
174
+ drawn: string | null,
173
175
  ): string {
174
176
  const tabs = panels
175
177
  .map(
@@ -186,7 +188,11 @@ function shell(
186
188
  <body><header><h1>_x</h1><nav>${tabs}</nav>
187
189
  <a href="${basePath}/${active.key}?json=1">--json</a></header>
188
190
  <main><p class="question">${escapeHtml(t(active.questionKey))}</p>
189
- <pre>${escapeHtml(JSON.stringify(payload, null, 2))}</pre></main></body></html>`;
191
+ ${
192
+ drawn === null
193
+ ? `<pre>${escapeHtml(JSON.stringify(payload, null, 2))}</pre>`
194
+ : `${drawn}<details><summary>--json</summary><pre>${escapeHtml(JSON.stringify(payload, null, 2))}</pre></details>`
195
+ }</main></body></html>`;
190
196
  }
191
197
 
192
198
  /** Build the dashboard. Throws X_DEV_DASHBOARD_IN_PROD before anything else happens. */
@@ -232,12 +238,14 @@ export function devDashboard(opts: DevDashboardOptions = {}): DevDashboard {
232
238
  url.searchParams.has('json') ||
233
239
  (request.headers.get('accept') ?? '').includes('application/json');
234
240
 
235
- return wantsJson
236
- ? jsonResponse(payload, payload.ok ? 200 : 500)
237
- : new Response(shell(await devShellStyle(), basePath, panels, panel, payload), {
238
- status: 200,
239
- headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' },
240
- });
241
+ if (wantsJson) return jsonResponse(payload, payload.ok ? 200 : 500);
242
+ const tabPath = `${basePath}/${panel.key}`;
243
+ const drawn =
244
+ payload.ok && panel.html !== undefined ? await panel.html(url.searchParams, tabPath) : null;
245
+ return new Response(shell(await devShellStyle(), basePath, panels, panel, payload, drawn), {
246
+ status: 200,
247
+ headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' },
248
+ });
241
249
  },
242
250
  };
243
251
  }