@scalio-cloud/ui 0.20.0 → 0.22.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/CHANGELOG.md +23 -0
- package/README.md +2 -1
- package/nav.css +60 -7
- package/package.json +1 -1
- package/react/blocks.js +12 -5
- package/react/index.js +1 -1
- package/react/profile.js +14 -1
- package/react/selftest.js +280 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.22.0
|
|
4
|
+
Login, Navigation und Banner vereinheitlichen (scalio-docs UISPEC section 3, ADR 0042 Nachtrag 2026-11-01).
|
|
5
|
+
- **Notice in the look of the test mode banner:** thin frame in the tone, 3px bar on the left, tinted background,
|
|
6
|
+
icon in the tone, text in the normal text colour (also for errors). Colours: red = `error` (Fehler), orange = `warn`
|
|
7
|
+
(wichtig), green = `ok` (gute Meldung), blue = `info` (Aktion). New aliases `important`, `success`, `action`
|
|
8
|
+
(`noticeKind`). Banners keep the full frame; stacked banners share one line. Dark mode has its own tones.
|
|
9
|
+
- `SelfTestBanner` is now a `Notice` banner (kind warn) and takes an optional `action` (e.g. link to «Tests»).
|
|
10
|
+
- **Behaviour change:** `normalizeTheme()` returns `system` when nothing (or something unknown) is stored, so every
|
|
11
|
+
app starts like the device on the first visit and on the login page (before: `light`).
|
|
12
|
+
- New `initialLang(stored, navLang)` / `browserLang()`: stored choice wins, otherwise German for a German browser and
|
|
13
|
+
English for everything else, the same in every app.
|
|
14
|
+
- **Visible change:** every `Notice` gets the new frame and colours (info notices turn from Klein blue to a lighter
|
|
15
|
+
blue, error text is no longer red, only bar and icon). No token changes.
|
|
16
|
+
|
|
17
|
+
## 0.21.0
|
|
18
|
+
Testmodus (scalio-docs ADR 0053, package scalio-selftest).
|
|
19
|
+
- `SelfTestPanel` for the page «Tests»: last runs with verdict, scope, source, version and counts; details per
|
|
20
|
+
section; «Neu fehlgeschlagen» and «Wieder in Ordnung» since the run before; «Jetzt ausführen» and «Abbrechen» with
|
|
21
|
+
live progress; schedule (scope and hour of the nightly run). `selftestApi(api)` maps the routes `/e2e/*`.
|
|
22
|
+
- `SelfTestBanner`: hint «Testmodus (SAFE)» while the test mode is on.
|
|
23
|
+
- CSS in `nav.css` (`.scalio-selftest*`).
|
|
24
|
+
- **Visible change:** only in apps that show the new page. No token changes.
|
|
25
|
+
|
|
3
26
|
## 0.20.0
|
|
4
27
|
Scalio Ideen (ADR 0050): feature requests from the apps, votes and comments, kept centrally in Compass.
|
|
5
28
|
- `supportApi(api, { ideas: true })` adds the routes of scalio-connect 0.8.0 (`ideas_router`). The support drawer then
|
package/README.md
CHANGED
|
@@ -127,7 +127,8 @@ scalio-connect 0.8.0 `peers.web.admin_router`.
|
|
|
127
127
|
`link` is the router's `NavLink` (it gets `to`, `end`, a `className` function and `onClick`); without it the
|
|
128
128
|
sidebar renders plain links and marks the item for `activePath`. Item test ids default to `nav-<path>`
|
|
129
129
|
(`nav-overview` for `/`), or set `testId`. Page blocks: `KpiTile` (tone `error` = red number),
|
|
130
|
-
`EmptyState` (children = next step), `Loading`, `Notice` (`
|
|
130
|
+
`EmptyState` (children = next step), `Loading`, `Notice` (`error` red = Fehler, `warn`/`important` orange =
|
|
131
|
+
wichtig, `ok`/`success` green = gute Meldung, `info`/`action` blue = Aktion; `banner` = full width), `StatusBadge`
|
|
131
132
|
(tones `neutral`, `running`, `ok`, `warn`, `error`) and `PageHeader` with `back` for detail pages.
|
|
132
133
|
|
|
133
134
|
Pages that belong together under one menu item keep their own addresses: the item lists them in `match`
|
package/nav.css
CHANGED
|
@@ -260,8 +260,14 @@
|
|
|
260
260
|
.scalio-spinner { width: 0.875rem; height: 0.875rem; border: 2px solid hsl(var(--border)); border-top-color: hsl(var(--primary)); border-radius: 999px; animation: scalio-spin 0.8s linear infinite; }
|
|
261
261
|
@keyframes scalio-spin { to { transform: rotate(360deg); } }
|
|
262
262
|
@media (prefers-reduced-motion: reduce) { .scalio-spinner { animation: none; } }
|
|
263
|
-
|
|
264
|
-
.
|
|
263
|
+
/* Notice: thin frame in the tone, 3px bar on the left, tinted background, icon in the tone (2026-11-01, look of the
|
|
264
|
+
test mode banner). Red = Fehler, orange = wichtig, green = gute Meldung, blue = Aktion. */
|
|
265
|
+
.scalio-notice {
|
|
266
|
+
--notice-tone: 37 99 235; display: flex; align-items: flex-start; gap: 0.625rem; padding: 0.75rem 1rem;
|
|
267
|
+
border: 1px solid rgb(var(--notice-tone) / 0.45); border-left: 3px solid rgb(var(--notice-tone)); border-radius: 2px;
|
|
268
|
+
background: rgb(var(--notice-tone) / 0.08); color: hsl(var(--foreground)); font-size: 0.875rem;
|
|
269
|
+
}
|
|
270
|
+
.scalio-notice-icon, .scalio-notice > .scalio-icon:first-child { flex-shrink: 0; margin-top: 0.125rem; color: rgb(var(--notice-tone)); }
|
|
265
271
|
.scalio-notice-body { flex: 1; min-width: 0; }
|
|
266
272
|
.scalio-notice-title { font-weight: 600; margin-bottom: 0.125rem; }
|
|
267
273
|
.scalio-notice-actions { display: flex; flex-shrink: 0; flex-wrap: wrap; align-items: center; gap: 0.5rem; }
|
|
@@ -277,7 +283,11 @@
|
|
|
277
283
|
}
|
|
278
284
|
.scalio-notice-dismiss:hover { opacity: 1; }
|
|
279
285
|
.scalio-notice-action:focus-visible, .scalio-notice-dismiss:focus-visible { outline: 2px solid hsl(var(--ring)); outline-offset: 1px; }
|
|
280
|
-
.scalio-notice.is-banner { align-items: center; border-
|
|
286
|
+
.scalio-notice.is-banner { align-items: center; border-radius: 0; padding: 0.625rem 1rem; }
|
|
287
|
+
.scalio-notice.is-banner .scalio-notice-icon { margin-top: 0; }
|
|
288
|
+
.scalio-notice.is-banner .scalio-notice-title { margin-bottom: 0; }
|
|
289
|
+
.scalio-notice.is-banner .scalio-notice-body { display: flex; flex-wrap: wrap; align-items: baseline; column-gap: 0.75rem; }
|
|
290
|
+
.scalio-notice.is-banner + .scalio-notice.is-banner { border-top-width: 0; }
|
|
281
291
|
@media (max-width: 639px) { .scalio-notice.is-banner { flex-wrap: wrap; } }
|
|
282
292
|
/* Password sign-in choice (Konten und Rollen -> Anmeldung). */
|
|
283
293
|
.scalio-policy { display: flex; flex-direction: column; gap: 0.5rem; }
|
|
@@ -286,10 +296,15 @@
|
|
|
286
296
|
.scalio-policy-help { font-size: 0.75rem; color: hsl(var(--muted-foreground)); }
|
|
287
297
|
.scalio-policy-help.is-warn { color: #b45309; }
|
|
288
298
|
.dark .scalio-policy-help.is-warn { color: #fbbf24; }
|
|
289
|
-
.scalio-notice.is-info {
|
|
290
|
-
.scalio-notice.is-ok {
|
|
291
|
-
.scalio-notice.is-warn {
|
|
292
|
-
.scalio-notice.is-error {
|
|
299
|
+
.scalio-notice.is-info { --notice-tone: 37 99 235; }
|
|
300
|
+
.scalio-notice.is-ok { --notice-tone: 22 163 74; }
|
|
301
|
+
.scalio-notice.is-warn { --notice-tone: 217 119 6; }
|
|
302
|
+
.scalio-notice.is-error { --notice-tone: 220 38 38; }
|
|
303
|
+
.dark .scalio-notice.is-info { --notice-tone: 96 165 250; }
|
|
304
|
+
.dark .scalio-notice.is-ok { --notice-tone: 74 222 128; }
|
|
305
|
+
.dark .scalio-notice.is-warn { --notice-tone: 245 158 11; }
|
|
306
|
+
.dark .scalio-notice.is-error { --notice-tone: 248 113 113; }
|
|
307
|
+
.dark .scalio-notice { background: rgb(var(--notice-tone) / 0.12); }
|
|
293
308
|
.scalio-badge {
|
|
294
309
|
display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.0625rem 0.375rem; border: 1px solid; border-radius: 2px;
|
|
295
310
|
font-family: 'JetBrains Mono', monospace; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; white-space: nowrap;
|
|
@@ -479,3 +494,41 @@
|
|
|
479
494
|
.dark .scalio-license-addon.tone-warn { border-color: rgb(251 191 36 / 0.4); background: rgb(251 191 36 / 0.08); }
|
|
480
495
|
.scalio-license-addon-error { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; }
|
|
481
496
|
.scalio-license-link { padding: 0; border: 0; background: none; color: inherit; font: inherit; text-decoration: underline; cursor: pointer; }
|
|
497
|
+
|
|
498
|
+
/* Self-check page (SelfTestPanel, ADR 0053) */
|
|
499
|
+
.scalio-selftest { border: 1px solid hsl(var(--border)); border-radius: 2px; background: hsl(var(--card)); color: hsl(var(--card-foreground)); font-size: 0.875rem; }
|
|
500
|
+
.scalio-selftest-part { padding: 1.25rem; }
|
|
501
|
+
.scalio-selftest-part + .scalio-selftest-part { border-top: 1px solid hsl(var(--border)); }
|
|
502
|
+
.scalio-selftest-head { display: flex; flex-wrap: wrap; align-items: flex-start; justify-content: space-between; gap: 1rem; }
|
|
503
|
+
.scalio-selftest-title { display: flex; align-items: center; gap: 0.5rem; font-family: 'Chivo', sans-serif; font-weight: 700; }
|
|
504
|
+
.scalio-selftest-title .scalio-icon { color: hsl(var(--primary)); }
|
|
505
|
+
.scalio-selftest-hint { margin: 0.25rem 0 0; color: hsl(var(--muted-foreground)); }
|
|
506
|
+
.scalio-selftest-actions { display: flex; gap: 0.5rem; }
|
|
507
|
+
.scalio-selftest-sub { display: flex; align-items: center; gap: 0.375rem; margin-bottom: 0.75rem; font-weight: 600; }
|
|
508
|
+
.scalio-selftest-live { margin-top: 1rem; display: flex; flex-direction: column; gap: 0.5rem; }
|
|
509
|
+
.scalio-selftest-live-head { display: flex; justify-content: space-between; color: hsl(var(--muted-foreground)); font-family: 'JetBrains Mono', monospace; font-size: 0.75rem; }
|
|
510
|
+
.scalio-selftest-form { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 0.75rem; }
|
|
511
|
+
.scalio-selftest-form label { display: flex; flex-direction: column; gap: 0.25rem; min-width: 10rem; }
|
|
512
|
+
.scalio-selftest-form label span { color: hsl(var(--muted-foreground)); font-size: 0.75rem; }
|
|
513
|
+
.scalio-selftest-run { border-top: 1px solid hsl(var(--border)); padding: 0.625rem 0; }
|
|
514
|
+
.scalio-selftest-run:first-of-type { border-top: 0; }
|
|
515
|
+
.scalio-selftest-run-head { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem 0.75rem; }
|
|
516
|
+
.scalio-selftest-when, .scalio-selftest-version { font-family: 'JetBrains Mono', monospace; font-size: 0.75rem; }
|
|
517
|
+
.scalio-selftest-source, .scalio-selftest-version { color: hsl(var(--muted-foreground)); }
|
|
518
|
+
.scalio-selftest-counts { display: inline-flex; gap: 0.5rem; font-family: 'JetBrains Mono', monospace; font-size: 0.6875rem; }
|
|
519
|
+
.scalio-selftest-run-head .scalio-btn { margin-left: auto; }
|
|
520
|
+
.scalio-selftest-run-body { display: flex; flex-direction: column; gap: 0.75rem; margin-top: 0.75rem; }
|
|
521
|
+
.scalio-selftest-run-body ul { margin: 0.25rem 0 0; padding-left: 1rem; }
|
|
522
|
+
.scalio-selftest-sections { display: flex; flex-direction: column; gap: 0.75rem; }
|
|
523
|
+
.scalio-selftest-section-title { font-weight: 600; margin-bottom: 0.25rem; }
|
|
524
|
+
.scalio-selftest-section ul { list-style: none; margin: 0; padding: 0; }
|
|
525
|
+
.scalio-selftest-section li { display: grid; grid-template-columns: 3rem minmax(0, 1fr); gap: 0 0.5rem; padding: 0.1875rem 0; }
|
|
526
|
+
.scalio-selftest-status { font-family: 'JetBrains Mono', monospace; font-size: 0.6875rem; font-weight: 600; }
|
|
527
|
+
.scalio-selftest-detail { grid-column: 2; color: hsl(var(--muted-foreground)); font-size: 0.75rem; overflow-wrap: anywhere; }
|
|
528
|
+
.scalio-selftest .is-ok, .scalio-selftest li.is-ok .scalio-selftest-status { color: #15803d; }
|
|
529
|
+
.scalio-selftest .is-warn, .scalio-selftest li.is-warn .scalio-selftest-status { color: #b45309; }
|
|
530
|
+
.scalio-selftest .is-error, .scalio-selftest li.is-error .scalio-selftest-status { color: hsl(var(--destructive)); }
|
|
531
|
+
.scalio-selftest .is-neutral { color: hsl(var(--muted-foreground)); }
|
|
532
|
+
.dark .scalio-selftest .is-ok, .dark .scalio-selftest li.is-ok .scalio-selftest-status { color: #4ade80; }
|
|
533
|
+
.dark .scalio-selftest .is-warn, .dark .scalio-selftest li.is-warn .scalio-selftest-status { color: #fbbf24; }
|
|
534
|
+
@media (max-width: 640px) { .scalio-selftest-run-head .scalio-btn { margin-left: 0; } }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@scalio-cloud/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.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/blocks.js
CHANGED
|
@@ -50,13 +50,20 @@ function Loading({ label, lang, labels, testId }) {
|
|
|
50
50
|
);
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
//
|
|
54
|
-
// title, one sentence, at most one main action plus "Später".
|
|
55
|
-
//
|
|
53
|
+
// One notice style for every hint in every app (ADR 0042 A6): thin frame, coloured bar on the left, icon, bold
|
|
54
|
+
// title, one sentence, at most one main action plus "Später". Colours (2026-11-01): red = error (Fehler),
|
|
55
|
+
// orange = warn (wichtig, e.g. test mode, licence), green = ok (gute Meldung), blue = info (Aktion, e.g. grant
|
|
56
|
+
// consent). The aliases important, success and action name the same kinds. banner: full width above the page
|
|
57
|
+
// (AppShell prop banner), several stack, the most important first. action: { label, onClick | href, testId }.
|
|
56
58
|
const NOTICE_ICONS = { info: "Info", ok: "Check", warn: "Alert", error: "Alert" };
|
|
59
|
+
const NOTICE_KINDS = { info: "info", action: "info", ok: "ok", success: "ok", warn: "warn", important: "warn", error: "error" };
|
|
60
|
+
|
|
61
|
+
function noticeKind(kind) {
|
|
62
|
+
return Object.prototype.hasOwnProperty.call(NOTICE_KINDS, kind) ? NOTICE_KINDS[kind] : "info";
|
|
63
|
+
}
|
|
57
64
|
|
|
58
65
|
function Notice({ kind, title, children, action, onDismiss, dismissLabel, banner, icon, lang, labels, testId }) {
|
|
59
|
-
const k =
|
|
66
|
+
const k = noticeKind(kind);
|
|
60
67
|
const L = labelsFor(lang, labels);
|
|
61
68
|
const Icons = require("./icons");
|
|
62
69
|
const Icon = icon === false ? null : icon || Icons[NOTICE_ICONS[k]];
|
|
@@ -93,4 +100,4 @@ function StatusBadge({ tone: t, children, testId }) {
|
|
|
93
100
|
return h("span", { className: cx("scalio-badge", `is-${tone(t, "neutral")}`), "data-testid": testId }, children);
|
|
94
101
|
}
|
|
95
102
|
|
|
96
|
-
module.exports = { KpiTile, EmptyState, Loading, Notice, StatusBadge, TONES };
|
|
103
|
+
module.exports = { KpiTile, EmptyState, Loading, Notice, noticeKind, StatusBadge, TONES };
|
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"));
|
|
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"));
|
|
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/profile.js
CHANGED
|
@@ -86,8 +86,19 @@ function applyTheme(mode) {
|
|
|
86
86
|
else if (mq.removeListener) mq.removeListener(set);
|
|
87
87
|
};
|
|
88
88
|
}
|
|
89
|
+
// Nothing stored yet (first visit, login page) means "system": every app starts like the device.
|
|
89
90
|
function normalizeTheme(value) {
|
|
90
|
-
return value === "dark" || value === "
|
|
91
|
+
return value === "dark" || value === "light" ? value : "system";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// --- language ----------------------------------------------------------------------------------------
|
|
95
|
+
// The same rule in every app: a stored choice wins; otherwise German for a German browser, English for the rest.
|
|
96
|
+
function browserLang(navLang) {
|
|
97
|
+
const v = navLang !== undefined ? navLang : (typeof navigator !== "undefined" && navigator.language) || "";
|
|
98
|
+
return String(v || "").toLowerCase().startsWith("de") ? "de" : "en";
|
|
99
|
+
}
|
|
100
|
+
function initialLang(stored, navLang) {
|
|
101
|
+
return stored === "de" || stored === "en" ? stored : browserLang(navLang);
|
|
91
102
|
}
|
|
92
103
|
|
|
93
104
|
// --- blocks ------------------------------------------------------------------------------------------
|
|
@@ -324,4 +335,6 @@ module.exports = {
|
|
|
324
335
|
resolveTheme,
|
|
325
336
|
applyTheme,
|
|
326
337
|
normalizeTheme,
|
|
338
|
+
browserLang,
|
|
339
|
+
initialLang,
|
|
327
340
|
};
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Page «Tests» of the test mode (scalio-docs ADR 0053, package scalio-selftest): last runs, live progress,
|
|
3
|
+
// run now / cancel, nightly schedule, results per section, what broke and what recovered since the run before.
|
|
4
|
+
// selftestApi(api) maps the routes of scalio-selftest (/e2e/*). SelfTestBanner shows the hint on every page.
|
|
5
|
+
const React = require("react");
|
|
6
|
+
const h = React.createElement;
|
|
7
|
+
const Icons = require("./icons");
|
|
8
|
+
|
|
9
|
+
const LABELS = {
|
|
10
|
+
de: {
|
|
11
|
+
title: "Selbsttest",
|
|
12
|
+
hint: "Diese Instanz prüft sich selbst von aussen: Schnittstellen und Browser, mit eigenen Testkonten.",
|
|
13
|
+
off: "Der Testmodus ist auf dieser Instanz aus (TEST_MODE und SCALIO_STAGE dev oder test). Gezeigt werden nur frühere Läufe.",
|
|
14
|
+
banner: "Testmodus ({suite}): Diese Instanz prüft sich automatisch selbst.",
|
|
15
|
+
runNow: "Jetzt ausführen",
|
|
16
|
+
queued: "Lauf angefordert. Er startet innerhalb einer Minute.",
|
|
17
|
+
cancel: "Abbrechen",
|
|
18
|
+
cancelling: "Abbruch angefordert.",
|
|
19
|
+
running: "Läuft",
|
|
20
|
+
waiting: "Wartet auf den Start",
|
|
21
|
+
checks: "Prüfungen",
|
|
22
|
+
never: "Noch keine Läufe.",
|
|
23
|
+
schedule: "Zeitplan",
|
|
24
|
+
suite: "Umfang",
|
|
25
|
+
hour: "Nachtlauf um",
|
|
26
|
+
noNight: "kein Nachtlauf",
|
|
27
|
+
save: "Speichern",
|
|
28
|
+
saved: "Zeitplan gespeichert.",
|
|
29
|
+
safeHint: "SAFE prüft nur mit eigenen Daten und simulierten Microsoft-Schichten. FULL schreibt auch in den Test-Tenant.",
|
|
30
|
+
runs: "Läufe",
|
|
31
|
+
newFailures: "Neu fehlgeschlagen",
|
|
32
|
+
recovered: "Wieder in Ordnung",
|
|
33
|
+
was: "vorher {s}",
|
|
34
|
+
sources: { manual: "von Hand", scheduled: "Nachtlauf", deploy: "nach Deploy" },
|
|
35
|
+
verdicts: { PASS: "Bestanden", WARN: "Warnung", FAIL: "Fehler", SKIP: "Übersprungen", CANCELLED: "Abgebrochen" },
|
|
36
|
+
show: "Details",
|
|
37
|
+
hide: "Ausblenden",
|
|
38
|
+
version: "Version",
|
|
39
|
+
},
|
|
40
|
+
en: {
|
|
41
|
+
title: "Self-check",
|
|
42
|
+
hint: "This instance checks itself from the outside: API and browser, with its own test accounts.",
|
|
43
|
+
off: "The test mode is off on this instance (TEST_MODE and SCALIO_STAGE dev or test). Only earlier runs are shown.",
|
|
44
|
+
banner: "Test mode ({suite}): this instance checks itself automatically.",
|
|
45
|
+
runNow: "Run now",
|
|
46
|
+
queued: "Run requested. It starts within a minute.",
|
|
47
|
+
cancel: "Cancel",
|
|
48
|
+
cancelling: "Cancel requested.",
|
|
49
|
+
running: "Running",
|
|
50
|
+
waiting: "Waiting to start",
|
|
51
|
+
checks: "checks",
|
|
52
|
+
never: "No runs yet.",
|
|
53
|
+
schedule: "Schedule",
|
|
54
|
+
suite: "Scope",
|
|
55
|
+
hour: "Nightly run at",
|
|
56
|
+
noNight: "no nightly run",
|
|
57
|
+
save: "Save",
|
|
58
|
+
saved: "Schedule saved.",
|
|
59
|
+
safeHint: "SAFE checks with own data and simulated Microsoft layers only. FULL also writes to the test tenant.",
|
|
60
|
+
runs: "Runs",
|
|
61
|
+
newFailures: "Newly failing",
|
|
62
|
+
recovered: "Recovered",
|
|
63
|
+
was: "was {s}",
|
|
64
|
+
sources: { manual: "by hand", scheduled: "nightly", deploy: "after deploy" },
|
|
65
|
+
verdicts: { PASS: "Passed", WARN: "Warning", FAIL: "Failed", SKIP: "Skipped", CANCELLED: "Cancelled" },
|
|
66
|
+
show: "Details",
|
|
67
|
+
hide: "Hide",
|
|
68
|
+
version: "Version",
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
function selftestLabels(lang, overrides) {
|
|
73
|
+
const base = LABELS[lang === "en" ? "en" : "de"];
|
|
74
|
+
return Object.assign({}, base, overrides || {});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const TONE = { PASS: "ok", WARN: "warn", FAIL: "error", SKIP: "neutral", CANCELLED: "neutral" };
|
|
78
|
+
|
|
79
|
+
function fill(text, values) {
|
|
80
|
+
return String(text || "").replace(/\{(\w+)\}/g, (m, k) => (values && values[k] !== undefined ? values[k] : m));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function errorText(err) {
|
|
84
|
+
const d = err && err.response && err.response.data && err.response.data.detail;
|
|
85
|
+
return typeof d === "string" ? d : (err && err.message) || String(err);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// api: axios instance with the app's base URL (/api).
|
|
89
|
+
function selftestApi(api) {
|
|
90
|
+
const data = (p) => p.then((r) => r.data);
|
|
91
|
+
return {
|
|
92
|
+
health: () => data(api.get("/e2e/health")),
|
|
93
|
+
runs: () => data(api.get("/e2e/runs")),
|
|
94
|
+
progress: () => data(api.get("/e2e/progress")),
|
|
95
|
+
setConfig: (cfg) => data(api.post("/e2e/config", cfg)),
|
|
96
|
+
run: () => data(api.post("/e2e/run")),
|
|
97
|
+
cancel: () => data(api.post("/e2e/cancel")),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function fmtTime(iso, lang) {
|
|
102
|
+
if (!iso) return "";
|
|
103
|
+
try {
|
|
104
|
+
return new Date(iso).toLocaleString(lang === "en" ? "en-GB" : "de-CH", { dateStyle: "short", timeStyle: "short" });
|
|
105
|
+
} catch (e) {
|
|
106
|
+
return iso;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function Verdict({ verdict, L }) {
|
|
111
|
+
return h("span", { className: `scalio-badge is-${TONE[verdict] || "neutral"}`, "data-testid": "selftest-verdict" },
|
|
112
|
+
(L.verdicts && L.verdicts[verdict]) || verdict || "-");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function Counts({ counts }) {
|
|
116
|
+
const c = counts || {};
|
|
117
|
+
return h("span", { className: "scalio-selftest-counts" },
|
|
118
|
+
["PASS", "FAIL", "WARN", "SKIP"].map((k) => h("span", { key: k, className: `is-${TONE[k]}` }, `${k} ${c[k] || 0}`)));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function groupBySection(results) {
|
|
122
|
+
const out = [];
|
|
123
|
+
const index = {};
|
|
124
|
+
(results || []).forEach((r) => {
|
|
125
|
+
if (!(r.section in index)) {
|
|
126
|
+
index[r.section] = out.length;
|
|
127
|
+
out.push({ section: r.section, items: [] });
|
|
128
|
+
}
|
|
129
|
+
out[index[r.section]].items.push(r);
|
|
130
|
+
});
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function ResultList({ results }) {
|
|
135
|
+
return h("div", { className: "scalio-selftest-sections" },
|
|
136
|
+
groupBySection(results).map((g) =>
|
|
137
|
+
h("div", { key: g.section, className: "scalio-selftest-section" },
|
|
138
|
+
h("div", { className: "scalio-selftest-section-title" }, g.section),
|
|
139
|
+
h("ul", null, g.items.map((r, i) =>
|
|
140
|
+
h("li", { key: r.name + i, className: `is-${TONE[r.status] || "neutral"}`, "data-testid": "selftest-result" },
|
|
141
|
+
h("span", { className: "scalio-selftest-status" }, r.status),
|
|
142
|
+
h("span", { className: "scalio-selftest-name" }, r.name),
|
|
143
|
+
r.detail ? h("span", { className: "scalio-selftest-detail" }, r.detail) : null))))));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function RunRow({ run, L, lang }) {
|
|
147
|
+
const [open, setOpen] = React.useState(false);
|
|
148
|
+
const reg = run.regression || {};
|
|
149
|
+
return h("div", { className: "scalio-selftest-run", "data-testid": "selftest-run" },
|
|
150
|
+
h("div", { className: "scalio-selftest-run-head" },
|
|
151
|
+
h(Verdict, { verdict: run.verdict, L }),
|
|
152
|
+
h("span", { className: "scalio-selftest-when" }, fmtTime(run.ranAt, lang)),
|
|
153
|
+
h("span", { className: "scalio-badge is-neutral" }, run.suite),
|
|
154
|
+
h("span", { className: "scalio-selftest-source" }, (L.sources && L.sources[run.source]) || run.source),
|
|
155
|
+
run.version ? h("span", { className: "scalio-selftest-version" }, `${run.version}${run.commit ? ` · ${String(run.commit).slice(0, 7)}` : ""}`) : null,
|
|
156
|
+
h(Counts, { counts: run.counts }),
|
|
157
|
+
run.results ? h("button", { type: "button", className: "scalio-btn is-ghost", onClick: () => setOpen(!open), "data-testid": "selftest-run-toggle" },
|
|
158
|
+
open ? L.hide : L.show) : null),
|
|
159
|
+
open ? h("div", { className: "scalio-selftest-run-body" },
|
|
160
|
+
(reg.newFailures || []).length ? h("div", { className: "scalio-notice is-error", "data-testid": "selftest-new-failures" },
|
|
161
|
+
h("strong", null, L.newFailures), h("ul", null, reg.newFailures.map((r, i) =>
|
|
162
|
+
h("li", { key: i }, `${r.section} / ${r.name}`, r.was ? ` (${fill(L.was, { s: r.was })})` : "")))) : null,
|
|
163
|
+
(reg.recovered || []).length ? h("div", { className: "scalio-notice is-ok", "data-testid": "selftest-recovered" },
|
|
164
|
+
h("strong", null, L.recovered), h("ul", null, reg.recovered.map((r, i) => h("li", { key: i }, `${r.section} / ${r.name}`)))) : null,
|
|
165
|
+
h(ResultList, { results: run.results })) : null);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Props: client (selftestApi or same shape), lang, labels, notify(text, tone), pollMs (default 3000), testId.
|
|
169
|
+
function SelfTestPanel(props) {
|
|
170
|
+
const { client, lang, notify } = props;
|
|
171
|
+
const L = selftestLabels(lang, props.labels);
|
|
172
|
+
const pollMs = props.pollMs === undefined ? 3000 : props.pollMs;
|
|
173
|
+
const [health, setHealth] = React.useState(null);
|
|
174
|
+
const [runs, setRuns] = React.useState([]);
|
|
175
|
+
const [progress, setProgress] = React.useState(null);
|
|
176
|
+
const [error, setError] = React.useState("");
|
|
177
|
+
const [cfg, setCfg] = React.useState({ suite: "", runHour: -1 });
|
|
178
|
+
const [busy, setBusy] = React.useState(false);
|
|
179
|
+
const say = (text, tone) => (notify ? notify(text, tone || "success") : null);
|
|
180
|
+
|
|
181
|
+
const load = React.useCallback(async () => {
|
|
182
|
+
try {
|
|
183
|
+
const [hh, rr, pp] = await Promise.all([client.health(), client.runs(), client.progress()]);
|
|
184
|
+
setHealth(hh);
|
|
185
|
+
setRuns(rr.runs || []);
|
|
186
|
+
setProgress(pp);
|
|
187
|
+
setCfg({ suite: hh.suite || "", runHour: hh.runHour === null || hh.runHour === undefined ? -1 : hh.runHour });
|
|
188
|
+
setError("");
|
|
189
|
+
} catch (err) {
|
|
190
|
+
setError(errorText(err));
|
|
191
|
+
}
|
|
192
|
+
}, [client]);
|
|
193
|
+
|
|
194
|
+
React.useEffect(() => { load(); }, [load]);
|
|
195
|
+
const active = !!(progress && (progress.running || progress.queued));
|
|
196
|
+
React.useEffect(() => {
|
|
197
|
+
if (!active || !pollMs) return undefined;
|
|
198
|
+
const t = setInterval(async () => {
|
|
199
|
+
try {
|
|
200
|
+
const p = await client.progress();
|
|
201
|
+
setProgress(p);
|
|
202
|
+
if (!p.running && !p.queued) load();
|
|
203
|
+
} catch (e) { /* keep the last state */ }
|
|
204
|
+
}, pollMs);
|
|
205
|
+
return () => clearInterval(t);
|
|
206
|
+
}, [active, pollMs, client, load]);
|
|
207
|
+
|
|
208
|
+
if (!health) return error ? h("div", { className: "scalio-notice is-error", role: "alert" }, error) : null;
|
|
209
|
+
const enabled = !!health.enabled;
|
|
210
|
+
|
|
211
|
+
const act = async (fn, ok) => {
|
|
212
|
+
setBusy(true);
|
|
213
|
+
try {
|
|
214
|
+
await fn();
|
|
215
|
+
if (ok) say(ok);
|
|
216
|
+
await load();
|
|
217
|
+
} catch (err) {
|
|
218
|
+
say(errorText(err), "error");
|
|
219
|
+
} finally {
|
|
220
|
+
setBusy(false);
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const done = progress ? (progress.results || []).length : 0;
|
|
225
|
+
const total = Math.max(done, (progress && progress.expectedTotal) || 0);
|
|
226
|
+
const pct = total ? Math.round((done / total) * 100) : 0;
|
|
227
|
+
const hours = [-1].concat(Array.from({ length: 24 }, (_, i) => i));
|
|
228
|
+
|
|
229
|
+
return h("section", { className: "scalio-selftest", "data-testid": props.testId || "selftest-panel" },
|
|
230
|
+
h("div", { className: "scalio-selftest-part" },
|
|
231
|
+
h("div", { className: "scalio-selftest-head" },
|
|
232
|
+
h("div", null,
|
|
233
|
+
h("div", { className: "scalio-selftest-title" }, h(Icons.Activity, { className: "scalio-icon" }), L.title,
|
|
234
|
+
health.suite ? h("span", { className: "scalio-badge is-running", "data-testid": "selftest-suite" }, health.suite) : null),
|
|
235
|
+
h("p", { className: "scalio-selftest-hint" }, enabled ? L.hint : L.off)),
|
|
236
|
+
enabled ? h("div", { className: "scalio-selftest-actions" },
|
|
237
|
+
active
|
|
238
|
+
? h("button", { type: "button", className: "scalio-btn is-danger", disabled: busy, "data-testid": "selftest-cancel",
|
|
239
|
+
onClick: () => act(() => client.cancel(), L.cancelling) }, h(Icons.X, { className: "scalio-icon" }), L.cancel)
|
|
240
|
+
: h("button", { type: "button", className: "scalio-btn is-primary", disabled: busy, "data-testid": "selftest-run-now",
|
|
241
|
+
onClick: () => act(() => client.run(), L.queued) }, h(Icons.Activity, { className: "scalio-icon" }), L.runNow)) : null),
|
|
242
|
+
active ? h("div", { className: "scalio-selftest-live", "data-testid": "selftest-live" },
|
|
243
|
+
h("div", { className: "scalio-selftest-live-head" },
|
|
244
|
+
h("span", null, progress.running ? L.running : L.waiting),
|
|
245
|
+
h("span", null, `${done}${total ? ` / ${total}` : ""} ${L.checks}`)),
|
|
246
|
+
h("div", { className: "scalio-license-bar" }, h("div", { style: { width: `${pct}%` } })),
|
|
247
|
+
progress.running ? h(ResultList, { results: (progress.results || []).slice(-8) }) : null) : null),
|
|
248
|
+
enabled ? h("div", { className: "scalio-selftest-part" },
|
|
249
|
+
h("div", { className: "scalio-selftest-sub" }, h(Icons.Clock, { className: "scalio-icon", width: 14, height: 14 }), L.schedule),
|
|
250
|
+
h("div", { className: "scalio-selftest-form" },
|
|
251
|
+
h("label", null, h("span", null, L.suite),
|
|
252
|
+
h("select", { className: "scalio-input", value: cfg.suite, "data-testid": "selftest-suite-select",
|
|
253
|
+
onChange: (e) => setCfg(Object.assign({}, cfg, { suite: e.target.value })) },
|
|
254
|
+
(health.suites || []).map((s) => h("option", { key: s, value: s }, s)))),
|
|
255
|
+
h("label", null, h("span", null, L.hour),
|
|
256
|
+
h("select", { className: "scalio-input", value: String(cfg.runHour), "data-testid": "selftest-hour-select",
|
|
257
|
+
onChange: (e) => setCfg(Object.assign({}, cfg, { runHour: Number(e.target.value) })) },
|
|
258
|
+
hours.map((x) => h("option", { key: x, value: String(x) }, x === -1 ? L.noNight : `${String(x).padStart(2, "0")}:00`)))),
|
|
259
|
+
h("button", { type: "button", className: "scalio-btn", disabled: busy, "data-testid": "selftest-save",
|
|
260
|
+
onClick: () => act(() => client.setConfig({ suite: cfg.suite, runHour: cfg.runHour }), L.saved) }, L.save)),
|
|
261
|
+
h("p", { className: "scalio-selftest-hint" }, L.safeHint)) : null,
|
|
262
|
+
h("div", { className: "scalio-selftest-part" },
|
|
263
|
+
h("div", { className: "scalio-selftest-sub" }, L.runs),
|
|
264
|
+
runs.length ? runs.map((r) => h(RunRow, { key: r.id || r.ranAt, run: r, L, lang }))
|
|
265
|
+
: h("p", { className: "scalio-selftest-hint", "data-testid": "selftest-empty" }, L.never)));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Hint above the page content while the test mode is on: a Notice banner, kind warn (orange = wichtig), the same
|
|
269
|
+
// in every app. Props: health ({enabled, suite}) or suite, lang, labels, optional action ({ label, href | onClick })
|
|
270
|
+
// e.g. to the page «Tests».
|
|
271
|
+
function SelfTestBanner(props) {
|
|
272
|
+
const suite = props.suite || (props.health && props.health.enabled && props.health.suite);
|
|
273
|
+
if (!suite) return null;
|
|
274
|
+
const L = selftestLabels(props.lang, props.labels);
|
|
275
|
+
const { Notice } = require("./blocks");
|
|
276
|
+
return h(Notice, { kind: "warn", banner: true, icon: Icons.Activity, title: fill(L.banner, { suite }), action: props.action,
|
|
277
|
+
lang: props.lang, testId: "selftest-banner" });
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
module.exports = { SelfTestPanel, SelfTestBanner, selftestApi, selftestLabels, SELFTEST_LABELS: LABELS };
|