@ultimat3/admin 22.15.0 → 24.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 +83 -91
- package/README.md +282 -89
- package/package.json +19 -17
- package/src/action-form.tsx +139 -0
- package/src/action-gate.ts +141 -68
- 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 +87 -0
- package/src/batch-queue.ts +90 -0
- package/src/batch.ts +396 -0
- package/src/catalog-keys.ts +55 -0
- package/src/crud-input.ts +104 -0
- package/src/crud-outcome.ts +224 -0
- package/src/crud.ts +172 -176
- package/src/describe.ts +131 -0
- package/src/detail.tsx +115 -43
- package/src/dev/data.ts +27 -12
- package/src/dev/facts.ts +28 -1
- package/src/dev/index.ts +1 -0
- package/src/dev/panel-cache.ts +8 -3
- 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-timeline.ts +7 -2
- package/src/dev/panel.ts +10 -2
- package/src/dev/server.ts +16 -11
- package/src/dev/sql-runner.ts +31 -0
- package/src/entity-columns.ts +24 -5
- package/src/errors.ts +92 -5
- package/src/fields.ts +63 -4
- package/src/form-decode.ts +132 -0
- package/src/form.tsx +96 -49
- package/src/index.ts +199 -9
- package/src/jobs/fleet-repo.ts +155 -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 +137 -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/keyset-seek.ts +189 -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 +242 -0
- package/src/list-request.ts +129 -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 +112 -38
- package/src/mounts.ts +44 -0
- package/src/page-guard.tsx +26 -29
- package/src/pagination.ts +10 -4
- package/src/policy-bridge.ts +115 -18
- package/src/registry.ts +140 -9
- package/src/related.ts +105 -0
- package/src/relations.ts +240 -0
- package/src/renderers.tsx +89 -0
- package/src/repo-entity.ts +195 -0
- package/src/resource-fields.ts +116 -0
- package/src/resource-layout.ts +89 -0
- package/src/resource-list.ts +194 -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/row-version.ts +20 -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 +441 -0
- package/src/screen-system.tsx +75 -0
- package/src/screens.ts +56 -0
- package/src/search.ts +22 -10
- 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 -16
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,
|
|
@@ -64,6 +100,32 @@ export {
|
|
|
64
100
|
staticAuthz,
|
|
65
101
|
} from './authz';
|
|
66
102
|
export {
|
|
103
|
+
type AdminBatchAnswer,
|
|
104
|
+
type AdminBatchInput,
|
|
105
|
+
type AdminBatchResult,
|
|
106
|
+
BATCH_NOT_OFFERED_REASON,
|
|
107
|
+
type BatchEnqueue,
|
|
108
|
+
type BatchRow,
|
|
109
|
+
type BatchRowOutcome,
|
|
110
|
+
type BatchSelection,
|
|
111
|
+
batchConfirmationToken,
|
|
112
|
+
batchPlan,
|
|
113
|
+
MAX_BATCH_QUEUED_ROWS,
|
|
114
|
+
MAX_BATCH_ROWS,
|
|
115
|
+
runAdminBatch,
|
|
116
|
+
} from './batch';
|
|
117
|
+
export {
|
|
118
|
+
AdminBatchBar,
|
|
119
|
+
type AdminBatchBarProps,
|
|
120
|
+
} from './batch-bar';
|
|
121
|
+
export {
|
|
122
|
+
ADMIN_BATCH_JOB,
|
|
123
|
+
batchEnqueue,
|
|
124
|
+
} from './batch-job';
|
|
125
|
+
export { BATCH_MATCHING_REASON, matchingConfirmationToken } from './batch-matching';
|
|
126
|
+
export { BATCH_QUEUED_REASON } from './batch-queue';
|
|
127
|
+
export {
|
|
128
|
+
type AdminUpdateOptions,
|
|
67
129
|
adminCreate,
|
|
68
130
|
adminDestroy,
|
|
69
131
|
adminDetail,
|
|
@@ -76,11 +138,25 @@ export {
|
|
|
76
138
|
type ListResult,
|
|
77
139
|
permissionsForOperation,
|
|
78
140
|
} from './crud';
|
|
79
|
-
export {
|
|
141
|
+
export {
|
|
142
|
+
type AdminActionDescription,
|
|
143
|
+
type AdminDescription,
|
|
144
|
+
type AdminResourceDescription,
|
|
145
|
+
type AdminRouteDescription,
|
|
146
|
+
type AdminScopeDescription,
|
|
147
|
+
type AdminSectionDescription,
|
|
148
|
+
describeAdmin,
|
|
149
|
+
} from './describe';
|
|
150
|
+
export {
|
|
151
|
+
AdminDetail,
|
|
152
|
+
type AdminDetailProps,
|
|
153
|
+
HISTORY_PARAM,
|
|
154
|
+
} from './detail';
|
|
80
155
|
export {
|
|
81
156
|
type AdminColumnFacts,
|
|
82
157
|
type AdminColumnReference,
|
|
83
158
|
adminColumnsOf,
|
|
159
|
+
adminSealedColumnsOf,
|
|
84
160
|
} from './entity-columns';
|
|
85
161
|
// The /_x dashboard is NOT re-exported here — it has its own door, `@ultimat3/admin/dev`, so a
|
|
86
162
|
// host that only mounts the dev panels never loads a production admin component.
|
|
@@ -88,13 +164,17 @@ export {
|
|
|
88
164
|
ADMIN_ERROR_CODES,
|
|
89
165
|
ADMIN_ERROR_TITLES,
|
|
90
166
|
AdminActionDuplicateError,
|
|
167
|
+
AdminActionNotApplicableError,
|
|
91
168
|
AdminEntityUnknownError,
|
|
92
169
|
type AdminErrorCode,
|
|
93
170
|
type AdminErrorParts,
|
|
94
171
|
AdminFieldUnsupportedError,
|
|
172
|
+
AdminFilterInvalidError,
|
|
173
|
+
AdminMountMissingError,
|
|
95
174
|
AdminPagePathInvalidError,
|
|
96
175
|
AdminPageUnguardedError,
|
|
97
176
|
AdminPolicyMissingError,
|
|
177
|
+
AdminRepoUnboundError,
|
|
98
178
|
adminErrorFrom,
|
|
99
179
|
DevDashboardInProdError,
|
|
100
180
|
DevSourceUnavailableError,
|
|
@@ -102,18 +182,53 @@ export {
|
|
|
102
182
|
export {
|
|
103
183
|
type AdminField,
|
|
104
184
|
type AdminFieldType,
|
|
185
|
+
type AdminFilterKind,
|
|
105
186
|
type AdminWidget,
|
|
106
187
|
fieldTypeFromColumn,
|
|
107
188
|
filterable,
|
|
189
|
+
filterKindFor,
|
|
108
190
|
listable,
|
|
109
191
|
searchable,
|
|
110
192
|
sortable,
|
|
111
193
|
WIDGET_BY_FIELD_TYPE,
|
|
112
194
|
widgetFor,
|
|
113
195
|
} from './fields';
|
|
114
|
-
export { AdminForm, type AdminFormProps } from './form';
|
|
115
|
-
export {
|
|
196
|
+
export { AdminForm, type AdminFormProps, issueText } from './form';
|
|
197
|
+
export { currencyFieldOf, decodeForm, posted } from './form-decode';
|
|
198
|
+
export { JOB_MANAGE, JOB_READ } from './jobs/job-actions';
|
|
199
|
+
export { JOB_ENTITY } from './jobs/job-entities';
|
|
200
|
+
export { JOBS_PATH, jobRowScope } from './jobs/job-resources';
|
|
201
|
+
export { AdminLayout, type AdminLayoutProps, actorLabel } from './layout';
|
|
116
202
|
export { AdminList, type AdminListProps } from './list';
|
|
203
|
+
export { type ResourceColumnsInput, resourceColumns } from './list-columns';
|
|
204
|
+
export { AdminFilterBar, type AdminFilterBarProps } from './list-filter-bar';
|
|
205
|
+
export {
|
|
206
|
+
type AskedFilter,
|
|
207
|
+
checkedFilter,
|
|
208
|
+
declaredFilters,
|
|
209
|
+
FILTER_PREFIX,
|
|
210
|
+
filterOpsOf,
|
|
211
|
+
filterParam,
|
|
212
|
+
knownFilterParams,
|
|
213
|
+
} from './list-filters';
|
|
214
|
+
export {
|
|
215
|
+
CURSOR_PARAM,
|
|
216
|
+
type ListLocation,
|
|
217
|
+
listHref,
|
|
218
|
+
pageRequestOf,
|
|
219
|
+
SCOPE_PARAM,
|
|
220
|
+
SORT_PARAM,
|
|
221
|
+
} from './list-request';
|
|
222
|
+
export {
|
|
223
|
+
type AdminListRequest,
|
|
224
|
+
findRow,
|
|
225
|
+
type ListWhere,
|
|
226
|
+
listWhere,
|
|
227
|
+
rowWhere,
|
|
228
|
+
scopeCounts,
|
|
229
|
+
scopeNamed,
|
|
230
|
+
} from './list-scope';
|
|
231
|
+
export { AdminScopeTabs, type AdminScopeTabsProps } from './list-scope-tabs';
|
|
117
232
|
export {
|
|
118
233
|
type AdminMcpOptions,
|
|
119
234
|
type AdminToolResult,
|
|
@@ -129,8 +244,15 @@ export {
|
|
|
129
244
|
adminToolCatalog,
|
|
130
245
|
adminToolDecisions,
|
|
131
246
|
} from './mcp-tools';
|
|
247
|
+
export {
|
|
248
|
+
ADMIN_MOUNTS,
|
|
249
|
+
adminMountAt,
|
|
250
|
+
adminMounts,
|
|
251
|
+
clearAdminMounts,
|
|
252
|
+
registerAdminMount,
|
|
253
|
+
} from './mounts';
|
|
132
254
|
export { adminNav, type NavGroup, type NavItem, type NavOptions, visibleNav } from './nav';
|
|
133
|
-
export { AdminPageDenied,
|
|
255
|
+
export { AdminPageDenied, auditRefusal } from './page-guard';
|
|
134
256
|
export {
|
|
135
257
|
type AdminCustomPage,
|
|
136
258
|
type AdminPageComponent,
|
|
@@ -168,48 +290,115 @@ export {
|
|
|
168
290
|
isDestructive,
|
|
169
291
|
ruleFor,
|
|
170
292
|
} from './permissions';
|
|
171
|
-
export {
|
|
293
|
+
export {
|
|
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 { ROW_CHANGED_REASON, rowVersion, VERSION_FIELD } from './row-version';
|
|
386
|
+
export {
|
|
387
|
+
type AdminRouteRequest,
|
|
388
|
+
type AdminRouteResponse,
|
|
389
|
+
type AdminScreen,
|
|
390
|
+
guardedScreen,
|
|
391
|
+
lookupHref,
|
|
392
|
+
widgetContextOf,
|
|
393
|
+
} from './screen-frame';
|
|
394
|
+
export { type OperationDecision, OperationMatrix, operationMatrix } from './screen-home';
|
|
207
395
|
export {
|
|
208
396
|
type AdminSearchHit,
|
|
209
397
|
type AdminSearchInput,
|
|
210
398
|
type AdminSearchResult,
|
|
211
399
|
adminSearch,
|
|
212
400
|
} from './search';
|
|
401
|
+
export { type AdminTestCtxInput, adminTestCtx } from './test-ctx';
|
|
213
402
|
export {
|
|
214
403
|
type AdminBranding,
|
|
215
404
|
adminBranding,
|
|
@@ -223,9 +412,10 @@ export { type ValidationIssue, type ValidationResult, validateInput } from './va
|
|
|
223
412
|
export {
|
|
224
413
|
assertMoney,
|
|
225
414
|
assertZone,
|
|
415
|
+
type GuardedField,
|
|
226
416
|
type SelectOption,
|
|
227
417
|
type WidgetContext,
|
|
228
418
|
type WidgetProps,
|
|
229
419
|
widgetProps,
|
|
230
420
|
} from './widget-value';
|
|
231
|
-
export { Widget, type WidgetInput } from './widgets';
|
|
421
|
+
export { optionLabel, Widget, type WidgetInput } from './widgets';
|
|
@@ -0,0 +1,155 @@
|
|
|
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 String(value);
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const absent = (value: unknown): boolean => value === null || value === undefined;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Code-unit order, never a collation: the same set reads the same way on every machine. NULL is
|
|
24
|
+
* the LARGEST value — last ascending, first descending — as the database orders a nullable sort
|
|
25
|
+
* key and as `repo-entity.ts` seeks one; it read as `''` here, which sorted it as a zero.
|
|
26
|
+
*/
|
|
27
|
+
const order = (a: unknown, b: unknown): number => {
|
|
28
|
+
if (absent(a) || absent(b)) return absent(a) && absent(b) ? 0 : absent(a) ? 1 : -1;
|
|
29
|
+
const x = comparable(a);
|
|
30
|
+
const y = comparable(b);
|
|
31
|
+
return x < y ? -1 : x > y ? 1 : 0;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A cursor carries its value as text (`pagination.ts`), or `null` for a row that held none; read
|
|
36
|
+
* back as the type of the row it is compared with. A `null` bound stays `null` — `new Date(null)`
|
|
37
|
+
* is the epoch and `new Date('')` is NaN, and either made the page after a NULL row the wrong one.
|
|
38
|
+
*/
|
|
39
|
+
const boundValue = (row: AdminRow, bound: KeysetBound, field: string): unknown => {
|
|
40
|
+
if (bound.value === null) return null;
|
|
41
|
+
const held = row[field];
|
|
42
|
+
if (held instanceof Date) return new Date(bound.value);
|
|
43
|
+
if (typeof held === 'number') return Number(bound.value);
|
|
44
|
+
if (typeof held === 'boolean') return bound.value === 'true';
|
|
45
|
+
return bound.value;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export function fleetRepo(idField: string, load: Load): AdminRepo<AdminRow> {
|
|
49
|
+
const matching = async (where: readonly AdminFilter[] | undefined): Promise<AdminRow[]> =>
|
|
50
|
+
(await load()).filter((row) => outsideRowScope(row, where ?? []) === null);
|
|
51
|
+
|
|
52
|
+
/** `(sort value, id)` against a bound, in the query's own direction. */
|
|
53
|
+
const versus = (row: AdminRow, bound: KeysetBound, query: AdminListQuery): number => {
|
|
54
|
+
const sign = query.sort.direction === 'asc' ? 1 : -1;
|
|
55
|
+
const field = query.sort.field;
|
|
56
|
+
const primary = order(row[field], boundValue(row, bound, field));
|
|
57
|
+
return sign * (primary !== 0 ? primary : order(row[idField], bound.id));
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const readOnly = async (): Promise<never> => {
|
|
61
|
+
assert(
|
|
62
|
+
false,
|
|
63
|
+
'a queue, task or worker row was written through the admin repo, and none offers a write',
|
|
64
|
+
'change it through its actions: admin.action.job.queue.pause, admin.action.job.task.run-now, …',
|
|
65
|
+
);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
async list(query) {
|
|
70
|
+
const sign = query.sort.direction === 'asc' ? 1 : -1;
|
|
71
|
+
const rows = (await matching(query.where)).sort(
|
|
72
|
+
(a, b) =>
|
|
73
|
+
sign * (order(a[query.sort.field], b[query.sort.field]) || order(a[idField], b[idField])),
|
|
74
|
+
);
|
|
75
|
+
const { after, before } = query;
|
|
76
|
+
if (before !== undefined) {
|
|
77
|
+
const earlier = rows.filter((row) => versus(row, before, query) < 0);
|
|
78
|
+
return earlier.slice(Math.max(0, earlier.length - query.limit));
|
|
79
|
+
}
|
|
80
|
+
const later =
|
|
81
|
+
after === undefined ? rows : rows.filter((row) => versus(row, after, query) > 0);
|
|
82
|
+
return later.slice(0, query.limit);
|
|
83
|
+
},
|
|
84
|
+
async find(id) {
|
|
85
|
+
return (await load()).find((row) => row[idField] === id) ?? null;
|
|
86
|
+
},
|
|
87
|
+
async count(where) {
|
|
88
|
+
return (await matching(where)).length;
|
|
89
|
+
},
|
|
90
|
+
create: readOnly,
|
|
91
|
+
update: readOnly,
|
|
92
|
+
destroy: readOnly,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const at = (ms: number | undefined): Date | null => (ms === undefined ? null : new Date(ms));
|
|
97
|
+
|
|
98
|
+
/** Every queue with rows, every paused one and every one a registered job names. */
|
|
99
|
+
export const queueRepo = fleetRepo('name', async () => {
|
|
100
|
+
const { driver, introspect } = jobsOperator();
|
|
101
|
+
const [stats, paused] = await Promise.all([driver.stats(), introspect.pausedQueues()]);
|
|
102
|
+
const pausedAt = new Map(paused.map((entry) => [entry.name, entry.pausedAt]));
|
|
103
|
+
const names = new Set([
|
|
104
|
+
...stats.map((queue) => queue.queue),
|
|
105
|
+
...pausedAt.keys(),
|
|
106
|
+
...registeredJobs().map((handle) => handle.queue),
|
|
107
|
+
]);
|
|
108
|
+
return [...names].map((name) => {
|
|
109
|
+
const queue = stats.find((one) => one.queue === name);
|
|
110
|
+
return {
|
|
111
|
+
name,
|
|
112
|
+
ready: queue?.ready ?? 0,
|
|
113
|
+
delayed: queue?.delayed ?? 0,
|
|
114
|
+
running: queue?.running ?? 0,
|
|
115
|
+
suspended: queue?.suspended ?? 0,
|
|
116
|
+
failed: queue?.failed ?? 0,
|
|
117
|
+
dead: queue?.dead ?? 0,
|
|
118
|
+
oldestReadyMs: queue?.oldestReadyMs ?? 0,
|
|
119
|
+
paused: pausedAt.has(name),
|
|
120
|
+
pausedAt: at(pausedAt.get(name)),
|
|
121
|
+
};
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
/** Every registered task: its schedule, its zone, the last occurrence that fired and the next. */
|
|
126
|
+
export const taskRepo = fleetRepo('name', async () => {
|
|
127
|
+
const { introspect } = jobsOperator();
|
|
128
|
+
const [paused, fires] = await Promise.all([introspect.pausedTasks(), introspect.taskFires()]);
|
|
129
|
+
const pausedNames = new Set(paused.map((entry) => entry.name));
|
|
130
|
+
const now = new Date();
|
|
131
|
+
return inspectManifest().tasks.map((task) => ({
|
|
132
|
+
name: task.name,
|
|
133
|
+
cron: task.cron,
|
|
134
|
+
tz: task.tz,
|
|
135
|
+
catchUp: task.catchUp,
|
|
136
|
+
enqueues: task.enqueues.join(', '),
|
|
137
|
+
lastFireAt: at(fires.find((fire) => fire.task === task.name)?.firedAt),
|
|
138
|
+
nextFireAt: nextTaskRun(task, now),
|
|
139
|
+
paused: pausedNames.has(task.name),
|
|
140
|
+
}));
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
/** Every worker whose heartbeat has not expired. */
|
|
144
|
+
export const workerRepo = fleetRepo('id', async () => {
|
|
145
|
+
const { introspect } = jobsOperator();
|
|
146
|
+
return (await introspect.workers()).map((worker) => ({
|
|
147
|
+
id: worker.id,
|
|
148
|
+
host: worker.host,
|
|
149
|
+
queues: worker.queues.join(', '),
|
|
150
|
+
concurrency: worker.concurrency,
|
|
151
|
+
inFlight: worker.inFlight.length,
|
|
152
|
+
startedAt: new Date(worker.startedAt),
|
|
153
|
+
heartbeatAt: new Date(worker.heartbeatAt),
|
|
154
|
+
}));
|
|
155
|
+
});
|
|
@@ -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
|
+
];
|