@cosmicdrift/kumiko-samples 0.383.0 → 0.384.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/package.json +1 -1
- package/packages/bundled-features/package.json +6 -1
- package/packages/bundled-features/src/audit/changes.json +6 -0
- package/packages/bundled-features/src/audit/event-classification.ts +83 -0
- package/packages/bundled-features/src/audit/feature.ts +3 -2
- package/packages/bundled-features/src/audit/handlers/audit-events.ts +63 -5
- package/packages/bundled-features/src/audit/handlers/details-all-tenants.query.ts +6 -5
- package/packages/bundled-features/src/audit/handlers/details.query.ts +12 -6
- package/packages/bundled-features/src/audit/handlers/list-all-tenants.query.ts +20 -12
- package/packages/bundled-features/src/audit/handlers/list.query.ts +14 -3
- package/packages/bundled-features/src/audit/screens.ts +32 -4
- package/packages/bundled-features/src/audit/web/audit-event-cell.tsx +22 -0
- package/packages/bundled-features/src/audit/web/audit-event-labels.ts +30 -0
- package/packages/bundled-features/src/audit/web/client-plugin.tsx +10 -1
- package/packages/bundled-features/src/audit/web/constants.ts +11 -0
- package/packages/bundled-features/src/audit/web/i18n.ts +48 -1
- package/packages/bundled-features/src/auth-email-password/changes.json +6 -0
- package/packages/bundled-features/src/auth-email-password/feature.ts +3 -2
- package/packages/bundled-features/src/auth-email-password/handlers/login-options.query.ts +19 -0
- package/packages/bundled-features/src/auth-email-password/index.ts +7 -1
- package/packages/bundled-features/src/auth-email-password/{auth-paths.ts → web/auth-paths.ts} +7 -4
- package/packages/bundled-features/src/auth-email-password/web/client-plugin.ts +7 -0
- package/packages/bundled-features/src/auth-email-password/web/constants.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/web/default-auth-pages-gate.tsx +31 -0
- package/packages/bundled-features/src/auth-email-password/web/login-screen.tsx +68 -42
- package/packages/bundled-features/src/jobs/changes.json +6 -0
- package/packages/bundled-features/src/jobs/feature.ts +34 -127
- package/packages/bundled-features/src/jobs/handlers/consumer.write.ts +98 -0
- package/packages/bundled-features/src/jobs/handlers/consumers.query.ts +137 -0
- package/packages/bundled-features/src/jobs/screens.ts +262 -0
- package/packages/bundled-features/src/jobs/web/constants.ts +29 -0
- package/packages/bundled-features/src/jobs/web/i18n.ts +41 -0
- package/packages/bundled-features/src/secrets/i18n.ts +0 -3
- package/packages/bundled-features/src/sessions/changes.json +6 -0
- package/packages/bundled-features/src/sessions/constants.ts +3 -1
- package/packages/bundled-features/src/sessions/handlers/detail.query.ts +7 -3
- package/packages/bundled-features/src/sessions/handlers/list.query.ts +16 -10
- package/packages/bundled-features/src/sessions/handlers/mine.query.ts +15 -9
- package/packages/bundled-features/src/sessions/screens.ts +23 -8
- package/packages/bundled-features/src/sessions/web/client-plugin.tsx +17 -0
- package/packages/bundled-features/src/sessions/web/constants.ts +3 -0
- package/packages/bundled-features/src/sessions/web/default-translations.ts +8 -0
- package/packages/bundled-features/src/sessions/web/i18n.ts +5 -1
- package/packages/bundled-features/src/sessions/web/index.ts +1 -0
- package/packages/bundled-features/src/sessions/web/session-device-cell.tsx +19 -0
- package/packages/bundled-features/src/sessions/web/user-agent.ts +74 -0
- package/packages/bundled-features/src/template-resolver/changes.json +6 -0
- package/packages/bundled-features/src/template-resolver/feature.ts +2 -0
- package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +34 -1
- package/packages/bundled-features/src/template-resolver/handlers/tenant-override-counts.query.ts +53 -0
- package/packages/bundled-features/src/template-resolver/handlers/tenant-override-selection.ts +51 -0
- package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +7 -38
- package/packages/bundled-features/src/template-resolver/web/qualified-names.ts +1 -0
- package/packages/bundled-features/src/user-data-rights/changes.json +18 -0
- package/packages/bundled-features/src/user-data-rights/feature.ts +33 -33
- package/packages/bundled-features/src/user-data-rights/web/i18n.ts +4 -1
- package/samples/apps/showcase/src/features/items/schema/item.ts +8 -2
- package/samples/apps/ui-walkthrough/src/features/tasks/schema/task.ts +2 -2
- package/samples/apps/use-all-bundled/feature-manifest.json +4 -0
- package/samples/apps/use-all-bundled/src/app/client.tsx +2 -0
- package/samples/apps/use-all-bundled/src/app/dev-app-options.ts +2 -1
- package/samples/apps/use-all-bundled/src/app/invoice-reports-feature.ts +30 -0
- package/samples/apps/use-all-bundled/src/app/seed.ts +51 -0
- package/samples/apps/workspaces/src/features/demo/schema/order.ts +2 -2
- package/samples/recipes/custom-fields-basic/src/feature.ts +13 -3
- package/samples/recipes/designer-demo/src/feature.ts +6 -2
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import {
|
|
3
|
+
InternalError,
|
|
4
|
+
UnprocessableError,
|
|
5
|
+
writeFailure,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/errors";
|
|
7
|
+
import {
|
|
8
|
+
type ConsumerRecoveryState,
|
|
9
|
+
ConsumerStatuses,
|
|
10
|
+
ConsumerTransitionError,
|
|
11
|
+
disableConsumer,
|
|
12
|
+
enableConsumer,
|
|
13
|
+
restartConsumer,
|
|
14
|
+
skipPoisonEvent,
|
|
15
|
+
} from "@cosmicdrift/kumiko-framework/pipeline";
|
|
16
|
+
import * as z from "zod";
|
|
17
|
+
import { JobErrors } from "../web/constants.ts";
|
|
18
|
+
|
|
19
|
+
const consumerTargetSchema = z.object({ name: z.string().min(1), instanceId: z.string().min(1) });
|
|
20
|
+
|
|
21
|
+
const CONSUMER_STATE_REASON = "event consumer state is global dispatcher state, not tenant data";
|
|
22
|
+
|
|
23
|
+
function rejected(code: string, i18nKey: string) {
|
|
24
|
+
return writeFailure(new UnprocessableError(code, { i18nKey }));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type ConsumerTransition = (
|
|
28
|
+
db: Parameters<typeof restartConsumer>[0],
|
|
29
|
+
name: string,
|
|
30
|
+
instanceId: string,
|
|
31
|
+
) => Promise<ConsumerRecoveryState & { readonly skippedEventId?: bigint | null }>;
|
|
32
|
+
|
|
33
|
+
function defineConsumerWrite(name: string, description: string, transition: ConsumerTransition) {
|
|
34
|
+
return defineWriteHandler({
|
|
35
|
+
name,
|
|
36
|
+
description,
|
|
37
|
+
schema: consumerTargetSchema,
|
|
38
|
+
access: { roles: ["SystemAdmin"] },
|
|
39
|
+
// Operator recovery for people only; skip drops an event for good.
|
|
40
|
+
agent: { expose: false },
|
|
41
|
+
handler: async (event, ctx) => {
|
|
42
|
+
if (!ctx.systemDb) {
|
|
43
|
+
throw new InternalError({
|
|
44
|
+
message: `jobs:write:${name} requires ctx.systemDb (feature must declare r.systemScope())`,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
const db = ctx.systemDb.unsafeRaw(CONSUMER_STATE_REASON);
|
|
48
|
+
try {
|
|
49
|
+
const next = await transition(db, event.payload.name, event.payload.instanceId);
|
|
50
|
+
if (next.skippedEventId === null) {
|
|
51
|
+
return rejected(JobErrors.consumerNothingToSkip, "jobs.errors.consumerNothingToSkip");
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
isSuccess: true,
|
|
55
|
+
data: {
|
|
56
|
+
name: next.name,
|
|
57
|
+
instanceId: next.instanceId,
|
|
58
|
+
status: next.status,
|
|
59
|
+
skippedEventId: next.skippedEventId?.toString() ?? null,
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
} catch (e) {
|
|
63
|
+
if (e instanceof ConsumerTransitionError) {
|
|
64
|
+
return e.rejection === "unknown-consumer"
|
|
65
|
+
? rejected(JobErrors.consumerUnknown, "jobs.errors.consumerUnknown")
|
|
66
|
+
: rejected(JobErrors.consumerWrongStatus, "jobs.errors.consumerWrongStatus");
|
|
67
|
+
}
|
|
68
|
+
ctx.log?.error(`jobs:write:${name} failed: ${e instanceof Error ? e.message : String(e)}`, {
|
|
69
|
+
consumer: event.payload.name,
|
|
70
|
+
instanceId: event.payload.instanceId,
|
|
71
|
+
});
|
|
72
|
+
return rejected(JobErrors.consumerTransitionFailed, "jobs.errors.consumerTransitionFailed");
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const consumerRestartWrite = defineConsumerWrite(
|
|
79
|
+
"consumer-restart",
|
|
80
|
+
"Sets a dead event consumer back to idle so it retries the event that stopped it; use it after a transient failure is fixed.",
|
|
81
|
+
restartConsumer,
|
|
82
|
+
);
|
|
83
|
+
export const consumerSkipWrite = defineConsumerWrite(
|
|
84
|
+
"consumer-skip",
|
|
85
|
+
"Moves a stuck event consumer past the event that keeps failing; that event is never delivered to this consumer, so use it only for events that can never succeed.",
|
|
86
|
+
(db, consumerName, instanceId) =>
|
|
87
|
+
skipPoisonEvent(db, consumerName, instanceId, { requireStatus: ConsumerStatuses.dead }),
|
|
88
|
+
);
|
|
89
|
+
export const consumerDisableWrite = defineConsumerWrite(
|
|
90
|
+
"consumer-disable",
|
|
91
|
+
"Pauses an event consumer; the dispatcher skips it until it is enabled again.",
|
|
92
|
+
disableConsumer,
|
|
93
|
+
);
|
|
94
|
+
export const consumerEnableWrite = defineConsumerWrite(
|
|
95
|
+
"consumer-enable",
|
|
96
|
+
"Resumes a paused event consumer from where it stopped.",
|
|
97
|
+
enableConsumer,
|
|
98
|
+
);
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { defineQueryHandler, type Registry } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
|
+
import {
|
|
4
|
+
type ConsumerProgress,
|
|
5
|
+
getAllConsumerProgress,
|
|
6
|
+
getConsumerState,
|
|
7
|
+
SEARCH_CONSUMER_NAME,
|
|
8
|
+
SSE_BROADCAST_CONSUMER_NAME,
|
|
9
|
+
} from "@cosmicdrift/kumiko-framework/pipeline";
|
|
10
|
+
import * as z from "zod";
|
|
11
|
+
import {
|
|
12
|
+
CONSUMER_ID_SEPARATOR,
|
|
13
|
+
CONSUMER_STATUSES,
|
|
14
|
+
type ConsumerStatus,
|
|
15
|
+
isConsumerStatus,
|
|
16
|
+
} from "../web/constants.ts";
|
|
17
|
+
|
|
18
|
+
const LAST_ERROR_PREVIEW_LENGTH = 90;
|
|
19
|
+
|
|
20
|
+
// Dead consumers are what support looks for, so they lead the default order.
|
|
21
|
+
const STATUS_RANK: Readonly<Record<ConsumerStatus, number>> = {
|
|
22
|
+
dead: 0,
|
|
23
|
+
disabled: 1,
|
|
24
|
+
processing: 2,
|
|
25
|
+
"never-run": 3,
|
|
26
|
+
idle: 4,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function consumerRowId(name: string, instanceId: string): string {
|
|
30
|
+
return `${name}${CONSUMER_ID_SEPARATOR}${instanceId}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function splitConsumerRowId(id: string): { name: string; instanceId: string } | null {
|
|
34
|
+
const at = id.lastIndexOf(CONSUMER_ID_SEPARATOR);
|
|
35
|
+
if (at <= 0) return null;
|
|
36
|
+
return { name: id.slice(0, at), instanceId: id.slice(at + 1) };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function registeredConsumerNames(registry: Registry): readonly string[] {
|
|
40
|
+
return [
|
|
41
|
+
SSE_BROADCAST_CONSUMER_NAME,
|
|
42
|
+
SEARCH_CONSUMER_NAME,
|
|
43
|
+
...registry.getAllMultiStreamProjections().keys(),
|
|
44
|
+
];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function previewOf(error: string | null): string | null {
|
|
48
|
+
if (error === null || error.length <= LAST_ERROR_PREVIEW_LENGTH) return error;
|
|
49
|
+
return `${error.slice(0, LAST_ERROR_PREVIEW_LENGTH).trimEnd()}…`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function toRow(c: ConsumerProgress) {
|
|
53
|
+
return {
|
|
54
|
+
id: consumerRowId(c.name, c.instanceId),
|
|
55
|
+
name: c.name,
|
|
56
|
+
instanceId: c.instanceId,
|
|
57
|
+
status: c.status,
|
|
58
|
+
lag: Number(c.lag),
|
|
59
|
+
attempts: c.attempts,
|
|
60
|
+
lastError: previewOf(c.lastError),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const statusFilterSchema = z.strictObject({
|
|
65
|
+
field: z.literal("status"),
|
|
66
|
+
op: z.literal("in"),
|
|
67
|
+
value: z.array(z.enum(CONSUMER_STATUSES)),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
export const consumersQuery = defineQueryHandler({
|
|
71
|
+
name: "consumers",
|
|
72
|
+
description:
|
|
73
|
+
"Lists every event consumer (projections, search indexer, SSE broadcaster) with status, lag behind the event store and last error, dead consumers first; use it to find a stuck consumer.",
|
|
74
|
+
schema: z.object({
|
|
75
|
+
filters: z.array(statusFilterSchema).max(1).optional(),
|
|
76
|
+
sort: z.enum(["name", "status", "lag", "attempts"]).optional(),
|
|
77
|
+
sortDirection: z.enum(["asc", "desc"]).optional(),
|
|
78
|
+
}),
|
|
79
|
+
access: { roles: ["SystemAdmin"] },
|
|
80
|
+
handler: async (query, ctx) => {
|
|
81
|
+
if (!ctx.systemDb) {
|
|
82
|
+
throw new InternalError({
|
|
83
|
+
message:
|
|
84
|
+
"jobs:query:consumers requires ctx.systemDb (feature must declare r.systemScope())",
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
const db = ctx.systemDb.unsafeRaw(
|
|
88
|
+
"event consumer state is global dispatcher state, not tenant data",
|
|
89
|
+
);
|
|
90
|
+
const progress = await getAllConsumerProgress(db, registeredConsumerNames(ctx.registry));
|
|
91
|
+
const statuses = query.payload.filters?.[0]?.value;
|
|
92
|
+
const visible = statuses
|
|
93
|
+
? progress.filter((c) => statuses.some((s) => s === c.status))
|
|
94
|
+
: progress;
|
|
95
|
+
const direction = query.payload.sortDirection === "desc" ? -1 : 1;
|
|
96
|
+
const sort = query.payload.sort;
|
|
97
|
+
const compare = (a: ConsumerProgress, b: ConsumerProgress): number => {
|
|
98
|
+
if (sort === "name") return a.name.localeCompare(b.name) * direction;
|
|
99
|
+
if (sort === "lag") return Number(a.lag - b.lag) * direction;
|
|
100
|
+
if (sort === "attempts") return (a.attempts - b.attempts) * direction;
|
|
101
|
+
const rank = (c: ConsumerProgress) =>
|
|
102
|
+
isConsumerStatus(c.status) ? STATUS_RANK[c.status] : 5;
|
|
103
|
+
return (rank(a) - rank(b)) * direction || a.name.localeCompare(b.name);
|
|
104
|
+
};
|
|
105
|
+
return { rows: [...visible].sort(compare).map(toRow), nextCursor: null };
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
export const consumerDetailQuery = defineQueryHandler({
|
|
110
|
+
name: "consumer-details",
|
|
111
|
+
description:
|
|
112
|
+
"Returns one event consumer with its status, lag, attempts, the full last error and the time of its last state change; use it to decide between restart, skip and pause.",
|
|
113
|
+
schema: z.object({ consumerId: z.string().min(1) }),
|
|
114
|
+
access: { roles: ["SystemAdmin"] },
|
|
115
|
+
handler: async (query, ctx) => {
|
|
116
|
+
if (!ctx.systemDb) {
|
|
117
|
+
throw new InternalError({
|
|
118
|
+
message:
|
|
119
|
+
"jobs:query:consumer-details requires ctx.systemDb (feature must declare r.systemScope())",
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
const target = splitConsumerRowId(query.payload.consumerId);
|
|
123
|
+
if (!target) return null;
|
|
124
|
+
const db = ctx.systemDb.unsafeRaw(
|
|
125
|
+
"event consumer state is global dispatcher state, not tenant data",
|
|
126
|
+
);
|
|
127
|
+
const all = await getAllConsumerProgress(db, registeredConsumerNames(ctx.registry));
|
|
128
|
+
const progress = all.find((c) => c.name === target.name && c.instanceId === target.instanceId);
|
|
129
|
+
if (!progress) return null;
|
|
130
|
+
const state = await getConsumerState(db, target.name, target.instanceId);
|
|
131
|
+
return {
|
|
132
|
+
...toRow(progress),
|
|
133
|
+
lastError: progress.lastError,
|
|
134
|
+
updatedAt: state ? state.updatedAt.toString() : null,
|
|
135
|
+
};
|
|
136
|
+
},
|
|
137
|
+
});
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ActionFormScreenDefinition,
|
|
3
|
+
i18nKey,
|
|
4
|
+
type ProjectionDetailScreenDefinition,
|
|
5
|
+
type ProjectionListScreenDefinition,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
import {
|
|
8
|
+
CONSUMER_INSTANCE_OPTION_KEY_PREFIX,
|
|
9
|
+
CONSUMER_STATUS_OPTION_KEY_PREFIX,
|
|
10
|
+
CONSUMER_STATUSES,
|
|
11
|
+
EVENT_CONSUMER_DETAIL_SCREEN_ID,
|
|
12
|
+
EVENT_CONSUMERS_SCREEN_ID,
|
|
13
|
+
JOB_RUN_DETAIL_SCREEN_ID,
|
|
14
|
+
JOB_RUNS_SCREEN_ID,
|
|
15
|
+
JobHandlers,
|
|
16
|
+
JobQueries,
|
|
17
|
+
} from "./web/constants.ts";
|
|
18
|
+
|
|
19
|
+
const systemAdminAccess = { roles: ["SystemAdmin"] as const };
|
|
20
|
+
|
|
21
|
+
export const jobRunsScreen: ProjectionListScreenDefinition = {
|
|
22
|
+
id: JOB_RUNS_SCREEN_ID,
|
|
23
|
+
type: "projectionList",
|
|
24
|
+
query: JobQueries.list,
|
|
25
|
+
columns: [
|
|
26
|
+
{ field: "jobName", label: i18nKey("jobs.runs.col.job") },
|
|
27
|
+
{ field: "status", label: i18nKey("jobs.runs.col.status") },
|
|
28
|
+
{
|
|
29
|
+
field: "startedAt",
|
|
30
|
+
label: i18nKey("jobs.runs.col.started"),
|
|
31
|
+
renderer: { format: "timestamp" },
|
|
32
|
+
},
|
|
33
|
+
{ field: "duration", label: i18nKey("jobs.runs.col.duration") },
|
|
34
|
+
],
|
|
35
|
+
defaultSort: { field: "startedAt", dir: "desc" },
|
|
36
|
+
facets: [
|
|
37
|
+
{
|
|
38
|
+
field: "status",
|
|
39
|
+
type: "select",
|
|
40
|
+
label: i18nKey("jobs.runs.filter.status"),
|
|
41
|
+
options: [
|
|
42
|
+
{ value: "queued", label: i18nKey("jobs.runs.filter.queued") },
|
|
43
|
+
{ value: "running", label: i18nKey("jobs.runs.filter.running") },
|
|
44
|
+
{ value: "completed", label: i18nKey("jobs.runs.filter.completed") },
|
|
45
|
+
{ value: "failed", label: i18nKey("jobs.runs.filter.failed") },
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
rowActions: [
|
|
50
|
+
{
|
|
51
|
+
kind: "navigate",
|
|
52
|
+
id: "open",
|
|
53
|
+
label: i18nKey("jobs.runs.open"),
|
|
54
|
+
screen: JOB_RUN_DETAIL_SCREEN_ID,
|
|
55
|
+
entityId: "id",
|
|
56
|
+
rowClick: true,
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
toolbarActions: [
|
|
60
|
+
{
|
|
61
|
+
kind: "drawer",
|
|
62
|
+
id: "trigger",
|
|
63
|
+
label: i18nKey("jobs.trigger.title"),
|
|
64
|
+
screen: "job-trigger",
|
|
65
|
+
style: "primary",
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
pagination: "infinite",
|
|
69
|
+
access: systemAdminAccess,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export const jobTriggerScreen: ActionFormScreenDefinition = {
|
|
73
|
+
id: "job-trigger",
|
|
74
|
+
type: "actionForm",
|
|
75
|
+
handler: JobHandlers.trigger,
|
|
76
|
+
fields: {
|
|
77
|
+
jobName: { type: "text", required: true },
|
|
78
|
+
payload: { type: "longText", multiline: true, default: "{}" },
|
|
79
|
+
},
|
|
80
|
+
layout: { sections: [{ fields: ["jobName", "payload"] }] },
|
|
81
|
+
submitLabel: i18nKey("jobs.trigger.submit"),
|
|
82
|
+
access: systemAdminAccess,
|
|
83
|
+
description: "jobs.screen.job-trigger.subtitle",
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export const jobRunDetailScreen: ProjectionDetailScreenDefinition = {
|
|
87
|
+
id: JOB_RUN_DETAIL_SCREEN_ID,
|
|
88
|
+
type: "projectionDetail",
|
|
89
|
+
query: JobQueries.details,
|
|
90
|
+
idParam: "runId",
|
|
91
|
+
fieldLabels: {
|
|
92
|
+
jobName: i18nKey("jobs.detail.field.job"),
|
|
93
|
+
status: i18nKey("jobs.detail.field.status"),
|
|
94
|
+
id: i18nKey("jobs.detail.field.id"),
|
|
95
|
+
startedAt: i18nKey("jobs.detail.field.started"),
|
|
96
|
+
finishedAt: i18nKey("jobs.detail.field.finished"),
|
|
97
|
+
duration: i18nKey("jobs.detail.field.duration"),
|
|
98
|
+
error: i18nKey("jobs.detail.field.error"),
|
|
99
|
+
logs: i18nKey("jobs.detail.logs"),
|
|
100
|
+
},
|
|
101
|
+
layout: {
|
|
102
|
+
sections: [
|
|
103
|
+
{
|
|
104
|
+
fields: [
|
|
105
|
+
"jobName",
|
|
106
|
+
"status",
|
|
107
|
+
"id",
|
|
108
|
+
{ field: "startedAt", renderer: { format: "timestamp" } },
|
|
109
|
+
{ field: "finishedAt", renderer: { format: "timestamp" } },
|
|
110
|
+
"duration",
|
|
111
|
+
"error",
|
|
112
|
+
],
|
|
113
|
+
},
|
|
114
|
+
// No section `title` here (fw#2312 label-dedup fix): the section
|
|
115
|
+
// holds exactly one field, so a title would repeat the field's own
|
|
116
|
+
// label (rendered by RenderField as the Field's heading) verbatim.
|
|
117
|
+
{
|
|
118
|
+
fields: [{ field: "logs", renderer: { format: "json" } }],
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
},
|
|
122
|
+
actions: [
|
|
123
|
+
{
|
|
124
|
+
kind: "writeHandler",
|
|
125
|
+
id: "retry",
|
|
126
|
+
label: i18nKey("jobs.detail.retry"),
|
|
127
|
+
handler: JobHandlers.retry,
|
|
128
|
+
payload: { map: { runId: "id" } },
|
|
129
|
+
visible: { field: "status", eq: "failed" },
|
|
130
|
+
style: "primary",
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
description: "jobs.screen.job-run-detail.subtitle",
|
|
134
|
+
listScreenId: JOB_RUNS_SCREEN_ID,
|
|
135
|
+
access: systemAdminAccess,
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
export const eventConsumersScreen: ProjectionListScreenDefinition = {
|
|
139
|
+
id: EVENT_CONSUMERS_SCREEN_ID,
|
|
140
|
+
type: "projectionList",
|
|
141
|
+
query: JobQueries.consumers,
|
|
142
|
+
columns: [
|
|
143
|
+
{ field: "name", label: i18nKey("jobs.consumers.col.name") },
|
|
144
|
+
{
|
|
145
|
+
field: "status",
|
|
146
|
+
label: i18nKey("jobs.consumers.col.status"),
|
|
147
|
+
renderer: { format: "enumOption", keyPrefix: CONSUMER_STATUS_OPTION_KEY_PREFIX },
|
|
148
|
+
},
|
|
149
|
+
{ field: "lag", label: i18nKey("jobs.consumers.col.lag"), hideOnNarrow: true },
|
|
150
|
+
{ field: "attempts", label: i18nKey("jobs.consumers.col.attempts"), hideOnNarrow: true },
|
|
151
|
+
{ field: "lastError", label: i18nKey("jobs.consumers.col.lastError") },
|
|
152
|
+
],
|
|
153
|
+
defaultSort: { field: "status", dir: "asc" },
|
|
154
|
+
facets: [
|
|
155
|
+
{
|
|
156
|
+
field: "status",
|
|
157
|
+
type: "select",
|
|
158
|
+
label: i18nKey("jobs.consumers.col.status"),
|
|
159
|
+
options: CONSUMER_STATUSES.map((status) => ({
|
|
160
|
+
value: status,
|
|
161
|
+
label: i18nKey(`${CONSUMER_STATUS_OPTION_KEY_PREFIX}${status}`),
|
|
162
|
+
})),
|
|
163
|
+
},
|
|
164
|
+
],
|
|
165
|
+
rowActions: [
|
|
166
|
+
{
|
|
167
|
+
kind: "navigate",
|
|
168
|
+
id: "open",
|
|
169
|
+
label: i18nKey("jobs.runs.open"),
|
|
170
|
+
screen: EVENT_CONSUMER_DETAIL_SCREEN_ID,
|
|
171
|
+
entityId: "id",
|
|
172
|
+
rowClick: true,
|
|
173
|
+
},
|
|
174
|
+
],
|
|
175
|
+
pagination: "infinite",
|
|
176
|
+
access: systemAdminAccess,
|
|
177
|
+
description: "jobs.screen.event-consumers.subtitle",
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
export const eventConsumerDetailScreen: ProjectionDetailScreenDefinition = {
|
|
181
|
+
id: EVENT_CONSUMER_DETAIL_SCREEN_ID,
|
|
182
|
+
type: "projectionDetail",
|
|
183
|
+
query: JobQueries.consumerDetails,
|
|
184
|
+
idParam: "consumerId",
|
|
185
|
+
fieldLabels: {
|
|
186
|
+
name: i18nKey("jobs.consumers.col.name"),
|
|
187
|
+
instanceId: i18nKey("jobs.consumers.field.instanceId"),
|
|
188
|
+
status: i18nKey("jobs.consumers.col.status"),
|
|
189
|
+
lag: i18nKey("jobs.consumers.col.lag"),
|
|
190
|
+
attempts: i18nKey("jobs.consumers.col.attempts"),
|
|
191
|
+
lastError: i18nKey("jobs.consumers.col.lastError"),
|
|
192
|
+
updatedAt: i18nKey("jobs.consumers.field.updatedAt"),
|
|
193
|
+
},
|
|
194
|
+
layout: {
|
|
195
|
+
sections: [
|
|
196
|
+
{
|
|
197
|
+
fields: [
|
|
198
|
+
"name",
|
|
199
|
+
{
|
|
200
|
+
field: "instanceId",
|
|
201
|
+
renderer: { format: "enumOption", keyPrefix: CONSUMER_INSTANCE_OPTION_KEY_PREFIX },
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
field: "status",
|
|
205
|
+
renderer: { format: "enumOption", keyPrefix: CONSUMER_STATUS_OPTION_KEY_PREFIX },
|
|
206
|
+
},
|
|
207
|
+
"lag",
|
|
208
|
+
"attempts",
|
|
209
|
+
{ field: "updatedAt", renderer: { format: "timestamp" } },
|
|
210
|
+
],
|
|
211
|
+
},
|
|
212
|
+
{ fields: ["lastError"] },
|
|
213
|
+
],
|
|
214
|
+
},
|
|
215
|
+
actions: [
|
|
216
|
+
{
|
|
217
|
+
kind: "writeHandler",
|
|
218
|
+
id: "restart",
|
|
219
|
+
icon: "refresh",
|
|
220
|
+
label: i18nKey("jobs.consumers.restart"),
|
|
221
|
+
handler: JobHandlers.consumerRestart,
|
|
222
|
+
payload: { map: { name: "name", instanceId: "instanceId" } },
|
|
223
|
+
visible: { field: "status", eq: "dead" },
|
|
224
|
+
style: "primary",
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
kind: "writeHandler",
|
|
228
|
+
id: "skip",
|
|
229
|
+
icon: "arrow-right",
|
|
230
|
+
label: i18nKey("jobs.consumers.skip"),
|
|
231
|
+
handler: JobHandlers.consumerSkip,
|
|
232
|
+
payload: { map: { name: "name", instanceId: "instanceId" } },
|
|
233
|
+
visible: { field: "status", eq: "dead" },
|
|
234
|
+
confirm: i18nKey("jobs.consumers.skip.confirm"),
|
|
235
|
+
style: "danger",
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
kind: "writeHandler",
|
|
239
|
+
id: "disable",
|
|
240
|
+
icon: "x",
|
|
241
|
+
label: i18nKey("jobs.consumers.disable"),
|
|
242
|
+
handler: JobHandlers.consumerDisable,
|
|
243
|
+
payload: { map: { name: "name", instanceId: "instanceId" } },
|
|
244
|
+
visible: { field: "status", ne: "disabled" },
|
|
245
|
+
style: "secondary",
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
kind: "writeHandler",
|
|
249
|
+
id: "enable",
|
|
250
|
+
icon: "check",
|
|
251
|
+
label: i18nKey("jobs.consumers.enable"),
|
|
252
|
+
handler: JobHandlers.consumerEnable,
|
|
253
|
+
payload: { map: { name: "name", instanceId: "instanceId" } },
|
|
254
|
+
visible: { field: "status", eq: "disabled" },
|
|
255
|
+
style: "primary",
|
|
256
|
+
},
|
|
257
|
+
],
|
|
258
|
+
primaryAction: "restart",
|
|
259
|
+
description: "jobs.screen.event-consumer-detail.subtitle",
|
|
260
|
+
listScreenId: EVENT_CONSUMERS_SCREEN_ID,
|
|
261
|
+
access: systemAdminAccess,
|
|
262
|
+
};
|
|
@@ -5,6 +5,10 @@ export const JOBS_FEATURE = "jobs" as const;
|
|
|
5
5
|
export const JobHandlers = {
|
|
6
6
|
trigger: "jobs:write:trigger",
|
|
7
7
|
retry: "jobs:write:retry",
|
|
8
|
+
consumerRestart: "jobs:write:consumer-restart",
|
|
9
|
+
consumerSkip: "jobs:write:consumer-skip",
|
|
10
|
+
consumerDisable: "jobs:write:consumer-disable",
|
|
11
|
+
consumerEnable: "jobs:write:consumer-enable",
|
|
8
12
|
} as const;
|
|
9
13
|
|
|
10
14
|
// Qualified query handler names (QN format: scope:type:name)
|
|
@@ -14,6 +18,8 @@ export const JobQueries = {
|
|
|
14
18
|
catalog: "jobs:query:catalog",
|
|
15
19
|
failures: "jobs:query:failures",
|
|
16
20
|
tenantRuns: "jobs:query:tenant-runs",
|
|
21
|
+
consumers: "jobs:query:consumers",
|
|
22
|
+
consumerDetails: "jobs:query:consumer-details",
|
|
17
23
|
} as const;
|
|
18
24
|
|
|
19
25
|
// Error codes
|
|
@@ -24,6 +30,10 @@ export const JobErrors = {
|
|
|
24
30
|
notManual: "job_not_manual",
|
|
25
31
|
invalidPayload: "job_invalid_payload",
|
|
26
32
|
payloadErased: "job_payload_erased",
|
|
33
|
+
consumerUnknown: "consumer_unknown",
|
|
34
|
+
consumerWrongStatus: "consumer_wrong_status",
|
|
35
|
+
consumerNothingToSkip: "consumer_nothing_to_skip",
|
|
36
|
+
consumerTransitionFailed: "consumer_transition_failed",
|
|
27
37
|
} as const;
|
|
28
38
|
|
|
29
39
|
/** SystemAdmin job-runs list. Nav: `jobs:screen:job-runs`. */
|
|
@@ -31,3 +41,22 @@ export const JOB_RUNS_SCREEN_ID = "job-runs" as const;
|
|
|
31
41
|
|
|
32
42
|
/** Run detail — route entityId is the run uuid. Nav: `jobs:screen:job-run-detail`. */
|
|
33
43
|
export const JOB_RUN_DETAIL_SCREEN_ID = "job-run-detail" as const;
|
|
44
|
+
|
|
45
|
+
/** SystemAdmin event-consumer list. Nav: `jobs:screen:event-consumers`. */
|
|
46
|
+
export const EVENT_CONSUMERS_SCREEN_ID = "event-consumers" as const;
|
|
47
|
+
|
|
48
|
+
/** Consumer detail — route entityId is `<name>@<instanceId>`. */
|
|
49
|
+
export const EVENT_CONSUMER_DETAIL_SCREEN_ID = "event-consumer-detail" as const;
|
|
50
|
+
|
|
51
|
+
export const CONSUMER_ID_SEPARATOR = "@" as const;
|
|
52
|
+
|
|
53
|
+
export const CONSUMER_STATUSES = ["idle", "processing", "dead", "disabled", "never-run"] as const;
|
|
54
|
+
export type ConsumerStatus = (typeof CONSUMER_STATUSES)[number];
|
|
55
|
+
|
|
56
|
+
export function isConsumerStatus(value: string): value is ConsumerStatus {
|
|
57
|
+
return CONSUMER_STATUSES.some((status) => status === value);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const CONSUMER_STATUS_OPTION_KEY_PREFIX = "jobs.consumers.status.";
|
|
61
|
+
|
|
62
|
+
export const CONSUMER_INSTANCE_OPTION_KEY_PREFIX = "jobs.consumers.instance.";
|
|
@@ -15,6 +15,47 @@ export const JOBS_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
|
15
15
|
"jobs:entity:__action-form__:field:jobName": { en: "Job name" },
|
|
16
16
|
"jobs:entity:__action-form__:field:payload": { en: "JSON payload" },
|
|
17
17
|
"jobs:nav.jobRuns": { en: "Jobs" },
|
|
18
|
+
"screen:event-consumers.title": { en: "Event consumers" },
|
|
19
|
+
"screen:event-consumer-detail.title": { en: "Event consumer" },
|
|
20
|
+
"jobs:nav.eventConsumers": { en: "Event consumers" },
|
|
21
|
+
"jobs.screen.event-consumers.subtitle": {
|
|
22
|
+
en: "Background readers that process events after they are saved; find stuck ones and get them moving again.",
|
|
23
|
+
},
|
|
24
|
+
"jobs.screen.event-consumer-detail.subtitle": {
|
|
25
|
+
en: "State and last error of one event consumer, with the actions to restart, skip or pause it.",
|
|
26
|
+
},
|
|
27
|
+
"jobs.consumers.col.name": { en: "Consumer" },
|
|
28
|
+
"jobs.consumers.col.status": { en: "Status" },
|
|
29
|
+
"jobs.consumers.col.lag": { en: "Events behind" },
|
|
30
|
+
"jobs.consumers.col.attempts": { en: "Attempts" },
|
|
31
|
+
"jobs.consumers.col.lastError": { en: "Last error" },
|
|
32
|
+
"jobs.consumers.field.instanceId": { en: "Instance" },
|
|
33
|
+
"jobs.consumers.field.updatedAt": { en: "Last change" },
|
|
34
|
+
"jobs.consumers.status.idle": { en: "Up to date" },
|
|
35
|
+
"jobs.consumers.status.processing": { en: "Running" },
|
|
36
|
+
"jobs.consumers.status.dead": { en: "Stopped by error" },
|
|
37
|
+
"jobs.consumers.status.disabled": { en: "Paused" },
|
|
38
|
+
"jobs.consumers.status.never-run": { en: "Not started yet" },
|
|
39
|
+
"jobs.consumers.instance.__shared__": { en: "Shared" },
|
|
40
|
+
"jobs.consumers.restart": { en: "Restart" },
|
|
41
|
+
"jobs.consumers.skip": { en: "Skip event" },
|
|
42
|
+
"jobs.consumers.skip.confirm": {
|
|
43
|
+
en: "The event that keeps failing is skipped and never delivered to this consumer, so its data may stay incomplete. Skip only if the event can never succeed.",
|
|
44
|
+
},
|
|
45
|
+
"jobs.consumers.disable": { en: "Pause" },
|
|
46
|
+
"jobs.consumers.enable": { en: "Resume" },
|
|
47
|
+
"jobs.errors.consumerUnknown": {
|
|
48
|
+
en: "This consumer has not run yet or does not exist.",
|
|
49
|
+
},
|
|
50
|
+
"jobs.errors.consumerWrongStatus": {
|
|
51
|
+
en: "The consumer is not in a state where this action applies. Reload and check its status.",
|
|
52
|
+
},
|
|
53
|
+
"jobs.errors.consumerNothingToSkip": {
|
|
54
|
+
en: "There is no failing event to skip.",
|
|
55
|
+
},
|
|
56
|
+
"jobs.errors.consumerTransitionFailed": {
|
|
57
|
+
en: "The consumer could not be changed. Try again; if it keeps failing, check the server log.",
|
|
58
|
+
},
|
|
18
59
|
"jobs.runs.open": { en: "Details" },
|
|
19
60
|
"jobs.runs.filter.status": { en: "Status" },
|
|
20
61
|
"jobs.runs.filter.completed": { en: "Completed" },
|
|
@@ -3,14 +3,11 @@ import { SECRETS_ERROR_KEYS } from "./web/constants.ts";
|
|
|
3
3
|
export const SECRETS_I18N = {
|
|
4
4
|
[SECRETS_ERROR_KEYS.unknownKey]: {
|
|
5
5
|
en: "Unknown secret key.",
|
|
6
|
-
de: "Unbekannter Geheimnis-Schlüssel.",
|
|
7
6
|
},
|
|
8
7
|
[SECRETS_ERROR_KEYS.writeDenied]: {
|
|
9
8
|
en: "You are not allowed to change this secret.",
|
|
10
|
-
de: "Dieses Geheimnis darf nicht geändert werden.",
|
|
11
9
|
},
|
|
12
10
|
[SECRETS_ERROR_KEYS.invalidValue]: {
|
|
13
11
|
en: "This value is not valid for this secret.",
|
|
14
|
-
de: "Dieser Wert ist für dieses Geheimnis nicht gültig.",
|
|
15
12
|
},
|
|
16
13
|
} as const;
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.384.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Session lists show the device",
|
|
6
|
+
"detail": "The new subpath @cosmicdrift/kumiko-bundled-features/sessions/web exports sessionsClient(). Apps must add sessionsClient() to their client features to get the device column (browser and OS parsed from the user agent) and the translated \"This session\" cell; without it the screens fall back to the raw columns. The IP display is unchanged."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.383.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
|
|
1
|
+
import { SESSIONS_FEATURE } from "./web/constants.ts";
|
|
2
|
+
|
|
3
|
+
export { SESSIONS_FEATURE };
|
|
2
4
|
export const USER_SESSION_ENTITY_NAME = "user-session" as const;
|
|
3
5
|
|
|
4
6
|
// Qualified write handler names — entity prefix is "userSession", snake_case
|
|
@@ -3,6 +3,7 @@ import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine
|
|
|
3
3
|
import * as z from "zod";
|
|
4
4
|
import { decryptStoredPii } from "../../shared/index.ts";
|
|
5
5
|
import { userSessionTable } from "../schema/user-session.ts";
|
|
6
|
+
import { parsedDeviceSchema, parseUserAgent } from "../web/user-agent.ts";
|
|
6
7
|
|
|
7
8
|
// Admin single-session inspector — mirrors list.query's decrypt handling for
|
|
8
9
|
// the one-row case. ctx.db (TenantDb) applies tenant-scoping automatically.
|
|
@@ -14,6 +15,7 @@ const sessionRowSchema = z.object({
|
|
|
14
15
|
revokedAt: z.unknown(),
|
|
15
16
|
ip: z.string().nullable(),
|
|
16
17
|
userAgent: z.string().nullable(),
|
|
18
|
+
device: parsedDeviceSchema,
|
|
17
19
|
});
|
|
18
20
|
|
|
19
21
|
export const createDetailQuery = (adminRoles: readonly string[] = access.admin) =>
|
|
@@ -37,6 +39,9 @@ export const createDetailQuery = (adminRoles: readonly string[] = access.admin)
|
|
|
37
39
|
userAgent: string | null;
|
|
38
40
|
}>(ctx.db, userSessionTable, { id: query.payload.id });
|
|
39
41
|
if (!row) return null;
|
|
42
|
+
const userAgent = row.userAgent
|
|
43
|
+
? await decryptStoredPii(row.userAgent, "userAgent", "sessions:detail")
|
|
44
|
+
: row.userAgent;
|
|
40
45
|
return {
|
|
41
46
|
id: row.id,
|
|
42
47
|
userId: row.userId,
|
|
@@ -44,9 +49,8 @@ export const createDetailQuery = (adminRoles: readonly string[] = access.admin)
|
|
|
44
49
|
expiresAt: row.expiresAt,
|
|
45
50
|
revokedAt: row.revokedAt,
|
|
46
51
|
ip: row.ip ? await decryptStoredPii(row.ip, "ip", "sessions:detail") : row.ip,
|
|
47
|
-
userAgent
|
|
48
|
-
|
|
49
|
-
: row.userAgent,
|
|
52
|
+
userAgent,
|
|
53
|
+
device: parseUserAgent(userAgent),
|
|
50
54
|
};
|
|
51
55
|
},
|
|
52
56
|
});
|