@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/index.ts
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
// Explicit exports only — a barrel that re-exports everything is how internal helpers become
|
|
3
3
|
// someone's dependency.
|
|
4
4
|
|
|
5
|
+
export { AdminActionForm, type AdminActionFormProps } from './action-form';
|
|
5
6
|
export {
|
|
7
|
+
ACTION_NOT_APPLICABLE_REASON,
|
|
6
8
|
type ActionGateInput,
|
|
7
9
|
type AdminActionButton,
|
|
8
10
|
actionButtons,
|
|
@@ -13,7 +15,30 @@ export {
|
|
|
13
15
|
invokeAdminAction,
|
|
14
16
|
permissionsForAction,
|
|
15
17
|
} from './action-gate';
|
|
16
|
-
export {
|
|
18
|
+
export {
|
|
19
|
+
ACTION_INPUT_PREFIX,
|
|
20
|
+
type ActionInputControl,
|
|
21
|
+
type ActionInputField,
|
|
22
|
+
actionInputFields,
|
|
23
|
+
decodeActionInput,
|
|
24
|
+
} from './action-input';
|
|
25
|
+
export { invokeRowAction, type RowActionInput } from './action-row';
|
|
26
|
+
export {
|
|
27
|
+
ACTION_OPERATION,
|
|
28
|
+
ACTION_PARAM,
|
|
29
|
+
AdminActions,
|
|
30
|
+
type AdminActionsProps,
|
|
31
|
+
actionFormHref,
|
|
32
|
+
BATCH_OPERATION,
|
|
33
|
+
DELETE_OPERATION,
|
|
34
|
+
OPERATION_FIELD,
|
|
35
|
+
} from './actions';
|
|
36
|
+
export {
|
|
37
|
+
type AdminRequestActor,
|
|
38
|
+
ANONYMOUS_ADMIN_ACTOR,
|
|
39
|
+
adminActorFrom,
|
|
40
|
+
requestActor,
|
|
41
|
+
} from './actor';
|
|
17
42
|
export {
|
|
18
43
|
type AdminApp,
|
|
19
44
|
type AdminAuth,
|
|
@@ -36,19 +61,30 @@ export {
|
|
|
36
61
|
runAiPane,
|
|
37
62
|
} from './ai-panes';
|
|
38
63
|
export {
|
|
64
|
+
AUDIT_PAGE_DEFAULT,
|
|
65
|
+
AUDIT_PAGE_MAX,
|
|
66
|
+
type AuditCursor,
|
|
39
67
|
type AuditDraft,
|
|
40
68
|
type AuditEntry,
|
|
41
69
|
type AuditFieldDiff,
|
|
42
70
|
type AuditLog,
|
|
43
71
|
type AuditLogOptions,
|
|
44
72
|
type AuditOutcome,
|
|
73
|
+
type AuditQuery,
|
|
45
74
|
type AuditSink,
|
|
75
|
+
auditCursorOf,
|
|
46
76
|
auditEntry,
|
|
47
77
|
deniedDraft,
|
|
48
78
|
diffRows,
|
|
49
79
|
memoryAuditLog,
|
|
50
80
|
REDACTED,
|
|
51
81
|
} from './audit';
|
|
82
|
+
export {
|
|
83
|
+
type PostgresAuditLogOptions,
|
|
84
|
+
postgresAuditLog,
|
|
85
|
+
SQL_ADMIN_AUDIT_INSERT,
|
|
86
|
+
} from './audit-pg';
|
|
87
|
+
export { ADMIN_AUDIT_TABLE, SQL_ADMIN_AUDIT_TABLE } from './audit-schema';
|
|
52
88
|
export {
|
|
53
89
|
type AdminActor,
|
|
54
90
|
type AdminAuthz,
|
|
@@ -63,6 +99,31 @@ export {
|
|
|
63
99
|
isAllowed,
|
|
64
100
|
staticAuthz,
|
|
65
101
|
} from './authz';
|
|
102
|
+
export {
|
|
103
|
+
type AdminBatchAnswer,
|
|
104
|
+
type AdminBatchInput,
|
|
105
|
+
type AdminBatchResult,
|
|
106
|
+
BATCH_NOT_OFFERED_REASON,
|
|
107
|
+
BATCH_QUEUED_REASON,
|
|
108
|
+
type BatchEnqueue,
|
|
109
|
+
type BatchRow,
|
|
110
|
+
type BatchRowOutcome,
|
|
111
|
+
type BatchSelection,
|
|
112
|
+
batchConfirmationToken,
|
|
113
|
+
batchPlan,
|
|
114
|
+
MAX_BATCH_QUEUED_ROWS,
|
|
115
|
+
MAX_BATCH_ROWS,
|
|
116
|
+
runAdminBatch,
|
|
117
|
+
} from './batch';
|
|
118
|
+
export {
|
|
119
|
+
AdminBatchBar,
|
|
120
|
+
type AdminBatchBarProps,
|
|
121
|
+
} from './batch-bar';
|
|
122
|
+
export {
|
|
123
|
+
ADMIN_BATCH_JOB,
|
|
124
|
+
batchEnqueue,
|
|
125
|
+
} from './batch-job';
|
|
126
|
+
export { BATCH_MATCHING_REASON, matchingConfirmationToken } from './batch-matching';
|
|
66
127
|
export {
|
|
67
128
|
adminCreate,
|
|
68
129
|
adminDestroy,
|
|
@@ -76,11 +137,25 @@ export {
|
|
|
76
137
|
type ListResult,
|
|
77
138
|
permissionsForOperation,
|
|
78
139
|
} from './crud';
|
|
79
|
-
export {
|
|
140
|
+
export {
|
|
141
|
+
type AdminActionDescription,
|
|
142
|
+
type AdminDescription,
|
|
143
|
+
type AdminResourceDescription,
|
|
144
|
+
type AdminRouteDescription,
|
|
145
|
+
type AdminScopeDescription,
|
|
146
|
+
type AdminSectionDescription,
|
|
147
|
+
describeAdmin,
|
|
148
|
+
} from './describe';
|
|
149
|
+
export {
|
|
150
|
+
AdminDetail,
|
|
151
|
+
type AdminDetailProps,
|
|
152
|
+
HISTORY_PARAM,
|
|
153
|
+
} from './detail';
|
|
80
154
|
export {
|
|
81
155
|
type AdminColumnFacts,
|
|
82
156
|
type AdminColumnReference,
|
|
83
157
|
adminColumnsOf,
|
|
158
|
+
adminSealedColumnsOf,
|
|
84
159
|
} from './entity-columns';
|
|
85
160
|
// The /_x dashboard is NOT re-exported here — it has its own door, `@ultimat3/admin/dev`, so a
|
|
86
161
|
// host that only mounts the dev panels never loads a production admin component.
|
|
@@ -88,13 +163,17 @@ export {
|
|
|
88
163
|
ADMIN_ERROR_CODES,
|
|
89
164
|
ADMIN_ERROR_TITLES,
|
|
90
165
|
AdminActionDuplicateError,
|
|
166
|
+
AdminActionNotApplicableError,
|
|
91
167
|
AdminEntityUnknownError,
|
|
92
168
|
type AdminErrorCode,
|
|
93
169
|
type AdminErrorParts,
|
|
94
170
|
AdminFieldUnsupportedError,
|
|
171
|
+
AdminFilterInvalidError,
|
|
172
|
+
AdminMountMissingError,
|
|
95
173
|
AdminPagePathInvalidError,
|
|
96
174
|
AdminPageUnguardedError,
|
|
97
175
|
AdminPolicyMissingError,
|
|
176
|
+
AdminRepoUnboundError,
|
|
98
177
|
adminErrorFrom,
|
|
99
178
|
DevDashboardInProdError,
|
|
100
179
|
DevSourceUnavailableError,
|
|
@@ -102,18 +181,53 @@ export {
|
|
|
102
181
|
export {
|
|
103
182
|
type AdminField,
|
|
104
183
|
type AdminFieldType,
|
|
184
|
+
type AdminFilterKind,
|
|
105
185
|
type AdminWidget,
|
|
106
186
|
fieldTypeFromColumn,
|
|
107
187
|
filterable,
|
|
188
|
+
filterKindFor,
|
|
108
189
|
listable,
|
|
109
190
|
searchable,
|
|
110
191
|
sortable,
|
|
111
192
|
WIDGET_BY_FIELD_TYPE,
|
|
112
193
|
widgetFor,
|
|
113
194
|
} from './fields';
|
|
114
|
-
export { AdminForm, type AdminFormProps } from './form';
|
|
115
|
-
export {
|
|
195
|
+
export { AdminForm, type AdminFormProps, issueText } from './form';
|
|
196
|
+
export { currencyFieldOf, decodeForm, posted } from './form-decode';
|
|
197
|
+
export { JOB_MANAGE, JOB_READ } from './jobs/job-actions';
|
|
198
|
+
export { JOB_ENTITY } from './jobs/job-entities';
|
|
199
|
+
export { JOBS_PATH, jobRowScope } from './jobs/job-resources';
|
|
200
|
+
export { AdminLayout, type AdminLayoutProps, actorLabel } from './layout';
|
|
116
201
|
export { AdminList, type AdminListProps } from './list';
|
|
202
|
+
export { type ResourceColumnsInput, resourceColumns } from './list-columns';
|
|
203
|
+
export { AdminFilterBar, type AdminFilterBarProps } from './list-filter-bar';
|
|
204
|
+
export {
|
|
205
|
+
type AskedFilter,
|
|
206
|
+
checkedFilter,
|
|
207
|
+
declaredFilters,
|
|
208
|
+
FILTER_PREFIX,
|
|
209
|
+
filterOpsOf,
|
|
210
|
+
filterParam,
|
|
211
|
+
knownFilterParams,
|
|
212
|
+
} from './list-filters';
|
|
213
|
+
export {
|
|
214
|
+
CURSOR_PARAM,
|
|
215
|
+
type ListLocation,
|
|
216
|
+
listHref,
|
|
217
|
+
pageRequestOf,
|
|
218
|
+
SCOPE_PARAM,
|
|
219
|
+
SORT_PARAM,
|
|
220
|
+
} from './list-request';
|
|
221
|
+
export {
|
|
222
|
+
type AdminListRequest,
|
|
223
|
+
findRow,
|
|
224
|
+
type ListWhere,
|
|
225
|
+
listWhere,
|
|
226
|
+
rowWhere,
|
|
227
|
+
scopeCounts,
|
|
228
|
+
scopeNamed,
|
|
229
|
+
} from './list-scope';
|
|
230
|
+
export { AdminScopeTabs, type AdminScopeTabsProps } from './list-scope-tabs';
|
|
117
231
|
export {
|
|
118
232
|
type AdminMcpOptions,
|
|
119
233
|
type AdminToolResult,
|
|
@@ -129,8 +243,15 @@ export {
|
|
|
129
243
|
adminToolCatalog,
|
|
130
244
|
adminToolDecisions,
|
|
131
245
|
} from './mcp-tools';
|
|
246
|
+
export {
|
|
247
|
+
ADMIN_MOUNTS,
|
|
248
|
+
adminMountAt,
|
|
249
|
+
adminMounts,
|
|
250
|
+
clearAdminMounts,
|
|
251
|
+
registerAdminMount,
|
|
252
|
+
} from './mounts';
|
|
132
253
|
export { adminNav, type NavGroup, type NavItem, type NavOptions, visibleNav } from './nav';
|
|
133
|
-
export { AdminPageDenied,
|
|
254
|
+
export { AdminPageDenied, auditRefusal } from './page-guard';
|
|
134
255
|
export {
|
|
135
256
|
type AdminCustomPage,
|
|
136
257
|
type AdminPageComponent,
|
|
@@ -168,48 +289,115 @@ export {
|
|
|
168
289
|
isDestructive,
|
|
169
290
|
ruleFor,
|
|
170
291
|
} from './permissions';
|
|
171
|
-
export {
|
|
292
|
+
export {
|
|
293
|
+
adminPermissions,
|
|
294
|
+
declareAdminPermissions,
|
|
295
|
+
type PolicyAuthzInput,
|
|
296
|
+
policyAuthz,
|
|
297
|
+
roleAuthz,
|
|
298
|
+
singlePolicyAuthz,
|
|
299
|
+
} from './policy-bridge';
|
|
172
300
|
export {
|
|
173
301
|
type AdminAction,
|
|
174
302
|
type AdminActionCtx,
|
|
303
|
+
type AdminBatchOptions,
|
|
175
304
|
type AdminColumn,
|
|
176
305
|
type AdminColumnDescription,
|
|
177
306
|
type AdminColumnMeta,
|
|
307
|
+
type AdminDb,
|
|
178
308
|
type AdminEntity,
|
|
179
309
|
type AdminEntityDescription,
|
|
180
310
|
type AdminFilter,
|
|
181
311
|
type AdminJobSummary,
|
|
182
312
|
type AdminListQuery,
|
|
313
|
+
type AdminMatchingResult,
|
|
183
314
|
type AdminRepo,
|
|
184
315
|
type AdminRow,
|
|
185
316
|
type AdminSort,
|
|
317
|
+
type AdminTable,
|
|
318
|
+
type AdminTableRead,
|
|
319
|
+
computedRow,
|
|
320
|
+
FILTER_OPS,
|
|
186
321
|
type FilterOp,
|
|
187
322
|
type KeysetBound,
|
|
188
323
|
type RegisteredEntity,
|
|
189
|
-
type
|
|
324
|
+
type RegisteredTable,
|
|
190
325
|
readField,
|
|
191
326
|
rowId,
|
|
192
327
|
} from './registry';
|
|
193
328
|
export {
|
|
194
|
-
type
|
|
329
|
+
type AdminRelated,
|
|
330
|
+
type AdminRelatedList,
|
|
331
|
+
RELATED_PAGE_SIZE,
|
|
332
|
+
relatedLists,
|
|
333
|
+
relatedOf,
|
|
334
|
+
} from './related';
|
|
335
|
+
export {
|
|
336
|
+
type AdminLookupRequest,
|
|
337
|
+
type AdminLookupResult,
|
|
338
|
+
type AdminOption,
|
|
339
|
+
adminLookup,
|
|
340
|
+
LOOKUP_SELECT_MAX,
|
|
341
|
+
labelOf,
|
|
342
|
+
type RelationData,
|
|
343
|
+
type RelationNeed,
|
|
344
|
+
relationNeeds,
|
|
345
|
+
relationsFor,
|
|
346
|
+
} from './relations';
|
|
347
|
+
export { ComputedCell, TRUNCATE_AT } from './renderers';
|
|
348
|
+
export { adminEntitiesOf, adminRepoFor, adminTablesOf } from './repo-entity';
|
|
349
|
+
export {
|
|
195
350
|
type AdminResource,
|
|
196
351
|
type AdminResourceOptions,
|
|
197
352
|
adminResource,
|
|
198
353
|
repoOf,
|
|
199
354
|
resourceFor,
|
|
200
355
|
} from './resource';
|
|
356
|
+
export type { AdminFieldOverride } from './resource-fields';
|
|
357
|
+
export {
|
|
358
|
+
type AdminFormGroupOptions,
|
|
359
|
+
type AdminSection,
|
|
360
|
+
type AdminSectionOptions,
|
|
361
|
+
DEFAULT_SECTION_KEY,
|
|
362
|
+
} from './resource-layout';
|
|
363
|
+
export {
|
|
364
|
+
ADMIN_RENDERERS,
|
|
365
|
+
type AdminColumnComponent,
|
|
366
|
+
type AdminColumnRenderProps,
|
|
367
|
+
type AdminComputedColumn,
|
|
368
|
+
type AdminComputedColumnOptions,
|
|
369
|
+
type AdminRenderer,
|
|
370
|
+
type AdminRowScope,
|
|
371
|
+
type AdminScope,
|
|
372
|
+
type AdminScopeOptions,
|
|
373
|
+
type AdminScopeWhere,
|
|
374
|
+
LOOKUP_SEGMENT,
|
|
375
|
+
} from './resource-list';
|
|
201
376
|
export {
|
|
202
377
|
type AdminRouteConfig,
|
|
378
|
+
type AdminRouteMatch,
|
|
203
379
|
adminRouteConfig,
|
|
204
380
|
adminRouteFor,
|
|
381
|
+
adminRouteMatch,
|
|
205
382
|
adminRoutes,
|
|
206
383
|
} from './routes';
|
|
384
|
+
export { ROW_OUT_OF_SCOPE_REASON } from './row-scope-write';
|
|
385
|
+
export {
|
|
386
|
+
type AdminRouteRequest,
|
|
387
|
+
type AdminRouteResponse,
|
|
388
|
+
type AdminScreen,
|
|
389
|
+
guardedScreen,
|
|
390
|
+
lookupHref,
|
|
391
|
+
widgetContextOf,
|
|
392
|
+
} from './screen-frame';
|
|
393
|
+
export { type OperationDecision, OperationMatrix, operationMatrix } from './screen-home';
|
|
207
394
|
export {
|
|
208
395
|
type AdminSearchHit,
|
|
209
396
|
type AdminSearchInput,
|
|
210
397
|
type AdminSearchResult,
|
|
211
398
|
adminSearch,
|
|
212
399
|
} from './search';
|
|
400
|
+
export { type AdminTestCtxInput, adminTestCtx } from './test-ctx';
|
|
213
401
|
export {
|
|
214
402
|
type AdminBranding,
|
|
215
403
|
adminBranding,
|
|
@@ -223,9 +411,10 @@ export { type ValidationIssue, type ValidationResult, validateInput } from './va
|
|
|
223
411
|
export {
|
|
224
412
|
assertMoney,
|
|
225
413
|
assertZone,
|
|
414
|
+
type GuardedField,
|
|
226
415
|
type SelectOption,
|
|
227
416
|
type WidgetContext,
|
|
228
417
|
type WidgetProps,
|
|
229
418
|
widgetProps,
|
|
230
419
|
} from './widget-value';
|
|
231
|
-
export { Widget, type WidgetInput } from './widgets';
|
|
420
|
+
export { optionLabel, Widget, type WidgetInput } from './widgets';
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// The queues, the tasks and the workers as `AdminRepo`s. Each is a SMALL set the store answers
|
|
2
|
+
// whole — one `stats()`, one registry read — so a page is that set, filtered by the package's one
|
|
3
|
+
// predicate evaluator (`outsideRowScope`), ordered and cut by keyset here. Read-only.
|
|
4
|
+
|
|
5
|
+
import { assert } from '@ultimat3/core';
|
|
6
|
+
import { inspectManifest, nextTaskRun, registeredJobs } from '@ultimat3/jobs';
|
|
7
|
+
import type { AdminFilter, AdminListQuery, AdminRepo, AdminRow, KeysetBound } from '../registry';
|
|
8
|
+
import { outsideRowScope } from '../row-scope-write';
|
|
9
|
+
import { jobsOperator } from './operator';
|
|
10
|
+
|
|
11
|
+
type Load = () => Promise<readonly AdminRow[]>;
|
|
12
|
+
|
|
13
|
+
const comparable = (value: unknown): string | number => {
|
|
14
|
+
if (value instanceof Date) return value.getTime();
|
|
15
|
+
if (typeof value === 'number') return value;
|
|
16
|
+
if (typeof value === 'boolean') return value ? 1 : 0;
|
|
17
|
+
return value === null || value === undefined ? '' : String(value);
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** Code-unit order, never a collation: the same set reads the same way on every machine. */
|
|
21
|
+
const order = (a: unknown, b: unknown): number => {
|
|
22
|
+
const x = comparable(a);
|
|
23
|
+
const y = comparable(b);
|
|
24
|
+
return x < y ? -1 : x > y ? 1 : 0;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** A cursor carries its value as text (`pagination.ts`); read back as the column's own type. */
|
|
28
|
+
const boundValue = (row: AdminRow, bound: KeysetBound, field: string): unknown => {
|
|
29
|
+
const held = row[field];
|
|
30
|
+
if (held instanceof Date) return new Date(bound.value);
|
|
31
|
+
if (typeof held === 'number') return Number(bound.value);
|
|
32
|
+
if (typeof held === 'boolean') return bound.value === 'true';
|
|
33
|
+
return bound.value;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export function fleetRepo(idField: string, load: Load): AdminRepo<AdminRow> {
|
|
37
|
+
const matching = async (where: readonly AdminFilter[] | undefined): Promise<AdminRow[]> =>
|
|
38
|
+
(await load()).filter((row) => outsideRowScope(row, where ?? []) === null);
|
|
39
|
+
|
|
40
|
+
/** `(sort value, id)` against a bound, in the query's own direction. */
|
|
41
|
+
const versus = (row: AdminRow, bound: KeysetBound, query: AdminListQuery): number => {
|
|
42
|
+
const sign = query.sort.direction === 'asc' ? 1 : -1;
|
|
43
|
+
const field = query.sort.field;
|
|
44
|
+
const primary = order(row[field], boundValue(row, bound, field));
|
|
45
|
+
return sign * (primary !== 0 ? primary : order(row[idField], bound.id));
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const readOnly = async (): Promise<never> => {
|
|
49
|
+
assert(
|
|
50
|
+
false,
|
|
51
|
+
'a queue, task or worker row was written through the admin repo, and none offers a write',
|
|
52
|
+
'change it through its actions: admin.action.job.queue.pause, admin.action.job.task.run-now, …',
|
|
53
|
+
);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
async list(query) {
|
|
58
|
+
const sign = query.sort.direction === 'asc' ? 1 : -1;
|
|
59
|
+
const rows = (await matching(query.where)).sort(
|
|
60
|
+
(a, b) =>
|
|
61
|
+
sign * (order(a[query.sort.field], b[query.sort.field]) || order(a[idField], b[idField])),
|
|
62
|
+
);
|
|
63
|
+
const { after, before } = query;
|
|
64
|
+
if (before !== undefined) {
|
|
65
|
+
const earlier = rows.filter((row) => versus(row, before, query) < 0);
|
|
66
|
+
return earlier.slice(Math.max(0, earlier.length - query.limit));
|
|
67
|
+
}
|
|
68
|
+
const later =
|
|
69
|
+
after === undefined ? rows : rows.filter((row) => versus(row, after, query) > 0);
|
|
70
|
+
return later.slice(0, query.limit);
|
|
71
|
+
},
|
|
72
|
+
async find(id) {
|
|
73
|
+
return (await load()).find((row) => row[idField] === id) ?? null;
|
|
74
|
+
},
|
|
75
|
+
async count(where) {
|
|
76
|
+
return (await matching(where)).length;
|
|
77
|
+
},
|
|
78
|
+
create: readOnly,
|
|
79
|
+
update: readOnly,
|
|
80
|
+
destroy: readOnly,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const at = (ms: number | undefined): Date | null => (ms === undefined ? null : new Date(ms));
|
|
85
|
+
|
|
86
|
+
/** Every queue with rows, every paused one and every one a registered job names. */
|
|
87
|
+
export const queueRepo = fleetRepo('name', async () => {
|
|
88
|
+
const { driver, introspect } = jobsOperator();
|
|
89
|
+
const [stats, paused] = await Promise.all([driver.stats(), introspect.pausedQueues()]);
|
|
90
|
+
const pausedAt = new Map(paused.map((entry) => [entry.name, entry.pausedAt]));
|
|
91
|
+
const names = new Set([
|
|
92
|
+
...stats.map((queue) => queue.queue),
|
|
93
|
+
...pausedAt.keys(),
|
|
94
|
+
...registeredJobs().map((handle) => handle.queue),
|
|
95
|
+
]);
|
|
96
|
+
return [...names].map((name) => {
|
|
97
|
+
const queue = stats.find((one) => one.queue === name);
|
|
98
|
+
return {
|
|
99
|
+
name,
|
|
100
|
+
ready: queue?.ready ?? 0,
|
|
101
|
+
delayed: queue?.delayed ?? 0,
|
|
102
|
+
running: queue?.running ?? 0,
|
|
103
|
+
suspended: queue?.suspended ?? 0,
|
|
104
|
+
failed: queue?.failed ?? 0,
|
|
105
|
+
dead: queue?.dead ?? 0,
|
|
106
|
+
oldestReadyMs: queue?.oldestReadyMs ?? 0,
|
|
107
|
+
paused: pausedAt.has(name),
|
|
108
|
+
pausedAt: at(pausedAt.get(name)),
|
|
109
|
+
};
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
/** Every registered task: its schedule, its zone, the last occurrence that fired and the next. */
|
|
114
|
+
export const taskRepo = fleetRepo('name', async () => {
|
|
115
|
+
const { introspect } = jobsOperator();
|
|
116
|
+
const [paused, fires] = await Promise.all([introspect.pausedTasks(), introspect.taskFires()]);
|
|
117
|
+
const pausedNames = new Set(paused.map((entry) => entry.name));
|
|
118
|
+
const now = new Date();
|
|
119
|
+
return inspectManifest().tasks.map((task) => ({
|
|
120
|
+
name: task.name,
|
|
121
|
+
cron: task.cron,
|
|
122
|
+
tz: task.tz,
|
|
123
|
+
catchUp: task.catchUp,
|
|
124
|
+
enqueues: task.enqueues.join(', '),
|
|
125
|
+
lastFireAt: at(fires.find((fire) => fire.task === task.name)?.firedAt),
|
|
126
|
+
nextFireAt: nextTaskRun(task, now),
|
|
127
|
+
paused: pausedNames.has(task.name),
|
|
128
|
+
}));
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
/** Every worker whose heartbeat has not expired. */
|
|
132
|
+
export const workerRepo = fleetRepo('id', async () => {
|
|
133
|
+
const { introspect } = jobsOperator();
|
|
134
|
+
return (await introspect.workers()).map((worker) => ({
|
|
135
|
+
id: worker.id,
|
|
136
|
+
host: worker.host,
|
|
137
|
+
queues: worker.queues.join(', '),
|
|
138
|
+
concurrency: worker.concurrency,
|
|
139
|
+
inFlight: worker.inFlight.length,
|
|
140
|
+
startedAt: new Date(worker.startedAt),
|
|
141
|
+
heartbeatAt: new Date(worker.heartbeatAt),
|
|
142
|
+
}));
|
|
143
|
+
});
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// Every change an operator makes to the queue, as an `AdminAction`: the button on a row, the batch
|
|
2
|
+
// bar's "checked" and "all matching", the MCP tool and the audit entry come from these declarations
|
|
3
|
+
// and from nothing jobs-specific. One permission gates them all — `job:manage`; reading is
|
|
4
|
+
// `job:read`, so an operator without the first sees every screen and no control.
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
cancelJob,
|
|
8
|
+
getTask,
|
|
9
|
+
type JobRecord,
|
|
10
|
+
pauseQueue,
|
|
11
|
+
promoteJob,
|
|
12
|
+
removeJob,
|
|
13
|
+
resumeQueue,
|
|
14
|
+
retryFromStep,
|
|
15
|
+
} from '@ultimat3/jobs';
|
|
16
|
+
import { t } from '@ultimat3/schema';
|
|
17
|
+
import { AdminFilterInvalidError } from '../errors';
|
|
18
|
+
import type { AdminAction, AdminFilter, AdminMatchingResult, AdminRow } from '../registry';
|
|
19
|
+
import { JOB_ENTITY } from './job-entities';
|
|
20
|
+
import { bulkWhere, JOBS_RESOURCE } from './job-where';
|
|
21
|
+
import { jobsOperator } from './operator';
|
|
22
|
+
|
|
23
|
+
/** Reading the jobs screens. The admin's own `<noun>:read`, for every jobs resource. */
|
|
24
|
+
export const JOB_READ = 'job:read';
|
|
25
|
+
/** Every action on the queue. Without it the screens render no control and refuse the call. */
|
|
26
|
+
export const JOB_MANAGE = 'job:manage';
|
|
27
|
+
|
|
28
|
+
const LIVE: ReadonlySet<unknown> = new Set(['ready', 'delayed', 'running', 'suspended']);
|
|
29
|
+
|
|
30
|
+
const idOf = (input: Readonly<Record<string, unknown>>): string => String(input['id'] ?? '');
|
|
31
|
+
|
|
32
|
+
const waitingOnClock = (row: AdminRow): boolean => {
|
|
33
|
+
const runAt = row['runAt'];
|
|
34
|
+
return (
|
|
35
|
+
(row['state'] === 'ready' || row['state'] === 'delayed') &&
|
|
36
|
+
runAt instanceof Date &&
|
|
37
|
+
runAt.getTime() > Date.now()
|
|
38
|
+
);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** A bulk verb over the list's `where`, held to the one state the action is for. */
|
|
42
|
+
function bulk(
|
|
43
|
+
where: readonly AdminFilter[],
|
|
44
|
+
states: readonly JobRecord['state'][],
|
|
45
|
+
run: (filter: NonNullable<ReturnType<typeof bulkWhere>>) => Promise<AdminMatchingResult>,
|
|
46
|
+
): Promise<AdminMatchingResult> {
|
|
47
|
+
const filter = bulkWhere(where);
|
|
48
|
+
if (filter === null) return Promise.resolve({ affected: 0, remaining: 0 });
|
|
49
|
+
if (!new Set<string>(states).has(filter.state)) {
|
|
50
|
+
throw new AdminFilterInvalidError({
|
|
51
|
+
entity: JOBS_RESOURCE,
|
|
52
|
+
asked: `all matching in scope=${filter.state}`,
|
|
53
|
+
cause: 'is not a state this action applies to',
|
|
54
|
+
known: states.map((state) => `scope=${state}`),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return run(filter);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const jobAction = (action: Omit<AdminAction, 'permission' | 'entity'>): AdminAction => ({
|
|
61
|
+
...action,
|
|
62
|
+
permission: JOB_MANAGE,
|
|
63
|
+
entity: JOB_ENTITY.jobs,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
export const jobActions: readonly AdminAction[] = [
|
|
67
|
+
jobAction({
|
|
68
|
+
name: 'job.retry',
|
|
69
|
+
// A dead letter, and only one: a finished `done` row run again is a second side effect.
|
|
70
|
+
when: (row) => row['state'] === 'dead',
|
|
71
|
+
batch: true,
|
|
72
|
+
matching: ({ where }) =>
|
|
73
|
+
bulk(where, ['dead'], (filter) => jobsOperator().introspect.requeueMany(filter)),
|
|
74
|
+
async handle({ input }) {
|
|
75
|
+
return { id: (await jobsOperator().introspect.requeue(idOf(input))).id };
|
|
76
|
+
},
|
|
77
|
+
}),
|
|
78
|
+
jobAction({
|
|
79
|
+
name: 'job.retry-from-step',
|
|
80
|
+
when: (row) => row['state'] === 'dead' || row['state'] === 'failed',
|
|
81
|
+
input: t.object({ step: t.string.min(1) }),
|
|
82
|
+
async handle({ input }) {
|
|
83
|
+
const step = String(input['step'] ?? '');
|
|
84
|
+
return { id: (await retryFromStep(jobsOperator().driver, idOf(input), step))?.id ?? null };
|
|
85
|
+
},
|
|
86
|
+
}),
|
|
87
|
+
jobAction({
|
|
88
|
+
name: 'job.run-now',
|
|
89
|
+
when: waitingOnClock,
|
|
90
|
+
batch: true,
|
|
91
|
+
matching: ({ where }) =>
|
|
92
|
+
bulk(where, ['delayed', 'ready'], (filter) => jobsOperator().introspect.promoteMany(filter)),
|
|
93
|
+
async handle({ input }) {
|
|
94
|
+
return { id: (await promoteJob(jobsOperator().driver, idOf(input))).id };
|
|
95
|
+
},
|
|
96
|
+
}),
|
|
97
|
+
jobAction({
|
|
98
|
+
name: 'job.cancel',
|
|
99
|
+
when: (row) => LIVE.has(row['state']),
|
|
100
|
+
async handle({ input }) {
|
|
101
|
+
return { id: (await cancelJob(jobsOperator().driver, idOf(input)))?.id ?? null };
|
|
102
|
+
},
|
|
103
|
+
}),
|
|
104
|
+
jobAction({
|
|
105
|
+
name: 'job.remove',
|
|
106
|
+
destructive: true,
|
|
107
|
+
// A running job is a worker's: cancel it first, as the store itself insists.
|
|
108
|
+
when: (row) => row['state'] !== 'running',
|
|
109
|
+
batch: true,
|
|
110
|
+
matching: ({ where }) =>
|
|
111
|
+
bulk(
|
|
112
|
+
where,
|
|
113
|
+
['ready', 'delayed', 'suspended', 'done', 'failed', 'dead', 'cancelled'],
|
|
114
|
+
(filter) => jobsOperator().introspect.removeMany(filter),
|
|
115
|
+
),
|
|
116
|
+
async handle({ input }) {
|
|
117
|
+
return { id: (await removeJob(jobsOperator().driver, idOf(input)))?.id ?? null };
|
|
118
|
+
},
|
|
119
|
+
}),
|
|
120
|
+
];
|
|
121
|
+
|
|
122
|
+
const fleetAction = (
|
|
123
|
+
entity: string,
|
|
124
|
+
action: Omit<AdminAction, 'permission' | 'entity'>,
|
|
125
|
+
): AdminAction => ({ ...action, permission: JOB_MANAGE, entity });
|
|
126
|
+
|
|
127
|
+
export const queueActions: readonly AdminAction[] = [
|
|
128
|
+
fleetAction(JOB_ENTITY.queues, {
|
|
129
|
+
name: 'job.queue.pause',
|
|
130
|
+
when: (row) => row['paused'] !== true,
|
|
131
|
+
async handle({ input }) {
|
|
132
|
+
await pauseQueue(jobsOperator().driver, idOf(input));
|
|
133
|
+
return { queue: idOf(input), paused: true };
|
|
134
|
+
},
|
|
135
|
+
}),
|
|
136
|
+
fleetAction(JOB_ENTITY.queues, {
|
|
137
|
+
name: 'job.queue.resume',
|
|
138
|
+
when: (row) => row['paused'] === true,
|
|
139
|
+
async handle({ input }) {
|
|
140
|
+
await resumeQueue(jobsOperator().driver, idOf(input));
|
|
141
|
+
return { queue: idOf(input), paused: false };
|
|
142
|
+
},
|
|
143
|
+
}),
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
export const taskActions: readonly AdminAction[] = [
|
|
147
|
+
fleetAction(JOB_ENTITY.tasks, {
|
|
148
|
+
name: 'job.task.pause',
|
|
149
|
+
when: (row) => row['paused'] !== true,
|
|
150
|
+
async handle({ input }) {
|
|
151
|
+
await jobsOperator().introspect.pauseTask(idOf(input));
|
|
152
|
+
return { task: idOf(input), paused: true };
|
|
153
|
+
},
|
|
154
|
+
}),
|
|
155
|
+
fleetAction(JOB_ENTITY.tasks, {
|
|
156
|
+
name: 'job.task.resume',
|
|
157
|
+
when: (row) => row['paused'] === true,
|
|
158
|
+
async handle({ input }) {
|
|
159
|
+
await jobsOperator().introspect.resumeTask(idOf(input));
|
|
160
|
+
return { task: idOf(input), paused: false };
|
|
161
|
+
},
|
|
162
|
+
}),
|
|
163
|
+
fleetAction(JOB_ENTITY.tasks, {
|
|
164
|
+
name: 'job.task.run-now',
|
|
165
|
+
async handle({ input }) {
|
|
166
|
+
// The row came from the registry, so a task that is gone is a task renamed since the page.
|
|
167
|
+
const handle = getTask(idOf(input));
|
|
168
|
+
const jobs = handle === undefined ? [] : await handle.enqueue();
|
|
169
|
+
return { task: idOf(input), jobs: jobs.length };
|
|
170
|
+
},
|
|
171
|
+
}),
|
|
172
|
+
];
|