@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.
Files changed (97) hide show
  1. package/CLAUDE.md +83 -91
  2. package/README.md +282 -89
  3. package/package.json +19 -17
  4. package/src/action-form.tsx +139 -0
  5. package/src/action-gate.ts +141 -68
  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 +87 -0
  20. package/src/batch-queue.ts +90 -0
  21. package/src/batch.ts +396 -0
  22. package/src/catalog-keys.ts +55 -0
  23. package/src/crud-input.ts +104 -0
  24. package/src/crud-outcome.ts +224 -0
  25. package/src/crud.ts +172 -176
  26. package/src/describe.ts +131 -0
  27. package/src/detail.tsx +115 -43
  28. package/src/dev/data.ts +27 -12
  29. package/src/dev/facts.ts +28 -1
  30. package/src/dev/index.ts +1 -0
  31. package/src/dev/panel-cache.ts +8 -3
  32. package/src/dev/panel-db.ts +37 -5
  33. package/src/dev/panel-jobs.ts +8 -0
  34. package/src/dev/panel-routes.ts +10 -0
  35. package/src/dev/panel-timeline.ts +7 -2
  36. package/src/dev/panel.ts +10 -2
  37. package/src/dev/server.ts +16 -11
  38. package/src/dev/sql-runner.ts +31 -0
  39. package/src/entity-columns.ts +24 -5
  40. package/src/errors.ts +92 -5
  41. package/src/fields.ts +63 -4
  42. package/src/form-decode.ts +132 -0
  43. package/src/form.tsx +96 -49
  44. package/src/index.ts +199 -9
  45. package/src/jobs/fleet-repo.ts +155 -0
  46. package/src/jobs/job-actions.ts +172 -0
  47. package/src/jobs/job-entities.ts +118 -0
  48. package/src/jobs/job-labels.ts +108 -0
  49. package/src/jobs/job-repo.ts +137 -0
  50. package/src/jobs/job-resources.ts +211 -0
  51. package/src/jobs/job-where.ts +170 -0
  52. package/src/jobs/operator.ts +34 -0
  53. package/src/jobs/overview-data.ts +129 -0
  54. package/src/jobs/overview-tab.tsx +61 -0
  55. package/src/jobs/overview.tsx +189 -0
  56. package/src/json-text.ts +52 -0
  57. package/src/keyset-seek.ts +189 -0
  58. package/src/layout.tsx +55 -43
  59. package/src/list-columns.tsx +53 -0
  60. package/src/list-filter-bar.tsx +207 -0
  61. package/src/list-filters.ts +242 -0
  62. package/src/list-request.ts +129 -0
  63. package/src/list-scope-tabs.tsx +60 -0
  64. package/src/list-scope.ts +113 -0
  65. package/src/list.tsx +111 -72
  66. package/src/mcp-tools.ts +57 -13
  67. package/src/mcp.ts +112 -38
  68. package/src/mounts.ts +44 -0
  69. package/src/page-guard.tsx +26 -29
  70. package/src/pagination.ts +10 -4
  71. package/src/policy-bridge.ts +115 -18
  72. package/src/registry.ts +140 -9
  73. package/src/related.ts +105 -0
  74. package/src/relations.ts +240 -0
  75. package/src/renderers.tsx +89 -0
  76. package/src/repo-entity.ts +195 -0
  77. package/src/resource-fields.ts +116 -0
  78. package/src/resource-layout.ts +89 -0
  79. package/src/resource-list.ts +194 -0
  80. package/src/resource.ts +129 -79
  81. package/src/route-config.ts +71 -0
  82. package/src/routes.ts +55 -54
  83. package/src/row-scope-write.ts +130 -0
  84. package/src/row-version.ts +20 -0
  85. package/src/screen-action.tsx +333 -0
  86. package/src/screen-frame.tsx +167 -0
  87. package/src/screen-home.tsx +201 -0
  88. package/src/screen-lookup.tsx +157 -0
  89. package/src/screen-resource.tsx +441 -0
  90. package/src/screen-system.tsx +75 -0
  91. package/src/screens.ts +56 -0
  92. package/src/search.ts +22 -10
  93. package/src/stylesheet-scope.ts +8 -0
  94. package/src/test-ctx.ts +31 -0
  95. package/src/validate.ts +3 -0
  96. package/src/widget-value.ts +36 -8
  97. package/src/widgets.tsx +84 -16
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
@@ -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
- // Two flat fields on the descriptor, one nested bag on the fact — the panel's budget
159
- // check reads `budget.js`. Spread, never `js: undefined`: `exactOptionalPropertyTypes`.
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 { inspectJobList, inspectQueues, jobDriver } = await import('@ultimat3/jobs');
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
- // `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' });
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
- failed: failed.filter((record) => record.queue === queue.queue).length,
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; readonly lcp?: number };
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
@@ -48,3 +48,4 @@ export {
48
48
  devDashboard,
49
49
  devShellStyle,
50
50
  } from './server';
51
+ export { DEV_SQL_MAX_ROWS, readOnlySql } from './sql-runner';
@@ -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
- // is still worth showing.
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
@@ -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
  };
@@ -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
- const loops = await sources.statementLoops().catch((): null => null);
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
- code: stringField(error, 'code') ?? 'X_NOT_IMPLEMENTED',
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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
- <pre>${escapeHtml(JSON.stringify(payload, null, 2))}</pre></main></body></html>`;
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 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
- });
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
  }