@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.
@@ -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 { useDispatcher, usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
5
- import { FormScreenShell } from "@cosmicdrift/kumiko-renderer-web";
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, Card, DataTable, Field, Heading, Input, Text } = usePrimitives();
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 [detailId, setDetailId] = useState<string | null>(null);
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
- <FormScreenShell testId="audit-log-screen">
83
+ <div className="p-6" data-testid="audit-log-screen">
79
84
  <Text variant="small">{t("audit.log.loading")}</Text>
80
- </FormScreenShell>
85
+ </div>
81
86
  );
82
87
  }
83
88
 
84
89
  if (state.kind === "error") {
85
90
  return (
86
- <FormScreenShell testId="audit-log-screen">
91
+ <div className="p-6" data-testid="audit-log-screen">
87
92
  <Banner variant="error">{state.message}</Banner>
88
- </FormScreenShell>
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
- <FormScreenShell testId="audit-log-screen" className="flex max-w-5xl flex-col gap-6">
94
- <Heading variant="page">{t("audit.log.title")}</Heading>
95
-
96
- <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
97
- <Field id="audit-filter-event" label={t("audit.log.filter.eventType")}>
98
- <Input
99
- kind="text"
100
- id="audit-filter-event"
101
- name="audit-filter-event"
102
- value={filters.eventType}
103
- onChange={(v: string) => setFilters((f) => ({ ...f, eventType: v }))}
104
- />
105
- </Field>
106
- <Field id="audit-filter-aggregate" label={t("audit.log.filter.aggregateType")}>
107
- <Input
108
- kind="text"
109
- id="audit-filter-aggregate"
110
- name="audit-filter-aggregate"
111
- value={filters.aggregateType}
112
- onChange={(v) => setFilters((f) => ({ ...f, aggregateType: v }))}
113
- />
114
- </Field>
115
- <Field id="audit-filter-from" label={t("audit.log.filter.from")}>
116
- <Input
117
- kind="date"
118
- id="audit-filter-from"
119
- name="audit-filter-from"
120
- value={filters.from}
121
- onChange={(v) => setFilters((f) => ({ ...f, from: v ?? "" }))}
122
- />
123
- </Field>
124
- <Field id="audit-filter-to" label={t("audit.log.filter.to")}>
125
- <Input
126
- kind="date"
127
- id="audit-filter-to"
128
- name="audit-filter-to"
129
- value={filters.to}
130
- onChange={(v) => setFilters((f) => ({ ...f, to: v ?? "" }))}
131
- />
132
- </Field>
133
- </div>
134
- <div className="flex gap-2">
135
- <Button
136
- type="button"
137
- variant="primary"
138
- onClick={() => {
139
- setBefore(undefined);
140
- void load(undefined);
141
- }}
142
- testId="audit-log-apply-filters"
143
- >
144
- {t("audit.log.filter.apply")}
145
- </Button>
146
- <Button
147
- type="button"
148
- variant="secondary"
149
- onClick={() => {
150
- setFilters(EMPTY_FILTERS);
151
- setBefore(undefined);
152
- }}
153
- testId="audit-log-reset-filters"
154
- >
155
- {t("audit.log.filter.reset")}
156
- </Button>
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
- <Card options={{ padded: false }}>
160
- <DataTable
161
- testId="audit-log-table"
162
- columns={[
163
- { field: "when", label: t("audit.log.col.when"), type: "string", sortable: false },
164
- { field: "type", label: t("audit.log.col.type"), type: "string", sortable: false },
165
- {
166
- field: "aggregate",
167
- label: t("audit.log.col.aggregate"),
168
- type: "string",
169
- sortable: false,
170
- },
171
- { field: "actor", label: t("audit.log.col.actor"), type: "string", sortable: false },
172
- ]}
173
- rows={state.rows.map((row) => ({
174
- id: row.id,
175
- values: {
176
- when: formatWhen(row.createdAt),
177
- type: row.type,
178
- aggregate: `${row.aggregateType} / ${row.aggregateId}`,
179
- actor: row.createdBy,
180
- },
181
- }))}
182
- rowActions={[
183
- {
184
- id: "details",
185
- label: t("audit.log.details"),
186
- style: "secondary",
187
- onTrigger: (row) => setDetailId(row.id),
188
- },
189
- ]}
190
- rowActionMode="inline"
191
- emptyState={<Text variant="small">{t("audit.log.empty")}</Text>}
192
- />
193
- </Card>
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
- </FormScreenShell>
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
  }
@@ -1,4 +1,5 @@
1
1
  // @runtime client
2
2
 
3
+ export { AuditLogDetailScreen } from "./audit-log-detail-screen";
3
4
  export { AuditLogScreen } from "./audit-log-screen";
4
5
  export { type AuditClientOptions, auditClient } from "./client-plugin";
@@ -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, Heading, Text } = usePrimitives();
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 { useDispatcher, useNav, usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
5
- import { FormScreenShell } from "@cosmicdrift/kumiko-renderer-web";
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, Card, DataTable, Field, Heading, Input, Text } = usePrimitives();
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
- <FormScreenShell testId="job-runs-screen">
74
+ <div className="p-6" data-testid="job-runs-screen">
69
75
  <Text variant="small">{t("jobs.runs.loading")}</Text>
70
- </FormScreenShell>
76
+ </div>
71
77
  );
72
78
  }
73
79
 
74
80
  if (state.kind === "error") {
75
81
  return (
76
- <FormScreenShell testId="job-runs-screen">
82
+ <div className="p-6" data-testid="job-runs-screen">
77
83
  <Banner variant="error">{state.message}</Banner>
78
- </FormScreenShell>
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
- <FormScreenShell testId="job-runs-screen" className="flex max-w-5xl flex-col gap-6">
84
- <Heading variant="page">{t("jobs.runs.title")}</Heading>
85
-
86
- <Field id="job-runs-status-filter" label={t("jobs.runs.filter.status")}>
87
- <Input
88
- kind="select"
89
- id="job-runs-status-filter"
90
- name="job-runs-status-filter"
91
- value={statusFilter}
92
- onChange={setStatusFilter}
93
- options={filterOptions}
94
- />
95
- </Field>
96
-
97
- <Card options={{ padded: false }}>
98
- <DataTable
99
- testId="job-runs-table"
100
- columns={[
101
- { field: "job", label: t("jobs.runs.col.job"), type: "string", sortable: true },
102
- { field: "status", label: t("jobs.runs.col.status"), type: "string", sortable: true },
103
- {
104
- field: "started",
105
- label: t("jobs.runs.col.started"),
106
- type: "string",
107
- sortable: true,
108
- },
109
- {
110
- field: "duration",
111
- label: t("jobs.runs.col.duration"),
112
- type: "string",
113
- sortable: false,
114
- },
115
- ]}
116
- rows={state.rows.map((row) => ({
117
- id: row.id,
118
- values: {
119
- job: row.jobName,
120
- status: row.status,
121
- started: formatWhen(row.startedAt),
122
- duration: row.duration ?? "—",
123
- },
124
- }))}
125
- rowActions={[
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();