@ultimat3/admin 24.0.0 → 25.1.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 +14 -11
- package/README.md +39 -8
- package/package.json +17 -17
- package/src/action-gate.ts +18 -1
- package/src/admin.module.scss +118 -4
- package/src/audit-pg.ts +3 -3
- package/src/audit.ts +1 -3
- package/src/authz.ts +8 -8
- package/src/batch.ts +3 -8
- package/src/crud-input.ts +2 -1
- package/src/describe.ts +6 -0
- package/src/dev/data.ts +2 -2
- package/src/dev/dev-t.ts +9 -0
- package/src/dev/jobs-state-chart.tsx +38 -0
- package/src/dev/panel-jobs.ts +10 -5
- package/src/dev/panel-timeline.ts +9 -1
- package/src/dev/panel.ts +6 -3
- package/src/dev/server.ts +42 -13
- package/src/dev/timeline-style.ts +47 -0
- package/src/dev/timeline-waterfall.ts +96 -0
- package/src/form-decode.ts +13 -4
- package/src/index.ts +8 -7
- package/src/jobs/operator.ts +2 -2
- package/src/jobs/overview-tab.tsx +2 -2
- package/src/layout.tsx +34 -8
- package/src/list.tsx +1 -1
- package/src/mcp-scopes.ts +36 -0
- package/src/mcp-tools.ts +16 -2
- package/src/mcp.ts +23 -11
- package/src/pagination.ts +22 -18
- package/src/policy-bridge.ts +6 -6
- package/src/registry.ts +7 -0
- package/src/related.ts +2 -2
- package/src/relations.ts +1 -1
- package/src/resource.ts +10 -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 +4 -4
- package/src/search.ts +2 -2
- package/src/inert-jsx.ts +0 -182
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);
|
|
@@ -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-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,11 +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
6
|
import { DevSourceUnavailableError } from '../errors';
|
|
7
7
|
import type { RequestTrace, SpanKind, StatementLoopFact, TimelineSpan } from './facts';
|
|
8
8
|
import type { DevPanel } from './panel';
|
|
9
|
+
import { timelineHtml } from './timeline-waterfall';
|
|
9
10
|
|
|
10
11
|
export interface FlameRow {
|
|
11
12
|
readonly span: TimelineSpan;
|
|
@@ -105,4 +106,11 @@ export const timelinePanel: DevPanel<TimelinePanelData> = {
|
|
|
105
106
|
loops === null ? null : loops.filter((loop) => loop.requestId === selected?.requestId),
|
|
106
107
|
};
|
|
107
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
|
+
},
|
|
108
116
|
};
|
package/src/dev/panel.ts
CHANGED
|
@@ -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 =
|
package/src/dev/server.ts
CHANGED
|
@@ -5,10 +5,10 @@
|
|
|
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
7
|
import { DEFAULT_ENVIRONMENT, escapeHtml, tryResolveEnvironment } from '@ultimat3/core';
|
|
8
|
-
import { FRAMEWORK_CATALOG_LOCALE, translatorFor } from '@ultimat3/i18n';
|
|
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,7 +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
|
-
/**
|
|
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
|
+
*/
|
|
89
101
|
const SHELL_ROLES = [
|
|
90
102
|
'bg',
|
|
91
103
|
'surface-raised',
|
|
@@ -93,6 +105,13 @@ const SHELL_ROLES = [
|
|
|
93
105
|
'fg-muted',
|
|
94
106
|
'line',
|
|
95
107
|
'accent',
|
|
108
|
+
'chart-1',
|
|
109
|
+
'chart-2',
|
|
110
|
+
'chart-3',
|
|
111
|
+
'chart-4',
|
|
112
|
+
'chart-5',
|
|
113
|
+
'chart-6',
|
|
114
|
+
'chart-7',
|
|
96
115
|
] as const satisfies readonly ColorRole[];
|
|
97
116
|
|
|
98
117
|
const SHELL_LAYOUT = `
|
|
@@ -107,7 +126,7 @@ p.question { color: rgb(var(--x-color-fg-muted)); margin: 0 0 1rem; }
|
|
|
107
126
|
pre { background: rgb(var(--x-color-surface-raised)); border: 1px solid rgb(var(--x-color-line));
|
|
108
127
|
padding: 1rem; overflow: auto; }
|
|
109
128
|
:focus-visible { outline: 2px solid rgb(var(--x-color-accent)); outline-offset: 2px; }
|
|
110
|
-
`;
|
|
129
|
+
${WATERFALL_STYLE}`;
|
|
111
130
|
|
|
112
131
|
let stylePromise: Promise<string> | undefined;
|
|
113
132
|
|
|
@@ -154,15 +173,10 @@ html[data-theme="dark"] { ${block('dark')} }
|
|
|
154
173
|
${SHELL_LAYOUT}`;
|
|
155
174
|
}
|
|
156
175
|
|
|
157
|
-
/**
|
|
158
|
-
* /_x is the framework's own tool, in the framework's own locale (`<html lang="en">` below): its
|
|
159
|
-
* strings are registered under `FRAMEWORK_CATALOG_LOCALE` only, so the AMBIENT locale — the app's,
|
|
160
|
-
* `es-co` for an app that defaults to it — answered `⟦dev.panel.mail.title⟧` for every tab.
|
|
161
|
-
*/
|
|
162
|
-
const t = (key: string): string => translatorFor(FRAMEWORK_CATALOG_LOCALE)(key);
|
|
163
|
-
|
|
164
176
|
function shell(
|
|
165
177
|
style: string,
|
|
178
|
+
/** The app surface's stylesheet, linked BEFORE the shell's `<style>` so the shell wins ties. */
|
|
179
|
+
stylesheetHref: string | undefined,
|
|
166
180
|
basePath: string,
|
|
167
181
|
panels: readonly DevPanel[],
|
|
168
182
|
active: DevPanel,
|
|
@@ -181,7 +195,11 @@ function shell(
|
|
|
181
195
|
<html lang="en"><head><meta charset="utf-8">
|
|
182
196
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
183
197
|
<meta name="robots" content="noindex, nofollow">
|
|
184
|
-
<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>
|
|
185
203
|
<body><header><h1>_x</h1><nav>${tabs}</nav>
|
|
186
204
|
<a href="${basePath}/${active.key}?json=1">--json</a></header>
|
|
187
205
|
<main><p class="question">${escapeHtml(t(active.questionKey))}</p>
|
|
@@ -238,8 +256,19 @@ export function devDashboard(opts: DevDashboardOptions = {}): DevDashboard {
|
|
|
238
256
|
if (wantsJson) return jsonResponse(payload, payload.ok ? 200 : 500);
|
|
239
257
|
const tabPath = `${basePath}/${panel.key}`;
|
|
240
258
|
const drawn =
|
|
241
|
-
payload.ok && panel.html !== undefined
|
|
242
|
-
|
|
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, {
|
|
243
272
|
status: 200,
|
|
244
273
|
headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' },
|
|
245
274
|
});
|
|
@@ -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/form-decode.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
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';
|
|
9
10
|
import type { AdminRow } from './registry';
|
|
10
11
|
import type { AdminResource } from './resource';
|
|
@@ -72,10 +73,15 @@ const TYPED: ReadonlySet<AdminField['widget']> = new Set([
|
|
|
72
73
|
const blank = (field: AdminField): unknown =>
|
|
73
74
|
field.required && !TYPED.has(field.widget) ? '' : null;
|
|
74
75
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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']);
|
|
79
85
|
|
|
80
86
|
const jsonOf = (raw: string): unknown => {
|
|
81
87
|
try {
|
|
@@ -91,6 +97,9 @@ function decodeField(field: AdminField, form: Posted): unknown {
|
|
|
91
97
|
// An unchecked box posts nothing at all, so absence IS the answer for a checkbox.
|
|
92
98
|
if (field.widget === 'checkbox') return raw !== undefined && raw !== 'false';
|
|
93
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);
|
|
94
103
|
switch (field.widget) {
|
|
95
104
|
case 'number-input':
|
|
96
105
|
return numberOf(raw);
|
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,
|
|
@@ -198,7 +197,7 @@ export { currencyFieldOf, decodeForm, posted } from './form-decode';
|
|
|
198
197
|
export { JOB_MANAGE, JOB_READ } from './jobs/job-actions';
|
|
199
198
|
export { JOB_ENTITY } from './jobs/job-entities';
|
|
200
199
|
export { JOBS_PATH, jobRowScope } from './jobs/job-resources';
|
|
201
|
-
export { AdminLayout, type AdminLayoutProps,
|
|
200
|
+
export { AdminLayout, type AdminLayoutProps, adminActorLabel } from './layout';
|
|
202
201
|
export { AdminList, type AdminListProps } from './list';
|
|
203
202
|
export { type ResourceColumnsInput, resourceColumns } from './list-columns';
|
|
204
203
|
export { AdminFilterBar, type AdminFilterBarProps } from './list-filter-bar';
|
|
@@ -230,12 +229,14 @@ export {
|
|
|
230
229
|
} from './list-scope';
|
|
231
230
|
export { AdminScopeTabs, type AdminScopeTabsProps } from './list-scope-tabs';
|
|
232
231
|
export {
|
|
232
|
+
type AdminMcpActor,
|
|
233
233
|
type AdminMcpOptions,
|
|
234
234
|
type AdminToolResult,
|
|
235
235
|
adminMcp,
|
|
236
236
|
callAdminTool,
|
|
237
237
|
type McpInput,
|
|
238
238
|
} from './mcp';
|
|
239
|
+
export { adminTokenScopes, adminToolScopes } from './mcp-scopes';
|
|
239
240
|
export {
|
|
240
241
|
type AdminMcpTool,
|
|
241
242
|
type AdminToolField,
|
|
@@ -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';
|
package/src/jobs/operator.ts
CHANGED
|
@@ -21,13 +21,13 @@ export function jobsOperator(): JobsOperator {
|
|
|
21
21
|
throw new DriverUnavailableError({
|
|
22
22
|
driver: 'none',
|
|
23
23
|
cause: 'the jobs dashboard was asked for rows and this process installed no queue',
|
|
24
|
-
fix: 'run the app through x dev or the container entry, which install the queue — or in a test: setJobDriver(
|
|
24
|
+
fix: 'run the app through x dev or the container entry, which install the queue — or in a test: setJobDriver(memoryJobDriver())',
|
|
25
25
|
});
|
|
26
26
|
}
|
|
27
27
|
if (driver.introspect === undefined) {
|
|
28
28
|
throw new NotImplementedError({
|
|
29
29
|
cause: `the operator surface of the "${driver.name}" jobs driver is not implemented: the driver has no introspect`,
|
|
30
|
-
fix: 'call setJobDriver(
|
|
30
|
+
fix: 'call setJobDriver(postgresJobDriver()) at boot — the pg and memory drivers implement introspect',
|
|
31
31
|
});
|
|
32
32
|
}
|
|
33
33
|
return { driver, introspect: driver.introspect };
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// own queue (the dev driver under `x dev`) — one jobs UI, not a second one in the dev panel. Only
|
|
3
3
|
// ever reached by `dev/panel-jobs.ts`'s dynamic `import()`, so `/_x`'s mount graph stays free of it.
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { ctxOf, runWithContext } from '@ultimat3/core';
|
|
6
6
|
import { renderToHtml } from '@ultimat3/render/server';
|
|
7
7
|
import { listHref } from '../list-request';
|
|
8
8
|
import { adminMounts } from '../mounts';
|
|
@@ -33,7 +33,7 @@ export async function jobsTabHtml(tabPath: string, params: URLSearchParams): Pro
|
|
|
33
33
|
const json = `${tabPath}?json=1`;
|
|
34
34
|
const data = await overviewData(range, Date.now());
|
|
35
35
|
// The framework catalog's locale for every label, whatever the app's default is.
|
|
36
|
-
return runWithContext(
|
|
36
|
+
return runWithContext(ctxOf({ locale: 'en', tz: 'UTC' }), () =>
|
|
37
37
|
renderToHtml(
|
|
38
38
|
<JobsOverview
|
|
39
39
|
data={data}
|
package/src/layout.tsx
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
// The admin shell: skip link, sidebar (brand, who is acting, search, permission-filtered nav, the
|
|
2
2
|
// way back to the app), main region. Landmarks and a visible focus order are the accessibility
|
|
3
3
|
// contract; the theme attributes come from the token system, so nothing here knows a colour.
|
|
4
|
-
// Every control is a link or a native
|
|
4
|
+
// Every control is a link, a native form or a native popover — the shell ships no script: below
|
|
5
|
+
// `md` the sidebar is a `popover` panel the menu button opens by `popovertarget`, and an engine
|
|
6
|
+
// with no popovers keeps it as a block above the screen (`admin.module.scss`).
|
|
5
7
|
|
|
6
8
|
// why: `*.module.scss` is typed by ONE ambient declaration, and it is `@ultimat3/ui`'s. A copy
|
|
7
9
|
// here would be a `.d.ts` in `src/`, which the package-shape gate reads as a build artifact.
|
|
8
10
|
/// <reference path="../../ui/src/scss.d.ts" />
|
|
9
11
|
|
|
10
12
|
import { t } from '@ultimat3/i18n';
|
|
11
|
-
import { Input } from '@ultimat3/ui';
|
|
13
|
+
import { Icon, Input, UI_KEYS } from '@ultimat3/ui';
|
|
14
|
+
import { iconMenu } from '@ultimat3/ui/icons/menu';
|
|
15
|
+
import { iconX } from '@ultimat3/ui/icons/x';
|
|
12
16
|
import type { JSX } from 'solid-js';
|
|
13
17
|
import type { AdminApp } from './admin';
|
|
14
18
|
import styles from './admin.module.scss';
|
|
@@ -27,8 +31,11 @@ export interface AdminLayoutProps {
|
|
|
27
31
|
readonly children: JSX.Element;
|
|
28
32
|
}
|
|
29
33
|
|
|
34
|
+
/** The panel's id: the menu button and the close button both name it. */
|
|
35
|
+
const SIDE_ID = 'x-admin-side';
|
|
36
|
+
|
|
30
37
|
/** The one rendering of "who am I acting as", so no two screens can disagree about it. */
|
|
31
|
-
export const
|
|
38
|
+
export const adminActorLabel = (actor: AdminActor | null | undefined): string =>
|
|
32
39
|
actor === null || actor === undefined || actor.id === 'anonymous'
|
|
33
40
|
? t('admin.actor.anonymous')
|
|
34
41
|
: t('admin.actor.signedIn', { id: actor.id, roles: (actor.roles ?? []).join(', ') });
|
|
@@ -44,10 +51,29 @@ export function AdminLayout(props: AdminLayoutProps): JSX.Element {
|
|
|
44
51
|
data-density={theme['data-density']}
|
|
45
52
|
style={theme.style}
|
|
46
53
|
>
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
54
|
+
{/* Ahead of the panel, not inside it: a closed panel is `display: none`, and so would be
|
|
55
|
+
the first Tab stop on every admin screen. */}
|
|
56
|
+
<a class={styles['skip']} href="#x-admin-main">
|
|
57
|
+
{t('admin.a11y.skip-to-content')}
|
|
58
|
+
</a>
|
|
59
|
+
|
|
60
|
+
<div class={styles['bar']}>
|
|
61
|
+
<button type="button" class={styles['menuButton']} popovertarget={SIDE_ID}>
|
|
62
|
+
<Icon glyph={iconMenu} size="md" />
|
|
63
|
+
{t(UI_KEYS.menu)}
|
|
64
|
+
</button>
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
<aside id={SIDE_ID} class={styles['side']} popover="auto">
|
|
68
|
+
<button
|
|
69
|
+
type="button"
|
|
70
|
+
class={styles['close']}
|
|
71
|
+
popovertarget={SIDE_ID}
|
|
72
|
+
popovertargetaction="hide"
|
|
73
|
+
aria-label={t(UI_KEYS.close)}
|
|
74
|
+
>
|
|
75
|
+
<Icon glyph={iconX} size="md" />
|
|
76
|
+
</button>
|
|
51
77
|
|
|
52
78
|
<a class={styles['brand']} href={base}>
|
|
53
79
|
{props.app.branding.logo === undefined ? null : (
|
|
@@ -59,7 +85,7 @@ export function AdminLayout(props: AdminLayoutProps): JSX.Element {
|
|
|
59
85
|
)}
|
|
60
86
|
<span>{t(props.app.branding.nameKey)}</span>
|
|
61
87
|
</a>
|
|
62
|
-
<p class={styles['actor']}>{
|
|
88
|
+
<p class={styles['actor']}>{adminActorLabel(props.actor)}</p>
|
|
63
89
|
|
|
64
90
|
{/* A native GET at the search route: the term rides the URL, so a result page is a link
|
|
65
91
|
an operator can send and the form needs no handler. */}
|
package/src/list.tsx
CHANGED
|
@@ -139,7 +139,7 @@ export function AdminList<Row extends AdminRow>(props: AdminListProps<Row>): JSX
|
|
|
139
139
|
rows={page.rows}
|
|
140
140
|
rowKey={idOf}
|
|
141
141
|
sort={{ key: page.sort.field, direction: page.sort.direction }}
|
|
142
|
-
nextCursor={page.hasMore ?
|
|
142
|
+
nextCursor={page.hasMore ? page.nextCursor : undefined}
|
|
143
143
|
prevCursor={page.prevCursor ?? undefined}
|
|
144
144
|
hrefFor={(cursor) => props.hrefFor({ ...state, cursor })}
|
|
145
145
|
// The header's NEXT state, as a URL. "Unsorted" is the list with no sort in it — the
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// The admin MCP surface's scopes: which admin permission each tool needs as a token scope, and
|
|
2
|
+
// what a resolved token carries. The gate itself is `@ultimat3/mcp`'s registry — this file only
|
|
3
|
+
// declares the cut (`defineAppMcp({ scopes })`) and the token's half of it.
|
|
4
|
+
|
|
5
|
+
import type { McpScopes } from '@ultimat3/mcp';
|
|
6
|
+
import { expandPermissions } from './authz';
|
|
7
|
+
import type { AdminMcpTool } from './mcp-tools';
|
|
8
|
+
import { ADMIN_DESTROY, ADMIN_PERMISSIONS } from './permissions';
|
|
9
|
+
|
|
10
|
+
const isAdminPermission = (scope: string): boolean =>
|
|
11
|
+
(ADMIN_PERMISSIONS as readonly string[]).includes(scope);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The scopes a token resolved to, as the registry checks them: by string membership, so the
|
|
15
|
+
* admin's implications are expanded here (`admin:destroy` ⇒ `admin:write` ⇒ `admin:read`) and a
|
|
16
|
+
* token granted `admin:write` is not refused a read.
|
|
17
|
+
*
|
|
18
|
+
* `undefined` — the app's resolver stated no scopes — is a token as strong as its actor: every
|
|
19
|
+
* admin scope, with the actor's own policy still deciding each tool. That is what every admin MCP
|
|
20
|
+
* token was before tools carried a scope, so an app that never issued a narrower token is
|
|
21
|
+
* unchanged. An EMPTY list is a token granted nothing, never "unstated". A scope that is no admin
|
|
22
|
+
* permission (`posts:write`) is dropped: the admin's tools are gated on its own vocabulary only.
|
|
23
|
+
*/
|
|
24
|
+
export function adminTokenScopes(granted: readonly string[] | undefined): ReadonlySet<string> {
|
|
25
|
+
return new Set(expandPermissions(granted ?? [ADMIN_DESTROY]).filter(isAdminPermission));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Scope → the tools it covers, for `defineAppMcp({ scopes })`. Every tool is in exactly one entry
|
|
30
|
+
* (its `scope`), so the map is total by construction and `X_MCP_SCOPE_UNCOVERED` cannot fire.
|
|
31
|
+
*/
|
|
32
|
+
export function adminToolScopes(tools: readonly AdminMcpTool[]): McpScopes {
|
|
33
|
+
const by = new Map<string, string[]>();
|
|
34
|
+
for (const tool of tools) by.set(tool.scope, [...(by.get(tool.scope) ?? []), tool.name]);
|
|
35
|
+
return Object.fromEntries(by);
|
|
36
|
+
}
|