@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,170 @@
|
|
|
1
|
+
// The admin's predicate IR → the job store's own filters. The jobs list is read through
|
|
2
|
+
// `JobIntrospection`, whose `list` and bulk verbs answer a FIXED set of predicates; this is the one
|
|
3
|
+
// translation, shared by the list, a row's lookup and "all matching", and anything it cannot say
|
|
4
|
+
// is refused by name — never dropped, which would widen the operator's list (or their bulk call).
|
|
5
|
+
|
|
6
|
+
import type { BulkFilter, JobFilter, JobState } from '@ultimat3/jobs';
|
|
7
|
+
import { isJobState } from '@ultimat3/jobs';
|
|
8
|
+
import { AdminFilterInvalidError } from '../errors';
|
|
9
|
+
import type { AdminFilter } from '../registry';
|
|
10
|
+
|
|
11
|
+
/** The resource the job rows are, named once for every refusal. */
|
|
12
|
+
export const JOBS_RESOURCE = 'x_jobs';
|
|
13
|
+
|
|
14
|
+
/** What the store answers, in the URL's own spelling — the refusal's `known` list. */
|
|
15
|
+
const ANSWERED = [
|
|
16
|
+
'f.id (starts with)',
|
|
17
|
+
'f.id.eq',
|
|
18
|
+
'f.name',
|
|
19
|
+
'f.queue',
|
|
20
|
+
'f.state',
|
|
21
|
+
'f.tenantId',
|
|
22
|
+
'f.createdAt.gte',
|
|
23
|
+
'f.createdAt.lte',
|
|
24
|
+
] as const;
|
|
25
|
+
|
|
26
|
+
/** A translated `where`: the store's filter, and the one predicate it has no column for. */
|
|
27
|
+
export interface JobWhere {
|
|
28
|
+
readonly filter: Omit<JobFilter, 'limit' | 'after' | 'before'>;
|
|
29
|
+
/** `id eq`: the store matches a prefix, so an exact id is checked on the row. */
|
|
30
|
+
readonly exactId?: string;
|
|
31
|
+
/** Two predicates no row satisfies at once (`state = dead` and `state = done`). */
|
|
32
|
+
readonly empty: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const refuse = (filter: AdminFilter, cause: string): never => {
|
|
36
|
+
throw new AdminFilterInvalidError({
|
|
37
|
+
entity: JOBS_RESOURCE,
|
|
38
|
+
asked: `f.${filter.field}.${filter.op}`,
|
|
39
|
+
cause,
|
|
40
|
+
known: ANSWERED,
|
|
41
|
+
});
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const text = (filter: AdminFilter): string =>
|
|
45
|
+
typeof filter.value === 'string'
|
|
46
|
+
? filter.value
|
|
47
|
+
: refuse(filter, 'takes one value as text — the job store compares no list here');
|
|
48
|
+
|
|
49
|
+
const instantMs = (filter: AdminFilter): number => {
|
|
50
|
+
const ms = Date.parse(text(filter));
|
|
51
|
+
return Number.isFinite(ms) ? ms : refuse(filter, 'takes an ISO-8601 instant');
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
type Mutable = { -readonly [K in keyof JobWhere['filter']]: JobWhere['filter'][K] };
|
|
55
|
+
|
|
56
|
+
/** Set one key, or notice that two predicates contradict each other. */
|
|
57
|
+
function put<K extends keyof Mutable>(
|
|
58
|
+
out: Mutable,
|
|
59
|
+
key: K,
|
|
60
|
+
value: NonNullable<Mutable[K]>,
|
|
61
|
+
narrower: (a: NonNullable<Mutable[K]>, b: NonNullable<Mutable[K]>) => Mutable[K] | null,
|
|
62
|
+
): boolean {
|
|
63
|
+
const held = out[key];
|
|
64
|
+
if (held === undefined || held === null) {
|
|
65
|
+
out[key] = value;
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
const merged = narrower(held as NonNullable<Mutable[K]>, value);
|
|
69
|
+
if (merged === null) return false;
|
|
70
|
+
out[key] = merged;
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const same = <T>(a: T, b: T): T | null => (a === b ? a : null);
|
|
75
|
+
|
|
76
|
+
export function jobWhere(where: readonly AdminFilter[]): JobWhere {
|
|
77
|
+
const out: Mutable = {};
|
|
78
|
+
let exactId: string | undefined;
|
|
79
|
+
let empty = false;
|
|
80
|
+
const keep = (holds: boolean): void => {
|
|
81
|
+
if (!holds) empty = true;
|
|
82
|
+
};
|
|
83
|
+
for (const filter of where) {
|
|
84
|
+
const key = `${filter.field}.${filter.op}`;
|
|
85
|
+
switch (key) {
|
|
86
|
+
case 'state.eq': {
|
|
87
|
+
const state = text(filter);
|
|
88
|
+
if (!isJobState(state)) refuse(filter, 'is not a job state');
|
|
89
|
+
keep(put(out, 'state', state as JobState, same));
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
case 'queue.eq':
|
|
93
|
+
keep(put(out, 'queue', text(filter), same));
|
|
94
|
+
break;
|
|
95
|
+
case 'name.eq':
|
|
96
|
+
keep(put(out, 'name', text(filter), same));
|
|
97
|
+
break;
|
|
98
|
+
case 'tenantId.eq':
|
|
99
|
+
keep(put(out, 'tenantId', text(filter), same));
|
|
100
|
+
break;
|
|
101
|
+
// A job id is opaque and pasted from a log line by its first characters: the store's own
|
|
102
|
+
// search is by prefix, so the id box's `contains` is answered as "starts with".
|
|
103
|
+
case 'id.contains':
|
|
104
|
+
keep(
|
|
105
|
+
put(out, 'idPrefix', text(filter), (a, b) =>
|
|
106
|
+
a.startsWith(b) ? a : b.startsWith(a) ? b : null,
|
|
107
|
+
),
|
|
108
|
+
);
|
|
109
|
+
break;
|
|
110
|
+
case 'id.eq': {
|
|
111
|
+
const id = text(filter);
|
|
112
|
+
if (exactId !== undefined && exactId !== id) empty = true;
|
|
113
|
+
exactId = id;
|
|
114
|
+
keep(put(out, 'idPrefix', id, (a, b) => (b.startsWith(a) ? b : null)));
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
case 'createdAt.gte':
|
|
118
|
+
case 'createdAt.gt': {
|
|
119
|
+
const from = instantMs(filter) + (filter.op === 'gt' ? 1 : 0);
|
|
120
|
+
put(out, 'createdFrom', from, (a, b) => Math.max(a, b));
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
// The store's upper bound is exclusive; `lte` includes its own millisecond.
|
|
124
|
+
case 'createdAt.lt':
|
|
125
|
+
case 'createdAt.lte': {
|
|
126
|
+
const to = instantMs(filter) + (filter.op === 'lte' ? 1 : 0);
|
|
127
|
+
put(out, 'createdTo', to, (a, b) => Math.min(a, b));
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
default:
|
|
131
|
+
refuse(filter, 'is not a predicate the job store answers');
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (
|
|
135
|
+
out.createdFrom !== undefined &&
|
|
136
|
+
out.createdTo !== undefined &&
|
|
137
|
+
out.createdFrom >= out.createdTo
|
|
138
|
+
) {
|
|
139
|
+
empty = true;
|
|
140
|
+
}
|
|
141
|
+
return { filter: out, ...(exactId === undefined ? {} : { exactId }), empty };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The same `where` as a BULK verb's filter, which answers fewer predicates than a page does: a
|
|
146
|
+
* state (required — "every job" is never one call), a queue, a name, a tenant. An id or a time
|
|
147
|
+
* range is a selection the operator checks row by row, and is refused by name here.
|
|
148
|
+
*/
|
|
149
|
+
export function bulkWhere(where: readonly AdminFilter[]): BulkFilter | null {
|
|
150
|
+
const translated = jobWhere(where);
|
|
151
|
+
if (translated.empty) return null;
|
|
152
|
+
const { state, queue, name, tenantId, idPrefix, createdFrom, createdTo } = translated.filter;
|
|
153
|
+
const narrowed = idPrefix !== undefined || createdFrom !== undefined || createdTo !== undefined;
|
|
154
|
+
if (narrowed || state === undefined) {
|
|
155
|
+
throw new AdminFilterInvalidError({
|
|
156
|
+
entity: JOBS_RESOURCE,
|
|
157
|
+
asked: narrowed ? 'all matching, narrowed by id or time' : 'all matching, over every state',
|
|
158
|
+
cause: narrowed
|
|
159
|
+
? 'cannot be one bulk call: the store bulk-edits by state, queue, name and tenant only — check the rows instead'
|
|
160
|
+
: 'cannot be one bulk call: pick a state tab first, so "every job" is never one call',
|
|
161
|
+
known: ['scope=<state>', 'f.queue', 'f.name', 'f.tenantId'],
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
state,
|
|
166
|
+
...(queue === undefined ? {} : { queue }),
|
|
167
|
+
...(name === undefined ? {} : { name }),
|
|
168
|
+
...(tenantId === undefined ? {} : { tenantId }),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// The one door from the jobs screens to the queue: the process's own driver, read per call — the
|
|
2
|
+
// boot installs it after `defineAdmin()` has run, and `x dev` swaps it — and its operator surface.
|
|
3
|
+
// A process with no driver, or one that ships no operator surface, is refused with the fix.
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
DriverUnavailableError,
|
|
7
|
+
type JobDriver,
|
|
8
|
+
type JobIntrospection,
|
|
9
|
+
JobsNotImplementedError,
|
|
10
|
+
jobDriver,
|
|
11
|
+
} from '@ultimat3/jobs';
|
|
12
|
+
|
|
13
|
+
export interface JobsOperator {
|
|
14
|
+
readonly driver: JobDriver;
|
|
15
|
+
readonly introspect: JobIntrospection;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function jobsOperator(): JobsOperator {
|
|
19
|
+
const driver = jobDriver();
|
|
20
|
+
if (driver === undefined) {
|
|
21
|
+
throw new DriverUnavailableError({
|
|
22
|
+
driver: 'none',
|
|
23
|
+
cause: 'the jobs dashboard was asked for rows and this process installed no queue',
|
|
24
|
+
fix: 'run the app through x dev or the container entry, which install the queue — or in a test: setJobDriver(createMemoryDriver())',
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
if (driver.introspect === undefined) {
|
|
28
|
+
throw new JobsNotImplementedError({
|
|
29
|
+
feature: `the operator surface of the "${driver.name}" jobs driver`,
|
|
30
|
+
fix: 'call setJobDriver(createPgDriver()) at boot — the pg and memory drivers implement introspect',
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
return { driver, introspect: driver.introspect };
|
|
34
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// What the jobs overview reads, and the arithmetic over it: queue depth from ONE `stats()`, the
|
|
2
|
+
// worker registry, and the settle counters folded into a fixed number of bars per range. Pure apart
|
|
3
|
+
// from `overviewData`, so the chart's bucketing is tested without a queue.
|
|
4
|
+
|
|
5
|
+
import { expectedQueryLoop } from '@ultimat3/db';
|
|
6
|
+
import { type CounterTotals, inspectQueues, type JobCounter } from '@ultimat3/jobs';
|
|
7
|
+
import { AdminFilterInvalidError } from '../errors';
|
|
8
|
+
import { jobsOperator } from './operator';
|
|
9
|
+
|
|
10
|
+
/** The ranges the chart and the per-name table answer, and how many bars each is drawn in. */
|
|
11
|
+
export const OVERVIEW_RANGES = {
|
|
12
|
+
'1h': { ms: 3_600_000, bars: 12 },
|
|
13
|
+
'24h': { ms: 86_400_000, bars: 24 },
|
|
14
|
+
'7d': { ms: 604_800_000, bars: 28 },
|
|
15
|
+
'30d': { ms: 2_592_000_000, bars: 30 },
|
|
16
|
+
} as const;
|
|
17
|
+
|
|
18
|
+
export type OverviewRange = keyof typeof OVERVIEW_RANGES;
|
|
19
|
+
export const RANGE_PARAM = 'range';
|
|
20
|
+
export const DEFAULT_RANGE: OverviewRange = '24h';
|
|
21
|
+
const RANGES: ReadonlyMap<string, OverviewRange> = new Map(
|
|
22
|
+
(Object.keys(OVERVIEW_RANGES) as OverviewRange[]).map((key) => [key, key]),
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
/** `?range=` as one of the four; absent is the default, anything else is refused by name. */
|
|
26
|
+
export function rangeOf(url: URL): OverviewRange {
|
|
27
|
+
const asked = url.searchParams.get(RANGE_PARAM);
|
|
28
|
+
if (asked === null || asked === '') return DEFAULT_RANGE;
|
|
29
|
+
const range = RANGES.get(asked);
|
|
30
|
+
if (range !== undefined) return range;
|
|
31
|
+
throw new AdminFilterInvalidError({
|
|
32
|
+
entity: 'jobs',
|
|
33
|
+
asked: `${RANGE_PARAM}=${asked}`,
|
|
34
|
+
cause: 'is not a range the overview draws',
|
|
35
|
+
known: [...RANGES.keys()].map((key) => `${RANGE_PARAM}=${key}`),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface OverviewBar {
|
|
40
|
+
readonly startMs: number;
|
|
41
|
+
readonly done: number;
|
|
42
|
+
/** Runs that ended badly: `failed` and `dead`. A retried attempt is not an ending. */
|
|
43
|
+
readonly failed: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Every bucket from `sinceMs` on, whatever tier it lives in, summed into `bars` equal bars. */
|
|
47
|
+
export function barsOf(
|
|
48
|
+
counters: readonly JobCounter[],
|
|
49
|
+
range: OverviewRange,
|
|
50
|
+
nowMs: number,
|
|
51
|
+
): readonly OverviewBar[] {
|
|
52
|
+
const { ms, bars } = OVERVIEW_RANGES[range];
|
|
53
|
+
const width = ms / bars;
|
|
54
|
+
const start = nowMs - ms;
|
|
55
|
+
const out = Array.from({ length: bars }, (_, index) => ({
|
|
56
|
+
startMs: start + index * width,
|
|
57
|
+
done: 0,
|
|
58
|
+
failed: 0,
|
|
59
|
+
}));
|
|
60
|
+
for (const bucket of counters) {
|
|
61
|
+
const index = Math.floor((bucket.bucketStart - start) / width);
|
|
62
|
+
const bar = out[Math.min(bars - 1, Math.max(0, index))];
|
|
63
|
+
if (bar === undefined) continue;
|
|
64
|
+
bar.done += bucket.done;
|
|
65
|
+
bar.failed += bucket.failed + bucket.dead;
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface NameRow {
|
|
71
|
+
readonly name: string;
|
|
72
|
+
/** Runs that ended in the range: done, failed and dead. */
|
|
73
|
+
readonly volume: number;
|
|
74
|
+
/** Of those, the share that did not end done — `null` when none ended. */
|
|
75
|
+
readonly failureRate: number | null;
|
|
76
|
+
/** Mean attempt duration, retries included — `null` when nothing was attempted. */
|
|
77
|
+
readonly meanMs: number | null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function nameRowsOf(totals: readonly CounterTotals[]): readonly NameRow[] {
|
|
81
|
+
return totals.map((row) => {
|
|
82
|
+
const volume = row.done + row.failed + row.dead;
|
|
83
|
+
const attempts = volume + row.retried;
|
|
84
|
+
return {
|
|
85
|
+
name: row.job,
|
|
86
|
+
volume,
|
|
87
|
+
failureRate: volume === 0 ? null : (row.failed + row.dead) / volume,
|
|
88
|
+
meanMs: attempts === 0 ? null : row.durationMs / attempts,
|
|
89
|
+
};
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface OverviewData {
|
|
94
|
+
readonly totals: Awaited<ReturnType<typeof inspectQueues>>['totals'];
|
|
95
|
+
readonly oldestReadyMs: number;
|
|
96
|
+
readonly workers: number;
|
|
97
|
+
readonly bars: readonly OverviewBar[];
|
|
98
|
+
readonly names: readonly NameRow[];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Two statements and one per job name that settled in the range: the totals say which names have
|
|
103
|
+
* buckets at all, and each one's buckets are read by name — the store keys counters by job. The
|
|
104
|
+
* loop is declared rather than hidden: it is bounded by the app's registered job names.
|
|
105
|
+
*/
|
|
106
|
+
export async function overviewData(range: OverviewRange, nowMs: number): Promise<OverviewData> {
|
|
107
|
+
const { driver, introspect } = jobsOperator();
|
|
108
|
+
const sinceMs = nowMs - OVERVIEW_RANGES[range].ms;
|
|
109
|
+
const [depth, workers, totals] = await Promise.all([
|
|
110
|
+
inspectQueues(driver),
|
|
111
|
+
introspect.workers(),
|
|
112
|
+
introspect.counterTotals(sinceMs),
|
|
113
|
+
]);
|
|
114
|
+
const counters = await expectedQueryLoop(
|
|
115
|
+
'the jobs overview reads each settled job name’s counter buckets; the store keys them by name',
|
|
116
|
+
async () => {
|
|
117
|
+
const all: JobCounter[] = [];
|
|
118
|
+
for (const row of totals) all.push(...(await introspect.counters({ job: row.job, sinceMs })));
|
|
119
|
+
return all;
|
|
120
|
+
},
|
|
121
|
+
);
|
|
122
|
+
return {
|
|
123
|
+
totals: depth.totals,
|
|
124
|
+
oldestReadyMs: depth.oldestReadyMs,
|
|
125
|
+
workers: workers.length,
|
|
126
|
+
bars: barsOf(counters, range, nowMs),
|
|
127
|
+
names: nameRowsOf(totals),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// The `/_x` jobs tab's body: the SAME `JobsOverview` `/admin/jobs` renders, over this process's
|
|
2
|
+
// own queue (the dev driver under `x dev`) — one jobs UI, not a second one in the dev panel. Only
|
|
3
|
+
// ever reached by `dev/panel-jobs.ts`'s dynamic `import()`, so `/_x`'s mount graph stays free of it.
|
|
4
|
+
|
|
5
|
+
import { createContext, runWithContext } from '@ultimat3/core';
|
|
6
|
+
import { renderToHtml } from '@ultimat3/render/server';
|
|
7
|
+
import { listHref } from '../list-request';
|
|
8
|
+
import { adminMounts } from '../mounts';
|
|
9
|
+
import { JOB_ENTITY } from './job-entities';
|
|
10
|
+
import { JobsOverview } from './overview';
|
|
11
|
+
import {
|
|
12
|
+
DEFAULT_RANGE,
|
|
13
|
+
OVERVIEW_RANGES,
|
|
14
|
+
type OverviewRange,
|
|
15
|
+
overviewData,
|
|
16
|
+
RANGE_PARAM,
|
|
17
|
+
} from './overview-data';
|
|
18
|
+
|
|
19
|
+
const isRange = (value: string | null): value is OverviewRange =>
|
|
20
|
+
value !== null && Object.hasOwn(OVERVIEW_RANGES, value);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The overview as HTML, its links into the app's own admin when one is mounted — the lists live
|
|
24
|
+
* there — and into this tab's `--json` otherwise. `/_x` is the framework's tool, so it reads in
|
|
25
|
+
* the framework's locale and in UTC, stated rather than ambient.
|
|
26
|
+
*/
|
|
27
|
+
export async function jobsTabHtml(tabPath: string, params: URLSearchParams): Promise<string> {
|
|
28
|
+
const asked = params.get(RANGE_PARAM);
|
|
29
|
+
const range = isRange(asked) ? asked : DEFAULT_RANGE;
|
|
30
|
+
const admin = adminMounts()[0];
|
|
31
|
+
const runs = admin?.resources.find((resource) => resource.name === JOB_ENTITY.jobs);
|
|
32
|
+
const workers = admin?.resources.find((resource) => resource.name === JOB_ENTITY.workers);
|
|
33
|
+
const json = `${tabPath}?json=1`;
|
|
34
|
+
const data = await overviewData(range, Date.now());
|
|
35
|
+
// The framework catalog's locale for every label, whatever the app's default is.
|
|
36
|
+
return runWithContext(createContext({ locale: 'en', tz: 'UTC' }), () =>
|
|
37
|
+
renderToHtml(
|
|
38
|
+
<JobsOverview
|
|
39
|
+
data={data}
|
|
40
|
+
range={range}
|
|
41
|
+
locale="en"
|
|
42
|
+
timeZone="UTC"
|
|
43
|
+
hrefs={{
|
|
44
|
+
state: (state) =>
|
|
45
|
+
admin === undefined || runs === undefined
|
|
46
|
+
? json
|
|
47
|
+
: listHref(admin.basePath, runs, { scope: state }),
|
|
48
|
+
name: (name) =>
|
|
49
|
+
admin === undefined || runs === undefined
|
|
50
|
+
? json
|
|
51
|
+
: listHref(admin.basePath, runs, {
|
|
52
|
+
filters: [{ field: 'name', op: 'eq', value: name }],
|
|
53
|
+
}),
|
|
54
|
+
workers:
|
|
55
|
+
admin === undefined || workers === undefined ? json : listHref(admin.basePath, workers),
|
|
56
|
+
range: (next) => `${tabPath}?${RANGE_PARAM}=${next}`,
|
|
57
|
+
}}
|
|
58
|
+
/>,
|
|
59
|
+
),
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// `/admin/jobs` — the overview: depth tiles from one `stats()`, done-vs-failed over a chosen range,
|
|
2
|
+
// and per job name its volume, failure rate and mean duration. A plain read with a declared refresh
|
|
3
|
+
// (`<meta http-equiv="refresh">`): no socket, no script, the admin's zero-JS rule kept.
|
|
4
|
+
|
|
5
|
+
import { t } from '@ultimat3/i18n';
|
|
6
|
+
import { BarChart, Card, DataTable, Link, StatTile } from '@ultimat3/ui';
|
|
7
|
+
import type { JSX } from 'solid-js';
|
|
8
|
+
import type { AdminApp, AdminRoute } from '../admin';
|
|
9
|
+
import styles from '../admin.module.scss';
|
|
10
|
+
import { listHref } from '../list-request';
|
|
11
|
+
import { type AdminScreen, guardedScreen, widgetContextOf } from '../screen-frame';
|
|
12
|
+
import { JOB_ENTITY } from './job-entities';
|
|
13
|
+
import {
|
|
14
|
+
type NameRow,
|
|
15
|
+
OVERVIEW_RANGES,
|
|
16
|
+
type OverviewData,
|
|
17
|
+
type OverviewRange,
|
|
18
|
+
overviewData,
|
|
19
|
+
RANGE_PARAM,
|
|
20
|
+
rangeOf,
|
|
21
|
+
} from './overview-data';
|
|
22
|
+
|
|
23
|
+
/** Seconds between the browser's own re-reads of the overview. A measurement, not a live feed. */
|
|
24
|
+
export const OVERVIEW_REFRESH_SECONDS = 30;
|
|
25
|
+
|
|
26
|
+
const TILE_STATES = ['ready', 'running', 'delayed', 'suspended', 'dead'] as const;
|
|
27
|
+
|
|
28
|
+
export interface JobsOverviewProps {
|
|
29
|
+
readonly data: OverviewData;
|
|
30
|
+
readonly range: OverviewRange;
|
|
31
|
+
readonly locale: string;
|
|
32
|
+
readonly timeZone: string;
|
|
33
|
+
/** The runs list in one state, the workers list, the overview at a range: links, never calls. */
|
|
34
|
+
readonly hrefs: {
|
|
35
|
+
readonly state: (state: string) => string;
|
|
36
|
+
readonly name: (name: string) => string;
|
|
37
|
+
readonly workers: string;
|
|
38
|
+
readonly range: (range: OverviewRange) => string;
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** What the overview draws, from data alone — the `/_x` jobs tab renders this same component. */
|
|
43
|
+
export function JobsOverview(props: JobsOverviewProps): JSX.Element {
|
|
44
|
+
const count = new Intl.NumberFormat(props.locale);
|
|
45
|
+
const percent = new Intl.NumberFormat(props.locale, {
|
|
46
|
+
style: 'percent',
|
|
47
|
+
maximumFractionDigits: 1,
|
|
48
|
+
});
|
|
49
|
+
const bucket = new Intl.DateTimeFormat(props.locale, {
|
|
50
|
+
timeZone: props.timeZone,
|
|
51
|
+
...(OVERVIEW_RANGES[props.range].ms > 86_400_000
|
|
52
|
+
? { month: 'short', day: 'numeric' }
|
|
53
|
+
: { hour: '2-digit', minute: '2-digit' }),
|
|
54
|
+
});
|
|
55
|
+
const tile = (key: string, value: string, href: string): JSX.Element => (
|
|
56
|
+
<li>
|
|
57
|
+
<a href={href}>
|
|
58
|
+
<StatTile label={t(`admin.jobs.tile.${key}`)} value={value} stat={key} />
|
|
59
|
+
</a>
|
|
60
|
+
</li>
|
|
61
|
+
);
|
|
62
|
+
const columns = [
|
|
63
|
+
{
|
|
64
|
+
key: 'name',
|
|
65
|
+
header: t('admin.jobs.byName.name'),
|
|
66
|
+
cell: (row: NameRow) => <Link href={props.hrefs.name(row.name)}>{row.name}</Link>,
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
key: 'volume',
|
|
70
|
+
header: t('admin.jobs.byName.volume'),
|
|
71
|
+
numeric: true,
|
|
72
|
+
cell: (row: NameRow) => <span>{count.format(row.volume)}</span>,
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
key: 'failureRate',
|
|
76
|
+
header: t('admin.jobs.byName.failureRate'),
|
|
77
|
+
numeric: true,
|
|
78
|
+
cell: (row: NameRow) => (
|
|
79
|
+
<span>
|
|
80
|
+
{row.failureRate === null ? t('admin.value.empty') : percent.format(row.failureRate)}
|
|
81
|
+
</span>
|
|
82
|
+
),
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
key: 'meanMs',
|
|
86
|
+
header: t('admin.jobs.byName.meanMs'),
|
|
87
|
+
numeric: true,
|
|
88
|
+
cell: (row: NameRow) => (
|
|
89
|
+
<span>
|
|
90
|
+
{row.meanMs === null
|
|
91
|
+
? t('admin.value.empty')
|
|
92
|
+
: t('admin.jobs.ms', { ms: count.format(Math.round(row.meanMs)) })}
|
|
93
|
+
</span>
|
|
94
|
+
),
|
|
95
|
+
},
|
|
96
|
+
];
|
|
97
|
+
return (
|
|
98
|
+
<>
|
|
99
|
+
<ul class={styles['tiles']}>
|
|
100
|
+
{TILE_STATES.map((state) =>
|
|
101
|
+
tile(state, count.format(props.data.totals[state]), props.hrefs.state(state)),
|
|
102
|
+
)}
|
|
103
|
+
{tile('workers', count.format(props.data.workers), props.hrefs.workers)}
|
|
104
|
+
{tile(
|
|
105
|
+
'oldestReady',
|
|
106
|
+
t('admin.jobs.seconds', {
|
|
107
|
+
seconds: count.format(Math.round(props.data.oldestReadyMs / 1000)),
|
|
108
|
+
}),
|
|
109
|
+
props.hrefs.state('ready'),
|
|
110
|
+
)}
|
|
111
|
+
</ul>
|
|
112
|
+
<nav aria-label={t('admin.jobs.range.label')}>
|
|
113
|
+
<ul class={styles['scopes']}>
|
|
114
|
+
{(Object.keys(OVERVIEW_RANGES) as OverviewRange[]).map((range) => (
|
|
115
|
+
<li>
|
|
116
|
+
<Link
|
|
117
|
+
href={props.hrefs.range(range)}
|
|
118
|
+
{...(range === props.range ? { 'aria-current': 'page' } : {})}
|
|
119
|
+
>
|
|
120
|
+
{t(`admin.jobs.range.${range}`)}
|
|
121
|
+
</Link>
|
|
122
|
+
</li>
|
|
123
|
+
))}
|
|
124
|
+
</ul>
|
|
125
|
+
</nav>
|
|
126
|
+
<Card header={<h2>{t('admin.jobs.chart.title')}</h2>}>
|
|
127
|
+
<BarChart
|
|
128
|
+
label={t('admin.jobs.chart.label', { range: t(`admin.jobs.range.${props.range}`) })}
|
|
129
|
+
points={props.data.bars.map((bar) => ({
|
|
130
|
+
key: bucket.format(new Date(bar.startMs)),
|
|
131
|
+
value: bar.done,
|
|
132
|
+
secondary: bar.failed,
|
|
133
|
+
}))}
|
|
134
|
+
seriesLabels={{
|
|
135
|
+
primary: t('admin.jobs.chart.done'),
|
|
136
|
+
secondary: t('admin.jobs.chart.failed'),
|
|
137
|
+
}}
|
|
138
|
+
/>
|
|
139
|
+
</Card>
|
|
140
|
+
<DataTable
|
|
141
|
+
caption={t('admin.jobs.byName.caption')}
|
|
142
|
+
columns={columns}
|
|
143
|
+
rows={props.data.names}
|
|
144
|
+
rowKey={(row) => row.name}
|
|
145
|
+
emptyTitle={t('admin.jobs.byName.empty')}
|
|
146
|
+
/>
|
|
147
|
+
</>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The overview screen: decided by the route's own pair (`admin:read` + `job:read`) first. */
|
|
152
|
+
export function jobsOverviewScreen(app: AdminApp, route: AdminRoute): AdminScreen {
|
|
153
|
+
return guardedScreen(app, route, async (request) => {
|
|
154
|
+
const runs = app.resource(JOB_ENTITY.jobs);
|
|
155
|
+
const refresh = <meta http-equiv="refresh" content={String(OVERVIEW_REFRESH_SECONDS)} />;
|
|
156
|
+
// The fleet's numbers are every tenant's: an org-scoped operator is sent to their own rows.
|
|
157
|
+
if (request.ctx.actor.orgId !== undefined) {
|
|
158
|
+
return (
|
|
159
|
+
<p class={styles['note']}>
|
|
160
|
+
{t('admin.jobs.scoped')}{' '}
|
|
161
|
+
<Link href={listHref(app.basePath, runs)}>{t(runs.titleKey)}</Link>
|
|
162
|
+
</p>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
const url = new URL(request.url);
|
|
166
|
+
// An unknown `?range=` is refused by name — the frame answers it 400, as a list's filter is.
|
|
167
|
+
const range = rangeOf(url);
|
|
168
|
+
const ctx = widgetContextOf(app, request.ctx);
|
|
169
|
+
const data = await overviewData(range, Date.now());
|
|
170
|
+
return (
|
|
171
|
+
<>
|
|
172
|
+
{refresh}
|
|
173
|
+
<JobsOverview
|
|
174
|
+
data={data}
|
|
175
|
+
range={range}
|
|
176
|
+
locale={ctx.locale}
|
|
177
|
+
timeZone={ctx.timeZone}
|
|
178
|
+
hrefs={{
|
|
179
|
+
state: (state) => listHref(app.basePath, runs, { scope: state }),
|
|
180
|
+
name: (name) =>
|
|
181
|
+
listHref(app.basePath, runs, { filters: [{ field: 'name', op: 'eq', value: name }] }),
|
|
182
|
+
workers: listHref(app.basePath, app.resource(JOB_ENTITY.workers)),
|
|
183
|
+
range: (next) => `${url.pathname}?${RANGE_PARAM}=${next}`,
|
|
184
|
+
}}
|
|
185
|
+
/>
|
|
186
|
+
</>
|
|
187
|
+
);
|
|
188
|
+
});
|
|
189
|
+
}
|
package/src/json-text.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// A value as the JSON an operator READS: keys sorted so two equal values read alike, a bigint as
|
|
2
|
+
// its digits, an instant as ISO text. Total — `JSON.stringify` throws on a bigint, and a `money()`
|
|
3
|
+
// column puts one on the row. Never a hash form: `@ultimat3/core`'s `canonicalJson` spells a
|
|
4
|
+
// bigint `BigInt(2)` so two types cannot collide, which is right for a key and wrong for a screen.
|
|
5
|
+
|
|
6
|
+
const byCodeUnit = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
|
|
7
|
+
|
|
8
|
+
function text(value: unknown, seen: ReadonlySet<unknown>): string {
|
|
9
|
+
switch (typeof value) {
|
|
10
|
+
case 'string':
|
|
11
|
+
return JSON.stringify(value);
|
|
12
|
+
case 'number':
|
|
13
|
+
return Number.isFinite(value) ? String(value) : 'null';
|
|
14
|
+
case 'boolean':
|
|
15
|
+
return String(value);
|
|
16
|
+
case 'bigint':
|
|
17
|
+
return value.toString();
|
|
18
|
+
case 'object':
|
|
19
|
+
break;
|
|
20
|
+
default:
|
|
21
|
+
return 'null';
|
|
22
|
+
}
|
|
23
|
+
if (value === null) return 'null';
|
|
24
|
+
if (value instanceof Date) {
|
|
25
|
+
return Number.isNaN(value.getTime()) ? 'null' : JSON.stringify(value.toISOString());
|
|
26
|
+
}
|
|
27
|
+
// A cycle has no JSON; the second visit is where it is cut, and it says so.
|
|
28
|
+
if (seen.has(value)) return '"[circular]"';
|
|
29
|
+
const inside = new Set(seen).add(value);
|
|
30
|
+
if (Array.isArray(value)) return `[${value.map((one) => text(one, inside)).join(',')}]`;
|
|
31
|
+
if (value instanceof Set) return text([...value], inside);
|
|
32
|
+
const entries: readonly (readonly [string, unknown])[] =
|
|
33
|
+
value instanceof Map
|
|
34
|
+
? [...value].map(([key, held]) => [String(key), held] as const)
|
|
35
|
+
: Object.entries(value);
|
|
36
|
+
return `{${[...entries]
|
|
37
|
+
.filter(([, held]) => held !== undefined)
|
|
38
|
+
.sort(([a], [b]) => byCodeUnit(a, b))
|
|
39
|
+
.map(([key, held]) => `${JSON.stringify(key)}:${text(held, inside)}`)
|
|
40
|
+
.join(',')}}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** `value` as deterministic, readable JSON text. */
|
|
44
|
+
export function jsonText(value: unknown): string {
|
|
45
|
+
return text(value, new Set());
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** One value as a line of an audit diff or a detail row: text as it is, anything else as JSON. */
|
|
49
|
+
export function valueText(value: unknown): string {
|
|
50
|
+
if (value === null || value === undefined) return '';
|
|
51
|
+
return typeof value === 'string' ? value : jsonText(value);
|
|
52
|
+
}
|