@cosmicdrift/kumiko-bundled-features 0.130.0 → 0.130.2
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 +6 -6
- package/src/admin-shell/__tests__/admin-shell.boot.test.ts +17 -1
- package/src/admin-shell/__tests__/overview-allowlist.test.ts +11 -2
- package/src/admin-shell/__tests__/overview-screens.boot.test.ts +2 -0
- package/src/admin-shell/feature.ts +2 -1
- package/src/admin-shell/i18n.ts +1 -0
- package/src/admin-shell/overview-allowlist.ts +5 -1
- package/src/admin-shell/web/platform-overview-screen.tsx +18 -1
- package/src/audit/__tests__/audit-screens.boot.test.ts +15 -2
- package/src/audit/constants.ts +4 -0
- package/src/audit/feature.ts +10 -1
- package/src/audit/handlers/details.query.ts +47 -0
- package/src/audit/i18n.ts +6 -2
- package/src/audit/web/audit-log-detail-screen.tsx +133 -0
- package/src/audit/web/audit-log-screen.tsx +141 -124
- package/src/audit/web/client-plugin.tsx +3 -1
- package/src/audit/web/index.ts +1 -0
- package/src/jobs/feature.ts +1 -0
- package/src/jobs/i18n.ts +3 -2
- package/src/jobs/web/job-run-detail-screen.tsx +25 -12
- package/src/jobs/web/job-runs-screen.tsx +79 -63
- package/src/text-content/web/__tests__/group-blocks.test.ts +28 -3
- package/src/text-content/web/client-plugin.tsx +124 -104
- package/src/tier-engine/web/tier-admin-screen.tsx +2 -2
- package/src/user/constants.ts +1 -0
- package/src/user-data-rights/feature.ts +1 -6
- package/src/user-data-rights/i18n.ts +4 -0
- package/src/user-data-rights/web/i18n.ts +22 -1
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
// @runtime client
|
|
2
|
-
// Paginated tenant-scoped audit log (event store).
|
|
2
|
+
// Paginated tenant-scoped audit log (event store). Rows link to the
|
|
3
|
+
// audit-log-detail screen; the screen title lives in the shell breadcrumb.
|
|
3
4
|
|
|
4
|
-
import {
|
|
5
|
-
|
|
5
|
+
import {
|
|
6
|
+
type DataTableSort,
|
|
7
|
+
useDispatcher,
|
|
8
|
+
useNav,
|
|
9
|
+
usePrimitives,
|
|
10
|
+
useTranslation,
|
|
11
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
6
12
|
import { type ReactNode, useCallback, useEffect, useState } from "react";
|
|
7
|
-
import { AuditQueries } from "../constants";
|
|
13
|
+
import { AUDIT_LOG_DETAIL_SCREEN_ID, AuditQueries } from "../constants";
|
|
8
14
|
|
|
9
15
|
type AuditRow = {
|
|
10
16
|
readonly id: string;
|
|
@@ -38,12 +44,13 @@ const EMPTY_FILTERS: Filters = { eventType: "", aggregateType: "", from: "", to:
|
|
|
38
44
|
|
|
39
45
|
export function AuditLogScreen(): ReactNode {
|
|
40
46
|
const t = useTranslation();
|
|
41
|
-
const { Banner, Button,
|
|
47
|
+
const { Banner, Button, DataTable, Field, Input, Text } = usePrimitives();
|
|
42
48
|
const dispatcher = useDispatcher();
|
|
49
|
+
const nav = useNav();
|
|
43
50
|
const [state, setState] = useState<State>({ kind: "loading" });
|
|
44
51
|
const [before, setBefore] = useState<string | undefined>(undefined);
|
|
45
52
|
const [filters, setFilters] = useState<Filters>(EMPTY_FILTERS);
|
|
46
|
-
const [
|
|
53
|
+
const [sort, setSort] = useState<DataTableSort | null>(null);
|
|
47
54
|
|
|
48
55
|
const load = useCallback(
|
|
49
56
|
async (cursor?: string): Promise<void> => {
|
|
@@ -71,139 +78,130 @@ export function AuditLogScreen(): ReactNode {
|
|
|
71
78
|
void load(before);
|
|
72
79
|
}, [load, before]);
|
|
73
80
|
|
|
74
|
-
const detailRow = state.kind === "ready" ? state.rows.find((r) => r.id === detailId) : undefined;
|
|
75
|
-
|
|
76
81
|
if (state.kind === "loading") {
|
|
77
82
|
return (
|
|
78
|
-
<
|
|
83
|
+
<div className="p-6" data-testid="audit-log-screen">
|
|
79
84
|
<Text variant="small">{t("audit.log.loading")}</Text>
|
|
80
|
-
</
|
|
85
|
+
</div>
|
|
81
86
|
);
|
|
82
87
|
}
|
|
83
88
|
|
|
84
89
|
if (state.kind === "error") {
|
|
85
90
|
return (
|
|
86
|
-
<
|
|
91
|
+
<div className="p-6" data-testid="audit-log-screen">
|
|
87
92
|
<Banner variant="error">{state.message}</Banner>
|
|
88
|
-
</
|
|
93
|
+
</div>
|
|
89
94
|
);
|
|
90
95
|
}
|
|
91
96
|
|
|
97
|
+
const openDetail = (id: string): void =>
|
|
98
|
+
nav.navigate({ screenId: AUDIT_LOG_DETAIL_SCREEN_ID, entityId: id });
|
|
99
|
+
|
|
92
100
|
return (
|
|
93
|
-
<
|
|
94
|
-
<
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
</
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
</
|
|
101
|
+
<div className="w-full" data-testid="audit-log-screen">
|
|
102
|
+
<div className="flex flex-col gap-4 px-6 pt-6">
|
|
103
|
+
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
104
|
+
<Field id="audit-filter-event" label={t("audit.log.filter.eventType")}>
|
|
105
|
+
<Input
|
|
106
|
+
kind="text"
|
|
107
|
+
id="audit-filter-event"
|
|
108
|
+
name="audit-filter-event"
|
|
109
|
+
value={filters.eventType}
|
|
110
|
+
onChange={(v: string) => setFilters((f) => ({ ...f, eventType: v }))}
|
|
111
|
+
/>
|
|
112
|
+
</Field>
|
|
113
|
+
<Field id="audit-filter-aggregate" label={t("audit.log.filter.aggregateType")}>
|
|
114
|
+
<Input
|
|
115
|
+
kind="text"
|
|
116
|
+
id="audit-filter-aggregate"
|
|
117
|
+
name="audit-filter-aggregate"
|
|
118
|
+
value={filters.aggregateType}
|
|
119
|
+
onChange={(v) => setFilters((f) => ({ ...f, aggregateType: v }))}
|
|
120
|
+
/>
|
|
121
|
+
</Field>
|
|
122
|
+
<Field id="audit-filter-from" label={t("audit.log.filter.from")}>
|
|
123
|
+
<Input
|
|
124
|
+
kind="date"
|
|
125
|
+
id="audit-filter-from"
|
|
126
|
+
name="audit-filter-from"
|
|
127
|
+
value={filters.from}
|
|
128
|
+
onChange={(v) => setFilters((f) => ({ ...f, from: v ?? "" }))}
|
|
129
|
+
/>
|
|
130
|
+
</Field>
|
|
131
|
+
<Field id="audit-filter-to" label={t("audit.log.filter.to")}>
|
|
132
|
+
<Input
|
|
133
|
+
kind="date"
|
|
134
|
+
id="audit-filter-to"
|
|
135
|
+
name="audit-filter-to"
|
|
136
|
+
value={filters.to}
|
|
137
|
+
onChange={(v) => setFilters((f) => ({ ...f, to: v ?? "" }))}
|
|
138
|
+
/>
|
|
139
|
+
</Field>
|
|
140
|
+
</div>
|
|
141
|
+
<div className="flex gap-2">
|
|
142
|
+
<Button
|
|
143
|
+
type="button"
|
|
144
|
+
variant="primary"
|
|
145
|
+
onClick={() => {
|
|
146
|
+
setBefore(undefined);
|
|
147
|
+
void load(undefined);
|
|
148
|
+
}}
|
|
149
|
+
testId="audit-log-apply-filters"
|
|
150
|
+
>
|
|
151
|
+
{t("audit.log.filter.apply")}
|
|
152
|
+
</Button>
|
|
153
|
+
<Button
|
|
154
|
+
type="button"
|
|
155
|
+
variant="secondary"
|
|
156
|
+
onClick={() => {
|
|
157
|
+
setFilters(EMPTY_FILTERS);
|
|
158
|
+
setBefore(undefined);
|
|
159
|
+
}}
|
|
160
|
+
testId="audit-log-reset-filters"
|
|
161
|
+
>
|
|
162
|
+
{t("audit.log.filter.reset")}
|
|
163
|
+
</Button>
|
|
164
|
+
</div>
|
|
157
165
|
</div>
|
|
158
166
|
|
|
159
|
-
<
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
{detailRow !== undefined && (
|
|
196
|
-
<Card slots={{ title: t("audit.log.detail.title") }} options={{ padded: true }}>
|
|
197
|
-
<pre className="max-h-96 overflow-auto whitespace-pre-wrap break-all text-xs">
|
|
198
|
-
{JSON.stringify(detailRow.payload, null, 2)}
|
|
199
|
-
</pre>
|
|
200
|
-
<Button type="button" variant="secondary" onClick={() => setDetailId(null)}>
|
|
201
|
-
{t("audit.log.detail.close")}
|
|
202
|
-
</Button>
|
|
203
|
-
</Card>
|
|
204
|
-
)}
|
|
167
|
+
<DataTable
|
|
168
|
+
testId="audit-log-table"
|
|
169
|
+
columns={[
|
|
170
|
+
{ field: "when", label: t("audit.log.col.when"), type: "string", sortable: true },
|
|
171
|
+
{ field: "type", label: t("audit.log.col.type"), type: "string", sortable: true },
|
|
172
|
+
{
|
|
173
|
+
field: "aggregate",
|
|
174
|
+
label: t("audit.log.col.aggregate"),
|
|
175
|
+
type: "string",
|
|
176
|
+
sortable: false,
|
|
177
|
+
},
|
|
178
|
+
{ field: "actor", label: t("audit.log.col.actor"), type: "string", sortable: false },
|
|
179
|
+
]}
|
|
180
|
+
sort={sort}
|
|
181
|
+
onSortChange={setSort}
|
|
182
|
+
rows={sortAudit(state.rows, sort).map((row) => ({
|
|
183
|
+
id: row.id,
|
|
184
|
+
values: {
|
|
185
|
+
when: formatWhen(row.createdAt),
|
|
186
|
+
type: row.type,
|
|
187
|
+
aggregate: `${row.aggregateType} / ${row.aggregateId}`,
|
|
188
|
+
actor: row.createdBy,
|
|
189
|
+
},
|
|
190
|
+
}))}
|
|
191
|
+
onRowClick={(row) => openDetail(row.id)}
|
|
192
|
+
rowActions={[
|
|
193
|
+
{
|
|
194
|
+
id: "details",
|
|
195
|
+
label: t("audit.log.details"),
|
|
196
|
+
style: "secondary",
|
|
197
|
+
onTrigger: (row) => openDetail(row.id),
|
|
198
|
+
},
|
|
199
|
+
]}
|
|
200
|
+
rowActionMode="inline"
|
|
201
|
+
emptyState={<Text variant="small">{t("audit.log.empty")}</Text>}
|
|
202
|
+
/>
|
|
205
203
|
|
|
206
|
-
<div className="flex gap-2">
|
|
204
|
+
<div className="flex gap-2 px-6 pb-6">
|
|
207
205
|
{before !== undefined && (
|
|
208
206
|
<Button
|
|
209
207
|
type="button"
|
|
@@ -225,10 +223,29 @@ export function AuditLogScreen(): ReactNode {
|
|
|
225
223
|
</Button>
|
|
226
224
|
)}
|
|
227
225
|
</div>
|
|
228
|
-
</
|
|
226
|
+
</div>
|
|
229
227
|
);
|
|
230
228
|
}
|
|
231
229
|
|
|
230
|
+
// Client-sort over the loaded page (≤50 rows). createdAt is an ISO string, so
|
|
231
|
+
// lexicographic compare is chronological. Cross-page order stays cursor-based.
|
|
232
|
+
const SORT_ACCESSORS: Record<string, (r: AuditRow) => string> = {
|
|
233
|
+
when: (r) => r.createdAt,
|
|
234
|
+
type: (r) => r.type,
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
function sortAudit(rows: readonly AuditRow[], sort: DataTableSort | null): readonly AuditRow[] {
|
|
238
|
+
if (sort === null) return rows;
|
|
239
|
+
const accessor = SORT_ACCESSORS[sort.field];
|
|
240
|
+
if (accessor === undefined) return rows;
|
|
241
|
+
const factor = sort.dir === "asc" ? 1 : -1;
|
|
242
|
+
return [...rows].sort((a, b) => {
|
|
243
|
+
const av = accessor(a);
|
|
244
|
+
const bv = accessor(b);
|
|
245
|
+
return av < bv ? -factor : av > bv ? factor : 0;
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
232
249
|
function formatWhen(value: string): string {
|
|
233
250
|
try {
|
|
234
251
|
return new Date(value).toLocaleString();
|
|
@@ -1,7 +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 { AUDIT_FEATURE, AUDIT_LOG_SCREEN_ID } from "../constants";
|
|
4
|
+
import { AUDIT_FEATURE, AUDIT_LOG_DETAIL_SCREEN_ID, AUDIT_LOG_SCREEN_ID } from "../constants";
|
|
5
|
+
import { AuditLogDetailScreen } from "./audit-log-detail-screen";
|
|
5
6
|
import { AuditLogScreen } from "./audit-log-screen";
|
|
6
7
|
import { defaultTranslations } from "./i18n";
|
|
7
8
|
|
|
@@ -15,6 +16,7 @@ export function auditClient(options?: AuditClientOptions): ClientFeatureDefiniti
|
|
|
15
16
|
translations: mergeTranslations(defaultTranslations, options?.translations ?? {}),
|
|
16
17
|
components: {
|
|
17
18
|
[AUDIT_LOG_SCREEN_ID]: AuditLogScreen,
|
|
19
|
+
[AUDIT_LOG_DETAIL_SCREEN_ID]: AuditLogDetailScreen,
|
|
18
20
|
},
|
|
19
21
|
};
|
|
20
22
|
}
|
package/src/audit/web/index.ts
CHANGED
package/src/jobs/feature.ts
CHANGED
|
@@ -198,6 +198,7 @@ export function createJobsFeature(): FeatureDefinition {
|
|
|
198
198
|
id: JOB_RUN_DETAIL_SCREEN_ID,
|
|
199
199
|
type: "custom",
|
|
200
200
|
renderer: { react: { __component: "JobRunDetailScreen" } },
|
|
201
|
+
listScreenId: JOB_RUNS_SCREEN_ID,
|
|
201
202
|
access: systemAdminAccess,
|
|
202
203
|
});
|
|
203
204
|
r.nav({
|
package/src/jobs/i18n.ts
CHANGED
|
@@ -21,13 +21,14 @@ export const JOBS_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
|
21
21
|
"jobs.runs.col.status": { de: "Status", en: "Status" },
|
|
22
22
|
"jobs.runs.col.started": { de: "Gestartet", en: "Started" },
|
|
23
23
|
"jobs.runs.col.duration": { de: "Dauer (ms)", en: "Duration (ms)" },
|
|
24
|
-
"jobs.detail.title": { de: "Job-Lauf", en: "Job run" },
|
|
25
24
|
"jobs.detail.loading": { de: "Lade Details…", en: "Loading details…" },
|
|
26
25
|
"jobs.detail.missing": { de: "Lauf nicht gefunden.", en: "Run not found." },
|
|
27
|
-
"jobs.detail.back": { de: "← Zurück zur Liste", en: "← Back to list" },
|
|
28
26
|
"jobs.detail.field.job": { de: "Job", en: "Job" },
|
|
29
27
|
"jobs.detail.field.status": { de: "Status", en: "Status" },
|
|
30
28
|
"jobs.detail.field.id": { de: "Run-ID", en: "Run ID" },
|
|
29
|
+
"jobs.detail.field.started": { de: "Gestartet", en: "Started" },
|
|
30
|
+
"jobs.detail.field.finished": { de: "Beendet", en: "Finished" },
|
|
31
|
+
"jobs.detail.field.duration": { de: "Dauer (ms)", en: "Duration (ms)" },
|
|
31
32
|
"jobs.detail.field.error": { de: "Fehler", en: "Error" },
|
|
32
33
|
"jobs.detail.logs": { de: "Logs", en: "Logs" },
|
|
33
34
|
"jobs.detail.logs.empty": { de: "Keine Log-Zeilen.", en: "No log lines." },
|
|
@@ -31,7 +31,7 @@ type State =
|
|
|
31
31
|
|
|
32
32
|
export function JobRunDetailScreen(): ReactNode {
|
|
33
33
|
const t = useTranslation();
|
|
34
|
-
const { Banner, Button, Card,
|
|
34
|
+
const { Banner, Button, Card, Text } = usePrimitives();
|
|
35
35
|
const dispatcher = useDispatcher();
|
|
36
36
|
const nav = useNav();
|
|
37
37
|
const runId = nav.route?.entityId;
|
|
@@ -102,23 +102,28 @@ export function JobRunDetailScreen(): ReactNode {
|
|
|
102
102
|
|
|
103
103
|
return (
|
|
104
104
|
<FormScreenShell testId="job-run-detail-screen" className="flex flex-col gap-6">
|
|
105
|
-
<Button
|
|
106
|
-
type="button"
|
|
107
|
-
variant="secondary"
|
|
108
|
-
onClick={() => nav.navigate({ screenId: JOB_RUNS_SCREEN_ID })}
|
|
109
|
-
testId="job-run-back"
|
|
110
|
-
>
|
|
111
|
-
{t("jobs.detail.back")}
|
|
112
|
-
</Button>
|
|
113
|
-
|
|
114
|
-
<Heading variant="page">{t("jobs.detail.title")}</Heading>
|
|
115
|
-
|
|
116
105
|
<Card slots={{ title: run.jobName }}>
|
|
117
106
|
<dl className="grid gap-3 text-sm">
|
|
118
107
|
<div>
|
|
119
108
|
<dt className="font-medium">{t("jobs.detail.field.status")}</dt>
|
|
120
109
|
<dd data-testid="job-run-status">{run.status}</dd>
|
|
121
110
|
</div>
|
|
111
|
+
<div>
|
|
112
|
+
<dt className="font-medium">{t("jobs.detail.field.started")}</dt>
|
|
113
|
+
<dd data-testid="job-run-started">{formatWhen(run.startedAt)}</dd>
|
|
114
|
+
</div>
|
|
115
|
+
{run.finishedAt !== undefined && run.finishedAt !== null && (
|
|
116
|
+
<div>
|
|
117
|
+
<dt className="font-medium">{t("jobs.detail.field.finished")}</dt>
|
|
118
|
+
<dd data-testid="job-run-finished">{formatWhen(run.finishedAt)}</dd>
|
|
119
|
+
</div>
|
|
120
|
+
)}
|
|
121
|
+
{run.duration !== undefined && run.duration !== null && (
|
|
122
|
+
<div>
|
|
123
|
+
<dt className="font-medium">{t("jobs.detail.field.duration")}</dt>
|
|
124
|
+
<dd data-testid="job-run-duration">{run.duration}</dd>
|
|
125
|
+
</div>
|
|
126
|
+
)}
|
|
122
127
|
<div>
|
|
123
128
|
<dt className="font-medium">{t("jobs.detail.field.id")}</dt>
|
|
124
129
|
<dd>
|
|
@@ -167,3 +172,11 @@ export function JobRunDetailScreen(): ReactNode {
|
|
|
167
172
|
</FormScreenShell>
|
|
168
173
|
);
|
|
169
174
|
}
|
|
175
|
+
|
|
176
|
+
function formatWhen(value: string): string {
|
|
177
|
+
try {
|
|
178
|
+
return new Date(value).toLocaleString();
|
|
179
|
+
} catch {
|
|
180
|
+
return value;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
// @runtime client
|
|
2
2
|
// SystemAdmin job-run list with link to detail screen.
|
|
3
3
|
|
|
4
|
-
import {
|
|
5
|
-
|
|
4
|
+
import {
|
|
5
|
+
type DataTableSort,
|
|
6
|
+
useDispatcher,
|
|
7
|
+
useNav,
|
|
8
|
+
usePrimitives,
|
|
9
|
+
useTranslation,
|
|
10
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
6
11
|
import { type ReactNode, useCallback, useEffect, useState } from "react";
|
|
7
12
|
import { JOB_RUN_DETAIL_SCREEN_ID, JobQueries } from "../constants";
|
|
8
13
|
|
|
@@ -33,11 +38,12 @@ const STATUS_FILTER_OPTIONS = [
|
|
|
33
38
|
|
|
34
39
|
export function JobRunsScreen(): ReactNode {
|
|
35
40
|
const t = useTranslation();
|
|
36
|
-
const { Banner,
|
|
41
|
+
const { Banner, DataTable, Field, Input, Text } = usePrimitives();
|
|
37
42
|
const dispatcher = useDispatcher();
|
|
38
43
|
const nav = useNav();
|
|
39
44
|
const [state, setState] = useState<State>({ kind: "loading" });
|
|
40
45
|
const [statusFilter, setStatusFilter] = useState("");
|
|
46
|
+
const [sort, setSort] = useState<DataTableSort | null>(null);
|
|
41
47
|
|
|
42
48
|
const filterOptions = STATUS_FILTER_OPTIONS.map((opt) => ({
|
|
43
49
|
value: opt.value,
|
|
@@ -65,80 +71,90 @@ export function JobRunsScreen(): ReactNode {
|
|
|
65
71
|
|
|
66
72
|
if (state.kind === "loading") {
|
|
67
73
|
return (
|
|
68
|
-
<
|
|
74
|
+
<div className="p-6" data-testid="job-runs-screen">
|
|
69
75
|
<Text variant="small">{t("jobs.runs.loading")}</Text>
|
|
70
|
-
</
|
|
76
|
+
</div>
|
|
71
77
|
);
|
|
72
78
|
}
|
|
73
79
|
|
|
74
80
|
if (state.kind === "error") {
|
|
75
81
|
return (
|
|
76
|
-
<
|
|
82
|
+
<div className="p-6" data-testid="job-runs-screen">
|
|
77
83
|
<Banner variant="error">{state.message}</Banner>
|
|
78
|
-
</
|
|
84
|
+
</div>
|
|
79
85
|
);
|
|
80
86
|
}
|
|
81
87
|
|
|
88
|
+
const openDetail = (id: string): void =>
|
|
89
|
+
nav.navigate({ screenId: JOB_RUN_DETAIL_SCREEN_ID, entityId: id });
|
|
90
|
+
|
|
82
91
|
return (
|
|
83
|
-
<
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
{
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
{
|
|
127
|
-
id: "open",
|
|
128
|
-
label: t("jobs.runs.open"),
|
|
129
|
-
style: "secondary",
|
|
130
|
-
onTrigger: (row) =>
|
|
131
|
-
nav.navigate({ screenId: JOB_RUN_DETAIL_SCREEN_ID, entityId: row.id }),
|
|
132
|
-
},
|
|
133
|
-
]}
|
|
134
|
-
rowActionMode="inline"
|
|
135
|
-
emptyState={<Text variant="small">{t("jobs.runs.empty")}</Text>}
|
|
136
|
-
/>
|
|
137
|
-
</Card>
|
|
138
|
-
</FormScreenShell>
|
|
92
|
+
<DataTable
|
|
93
|
+
testId="job-runs-table"
|
|
94
|
+
columns={[
|
|
95
|
+
{ field: "job", label: t("jobs.runs.col.job"), type: "string", sortable: true },
|
|
96
|
+
{ field: "status", label: t("jobs.runs.col.status"), type: "string", sortable: true },
|
|
97
|
+
{ field: "started", label: t("jobs.runs.col.started"), type: "string", sortable: true },
|
|
98
|
+
{ field: "duration", label: t("jobs.runs.col.duration"), type: "string", sortable: false },
|
|
99
|
+
]}
|
|
100
|
+
sort={sort}
|
|
101
|
+
onSortChange={setSort}
|
|
102
|
+
rows={sortJobRuns(state.rows, sort).map((row) => ({
|
|
103
|
+
id: row.id,
|
|
104
|
+
values: {
|
|
105
|
+
job: row.jobName,
|
|
106
|
+
status: row.status,
|
|
107
|
+
started: formatWhen(row.startedAt),
|
|
108
|
+
duration: row.duration ?? "—",
|
|
109
|
+
},
|
|
110
|
+
}))}
|
|
111
|
+
onRowClick={(row) => openDetail(row.id)}
|
|
112
|
+
toolbarStart={
|
|
113
|
+
<Field id="job-runs-status-filter" label={t("jobs.runs.filter.status")}>
|
|
114
|
+
<Input
|
|
115
|
+
kind="select"
|
|
116
|
+
id="job-runs-status-filter"
|
|
117
|
+
name="job-runs-status-filter"
|
|
118
|
+
value={statusFilter}
|
|
119
|
+
onChange={setStatusFilter}
|
|
120
|
+
options={filterOptions}
|
|
121
|
+
/>
|
|
122
|
+
</Field>
|
|
123
|
+
}
|
|
124
|
+
rowActions={[
|
|
125
|
+
{
|
|
126
|
+
id: "open",
|
|
127
|
+
label: t("jobs.runs.open"),
|
|
128
|
+
style: "secondary",
|
|
129
|
+
onTrigger: (row) => openDetail(row.id),
|
|
130
|
+
},
|
|
131
|
+
]}
|
|
132
|
+
rowActionMode="inline"
|
|
133
|
+
emptyState={<Text variant="small">{t("jobs.runs.empty")}</Text>}
|
|
134
|
+
/>
|
|
139
135
|
);
|
|
140
136
|
}
|
|
141
137
|
|
|
138
|
+
// Client-sort over the loaded page (≤50 rows). startedAt is an ISO string, so
|
|
139
|
+
// lexicographic compare is chronological — no Date parsing needed.
|
|
140
|
+
const SORT_ACCESSORS: Record<string, (r: JobRunRow) => string | number> = {
|
|
141
|
+
job: (r) => r.jobName,
|
|
142
|
+
status: (r) => r.status,
|
|
143
|
+
started: (r) => r.startedAt,
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
function sortJobRuns(rows: readonly JobRunRow[], sort: DataTableSort | null): readonly JobRunRow[] {
|
|
147
|
+
if (sort === null) return rows;
|
|
148
|
+
const accessor = SORT_ACCESSORS[sort.field];
|
|
149
|
+
if (accessor === undefined) return rows;
|
|
150
|
+
const factor = sort.dir === "asc" ? 1 : -1;
|
|
151
|
+
return [...rows].sort((a, b) => {
|
|
152
|
+
const av = accessor(a);
|
|
153
|
+
const bv = accessor(b);
|
|
154
|
+
return av < bv ? -factor : av > bv ? factor : 0;
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
142
158
|
function formatWhen(value: string): string {
|
|
143
159
|
try {
|
|
144
160
|
return new Date(value).toLocaleString();
|