@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/dev/server.ts
CHANGED
|
@@ -4,11 +4,11 @@
|
|
|
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';
|
|
8
|
-
import { FRAMEWORK_CATALOG_LOCALE, translatorFor } from '@ultimat3/i18n';
|
|
7
|
+
import { DEFAULT_ENVIRONMENT, escapeHtml, tryResolveEnvironment } from '@ultimat3/core';
|
|
9
8
|
import type { ColorRole } from '@ultimat3/ui';
|
|
10
9
|
import { DevDashboardInProdError } from '../errors';
|
|
11
10
|
import { defaultDevSources } from './data';
|
|
11
|
+
import { t } from './dev-t';
|
|
12
12
|
import type { DevSources } from './facts';
|
|
13
13
|
import { type DevPanel, type PanelPayload, panelPayload } from './panel';
|
|
14
14
|
import { cachePanel } from './panel-cache';
|
|
@@ -20,6 +20,7 @@ import { manifestPanel } from './panel-manifest';
|
|
|
20
20
|
import { policyPanel } from './panel-policy';
|
|
21
21
|
import { routesPanel } from './panel-routes';
|
|
22
22
|
import { timelinePanel } from './panel-timeline';
|
|
23
|
+
import { WATERFALL_STYLE } from './timeline-style';
|
|
23
24
|
|
|
24
25
|
export const DEV_PANELS: readonly DevPanel[] = [
|
|
25
26
|
routesPanel,
|
|
@@ -43,6 +44,13 @@ export interface DevDashboardOptions {
|
|
|
43
44
|
readonly basePath?: string;
|
|
44
45
|
readonly sources?: DevSources;
|
|
45
46
|
readonly panels?: readonly DevPanel[];
|
|
47
|
+
/**
|
|
48
|
+
* The app surface's stylesheet URL — what `<link rel="stylesheet">` on an `app/` document carries.
|
|
49
|
+
* A drawn panel (the jobs tab) renders ui's own components, whose rules live there and not in the
|
|
50
|
+
* inlined shell; the host that mounts `/_x` is the one that knows the content-hashed URL. Asked
|
|
51
|
+
* per request, because a rebuild under `x dev` moves the hash. Absent or `undefined`: no link.
|
|
52
|
+
*/
|
|
53
|
+
readonly stylesheetHref?: () => string | undefined;
|
|
46
54
|
}
|
|
47
55
|
|
|
48
56
|
const envOf = (name: string): string | undefined => {
|
|
@@ -85,10 +93,11 @@ const jsonResponse = (body: unknown, status = 200): Response =>
|
|
|
85
93
|
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
|
|
86
94
|
});
|
|
87
95
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
96
|
+
/**
|
|
97
|
+
* The roles /_x paints with. `--x-*` is the admin's namespace; the values are ui's. The chart
|
|
98
|
+
* series are the timeline's span kinds — `chart-*` is contrast-gated against both surfaces, and
|
|
99
|
+
* every kind is named in text beside its bar, so colour is never the only key.
|
|
100
|
+
*/
|
|
92
101
|
const SHELL_ROLES = [
|
|
93
102
|
'bg',
|
|
94
103
|
'surface-raised',
|
|
@@ -96,6 +105,13 @@ const SHELL_ROLES = [
|
|
|
96
105
|
'fg-muted',
|
|
97
106
|
'line',
|
|
98
107
|
'accent',
|
|
108
|
+
'chart-1',
|
|
109
|
+
'chart-2',
|
|
110
|
+
'chart-3',
|
|
111
|
+
'chart-4',
|
|
112
|
+
'chart-5',
|
|
113
|
+
'chart-6',
|
|
114
|
+
'chart-7',
|
|
99
115
|
] as const satisfies readonly ColorRole[];
|
|
100
116
|
|
|
101
117
|
const SHELL_LAYOUT = `
|
|
@@ -110,7 +126,7 @@ p.question { color: rgb(var(--x-color-fg-muted)); margin: 0 0 1rem; }
|
|
|
110
126
|
pre { background: rgb(var(--x-color-surface-raised)); border: 1px solid rgb(var(--x-color-line));
|
|
111
127
|
padding: 1rem; overflow: auto; }
|
|
112
128
|
:focus-visible { outline: 2px solid rgb(var(--x-color-accent)); outline-offset: 2px; }
|
|
113
|
-
`;
|
|
129
|
+
${WATERFALL_STYLE}`;
|
|
114
130
|
|
|
115
131
|
let stylePromise: Promise<string> | undefined;
|
|
116
132
|
|
|
@@ -157,15 +173,10 @@ html[data-theme="dark"] { ${block('dark')} }
|
|
|
157
173
|
${SHELL_LAYOUT}`;
|
|
158
174
|
}
|
|
159
175
|
|
|
160
|
-
/**
|
|
161
|
-
* /_x is the framework's own tool, in the framework's own locale (`<html lang="en">` below): its
|
|
162
|
-
* strings are registered under `FRAMEWORK_CATALOG_LOCALE` only, so the AMBIENT locale — the app's,
|
|
163
|
-
* `es-co` for an app that defaults to it — answered `⟦dev.panel.mail.title⟧` for every tab.
|
|
164
|
-
*/
|
|
165
|
-
const t = (key: string): string => translatorFor(FRAMEWORK_CATALOG_LOCALE)(key);
|
|
166
|
-
|
|
167
176
|
function shell(
|
|
168
177
|
style: string,
|
|
178
|
+
/** The app surface's stylesheet, linked BEFORE the shell's `<style>` so the shell wins ties. */
|
|
179
|
+
stylesheetHref: string | undefined,
|
|
169
180
|
basePath: string,
|
|
170
181
|
panels: readonly DevPanel[],
|
|
171
182
|
active: DevPanel,
|
|
@@ -184,7 +195,11 @@ function shell(
|
|
|
184
195
|
<html lang="en"><head><meta charset="utf-8">
|
|
185
196
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
186
197
|
<meta name="robots" content="noindex, nofollow">
|
|
187
|
-
<title>_x · ${escapeHtml(active.key)}</title
|
|
198
|
+
<title>_x · ${escapeHtml(active.key)}</title>${
|
|
199
|
+
stylesheetHref === undefined
|
|
200
|
+
? ''
|
|
201
|
+
: `<link rel="stylesheet" href="${escapeHtml(stylesheetHref)}">`
|
|
202
|
+
}<style>${style}</style></head>
|
|
188
203
|
<body><header><h1>_x</h1><nav>${tabs}</nav>
|
|
189
204
|
<a href="${basePath}/${active.key}?json=1">--json</a></header>
|
|
190
205
|
<main><p class="question">${escapeHtml(t(active.questionKey))}</p>
|
|
@@ -241,8 +256,19 @@ export function devDashboard(opts: DevDashboardOptions = {}): DevDashboard {
|
|
|
241
256
|
if (wantsJson) return jsonResponse(payload, payload.ok ? 200 : 500);
|
|
242
257
|
const tabPath = `${basePath}/${panel.key}`;
|
|
243
258
|
const drawn =
|
|
244
|
-
payload.ok && panel.html !== undefined
|
|
245
|
-
|
|
259
|
+
payload.ok && panel.html !== undefined
|
|
260
|
+
? await panel.html(url.searchParams, tabPath, payload.data)
|
|
261
|
+
: null;
|
|
262
|
+
const page = shell(
|
|
263
|
+
await devShellStyle(),
|
|
264
|
+
opts.stylesheetHref?.(),
|
|
265
|
+
basePath,
|
|
266
|
+
panels,
|
|
267
|
+
panel,
|
|
268
|
+
payload,
|
|
269
|
+
drawn,
|
|
270
|
+
);
|
|
271
|
+
return new Response(page, {
|
|
246
272
|
status: 200,
|
|
247
273
|
headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' },
|
|
248
274
|
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// The `/_x` DB panel's statement, RUN: inside `readOnlyQuery` — its own `BEGIN READ ONLY`
|
|
2
|
+
// transaction, a statement timeout and a row ceiling — never through the app's plain `db.query`.
|
|
3
|
+
// A host wires `runSql: readOnlySql(client)`; the guard upstream decides what reaches it.
|
|
4
|
+
|
|
5
|
+
import { type DbClient, readOnlyQuery } from '@ultimat3/db';
|
|
6
|
+
import type { SqlResult } from './facts';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The most rows the panel asks the server for. It draws a grid, not an export: a cursor fetches
|
|
10
|
+
* this many and the rest of a `select * from events` is never materialised in the dev process.
|
|
11
|
+
*/
|
|
12
|
+
export const DEV_SQL_MAX_ROWS = 500;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The runner a `/_x` host hands the DB panel. `assertReadOnlyQuery` (`panel-db.ts`) is the parse
|
|
16
|
+
* guard; this is the SERVER saying no: a statement the guard let through that writes, locks or
|
|
17
|
+
* runs away still meets a read-only transaction that always rolls back, with the app's own
|
|
18
|
+
* credentials and no way to commit. Columns are the first row's keys — the driver hands objects.
|
|
19
|
+
*/
|
|
20
|
+
export function readOnlySql(client: DbClient): (sql: string) => Promise<SqlResult> {
|
|
21
|
+
return async (sql) => {
|
|
22
|
+
const started = performance.now();
|
|
23
|
+
const { rows } = await readOnlyQuery<Readonly<Record<string, unknown>>>(sql, {
|
|
24
|
+
client,
|
|
25
|
+
maxRows: DEV_SQL_MAX_ROWS,
|
|
26
|
+
});
|
|
27
|
+
const elapsedMs = Math.round(performance.now() - started);
|
|
28
|
+
const columns = Object.keys(rows[0] ?? {});
|
|
29
|
+
return { columns, rows: rows.map((row) => columns.map((column) => row[column])), elapsedMs };
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// The `/_x` timeline waterfall's rules, inlined into the shell's one `<style>` (`server.ts`) — /_x
|
|
2
|
+
// has no stylesheet pipeline, and the CSP hash a host computes is of that whole text. Colours are
|
|
3
|
+
// the shell's `--x-color-*` tokens only; each span kind takes one `chart-*` series role.
|
|
4
|
+
|
|
5
|
+
import { SPAN_KINDS } from './timeline-waterfall';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The timeline's waterfall (`timeline-waterfall.ts`): a label column, a track whose bar is placed
|
|
9
|
+
* by the row's own `--offset`/`--width` (server-computed shares), and the duration. Below 720px the
|
|
10
|
+
* label takes its own line, so a long SQL text never squeezes the track to nothing on a phone.
|
|
11
|
+
*/
|
|
12
|
+
export const WATERFALL_STYLE = `
|
|
13
|
+
h2 { font-size: .875rem; margin: 0 0 .5rem; }
|
|
14
|
+
.tl { display: grid; gap: 1.5rem; }
|
|
15
|
+
@media (min-width: 1024px) { .tl { grid-template-columns: minmax(12rem, 18rem) minmax(0, 1fr); } }
|
|
16
|
+
.tl-requests ol, .tl-kinds, .tl-flame, .tl-loops ul { list-style: none; margin: 0; padding: 0; }
|
|
17
|
+
.tl-requests li { display: flex; justify-content: space-between; gap: .5rem; padding: .25rem 0;
|
|
18
|
+
border-bottom: 1px solid rgb(var(--x-color-line)); }
|
|
19
|
+
.tl-requests a { overflow-wrap: anywhere; }
|
|
20
|
+
.tl-requests a[aria-current] { color: rgb(var(--x-color-fg)); font-weight: 700; }
|
|
21
|
+
.tl-ms { color: rgb(var(--x-color-fg-muted)); white-space: nowrap; font-variant-numeric: tabular-nums; }
|
|
22
|
+
.tl-kinds { display: flex; flex-wrap: wrap; gap: .25rem 1rem; margin-bottom: 1rem; }
|
|
23
|
+
.tl-swatch { display: inline-block; inline-size: .75rem; block-size: .75rem; border-radius: 2px;
|
|
24
|
+
margin-inline-end: .375rem; vertical-align: -1px; background: rgb(var(--tl-color)); }
|
|
25
|
+
.tl-row { display: grid; grid-template-columns: minmax(0, 1fr) 4.5rem; gap: .125rem .75rem;
|
|
26
|
+
align-items: center; padding: .25rem 0; border-bottom: 1px solid rgb(var(--x-color-line)); }
|
|
27
|
+
.tl-label { grid-column: 1 / -1; display: flex; gap: .5rem; align-items: baseline; min-inline-size: 0;
|
|
28
|
+
padding-inline-start: calc(var(--depth) * .75rem); }
|
|
29
|
+
@media (min-width: 720px) {
|
|
30
|
+
.tl-row { grid-template-columns: minmax(0, 22rem) minmax(0, 1fr) 4.5rem; }
|
|
31
|
+
.tl-label { grid-column: auto; }
|
|
32
|
+
}
|
|
33
|
+
.tl-kind { flex: none; padding: 0 .375rem; border-radius: 2px; font-size: .75rem;
|
|
34
|
+
border: 1px solid rgb(var(--tl-color)); color: rgb(var(--x-color-fg)); }
|
|
35
|
+
.tl-name { flex: none; }
|
|
36
|
+
.tl-detail { min-inline-size: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
37
|
+
color: rgb(var(--x-color-fg-muted)); }
|
|
38
|
+
.tl-track { position: relative; block-size: .875rem; border-radius: 2px;
|
|
39
|
+
background: rgb(var(--x-color-line) / .35); }
|
|
40
|
+
.tl-bar { position: absolute; inset-block: 0; inset-inline-start: var(--offset);
|
|
41
|
+
inline-size: max(var(--width), 2px); border-radius: 2px; background: rgb(var(--tl-color)); }
|
|
42
|
+
.tl-row > .tl-ms { text-align: end; }
|
|
43
|
+
.tl-loops { margin-top: 1.5rem; }
|
|
44
|
+
.tl-loops li { padding: .5rem 0; border-bottom: 1px solid rgb(var(--x-color-line)); }
|
|
45
|
+
.tl-empty { color: rgb(var(--x-color-fg-muted)); }
|
|
46
|
+
${SPAN_KINDS.map((kind, index) => `[data-kind="${kind}"] { --tl-color: var(--x-color-chart-${index + 1}); }`).join('\n')}
|
|
47
|
+
`;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// The `/_x` timeline tab, drawn: the selected request's spans as a waterfall — one row per span,
|
|
2
|
+
// indented under its parent, its bar placed and sized by the server from the payload's own
|
|
3
|
+
// `offset`/`width` — beside the recent requests, the time per kind and the N+1 verdicts. Plain
|
|
4
|
+
// escaped HTML on the shell's tokens: `/_x` loads no Solid and no stylesheet pipeline to draw it.
|
|
5
|
+
|
|
6
|
+
import { escapeHtml } from '@ultimat3/core';
|
|
7
|
+
import { t } from './dev-t';
|
|
8
|
+
import type { SpanKind, StatementLoopFact } from './facts';
|
|
9
|
+
import type { FlameRow, TimelinePanelData } from './panel-timeline';
|
|
10
|
+
|
|
11
|
+
/** Every kind a span can be, in the order the legend lists them — and the colour slot each takes. */
|
|
12
|
+
export const SPAN_KINDS: readonly SpanKind[] = [
|
|
13
|
+
'http',
|
|
14
|
+
'render',
|
|
15
|
+
'action',
|
|
16
|
+
'policy',
|
|
17
|
+
'sql',
|
|
18
|
+
'cache',
|
|
19
|
+
'job',
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
/** `/_x` is the framework's English tool; a duration reads to a tenth of a millisecond. */
|
|
23
|
+
const MS = new Intl.NumberFormat('en', { maximumFractionDigits: 1 });
|
|
24
|
+
const ms = (value: number): string => t('dev.panel.timeline.ms', { ms: MS.format(value) });
|
|
25
|
+
|
|
26
|
+
/** A 0..1 share as a CSS percentage: two decimals at most, never `-0`, clamped to the track. */
|
|
27
|
+
const percent = (share: number): string => {
|
|
28
|
+
const clamped = Number.isFinite(share) ? Math.min(1, Math.max(0, share)) : 0;
|
|
29
|
+
const text = (clamped * 100).toFixed(2).replace(/\.?0+$/, '');
|
|
30
|
+
return `${text === '-0' ? '0' : text}%`;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const kindLabel = (kind: SpanKind): string => t(`dev.panel.timeline.kind.${kind}`);
|
|
34
|
+
|
|
35
|
+
const kindTag = (kind: SpanKind): string =>
|
|
36
|
+
`<span class="tl-kind">${escapeHtml(kindLabel(kind))}</span>`;
|
|
37
|
+
|
|
38
|
+
function requestList(data: TimelinePanelData, tabPath: string): string {
|
|
39
|
+
const shown = data.selected?.requestId;
|
|
40
|
+
const items = data.requests
|
|
41
|
+
.map((request) => {
|
|
42
|
+
const href = `${tabPath}?requestId=${encodeURIComponent(request.requestId)}`;
|
|
43
|
+
const current = request.requestId === shown ? ' aria-current="true"' : '';
|
|
44
|
+
return `<li><a href="${escapeHtml(href)}"${current}>${escapeHtml(request.path)}</a> <span class="tl-ms">${escapeHtml(ms(request.totalMs))}</span></li>`;
|
|
45
|
+
})
|
|
46
|
+
.join('');
|
|
47
|
+
const title = escapeHtml(t('dev.panel.timeline.requests'));
|
|
48
|
+
return `<nav class="tl-requests" aria-label="${title}"><h2>${title}</h2><ol>${items}</ol></nav>`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function kindTotals(data: TimelinePanelData): string {
|
|
52
|
+
const items = SPAN_KINDS.filter((kind) => Object.hasOwn(data.totalsByKind, kind))
|
|
53
|
+
.map(
|
|
54
|
+
(kind) =>
|
|
55
|
+
`<li><span class="tl-swatch" data-kind="${kind}" aria-hidden="true"></span>${escapeHtml(kindLabel(kind))} <span class="tl-ms">${escapeHtml(ms(data.totalsByKind[kind] ?? 0))}</span></li>`,
|
|
56
|
+
)
|
|
57
|
+
.join('');
|
|
58
|
+
return `<ul class="tl-kinds" aria-label="${escapeHtml(t('dev.panel.timeline.byKind'))}">${items}</ul>`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function row(flame: FlameRow): string {
|
|
62
|
+
const { span } = flame;
|
|
63
|
+
const style = `--depth:${flame.depth};--offset:${percent(flame.offset)};--width:${percent(flame.width)}`;
|
|
64
|
+
const detail =
|
|
65
|
+
span.detail === ''
|
|
66
|
+
? ''
|
|
67
|
+
: `<code class="tl-detail" title="${escapeHtml(span.detail)}">${escapeHtml(span.detail)}</code>`;
|
|
68
|
+
return `<li class="tl-row" data-kind="${span.kind}" style="${style}"><span class="tl-label">${kindTag(span.kind)} <span class="tl-name">${escapeHtml(span.name)}</span>${detail}</span><span class="tl-track" aria-hidden="true"><span class="tl-bar"></span></span><span class="tl-ms">${escapeHtml(ms(span.durationMs))}</span></li>`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function verdicts(loops: readonly StatementLoopFact[] | null): string {
|
|
72
|
+
const title = `<h2>${escapeHtml(t('dev.panel.timeline.nPlusOne.title'))}</h2>`;
|
|
73
|
+
if (loops === null) {
|
|
74
|
+
return `<section class="tl-loops">${title}<p class="question">${escapeHtml(t('dev.panel.timeline.nPlusOne.unwired'))}</p></section>`;
|
|
75
|
+
}
|
|
76
|
+
if (loops.length === 0) {
|
|
77
|
+
return `<section class="tl-loops">${title}<p class="question">${escapeHtml(t('dev.panel.timeline.nPlusOne.clean'))}</p></section>`;
|
|
78
|
+
}
|
|
79
|
+
const items = loops
|
|
80
|
+
.map(
|
|
81
|
+
(loop) =>
|
|
82
|
+
`<li><strong>${escapeHtml(loop.code)}</strong> ${escapeHtml(loop.cause)}<br><span class="question">${escapeHtml(t('dev.panel.timeline.nPlusOne.fix'))}</span> <code>${escapeHtml(loop.fix)}</code></li>`,
|
|
83
|
+
)
|
|
84
|
+
.join('');
|
|
85
|
+
return `<section class="tl-loops">${title}<ul>${items}</ul></section>`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The tab body. Pure over the panel's data, so the `--json` payload and the drawing agree. */
|
|
89
|
+
export function timelineHtml(data: TimelinePanelData, tabPath: string): string {
|
|
90
|
+
const selected = data.selected;
|
|
91
|
+
if (selected === null) {
|
|
92
|
+
return `<p class="tl-empty">${escapeHtml(t('dev.panel.timeline.empty'))}</p>`;
|
|
93
|
+
}
|
|
94
|
+
const heading = `${selected.method} ${selected.path} · ${selected.status} · ${ms(selected.totalMs)}`;
|
|
95
|
+
return `<div class="tl">${requestList(data, tabPath)}<section class="tl-trace" aria-labelledby="tl-heading"><h2 id="tl-heading">${escapeHtml(heading)}</h2>${kindTotals(data)}<ol class="tl-flame" aria-label="${escapeHtml(t('dev.panel.timeline.spans'))}">${data.flame.map(row).join('')}</ol>${verdicts(data.nPlusOne)}</section></div>`;
|
|
96
|
+
}
|
package/src/errors.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// The X_* codes owned by @ultimat3/admin. Every one names the exact edit that fixes it,
|
|
2
2
|
// because the two dashboards fail at boot (bad registry, bad mount) where an agent has no
|
|
3
3
|
// stack trace to reason from — only the message.
|
|
4
|
-
import { registerErrorCodes, UltimateError } from '@ultimat3/core';
|
|
4
|
+
import { NotImplementedError, registerErrorCodes, UltimateError } from '@ultimat3/core';
|
|
5
5
|
|
|
6
6
|
/** Codes this package declares and owns. */
|
|
7
7
|
export const ADMIN_OWNED_ERROR_CODES = [
|
|
@@ -32,8 +32,8 @@ export const ADMIN_OWNED_ERROR_CODES = [
|
|
|
32
32
|
] as const;
|
|
33
33
|
|
|
34
34
|
/**
|
|
35
|
-
* `X_NOT_IMPLEMENTED` is `@ultimat3/core`'s. `DevSourceUnavailableError`
|
|
36
|
-
* neither titles nor registers
|
|
35
|
+
* `X_NOT_IMPLEMENTED` is `@ultimat3/core`'s. `DevSourceUnavailableError` IS core's
|
|
36
|
+
* `NotImplementedError`, narrowed; this package neither constructs, titles nor registers the code, because the owner's title is the only one that may exist.
|
|
37
37
|
*/
|
|
38
38
|
export const ADMIN_BORROWED_ERROR_CODES = ['X_NOT_IMPLEMENTED'] as const;
|
|
39
39
|
|
|
@@ -254,11 +254,15 @@ export class AdminPagePathInvalidError extends UltimateError {
|
|
|
254
254
|
* A `/_x` panel needs a fact the framework cannot introspect on its own — request traces,
|
|
255
255
|
* caught mail, the read-only SQL tool, the committed manifest. Thrown instead of drawing an
|
|
256
256
|
* empty panel, because an empty panel reads as "nothing happened".
|
|
257
|
+
*
|
|
258
|
+
* Core's NotImplementedError and not a sibling of it: core is the one constructor of the code.
|
|
259
|
+
* A class of its own all the same, because a panel degrades on "this source is not wired" and on
|
|
260
|
+
* nothing wider — a wired source whose driver answers X_NOT_IMPLEMENTED is a diagnostic, not an
|
|
261
|
+
* unwired tab (dev/panel-degrade.test.ts).
|
|
257
262
|
*/
|
|
258
|
-
export class DevSourceUnavailableError extends
|
|
263
|
+
export class DevSourceUnavailableError extends NotImplementedError {
|
|
259
264
|
constructor(input: { source: string; panel: string; wiring?: string }) {
|
|
260
265
|
super({
|
|
261
|
-
code: 'X_NOT_IMPLEMENTED',
|
|
262
266
|
cause: `the /_x ${input.panel} panel needs the "${input.source}" source, which is not wired in this process`,
|
|
263
267
|
// `wiring`, when supplied, replaces the whole `defaultDevSources(...)` argument text —
|
|
264
268
|
// the default `hooks: { <source> }` phrasing is only valid when the source really is a
|
package/src/form-decode.ts
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
// It converts and never validates: a value that will not convert is passed through as the string
|
|
6
6
|
// that was typed, so the schema refuses it in its own words against the field that carried it.
|
|
7
7
|
|
|
8
|
+
import { numeric } from '@ultimat3/schema';
|
|
8
9
|
import type { AdminField } from './fields';
|
|
10
|
+
import type { AdminRow } from './registry';
|
|
9
11
|
import type { AdminResource } from './resource';
|
|
10
12
|
|
|
11
13
|
/** The posted name of a money field's currency, beside the minor-unit amount under the field's own. */
|
|
@@ -13,6 +15,39 @@ export const currencyFieldOf = (field: string): string => `${field}.currency`;
|
|
|
13
15
|
|
|
14
16
|
type Posted = Readonly<Record<string, unknown>>;
|
|
15
17
|
|
|
18
|
+
/**
|
|
19
|
+
* What a date control is RENDERED with: `<input type="date">` wants `YYYY-MM-DD`, `datetime-local`
|
|
20
|
+
* wants `YYYY-MM-DDTHH:mm`. One function for the widget that draws the box and for the decode that
|
|
21
|
+
* reads it back, so "the operator left it as it was" is a comparison of one text with itself.
|
|
22
|
+
*/
|
|
23
|
+
export const datetimeInputValue = (iso: string, precision: 'date' | 'instant'): string =>
|
|
24
|
+
iso.slice(0, precision === 'date' ? 10 : 16);
|
|
25
|
+
|
|
26
|
+
const isoOf = (value: unknown): string | undefined =>
|
|
27
|
+
value instanceof Date
|
|
28
|
+
? Number.isNaN(value.getTime())
|
|
29
|
+
? undefined
|
|
30
|
+
: value.toISOString()
|
|
31
|
+
: typeof value === 'string'
|
|
32
|
+
? value
|
|
33
|
+
: undefined;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* An instant posted back as the box rendered it. `datetime-local` holds minutes, so writing what
|
|
37
|
+
* came back turned `10:20:45.123` into `10:20:00.000` on every edit of ANY field — a change nobody
|
|
38
|
+
* made, recorded in the audit diff as theirs. Such a field is left out of the patch and the stored
|
|
39
|
+
* instant stands. A calendar date carries every digit it has and is never in this position.
|
|
40
|
+
*/
|
|
41
|
+
const untouchedInstant = (
|
|
42
|
+
field: AdminField,
|
|
43
|
+
raw: string | undefined,
|
|
44
|
+
before: AdminRow,
|
|
45
|
+
): boolean => {
|
|
46
|
+
if (field.widget !== 'datetime' || field.type === 'date' || raw === undefined) return false;
|
|
47
|
+
const stored = isoOf(before[field.name]);
|
|
48
|
+
return stored !== undefined && raw === datetimeInputValue(stored, 'instant');
|
|
49
|
+
};
|
|
50
|
+
|
|
16
51
|
/** The last value a posted name carried, as text. A repeated name arrives as a list. */
|
|
17
52
|
export const posted = (form: Posted, name: string): string | undefined => {
|
|
18
53
|
if (!Object.hasOwn(form, name)) return undefined;
|
|
@@ -38,10 +73,15 @@ const TYPED: ReadonlySet<AdminField['widget']> = new Set([
|
|
|
38
73
|
const blank = (field: AdminField): unknown =>
|
|
39
74
|
field.required && !TYPED.has(field.widget) ? '' : null;
|
|
40
75
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
76
|
+
/**
|
|
77
|
+
* Schema's own decimal reader, never `Number(raw)`: that reads hex, binary and octal too, so `0x10`
|
|
78
|
+
* in a money box was stored as `minor: 16`. Anything else is handed on as typed for the schema to
|
|
79
|
+
* refuse against the field. Whitespace never reaches here — `decodeField` blanks it first.
|
|
80
|
+
*/
|
|
81
|
+
const numberOf = (raw: string): unknown => numeric(raw) ?? raw;
|
|
82
|
+
|
|
83
|
+
/** The widgets that hold a number: a box of only whitespace is as empty as `''` for them. */
|
|
84
|
+
const NUMERIC: ReadonlySet<AdminField['widget']> = new Set(['money', 'number-input']);
|
|
45
85
|
|
|
46
86
|
const jsonOf = (raw: string): unknown => {
|
|
47
87
|
try {
|
|
@@ -57,6 +97,9 @@ function decodeField(field: AdminField, form: Posted): unknown {
|
|
|
57
97
|
// An unchecked box posts nothing at all, so absence IS the answer for a checkbox.
|
|
58
98
|
if (field.widget === 'checkbox') return raw !== undefined && raw !== 'false';
|
|
59
99
|
if (raw === undefined || raw === '') return blank(field);
|
|
100
|
+
// `Number(' ')` is 0, so a space in an untouched optional number box was a write of 0 over a
|
|
101
|
+
// stored null — a change the audit diff then attributed to the operator.
|
|
102
|
+
if (NUMERIC.has(field.widget) && raw.trim() === '') return blank(field);
|
|
60
103
|
switch (field.widget) {
|
|
61
104
|
case 'number-input':
|
|
62
105
|
return numberOf(raw);
|
|
@@ -79,13 +122,20 @@ function decodeField(field: AdminField, form: Posted): unknown {
|
|
|
79
122
|
/**
|
|
80
123
|
* Every field the form rendered, decoded. A sealed column rides along as the string that was
|
|
81
124
|
* typed — `crud.ts` drops an empty one, which is what makes an untouched box mean "unchanged".
|
|
125
|
+
*
|
|
126
|
+
* `before` is the row an EDIT form was rendered from: an instant that comes back exactly as it was
|
|
127
|
+
* drawn is left out rather than rewritten at the control's precision. A create has none.
|
|
82
128
|
*/
|
|
83
129
|
export function decodeForm(
|
|
84
130
|
resource: AdminResource,
|
|
85
131
|
form: Posted,
|
|
132
|
+
before?: AdminRow,
|
|
86
133
|
): Readonly<Record<string, unknown>> {
|
|
87
134
|
const out: Record<string, unknown> = {};
|
|
88
|
-
for (const field of resource.formFields)
|
|
135
|
+
for (const field of resource.formFields) {
|
|
136
|
+
if (before !== undefined && untouchedInstant(field, posted(form, field.name), before)) continue;
|
|
137
|
+
out[field.name] = decodeField(field, form);
|
|
138
|
+
}
|
|
89
139
|
for (const field of resource.secretFields) out[field.name] = posted(form, field.name) ?? '';
|
|
90
140
|
return out;
|
|
91
141
|
}
|
package/src/form.tsx
CHANGED
|
@@ -12,6 +12,7 @@ import type { AdminField } from './fields';
|
|
|
12
12
|
import type { AdminRow } from './registry';
|
|
13
13
|
import type { AdminResource } from './resource';
|
|
14
14
|
import { fieldsFor } from './resource-layout';
|
|
15
|
+
import { VERSION_FIELD } from './row-version';
|
|
15
16
|
import type { ValidationIssue } from './validate';
|
|
16
17
|
import type { WidgetContext } from './widget-value';
|
|
17
18
|
import { Widget } from './widgets';
|
|
@@ -28,6 +29,11 @@ export interface AdminFormProps<Row extends AdminRow> {
|
|
|
28
29
|
readonly action: string;
|
|
29
30
|
/** Where Cancel goes — the list for a create, the row for an edit. */
|
|
30
31
|
readonly cancelHref: string;
|
|
32
|
+
/**
|
|
33
|
+
* An edit's `rowVersion()` of the row it was rendered from, posted back as `_version` so the
|
|
34
|
+
* write is refused if the row moved on meanwhile. `null` for a create, which has no row.
|
|
35
|
+
*/
|
|
36
|
+
readonly version: string | null;
|
|
31
37
|
}
|
|
32
38
|
|
|
33
39
|
const issuesFor = (issues: readonly ValidationIssue[], field: string): readonly ValidationIssue[] =>
|
|
@@ -90,15 +96,23 @@ export function AdminForm<Row extends AdminRow>(props: AdminFormProps<Row>): JSX
|
|
|
90
96
|
return (
|
|
91
97
|
<Card header={<h2>{t(titleKey, { entity: t(props.resource.titleKey) })}</h2>}>
|
|
92
98
|
<form class={styles['form']} method="post" action={props.action}>
|
|
99
|
+
{props.version === null ? null : (
|
|
100
|
+
<input type="hidden" name={VERSION_FIELD} value={props.version} />
|
|
101
|
+
)}
|
|
93
102
|
{props.issues.length === 0 ? null : (
|
|
94
103
|
<div class="x-admin-issues" role="alert" tabindex={-1}>
|
|
95
104
|
<h3>{t('admin.form.issues')}</h3>
|
|
96
105
|
<ul>
|
|
97
|
-
{props.issues.map((issue) =>
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
106
|
+
{props.issues.map((issue) =>
|
|
107
|
+
// The form's own issue (the row changed under it) names no control to jump to.
|
|
108
|
+
issue.path === VERSION_FIELD ? (
|
|
109
|
+
<li>{issueText(issue)}</li>
|
|
110
|
+
) : (
|
|
111
|
+
<li>
|
|
112
|
+
<a href={`#x-admin-field-${issue.path}`}>{issue.path}</a>: {issueText(issue)}
|
|
113
|
+
</li>
|
|
114
|
+
),
|
|
115
|
+
)}
|
|
102
116
|
</ul>
|
|
103
117
|
</div>
|
|
104
118
|
)}
|
package/src/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ export {
|
|
|
9
9
|
type AdminActionButton,
|
|
10
10
|
actionButtons,
|
|
11
11
|
actionDecisions,
|
|
12
|
+
adminPermissionForAction,
|
|
12
13
|
decideAction,
|
|
13
14
|
type InvokeInput,
|
|
14
15
|
type InvokeResult,
|
|
@@ -77,24 +78,22 @@ export {
|
|
|
77
78
|
deniedDraft,
|
|
78
79
|
diffRows,
|
|
79
80
|
memoryAuditLog,
|
|
80
|
-
REDACTED,
|
|
81
81
|
} from './audit';
|
|
82
82
|
export {
|
|
83
83
|
type PostgresAuditLogOptions,
|
|
84
84
|
postgresAuditLog,
|
|
85
|
-
SQL_ADMIN_AUDIT_INSERT,
|
|
86
85
|
} from './audit-pg';
|
|
87
|
-
|
|
86
|
+
// The audit table's DDL is `@ultimat3/admin/schema`'s alone — one import path for one statement.
|
|
88
87
|
export {
|
|
89
88
|
type AdminActor,
|
|
90
89
|
type AdminAuthz,
|
|
91
90
|
type AdminAuthzQuery,
|
|
92
91
|
type AdminDecision,
|
|
93
92
|
type AdminSubject,
|
|
94
|
-
|
|
93
|
+
adminAllowed,
|
|
94
|
+
adminDenied,
|
|
95
95
|
anonymousAuthz,
|
|
96
96
|
decideAll,
|
|
97
|
-
denied,
|
|
98
97
|
expandPermissions,
|
|
99
98
|
isAllowed,
|
|
100
99
|
staticAuthz,
|
|
@@ -104,7 +103,6 @@ export {
|
|
|
104
103
|
type AdminBatchInput,
|
|
105
104
|
type AdminBatchResult,
|
|
106
105
|
BATCH_NOT_OFFERED_REASON,
|
|
107
|
-
BATCH_QUEUED_REASON,
|
|
108
106
|
type BatchEnqueue,
|
|
109
107
|
type BatchRow,
|
|
110
108
|
type BatchRowOutcome,
|
|
@@ -124,7 +122,9 @@ export {
|
|
|
124
122
|
batchEnqueue,
|
|
125
123
|
} from './batch-job';
|
|
126
124
|
export { BATCH_MATCHING_REASON, matchingConfirmationToken } from './batch-matching';
|
|
125
|
+
export { BATCH_QUEUED_REASON } from './batch-queue';
|
|
127
126
|
export {
|
|
127
|
+
type AdminUpdateOptions,
|
|
128
128
|
adminCreate,
|
|
129
129
|
adminDestroy,
|
|
130
130
|
adminDetail,
|
|
@@ -197,7 +197,7 @@ export { currencyFieldOf, decodeForm, posted } from './form-decode';
|
|
|
197
197
|
export { JOB_MANAGE, JOB_READ } from './jobs/job-actions';
|
|
198
198
|
export { JOB_ENTITY } from './jobs/job-entities';
|
|
199
199
|
export { JOBS_PATH, jobRowScope } from './jobs/job-resources';
|
|
200
|
-
export { AdminLayout, type AdminLayoutProps,
|
|
200
|
+
export { AdminLayout, type AdminLayoutProps, adminActorLabel } from './layout';
|
|
201
201
|
export { AdminList, type AdminListProps } from './list';
|
|
202
202
|
export { type ResourceColumnsInput, resourceColumns } from './list-columns';
|
|
203
203
|
export { AdminFilterBar, type AdminFilterBarProps } from './list-filter-bar';
|
|
@@ -229,12 +229,14 @@ export {
|
|
|
229
229
|
} from './list-scope';
|
|
230
230
|
export { AdminScopeTabs, type AdminScopeTabsProps } from './list-scope-tabs';
|
|
231
231
|
export {
|
|
232
|
+
type AdminMcpActor,
|
|
232
233
|
type AdminMcpOptions,
|
|
233
234
|
type AdminToolResult,
|
|
234
235
|
adminMcp,
|
|
235
236
|
callAdminTool,
|
|
236
237
|
type McpInput,
|
|
237
238
|
} from './mcp';
|
|
239
|
+
export { adminTokenScopes, adminToolScopes } from './mcp-scopes';
|
|
238
240
|
export {
|
|
239
241
|
type AdminMcpTool,
|
|
240
242
|
type AdminToolField,
|
|
@@ -290,7 +292,6 @@ export {
|
|
|
290
292
|
ruleFor,
|
|
291
293
|
} from './permissions';
|
|
292
294
|
export {
|
|
293
|
-
adminPermissions,
|
|
294
295
|
declareAdminPermissions,
|
|
295
296
|
type PolicyAuthzInput,
|
|
296
297
|
policyAuthz,
|
|
@@ -341,8 +342,8 @@ export {
|
|
|
341
342
|
labelOf,
|
|
342
343
|
type RelationData,
|
|
343
344
|
type RelationNeed,
|
|
345
|
+
relationDataFor,
|
|
344
346
|
relationNeeds,
|
|
345
|
-
relationsFor,
|
|
346
347
|
} from './relations';
|
|
347
348
|
export { ComputedCell, TRUNCATE_AT } from './renderers';
|
|
348
349
|
export { adminEntitiesOf, adminRepoFor, adminTablesOf } from './repo-entity';
|
|
@@ -382,6 +383,7 @@ export {
|
|
|
382
383
|
adminRoutes,
|
|
383
384
|
} from './routes';
|
|
384
385
|
export { ROW_OUT_OF_SCOPE_REASON } from './row-scope-write';
|
|
386
|
+
export { ROW_CHANGED_REASON, rowVersion, VERSION_FIELD } from './row-version';
|
|
385
387
|
export {
|
|
386
388
|
type AdminRouteRequest,
|
|
387
389
|
type AdminRouteResponse,
|
package/src/jobs/fleet-repo.ts
CHANGED
|
@@ -14,18 +14,30 @@ const comparable = (value: unknown): string | number => {
|
|
|
14
14
|
if (value instanceof Date) return value.getTime();
|
|
15
15
|
if (typeof value === 'number') return value;
|
|
16
16
|
if (typeof value === 'boolean') return value ? 1 : 0;
|
|
17
|
-
return
|
|
17
|
+
return String(value);
|
|
18
18
|
};
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
const absent = (value: unknown): boolean => value === null || value === undefined;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Code-unit order, never a collation: the same set reads the same way on every machine. NULL is
|
|
24
|
+
* the LARGEST value — last ascending, first descending — as the database orders a nullable sort
|
|
25
|
+
* key and as `repo-entity.ts` seeks one; it read as `''` here, which sorted it as a zero.
|
|
26
|
+
*/
|
|
21
27
|
const order = (a: unknown, b: unknown): number => {
|
|
28
|
+
if (absent(a) || absent(b)) return absent(a) && absent(b) ? 0 : absent(a) ? 1 : -1;
|
|
22
29
|
const x = comparable(a);
|
|
23
30
|
const y = comparable(b);
|
|
24
31
|
return x < y ? -1 : x > y ? 1 : 0;
|
|
25
32
|
};
|
|
26
33
|
|
|
27
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* A cursor carries its value as text (`pagination.ts`), or `null` for a row that held none; read
|
|
36
|
+
* back as the type of the row it is compared with. A `null` bound stays `null` — `new Date(null)`
|
|
37
|
+
* is the epoch and `new Date('')` is NaN, and either made the page after a NULL row the wrong one.
|
|
38
|
+
*/
|
|
28
39
|
const boundValue = (row: AdminRow, bound: KeysetBound, field: string): unknown => {
|
|
40
|
+
if (bound.value === null) return null;
|
|
29
41
|
const held = row[field];
|
|
30
42
|
if (held instanceof Date) return new Date(bound.value);
|
|
31
43
|
if (typeof held === 'number') return Number(bound.value);
|