@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.
- package/CLAUDE.md +32 -30
- package/README.md +51 -16
- package/package.json +17 -17
- package/src/action-gate.ts +33 -42
- package/src/admin.module.scss +118 -4
- package/src/audit-pg.ts +2 -2
- package/src/audit.ts +1 -3
- package/src/authz.ts +8 -8
- package/src/batch-matching.ts +26 -31
- package/src/batch-queue.ts +90 -0
- package/src/batch.ts +11 -51
- package/src/crud-input.ts +2 -1
- package/src/crud-outcome.ts +96 -38
- package/src/crud.ts +48 -4
- package/src/describe.ts +6 -0
- package/src/dev/data.ts +5 -8
- package/src/dev/dev-t.ts +9 -0
- package/src/dev/facts.ts +1 -1
- package/src/dev/index.ts +1 -0
- package/src/dev/jobs-state-chart.tsx +38 -0
- package/src/dev/panel-cache.ts +8 -3
- package/src/dev/panel-jobs.ts +10 -5
- package/src/dev/panel-timeline.ts +16 -3
- package/src/dev/panel.ts +10 -5
- package/src/dev/server.ts +43 -17
- package/src/dev/sql-runner.ts +31 -0
- package/src/dev/timeline-style.ts +47 -0
- package/src/dev/timeline-waterfall.ts +96 -0
- package/src/errors.ts +9 -5
- package/src/form-decode.ts +55 -5
- package/src/form.tsx +19 -5
- package/src/index.ts +11 -9
- package/src/jobs/fleet-repo.ts +15 -3
- package/src/jobs/job-repo.ts +51 -17
- package/src/jobs/operator.ts +5 -5
- package/src/jobs/overview-tab.tsx +2 -2
- package/src/keyset-seek.ts +189 -0
- package/src/layout.tsx +34 -8
- package/src/list-filters.ts +30 -5
- package/src/list-request.ts +8 -6
- package/src/list.tsx +1 -1
- package/src/mcp-scopes.ts +36 -0
- package/src/mcp-tools.ts +16 -2
- package/src/mcp.ts +27 -11
- package/src/pagination.ts +32 -22
- package/src/policy-bridge.ts +16 -14
- package/src/registry.ts +9 -1
- package/src/related.ts +2 -2
- package/src/relations.ts +26 -10
- package/src/repo-entity.ts +37 -46
- package/src/resource-list.ts +9 -0
- package/src/resource.ts +10 -0
- package/src/row-version.ts +20 -0
- package/src/screen-home-access.tsx +117 -0
- package/src/screen-home-counts.ts +42 -0
- package/src/screen-home-kpis.tsx +64 -0
- package/src/screen-home.module.scss +169 -0
- package/src/screen-home.tsx +21 -82
- package/src/screen-lookup.tsx +1 -1
- package/src/screen-resource.tsx +60 -12
- package/src/screen-system.tsx +8 -2
- package/src/search.ts +12 -4
- package/src/widgets.tsx +2 -6
- package/src/inert-jsx.ts +0 -182
package/src/crud-outcome.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
52
|
-
*
|
|
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.
|
|
64
|
-
* the
|
|
65
|
-
*
|
|
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
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
94
|
+
return await audit.atomic(async () => {
|
|
92
95
|
const value = await write();
|
|
93
|
-
const
|
|
96
|
+
const appended = await audit.append({
|
|
94
97
|
...entry,
|
|
95
98
|
...written(value),
|
|
96
99
|
outcome: 'allowed',
|
|
97
|
-
reason:
|
|
100
|
+
reason: reasons.allowed,
|
|
98
101
|
});
|
|
99
|
-
return { value, audit };
|
|
102
|
+
return { value, audit: appended };
|
|
100
103
|
});
|
|
101
104
|
} catch (error) {
|
|
102
|
-
await
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
148
|
-
return
|
|
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
|
-
//
|
|
159
|
-
//
|
|
160
|
-
budget: {
|
|
161
|
-
...(route.budgetJs === null ? {} : { js: route.budgetJs }),
|
|
162
|
-
...(route.budgetLcp === null ? {} : { lcp: route.budgetLcp }),
|
|
163
|
-
},
|
|
158
|
+
// A flat field on the descriptor, a nested bag on the fact — the panel's budget check
|
|
159
|
+
// reads `budget.js`. Spread, never `js: undefined`: `exactOptionalPropertyTypes`.
|
|
160
|
+
budget: route.budgetJs === null ? {} : { js: route.budgetJs },
|
|
164
161
|
// `revalidateTags`, already flattened to keys — never `revalidate.tags`, a shape the
|
|
165
162
|
// descriptor does not have and which answered `[]` for every ISR route in the app.
|
|
166
163
|
revalidateTags: route.revalidateTags,
|
package/src/dev/dev-t.ts
ADDED
|
@@ -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
|
|
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
|
@@ -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
|
+
}
|
package/src/dev/panel-cache.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Kills: "why is this page still stale?" — the tag graph, and the invalidation log showing
|
|
3
3
|
// what busted what.
|
|
4
4
|
|
|
5
|
+
import { DevSourceUnavailableError } from '../errors';
|
|
5
6
|
import type { CacheEdgeFact, InvalidationFact } from './facts';
|
|
6
7
|
import type { DevPanel } from './panel';
|
|
7
8
|
|
|
@@ -23,11 +24,15 @@ export const cachePanel: DevPanel<CachePanelData> = {
|
|
|
23
24
|
questionKey: 'dev.panel.cache.question',
|
|
24
25
|
async data(sources): Promise<CachePanelData> {
|
|
25
26
|
const graph = await sources.cacheGraph();
|
|
26
|
-
// The invalidation log needs a running process that has served a write; the graph alone
|
|
27
|
-
//
|
|
27
|
+
// The invalidation log needs a running process that has served a write; the graph alone is
|
|
28
|
+
// still worth showing when it is NOT WIRED — and only then. A log that is wired and failed is
|
|
29
|
+
// a diagnostic, not "no invalidations yet".
|
|
28
30
|
const invalidations = await sources
|
|
29
31
|
.invalidations()
|
|
30
|
-
.catch((): readonly InvalidationFact[] =>
|
|
32
|
+
.catch((error: unknown): readonly InvalidationFact[] => {
|
|
33
|
+
if (!(error instanceof DevSourceUnavailableError)) throw error;
|
|
34
|
+
return [];
|
|
35
|
+
});
|
|
31
36
|
|
|
32
37
|
const busted = new Set(invalidations.flatMap((event) => event.busted));
|
|
33
38
|
// A `Map`, then `Object.fromEntries` — never `count[key] = (count[key] ?? 0) + 1` on a plain
|
package/src/dev/panel-jobs.ts
CHANGED
|
@@ -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
|
|
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
|
|
83
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
22
|
-
*
|
|
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
|
-
|
|
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
|
},
|