@ultimat3/admin 22.14.0 → 23.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 +73 -80
- package/README.md +276 -87
- package/package.json +19 -17
- package/src/action-form.tsx +139 -0
- package/src/action-gate.ts +127 -28
- package/src/action-input.ts +119 -0
- package/src/action-label.ts +9 -0
- package/src/action-row.ts +37 -0
- package/src/actions.tsx +89 -66
- package/src/actor.ts +53 -0
- package/src/admin.module.scss +298 -0
- package/src/admin.ts +187 -23
- package/src/audit-pg.ts +207 -0
- package/src/audit-schema.ts +47 -0
- package/src/audit.ts +109 -22
- package/src/authz.ts +11 -0
- package/src/batch-bar.tsx +66 -0
- package/src/batch-job.ts +152 -0
- package/src/batch-matching.ts +92 -0
- package/src/batch.ts +431 -0
- package/src/catalog-keys.ts +55 -0
- package/src/crud-input.ts +104 -0
- package/src/crud-outcome.ts +166 -0
- package/src/crud.ts +129 -177
- package/src/describe.ts +131 -0
- package/src/detail.tsx +115 -43
- package/src/dev/data.ts +24 -6
- package/src/dev/facts.ts +27 -0
- package/src/dev/panel-db.ts +37 -5
- package/src/dev/panel-jobs.ts +8 -0
- package/src/dev/panel-routes.ts +10 -0
- package/src/dev/panel.ts +6 -0
- package/src/dev/server.ts +15 -7
- package/src/entity-columns.ts +24 -5
- package/src/errors.ts +83 -0
- package/src/fields.ts +63 -4
- package/src/form-decode.ts +91 -0
- package/src/form.tsx +78 -45
- package/src/index.ts +198 -9
- package/src/jobs/fleet-repo.ts +143 -0
- package/src/jobs/job-actions.ts +172 -0
- package/src/jobs/job-entities.ts +118 -0
- package/src/jobs/job-labels.ts +108 -0
- package/src/jobs/job-repo.ts +103 -0
- package/src/jobs/job-resources.ts +211 -0
- package/src/jobs/job-where.ts +170 -0
- package/src/jobs/operator.ts +34 -0
- package/src/jobs/overview-data.ts +129 -0
- package/src/jobs/overview-tab.tsx +61 -0
- package/src/jobs/overview.tsx +189 -0
- package/src/json-text.ts +52 -0
- package/src/layout.tsx +55 -43
- package/src/list-columns.tsx +53 -0
- package/src/list-filter-bar.tsx +207 -0
- package/src/list-filters.ts +217 -0
- package/src/list-request.ts +127 -0
- package/src/list-scope-tabs.tsx +60 -0
- package/src/list-scope.ts +113 -0
- package/src/list.tsx +111 -72
- package/src/mcp-tools.ts +57 -13
- package/src/mcp.ts +108 -38
- package/src/mounts.ts +44 -0
- package/src/page-guard.tsx +26 -29
- package/src/policy-bridge.ts +110 -15
- package/src/registry.ts +138 -8
- package/src/related.ts +105 -0
- package/src/relations.ts +224 -0
- package/src/renderers.tsx +89 -0
- package/src/repo-entity.ts +204 -0
- package/src/resource-fields.ts +116 -0
- package/src/resource-layout.ts +89 -0
- package/src/resource-list.ts +185 -0
- package/src/resource.ts +129 -79
- package/src/route-config.ts +71 -0
- package/src/routes.ts +55 -54
- package/src/row-scope-write.ts +130 -0
- package/src/screen-action.tsx +333 -0
- package/src/screen-frame.tsx +167 -0
- package/src/screen-home.tsx +201 -0
- package/src/screen-lookup.tsx +157 -0
- package/src/screen-resource.tsx +393 -0
- package/src/screen-system.tsx +69 -0
- package/src/screens.ts +56 -0
- package/src/search.ts +13 -9
- package/src/stylesheet-scope.ts +8 -0
- package/src/test-ctx.ts +31 -0
- package/src/validate.ts +3 -0
- package/src/widget-value.ts +36 -8
- package/src/widgets.tsx +84 -12
package/src/describe.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// One admin, as plain data: its resources with the filters, sorts and scopes each list answers,
|
|
2
|
+
// whether a row scope narrows it, how its detail and form are arranged, the related lists and the
|
|
3
|
+
// actions it offers — and its routes with the permissions that gate them.
|
|
4
|
+
// JSON-safe and deterministic — what `x manifest` records, read off `AdminApp.describe()` by a
|
|
5
|
+
// tier that may not import this package.
|
|
6
|
+
|
|
7
|
+
import type { AdminRoute } from './admin';
|
|
8
|
+
import { batchPlan } from './batch';
|
|
9
|
+
import type { AdminAction } from './registry';
|
|
10
|
+
import type { AdminResource } from './resource';
|
|
11
|
+
import type { AdminSection } from './resource-layout';
|
|
12
|
+
|
|
13
|
+
export interface AdminScopeDescription {
|
|
14
|
+
readonly name: string;
|
|
15
|
+
readonly default: boolean;
|
|
16
|
+
/** The tab reads a count — one extra query per list page. */
|
|
17
|
+
readonly count: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** One titled group of a detail page or a form. `null`: the untitled default group. */
|
|
21
|
+
export interface AdminSectionDescription {
|
|
22
|
+
readonly title: string | null;
|
|
23
|
+
readonly fields: readonly string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** One action as an agent, the batch bar and a reviewer need to know it. */
|
|
27
|
+
export interface AdminActionDescription {
|
|
28
|
+
readonly name: string;
|
|
29
|
+
readonly permission: string;
|
|
30
|
+
readonly destructive: boolean;
|
|
31
|
+
/** It declares an input schema: its button is a form. */
|
|
32
|
+
readonly input: boolean;
|
|
33
|
+
/** It declares `when`: it applies to some rows only. */
|
|
34
|
+
readonly when: boolean;
|
|
35
|
+
/** It is in the batch bar and its MCP tool takes `ids`. */
|
|
36
|
+
readonly batch: boolean;
|
|
37
|
+
/** Rows a batch runs inline before it is queued as `admin.batch` jobs. `null`: always inline. */
|
|
38
|
+
readonly threshold: number | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface AdminResourceDescription {
|
|
42
|
+
readonly entity: string;
|
|
43
|
+
/** Mount-relative: `/posts`. */
|
|
44
|
+
readonly path: string;
|
|
45
|
+
/** The fields a list URL's `f.<field>` and the MCP list tool's `where` may name. */
|
|
46
|
+
readonly filters: readonly string[];
|
|
47
|
+
/** The fields `?sort=` may name. */
|
|
48
|
+
readonly sorts: readonly string[];
|
|
49
|
+
readonly scopes: readonly AdminScopeDescription[];
|
|
50
|
+
/** `rows` is declared: every read of the resource is narrowed per actor. */
|
|
51
|
+
readonly rowScoped: boolean;
|
|
52
|
+
readonly sections: readonly AdminSectionDescription[];
|
|
53
|
+
readonly formGroups: readonly AdminSectionDescription[];
|
|
54
|
+
/** The `hasMany` relations drawn on the detail page, by name. */
|
|
55
|
+
readonly related: readonly string[];
|
|
56
|
+
/** Declaration order — the order the buttons are drawn in. */
|
|
57
|
+
readonly actions: readonly AdminActionDescription[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface AdminRouteDescription {
|
|
61
|
+
readonly url: string;
|
|
62
|
+
readonly view: string;
|
|
63
|
+
/** The resource a generated screen belongs to; `null` for the dashboard, a page, a system screen. */
|
|
64
|
+
readonly entity: string | null;
|
|
65
|
+
readonly permissions: readonly string[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface AdminDescription {
|
|
69
|
+
readonly basePath: string;
|
|
70
|
+
/** Which audit log the admin writes: `memory` forgets at a restart, `postgres` is the record. */
|
|
71
|
+
readonly audit: string;
|
|
72
|
+
readonly resources: readonly AdminResourceDescription[];
|
|
73
|
+
readonly routes: readonly AdminRouteDescription[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const byCodeUnit = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
|
|
77
|
+
|
|
78
|
+
const sectionOf = (section: AdminSection): AdminSectionDescription => ({
|
|
79
|
+
title: section.titleKey,
|
|
80
|
+
fields: section.fields.map((field) => field.name),
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const actionOf = (action: AdminAction): AdminActionDescription => ({
|
|
84
|
+
name: action.name,
|
|
85
|
+
permission: action.permission,
|
|
86
|
+
destructive: action.destructive === true,
|
|
87
|
+
input: action.input !== undefined,
|
|
88
|
+
when: action.when !== undefined,
|
|
89
|
+
batch: action.batch !== undefined,
|
|
90
|
+
threshold: batchPlan(action)?.threshold ?? null,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
/** Sorted by name and by URL, so two boots of one app describe it in the same bytes. */
|
|
94
|
+
export function describeAdmin(
|
|
95
|
+
basePath: string,
|
|
96
|
+
resources: readonly AdminResource[],
|
|
97
|
+
routes: readonly AdminRoute[],
|
|
98
|
+
audit: string,
|
|
99
|
+
): AdminDescription {
|
|
100
|
+
return {
|
|
101
|
+
basePath,
|
|
102
|
+
audit,
|
|
103
|
+
resources: resources
|
|
104
|
+
.map((resource) => ({
|
|
105
|
+
entity: resource.name,
|
|
106
|
+
path: resource.path,
|
|
107
|
+
// Declaration order for both: it is the order the filter bar and the tabs are drawn in.
|
|
108
|
+
filters: resource.filters.map((field) => field.name),
|
|
109
|
+
sorts: resource.fields.filter((field) => field.sortable).map((field) => field.name),
|
|
110
|
+
scopes: resource.scopes.map((scope) => ({
|
|
111
|
+
name: scope.name,
|
|
112
|
+
default: scope.default,
|
|
113
|
+
count: scope.count,
|
|
114
|
+
})),
|
|
115
|
+
rowScoped: resource.rowScope !== undefined,
|
|
116
|
+
sections: resource.sections.map(sectionOf),
|
|
117
|
+
formGroups: resource.formGroups.map(sectionOf),
|
|
118
|
+
related: [...resource.related],
|
|
119
|
+
actions: resource.actions.map(actionOf),
|
|
120
|
+
}))
|
|
121
|
+
.sort((a, b) => byCodeUnit(a.entity, b.entity)),
|
|
122
|
+
routes: routes
|
|
123
|
+
.map((route) => ({
|
|
124
|
+
url: route.path,
|
|
125
|
+
view: route.view,
|
|
126
|
+
entity: route.entity,
|
|
127
|
+
permissions: [...route.permissions],
|
|
128
|
+
}))
|
|
129
|
+
.sort((a, b) => byCodeUnit(a.url, b.url)),
|
|
130
|
+
};
|
|
131
|
+
}
|
package/src/detail.tsx
CHANGED
|
@@ -1,16 +1,21 @@
|
|
|
1
|
-
// The detail view:
|
|
2
|
-
// and the actions this actor may run on it
|
|
3
|
-
//
|
|
1
|
+
// The detail view: the row's fields in their sections, the related resources' own lists filtered
|
|
2
|
+
// to this row, the row's history, and the actions this actor may run on it — each a link or a
|
|
3
|
+
// native form, so the screen ships no script. A detail page with no row is a state, not a card.
|
|
4
4
|
|
|
5
5
|
import { t } from '@ultimat3/i18n';
|
|
6
|
-
import { Card, ErrorState } from '@ultimat3/ui';
|
|
6
|
+
import { Button, Card, DataTable, ErrorState, Link } from '@ultimat3/ui';
|
|
7
7
|
import type { JSX } from 'solid-js';
|
|
8
|
-
import
|
|
9
|
-
import
|
|
10
|
-
import type { AuditEntry } from './audit';
|
|
8
|
+
import { AdminActions, ConfirmationField, DELETE_OPERATION, OPERATION_FIELD } from './actions';
|
|
9
|
+
import styles from './admin.module.scss';
|
|
10
|
+
import type { AuditCursor, AuditEntry } from './audit';
|
|
11
11
|
import type { AdminActor, AdminAuthz } from './authz';
|
|
12
12
|
import { type AdminErrorParts, adminErrorFrom } from './errors';
|
|
13
|
+
import { valueText } from './json-text';
|
|
14
|
+
import { resourceColumns } from './list-columns';
|
|
15
|
+
import { confirmationToken } from './permissions';
|
|
13
16
|
import type { AdminRow } from './registry';
|
|
17
|
+
import { rowId } from './registry';
|
|
18
|
+
import type { AdminRelatedList } from './related';
|
|
14
19
|
import type { AdminResource } from './resource';
|
|
15
20
|
import type { WidgetContext } from './widget-value';
|
|
16
21
|
import { Widget } from './widgets';
|
|
@@ -18,19 +23,24 @@ import { Widget } from './widgets';
|
|
|
18
23
|
export interface AdminDetailProps<Row extends AdminRow> {
|
|
19
24
|
readonly resource: AdminResource<Row>;
|
|
20
25
|
readonly row: Row | null;
|
|
21
|
-
readonly loading: boolean;
|
|
22
26
|
readonly error: AdminErrorParts | null;
|
|
23
27
|
readonly ctx: WidgetContext;
|
|
24
28
|
readonly actor: AdminActor;
|
|
25
29
|
readonly authz: AdminAuthz;
|
|
30
|
+
/** This row's history, newest first: one keyset page of what changed it or was refused. */
|
|
26
31
|
readonly audit: readonly AuditEntry[];
|
|
32
|
+
/** The URL of the next, older page of the history, or `null` when this page is the last. */
|
|
33
|
+
readonly olderHref?: string | null;
|
|
34
|
+
/** Each declared related resource's own list, filtered to this row. */
|
|
35
|
+
readonly related?: readonly AdminRelatedList[];
|
|
36
|
+
/** The URL of a related resource's list, filtered to this row — "everything", one link away. */
|
|
37
|
+
readonly relatedHref?: (list: AdminRelatedList) => string;
|
|
27
38
|
readonly basePath: string;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
readonly
|
|
33
|
-
readonly onCancel?: () => void;
|
|
39
|
+
/**
|
|
40
|
+
* What the gate already decided about THIS row, so the view renders a control only for an
|
|
41
|
+
* operation the POST behind it would allow. Absent reads as "not allowed".
|
|
42
|
+
*/
|
|
43
|
+
readonly may?: { readonly update: boolean; readonly delete: boolean };
|
|
34
44
|
}
|
|
35
45
|
|
|
36
46
|
/**
|
|
@@ -49,13 +59,6 @@ export function AdminDetail<Row extends AdminRow>(props: AdminDetailProps<Row>):
|
|
|
49
59
|
if (props.error !== null) {
|
|
50
60
|
return <ErrorState error={adminErrorFrom(props.error)} />;
|
|
51
61
|
}
|
|
52
|
-
if (props.loading) {
|
|
53
|
-
return (
|
|
54
|
-
<Card header={<h2>{t(props.resource.titleKey)}</h2>}>
|
|
55
|
-
<p aria-busy="true">{t('admin.detail.loading')}</p>
|
|
56
|
-
</Card>
|
|
57
|
-
);
|
|
58
|
-
}
|
|
59
62
|
if (props.row === null) {
|
|
60
63
|
return (
|
|
61
64
|
<ErrorState
|
|
@@ -73,6 +76,7 @@ export function AdminDetail<Row extends AdminRow>(props: AdminDetailProps<Row>):
|
|
|
73
76
|
|
|
74
77
|
const row = props.row;
|
|
75
78
|
const id = String(row[props.resource.idField] ?? '');
|
|
79
|
+
const href = `${props.basePath}${props.resource.path}/${id}`;
|
|
76
80
|
|
|
77
81
|
return (
|
|
78
82
|
<>
|
|
@@ -81,31 +85,76 @@ export function AdminDetail<Row extends AdminRow>(props: AdminDetailProps<Row>):
|
|
|
81
85
|
actions={props.resource.actions}
|
|
82
86
|
actor={props.actor}
|
|
83
87
|
authz={props.authz}
|
|
84
|
-
subject={{ entity: props.resource.name, id }}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
onRun={props.onRun}
|
|
88
|
-
onRequestConfirm={props.onRequestConfirm}
|
|
89
|
-
onConfirmationInput={props.onConfirmationInput}
|
|
90
|
-
onCancel={props.onCancel}
|
|
88
|
+
subject={{ entity: props.resource.name, id, row }}
|
|
89
|
+
sealed={props.resource.secretFields.map((field) => field.name)}
|
|
90
|
+
href={href}
|
|
91
91
|
/>
|
|
92
92
|
|
|
93
|
-
|
|
94
|
-
{
|
|
95
|
-
.
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
<
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
93
|
+
{props.resource.sections.map((section) => (
|
|
94
|
+
<section class={styles['section']}>
|
|
95
|
+
{section.titleKey === null ? null : <h3>{t(section.titleKey)}</h3>}
|
|
96
|
+
<dl class={styles['fields']}>
|
|
97
|
+
{section.fields.map((field) => (
|
|
98
|
+
<>
|
|
99
|
+
<dt>{t(field.labelKey)}</dt>
|
|
100
|
+
<dd>
|
|
101
|
+
<Widget field={field} value={row[field.name]} ctx={props.ctx} mode="read" />
|
|
102
|
+
</dd>
|
|
103
|
+
</>
|
|
104
|
+
))}
|
|
105
|
+
</dl>
|
|
106
|
+
</section>
|
|
107
|
+
))}
|
|
105
108
|
|
|
106
|
-
<
|
|
109
|
+
<div class={styles['actions']}>
|
|
110
|
+
{props.may?.update === true ? (
|
|
111
|
+
<Link appearance="button" variant="secondary" tone="neutral" href={`${href}/edit`}>
|
|
112
|
+
{t('admin.detail.edit')}
|
|
113
|
+
</Link>
|
|
114
|
+
) : null}
|
|
115
|
+
{props.may?.delete === true ? (
|
|
116
|
+
// Destructive, so it re-confirms: the token is on screen to be copied, and typing it
|
|
117
|
+
// is the deliberate act. A native POST — the row's own URL is the subject.
|
|
118
|
+
<form class={styles['actionForm']} method="post" action={href}>
|
|
119
|
+
<input type="hidden" name={OPERATION_FIELD} value={DELETE_OPERATION} />
|
|
120
|
+
<ConfirmationField token={confirmationToken(props.resource.name, id)} />
|
|
121
|
+
<Button type="submit" size="sm" variant="secondary" tone="danger">
|
|
122
|
+
{t('admin.detail.delete')}
|
|
123
|
+
</Button>
|
|
124
|
+
</form>
|
|
125
|
+
) : null}
|
|
126
|
+
</div>
|
|
107
127
|
</Card>
|
|
108
128
|
|
|
129
|
+
{(props.related ?? []).map((list) => {
|
|
130
|
+
const target = list.related.resource;
|
|
131
|
+
const base = `${props.basePath}${target.path}`;
|
|
132
|
+
return (
|
|
133
|
+
<Card header={<h2>{t(target.titleKey)}</h2>}>
|
|
134
|
+
{list.page.rows.length === 0 ? (
|
|
135
|
+
<p class="x-admin-empty">{t('admin.list.empty')}</p>
|
|
136
|
+
) : (
|
|
137
|
+
<DataTable
|
|
138
|
+
caption={t(target.titleKey)}
|
|
139
|
+
// The related resource's OWN columns, minus the key every row here shares.
|
|
140
|
+
columns={resourceColumns({
|
|
141
|
+
resource: target,
|
|
142
|
+
ctx: props.ctx,
|
|
143
|
+
hrefOf: (rowKey) => `${base}/${rowKey}`,
|
|
144
|
+
omit: list.related.field,
|
|
145
|
+
})}
|
|
146
|
+
rows={list.page.rows}
|
|
147
|
+
rowKey={(one) => rowId(one, target.idField)}
|
|
148
|
+
hrefFor={() => props.relatedHref?.(list) ?? base}
|
|
149
|
+
/>
|
|
150
|
+
)}
|
|
151
|
+
{list.page.hasMore && props.relatedHref !== undefined ? (
|
|
152
|
+
<Link href={props.relatedHref(list)}>{t('admin.related.all')}</Link>
|
|
153
|
+
) : null}
|
|
154
|
+
</Card>
|
|
155
|
+
);
|
|
156
|
+
})}
|
|
157
|
+
|
|
109
158
|
<Card header={<h2>{t('admin.audit.title')}</h2>}>
|
|
110
159
|
{props.audit.length === 0 ? (
|
|
111
160
|
<p class="x-admin-empty">{t('admin.audit.empty')}</p>
|
|
@@ -121,8 +170,8 @@ export function AdminDetail<Row extends AdminRow>(props: AdminDetailProps<Row>):
|
|
|
121
170
|
<ul>
|
|
122
171
|
{entry.diff.map((change) => (
|
|
123
172
|
<li>
|
|
124
|
-
<code>{change.field}</code>: <del>{
|
|
125
|
-
<ins>{
|
|
173
|
+
<code>{change.field}</code>: <del>{valueText(change.before)}</del>{' '}
|
|
174
|
+
<ins>{valueText(change.after)}</ins>
|
|
126
175
|
</li>
|
|
127
176
|
))}
|
|
128
177
|
</ul>
|
|
@@ -130,7 +179,30 @@ export function AdminDetail<Row extends AdminRow>(props: AdminDetailProps<Row>):
|
|
|
130
179
|
))}
|
|
131
180
|
</ol>
|
|
132
181
|
)}
|
|
182
|
+
{props.olderHref === null || props.olderHref === undefined ? null : (
|
|
183
|
+
<Link href={props.olderHref}>{t('admin.audit.older')}</Link>
|
|
184
|
+
)}
|
|
133
185
|
</Card>
|
|
134
186
|
</>
|
|
135
187
|
);
|
|
136
188
|
}
|
|
189
|
+
|
|
190
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
191
|
+
|
|
192
|
+
/** The query parameter an older page of a row's history is asked for by: `?history=<at>~<id>`. */
|
|
193
|
+
export const HISTORY_PARAM = 'history';
|
|
194
|
+
|
|
195
|
+
/** The cursor as one parameter value — an ISO instant holds no `~`, a uuid neither. */
|
|
196
|
+
export const historyCursorText = (cursor: AuditCursor): string => `${cursor.at}~${cursor.id}`;
|
|
197
|
+
|
|
198
|
+
/** A posted-back cursor, or `undefined` for anything that is not one: page one, never an error. */
|
|
199
|
+
export function historyCursorOf(raw: string | null): AuditCursor | undefined {
|
|
200
|
+
if (raw === null) return undefined;
|
|
201
|
+
const at = raw.indexOf('~');
|
|
202
|
+
if (at <= 0) return undefined;
|
|
203
|
+
const instant = raw.slice(0, at);
|
|
204
|
+
const id = raw.slice(at + 1);
|
|
205
|
+
// A uuid, because the durable log compares it as one: anything else would be a cast error from
|
|
206
|
+
// the database where a hand-edited URL deserves page one.
|
|
207
|
+
return Number.isNaN(Date.parse(instant)) || !UUID.test(id) ? undefined : { at: instant, id };
|
|
208
|
+
}
|
package/src/dev/data.ts
CHANGED
|
@@ -164,6 +164,12 @@ export function defaultDevSources(opts: DevSourceOptions = {}): DevSources {
|
|
|
164
164
|
// `revalidateTags`, already flattened to keys — never `revalidate.tags`, a shape the
|
|
165
165
|
// descriptor does not have and which answered `[]` for every ISR route in the app.
|
|
166
166
|
revalidateTags: route.revalidateTags,
|
|
167
|
+
// Present only on a route a package mounted. Copied, never aliased: the fact is a panel
|
|
168
|
+
// payload, and the registry's own list must not be reachable through it.
|
|
169
|
+
mount:
|
|
170
|
+
route.mount === undefined
|
|
171
|
+
? null
|
|
172
|
+
: { by: route.mount.by, permissions: [...route.mount.permissions] },
|
|
167
173
|
}));
|
|
168
174
|
},
|
|
169
175
|
|
|
@@ -211,24 +217,27 @@ export function defaultDevSources(opts: DevSourceOptions = {}): DevSources {
|
|
|
211
217
|
// field on a descriptor that never carried it, so every job on the panel reported
|
|
212
218
|
// non-idempotent — the opposite of what `job()` refuses to register without.
|
|
213
219
|
idempotent: job.idempotent,
|
|
220
|
+
concurrency: job.concurrency,
|
|
221
|
+
onSettled: job.onSettled,
|
|
214
222
|
}));
|
|
215
223
|
},
|
|
216
224
|
|
|
217
225
|
async queues(): Promise<readonly QueueFact[]> {
|
|
218
|
-
const {
|
|
226
|
+
const { inspectQueues, jobDriver } = await import('@ultimat3/jobs');
|
|
219
227
|
const driver = jobDriver();
|
|
220
228
|
if (driver === undefined) throw unavailable('queues', 'jobs');
|
|
221
229
|
const report = await inspectQueues(driver);
|
|
222
|
-
//
|
|
223
|
-
|
|
224
|
-
const failed =
|
|
225
|
-
driver.introspect === undefined ? [] : await inspectJobList(driver, { state: 'failed' });
|
|
230
|
+
// A driver with no introspection has nothing to pause a queue with.
|
|
231
|
+
const paused = new Set((await driver.introspect?.pausedQueues())?.map((entry) => entry.name));
|
|
226
232
|
return report.queues.map((queue) => ({
|
|
227
233
|
name: queue.queue,
|
|
228
234
|
depth: queue.ready + queue.delayed,
|
|
229
235
|
running: queue.running,
|
|
230
|
-
|
|
236
|
+
// `stats()` has its own `failed` bucket: it was counted off the job LIST, one page of it,
|
|
237
|
+
// so a queue with more failed rows than a page holds reported the page size.
|
|
238
|
+
failed: queue.failed,
|
|
231
239
|
deadLetter: queue.dead,
|
|
240
|
+
paused: paused.has(queue.queue),
|
|
232
241
|
}));
|
|
233
242
|
},
|
|
234
243
|
|
|
@@ -248,6 +257,15 @@ export function defaultDevSources(opts: DevSourceOptions = {}): DevSources {
|
|
|
248
257
|
queue: trace.queue,
|
|
249
258
|
status: RUN_STATUS[trace.state],
|
|
250
259
|
attempt: trace.attempt,
|
|
260
|
+
concurrencyKey: trace.concurrencyKey,
|
|
261
|
+
progress:
|
|
262
|
+
trace.progress === null
|
|
263
|
+
? null
|
|
264
|
+
: {
|
|
265
|
+
done: trace.progress.done,
|
|
266
|
+
total: trace.progress.total,
|
|
267
|
+
note: trace.progress.note ?? null,
|
|
268
|
+
},
|
|
251
269
|
steps: trace.steps.map((step) => ({
|
|
252
270
|
name: step.name,
|
|
253
271
|
status: STEP_STATUS[step.status],
|
package/src/dev/facts.ts
CHANGED
|
@@ -22,6 +22,12 @@ export interface RouteFact {
|
|
|
22
22
|
readonly handler: string;
|
|
23
23
|
readonly budget: { readonly js?: string; readonly lcp?: number };
|
|
24
24
|
readonly revalidateTags: readonly string[];
|
|
25
|
+
/**
|
|
26
|
+
* `RouteDescriptor.mount` — who mounted a route no surface file declares (`defineAdmin`), and
|
|
27
|
+
* every permission that gates it. `null` for a file route: nobody mounted it, and `handler` is
|
|
28
|
+
* then a path an editor can open rather than a package name.
|
|
29
|
+
*/
|
|
30
|
+
readonly mount: { readonly by: string; readonly permissions: readonly string[] } | null;
|
|
25
31
|
}
|
|
26
32
|
|
|
27
33
|
/**
|
|
@@ -104,6 +110,17 @@ export interface JobDefFact {
|
|
|
104
110
|
* the app unsafe to retry.
|
|
105
111
|
*/
|
|
106
112
|
readonly idempotent: boolean;
|
|
113
|
+
/**
|
|
114
|
+
* `JobDescriptor.concurrency` — the declared fleet-wide cap, `null` for none. `keyed` says the
|
|
115
|
+
* limit holds per `concurrency.key(input)`; the key itself is a RUN's fact (`JobRunFact`).
|
|
116
|
+
*/
|
|
117
|
+
readonly concurrency: {
|
|
118
|
+
readonly limit: number;
|
|
119
|
+
readonly keyed: boolean;
|
|
120
|
+
readonly whenBusy: 'wait' | 'fail' | null;
|
|
121
|
+
} | null;
|
|
122
|
+
/** Whether the job declares an `onSettled` hook — what is told how each run ended. */
|
|
123
|
+
readonly onSettled: boolean;
|
|
107
124
|
}
|
|
108
125
|
|
|
109
126
|
export interface QueueFact {
|
|
@@ -112,6 +129,8 @@ export interface QueueFact {
|
|
|
112
129
|
readonly running: number;
|
|
113
130
|
readonly failed: number;
|
|
114
131
|
readonly deadLetter: number;
|
|
132
|
+
/** A paused queue is claimed by no worker; its depth is work waiting on an operator. */
|
|
133
|
+
readonly paused: boolean;
|
|
115
134
|
}
|
|
116
135
|
|
|
117
136
|
export interface JobStepFact {
|
|
@@ -128,6 +147,14 @@ export interface JobRunFact {
|
|
|
128
147
|
readonly queue: string;
|
|
129
148
|
readonly status: 'ok' | 'running' | 'failed' | 'dead';
|
|
130
149
|
readonly attempt: number;
|
|
150
|
+
/** The concurrency key this run counts under — `null` unless its job declares a keyed cap. */
|
|
151
|
+
readonly concurrencyKey: string | null;
|
|
152
|
+
/** What the body last reported through `progress()`; `null` until it reports. */
|
|
153
|
+
readonly progress: {
|
|
154
|
+
readonly done: number;
|
|
155
|
+
readonly total: number;
|
|
156
|
+
readonly note: string | null;
|
|
157
|
+
} | null;
|
|
131
158
|
readonly steps: readonly JobStepFact[];
|
|
132
159
|
}
|
|
133
160
|
|
package/src/dev/panel-db.ts
CHANGED
|
@@ -8,8 +8,31 @@ import { DevSourceUnavailableError } from '../errors';
|
|
|
8
8
|
import type { DriftFact, SqlResult, TableFact } from './facts';
|
|
9
9
|
import type { DevPanel } from './panel';
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Where the whole schema can be READ, as SQL: the dump `x db gen` writes from the migrations, one
|
|
13
|
+
* file per table, with the framework's own tables under `framework/`. The table list above it is
|
|
14
|
+
* what THIS database holds; the dump is what the migrations say every database holds, and the
|
|
15
|
+
* gate keeps it true (`X_SCHEMA_DUMP_DRIFT`).
|
|
16
|
+
*
|
|
17
|
+
* A path and a command, not a URL: `/_x` serves no files, and the panel's job is to say where to
|
|
18
|
+
* look. The directory is `@ultimat3/cli`'s `SCHEMA_DUMP_DIR` repeated — this package sits below
|
|
19
|
+
* the CLI and cannot import it — and `packages/cli/src/db-schema-dump.test.ts` holds the two equal.
|
|
20
|
+
*/
|
|
21
|
+
export interface SchemaDumpLink {
|
|
22
|
+
/** App-root-relative, POSIX. */
|
|
23
|
+
readonly directory: string;
|
|
24
|
+
/** The one command that rewrites it. */
|
|
25
|
+
readonly regenerate: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const SCHEMA_DUMP: SchemaDumpLink = Object.freeze({
|
|
29
|
+
directory: 'packages/db/schema',
|
|
30
|
+
regenerate: 'x db gen',
|
|
31
|
+
});
|
|
32
|
+
|
|
11
33
|
export interface DbPanelData {
|
|
12
34
|
readonly tables: readonly TableFact[];
|
|
35
|
+
readonly schemaDump: SchemaDumpLink;
|
|
13
36
|
/**
|
|
14
37
|
* `null` when no host wired the check — NOT `[]`. Drift is the entities against the database,
|
|
15
38
|
* so it needs the connection this process may not have, and an empty list reads as "the schema
|
|
@@ -58,7 +81,7 @@ function sanitize(sql: string): string {
|
|
|
58
81
|
/**
|
|
59
82
|
* Read-only is enforced here, not just in the UI: /_x runs with the developer's own DB
|
|
60
83
|
* credentials, so "the textarea only sends SELECTs" would be the whole safety story.
|
|
61
|
-
*
|
|
84
|
+
* The developer's own client (`psql "$DATABASE_URL"`) is the deliberate way out.
|
|
62
85
|
*
|
|
63
86
|
* ONE implementation of "is this a read", and it is `@ultimat3/mcp`'s. That guard refuses four
|
|
64
87
|
* things this file's own keyword scan never did — a batch of statements, a call into the
|
|
@@ -106,11 +129,11 @@ export function assertReadOnly(sql: string): ReadOnlyVerdict {
|
|
|
106
129
|
if (!isUltimateError(error)) throw error;
|
|
107
130
|
// Two classes arrive through one code, and the panel cannot tell them apart without reading
|
|
108
131
|
// another package's prose — so the way out is phrased to be true for both. It used to assert
|
|
109
|
-
// `Run it with: x db psql --write`, which is the wrong instruction for a missing quote
|
|
110
|
-
//
|
|
132
|
+
// `Run it with: x db psql --write`, which is the wrong instruction for a missing quote — and
|
|
133
|
+
// named a command `x db` never had. The way out for a write is the developer's own client.
|
|
111
134
|
return {
|
|
112
135
|
kind: 'refused',
|
|
113
|
-
refused: `refused: ${error.cause}. Fix the statement, or — if it is meant to write — run it
|
|
136
|
+
refused: `refused: ${error.cause}. Fix the statement, or — if it is meant to write — run it in your own client: psql "$DATABASE_URL"`,
|
|
114
137
|
};
|
|
115
138
|
}
|
|
116
139
|
}
|
|
@@ -132,7 +155,15 @@ export const dbPanel: DevPanel<DbPanelData> = {
|
|
|
132
155
|
]);
|
|
133
156
|
const sql = params.get('sql');
|
|
134
157
|
if (sql === null || sql.trim() === '') {
|
|
135
|
-
return {
|
|
158
|
+
return {
|
|
159
|
+
tables,
|
|
160
|
+
schemaDump: SCHEMA_DUMP,
|
|
161
|
+
drift,
|
|
162
|
+
sql: null,
|
|
163
|
+
result: null,
|
|
164
|
+
refused: null,
|
|
165
|
+
readOnly: true,
|
|
166
|
+
};
|
|
136
167
|
}
|
|
137
168
|
|
|
138
169
|
// `verdict.sql`, never `sql`: what the guard proved read-only is what runs. `sql` below is
|
|
@@ -140,6 +171,7 @@ export const dbPanel: DevPanel<DbPanelData> = {
|
|
|
140
171
|
const verdict = assertReadOnly(sql);
|
|
141
172
|
return {
|
|
142
173
|
tables,
|
|
174
|
+
schemaDump: SCHEMA_DUMP,
|
|
143
175
|
drift,
|
|
144
176
|
sql,
|
|
145
177
|
result: verdict.kind === 'runnable' ? await sources.runSql(verdict.sql) : null,
|
package/src/dev/panel-jobs.ts
CHANGED
|
@@ -74,4 +74,12 @@ export const jobsPanel: DevPanel<JobsPanelData> = {
|
|
|
74
74
|
backfillsInFlight: backfills.filter((pass) => pass.status === 'running').length,
|
|
75
75
|
};
|
|
76
76
|
},
|
|
77
|
+
/**
|
|
78
|
+
* One jobs UI: the tab draws the admin's own overview component over this process's queue.
|
|
79
|
+
* Dynamic, so `/_x`'s mount graph loads no view until this tab is opened.
|
|
80
|
+
*/
|
|
81
|
+
async html(params, tabPath) {
|
|
82
|
+
const { jobsTabHtml } = await import('../jobs/overview-tab');
|
|
83
|
+
return jobsTabHtml(tabPath, params);
|
|
84
|
+
},
|
|
77
85
|
};
|
package/src/dev/panel-routes.ts
CHANGED
|
@@ -13,6 +13,11 @@ export interface RoutesPanelData {
|
|
|
13
13
|
// no member it could ever hold — and it was published from a `RouteFact.hasMeta` that read a
|
|
14
14
|
// key no descriptor has, which made it name EVERY route instead. See `RouteFact` in `facts.ts`.
|
|
15
15
|
readonly overBudget: readonly string[];
|
|
16
|
+
/**
|
|
17
|
+
* Routes no surface file declares, counted per mounting call — `{ defineAdmin: 14 }`. Each row's
|
|
18
|
+
* own `mount` names its permissions; this is the line that says an admin is mounted at all.
|
|
19
|
+
*/
|
|
20
|
+
readonly mountedBy: Readonly<Record<string, number>>;
|
|
16
21
|
}
|
|
17
22
|
|
|
18
23
|
const BUDGET_LIMIT_KB = 40;
|
|
@@ -35,6 +40,10 @@ export const routesPanel: DevPanel<RoutesPanelData> = {
|
|
|
35
40
|
const counts = new Map<string, number>();
|
|
36
41
|
for (const route of routes) counts.set(route.render, (counts.get(route.render) ?? 0) + 1);
|
|
37
42
|
const byRenderMode = Object.fromEntries(counts);
|
|
43
|
+
const mounts = new Map<string, number>();
|
|
44
|
+
for (const route of routes) {
|
|
45
|
+
if (route.mount !== null) mounts.set(route.mount.by, (mounts.get(route.mount.by) ?? 0) + 1);
|
|
46
|
+
}
|
|
38
47
|
return {
|
|
39
48
|
// Code units, never `localeCompare`: with no locale argument it answers from the runtime's
|
|
40
49
|
// ICU default locale and collation version, so the same route table reads in a different
|
|
@@ -46,6 +55,7 @@ export const routesPanel: DevPanel<RoutesPanelData> = {
|
|
|
46
55
|
overBudget: routes
|
|
47
56
|
.filter((route) => kb(route.budget.js) > BUDGET_LIMIT_KB)
|
|
48
57
|
.map((route) => route.path),
|
|
58
|
+
mountedBy: Object.fromEntries(mounts),
|
|
49
59
|
};
|
|
50
60
|
},
|
|
51
61
|
};
|
package/src/dev/panel.ts
CHANGED
|
@@ -16,6 +16,12 @@ export interface DevPanel<Data = unknown> {
|
|
|
16
16
|
*/
|
|
17
17
|
readonly questionKey: string;
|
|
18
18
|
data(sources: DevSources, params: URLSearchParams): Promise<Data>;
|
|
19
|
+
/**
|
|
20
|
+
* The tab's body as HTML, when the panel draws one — the jobs tab renders the admin's own
|
|
21
|
+
* jobs overview. Absent: the tab is its `--json` payload in a `<pre>`. Asked only once `data`
|
|
22
|
+
* answered, so an unwired source is still the payload's own error with its fix.
|
|
23
|
+
*/
|
|
24
|
+
html?(params: URLSearchParams, tabPath: string): Promise<string>;
|
|
19
25
|
}
|
|
20
26
|
|
|
21
27
|
export type PanelPayload =
|
package/src/dev/server.ts
CHANGED
|
@@ -170,6 +170,8 @@ function shell(
|
|
|
170
170
|
panels: readonly DevPanel[],
|
|
171
171
|
active: DevPanel,
|
|
172
172
|
payload: PanelPayload,
|
|
173
|
+
/** The panel's own drawing, when it has one; the payload follows it, folded. */
|
|
174
|
+
drawn: string | null,
|
|
173
175
|
): string {
|
|
174
176
|
const tabs = panels
|
|
175
177
|
.map(
|
|
@@ -186,7 +188,11 @@ function shell(
|
|
|
186
188
|
<body><header><h1>_x</h1><nav>${tabs}</nav>
|
|
187
189
|
<a href="${basePath}/${active.key}?json=1">--json</a></header>
|
|
188
190
|
<main><p class="question">${escapeHtml(t(active.questionKey))}</p>
|
|
189
|
-
|
|
191
|
+
${
|
|
192
|
+
drawn === null
|
|
193
|
+
? `<pre>${escapeHtml(JSON.stringify(payload, null, 2))}</pre>`
|
|
194
|
+
: `${drawn}<details><summary>--json</summary><pre>${escapeHtml(JSON.stringify(payload, null, 2))}</pre></details>`
|
|
195
|
+
}</main></body></html>`;
|
|
190
196
|
}
|
|
191
197
|
|
|
192
198
|
/** Build the dashboard. Throws X_DEV_DASHBOARD_IN_PROD before anything else happens. */
|
|
@@ -232,12 +238,14 @@ export function devDashboard(opts: DevDashboardOptions = {}): DevDashboard {
|
|
|
232
238
|
url.searchParams.has('json') ||
|
|
233
239
|
(request.headers.get('accept') ?? '').includes('application/json');
|
|
234
240
|
|
|
235
|
-
return
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
+
if (wantsJson) return jsonResponse(payload, payload.ok ? 200 : 500);
|
|
242
|
+
const tabPath = `${basePath}/${panel.key}`;
|
|
243
|
+
const drawn =
|
|
244
|
+
payload.ok && panel.html !== undefined ? await panel.html(url.searchParams, tabPath) : null;
|
|
245
|
+
return new Response(shell(await devShellStyle(), basePath, panels, panel, payload, drawn), {
|
|
246
|
+
status: 200,
|
|
247
|
+
headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' },
|
|
248
|
+
});
|
|
241
249
|
},
|
|
242
250
|
};
|
|
243
251
|
}
|