@scalio-cloud/ui 0.27.0 → 0.28.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/README.md CHANGED
@@ -306,7 +306,8 @@ that returns the view model described in `react/license.js`. Nexus does this: it
306
306
  add-ons and read-only notes (demo mode), and passes its trial notices as `statusExtra` and the reminder settings as `children`.
307
307
 
308
308
  Since 0.27.0 (ADR 0069) the panel shows three areas in the settings form (title and hint left, card right): «Status und
309
- Nutzung», «Add-ons», «Lizenzschlüssel». `children` with `extraTitle` (and `extraHint`) becomes a fourth area.
309
+ Nutzung», «Add-ons», «Lizenzschlüssel». `children` with `extraTitle` (and `extraHint`) becomes a fourth area. The panel passes its `lang` to every `client` call;
310
+ `licenseApi` sends it as `?lang=`, so scalio-license 0.4.0 renders warnings and errors in the language of the page.
310
311
 
311
312
  ## Gruppe System: Zurücksetzen and helpers (React)
312
313
 
@@ -324,6 +325,31 @@ const reset = factoryResetApi(api); // once, outside the component
324
325
  `SettingsField` (label, field, help) and `SettingsRow` (title and help left, a switch or button right; `tone="danger"`)
325
326
  keep the fields inside a `SettingsSection` the same in every app.
326
327
 
328
+ ## Protokoll: Aktionen und Jobs (React)
329
+
330
+ One menu item «Protokoll» with two tabs like Nexus (scalio-docs ADR 0070). The server side is scalio-platform
331
+ `audit_router` and `jobs_router` (0.7.0).
332
+
333
+ ```jsx
334
+ import { AuditNav, AuditLog, JobsPanel, auditApi, jobsApi } from "@scalio-cloud/ui/react";
335
+
336
+ const audit = auditApi(api); // GET /audit, /audit/export.csv
337
+ const jobs = jobsApi(api); // GET /jobs, /jobs/runs, /jobs/queue, POST /jobs/{key}/run, queue actions
338
+
339
+ <PageHeader title={L.auditLog} nav={<AuditNav link={NavLink} lang={lang} />} />
340
+ <AuditLog client={audit} lang={lang} notify={toast} actionLabel={(a, lang) => ACTIONS[lang][a]} tenantLabel="Kunde" />
341
+ <JobsPanel client={jobs} lang={lang} notify={toast} canRun={can("jobs.run")} link={Link} confirm={confirm} />
342
+ ```
343
+
344
+ - `AuditLog`: note «unveränderlich», chips with counts (categories from the server), search, filters for action,
345
+ status, tenant and time range, sortable columns, column picker (person, tenant, IP), CSV with the same filters,
346
+ «Ältere laden» (cursor), view with «alt → neu», cards on phones. Extras: `toolbar`, `rowActions(entry)`,
347
+ `renderDetail(entry)` (Nexus KI, Citadel PDF, Nexus steps).
348
+ - `JobsPanel`: tabs Warteschlange (only when the app has one), Läufe (filter by job and status) and Hintergrundjobs
349
+ (schedule, last run, last result, «Jetzt ausführen» with `canRun`); reloads every 15 seconds (`pollMs`). Extras:
350
+ `queueActions(item, reload)`, `renderQueueDetail(item, reload)`.
351
+ - `AuditNav`: the tabs `/audit` and `/jobs` (`base` for another prefix, `jobs={false}` hides Jobs).
352
+
327
353
  ## Command palette (React)
328
354
 
329
355
  Reads the same navigation data as the sidebar. Put it once into the layout; the sidebar search button opens it.
package/nav.css CHANGED
@@ -686,3 +686,62 @@
686
686
  .dark .scalio-selftest .is-ok, .dark .scalio-selftest li.is-ok .scalio-selftest-status { color: #4ade80; }
687
687
  .dark .scalio-selftest .is-warn, .dark .scalio-selftest li.is-warn .scalio-selftest-status { color: #fbbf24; }
688
688
  @media (max-width: 640px) { .scalio-selftest-run-head .scalio-btn { margin-left: 0; } }
689
+
690
+ /* Protokoll: Aktionen und Jobs (ADR 0070, react/audit.js) */
691
+ .scalio-audit, .scalio-jobs { display: flex; flex-direction: column; gap: 1rem; font-size: 0.875rem; }
692
+ .scalio-audit-note { display: flex; align-items: center; gap: 0.5rem; padding: 0.625rem 1rem; border: 1px solid hsl(var(--border)); background: hsl(var(--muted) / 0.5); color: hsl(var(--muted-foreground)); font-size: 0.75rem; border-radius: 2px; }
693
+ .scalio-audit-chips { display: flex; flex-wrap: wrap; gap: 0.5rem; }
694
+ .scalio-audit-chip { display: inline-flex; align-items: center; gap: 0.375rem; padding: 0.25rem 0.75rem; border: 1px solid hsl(var(--border)); border-radius: 999px; background: hsl(var(--card)); color: hsl(var(--muted-foreground)); font-size: 0.75rem; font-weight: 600; cursor: pointer; }
695
+ .scalio-audit-chip:hover { border-color: hsl(var(--primary) / 0.5); background: hsl(var(--primary) / 0.05); }
696
+ .scalio-audit-chip.is-active { border-color: hsl(var(--primary)); background: hsl(var(--primary)); color: hsl(var(--primary-foreground)); }
697
+ .scalio-audit-chip-count { font-family: 'JetBrains Mono', monospace; font-size: 0.625rem; opacity: 0.7; }
698
+ .scalio-audit-toolbar { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 0.5rem; }
699
+ .scalio-audit-toolbar select.scalio-input { width: auto; max-width: 14rem; }
700
+ .scalio-audit-search { position: relative; flex: 1 1 14rem; max-width: 20rem; display: flex; align-items: center; }
701
+ .scalio-audit-search .scalio-icon { position: absolute; left: 0.625rem; color: hsl(var(--muted-foreground)); }
702
+ .scalio-audit-search .scalio-input { width: 100%; padding-left: 2rem; }
703
+ .scalio-audit-date { display: flex; flex-direction: column; gap: 0.125rem; }
704
+ .scalio-audit-date span { color: hsl(var(--muted-foreground)); font-size: 0.6875rem; }
705
+ .scalio-audit-tools { margin-left: auto; display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; }
706
+ .scalio-audit-count, .scalio-audit-mono { font-family: 'JetBrains Mono', monospace; font-size: 0.75rem; }
707
+ .scalio-audit-count { color: hsl(var(--muted-foreground)); }
708
+ .scalio-audit-mono { white-space: nowrap; }
709
+ .scalio-audit-cols { position: relative; }
710
+ .scalio-audit-cols-menu { position: absolute; right: 0; top: calc(100% + 4px); z-index: 20; display: flex; flex-direction: column; gap: 0.375rem; min-width: 10rem; padding: 0.625rem 0.75rem; border: 1px solid hsl(var(--border)); background: hsl(var(--popover, var(--card))); border-radius: 2px; box-shadow: 0 6px 18px rgb(0 0 0 / 0.08); }
711
+ .scalio-audit-cols-menu label { display: flex; align-items: center; gap: 0.5rem; }
712
+ .scalio-audit-sort { background: none; border: 0; padding: 0; font: inherit; color: inherit; text-transform: inherit; letter-spacing: inherit; cursor: pointer; }
713
+ .scalio-audit-action { font-weight: 600; color: hsl(var(--foreground)); }
714
+ .scalio-audit-sub { margin-top: 0.125rem; color: hsl(var(--muted-foreground)); font-size: 0.75rem; overflow-wrap: anywhere; }
715
+ .scalio-audit-table tr.is-error td:first-child { box-shadow: inset 3px 0 0 hsl(var(--destructive)); }
716
+ .scalio-audit-row-actions { text-align: right; white-space: nowrap; }
717
+ .scalio-audit-row-actions .scalio-btn { margin-left: 0.25rem; }
718
+ .scalio-audit-empty { padding: 2.5rem 1rem; text-align: center; color: hsl(var(--muted-foreground)); }
719
+ .scalio-audit-more { display: flex; justify-content: center; }
720
+ .scalio-audit-cards { display: none; flex-direction: column; gap: 0.75rem; }
721
+ .scalio-audit-card { padding: 1rem; border: 1px solid hsl(var(--border)); background: hsl(var(--card)); border-radius: 2px; }
722
+ .scalio-audit-card-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; margin-top: 0.5rem; color: hsl(var(--muted-foreground)); font-family: 'JetBrains Mono', monospace; font-size: 0.6875rem; }
723
+ .scalio-audit-card .scalio-audit-row-actions { margin-top: 0.5rem; text-align: left; border-top: 1px solid hsl(var(--border)); padding-top: 0.5rem; }
724
+ @media (max-width: 767px) {
725
+ .scalio-audit-table-wrap { display: none; }
726
+ .scalio-audit-cards { display: flex; }
727
+ .scalio-audit-tools { margin-left: 0; }
728
+ }
729
+ .scalio-audit-dialog { max-width: 42rem; }
730
+ .scalio-audit-dl { display: grid; grid-template-columns: 8rem 1fr; gap: 0.375rem 0.75rem; margin: 0; font-size: 0.8125rem; }
731
+ .scalio-audit-dl dt { color: hsl(var(--muted-foreground)); }
732
+ .scalio-audit-dl dd { margin: 0; overflow-wrap: anywhere; }
733
+ .scalio-audit-changes { margin-top: 1rem; border: 1px solid hsl(var(--border)); border-radius: 2px; }
734
+ .scalio-audit-changes-title { padding: 0.375rem 0.75rem; border-bottom: 1px solid hsl(var(--border)); background: hsl(var(--muted) / 0.5); color: hsl(var(--muted-foreground)); font-size: 0.625rem; font-weight: 700; letter-spacing: 0.15em; text-transform: uppercase; }
735
+ .scalio-audit-change { display: grid; grid-template-columns: 8rem auto auto 1fr; align-items: center; gap: 0.5rem; padding: 0.375rem 0.75rem; font-size: 0.75rem; border-top: 1px solid hsl(var(--border)); }
736
+ .scalio-audit-change:first-of-type { border-top: 0; }
737
+ .scalio-audit-field { font-weight: 600; }
738
+ .scalio-audit-old { font-family: 'JetBrains Mono', monospace; color: hsl(var(--muted-foreground)); text-decoration: line-through; }
739
+ .scalio-audit-new { font-family: 'JetBrains Mono', monospace; font-weight: 600; color: #15803d; }
740
+ .dark .scalio-audit-new { color: #4ade80; }
741
+ .scalio-jobs-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr)); gap: 1px; background: hsl(var(--border)); border: 1px solid hsl(var(--border)); }
742
+ .scalio-jobs-card { display: flex; flex-direction: column; gap: 0.75rem; padding: 1.25rem; background: hsl(var(--card)); }
743
+ .scalio-jobs-card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 0.75rem; }
744
+ .scalio-jobs-name { font-family: 'Chivo', sans-serif; font-weight: 700; }
745
+ .scalio-jobs-card-foot { margin-top: auto; display: flex; align-items: center; justify-content: flex-end; gap: 0.5rem; }
746
+ .scalio-jobs-steps { margin: 0; padding-left: 1.25rem; display: flex; flex-direction: column; gap: 0.375rem; }
747
+ .scalio-jobs-steps li { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scalio-cloud/ui",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "description": "Scalio Control Room design tokens, Tailwind preset, self-hosted fonts, the shared app header, sign-in layout, sidebar and page blocks",
5
5
  "license": "SEE LICENSE IN README.md",
6
6
  "private": false,
package/react/audit.js ADDED
@@ -0,0 +1,619 @@
1
+ "use strict";
2
+ // Protokoll in every app like Nexus (scalio-docs ADR 0070, prot/P1-P9): one menu item «Protokoll» with the tabs
3
+ // Aktionen (/audit) and Jobs (/jobs). AuditLog is the tab Aktionen, JobsPanel the tab Jobs, AuditNav the two tabs.
4
+ // auditApi / jobsApi map the routes of scalio-platform `audit_router` and `jobs_router`.
5
+ const React = require("react");
6
+ const h = React.createElement;
7
+ const Icons = require("./icons");
8
+ const { SubNav } = require("./nav");
9
+
10
+ const LABELS = {
11
+ de: {
12
+ tabActions: "Aktionen",
13
+ tabJobs: "Jobs",
14
+ actionsTitle: "Protokoll",
15
+ actionsHint: "Wer hat wann was gemacht. Einträge werden 365 Tage aufbewahrt.",
16
+ immutable: "Dies ist ein unveränderliches Protokoll: jede Aktion wird erfasst und kann hier weder bearbeitet noch gelöscht werden.",
17
+ all: "Alle",
18
+ search: "Suchen nach Person, Objekt, Text…",
19
+ allActions: "Alle Aktionen",
20
+ allStatuses: "Alle Status",
21
+ allTenants: "Alle Tenants",
22
+ from: "Von",
23
+ to: "Bis",
24
+ reset: "Filter zurücksetzen",
25
+ columns: "Spalten",
26
+ exportCsv: "CSV",
27
+ exported: "CSV heruntergeladen.",
28
+ time: "Zeit",
29
+ person: "Person",
30
+ action: "Aktion",
31
+ object: "Objekt",
32
+ tenant: "Tenant",
33
+ status: "Status",
34
+ ip: "IP",
35
+ view: "Ansehen",
36
+ more: "Ältere laden",
37
+ empty: "Keine passenden Einträge.",
38
+ system: "System",
39
+ changes: "Änderungen (alt → neu)",
40
+ details: "Details",
41
+ close: "Schliessen",
42
+ statuses: { ok: "Erfolgreich", error: "Fehler", blocked: "Blockiert", warning: "Warnung", queued: "Wartet", running: "Läuft",
43
+ waiting: "Wartet", finished: "Fertig", cancelled: "Abgebrochen", done: "Fertig" },
44
+ jobsTitle: "Jobs",
45
+ jobsHint: "Hintergrundjobs, ihre Läufe und wartende Arbeit dieser Instanz.",
46
+ queue: "Warteschlange",
47
+ runs: "Läufe",
48
+ background: "Hintergrundjobs",
49
+ schedule: "Zeitplan",
50
+ lastRun: "Letzter Lauf",
51
+ never: "Noch nie gelaufen",
52
+ runNow: "Jetzt ausführen",
53
+ requested: "Angefordert",
54
+ queued: "Angefordert. Der Job startet in wenigen Sekunden.",
55
+ ranNow: "Job ausgeführt.",
56
+ job: "Job",
57
+ trigger: "Auslöser",
58
+ triggers: { schedule: "Zeitplan", manual: "von Hand" },
59
+ duration: "Dauer",
60
+ result: "Ergebnis",
61
+ allJobs: "Alle Jobs",
62
+ noRuns: "Noch keine Läufe.",
63
+ noQueue: "Nichts in der Warteschlange.",
64
+ type: "Typ",
65
+ created: "Erstellt",
66
+ by: "Von",
67
+ cancel: "Abbrechen",
68
+ retry: "Erneut versuchen",
69
+ delete: "Löschen",
70
+ open: "Öffnen",
71
+ steps: "Schritte",
72
+ done: "Erledigt.",
73
+ },
74
+ en: {
75
+ tabActions: "Actions",
76
+ tabJobs: "Jobs",
77
+ actionsTitle: "Activity log",
78
+ actionsHint: "Who did what and when. Entries are kept for 365 days.",
79
+ immutable: "This is an immutable log: every action is recorded and cannot be edited or deleted here.",
80
+ all: "All",
81
+ search: "Search by person, object, text…",
82
+ allActions: "All actions",
83
+ allStatuses: "All statuses",
84
+ allTenants: "All tenants",
85
+ from: "From",
86
+ to: "To",
87
+ reset: "Reset filters",
88
+ columns: "Columns",
89
+ exportCsv: "CSV",
90
+ exported: "CSV downloaded.",
91
+ time: "Time",
92
+ person: "Person",
93
+ action: "Action",
94
+ object: "Object",
95
+ tenant: "Tenant",
96
+ status: "Status",
97
+ ip: "IP",
98
+ view: "View",
99
+ more: "Load older",
100
+ empty: "No matching entries.",
101
+ system: "System",
102
+ changes: "Changes (old → new)",
103
+ details: "Details",
104
+ close: "Close",
105
+ statuses: { ok: "Succeeded", error: "Failed", blocked: "Blocked", warning: "Warning", queued: "Queued", running: "Running",
106
+ waiting: "Waiting", finished: "Finished", cancelled: "Cancelled", done: "Done" },
107
+ jobsTitle: "Jobs",
108
+ jobsHint: "Background jobs, their runs and queued work of this instance.",
109
+ queue: "Queue",
110
+ runs: "Runs",
111
+ background: "Background jobs",
112
+ schedule: "Schedule",
113
+ lastRun: "Last run",
114
+ never: "Never ran",
115
+ runNow: "Run now",
116
+ requested: "Requested",
117
+ queued: "Requested. The job starts within a few seconds.",
118
+ ranNow: "Job ran.",
119
+ job: "Job",
120
+ trigger: "Trigger",
121
+ triggers: { schedule: "schedule", manual: "by hand" },
122
+ duration: "Duration",
123
+ result: "Result",
124
+ allJobs: "All jobs",
125
+ noRuns: "No runs yet.",
126
+ noQueue: "Nothing queued.",
127
+ type: "Type",
128
+ created: "Created",
129
+ by: "By",
130
+ cancel: "Cancel",
131
+ retry: "Retry",
132
+ delete: "Delete",
133
+ open: "Open",
134
+ steps: "Steps",
135
+ done: "Done.",
136
+ },
137
+ };
138
+
139
+ function auditLabels(lang, overrides) {
140
+ return Object.assign({}, LABELS[lang === "en" ? "en" : "de"], overrides || {});
141
+ }
142
+
143
+ const TONE = { ok: "ok", finished: "ok", done: "ok", error: "error", blocked: "error", warning: "warn", running: "running",
144
+ queued: "neutral", waiting: "neutral", cancelled: "neutral" };
145
+
146
+ function cx() {
147
+ return Array.prototype.slice.call(arguments).filter(Boolean).join(" ");
148
+ }
149
+
150
+ function errorText(err) {
151
+ const d = err && err.response && err.response.data && err.response.data.detail;
152
+ return typeof d === "string" ? d : (err && err.message) || String(err);
153
+ }
154
+
155
+ function fmtTime(iso, lang) {
156
+ if (!iso) return "";
157
+ try {
158
+ return new Date(iso).toLocaleString(lang === "en" ? "en-GB" : "de-CH", { dateStyle: "short", timeStyle: "medium" });
159
+ } catch (e) {
160
+ return iso;
161
+ }
162
+ }
163
+
164
+ function fmtDuration(sec) {
165
+ if (sec === null || sec === undefined) return "";
166
+ if (sec < 1) return `${Math.round(sec * 1000)} ms`;
167
+ if (sec < 60) return `${Math.round(sec * 10) / 10} s`;
168
+ return `${Math.floor(sec / 60)} min ${Math.round(sec % 60)} s`;
169
+ }
170
+
171
+ function fmtValue(v) {
172
+ if (v === null || v === undefined || v === "") return "–";
173
+ return Array.isArray(v) ? v.join(", ") : String(v);
174
+ }
175
+
176
+ function clean(params) {
177
+ const out = {};
178
+ Object.keys(params || {}).forEach((k) => {
179
+ const v = params[k];
180
+ if (v !== "" && v !== null && v !== undefined && v !== "all") out[k] = v;
181
+ });
182
+ return out;
183
+ }
184
+
185
+ // api: axios instance with the app's base URL (/api).
186
+ function auditApi(api, base) {
187
+ const p = base || "/audit";
188
+ const data = (x) => x.then((r) => r.data);
189
+ return {
190
+ list: (params) => data(api.get(p, { params: clean(params) })),
191
+ exportCsv: (params) => data(api.get(`${p}/export.csv`, { params: clean(params), responseType: "blob" })),
192
+ };
193
+ }
194
+
195
+ function jobsApi(api, base) {
196
+ const p = base || "/jobs";
197
+ const data = (x) => x.then((r) => r.data);
198
+ return {
199
+ list: () => data(api.get(p)),
200
+ runs: (params) => data(api.get(`${p}/runs`, { params: clean(params) })),
201
+ run: (key) => data(api.post(`${p}/${encodeURIComponent(key)}/run`)),
202
+ queue: (params) => data(api.get(`${p}/queue`, { params: clean(params) })),
203
+ cancel: (id) => data(api.post(`${p}/queue/${encodeURIComponent(id)}/cancel`)),
204
+ retry: (id) => data(api.post(`${p}/queue/${encodeURIComponent(id)}/retry`)),
205
+ remove: (id) => data(api.delete(`${p}/queue/${encodeURIComponent(id)}`)),
206
+ };
207
+ }
208
+
209
+ // The two tabs under the menu item «Protokoll». Props: link (router NavLink), activePath, lang, labels, jobs
210
+ // (false hides the tab Jobs), base ({audit: "/audit", jobs: "/jobs"}) for apps under another prefix (Compass).
211
+ function AuditNav(props) {
212
+ const L = auditLabels(props.lang, props.labels);
213
+ const base = Object.assign({ audit: "/audit", jobs: "/jobs" }, props.base || {});
214
+ return h(SubNav, {
215
+ link: props.link, activePath: props.activePath, testId: props.testId || "audit-subnav",
216
+ items: [
217
+ { to: base.audit, label: L.tabActions, end: true, testId: "subnav-actions" },
218
+ { to: base.jobs, label: L.tabJobs, testId: "subnav-jobs", hidden: props.jobs === false },
219
+ ],
220
+ });
221
+ }
222
+
223
+ function Badge({ status, L, testId }) {
224
+ return h("span", { className: `scalio-badge is-${TONE[status] || "neutral"}`, "data-testid": testId },
225
+ (L.statuses && L.statuses[status]) || status || "–");
226
+ }
227
+
228
+ function Dialog({ title, onClose, children, testId, closeLabel }) {
229
+ React.useEffect(() => {
230
+ const key = (e) => { if (e.key === "Escape") onClose(); };
231
+ document.addEventListener("keydown", key);
232
+ return () => document.removeEventListener("keydown", key);
233
+ }, [onClose]);
234
+ return h("div", { className: "scalio-dialog-root" },
235
+ h("div", { className: "scalio-dialog-backdrop", onClick: onClose }),
236
+ h("div", { className: "scalio-dialog scalio-audit-dialog", role: "dialog", "aria-modal": "true", "aria-label": title, "data-testid": testId },
237
+ h("div", { className: "scalio-dialog-head" },
238
+ h("div", { className: "scalio-dialog-title" }, title),
239
+ h("button", { type: "button", className: "scalio-btn is-ghost scalio-btn-icon", onClick: onClose, "aria-label": closeLabel || "×", "data-testid": "dialog-close" },
240
+ h(Icons.X, { className: "scalio-icon" }))),
241
+ children));
242
+ }
243
+
244
+ function who(e, L) {
245
+ const a = e.actor || {};
246
+ if (a.kind === "system") return a.name || L.system;
247
+ return a.name || a.email || "–";
248
+ }
249
+
250
+ function download(blob, name) {
251
+ if (typeof window === "undefined" || !window.URL || !window.URL.createObjectURL) return;
252
+ const url = window.URL.createObjectURL(blob);
253
+ const a = document.createElement("a");
254
+ a.href = url;
255
+ a.download = name;
256
+ document.body.appendChild(a);
257
+ a.click();
258
+ a.remove();
259
+ window.URL.revokeObjectURL(url);
260
+ }
261
+
262
+ const COLS = ["person", "tenant", "ip"];
263
+
264
+ function loadCols(key) {
265
+ try {
266
+ const raw = window.localStorage.getItem(key);
267
+ return raw ? JSON.parse(raw) : null;
268
+ } catch (e) {
269
+ return null;
270
+ }
271
+ }
272
+
273
+ // Tab Aktionen. Props: client (auditApi), lang, labels, notify(text, tone), actionLabel(key, lang) for readable
274
+ // action names, categoryLabel (optional), showTenant (default: when the server lists tenants), tenantLabel ("Kunde"),
275
+ // toolbar (extra buttons, e.g. Nexus KI or Citadel PDF), rowActions(entry) (extra buttons per row), renderDetail(entry)
276
+ // (extra content in the view, e.g. Nexus workflow steps), initialCategory, pageSize, storageKey, testId.
277
+ function AuditLog(props) {
278
+ const { client, notify } = props;
279
+ const lang = props.lang === "en" ? "en" : "de";
280
+ const L = auditLabels(lang, props.labels);
281
+ const say = (text, tone) => (notify ? notify(text, tone || "success") : null);
282
+ const actionLabel = (a) => (props.actionLabel ? props.actionLabel(a, lang) || a : a);
283
+ const [filters, setFilters] = React.useState({ category: props.initialCategory || "all", action: "all", status: "all", tenant: "", since: "", until: "" });
284
+ const [q, setQ] = React.useState("");
285
+ const [query, setQuery] = React.useState("");
286
+ const [data, setData] = React.useState(null);
287
+ const [items, setItems] = React.useState([]);
288
+ const [error, setError] = React.useState("");
289
+ const [busy, setBusy] = React.useState(false);
290
+ const [open, setOpen] = React.useState(null);
291
+ const [sort, setSort] = React.useState({ key: "ts", dir: -1 });
292
+ const storageKey = props.storageKey || "scalio-audit-cols";
293
+ const [cols, setCols] = React.useState(() => Object.assign({ person: true, tenant: true, ip: false }, (typeof window !== "undefined" && loadCols(storageKey)) || {}));
294
+ const [colsOpen, setColsOpen] = React.useState(false);
295
+
296
+ React.useEffect(() => {
297
+ const t = setTimeout(() => setQuery(q.trim()), 300);
298
+ return () => clearTimeout(t);
299
+ }, [q]);
300
+
301
+ const params = React.useMemo(() => Object.assign({}, filters, { q: query, limit: props.pageSize || 200 }), [filters, query, props.pageSize]);
302
+
303
+ const load = React.useCallback(async () => {
304
+ try {
305
+ const d = await client.list(params);
306
+ setData(d);
307
+ setItems(d.items || []);
308
+ setError("");
309
+ } catch (err) {
310
+ setError(errorText(err));
311
+ }
312
+ }, [client, params]);
313
+
314
+ React.useEffect(() => { load(); }, [load]);
315
+
316
+ const more = async () => {
317
+ if (!data || !data.next) return;
318
+ setBusy(true);
319
+ try {
320
+ const d = await client.list(Object.assign({}, params, { before: data.next }));
321
+ setItems(items.concat(d.items || []));
322
+ setData(Object.assign({}, data, { next: d.next }));
323
+ } catch (err) {
324
+ say(errorText(err), "error");
325
+ } finally {
326
+ setBusy(false);
327
+ }
328
+ };
329
+
330
+ const exportCsv = async () => {
331
+ setBusy(true);
332
+ try {
333
+ const blob = await client.exportCsv(Object.assign({}, filters, { q: query, lang }));
334
+ download(blob, lang === "en" ? "activity-log.csv" : "protokoll.csv");
335
+ say(L.exported);
336
+ } catch (err) {
337
+ say(errorText(err), "error");
338
+ } finally {
339
+ setBusy(false);
340
+ }
341
+ };
342
+
343
+ const setCol = (k, v) => {
344
+ const next = Object.assign({}, cols, { [k]: v });
345
+ setCols(next);
346
+ try { window.localStorage.setItem(storageKey, JSON.stringify(next)); } catch (e) { /* private window */ }
347
+ };
348
+
349
+ const set = (k, v) => setFilters(Object.assign({}, filters, { [k]: v }));
350
+ const filtered = !!(query || filters.action !== "all" || filters.status !== "all" || filters.tenant || filters.since || filters.until || filters.category !== "all");
351
+ const reset = () => { setQ(""); setQuery(""); setFilters({ category: "all", action: "all", status: "all", tenant: "", since: "", until: "" }); };
352
+
353
+ if (!data) return error ? h("div", { className: "scalio-notice is-error", role: "alert" }, error) : null;
354
+
355
+ const counts = data.counts || {};
356
+ const cats = data.categories || [];
357
+ const tenants = data.tenants || [];
358
+ const showTenant = props.showTenant !== undefined ? props.showTenant : tenants.length > 0;
359
+ const tenantName = (id) => (tenants.find((t) => t.id === id) || {}).name || id || "–";
360
+ const catName = (c) => (props.categoryLabel ? props.categoryLabel(c, lang) : c[lang] || c.de || c.key);
361
+
362
+ const sortVal = {
363
+ ts: (e) => e.ts || "",
364
+ person: (e) => who(e, L).toLowerCase(),
365
+ action: (e) => actionLabel(e.action).toLowerCase(),
366
+ status: (e) => e.status || "",
367
+ };
368
+ const rows = items.slice().sort((a, b) => {
369
+ const f = sortVal[sort.key] || sortVal.ts;
370
+ const x = f(a);
371
+ const y = f(b);
372
+ return x < y ? -sort.dir : x > y ? sort.dir : 0;
373
+ });
374
+ const th = (key, label, cls) => h("th", { className: cls, key },
375
+ sortVal[key] ? h("button", { type: "button", className: "scalio-audit-sort", "data-testid": `audit-sort-${key}`,
376
+ onClick: () => setSort({ key, dir: sort.key === key ? -sort.dir : key === "ts" ? -1 : 1 }) },
377
+ label, sort.key === key ? (sort.dir < 0 ? " ↓" : " ↑") : "") : label);
378
+
379
+ const showPerson = cols.person !== false;
380
+ const showT = showTenant && cols.tenant !== false;
381
+ const showIp = !!cols.ip;
382
+ const colCount = 4 + (showPerson ? 1 : 0) + (showT ? 1 : 0) + (showIp ? 1 : 0);
383
+ const subline = (e) => [e.target && (e.target.name || e.target.id), e.detail].filter(Boolean).join(" · ");
384
+
385
+ return h("section", { className: "scalio-audit", "data-testid": props.testId || "audit-log" },
386
+ h("div", { className: "scalio-audit-note", "data-testid": "audit-immutable-note" },
387
+ h(Icons.KeyRound, { className: "scalio-icon", width: 13, height: 13 }), L.immutable),
388
+ cats.length ? h("div", { className: "scalio-audit-chips", "data-testid": "audit-chips" },
389
+ [{ key: "all", always: true }].concat(cats).filter((c) => c.always || c.key === filters.category || (counts[c.key] || 0) > 0).map((c) =>
390
+ h("button", { key: c.key, type: "button", className: cx("scalio-audit-chip", filters.category === c.key && "is-active"),
391
+ "data-testid": `audit-cat-${c.key}`, onClick: () => setFilters(Object.assign({}, filters, { category: c.key, action: "all" })) },
392
+ c.key === "all" ? L.all : catName(c), h("span", { className: "scalio-audit-chip-count" }, counts[c.key] || 0)))) : null,
393
+ h("div", { className: "scalio-audit-toolbar" },
394
+ h("label", { className: "scalio-audit-search" },
395
+ h(Icons.Search, { className: "scalio-icon", width: 14, height: 14 }),
396
+ h("input", { className: "scalio-input", value: q, placeholder: L.search, "aria-label": L.search, "data-testid": "audit-search",
397
+ onChange: (e) => setQ(e.target.value) })),
398
+ h("select", { className: "scalio-input", value: filters.action, "aria-label": L.action, "data-testid": "audit-filter-action", onChange: (e) => set("action", e.target.value) },
399
+ [h("option", { key: "all", value: "all" }, L.allActions)].concat((data.actions || []).map((a) => h("option", { key: a, value: a }, actionLabel(a))))),
400
+ h("select", { className: "scalio-input", value: filters.status, "aria-label": L.status, "data-testid": "audit-filter-status", onChange: (e) => set("status", e.target.value) },
401
+ ["all", "ok", "error", "blocked"].map((s) => h("option", { key: s, value: s }, s === "all" ? L.allStatuses : L.statuses[s]))),
402
+ showTenant ? h("select", { className: "scalio-input", value: filters.tenant, "aria-label": props.tenantLabel || L.tenant, "data-testid": "audit-filter-tenant", onChange: (e) => set("tenant", e.target.value) },
403
+ [h("option", { key: "", value: "" }, props.allTenantsLabel || L.allTenants)].concat(tenants.map((t) => h("option", { key: t.id, value: t.id }, t.name || t.id)))) : null,
404
+ h("label", { className: "scalio-audit-date" }, h("span", null, L.from),
405
+ h("input", { type: "date", className: "scalio-input", value: filters.since, "data-testid": "audit-filter-since", onChange: (e) => set("since", e.target.value) })),
406
+ h("label", { className: "scalio-audit-date" }, h("span", null, L.to),
407
+ h("input", { type: "date", className: "scalio-input", value: filters.until, "data-testid": "audit-filter-until", onChange: (e) => set("until", e.target.value) })),
408
+ filtered ? h("button", { type: "button", className: "scalio-btn is-ghost", "data-testid": "audit-reset", onClick: reset }, L.reset) : null,
409
+ h("div", { className: "scalio-audit-tools" },
410
+ h("span", { className: "scalio-audit-count", "data-testid": "audit-count" }, `${items.length} / ${counts.all !== undefined ? counts.all : items.length}`),
411
+ props.toolbar || null,
412
+ h("div", { className: "scalio-audit-cols" },
413
+ h("button", { type: "button", className: "scalio-btn", "aria-expanded": colsOpen, "data-testid": "audit-columns", onClick: () => setColsOpen(!colsOpen) }, L.columns),
414
+ colsOpen ? h("div", { className: "scalio-audit-cols-menu", "data-testid": "audit-columns-menu" },
415
+ COLS.filter((k) => k !== "tenant" || showTenant).map((k) => h("label", { key: k },
416
+ h("input", { type: "checkbox", checked: k === "ip" ? !!cols[k] : cols[k] !== false, "data-testid": `audit-col-${k}`, onChange: (e) => setCol(k, e.target.checked) }),
417
+ k === "tenant" ? props.tenantLabel || L.tenant : L[k]))) : null),
418
+ h("button", { type: "button", className: "scalio-btn", disabled: busy, "data-testid": "audit-export-csv", onClick: exportCsv },
419
+ h(Icons.Download, { className: "scalio-icon" }), L.exportCsv))),
420
+ error ? h("div", { className: "scalio-notice is-error", role: "alert" }, error) : null,
421
+ h("div", { className: "scalio-users-table-wrap scalio-audit-table-wrap" },
422
+ h("table", { className: "scalio-users-table scalio-audit-table", "data-testid": "audit-table" },
423
+ h("thead", null, h("tr", null,
424
+ th("ts", L.time),
425
+ showPerson ? th("person", L.person) : null,
426
+ th("action", L.action),
427
+ showT ? th("tenant", props.tenantLabel || L.tenant) : null,
428
+ showIp ? th("ip", L.ip) : null,
429
+ th("status", L.status),
430
+ h("th", { key: "x" }))),
431
+ h("tbody", null,
432
+ rows.length === 0 ? h("tr", null, h("td", { colSpan: colCount, className: "scalio-audit-empty", "data-testid": "audit-empty" }, L.empty)) : null,
433
+ rows.map((e) => h("tr", { key: e.id, "data-testid": "audit-row", className: e.status !== "ok" ? "is-error" : undefined },
434
+ h("td", { className: "scalio-audit-mono" }, fmtTime(e.ts, lang)),
435
+ showPerson ? h("td", null, who(e, L)) : null,
436
+ h("td", null,
437
+ h("div", { className: "scalio-audit-action" }, actionLabel(e.action)),
438
+ subline(e) ? h("div", { className: "scalio-audit-sub" }, subline(e)) : null),
439
+ showT ? h("td", null, e.tenant ? tenantName(e.tenant) : "–") : null,
440
+ showIp ? h("td", { className: "scalio-audit-mono" }, e.ip || "–") : null,
441
+ h("td", null, h(Badge, { status: e.status, L })),
442
+ h("td", { className: "scalio-audit-row-actions" },
443
+ props.rowActions ? props.rowActions(e) : null,
444
+ h("button", { type: "button", className: "scalio-btn is-ghost", "data-testid": `audit-view-${e.id}`, onClick: () => setOpen(e) }, L.view))))))),
445
+ h("div", { className: "scalio-audit-cards", "data-testid": "audit-cards" },
446
+ rows.length === 0 ? h("div", { className: "scalio-audit-empty" }, L.empty) : null,
447
+ rows.map((e) => h("div", { key: e.id, className: "scalio-audit-card", "data-testid": "audit-card" },
448
+ h("div", { className: "scalio-audit-action" }, actionLabel(e.action)),
449
+ subline(e) ? h("div", { className: "scalio-audit-sub" }, subline(e)) : null,
450
+ h("div", { className: "scalio-audit-card-meta" }, h(Badge, { status: e.status, L }), h("span", null, `${who(e, L)} · ${fmtTime(e.ts, lang)}`)),
451
+ h("div", { className: "scalio-audit-row-actions" },
452
+ props.rowActions ? props.rowActions(e) : null,
453
+ h("button", { type: "button", className: "scalio-btn is-ghost", onClick: () => setOpen(e) }, L.view))))),
454
+ data.next ? h("div", { className: "scalio-audit-more" },
455
+ h("button", { type: "button", className: "scalio-btn", disabled: busy, "data-testid": "audit-more", onClick: more }, L.more)) : null,
456
+ open ? h(Dialog, { title: actionLabel(open.action), onClose: () => setOpen(null), testId: "audit-detail", closeLabel: L.close },
457
+ h("dl", { className: "scalio-audit-dl" },
458
+ h("dt", null, L.time), h("dd", null, fmtTime(open.ts, lang)),
459
+ h("dt", null, L.person), h("dd", null, who(open, L), open.actor && open.actor.email && open.actor.name ? ` (${open.actor.email})` : ""),
460
+ h("dt", null, L.status), h("dd", null, h(Badge, { status: open.status, L })),
461
+ open.target ? [h("dt", { key: "t" }, L.object), h("dd", { key: "tv" }, [open.target.type, open.target.name || open.target.id].filter(Boolean).join(" · "))] : null,
462
+ open.tenant ? [h("dt", { key: "n" }, props.tenantLabel || L.tenant), h("dd", { key: "nv" }, tenantName(open.tenant))] : null,
463
+ open.ip ? [h("dt", { key: "i" }, L.ip), h("dd", { key: "iv", className: "scalio-audit-mono" }, open.ip)] : null,
464
+ open.detail ? [h("dt", { key: "d" }, L.details), h("dd", { key: "dv" }, open.detail)] : null),
465
+ open.changes && open.changes.length ? h("div", { className: "scalio-audit-changes", "data-testid": "audit-changes" },
466
+ h("div", { className: "scalio-audit-changes-title" }, L.changes),
467
+ open.changes.map((c, i) => h("div", { key: i, className: "scalio-audit-change" },
468
+ h("span", { className: "scalio-audit-field" }, c.field),
469
+ h("span", { className: "scalio-audit-old" }, fmtValue(c.old)),
470
+ h("span", null, "→"),
471
+ h("span", { className: "scalio-audit-new" }, fmtValue(c.new))))) : null,
472
+ props.renderDetail ? props.renderDetail(open) : null) : null);
473
+ }
474
+
475
+ function jobName(job, lang) {
476
+ return (job && (job[lang] || job.de || job.key)) || "";
477
+ }
478
+
479
+ // Tab Jobs. Props: client (jobsApi), lang, labels, notify(text, tone), confirm({title, text, action, danger}),
480
+ // canRun (may run jobs and act on the queue), link (router Link for queue items with `link`), pollMs (default 15000),
481
+ // renderQueueDetail(item) (extra content, e.g. Nexus steps and debug), queueActions(item) (extra buttons), testId.
482
+ function JobsPanel(props) {
483
+ const { client, notify } = props;
484
+ const lang = props.lang === "en" ? "en" : "de";
485
+ const L = auditLabels(lang, props.labels);
486
+ const say = (text, tone) => (notify ? notify(text, tone || "success") : null);
487
+ const pollMs = props.pollMs === undefined ? 15000 : props.pollMs;
488
+ const [info, setInfo] = React.useState(null);
489
+ const [runs, setRuns] = React.useState([]);
490
+ const [queue, setQueue] = React.useState([]);
491
+ const [tab, setTab] = React.useState(props.initialTab || "");
492
+ const [runFilter, setRunFilter] = React.useState({ key: "", status: "" });
493
+ const [busy, setBusy] = React.useState("");
494
+ const [error, setError] = React.useState("");
495
+ const [open, setOpen] = React.useState(null);
496
+
497
+ const load = React.useCallback(async () => {
498
+ try {
499
+ const i = await client.list();
500
+ const [r, qq] = await Promise.all([client.runs(runFilter), i.queue ? client.queue() : Promise.resolve([])]);
501
+ setInfo(i);
502
+ setRuns(r || []);
503
+ setQueue(qq || []);
504
+ setError("");
505
+ } catch (err) {
506
+ setError(errorText(err));
507
+ }
508
+ }, [client, runFilter]);
509
+
510
+ React.useEffect(() => { load(); }, [load]);
511
+ React.useEffect(() => {
512
+ if (!pollMs) return undefined;
513
+ const t = setInterval(load, pollMs);
514
+ return () => clearInterval(t);
515
+ }, [pollMs, load]);
516
+
517
+ if (!info) return error ? h("div", { className: "scalio-notice is-error", role: "alert" }, error) : null;
518
+ const jobs = info.jobs || [];
519
+ const actionsOk = info.queue_actions || {};
520
+ const current = tab || (info.queue ? "queue" : "jobs");
521
+ const byKey = {};
522
+ jobs.forEach((j) => { byKey[j.key] = j; });
523
+
524
+ const act = async (id, fn, ok, ask) => {
525
+ if (ask && props.confirm && !(await props.confirm(ask))) return;
526
+ setBusy(id);
527
+ try {
528
+ const res = await fn();
529
+ say(typeof ok === "function" ? ok(res) : ok);
530
+ await load();
531
+ } catch (err) {
532
+ say(errorText(err), "error");
533
+ } finally {
534
+ setBusy("");
535
+ }
536
+ };
537
+
538
+ const tabs = [
539
+ info.queue ? { id: "queue", testId: "jobs-tab-queue", label: L.queue, badge: queue.filter((x) => ["queued", "running", "waiting", "error"].includes(x.status)).length || null } : null,
540
+ { id: "runs", testId: "jobs-tab-runs", label: L.runs },
541
+ { id: "jobs", testId: "jobs-tab-jobs", label: L.background },
542
+ ].filter(Boolean);
543
+
544
+ const QueueLink = ({ item }) => {
545
+ if (!item.link) return null;
546
+ if (props.link) return h(props.link, { to: item.link, className: "scalio-btn is-ghost", "data-testid": `queue-open-${item.id}` }, L.open);
547
+ return h("a", { href: item.link, className: "scalio-btn is-ghost", "data-testid": `queue-open-${item.id}` }, L.open);
548
+ };
549
+
550
+ return h("section", { className: "scalio-jobs", "data-testid": props.testId || "jobs-panel" },
551
+ h(SubNav, { items: tabs, value: current, onSelect: setTab, testId: "jobs-tabs" }),
552
+ error ? h("div", { className: "scalio-notice is-error", role: "alert" }, error) : null,
553
+
554
+ current === "jobs" ? h("div", { className: "scalio-jobs-grid", "data-testid": "jobs-background" },
555
+ jobs.map((j) => h("div", { key: j.key, className: "scalio-jobs-card", "data-testid": `job-${j.key}` },
556
+ h("div", { className: "scalio-jobs-card-head" },
557
+ h("div", null,
558
+ h("div", { className: "scalio-jobs-name" }, jobName(j, lang)),
559
+ (j[`description_${lang}`] || j.description_de) ? h("div", { className: "scalio-audit-sub" }, j[`description_${lang}`] || j.description_de) : null),
560
+ j.last_status ? h(Badge, { status: j.last_status, L, testId: `job-status-${j.key}` }) : null),
561
+ h("dl", { className: "scalio-audit-dl" },
562
+ (j[`schedule_${lang}`] || j.schedule_de) ? [h("dt", { key: "s" }, L.schedule), h("dd", { key: "sv" }, j[`schedule_${lang}`] || j.schedule_de)] : null,
563
+ h("dt", null, L.lastRun), h("dd", null, j.last_run ? `${fmtTime(j.last_run, lang)}${j.last_duration !== null && j.last_duration !== undefined ? ` · ${fmtDuration(j.last_duration)}` : ""}` : L.never),
564
+ j.last_result && j.last_result.summary ? [h("dt", { key: "r" }, L.result), h("dd", { key: "rv" }, j.last_result.summary)] : null),
565
+ h("div", { className: "scalio-jobs-card-foot" },
566
+ j.requested ? h("span", { className: "scalio-badge is-running", "data-testid": `job-requested-${j.key}` }, L.requested) : null,
567
+ props.canRun && j.runnable ? h("button", { type: "button", className: "scalio-btn", disabled: busy === j.key || j.requested, "data-testid": `job-run-${j.key}`,
568
+ onClick: () => act(j.key, () => client.run(j.key), (res) => (res && res.queued === false ? L.ranNow : L.queued)) },
569
+ h(Icons.Activity, { className: "scalio-icon" }), L.runNow) : null)))) : null,
570
+
571
+ current === "runs" ? h("div", { "data-testid": "jobs-runs" },
572
+ h("div", { className: "scalio-audit-toolbar" },
573
+ h("select", { className: "scalio-input", value: runFilter.key, "data-testid": "runs-filter-job", onChange: (e) => setRunFilter(Object.assign({}, runFilter, { key: e.target.value })) },
574
+ [h("option", { key: "", value: "" }, L.allJobs)].concat(jobs.map((j) => h("option", { key: j.key, value: j.key }, jobName(j, lang))))),
575
+ h("select", { className: "scalio-input", value: runFilter.status, "data-testid": "runs-filter-status", onChange: (e) => setRunFilter(Object.assign({}, runFilter, { status: e.target.value })) },
576
+ ["", "ok", "warning", "error"].map((s) => h("option", { key: s, value: s }, s ? L.statuses[s] : L.allStatuses)))),
577
+ h("div", { className: "scalio-users-table-wrap" },
578
+ h("table", { className: "scalio-users-table scalio-audit-table", "data-testid": "runs-table" },
579
+ h("thead", null, h("tr", null, h("th", null, L.time), h("th", null, L.job), h("th", null, L.result), h("th", null, L.trigger), h("th", null, L.duration), h("th", null, L.status))),
580
+ h("tbody", null,
581
+ runs.length === 0 ? h("tr", null, h("td", { colSpan: 6, className: "scalio-audit-empty", "data-testid": "runs-empty" }, L.noRuns)) : null,
582
+ runs.map((r) => h("tr", { key: r.id, "data-testid": "run-row", className: r.status === "error" ? "is-error" : undefined },
583
+ h("td", { className: "scalio-audit-mono" }, fmtTime(r.ts, lang)),
584
+ h("td", null, jobName(byKey[r.key], lang) || r.key),
585
+ h("td", null, r.summary || "–"),
586
+ h("td", null, (L.triggers[r.trigger] || r.trigger) + (r.by ? ` · ${r.by}` : "")),
587
+ h("td", { className: "scalio-audit-mono" }, fmtDuration(r.duration)),
588
+ h("td", null, h(Badge, { status: r.status, L })))))))) : null,
589
+
590
+ current === "queue" ? h("div", { "data-testid": "jobs-queue" },
591
+ h("div", { className: "scalio-users-table-wrap" },
592
+ h("table", { className: "scalio-users-table scalio-audit-table", "data-testid": "queue-table" },
593
+ h("thead", null, h("tr", null, h("th", null, L.job), h("th", null, L.type), h("th", null, L.created), h("th", null, L.status), h("th", null))),
594
+ h("tbody", null,
595
+ queue.length === 0 ? h("tr", null, h("td", { colSpan: 5, className: "scalio-audit-empty", "data-testid": "queue-empty" }, L.noQueue)) : null,
596
+ queue.map((it) => h("tr", { key: it.id, "data-testid": "queue-row", className: it.status === "error" ? "is-error" : undefined },
597
+ h("td", null, h("div", { className: "scalio-audit-action" }, it.title || it.id), it.detail ? h("div", { className: "scalio-audit-sub" }, it.detail) : null),
598
+ h("td", null, it.type || "–"),
599
+ h("td", { className: "scalio-audit-mono" }, fmtTime(it.created_at, lang), it.by ? h("div", { className: "scalio-audit-sub" }, it.by) : null),
600
+ h("td", null, h(Badge, { status: it.status, L })),
601
+ h("td", { className: "scalio-audit-row-actions" },
602
+ props.queueActions ? props.queueActions(it, load) : null,
603
+ (it.steps && it.steps.length) || props.renderQueueDetail ? h("button", { type: "button", className: "scalio-btn is-ghost", "data-testid": `queue-view-${it.id}`, onClick: () => setOpen(it) }, L.steps) : null,
604
+ h(QueueLink, { item: it }),
605
+ props.canRun && actionsOk.retry && it.can_retry ? h("button", { type: "button", className: "scalio-btn is-ghost", disabled: busy === it.id, "data-testid": `queue-retry-${it.id}`,
606
+ onClick: () => act(it.id, () => client.retry(it.id), L.done) }, L.retry) : null,
607
+ props.canRun && actionsOk.cancel && it.can_cancel ? h("button", { type: "button", className: "scalio-btn is-ghost is-danger", disabled: busy === it.id, "data-testid": `queue-cancel-${it.id}`,
608
+ onClick: () => act(it.id, () => client.cancel(it.id), L.done, { title: L.cancel, text: it.title || it.id, action: L.cancel, danger: true }) }, L.cancel) : null,
609
+ props.canRun && actionsOk.delete && it.can_delete ? h("button", { type: "button", className: "scalio-btn is-ghost is-danger", disabled: busy === it.id, "data-testid": `queue-delete-${it.id}`,
610
+ onClick: () => act(it.id, () => client.remove(it.id), L.done, { title: L.delete, text: it.title || it.id, action: L.delete, danger: true }) }, L.delete) : null))))))) : null,
611
+
612
+ open ? h(Dialog, { title: open.title || open.id, onClose: () => setOpen(null), testId: "queue-detail", closeLabel: L.close },
613
+ open.steps && open.steps.length ? h("ol", { className: "scalio-jobs-steps" },
614
+ open.steps.map((s, i) => h("li", { key: i, className: `is-${TONE[s.status] || "neutral"}` },
615
+ h(Badge, { status: s.status, L }), h("span", null, s.name), s.detail ? h("span", { className: "scalio-audit-sub" }, s.detail) : null))) : null,
616
+ props.renderQueueDetail ? props.renderQueueDetail(open, load) : null) : null);
617
+ }
618
+
619
+ module.exports = { AuditLog, JobsPanel, AuditNav, auditApi, jobsApi, auditLabels, AUDIT_LABELS: LABELS };
package/react/index.js CHANGED
@@ -378,6 +378,6 @@ module.exports = {
378
378
  TEST_IDS,
379
379
  initials,
380
380
  };
381
- Object.assign(module.exports, Nav, Blocks, require("./settings"), require("./support"), require("./beacon"), require("./security"), require("./license"), require("./palette"), require("./unify"), require("./profile"), require("./actions"), require("./selftest"), require("./users"), require("./roles"), require("./wizard"), require("./maintenance"));
381
+ Object.assign(module.exports, Nav, Blocks, require("./settings"), require("./support"), require("./beacon"), require("./security"), require("./license"), require("./palette"), require("./unify"), require("./profile"), require("./actions"), require("./selftest"), require("./users"), require("./roles"), require("./wizard"), require("./maintenance"), require("./audit"));
382
382
  // After the exports above: auth.js uses LanguageToggle and ThemeToggle from this module.
383
383
  Object.assign(module.exports, require("./auth"));
package/react/license.js CHANGED
@@ -25,14 +25,17 @@ function errorText(err) {
25
25
  }
26
26
 
27
27
  // api: axios instance with the app's base URL (/api). Every method resolves to the license as the backend sends it.
28
+ // lang ("de"/"en", last argument): scalio-license 0.4.0 renders its messages in that language (?lang=); older
29
+ // backends ignore it.
28
30
  function licenseApi(api) {
29
31
  const data = (p) => p.then((r) => r.data);
32
+ const q = (lang) => (lang === "de" || lang === "en" ? { params: { lang } } : undefined);
30
33
  return {
31
- get: () => data(api.get("/license")),
32
- activate: (key) => data(api.put("/license", { key })),
33
- removeKey: () => data(api.delete("/license")),
34
- addAddon: (key) => data(api.post("/license/addons", { key })),
35
- removeAddon: (addon) => data(api.delete(`/license/addons/${encodeURIComponent(addon.id)}`)),
34
+ get: (lang) => data(api.get("/license", q(lang))),
35
+ activate: (key, lang) => data(api.put("/license", { key }, q(lang))),
36
+ removeKey: (lang) => data(api.delete("/license", q(lang))),
37
+ addAddon: (key, lang) => data(api.post("/license/addons", { key }, q(lang))),
38
+ removeAddon: (addon, lang) => data(api.delete(`/license/addons/${encodeURIComponent(addon.id)}`, q(lang))),
36
39
  };
37
40
  }
38
41
 
@@ -121,9 +124,9 @@ function LicensePanel(props) {
121
124
 
122
125
  React.useEffect(() => {
123
126
  let alive = true;
124
- client.get().then((d) => alive && setLic(d)).catch((err) => alive && setLoadError(fmtErr(err)));
127
+ client.get(lang).then((d) => alive && setLic(d)).catch((err) => alive && setLoadError(fmtErr(err)));
125
128
  return () => { alive = false; };
126
- }, [client]);
129
+ }, [client, lang]);
127
130
 
128
131
  const say = (text, tone) => {
129
132
  if (notify) notify(text, tone || "success");
@@ -151,15 +154,15 @@ function LicensePanel(props) {
151
154
  }
152
155
  };
153
156
  const activate = async () => {
154
- if (await run(() => client.activate(key.trim()), L.activated)) setKey("");
157
+ if (await run(() => client.activate(key.trim(), lang), L.activated)) setKey("");
155
158
  };
156
159
  const remove = async () => {
157
160
  const title = fill(L.removeConfirm, { product: props.product || "Scalio" });
158
161
  const yes = confirm ? await confirm({ title, action: L.remove, danger: true }) : window.confirm(title);
159
- if (yes) run(() => client.removeKey(), L.removed);
162
+ if (yes) run(() => client.removeKey(lang), L.removed);
160
163
  };
161
164
  const addAddon = async () => {
162
- if (await run(() => client.addAddon(addon.trim()), L.addonActivated)) setAddon("");
165
+ if (await run(() => client.addAddon(addon.trim(), lang), L.addonActivated)) setAddon("");
163
166
  };
164
167
  const copy = async (value, copied) => {
165
168
  try {
@@ -243,7 +246,7 @@ function LicensePanel(props) {
243
246
  m.managed || m.addonsReadOnly || a.removable === false
244
247
  ? null
245
248
  : h("button", { type: "button", className: "scalio-btn is-ghost is-danger", disabled: busy, title: L.remove, "aria-label": L.remove,
246
- "data-testid": `addon-remove-${a.id}`, onClick: () => run(() => client.removeAddon(a), L.addonRemoved) },
249
+ "data-testid": `addon-remove-${a.id}`, onClick: () => run(() => client.removeAddon(a, lang), L.addonRemoved) },
247
250
  h(Icons.Trash, { className: "scalio-icon" }))
248
251
  )
249
252
  )
@@ -256,7 +259,7 @@ function LicensePanel(props) {
256
259
  h("span", null, e.error),
257
260
  e.addon && !m.managed && !m.addonsReadOnly
258
261
  ? h("button", { type: "button", className: "scalio-license-link", disabled: busy, "data-testid": `addon-remove-${e.addon.id}`,
259
- onClick: () => run(() => client.removeAddon(e.addon), L.addonRemoved) }, L.remove)
262
+ onClick: () => run(() => client.removeAddon(e.addon, lang), L.addonRemoved) }, L.remove)
260
263
  : null
261
264
  )
262
265
  ),