@cosmicdrift/kumiko-bundled-features 0.240.0 → 0.241.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 +9 -9
- package/src/admin-shell/__tests__/admin-shell-security.integration.test.ts +16 -7
- package/src/admin-shell/__tests__/overview-allowlist.test.ts +30 -0
- package/src/admin-shell/__tests__/overview-screens.boot.test.ts +31 -23
- package/src/admin-shell/changes.json +6 -0
- package/src/admin-shell/feature.ts +60 -6
- package/src/admin-shell/i18n.ts +0 -7
- package/src/admin-shell/web/client-plugin.tsx +1 -11
- package/src/audit/__tests__/audit-screens.boot.test.ts +26 -6
- package/src/audit/changes.json +7 -0
- package/src/audit/feature.ts +57 -7
- package/src/audit/handlers/list.query.ts +50 -14
- package/src/audit/i18n.ts +3 -18
- package/src/audit/web/client-plugin.tsx +1 -7
- package/src/audit/web/index.ts +0 -2
- package/src/config/__tests__/config.integration.test.ts +31 -0
- package/src/config/handlers/readiness.query.ts +8 -1
- package/src/jobs/__tests__/jobs-screens.boot.test.ts +7 -2
- package/src/jobs/__tests__/jobs-security.integration.test.ts +41 -2
- package/src/jobs/changes.json +7 -0
- package/src/jobs/feature.ts +101 -9
- package/src/jobs/handlers/list.query.ts +21 -4
- package/src/jobs/handlers/trigger.write.ts +16 -3
- package/src/jobs/i18n.ts +3 -25
- package/src/jobs/index.ts +1 -0
- package/src/jobs/web/client-plugin.tsx +1 -7
- package/src/jobs/web/index.ts +0 -2
- package/src/tenant/__tests__/tenant.integration.test.ts +32 -0
- package/src/tenant/handlers/list.query.ts +1 -0
- package/src/tier-engine/__tests__/drift.test.ts +1 -1
- package/src/tier-engine/__tests__/tier-engine-screens.boot.test.ts +82 -0
- package/src/tier-engine/changes.json +9 -1
- package/src/tier-engine/constants.ts +5 -5
- package/src/tier-engine/feature.ts +36 -10
- package/src/tier-engine/i18n.ts +19 -22
- package/src/tier-engine/web/client-plugin.tsx +5 -11
- package/src/tier-engine/web/index.ts +0 -1
- package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +99 -0
- package/src/user-data-rights/changes.json +7 -0
- package/src/user-data-rights/constants.ts +20 -6
- package/src/user-data-rights/feature.ts +113 -14
- package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +29 -71
- package/src/user-data-rights/web/client-plugin.tsx +19 -21
- package/src/user-data-rights/web/i18n.ts +6 -11
- package/src/user-data-rights/web/index.ts +1 -1
- package/src/user-data-rights/web/privacy-center-screen.tsx +32 -245
- package/src/user-profile/__tests__/profile-screen.boot.test.ts +97 -0
- package/src/user-profile/__tests__/profile-screen.test.tsx +140 -104
- package/src/user-profile/changes.json +7 -0
- package/src/user-profile/constants.ts +15 -7
- package/src/user-profile/feature.ts +96 -9
- package/src/user-profile/i18n.ts +8 -12
- package/src/user-profile/web/client-plugin.ts +22 -5
- package/src/user-profile/web/index.ts +1 -1
- package/src/user-profile/web/profile-screen.tsx +122 -265
- package/src/admin-shell/web/overview-layout.tsx +0 -69
- package/src/admin-shell/web/overview-query.ts +0 -15
- package/src/admin-shell/web/platform-overview-screen.tsx +0 -91
- package/src/admin-shell/web/tenant-overview-screen.tsx +0 -91
- package/src/audit/__tests__/audit-log-screen.test.tsx +0 -302
- package/src/audit/web/audit-log-detail-screen.tsx +0 -169
- package/src/audit/web/audit-log-screen.tsx +0 -290
- package/src/jobs/web/__tests__/job-runs-screen.test.tsx +0 -225
- package/src/jobs/web/job-run-detail-screen.tsx +0 -181
- package/src/jobs/web/job-runs-screen.tsx +0 -290
- package/src/tier-engine/web/tier-admin-screen.tsx +0 -168
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
// @runtime client
|
|
2
2
|
import { mergeTranslations, type TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
3
3
|
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
|
|
4
|
-
import { AUDIT_FEATURE
|
|
5
|
-
import { AuditLogDetailScreen } from "./audit-log-detail-screen";
|
|
6
|
-
import { AuditLogScreen } from "./audit-log-screen";
|
|
4
|
+
import { AUDIT_FEATURE } from "../constants";
|
|
7
5
|
import { defaultTranslations } from "./i18n";
|
|
8
6
|
|
|
9
7
|
export type AuditClientOptions = {
|
|
@@ -14,9 +12,5 @@ export function auditClient(options?: AuditClientOptions): ClientFeatureDefiniti
|
|
|
14
12
|
return {
|
|
15
13
|
name: AUDIT_FEATURE,
|
|
16
14
|
translations: mergeTranslations(defaultTranslations, options?.translations ?? {}),
|
|
17
|
-
components: {
|
|
18
|
-
[AUDIT_LOG_SCREEN_ID]: AuditLogScreen,
|
|
19
|
-
[AUDIT_LOG_DETAIL_SCREEN_ID]: AuditLogDetailScreen,
|
|
20
|
-
},
|
|
21
15
|
};
|
|
22
16
|
}
|
package/src/audit/web/index.ts
CHANGED
|
@@ -939,6 +939,37 @@ describe("config.readiness query handler", () => {
|
|
|
939
939
|
expect(keys).not.toContain("orders:config:max-order-count");
|
|
940
940
|
});
|
|
941
941
|
|
|
942
|
+
type MissingWithSummary = Missing & { missingCount: number; missingTone: "default" | "warn" };
|
|
943
|
+
|
|
944
|
+
test("missingCount matches missing.length and missingTone is warn while something is missing (fw#2312)", async () => {
|
|
945
|
+
const result = await stack.http.queryOk<MissingWithSummary>(
|
|
946
|
+
ConfigQueries.readiness,
|
|
947
|
+
{},
|
|
948
|
+
readinessAdminFor(6),
|
|
949
|
+
);
|
|
950
|
+
|
|
951
|
+
expect(result.missingCount).toBe(result.missing.length);
|
|
952
|
+
expect(result.missingCount).toBeGreaterThan(0);
|
|
953
|
+
expect(result.missingTone).toBe("warn");
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
test("missingCount is 0 and missingTone is default once every required key is set (fw#2312)", async () => {
|
|
957
|
+
const admin = readinessAdminFor(7);
|
|
958
|
+
for (const [key, value] of [
|
|
959
|
+
["transport:config:smtp-host", "smtp.example.com"],
|
|
960
|
+
["transport:config:api-url", "https://api.example.com"],
|
|
961
|
+
["transport:config:webhook-url", "https://webhook.example.com"],
|
|
962
|
+
["transport:config:timeout", 30],
|
|
963
|
+
] as const) {
|
|
964
|
+
await stack.http.writeOk(ConfigHandlers.set, { key, value }, admin);
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
const result = await stack.http.queryOk<MissingWithSummary>(ConfigQueries.readiness, {}, admin);
|
|
968
|
+
expect(result.missing).toEqual([]);
|
|
969
|
+
expect(result.missingCount).toBe(0);
|
|
970
|
+
expect(result.missingTone).toBe("default");
|
|
971
|
+
});
|
|
972
|
+
|
|
942
973
|
test("whitespace-only text value still counts as missing (requireNonEmpty-Parität)", async () => {
|
|
943
974
|
const admin = readinessAdminFor(2);
|
|
944
975
|
await stack.http.writeOk(
|
|
@@ -140,6 +140,13 @@ export const readinessQuery = defineQueryHandler({
|
|
|
140
140
|
query.user,
|
|
141
141
|
db,
|
|
142
142
|
);
|
|
143
|
-
|
|
143
|
+
// Additive, display-ready fields for the admin-shell dashboard stat
|
|
144
|
+
// panel (fw#2312) — `missing` itself stays untouched for existing
|
|
145
|
+
// consumers that read the array directly.
|
|
146
|
+
return {
|
|
147
|
+
missing,
|
|
148
|
+
missingCount: missing.length,
|
|
149
|
+
missingTone: missing.length > 0 ? "warn" : "default",
|
|
150
|
+
};
|
|
144
151
|
},
|
|
145
152
|
});
|
|
@@ -16,9 +16,14 @@ describe("jobs screens + handler access alignment", () => {
|
|
|
16
16
|
|
|
17
17
|
test("job-runs screens are SystemAdmin-gated", () => {
|
|
18
18
|
const jobs = createJobsFeature();
|
|
19
|
-
|
|
19
|
+
const expectedTypeById = {
|
|
20
|
+
[JOB_RUNS_SCREEN_ID]: "projectionList",
|
|
21
|
+
[JOB_RUN_DETAIL_SCREEN_ID]: "projectionDetail",
|
|
22
|
+
"job-trigger": "actionForm",
|
|
23
|
+
} as const;
|
|
24
|
+
for (const id of Object.keys(expectedTypeById) as (keyof typeof expectedTypeById)[]) {
|
|
20
25
|
const screen = jobs.screens[id];
|
|
21
|
-
expect(screen?.type).toBe(
|
|
26
|
+
expect(screen?.type).toBe(expectedTypeById[id]);
|
|
22
27
|
if (screen && "access" in screen && screen.access && "roles" in screen.access) {
|
|
23
28
|
expect(screen.access.roles).toEqual(SYSTEM_ADMIN_ROLES);
|
|
24
29
|
}
|
|
@@ -2,7 +2,12 @@
|
|
|
2
2
|
// Real HTTP via setupTestStack — no mocks.
|
|
3
3
|
|
|
4
4
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
5
|
-
import {
|
|
5
|
+
import { sql } from "@cosmicdrift/kumiko-framework/db";
|
|
6
|
+
import {
|
|
7
|
+
defineFeature,
|
|
8
|
+
type SessionUser,
|
|
9
|
+
SYSTEM_TENANT_ID,
|
|
10
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
6
11
|
import {
|
|
7
12
|
createTestUser,
|
|
8
13
|
setupTestStack,
|
|
@@ -10,7 +15,7 @@ import {
|
|
|
10
15
|
TestUsers,
|
|
11
16
|
unsafePushTables,
|
|
12
17
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
13
|
-
import { rolesOf } from "@cosmicdrift/kumiko-framework/testing";
|
|
18
|
+
import { rolesOf, seedRow } from "@cosmicdrift/kumiko-framework/testing";
|
|
14
19
|
import { createConfigFeature } from "../../config/feature";
|
|
15
20
|
import {
|
|
16
21
|
JOB_RUN_DETAIL_SCREEN_ID,
|
|
@@ -108,3 +113,37 @@ describe("SystemAdmin can use jobs queries", () => {
|
|
|
108
113
|
expect(Array.isArray(res.rows)).toBe(true);
|
|
109
114
|
});
|
|
110
115
|
});
|
|
116
|
+
|
|
117
|
+
describe("jobs:query:list totalCount (fw#2312)", () => {
|
|
118
|
+
test("without totalCount the shape carries no total field", async () => {
|
|
119
|
+
const res = await stack.http.queryOk<{ rows: readonly unknown[]; total?: number }>(
|
|
120
|
+
JobQueries.list,
|
|
121
|
+
{ jobName: "jobs-sec-app:job:total-count-shape" },
|
|
122
|
+
systemAdmin,
|
|
123
|
+
);
|
|
124
|
+
expect(res.total).toBeUndefined();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("total reflects the full match count, not capped by limit", async () => {
|
|
128
|
+
const jobName = "jobs-sec-app:job:total-count-probe";
|
|
129
|
+
for (let i = 0; i < 6; i++) {
|
|
130
|
+
await seedRow(stack.db, jobRunsTable, {
|
|
131
|
+
id: crypto.randomUUID(),
|
|
132
|
+
tenantId: SYSTEM_TENANT_ID,
|
|
133
|
+
jobName,
|
|
134
|
+
bullJobId: `total-count-${i}`,
|
|
135
|
+
status: "completed",
|
|
136
|
+
attempt: 1,
|
|
137
|
+
startedAt: sql`now()`,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const res = await stack.http.queryOk<{ rows: readonly unknown[]; total?: number }>(
|
|
142
|
+
JobQueries.list,
|
|
143
|
+
{ jobName, limit: 2, totalCount: true },
|
|
144
|
+
systemAdmin,
|
|
145
|
+
);
|
|
146
|
+
expect(res.rows.length).toBe(2);
|
|
147
|
+
expect(res.total).toBe(6);
|
|
148
|
+
});
|
|
149
|
+
});
|
package/src/jobs/changes.json
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.241.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "JobRunsScreen and JobRunDetailScreen removed; job-runs/job-run-detail are now declarative screens (fw#2312).",
|
|
6
|
+
"detail": "`job-runs`/`job-run-detail` render through the generic renderer (`projectionList`/`projectionDetail`) instead of custom React components — the renderer selects a screen by its `screen.type`, not a client component registry, so `JobRunsScreen`/`JobRunDetailScreen` were dead exports the moment the screen definitions switched. Job triggering moved from an inline control on the old custom screen to a new `job-trigger` `actionForm`, opened via a `kind: \"drawer\"` `toolbarAction` on `job-runs`. `job-run-detail`'s `logs` field now uses the `format: \"json\"` field-renderer (see framework changelog) instead of a raw escaped-string dump.",
|
|
7
|
+
"migration": "Breaking only if you imported these components directly (no shipped consumer app did). Remove `import { JobRunsScreen, JobRunDetailScreen } from \"@cosmicdrift/kumiko-bundled-features/jobs/web\"` and any `components: { JobRunsScreen: ..., JobRunDetailScreen: ... }` entry in your `jobsClient()`/renderer setup — the bundled feature registers its own `job-runs`/`job-run-detail`/`job-trigger` screens now, nothing left for an app to wire."
|
|
8
|
+
},
|
|
2
9
|
{
|
|
3
10
|
"version": "0.120.0",
|
|
4
11
|
"type": "improvement",
|
package/src/jobs/feature.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
-
import { JOB_RUN_DETAIL_SCREEN_ID, JOB_RUNS_SCREEN_ID } from "./constants";
|
|
2
|
+
import { JOB_RUN_DETAIL_SCREEN_ID, JOB_RUNS_SCREEN_ID, JobHandlers, JobQueries } from "./constants";
|
|
3
3
|
import { catalogQuery } from "./handlers/catalog.query";
|
|
4
4
|
import { detailQuery } from "./handlers/detail.query";
|
|
5
5
|
import { listQuery } from "./handlers/list.query";
|
|
@@ -109,20 +109,112 @@ export function createJobsFeature(options: JobsFeatureOptions = {}): FeatureDefi
|
|
|
109
109
|
|
|
110
110
|
r.translations({ keys: JOBS_I18N });
|
|
111
111
|
|
|
112
|
-
// kumiko-lint-ignore app-feature-structure Phase-3 conversion tracked in #2312
|
|
113
112
|
r.screen({
|
|
114
113
|
id: JOB_RUNS_SCREEN_ID,
|
|
115
|
-
type: "
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
114
|
+
type: "projectionList",
|
|
115
|
+
query: JobQueries.list,
|
|
116
|
+
columns: [
|
|
117
|
+
{ field: "jobName", label: "jobs.runs.col.job" },
|
|
118
|
+
{ field: "status", label: "jobs.runs.col.status" },
|
|
119
|
+
{ field: "startedAt", label: "jobs.runs.col.started", renderer: { format: "timestamp" } },
|
|
120
|
+
{ field: "duration", label: "jobs.runs.col.duration" },
|
|
121
|
+
],
|
|
122
|
+
defaultSort: { field: "startedAt", dir: "desc" },
|
|
123
|
+
facets: [
|
|
124
|
+
{
|
|
125
|
+
field: "status",
|
|
126
|
+
type: "select",
|
|
127
|
+
label: "jobs.runs.filter.status",
|
|
128
|
+
options: [
|
|
129
|
+
{ value: "queued", label: "jobs.runs.filter.queued" },
|
|
130
|
+
{ value: "running", label: "jobs.runs.filter.running" },
|
|
131
|
+
{ value: "completed", label: "jobs.runs.filter.completed" },
|
|
132
|
+
{ value: "failed", label: "jobs.runs.filter.failed" },
|
|
133
|
+
],
|
|
134
|
+
},
|
|
135
|
+
],
|
|
136
|
+
rowActions: [
|
|
137
|
+
{
|
|
138
|
+
kind: "navigate",
|
|
139
|
+
id: "open",
|
|
140
|
+
label: "jobs.runs.open",
|
|
141
|
+
screen: JOB_RUN_DETAIL_SCREEN_ID,
|
|
142
|
+
entityId: "id",
|
|
143
|
+
rowClick: true,
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
toolbarActions: [
|
|
147
|
+
{
|
|
148
|
+
kind: "drawer",
|
|
149
|
+
id: "trigger",
|
|
150
|
+
label: "jobs.trigger.title",
|
|
151
|
+
screen: "job-trigger",
|
|
152
|
+
style: "primary",
|
|
153
|
+
},
|
|
154
|
+
],
|
|
155
|
+
pagination: "infinite",
|
|
119
156
|
access: systemAdminAccess,
|
|
120
157
|
});
|
|
121
|
-
|
|
158
|
+
r.screen({
|
|
159
|
+
id: "job-trigger",
|
|
160
|
+
type: "actionForm",
|
|
161
|
+
handler: JobHandlers.trigger,
|
|
162
|
+
fields: {
|
|
163
|
+
jobName: { type: "text", required: true },
|
|
164
|
+
payload: { type: "longText", multiline: true, default: "{}" },
|
|
165
|
+
},
|
|
166
|
+
layout: { sections: [{ fields: ["jobName", "payload"] }] },
|
|
167
|
+
submitLabel: "jobs.trigger.submit",
|
|
168
|
+
access: systemAdminAccess,
|
|
169
|
+
description: "Manually trigger a job by name with an optional JSON object payload.",
|
|
170
|
+
});
|
|
122
171
|
r.screen({
|
|
123
172
|
id: JOB_RUN_DETAIL_SCREEN_ID,
|
|
124
|
-
type: "
|
|
125
|
-
|
|
173
|
+
type: "projectionDetail",
|
|
174
|
+
query: JobQueries.details,
|
|
175
|
+
idParam: "runId",
|
|
176
|
+
fieldLabels: {
|
|
177
|
+
jobName: "jobs.detail.field.job",
|
|
178
|
+
status: "jobs.detail.field.status",
|
|
179
|
+
id: "jobs.detail.field.id",
|
|
180
|
+
startedAt: "jobs.detail.field.started",
|
|
181
|
+
finishedAt: "jobs.detail.field.finished",
|
|
182
|
+
duration: "jobs.detail.field.duration",
|
|
183
|
+
error: "jobs.detail.field.error",
|
|
184
|
+
logs: "jobs.detail.logs",
|
|
185
|
+
},
|
|
186
|
+
layout: {
|
|
187
|
+
sections: [
|
|
188
|
+
{
|
|
189
|
+
fields: [
|
|
190
|
+
"jobName",
|
|
191
|
+
"status",
|
|
192
|
+
"id",
|
|
193
|
+
{ field: "startedAt", renderer: { format: "timestamp" } },
|
|
194
|
+
{ field: "finishedAt", renderer: { format: "timestamp" } },
|
|
195
|
+
"duration",
|
|
196
|
+
"error",
|
|
197
|
+
],
|
|
198
|
+
},
|
|
199
|
+
// No section `title` here (fw#2312 label-dedup fix): the section
|
|
200
|
+
// holds exactly one field, so a title would repeat the field's own
|
|
201
|
+
// label (rendered by RenderField as the Field's heading) verbatim.
|
|
202
|
+
{
|
|
203
|
+
fields: [{ field: "logs", renderer: { format: "json" } }],
|
|
204
|
+
},
|
|
205
|
+
],
|
|
206
|
+
},
|
|
207
|
+
actions: [
|
|
208
|
+
{
|
|
209
|
+
kind: "writeHandler",
|
|
210
|
+
id: "retry",
|
|
211
|
+
label: "jobs.detail.retry",
|
|
212
|
+
handler: JobHandlers.retry,
|
|
213
|
+
payload: { map: { runId: "id" } },
|
|
214
|
+
visible: { field: "status", eq: "failed" },
|
|
215
|
+
style: "primary",
|
|
216
|
+
},
|
|
217
|
+
],
|
|
126
218
|
description:
|
|
127
219
|
"Detail view of one job run with status, timings, error and log lines, and a retry action for failed runs; reached from a row of the job-runs list.",
|
|
128
220
|
listScreenId: JOB_RUNS_SCREEN_ID,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { selectMany, type WhereObject } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
1
|
+
import { countWhere, selectMany, type WhereObject } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
4
|
import { z } from "zod";
|
|
@@ -28,7 +28,13 @@ export const listQuery = defineQueryHandler({
|
|
|
28
28
|
schema: z.object({
|
|
29
29
|
jobName: z.string().optional(),
|
|
30
30
|
status: z.enum(["queued", "running", "completed", "failed"]).optional(),
|
|
31
|
+
filters: z
|
|
32
|
+
.array(z.object({ field: z.string(), op: z.literal("in"), value: z.array(z.string()) }))
|
|
33
|
+
.optional(),
|
|
34
|
+
sort: z.enum(["jobName", "status", "startedAt", "duration"]).optional(),
|
|
35
|
+
sortDirection: z.enum(["asc", "desc"]).optional(),
|
|
31
36
|
limit: z.number().optional(),
|
|
37
|
+
totalCount: z.boolean().optional(),
|
|
32
38
|
}),
|
|
33
39
|
access: { roles: ["SystemAdmin"] },
|
|
34
40
|
handler: async (query, ctx) => {
|
|
@@ -40,12 +46,23 @@ export const listQuery = defineQueryHandler({
|
|
|
40
46
|
const db = ctx.systemDb.acknowledgeCrossTenant("cross-tenant job monitoring");
|
|
41
47
|
const where: WhereObject = {};
|
|
42
48
|
if (query.payload.jobName) where["jobName"] = query.payload.jobName;
|
|
43
|
-
|
|
49
|
+
const statusFilter = query.payload.filters?.find((filter) => filter.field === "status");
|
|
50
|
+
const statuses = statusFilter?.value ?? (query.payload.status ? [query.payload.status] : []);
|
|
51
|
+
if (statuses.length === 1) where["status"] = statuses[0];
|
|
52
|
+
const sortColumn = query.payload.sort ?? "startedAt";
|
|
44
53
|
const rows = await selectMany(db, jobRunsTable, where, {
|
|
45
|
-
orderBy: { col:
|
|
54
|
+
orderBy: { col: sortColumn, direction: query.payload.sortDirection ?? "desc" },
|
|
46
55
|
limit: query.payload.limit ?? 50,
|
|
47
56
|
});
|
|
57
|
+
// countWhere reruns the SAME `where` without the limit — `rows.length` is
|
|
58
|
+
// capped at the page size and would silently undercount the total.
|
|
59
|
+
const total =
|
|
60
|
+
query.payload.totalCount === true ? await countWhere(db, jobRunsTable, where) : undefined;
|
|
48
61
|
// payload/error are stored encrypted under the triggering user's DEK (#799, #2307).
|
|
49
|
-
return {
|
|
62
|
+
return {
|
|
63
|
+
rows: await mapWithConcurrency(rows, KMS_POOL_CONCURRENCY, decryptRunRow),
|
|
64
|
+
nextCursor: null,
|
|
65
|
+
...(total !== undefined && { total }),
|
|
66
|
+
};
|
|
50
67
|
},
|
|
51
68
|
});
|
|
@@ -17,7 +17,7 @@ export const triggerWrite = defineWriteHandler({
|
|
|
17
17
|
"Starts one manually-triggerable job with the given payload after validating it against the job's schema; use it to run a maintenance or import job on demand.",
|
|
18
18
|
schema: z.object({
|
|
19
19
|
jobName: z.string(),
|
|
20
|
-
payload: z.record(z.string(), z.unknown()).optional(),
|
|
20
|
+
payload: z.union([z.record(z.string(), z.unknown()), z.string()]).optional(),
|
|
21
21
|
}),
|
|
22
22
|
access: { roles: ["SystemAdmin"] },
|
|
23
23
|
handler: async (event, ctx) => {
|
|
@@ -43,8 +43,21 @@ export const triggerWrite = defineWriteHandler({
|
|
|
43
43
|
);
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
let rawPayload: DbRow = {};
|
|
47
|
+
if (typeof event.payload.payload === "string" && event.payload.payload.trim() !== "") {
|
|
48
|
+
try {
|
|
49
|
+
const parsed: unknown = JSON.parse(event.payload.payload);
|
|
50
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
51
|
+
return writeFailure(new UnprocessableError(JobErrors.notManual));
|
|
52
|
+
}
|
|
53
|
+
rawPayload = parsed as DbRow;
|
|
54
|
+
} catch {
|
|
55
|
+
return writeFailure(new UnprocessableError(JobErrors.notManual));
|
|
56
|
+
}
|
|
57
|
+
} else if (event.payload.payload !== undefined) {
|
|
58
|
+
rawPayload = event.payload.payload as DbRow;
|
|
59
|
+
}
|
|
60
|
+
let payload: DbRow = rawPayload;
|
|
48
61
|
if (jobDef.schema !== undefined) {
|
|
49
62
|
const parsed = jobDef.schema.safeParse(rawPayload);
|
|
50
63
|
if (!parsed.success) {
|
package/src/jobs/i18n.ts
CHANGED
|
@@ -6,13 +6,12 @@ type LocalizedString = { readonly en: string };
|
|
|
6
6
|
export const JOBS_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
7
7
|
"screen:job-runs.title": { en: "Job runs" },
|
|
8
8
|
"screen:job-run-detail.title": { en: "Job run" },
|
|
9
|
+
"screen:job-trigger.title": { en: "Trigger job" },
|
|
10
|
+
"jobs:entity:__action-form__:field:jobName": { en: "Job name" },
|
|
11
|
+
"jobs:entity:__action-form__:field:payload": { en: "JSON payload" },
|
|
9
12
|
"jobs:nav.jobRuns": { en: "Jobs" },
|
|
10
|
-
"jobs.runs.title": { en: "Job runs" },
|
|
11
|
-
"jobs.runs.loading": { en: "Loading runs…" },
|
|
12
|
-
"jobs.runs.empty": { en: "No job runs." },
|
|
13
13
|
"jobs.runs.open": { en: "Details" },
|
|
14
14
|
"jobs.runs.filter.status": { en: "Status" },
|
|
15
|
-
"jobs.runs.filter.all": { en: "All" },
|
|
16
15
|
"jobs.runs.filter.completed": { en: "Completed" },
|
|
17
16
|
"jobs.runs.filter.failed": { en: "Failed" },
|
|
18
17
|
"jobs.runs.filter.running": { en: "Running" },
|
|
@@ -21,8 +20,6 @@ export const JOBS_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
|
21
20
|
"jobs.runs.col.status": { en: "Status" },
|
|
22
21
|
"jobs.runs.col.started": { en: "Started" },
|
|
23
22
|
"jobs.runs.col.duration": { en: "Duration (ms)" },
|
|
24
|
-
"jobs.detail.loading": { en: "Loading details…" },
|
|
25
|
-
"jobs.detail.missing": { en: "Run not found." },
|
|
26
23
|
"jobs.detail.field.job": { en: "Job" },
|
|
27
24
|
"jobs.detail.field.status": { en: "Status" },
|
|
28
25
|
"jobs.detail.field.id": { en: "Run ID" },
|
|
@@ -31,28 +28,9 @@ export const JOBS_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
|
31
28
|
"jobs.detail.field.duration": { en: "Duration (ms)" },
|
|
32
29
|
"jobs.detail.field.error": { en: "Error" },
|
|
33
30
|
"jobs.detail.logs": { en: "Logs" },
|
|
34
|
-
"jobs.detail.logs.empty": { en: "No log lines." },
|
|
35
31
|
"jobs.detail.retry": { en: "Retry" },
|
|
36
|
-
"jobs.detail.retrying": { en: "Retrying…" },
|
|
37
32
|
"jobs.trigger.title": { en: "Run a job" },
|
|
38
|
-
"jobs.trigger.explainer": {
|
|
39
|
-
en: "Only manually triggerable jobs. After starting, the run appears in the history below.",
|
|
40
|
-
},
|
|
41
|
-
"jobs.trigger.job": { en: "Job" },
|
|
42
|
-
"jobs.trigger.payload": { en: "Payload (JSON)" },
|
|
43
|
-
"jobs.trigger.payload.hint": { en: "Schema hint" },
|
|
44
|
-
"jobs.trigger.payload.invalidJson": {
|
|
45
|
-
en: "Payload is not a valid JSON object.",
|
|
46
|
-
},
|
|
47
33
|
"jobs.trigger.submit": { en: "Run" },
|
|
48
|
-
"jobs.trigger.submitting": { en: "Starting…" },
|
|
49
|
-
"jobs.trigger.success": { en: "Job started." },
|
|
50
|
-
"jobs.trigger.empty": {
|
|
51
|
-
en: "No manually triggerable jobs are registered.",
|
|
52
|
-
},
|
|
53
|
-
"jobs.trigger.perTenant": {
|
|
54
|
-
en: "Runs once per active tenant.",
|
|
55
|
-
},
|
|
56
34
|
"jobs.errors.unknownJob": { en: "Unknown job." },
|
|
57
35
|
"jobs.errors.notManual": {
|
|
58
36
|
en: "This job cannot be triggered manually.",
|
package/src/jobs/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { JOBS_FEATURE, JobErrors, JobHandlers, JobQueries } from "./constants";
|
|
1
2
|
export { createJobsFeature, type JobsFeatureOptions } from "./feature";
|
|
2
3
|
export type { JobRunLoggerCallbacks } from "./job-run-logger";
|
|
3
4
|
export { createJobRunLogger } from "./job-run-logger";
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
// @runtime client
|
|
2
2
|
import { mergeTranslations, type TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
3
3
|
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
|
|
4
|
-
import {
|
|
4
|
+
import { JOBS_FEATURE } from "../constants";
|
|
5
5
|
import { defaultTranslations } from "./i18n";
|
|
6
|
-
import { JobRunDetailScreen } from "./job-run-detail-screen";
|
|
7
|
-
import { JobRunsScreen } from "./job-runs-screen";
|
|
8
6
|
|
|
9
7
|
export type JobsClientOptions = {
|
|
10
8
|
readonly translations?: TranslationsByLocale;
|
|
@@ -14,9 +12,5 @@ export function jobsClient(options?: JobsClientOptions): ClientFeatureDefinition
|
|
|
14
12
|
return {
|
|
15
13
|
name: JOBS_FEATURE,
|
|
16
14
|
translations: mergeTranslations(defaultTranslations, options?.translations ?? {}),
|
|
17
|
-
components: {
|
|
18
|
-
[JOB_RUNS_SCREEN_ID]: JobRunsScreen,
|
|
19
|
-
[JOB_RUN_DETAIL_SCREEN_ID]: JobRunDetailScreen,
|
|
20
|
-
},
|
|
21
15
|
};
|
|
22
16
|
}
|
package/src/jobs/web/index.ts
CHANGED
|
@@ -190,6 +190,38 @@ describe("scenario 5: tenant.list", () => {
|
|
|
190
190
|
"SystemAdmin",
|
|
191
191
|
]);
|
|
192
192
|
});
|
|
193
|
+
|
|
194
|
+
test("without totalCount the shape carries no total field (fw#2312)", async () => {
|
|
195
|
+
const result = await stack.http.queryOk<{
|
|
196
|
+
rows: Record<string, unknown>[];
|
|
197
|
+
nextCursor: string | null;
|
|
198
|
+
total?: number;
|
|
199
|
+
}>(TenantQueries.list, {}, systemAdmin);
|
|
200
|
+
expect(result.total).toBeUndefined();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("total reflects the full match count, not capped by limit (fw#2312)", async () => {
|
|
204
|
+
await stack.http.writeOk(
|
|
205
|
+
TenantHandlers.create,
|
|
206
|
+
{ key: "gamma", name: "Gamma LLC" },
|
|
207
|
+
systemAdmin,
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
const unpaged = await stack.http.queryOk<{ rows: Record<string, unknown>[] }>(
|
|
211
|
+
TenantQueries.list,
|
|
212
|
+
{},
|
|
213
|
+
systemAdmin,
|
|
214
|
+
);
|
|
215
|
+
const expectedTotal = unpaged.rows.length;
|
|
216
|
+
expect(expectedTotal).toBeGreaterThanOrEqual(3);
|
|
217
|
+
|
|
218
|
+
const paged = await stack.http.queryOk<{
|
|
219
|
+
rows: Record<string, unknown>[];
|
|
220
|
+
total?: number;
|
|
221
|
+
}>(TenantQueries.list, { limit: 1, totalCount: true }, systemAdmin);
|
|
222
|
+
expect(paged.rows.length).toBe(1);
|
|
223
|
+
expect(paged.total).toBe(expectedTotal);
|
|
224
|
+
});
|
|
193
225
|
});
|
|
194
226
|
|
|
195
227
|
// --- Scenario 6: Config integration ---
|
|
@@ -18,7 +18,7 @@ describe("tier-engine drift pins", () => {
|
|
|
18
18
|
expect(TierEngineHandlers.update).toBe("tier-engine:write:tier-assignment:update");
|
|
19
19
|
expect(TierEngineQueries.list).toBe("tier-engine:query:tier-assignment:list");
|
|
20
20
|
expect(TierEngineQueries.getActiveTier).toBe("tier-engine:query:get-active-tier");
|
|
21
|
-
// Screen↔Handler-Contract:
|
|
21
|
+
// Screen↔Handler-Contract: the tier-admin actionForm dispatches exactly these QNs.
|
|
22
22
|
expect(TierEngineHandlers.setTenantTier).toBe("tier-engine:write:set-tenant-tier");
|
|
23
23
|
expect(TierEngineQueries.getTenantTier).toBe("tier-engine:query:get-tenant-tier");
|
|
24
24
|
expect(TierEngineQueries.tierOptions).toBe("tier-engine:query:tier-options");
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { validateBoot } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { rolesOf } from "@cosmicdrift/kumiko-framework/testing";
|
|
4
|
+
import { createConfigFeature } from "../../config";
|
|
5
|
+
import { createTenantFeature } from "../../tenant/feature";
|
|
6
|
+
import type { TierMap } from "../compose-app";
|
|
7
|
+
import { TIER_ADMIN_SCREEN_ID } from "../constants";
|
|
8
|
+
import { createTierEngineFeature } from "../feature";
|
|
9
|
+
|
|
10
|
+
const SYSTEM_ADMIN_ROLES = ["SystemAdmin"] as const;
|
|
11
|
+
|
|
12
|
+
type TestCaps = { readonly maxItems: number };
|
|
13
|
+
const TEST_TIER_MAP: TierMap<TestCaps> = {
|
|
14
|
+
free: { features: [], caps: { maxItems: 1 } },
|
|
15
|
+
pro: { features: ["feat-pro"], caps: { maxItems: 5 } },
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
describe("tier-engine tier-admin screen + handler access alignment", () => {
|
|
19
|
+
test("boot-validates with the tier-admin actionForm registered (tierMap configured)", () => {
|
|
20
|
+
const features = [
|
|
21
|
+
createConfigFeature(),
|
|
22
|
+
createTenantFeature(),
|
|
23
|
+
createTierEngineFeature({ tierMap: TEST_TIER_MAP }),
|
|
24
|
+
];
|
|
25
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("boot-validates in storage-only mode with tier-admin screen showing the no-tiers message (no tierMap)", () => {
|
|
29
|
+
const features = [createConfigFeature(), createTenantFeature(), createTierEngineFeature()];
|
|
30
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
31
|
+
const tierEngine = createTierEngineFeature();
|
|
32
|
+
const screen = tierEngine.screens[TIER_ADMIN_SCREEN_ID];
|
|
33
|
+
expect(screen).toBeDefined();
|
|
34
|
+
if (screen && "layout" in screen) {
|
|
35
|
+
expect(screen.layout.sections[0]).toMatchObject({ description: "tier-admin.error.noTiers" });
|
|
36
|
+
}
|
|
37
|
+
if (screen && "fields" in screen) {
|
|
38
|
+
expect(screen.fields["tier"]).toEqual({ type: "select", options: [], required: true });
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("tier-admin is a SystemAdmin-gated actionForm dispatching set-tenant-tier", () => {
|
|
43
|
+
const tierEngine = createTierEngineFeature({ tierMap: TEST_TIER_MAP });
|
|
44
|
+
const screen = tierEngine.screens[TIER_ADMIN_SCREEN_ID];
|
|
45
|
+
expect(screen?.type).toBe("actionForm");
|
|
46
|
+
if (screen && "handler" in screen) {
|
|
47
|
+
expect(screen.handler).toBe("tier-engine:write:set-tenant-tier");
|
|
48
|
+
}
|
|
49
|
+
if (screen && "access" in screen && screen.access && "roles" in screen.access) {
|
|
50
|
+
expect(screen.access.roles).toEqual(SYSTEM_ADMIN_ROLES);
|
|
51
|
+
}
|
|
52
|
+
if (screen && "layout" in screen) {
|
|
53
|
+
expect(screen.layout.sections[0]).toMatchObject({ description: "tier-admin.explainer" });
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("tier-admin's tier field options come from the tierMap", () => {
|
|
58
|
+
const tierEngine = createTierEngineFeature({ tierMap: TEST_TIER_MAP });
|
|
59
|
+
const screen = tierEngine.screens[TIER_ADMIN_SCREEN_ID];
|
|
60
|
+
if (screen && "fields" in screen) {
|
|
61
|
+
expect(screen.fields["tier"]).toEqual({
|
|
62
|
+
type: "select",
|
|
63
|
+
options: ["free", "pro"],
|
|
64
|
+
required: true,
|
|
65
|
+
});
|
|
66
|
+
expect(screen.fields["tenantId"]).toEqual({
|
|
67
|
+
type: "reference",
|
|
68
|
+
entity: "tenant:tenant",
|
|
69
|
+
labelField: "name",
|
|
70
|
+
required: true,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("set-tenant-tier write + get-tenant-tier/tier-options reads stay SystemAdmin-only", () => {
|
|
76
|
+
const tierEngine = createTierEngineFeature({ tierMap: TEST_TIER_MAP });
|
|
77
|
+
const roles = [...SYSTEM_ADMIN_ROLES];
|
|
78
|
+
expect(rolesOf(tierEngine.writeHandlers["set-tenant-tier"]?.access)).toEqual(roles);
|
|
79
|
+
expect(rolesOf(tierEngine.queryHandlers["get-tenant-tier"]?.access)).toEqual(roles);
|
|
80
|
+
expect(rolesOf(tierEngine.queryHandlers["tier-options"]?.access)).toEqual(roles);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -1 +1,9 @@
|
|
|
1
|
-
[
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.241.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "TierAdminScreen removed; tier-admin is now a declarative actionForm (fw#2312).",
|
|
6
|
+
"detail": "`tier-engine:screen:tier-admin` is now a declarative `actionForm` (a `reference` field for the tenant, a `select` field for the tier) instead of a custom React component: it dispatches `set-tenant-tier` directly, so the renderer's generic form handles tenant lookup, validation and submit. Two behaviors are intentionally not carried over: the current tier of the selected tenant is no longer shown before submit (declarative forms have no dependent-query support), and the success state no longer names the newly assigned tier (a generic actionForm success doesn't surface write-response data). Both are visible again after a page reload / re-navigation — the assignment itself is unchanged.",
|
|
7
|
+
"migration": "Breaking only if you imported `TierAdminScreen` directly (no shipped consumer app did — all reference the screen by its qualified id `tier-engine:screen:tier-admin`). Remove `import { TierAdminScreen } from \"@cosmicdrift/kumiko-bundled-features/tier-engine/web\"` and any `components: { TierAdminScreen: ... }` entry in your renderer setup — the bundled feature registers the `tier-admin` screen itself now, nothing left for an app to wire."
|
|
8
|
+
}
|
|
9
|
+
]
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// @runtime client
|
|
2
|
-
// Pure string-literal QNs/ids —
|
|
3
|
-
// (web/
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
2
|
+
// Pure string-literal QNs/ids — imported by both server (feature.ts) and
|
|
3
|
+
// client (web/client-plugin.tsx). Marked `client`, the most permissive
|
|
4
|
+
// category in the kumiko isolation model (runtime may import client, client
|
|
5
|
+
// may only import client) — otherwise the runtime isolation guard throws on
|
|
6
|
+
// the client imports. Same pattern as text-content/constants.
|
|
7
7
|
// Feature name
|
|
8
8
|
export const TIER_ENGINE_FEATURE = "tier-engine" as const;
|
|
9
9
|
|