@ultimat3/admin 23.0.0 → 25.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 (64) hide show
  1. package/CLAUDE.md +32 -30
  2. package/README.md +51 -16
  3. package/package.json +17 -17
  4. package/src/action-gate.ts +33 -42
  5. package/src/admin.module.scss +118 -4
  6. package/src/audit-pg.ts +2 -2
  7. package/src/audit.ts +1 -3
  8. package/src/authz.ts +8 -8
  9. package/src/batch-matching.ts +26 -31
  10. package/src/batch-queue.ts +90 -0
  11. package/src/batch.ts +11 -51
  12. package/src/crud-input.ts +2 -1
  13. package/src/crud-outcome.ts +96 -38
  14. package/src/crud.ts +48 -4
  15. package/src/describe.ts +6 -0
  16. package/src/dev/data.ts +5 -8
  17. package/src/dev/dev-t.ts +9 -0
  18. package/src/dev/facts.ts +1 -1
  19. package/src/dev/index.ts +1 -0
  20. package/src/dev/jobs-state-chart.tsx +38 -0
  21. package/src/dev/panel-cache.ts +8 -3
  22. package/src/dev/panel-jobs.ts +10 -5
  23. package/src/dev/panel-timeline.ts +16 -3
  24. package/src/dev/panel.ts +10 -5
  25. package/src/dev/server.ts +43 -17
  26. package/src/dev/sql-runner.ts +31 -0
  27. package/src/dev/timeline-style.ts +47 -0
  28. package/src/dev/timeline-waterfall.ts +96 -0
  29. package/src/errors.ts +9 -5
  30. package/src/form-decode.ts +55 -5
  31. package/src/form.tsx +19 -5
  32. package/src/index.ts +11 -9
  33. package/src/jobs/fleet-repo.ts +15 -3
  34. package/src/jobs/job-repo.ts +51 -17
  35. package/src/jobs/operator.ts +5 -5
  36. package/src/jobs/overview-tab.tsx +2 -2
  37. package/src/keyset-seek.ts +189 -0
  38. package/src/layout.tsx +34 -8
  39. package/src/list-filters.ts +30 -5
  40. package/src/list-request.ts +8 -6
  41. package/src/list.tsx +1 -1
  42. package/src/mcp-scopes.ts +36 -0
  43. package/src/mcp-tools.ts +16 -2
  44. package/src/mcp.ts +27 -11
  45. package/src/pagination.ts +32 -22
  46. package/src/policy-bridge.ts +16 -14
  47. package/src/registry.ts +9 -1
  48. package/src/related.ts +2 -2
  49. package/src/relations.ts +26 -10
  50. package/src/repo-entity.ts +37 -46
  51. package/src/resource-list.ts +9 -0
  52. package/src/resource.ts +10 -0
  53. package/src/row-version.ts +20 -0
  54. package/src/screen-home-access.tsx +117 -0
  55. package/src/screen-home-counts.ts +42 -0
  56. package/src/screen-home-kpis.tsx +64 -0
  57. package/src/screen-home.module.scss +169 -0
  58. package/src/screen-home.tsx +21 -82
  59. package/src/screen-lookup.tsx +1 -1
  60. package/src/screen-resource.tsx +60 -12
  61. package/src/screen-system.tsx +8 -2
  62. package/src/search.ts +12 -4
  63. package/src/widgets.tsx +2 -6
  64. package/src/inert-jsx.ts +0 -182
@@ -2,12 +2,19 @@
2
2
  // row that was not there, a repo that threw. Each leaves its entry and answers the result the
3
3
  // caller renders — so `crud.ts` reads as the five steps of each operation and nothing else.
4
4
 
5
- import { type AuditEntry, type AuditFieldDiff, deniedDraft } from './audit';
5
+ import {
6
+ type AuditDraft,
7
+ type AuditEntry,
8
+ type AuditFieldDiff,
9
+ type AuditLog,
10
+ deniedDraft,
11
+ } from './audit';
6
12
  import type { AdminDecision } from './authz';
7
13
  import type { CrudCtx, CrudResult } from './crud';
8
14
  import type { AdminOperation } from './permissions';
9
15
  import type { AdminRow } from './registry';
10
16
  import type { AdminResource } from './resource';
17
+ import { ROW_CHANGED_REASON } from './row-version';
11
18
  import type { ValidationIssue } from './validate';
12
19
 
13
20
  export const deniedEntry = (
@@ -47,31 +54,35 @@ export async function refuse<Row extends AdminRow>(
47
54
  /** The reason on a `failed` entry. A key, never the database's message. */
48
55
  const WRITE_FAILED_REASON = 'admin.audit.write-failed';
49
56
 
57
+ /** What every entry of one audited write shares: who, what, on which table, under which grant. */
58
+ export type AuditedEntry = Pick<
59
+ AuditDraft,
60
+ 'requestId' | 'actor' | 'operation' | 'kind' | 'entity' | 'permission'
61
+ >;
62
+
50
63
  /**
51
- * Run a repo WRITE and append its entry — as one unit where the log can make it one — and leave a
52
- * `failed` entry behind if either throws.
53
- *
54
- * `AuditOutcome` has declared a `failed` member all along and this file emitted it in exactly one
55
- * place — `invalid()`, for a VALIDATION issue. A constraint violation, a statement that timed out
56
- * after committing, a connection dropped mid-write: each left no entry at all, which is the case
57
- * an auditor opens the log for. Both siblings already do this and each states the rule
58
- * (`search.ts`, `action-gate.ts`).
64
+ * Run a WRITE and append its entry as one unit where the log can make it one, and leave a `failed`
65
+ * entry behind if either throws — the one shape for a CRUD write, an action's handler, a set-based
66
+ * batch and a queued one.
59
67
  *
60
68
  * A mutation cannot append BEFORE the call the way a read does — that would record a write which
61
69
  * may never have happened. So the write and its entry go inside `audit.atomic`: a durable log
62
70
  * opens a transaction, so a row never changes without the entry naming who changed it, and an
63
- * entry never describes a write that rolled back. The `failed` entry is written OUTSIDE it, after
64
- * the rollback — inside, it would be rolled back with the write it reports. Nothing about the
65
- * thrown value is read or rendered; the caller owns it, and an audit reason is a key.
71
+ * entry never describes a write that rolled back. A sink that throws on the `allowed` entry rolls
72
+ * the write back with it; the log then says `failed`, and so does the caller's error — it used to
73
+ * say `failed` for an action that had committed, and an operator re-ran it.
74
+ *
75
+ * The `failed` entry is written OUTSIDE, after the rollback — inside, it would be rolled back with
76
+ * the write it reports. Nothing about the thrown value is read or rendered (a `catch` binding holds
77
+ * whatever an app threw, and `String()` of a prototype-less object throws from inside the block
78
+ * that owes the auditor its entry); the caller gets it UNCHANGED, and an audit reason is a key.
66
79
  */
67
- export async function auditedWrite<T>(
68
- // Only the NAME is read, so this stays invariance-free: `AdminResource<Row>` at four call
69
- // sites would need the generic threaded through for nothing.
70
- resource: { readonly name: string },
71
- op: AdminOperation,
72
- ctx: CrudCtx,
80
+ export async function atomicallyAudited<T>(
81
+ audit: AuditLog,
82
+ entry: AuditedEntry,
83
+ reasons: { readonly allowed: string; readonly failed: string },
84
+ /** The id of the row the write addresses, for the `failed` entry: `null` for a set or a create. */
73
85
  entityId: string | null,
74
- decision: AdminDecision,
75
86
  write: () => Promise<T>,
76
87
  /** The diff of what the write did, and the id of the row it left — known only afterwards. */
77
88
  written: (value: T) => {
@@ -79,37 +90,55 @@ export async function auditedWrite<T>(
79
90
  readonly diff: readonly AuditFieldDiff[];
80
91
  },
81
92
  ): Promise<{ readonly value: T; readonly audit: AuditEntry }> {
82
- const entry = {
83
- requestId: ctx.requestId,
84
- actor: ctx.actor,
85
- operation: op,
86
- kind: 'operation',
87
- entity: resource.name,
88
- permission: decision.permission,
89
- } as const;
90
93
  try {
91
- return await ctx.audit.atomic(async () => {
94
+ return await audit.atomic(async () => {
92
95
  const value = await write();
93
- const audit = await ctx.audit.append({
96
+ const appended = await audit.append({
94
97
  ...entry,
95
98
  ...written(value),
96
99
  outcome: 'allowed',
97
- reason: decision.reason,
100
+ reason: reasons.allowed,
98
101
  });
99
- return { value, audit };
102
+ return { value, audit: appended };
100
103
  });
101
104
  } catch (error) {
102
- await ctx.audit.append({
103
- ...entry,
104
- entityId,
105
- outcome: 'failed',
106
- reason: WRITE_FAILED_REASON,
107
- diff: [],
108
- });
105
+ await audit.append({ ...entry, entityId, outcome: 'failed', reason: reasons.failed, diff: [] });
109
106
  throw error;
110
107
  }
111
108
  }
112
109
 
110
+ /** A repo WRITE of one CRUD operation, through `atomicallyAudited`. */
111
+ export function auditedWrite<T>(
112
+ // Only the NAME is read, so this stays invariance-free: `AdminResource<Row>` at four call
113
+ // sites would need the generic threaded through for nothing.
114
+ resource: { readonly name: string },
115
+ op: AdminOperation,
116
+ ctx: CrudCtx,
117
+ entityId: string | null,
118
+ decision: AdminDecision,
119
+ write: () => Promise<T>,
120
+ written: (value: T) => {
121
+ readonly entityId: string | null;
122
+ readonly diff: readonly AuditFieldDiff[];
123
+ },
124
+ ): Promise<{ readonly value: T; readonly audit: AuditEntry }> {
125
+ return atomicallyAudited(
126
+ ctx.audit,
127
+ {
128
+ requestId: ctx.requestId,
129
+ actor: ctx.actor,
130
+ operation: op,
131
+ kind: 'operation',
132
+ entity: resource.name,
133
+ permission: decision.permission,
134
+ },
135
+ { allowed: decision.reason, failed: WRITE_FAILED_REASON },
136
+ entityId,
137
+ write,
138
+ written,
139
+ );
140
+ }
141
+
113
142
  /** The key a write to a row that is not there is logged with. */
114
143
  const ROW_MISSING_REASON = 'admin.error.row-missing';
115
144
 
@@ -138,6 +167,35 @@ export async function missingRow<Row extends AdminRow>(
138
167
  };
139
168
  }
140
169
 
170
+ /** An update against a version the row no longer has — `crud.ts`'s optimistic check. */
171
+ export async function staleRow<Row extends AdminRow>(
172
+ resource: AdminResource<Row>,
173
+ ctx: CrudCtx,
174
+ id: string,
175
+ decision: AdminDecision,
176
+ row: Row,
177
+ version: string,
178
+ ): Promise<CrudResult<Row>> {
179
+ return {
180
+ ok: false,
181
+ kind: 'stale',
182
+ row,
183
+ version,
184
+ audit: await ctx.audit.append({
185
+ requestId: ctx.requestId,
186
+ actor: ctx.actor,
187
+ operation: 'update',
188
+ kind: 'operation',
189
+ entity: resource.name,
190
+ entityId: id,
191
+ permission: decision.permission,
192
+ outcome: 'failed',
193
+ reason: ROW_CHANGED_REASON,
194
+ diff: [],
195
+ }),
196
+ };
197
+ }
198
+
141
199
  export async function invalid<Row extends AdminRow>(
142
200
  resource: AdminResource<Row>,
143
201
  op: AdminOperation,
package/src/crud.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  import type { AuditEntry, AuditLog } from './audit';
6
6
  import { type AdminActor, type AdminAuthz, type AdminDecision, decideAll } from './authz';
7
7
  import { onlyFor, rowDiff, splitSecrets, withoutTenant, withTenant } from './crud-input';
8
- import { auditedWrite, deniedEntry, invalid, missingRow, refuse } from './crud-outcome';
8
+ import { auditedWrite, deniedEntry, invalid, missingRow, refuse, staleRow } from './crud-outcome';
9
9
  import { type AdminListRequest, findRow, listWhere } from './list-scope';
10
10
  import { type AdminPage, fetchPage } from './pagination';
11
11
  import {
@@ -20,6 +20,7 @@ import type { AdminRow } from './registry';
20
20
  import { type AdminResource, repoOf } from './resource';
21
21
  import type { AdminScope } from './resource-list';
22
22
  import { outOfScopeDecision, writeOutsideScope } from './row-scope-write';
23
+ import { rowVersion } from './row-version';
23
24
  import { type ValidationIssue, validateInput } from './validate';
24
25
 
25
26
  export interface CrudCtx {
@@ -49,7 +50,28 @@ export type CrudResult<Row extends AdminRow> =
49
50
  * actor's row scope, which are one answer on purpose. Nothing was written; the attempt is on
50
51
  * the log as `failed`, like every other write that did not happen.
51
52
  */
52
- | { readonly ok: false; readonly kind: 'missing'; readonly audit: AuditEntry };
53
+ | { readonly ok: false; readonly kind: 'missing'; readonly audit: AuditEntry }
54
+ /**
55
+ * An update against a `version` the row no longer has: somebody wrote it after the form was
56
+ * rendered. Nothing was written; the attempt is on the log as `failed`
57
+ * (`admin.error.row-changed`), and `row` is the row as it is NOW — what a re-render shows.
58
+ */
59
+ | {
60
+ readonly ok: false;
61
+ readonly kind: 'stale';
62
+ readonly row: Row;
63
+ readonly version: string;
64
+ readonly audit: AuditEntry;
65
+ };
66
+
67
+ export interface AdminUpdateOptions {
68
+ /**
69
+ * `rowVersion()` of the row the caller was shown — the edit form posts it as `_version`. Given,
70
+ * a row that has changed since is refused (`kind: 'stale'`) instead of overwritten with the
71
+ * stale copy. Omitted (an MCP call, a script), the write is against the row as it is now.
72
+ */
73
+ readonly version?: string;
74
+ }
53
75
 
54
76
  export type ListResult<Row extends AdminRow> =
55
77
  | {
@@ -89,6 +111,12 @@ export function decideOperation(
89
111
  * pair was the only gate on a single row.
90
112
  */
91
113
  row?: AdminRow | null,
114
+ /**
115
+ * The validated values a create or an update WRITES — the row as the write leaves it. A rule
116
+ * over what is written ("nobody files an invoice as paid") reads it as `input`; absent on every
117
+ * surface that writes nothing.
118
+ */
119
+ input?: Readonly<Record<string, unknown>>,
92
120
  ): AdminDecision {
93
121
  // First, before any grant is consulted: an operation the resource does not OFFER is refused for
94
122
  // everyone. `canOperate` checked this for the nav, the buttons and the MCP tools, and the three
@@ -106,6 +134,7 @@ export function decideOperation(
106
134
  entity: resource.name,
107
135
  ...(id === undefined ? {} : { id }),
108
136
  ...(row === undefined ? {} : { row }),
137
+ ...(input === undefined ? {} : { input }),
109
138
  });
110
139
  }
111
140
 
@@ -231,13 +260,17 @@ export async function adminCreate<Row extends AdminRow>(
231
260
  const permission = entityPermissionFor(resource.permission, 'create');
232
261
  return refuse(resource, 'create', ctx, outOfScopeDecision(permission, outside), null);
233
262
  }
263
+ // Decided again on what is WRITTEN: the first decision had no values to read, and a rule over
264
+ // them is the one an ownership or a state rule on a create is.
265
+ const written = decideOperation(resource, 'create', ctx, undefined, undefined, parsed.value);
266
+ if (!written.allowed) return refuse(resource, 'create', ctx, written, null);
234
267
 
235
268
  const { value: row, audit } = await auditedWrite(
236
269
  resource,
237
270
  'create',
238
271
  ctx,
239
272
  null,
240
- decision,
273
+ written,
241
274
  () => repoOf(resource).create({ ...parsed.value, ...secrets }),
242
275
  (made) => ({
243
276
  entityId: String(made[resource.idField] ?? ''),
@@ -252,6 +285,7 @@ export async function adminUpdate<Row extends AdminRow>(
252
285
  ctx: CrudCtx,
253
286
  id: string,
254
287
  patch: Readonly<Record<string, unknown>>,
288
+ options: AdminUpdateOptions = {},
255
289
  ): Promise<CrudResult<Row>> {
256
290
  // `before` was already loaded here, just after the guard rather than before it — so the rule
257
291
  // that decides whether this actor may touch THIS row never saw the row.
@@ -260,6 +294,12 @@ export async function adminUpdate<Row extends AdminRow>(
260
294
  const decision = decideOperation(resource, 'update', ctx, id, before);
261
295
  if (!decision.allowed) return refuse(resource, 'update', ctx, decision, id);
262
296
  if (before === null) return missingRow(resource, 'update', ctx, id, decision);
297
+ // Optimistic concurrency: the patch was composed against what the caller was SHOWN, and a merge
298
+ // onto a row that has moved on writes their stale copy of every field they posted.
299
+ const now = rowVersion(before);
300
+ if (options.version !== undefined && options.version !== now) {
301
+ return staleRow(resource, ctx, id, decision, before, now);
302
+ }
263
303
 
264
304
  // `{ ...before }` carries no sealed value — a row's sealed properties are non-enumerable — and
265
305
  // `$schema` has no member for one, so the merged object is exactly what the schema describes.
@@ -286,6 +326,10 @@ export async function adminUpdate<Row extends AdminRow>(
286
326
  const permission = entityPermissionFor(resource.permission, 'update');
287
327
  return refuse(resource, 'update', ctx, outOfScopeDecision(permission, outside), id);
288
328
  }
329
+ // Decided again with the row before AND the row the write leaves — a rule over the values an
330
+ // operator is about to write ("not to paid", "not to another owner") reads `input`.
331
+ const written = decideOperation(resource, 'update', ctx, id, before, parsed.value);
332
+ if (!written.allowed) return refuse(resource, 'update', ctx, written, id);
289
333
  const validatedPatch: Readonly<Record<string, unknown>> = {
290
334
  ...Object.fromEntries(
291
335
  submittedKeys
@@ -299,7 +343,7 @@ export async function adminUpdate<Row extends AdminRow>(
299
343
  'update',
300
344
  ctx,
301
345
  id,
302
- decision,
346
+ written,
303
347
  () => repo.update(id, validatedPatch),
304
348
  (changed) => ({ entityId: id, diff: rowDiff(resource, before, changed, secrets) }),
305
349
  );
package/src/describe.ts CHANGED
@@ -36,6 +36,10 @@ export interface AdminActionDescription {
36
36
  readonly batch: boolean;
37
37
  /** Rows a batch runs inline before it is queued as `admin.batch` jobs. `null`: always inline. */
38
38
  readonly threshold: number | null;
39
+ /** Declared `readonly`: its admin gate is `admin:read` — unless `destructive` or `matching` holds it. */
40
+ readonly readonly: boolean;
41
+ /** It declares `matching`: "all matching" is ONE set-based write, which keeps the write gate. */
42
+ readonly matching: boolean;
39
43
  }
40
44
 
41
45
  export interface AdminResourceDescription {
@@ -88,6 +92,8 @@ const actionOf = (action: AdminAction): AdminActionDescription => ({
88
92
  when: action.when !== undefined,
89
93
  batch: action.batch !== undefined,
90
94
  threshold: batchPlan(action)?.threshold ?? null,
95
+ readonly: action.readonly === true,
96
+ matching: action.matching !== undefined,
91
97
  });
92
98
 
93
99
  /** Sorted by name and by URL, so two boots of one app describe it in the same bytes. */
package/src/dev/data.ts CHANGED
@@ -144,8 +144,8 @@ export function defaultDevSources(opts: DevSourceOptions = {}): DevSources {
144
144
 
145
145
  const sources: DevSources = {
146
146
  async routes(): Promise<readonly RouteFact[]> {
147
- const { describeRoutes } = await import('@ultimat3/render');
148
- return describeRoutes().map((route) => ({
147
+ const { describePages } = await import('@ultimat3/render');
148
+ return describePages().map((route) => ({
149
149
  path: route.path,
150
150
  // `RouteDescriptor` calls the render mode `mode`; the panel's own word is `render`, and
151
151
  // the two are bridged here, once. Reading `route['render']` answered `undefined` for
@@ -155,12 +155,9 @@ 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,
@@ -0,0 +1,9 @@
1
+ // The `/_x` translator: the framework catalog in the framework's own locale, whatever the app's
2
+ // default is. Every `/_x` string is registered under `DEFAULT_LOCALE` only, so the
3
+ // AMBIENT locale — an `es-co` app's — answered `⟦dev.panel.mail.title⟧` for every tab.
4
+
5
+ import { DEFAULT_LOCALE } from '@ultimat3/core';
6
+ import { type TranslateVars, translatorFor } from '@ultimat3/i18n';
7
+
8
+ export const t = (key: string, vars?: TranslateVars): string =>
9
+ translatorFor(DEFAULT_LOCALE)(key, vars);
package/src/dev/facts.ts CHANGED
@@ -20,7 +20,7 @@ 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
25
  /**
26
26
  * `RouteDescriptor.mount` — who mounted a route no surface file declares (`defineAdmin`), and
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';
@@ -0,0 +1,38 @@
1
+ // The `/_x` jobs tab's opening ring: the recent runs this panel read, by state — ui's own
2
+ // `DonutChart`, server markup with a hidden data table. Only ever reached by `panel-jobs.ts`'s
3
+ // dynamic `import()`, so `/_x`'s mount graph loads no Solid until the jobs tab is opened.
4
+
5
+ import { ctxOf, runWithContext } from '@ultimat3/core';
6
+ import { renderToHtml } from '@ultimat3/render/server';
7
+ import { DonutChart } from '@ultimat3/ui';
8
+ import { t } from './dev-t';
9
+ import type { JobRunFact } from './facts';
10
+ import type { JobsPanelData } from './panel-jobs';
11
+
12
+ /** Every state a run can be in, in the order the ring and its legend draw them. */
13
+ const RUN_STATES: readonly JobRunFact['status'][] = ['ok', 'running', 'failed', 'dead'];
14
+
15
+ /**
16
+ * The ring as HTML, or `''` when no run has been recorded: an empty circle reads as a broken chart,
17
+ * and the overview below already says the queue is idle. The framework's locale and UTC, stated
18
+ * rather than ambient — `/_x` is the framework's tool.
19
+ */
20
+ export async function jobsStateHtml(data: JobsPanelData): Promise<string> {
21
+ if (data.runs.length === 0) return '';
22
+ const counts = RUN_STATES.map((state) => ({
23
+ label: t(`dev.panel.jobs.state.${state}`),
24
+ value: data.runs.filter((run) => run.status === state).length,
25
+ }));
26
+ return runWithContext(ctxOf({ locale: 'en', tz: 'UTC' }), () =>
27
+ renderToHtml(
28
+ <DonutChart
29
+ label={t('dev.panel.jobs.chart.label')}
30
+ showCaption
31
+ segments={counts}
32
+ centreLabel={t('dev.panel.jobs.chart.total')}
33
+ keyLabel={t('dev.panel.jobs.chart.state')}
34
+ valueLabel={t('dev.panel.jobs.chart.runs')}
35
+ />,
36
+ ),
37
+ );
38
+ }
@@ -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
@@ -75,11 +75,16 @@ export const jobsPanel: DevPanel<JobsPanelData> = {
75
75
  };
76
76
  },
77
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.
78
+ * One jobs UI: the tab draws the admin's own overview component over this process's queue,
79
+ * under a ring of the runs this panel read by state. Dynamic, so `/_x`'s mount graph loads no
80
+ * view until this tab is opened.
80
81
  */
81
- async html(params, tabPath) {
82
- const { jobsTabHtml } = await import('../jobs/overview-tab');
83
- return jobsTabHtml(tabPath, params);
82
+ async html(params, tabPath, data) {
83
+ const [{ jobsTabHtml }, { jobsStateHtml }] = await Promise.all([
84
+ import('../jobs/overview-tab'),
85
+ import('./jobs-state-chart'),
86
+ ]);
87
+ const ring = data === undefined ? '' : await jobsStateHtml(data);
88
+ return `${ring}${await jobsTabHtml(tabPath, params)}`;
84
89
  },
85
90
  };
@@ -1,10 +1,12 @@
1
1
  // Panel: Request timeline.
2
- // Kills: "where did the 800ms go?" — a flamegraph of one request: SQL, cache hits, action
2
+ // Kills: "where did the 800ms go?" — a waterfall of one request: SQL, cache hits, action
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';
9
+ import { timelineHtml } from './timeline-waterfall';
8
10
 
9
11
  export interface FlameRow {
10
12
  readonly span: TimelineSpan;
@@ -68,8 +70,12 @@ export const timelinePanel: DevPanel<TimelinePanelData> = {
68
70
  (wanted === null ? traces[0] : traces.find((trace) => trace.requestId === wanted)) ?? null;
69
71
  // Degrade rather than reject, as `panel-live.ts` does for `subscribers`: a host with traces
70
72
  // 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);
73
+ // "nobody counted" is not "counted, and this request was clean". Only for the error that MEANS
74
+ // unwired: a detector that ran and failed is a diagnostic, never "no detector".
75
+ const loops = await sources.statementLoops().catch((error: unknown): null => {
76
+ if (!(error instanceof DevSourceUnavailableError)) throw error;
77
+ return null;
78
+ });
73
79
 
74
80
  const totalsByKind: Record<string, number> = {};
75
81
  const sqlCounts = new Map<string, number>();
@@ -100,4 +106,11 @@ export const timelinePanel: DevPanel<TimelinePanelData> = {
100
106
  loops === null ? null : loops.filter((loop) => loop.requestId === selected?.requestId),
101
107
  };
102
108
  },
109
+ /**
110
+ * The waterfall, drawn from the data above — never a second read of the traces. With no data
111
+ * handed there is nothing to draw, and the server folds the payload under an empty body.
112
+ */
113
+ async html(_params, tabPath, data) {
114
+ return data === undefined ? '' : timelineHtml(data, tabPath);
115
+ },
103
116
  };
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> {
@@ -18,10 +18,13 @@ export interface DevPanel<Data = unknown> {
18
18
  data(sources: DevSources, params: URLSearchParams): Promise<Data>;
19
19
  /**
20
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.
21
+ * jobs overview, the timeline its waterfall. Absent: the tab is its `--json` payload in a
22
+ * `<pre>`. Asked only once `data` answered, and handed that answer, so the drawing and the
23
+ * `--json` payload are the same facts and an unwired source is still the payload's own error.
24
+ * Optional only for a caller that draws a tab outside the server (a test of the jobs tab); the
25
+ * server always hands it.
23
26
  */
24
- html?(params: URLSearchParams, tabPath: string): Promise<string>;
27
+ html?(params: URLSearchParams, tabPath: string, data?: Data): Promise<string>;
25
28
  }
26
29
 
27
30
  export type PanelPayload =
@@ -53,7 +56,9 @@ export async function panelPayload(
53
56
  panel: panel.key,
54
57
  ok: false,
55
58
  error: {
56
- code: stringField(error, 'code') ?? 'X_NOT_IMPLEMENTED',
59
+ // Core's code for a failure nothing classified. `X_NOT_IMPLEMENTED` was here, and it told
60
+ // the reader a feature was missing when a source had thrown something with no code at all.
61
+ code: stringField(error, 'code') ?? InternalError.code,
57
62
  cause: stringField(error, 'cause') ?? renderThrowable(error),
58
63
  fix: stringField(error, 'fix') ?? 'x dev --help',
59
64
  },