@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
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// The four things an operator browses — job rows, queues, tasks, workers — declared as the entity
|
|
2
|
+
// SURFACE the admin derives every screen from (`registry.ts`'s `AdminEntity`), so their lists,
|
|
3
|
+
// filters, detail pages, batch bar and MCP tools are the ones an app's own tables get. Hand-built
|
|
4
|
+
// rather than `entity()`: none is an app table, and `entity()` would put them in the app's schema.
|
|
5
|
+
|
|
6
|
+
import { JOB_STATES } from '@ultimat3/jobs';
|
|
7
|
+
import type { AdminColumn, AdminColumnMeta, AdminEntity } from '../registry';
|
|
8
|
+
|
|
9
|
+
/** The resource names. `x_` is the framework's table prefix: no app entity is named so. */
|
|
10
|
+
export const JOB_ENTITY = {
|
|
11
|
+
jobs: 'x_jobs',
|
|
12
|
+
queues: 'x_job_queues',
|
|
13
|
+
tasks: 'x_job_tasks',
|
|
14
|
+
workers: 'x_job_workers',
|
|
15
|
+
} as const;
|
|
16
|
+
|
|
17
|
+
type Shape = Partial<Omit<AdminColumnMeta, 'kind'>>;
|
|
18
|
+
|
|
19
|
+
const column = (kind: string, shape: Shape = {}): AdminColumn => ({
|
|
20
|
+
$meta: {
|
|
21
|
+
kind,
|
|
22
|
+
notNull: shape.notNull ?? true,
|
|
23
|
+
primaryKey: shape.primaryKey ?? false,
|
|
24
|
+
unique: shape.unique ?? false,
|
|
25
|
+
index: shape.index ?? false,
|
|
26
|
+
...(shape.length === undefined ? {} : { length: shape.length }),
|
|
27
|
+
...(shape.values === undefined ? {} : { values: shape.values }),
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
/** A bounded line of text — one line in a form, a `contains` the store can answer or refuse. */
|
|
32
|
+
const line = (shape: Shape = {}): AdminColumn => column('text', { length: 200, ...shape });
|
|
33
|
+
const key = (): AdminColumn => line({ primaryKey: true, index: true });
|
|
34
|
+
const optional = (kind: string): AdminColumn => column(kind, { notNull: false });
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Read-only by construction: no screen offers a create or an update, and a schema that refuses
|
|
38
|
+
* every input is what any path that reached one would meet.
|
|
39
|
+
*/
|
|
40
|
+
const READ_ONLY_SCHEMA = {
|
|
41
|
+
'~standard': {
|
|
42
|
+
version: 1,
|
|
43
|
+
vendor: 'ultimate-admin',
|
|
44
|
+
validate: () => ({
|
|
45
|
+
issues: [{ message: 'an operator surface is read-only; use its actions' }],
|
|
46
|
+
}),
|
|
47
|
+
},
|
|
48
|
+
} as const;
|
|
49
|
+
|
|
50
|
+
function surface(name: string, columns: Readonly<Record<string, AdminColumn>>): AdminEntity {
|
|
51
|
+
return {
|
|
52
|
+
$name: name,
|
|
53
|
+
$primaryKey: [Object.keys(columns)[0] ?? 'id'],
|
|
54
|
+
$columns: columns,
|
|
55
|
+
$schema: READ_ONLY_SCHEMA,
|
|
56
|
+
$tenantColumn: null,
|
|
57
|
+
$describe: () => ({
|
|
58
|
+
columns: Object.keys(columns).map((property) => ({ property, references: null })),
|
|
59
|
+
}),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const jobEntity: AdminEntity = surface(JOB_ENTITY.jobs, {
|
|
64
|
+
id: key(),
|
|
65
|
+
name: line({ index: true }),
|
|
66
|
+
queue: line({ index: true }),
|
|
67
|
+
state: line({ index: true, values: JOB_STATES }),
|
|
68
|
+
attempt: column('integer'),
|
|
69
|
+
maxAttempts: column('integer'),
|
|
70
|
+
runAt: column('timestamptz'),
|
|
71
|
+
createdAt: column('timestamptz', { index: true }),
|
|
72
|
+
updatedAt: column('timestamptz'),
|
|
73
|
+
tenantId: column('text', { notNull: false, length: 200, index: true }),
|
|
74
|
+
lastError: optional('text'),
|
|
75
|
+
lastErrorStack: optional('text'),
|
|
76
|
+
progress: optional('jsonb'),
|
|
77
|
+
// What the queue holds, ALREADY redacted by `@ultimat3/jobs` (`inspectJob`): every key the app
|
|
78
|
+
// declared secret is replaced before the admin sees the value.
|
|
79
|
+
input: optional('jsonb'),
|
|
80
|
+
steps: optional('jsonb'),
|
|
81
|
+
traceparent: optional('text'),
|
|
82
|
+
enqueuedBy: optional('text'),
|
|
83
|
+
claimedBy: optional('text'),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
export const queueEntity: AdminEntity = surface(JOB_ENTITY.queues, {
|
|
87
|
+
name: key(),
|
|
88
|
+
ready: column('integer'),
|
|
89
|
+
delayed: column('integer'),
|
|
90
|
+
running: column('integer'),
|
|
91
|
+
suspended: column('integer'),
|
|
92
|
+
failed: column('integer'),
|
|
93
|
+
dead: column('integer'),
|
|
94
|
+
oldestReadyMs: column('integer'),
|
|
95
|
+
paused: column('boolean', { index: true }),
|
|
96
|
+
pausedAt: optional('timestamptz'),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
export const taskEntity: AdminEntity = surface(JOB_ENTITY.tasks, {
|
|
100
|
+
name: key(),
|
|
101
|
+
cron: line(),
|
|
102
|
+
tz: line(),
|
|
103
|
+
catchUp: line(),
|
|
104
|
+
enqueues: line(),
|
|
105
|
+
lastFireAt: optional('timestamptz'),
|
|
106
|
+
nextFireAt: optional('timestamptz'),
|
|
107
|
+
paused: column('boolean', { index: true }),
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
export const workerEntity: AdminEntity = surface(JOB_ENTITY.workers, {
|
|
111
|
+
id: key(),
|
|
112
|
+
host: line(),
|
|
113
|
+
queues: line(),
|
|
114
|
+
concurrency: column('integer'),
|
|
115
|
+
inFlight: column('integer'),
|
|
116
|
+
startedAt: column('timestamptz'),
|
|
117
|
+
heartbeatAt: column('timestamptz'),
|
|
118
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// The catalog keys the jobs screens are labelled by, spelled out. They are the keys the admin
|
|
2
|
+
// would derive (`admin.<entity>.field.<name>`), named here because a key the FRAMEWORK catalog
|
|
3
|
+
// holds must be named by framework source (`scripts/i18n-catalog.ts`): derived, nothing could
|
|
4
|
+
// tell a live key from a stale one.
|
|
5
|
+
|
|
6
|
+
import type { AdminFieldOverride } from '../resource-fields';
|
|
7
|
+
|
|
8
|
+
export const RUN_LABELS = {
|
|
9
|
+
title: 'admin.x_jobs.title',
|
|
10
|
+
fields: {
|
|
11
|
+
id: 'admin.x_jobs.field.id',
|
|
12
|
+
name: 'admin.x_jobs.field.name',
|
|
13
|
+
queue: 'admin.x_jobs.field.queue',
|
|
14
|
+
state: 'admin.x_jobs.field.state',
|
|
15
|
+
attempt: 'admin.x_jobs.field.attempt',
|
|
16
|
+
maxAttempts: 'admin.x_jobs.field.maxAttempts',
|
|
17
|
+
runAt: 'admin.x_jobs.field.runAt',
|
|
18
|
+
createdAt: 'admin.x_jobs.field.createdAt',
|
|
19
|
+
updatedAt: 'admin.x_jobs.field.updatedAt',
|
|
20
|
+
tenantId: 'admin.x_jobs.field.tenantId',
|
|
21
|
+
lastError: 'admin.x_jobs.field.lastError',
|
|
22
|
+
lastErrorStack: 'admin.x_jobs.field.lastErrorStack',
|
|
23
|
+
progress: 'admin.x_jobs.field.progress',
|
|
24
|
+
input: 'admin.x_jobs.field.input',
|
|
25
|
+
steps: 'admin.x_jobs.field.steps',
|
|
26
|
+
traceparent: 'admin.x_jobs.field.traceparent',
|
|
27
|
+
enqueuedBy: 'admin.x_jobs.field.enqueuedBy',
|
|
28
|
+
claimedBy: 'admin.x_jobs.field.claimedBy',
|
|
29
|
+
},
|
|
30
|
+
columns: { completed: 'admin.x_jobs.column.completed', error: 'admin.x_jobs.column.error' },
|
|
31
|
+
} as const;
|
|
32
|
+
|
|
33
|
+
export const QUEUE_LABELS = {
|
|
34
|
+
title: 'admin.x_job_queues.title',
|
|
35
|
+
fields: {
|
|
36
|
+
name: 'admin.x_job_queues.field.name',
|
|
37
|
+
ready: 'admin.x_job_queues.field.ready',
|
|
38
|
+
delayed: 'admin.x_job_queues.field.delayed',
|
|
39
|
+
running: 'admin.x_job_queues.field.running',
|
|
40
|
+
suspended: 'admin.x_job_queues.field.suspended',
|
|
41
|
+
failed: 'admin.x_job_queues.field.failed',
|
|
42
|
+
dead: 'admin.x_job_queues.field.dead',
|
|
43
|
+
oldestReadyMs: 'admin.x_job_queues.field.oldestReadyMs',
|
|
44
|
+
paused: 'admin.x_job_queues.field.paused',
|
|
45
|
+
pausedAt: 'admin.x_job_queues.field.pausedAt',
|
|
46
|
+
},
|
|
47
|
+
columns: { oldest: 'admin.x_job_queues.column.oldest', jobs: 'admin.x_job_queues.column.jobs' },
|
|
48
|
+
} as const;
|
|
49
|
+
|
|
50
|
+
export const TASK_LABELS = {
|
|
51
|
+
title: 'admin.x_job_tasks.title',
|
|
52
|
+
fields: {
|
|
53
|
+
name: 'admin.x_job_tasks.field.name',
|
|
54
|
+
cron: 'admin.x_job_tasks.field.cron',
|
|
55
|
+
tz: 'admin.x_job_tasks.field.tz',
|
|
56
|
+
catchUp: 'admin.x_job_tasks.field.catchUp',
|
|
57
|
+
enqueues: 'admin.x_job_tasks.field.enqueues',
|
|
58
|
+
lastFireAt: 'admin.x_job_tasks.field.lastFireAt',
|
|
59
|
+
nextFireAt: 'admin.x_job_tasks.field.nextFireAt',
|
|
60
|
+
paused: 'admin.x_job_tasks.field.paused',
|
|
61
|
+
},
|
|
62
|
+
} as const;
|
|
63
|
+
|
|
64
|
+
export const WORKER_LABELS = {
|
|
65
|
+
title: 'admin.x_job_workers.title',
|
|
66
|
+
fields: {
|
|
67
|
+
id: 'admin.x_job_workers.field.id',
|
|
68
|
+
host: 'admin.x_job_workers.field.host',
|
|
69
|
+
queues: 'admin.x_job_workers.field.queues',
|
|
70
|
+
concurrency: 'admin.x_job_workers.field.concurrency',
|
|
71
|
+
inFlight: 'admin.x_job_workers.field.inFlight',
|
|
72
|
+
startedAt: 'admin.x_job_workers.field.startedAt',
|
|
73
|
+
heartbeatAt: 'admin.x_job_workers.field.heartbeatAt',
|
|
74
|
+
},
|
|
75
|
+
columns: {
|
|
76
|
+
heartbeat: 'admin.x_job_workers.column.heartbeat',
|
|
77
|
+
running: 'admin.x_job_workers.column.running',
|
|
78
|
+
},
|
|
79
|
+
} as const;
|
|
80
|
+
|
|
81
|
+
/** One tab per job state, plus every row. */
|
|
82
|
+
export const SCOPE_LABELS = {
|
|
83
|
+
all: 'admin.jobs.scope.all',
|
|
84
|
+
ready: 'admin.jobs.scope.ready',
|
|
85
|
+
delayed: 'admin.jobs.scope.delayed',
|
|
86
|
+
running: 'admin.jobs.scope.running',
|
|
87
|
+
suspended: 'admin.jobs.scope.suspended',
|
|
88
|
+
done: 'admin.jobs.scope.done',
|
|
89
|
+
failed: 'admin.jobs.scope.failed',
|
|
90
|
+
dead: 'admin.jobs.scope.dead',
|
|
91
|
+
cancelled: 'admin.jobs.scope.cancelled',
|
|
92
|
+
} as const;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The step box of "retry from a step", at the key `action-input.ts` derives for it — the one input
|
|
96
|
+
* an operator types on these screens. `job-actions.test.ts` pins the derivation to this name.
|
|
97
|
+
*/
|
|
98
|
+
export const RETRY_STEP_LABEL = 'admin.input.job.retry-from-step.step';
|
|
99
|
+
|
|
100
|
+
/** Each field's label key laid over the field's own overrides. */
|
|
101
|
+
export function labelled(
|
|
102
|
+
labels: Readonly<Record<string, string>>,
|
|
103
|
+
overrides: Readonly<Record<string, AdminFieldOverride>> = {},
|
|
104
|
+
): Readonly<Record<string, AdminFieldOverride>> {
|
|
105
|
+
return Object.fromEntries(
|
|
106
|
+
Object.entries(labels).map(([name, labelKey]) => [name, { ...overrides[name], labelKey }]),
|
|
107
|
+
);
|
|
108
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// The job rows as an `AdminRepo`: a page is ONE `JobIntrospection.list` call, newest first and
|
|
2
|
+
// keyset by `(createdAt, id)` — the store's own order, and the only one it answers — and a detail
|
|
3
|
+
// is the store's own trace, its input already redacted. Read-only: every change is an action.
|
|
4
|
+
|
|
5
|
+
import { assert } from '@ultimat3/core';
|
|
6
|
+
import { inspectJob, type JobRecord, jobCursor, MAX_JOB_PAGE } from '@ultimat3/jobs';
|
|
7
|
+
import { AdminFilterInvalidError } from '../errors';
|
|
8
|
+
import type { AdminListQuery, AdminRepo, AdminRow, KeysetBound } from '../registry';
|
|
9
|
+
import { JOBS_RESOURCE, jobWhere } from './job-where';
|
|
10
|
+
import { jobsOperator } from './operator';
|
|
11
|
+
|
|
12
|
+
/** The one order the store pages in. Any other is refused, never answered in this one. */
|
|
13
|
+
export const JOB_SORT = { field: 'createdAt', direction: 'desc' } as const;
|
|
14
|
+
|
|
15
|
+
const at = (ms: number | undefined): Date | null => (ms === undefined ? null : new Date(ms));
|
|
16
|
+
|
|
17
|
+
/** A row as a list shows it: no payload and no stack — those are the detail page's. */
|
|
18
|
+
export function jobRow(record: JobRecord): AdminRow {
|
|
19
|
+
return {
|
|
20
|
+
id: record.id,
|
|
21
|
+
name: record.name,
|
|
22
|
+
queue: record.queue,
|
|
23
|
+
state: record.state,
|
|
24
|
+
attempt: record.attempt,
|
|
25
|
+
maxAttempts: record.maxAttempts,
|
|
26
|
+
runAt: at(record.runAt),
|
|
27
|
+
createdAt: at(record.createdAt),
|
|
28
|
+
updatedAt: at(record.updatedAt),
|
|
29
|
+
tenantId: record.tenantId ?? null,
|
|
30
|
+
lastError: record.lastError ?? null,
|
|
31
|
+
progress: record.progress ?? null,
|
|
32
|
+
traceparent: record.traceparent ?? null,
|
|
33
|
+
enqueuedBy: record.enqueuedBy ?? null,
|
|
34
|
+
claimedBy: record.claimedBy ?? null,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A keyset bound as the store's cursor: the bound row's instant in ms, and its id. */
|
|
39
|
+
const cursorOf = (bound: KeysetBound): string =>
|
|
40
|
+
jobCursor({ createdAt: Date.parse(bound.value), id: bound.id });
|
|
41
|
+
|
|
42
|
+
function assertOrder(query: AdminListQuery): void {
|
|
43
|
+
const { field, direction } = query.sort;
|
|
44
|
+
if (field === JOB_SORT.field && direction === JOB_SORT.direction) return;
|
|
45
|
+
throw new AdminFilterInvalidError({
|
|
46
|
+
entity: JOBS_RESOURCE,
|
|
47
|
+
asked: `sort=${field}:${direction}`,
|
|
48
|
+
cause: 'is not an order the job store pages in — a job list is newest first',
|
|
49
|
+
known: [`sort=${JOB_SORT.field}:${JOB_SORT.direction}`],
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function list(query: AdminListQuery): Promise<readonly AdminRow[]> {
|
|
54
|
+
assertOrder(query);
|
|
55
|
+
const where = jobWhere(query.where ?? []);
|
|
56
|
+
if (where.empty) return [];
|
|
57
|
+
const { introspect } = jobsOperator();
|
|
58
|
+
const records = await introspect.list({
|
|
59
|
+
...where.filter,
|
|
60
|
+
// The admin asks one row past its page to learn whether there is a next one; the store's page
|
|
61
|
+
// is bounded, and a full store page simply reads as "maybe more".
|
|
62
|
+
limit: Math.min(query.limit, MAX_JOB_PAGE),
|
|
63
|
+
...(query.after === undefined ? {} : { after: cursorOf(query.after) }),
|
|
64
|
+
...(query.before === undefined ? {} : { before: cursorOf(query.before) }),
|
|
65
|
+
});
|
|
66
|
+
const exact = where.exactId;
|
|
67
|
+
return (exact === undefined ? records : records.filter((record) => record.id === exact)).map(
|
|
68
|
+
jobRow,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The detail: the row, plus what `inspectJob` says of it — redacted input, steps, the stack. */
|
|
73
|
+
async function find(id: string): Promise<AdminRow | null> {
|
|
74
|
+
const { driver, introspect } = jobsOperator();
|
|
75
|
+
const [record, trace] = await Promise.all([introspect.job(id), inspectJob(driver, id)]);
|
|
76
|
+
if (record === undefined || trace === undefined) return null;
|
|
77
|
+
return {
|
|
78
|
+
...jobRow(record),
|
|
79
|
+
input: trace.input ?? null,
|
|
80
|
+
lastErrorStack: trace.stack,
|
|
81
|
+
steps: trace.steps,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Unreachable through the admin — the resource offers list and detail only (`job-resources.ts`) —
|
|
87
|
+
* so a call here is a caller that went round the route table: an invariant, not a refusal.
|
|
88
|
+
*/
|
|
89
|
+
const readOnly = async (): Promise<never> => {
|
|
90
|
+
assert(
|
|
91
|
+
false,
|
|
92
|
+
'a job row was written through the admin repo, and the jobs resource offers no write',
|
|
93
|
+
'change a job through its actions: admin.action.job.retry, .run-now, .cancel, .remove',
|
|
94
|
+
);
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export const jobRepo: AdminRepo<AdminRow> = {
|
|
98
|
+
list,
|
|
99
|
+
find,
|
|
100
|
+
create: readOnly,
|
|
101
|
+
update: readOnly,
|
|
102
|
+
destroy: readOnly,
|
|
103
|
+
};
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// The jobs dashboard as `defineAdmin()` input: four resources over the queue's operator surface,
|
|
2
|
+
// each an ordinary `AdminResourceOptions` — a `repo:`, scopes, a row scope, computed columns,
|
|
3
|
+
// sections — and `job-actions.ts`'s actions, attached by `entity` as an app's are. `defineAdmin`
|
|
4
|
+
// declares them for every app, so `/admin/jobs/*` exists with no app code.
|
|
5
|
+
|
|
6
|
+
import { JOB_STATES } from '@ultimat3/jobs';
|
|
7
|
+
import type { AdminActor } from '../authz';
|
|
8
|
+
import { listHref } from '../list-request';
|
|
9
|
+
import type { AdminAction, AdminEntity, AdminFilter } from '../registry';
|
|
10
|
+
import { type AdminResourceOptions, adminResource } from '../resource';
|
|
11
|
+
import type { AdminRowScope, AdminScopeOptions } from '../resource-list';
|
|
12
|
+
import { queueRepo, taskRepo, workerRepo } from './fleet-repo';
|
|
13
|
+
import { jobActions, queueActions, taskActions } from './job-actions';
|
|
14
|
+
import { JOB_ENTITY, jobEntity, queueEntity, taskEntity, workerEntity } from './job-entities';
|
|
15
|
+
import {
|
|
16
|
+
labelled,
|
|
17
|
+
QUEUE_LABELS,
|
|
18
|
+
RUN_LABELS,
|
|
19
|
+
SCOPE_LABELS,
|
|
20
|
+
TASK_LABELS,
|
|
21
|
+
WORKER_LABELS,
|
|
22
|
+
} from './job-labels';
|
|
23
|
+
import { JOB_SORT, jobRepo } from './job-repo';
|
|
24
|
+
|
|
25
|
+
/** The permission noun every jobs resource shares: `job:read` lists any of them. */
|
|
26
|
+
export const JOB_PERMISSION_NOUN = 'job';
|
|
27
|
+
/** Where the jobs screens sit under the admin's base path. The overview is this path itself. */
|
|
28
|
+
export const JOBS_PATH = '/jobs';
|
|
29
|
+
export const JOBS_GROUP = 'admin.group.jobs';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* An org-scoped operator — an actor with an `orgId` — sees that org's job rows and nothing else.
|
|
33
|
+
* A platform operator is the actor without one, and sees every row.
|
|
34
|
+
*/
|
|
35
|
+
export const jobRowScope: AdminRowScope = (actor: AdminActor): readonly AdminFilter[] =>
|
|
36
|
+
actor.orgId === undefined ? [] : [{ field: 'tenantId', op: 'eq', value: actor.orgId }];
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Queues, tasks and workers are the FLEET, shared by every tenant: an org-scoped operator who
|
|
40
|
+
* paused a queue would pause it for every org. So they are a platform operator's rows only — the
|
|
41
|
+
* predicate below holds for no row, because every row of these three has a key.
|
|
42
|
+
*/
|
|
43
|
+
const platformOnly =
|
|
44
|
+
(key: string): AdminRowScope =>
|
|
45
|
+
(actor) =>
|
|
46
|
+
actor.orgId === undefined ? [] : [{ field: key, op: 'is-null', value: true }];
|
|
47
|
+
|
|
48
|
+
/** One tab per state, plus every row. Counts are the overview's tiles (`stats()`), not the tabs. */
|
|
49
|
+
const stateScopes: Readonly<Record<string, AdminScopeOptions>> = Object.fromEntries([
|
|
50
|
+
['all', { where: [], default: true, labelKey: SCOPE_LABELS.all }],
|
|
51
|
+
...JOB_STATES.map((state) => [
|
|
52
|
+
state,
|
|
53
|
+
{ where: [{ field: 'state', op: 'eq', value: state }], labelKey: SCOPE_LABELS[state] },
|
|
54
|
+
]),
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
/** A choice the store answers by equality — never the `contains` a free-text column defaults to. */
|
|
58
|
+
const exact = { type: 'enum', sortable: false } as const;
|
|
59
|
+
const unsorted = { sortable: false } as const;
|
|
60
|
+
const unsearched = { searchable: false } as const;
|
|
61
|
+
|
|
62
|
+
function runsOptions(): AdminResourceOptions {
|
|
63
|
+
return {
|
|
64
|
+
repo: jobRepo,
|
|
65
|
+
path: `${JOBS_PATH}/runs`,
|
|
66
|
+
titleKey: RUN_LABELS.title,
|
|
67
|
+
permission: JOB_PERMISSION_NOUN,
|
|
68
|
+
group: JOBS_GROUP,
|
|
69
|
+
// The list's search box is the label's filter: an id pasted from a log line, by its prefix.
|
|
70
|
+
labelField: 'id',
|
|
71
|
+
fields: labelled(RUN_LABELS.fields, {
|
|
72
|
+
id: { searchable: true, sortable: false, hintKey: 'admin.jobs.hint.id' },
|
|
73
|
+
name: exact,
|
|
74
|
+
queue: exact,
|
|
75
|
+
tenantId: exact,
|
|
76
|
+
attempt: unsorted,
|
|
77
|
+
maxAttempts: unsorted,
|
|
78
|
+
runAt: unsorted,
|
|
79
|
+
updatedAt: unsorted,
|
|
80
|
+
// Free text the store cannot search: the admin's search asks only what it can answer.
|
|
81
|
+
lastError: unsearched,
|
|
82
|
+
lastErrorStack: unsearched,
|
|
83
|
+
traceparent: unsearched,
|
|
84
|
+
enqueuedBy: unsearched,
|
|
85
|
+
claimedBy: unsearched,
|
|
86
|
+
}),
|
|
87
|
+
listFields: ['id', 'name', 'state', 'queue', 'attempt', 'runAt'],
|
|
88
|
+
columns: {
|
|
89
|
+
// `progress` itself is the column (JSON, on the detail page); the list shows `done / total`.
|
|
90
|
+
completed: {
|
|
91
|
+
value: (row) => {
|
|
92
|
+
const progress = row['progress'];
|
|
93
|
+
if (typeof progress !== 'object' || progress === null) return '';
|
|
94
|
+
const { done, total } = progress as { done?: unknown; total?: unknown };
|
|
95
|
+
return `${String(done)} / ${String(total)}`;
|
|
96
|
+
},
|
|
97
|
+
render: 'truncate',
|
|
98
|
+
labelKey: RUN_LABELS.columns.completed,
|
|
99
|
+
},
|
|
100
|
+
error: {
|
|
101
|
+
value: (row) => row['lastError'],
|
|
102
|
+
render: 'truncate',
|
|
103
|
+
labelKey: RUN_LABELS.columns.error,
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
scopes: stateScopes,
|
|
107
|
+
rows: jobRowScope,
|
|
108
|
+
sections: [
|
|
109
|
+
{
|
|
110
|
+
titleKey: 'admin.jobs.section.run',
|
|
111
|
+
fields: ['name', 'queue', 'state', 'attempt', 'maxAttempts', 'runAt', 'createdAt'],
|
|
112
|
+
},
|
|
113
|
+
{ titleKey: 'admin.jobs.section.failure', fields: ['lastError', 'lastErrorStack'] },
|
|
114
|
+
{ titleKey: 'admin.jobs.section.work', fields: ['input', 'progress', 'steps'] },
|
|
115
|
+
{
|
|
116
|
+
titleKey: 'admin.jobs.section.trace',
|
|
117
|
+
fields: ['tenantId', 'traceparent', 'enqueuedBy', 'claimedBy', 'updatedAt'],
|
|
118
|
+
},
|
|
119
|
+
],
|
|
120
|
+
defaultSort: JOB_SORT,
|
|
121
|
+
pageSize: 50,
|
|
122
|
+
operations: ['list', 'detail', 'search'],
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The whole dashboard as `defineAdmin` input, its links under `basePath`. */
|
|
127
|
+
export function jobsAdmin(basePath: string): {
|
|
128
|
+
readonly entities: readonly AdminEntity[];
|
|
129
|
+
readonly resources: Readonly<Record<string, AdminResourceOptions>>;
|
|
130
|
+
readonly actions: readonly AdminAction[];
|
|
131
|
+
} {
|
|
132
|
+
// Built once more here only to spell links through the list's own URL grammar (`listHref`).
|
|
133
|
+
const runs = adminResource(jobEntity, runsOptions());
|
|
134
|
+
const runsOf = (field: string, value: string): string =>
|
|
135
|
+
listHref(basePath, runs, { filters: [{ field, op: 'eq', value }] });
|
|
136
|
+
const fleet = {
|
|
137
|
+
permission: JOB_PERMISSION_NOUN,
|
|
138
|
+
group: JOBS_GROUP,
|
|
139
|
+
operations: ['list', 'detail', 'search'],
|
|
140
|
+
} as const;
|
|
141
|
+
return {
|
|
142
|
+
entities: [jobEntity, queueEntity, taskEntity, workerEntity],
|
|
143
|
+
resources: {
|
|
144
|
+
[JOB_ENTITY.jobs]: runsOptions(),
|
|
145
|
+
[JOB_ENTITY.queues]: {
|
|
146
|
+
...fleet,
|
|
147
|
+
repo: queueRepo,
|
|
148
|
+
path: `${JOBS_PATH}/queues`,
|
|
149
|
+
titleKey: QUEUE_LABELS.title,
|
|
150
|
+
fields: labelled(QUEUE_LABELS.fields),
|
|
151
|
+
labelField: 'name',
|
|
152
|
+
listFields: ['name', 'ready', 'delayed', 'running', 'dead', 'failed', 'paused'],
|
|
153
|
+
columns: {
|
|
154
|
+
oldest: {
|
|
155
|
+
value: (row) => `${String(row['oldestReadyMs'])} ms`,
|
|
156
|
+
render: 'truncate',
|
|
157
|
+
labelKey: QUEUE_LABELS.columns.oldest,
|
|
158
|
+
},
|
|
159
|
+
jobs: {
|
|
160
|
+
value: (row) => ({
|
|
161
|
+
href: runsOf('queue', String(row['name'])),
|
|
162
|
+
label: String(row['name']),
|
|
163
|
+
}),
|
|
164
|
+
render: 'link',
|
|
165
|
+
labelKey: QUEUE_LABELS.columns.jobs,
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
defaultSort: { field: 'name', direction: 'asc' },
|
|
169
|
+
rows: platformOnly('name'),
|
|
170
|
+
},
|
|
171
|
+
[JOB_ENTITY.tasks]: {
|
|
172
|
+
...fleet,
|
|
173
|
+
repo: taskRepo,
|
|
174
|
+
path: `${JOBS_PATH}/tasks`,
|
|
175
|
+
titleKey: TASK_LABELS.title,
|
|
176
|
+
fields: labelled(TASK_LABELS.fields),
|
|
177
|
+
labelField: 'name',
|
|
178
|
+
listFields: ['name', 'cron', 'tz', 'lastFireAt', 'nextFireAt', 'paused'],
|
|
179
|
+
defaultSort: { field: 'name', direction: 'asc' },
|
|
180
|
+
rows: platformOnly('name'),
|
|
181
|
+
},
|
|
182
|
+
[JOB_ENTITY.workers]: {
|
|
183
|
+
...fleet,
|
|
184
|
+
repo: workerRepo,
|
|
185
|
+
path: `${JOBS_PATH}/workers`,
|
|
186
|
+
titleKey: WORKER_LABELS.title,
|
|
187
|
+
fields: labelled(WORKER_LABELS.fields),
|
|
188
|
+
labelField: 'id',
|
|
189
|
+
listFields: ['id', 'host', 'queues', 'inFlight', 'concurrency', 'startedAt'],
|
|
190
|
+
columns: {
|
|
191
|
+
heartbeat: {
|
|
192
|
+
value: (row) => row['heartbeatAt'],
|
|
193
|
+
render: 'relative-time',
|
|
194
|
+
labelKey: WORKER_LABELS.columns.heartbeat,
|
|
195
|
+
},
|
|
196
|
+
running: {
|
|
197
|
+
value: (row) => ({
|
|
198
|
+
href: listHref(basePath, runs, { scope: 'running' }),
|
|
199
|
+
label: String(row['inFlight']),
|
|
200
|
+
}),
|
|
201
|
+
render: 'link',
|
|
202
|
+
labelKey: WORKER_LABELS.columns.running,
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
defaultSort: { field: 'id', direction: 'asc' },
|
|
206
|
+
rows: platformOnly('id'),
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
actions: [...jobActions, ...queueActions, ...taskActions],
|
|
210
|
+
};
|
|
211
|
+
}
|