portal: Betriebszustand an den Modul-Kacheln
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Canceled after 0s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (pull_request) Canceled after 0s
Core-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
Supply-Chain-Scan (npm) / npm-audit (pull_request) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (pull_request) Canceled after 0s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (pull_request) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (pull_request) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Canceled after 0s
CI / Web (dms-browser) (pull_request) Canceled after 0s
CI / Web (pdf-viewer) (pull_request) Canceled after 0s
CI / Web (shl) (pull_request) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) Canceled after 0s

Die Kachel zeigt Punkt und Text, wenn die Statusseite ein Pruefziel kennt,
dessen Name dem Modul-Schluessel oder der Bezeichnung entspricht. Ohne
Berechtigung (403) oder ohne Statusquelle bleibt die Kachel unveraendert.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
patrickandClaude Sonnet 5.5 committed 2026-10-28 23:56:02 +01:00
1 parent 44c0919724
commit 63dc5c5a6f
5 files changed
+80 -1

No files matched your search

+1 -1
View File
@@ -6,7 +6,7 @@ import { getSession } from "../lib/http";
import { StatTile } from "./Tile";
import type { KennzahlenSummary, ModuleStatusRow } from "../lib/adminData";
async function getJson<T>(path: string): Promise<T | null> {
export async function getJson<T>(path: string): Promise<T | null> {
try {
const res = await getSession().fetch(path);
return res.ok ? ((await res.json()) as T) : null;
+28
View File
@@ -188,3 +188,31 @@ describe("Befehlsleiste im Portal", () => {
expect(btn.textContent).toContain("Strg K");
});
});
describe("Status an der Kachel", () => {
beforeEach(() => localStorage.clear());
function replyWithStatus(status: Response) {
fetchMock.mockImplementation(async (url: string) => {
if (url === "/api/visible-modules") return new Response(JSON.stringify({ modules: mods }), { status: 200 });
if (url === "/api/worklist") return new Response(JSON.stringify({ approvals: 0, tasks: 0, remindersDue: 0 }), { status: 200 });
if (url === "/api/system-status") return status;
return new Response("", { status: 404 });
});
}
it("zeigt den Zustand nur an Kacheln mit passendem Pruefziel", async () => {
replyWithStatus(new Response(JSON.stringify({ modules: [{ name: "DMS", status: "down", lastChecked: null }] }), { status: 200 }));
renderGrid();
const dms = await screen.findByRole("link", { name: /Dokumente/ });
await vi.waitFor(() => expect(dms.textContent).toContain("gestört"));
expect(screen.getByRole("link", { name: /Rollen & Rechte/ }).textContent).not.toContain("gestört");
});
it("ohne Berechtigung (403) bleiben die Kacheln ohne Status", async () => {
replyWithStatus(new Response("", { status: 403 }));
renderGrid();
const dms = await screen.findByRole("link", { name: /Dokumente/ });
expect(dms.textContent).not.toMatch(/in Betrieb|gestört|unbekannt/);
});
});
+18
View File
@@ -6,6 +6,9 @@ import type { ModuleEntry } from "../lib/modules";
import type { Worklist } from "../lib/worklist";
import { getSession, getWorkflowSession } from "../lib/http";
import { ModuleTile, StatTile } from "./Tile";
import { getJson, statusKey } from "./AdminPanel";
import { statusForModule } from "../lib/moduleStatus";
import type { ModuleStatusRow } from "../lib/adminData";
import { Skeleton } from "./Skeleton";
import { CommandPalette } from "./CommandPalette";
import type { CommandItem } from "../lib/commandSearch";
@@ -137,6 +140,17 @@ function Overview({ modules, worklist, who }: OverviewProps) {
const [collapsed, setCollapsed] = useState<string[]>([]);
const [live, setLive] = useState("");
const [paletteOpen, setPaletteOpen] = useState(false);
const [statusRows, setStatusRows] = useState<ModuleStatusRow[] | null>(null);
useEffect(() => {
let cancelled = false;
getJson<{ modules: ModuleStatusRow[] }>("/api/system-status").then((s) => {
if (!cancelled) setStatusRows(s?.modules ?? null);
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
function onKey(e: KeyboardEvent) {
@@ -184,6 +198,9 @@ function Overview({ modules, worklist, who }: OverviewProps) {
const renderTile = (mod: ModuleEntry, compact = false) => {
const badge = badgeFor(mod.key, worklist);
const isFav = favorites.includes(mod.key);
const raw = statusForModule(statusRows, mod);
const state = raw === null ? null : statusKey(raw);
const fallback = { up: "in Betrieb", down: "gestört", unknown: "unbekannt" } as const;
return (
<li key={mod.key} className="np-tile-wrap" data-density={compact ? "compact" : undefined}>
<ModuleTile
@@ -192,6 +209,7 @@ function Overview({ modules, worklist, who }: OverviewProps) {
description={mod.description}
href={resolveModuleHref(mod.href)}
badgeText={badge !== null && badge > 0 ? `${badge} ${t("portal.badge.open", "offen")}` : undefined}
status={state ? { state, text: t(`portal.status.${state}`, fallback[state]) } : undefined}
onOpen={() => setRecent(pushRecent(mod.key, keys))}
/>
<button
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { statusForModule } from "./moduleStatus";
const row = (name: string, status: string) => ({ name, status, lastChecked: null });
const mod = { key: "workflow_cases", label: "Akten & Wiedervorlagen" };
describe("statusForModule", () => {
it("liefert null ohne Statusdaten oder ohne Treffer", () => {
expect(statusForModule(null, mod)).toBeNull();
expect(statusForModule([row("dms", "up")], mod)).toBeNull();
});
it("trifft auf Schluessel und Bezeichnung, unabhaengig von Schreibweise", () => {
expect(statusForModule([row("Workflow-Cases", "up")], mod)).toBe("up");
expect(statusForModule([row("akten wiedervorlagen", "down")], mod)).toBe("down");
});
it("down gewinnt, unbekannt ist nicht up", () => {
expect(statusForModule([row("workflow_cases", "up"), row("Akten & Wiedervorlagen", "down")], mod)).toBe("down");
expect(statusForModule([row("workflow_cases", "up"), row("Akten & Wiedervorlagen", "degraded")], mod)).toBe("unknown");
});
});
+13
View File
@@ -0,0 +1,13 @@
import type { ModuleStatusRow } from "./adminData";
const norm = (s: string): string => s.toLowerCase().replace(/[^a-z0-9]/g, "");
// Die Statusseite kennt Pruefziele mit frei vergebenem Namen; nur Treffer auf Schluessel oder Bezeichnung zaehlen.
export function statusForModule(rows: ModuleStatusRow[] | null, mod: { key: string; label: string }): string | null {
if (!rows) return null;
const keys = new Set([norm(mod.key), norm(mod.label)]);
const hits = rows.filter((r) => keys.has(norm(r.name)));
if (hits.length === 0) return null;
if (hits.some((r) => r.status === "down")) return "down";
return hits.every((r) => r.status === "up") ? "up" : "unknown";
}