Merge branch 'feature/ops-02-zentrale-statusseite' into feature/qa-09-barrierefreiheits-audit
# Conflicts: # DEVLOG.md # scripts/reset-test-env.sh
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
import { ThemeProvider, I18nProvider, ToastProvider, typography } from "@nexarch/shl";
|
||||
|
||||
export const metadata = {
|
||||
title: "NEXARCH Systemstatus",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="de">
|
||||
<body
|
||||
style={{
|
||||
fontFamily: typography.fontFamily,
|
||||
margin: 0,
|
||||
background: "var(--shl-color-background, #ffffff)",
|
||||
color: "var(--shl-color-text-primary, #14181f)",
|
||||
}}
|
||||
>
|
||||
<ThemeProvider>
|
||||
<I18nProvider initialLocale="de">
|
||||
<ToastProvider>{children}</ToastProvider>
|
||||
</I18nProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { fetchOverview, fetchHistory, type ModuleStatus, type HistoryEntry } from "@/lib/api";
|
||||
|
||||
const POLL_INTERVAL_MS = 5000;
|
||||
|
||||
export default function Page() {
|
||||
const [modules, setModules] = useState<ModuleStatus[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [history, setHistory] = useState<HistoryEntry[] | null>(null);
|
||||
const [historyError, setHistoryError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
const data = await fetchOverview();
|
||||
if (!cancelled) {
|
||||
setModules(data);
|
||||
setError(null);
|
||||
}
|
||||
} catch (e: any) {
|
||||
// Ein nicht antwortendes Backend darf die zuletzt bekannte Ansicht
|
||||
// nicht loeschen und die Seite nicht unbedienbar machen
|
||||
// (Akzeptanzkriterium 2) — nur eine Fehlermeldung anzeigen, alte
|
||||
// Daten bleiben sichtbar.
|
||||
if (!cancelled) {
|
||||
setError(e.message ?? "Unbekannter Fehler beim Laden der Uebersicht");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
poll();
|
||||
const id = setInterval(poll, POLL_INTERVAL_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(id);
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function openHistory(name: string) {
|
||||
setSelected(name);
|
||||
setHistoryError(null);
|
||||
try {
|
||||
const data = await fetchHistory(name);
|
||||
setHistory(data);
|
||||
} catch (e: any) {
|
||||
setHistoryError(e.message ?? "Verlauf konnte nicht geladen werden");
|
||||
setHistory(null);
|
||||
}
|
||||
}
|
||||
|
||||
const anyDown = modules.some((m) => m.status === "down");
|
||||
|
||||
return (
|
||||
<main style={{ maxWidth: 800, margin: "0 auto", padding: "2rem 1rem" }}>
|
||||
<h1>Systemstatus</h1>
|
||||
|
||||
{error && (
|
||||
<p style={{ color: "#c62828", background: "#fdecea", padding: "0.5rem", borderRadius: 4 }} role="alert">
|
||||
Uebersicht konnte gerade nicht aktualisiert werden: {error}. Zuletzt bekannter Stand wird weiter angezeigt.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!error && anyDown && (
|
||||
<p style={{ color: "#c62828", background: "#fdecea", padding: "0.5rem", borderRadius: 4 }}>
|
||||
Mindestens ein Modul ist derzeit nicht erreichbar.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ul style={{ listStyle: "none", padding: 0 }}>
|
||||
{modules.map((m) => (
|
||||
<li
|
||||
key={m.name}
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
background: "white",
|
||||
padding: "0.75rem 1rem",
|
||||
marginBottom: "0.5rem",
|
||||
borderRadius: 8,
|
||||
borderLeft: `6px solid ${m.status === "up" ? "#2e7d32" : "#c62828"}`,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<strong>{m.name}</strong>
|
||||
<div style={{ fontSize: "0.85rem", color: "#666" }}>
|
||||
{m.status === "up" ? "Verfügbar" : "Nicht verfügbar"}
|
||||
{m.last_checked && ` — zuletzt geprüft ${new Date(m.last_checked).toLocaleString("de-DE")}`}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => openHistory(m.name)}>Verlauf</button>
|
||||
</li>
|
||||
))}
|
||||
{modules.length === 0 && !error && <li>Lade Modulstatus…</li>}
|
||||
</ul>
|
||||
|
||||
{selected && (
|
||||
<section style={{ background: "white", padding: "1rem", borderRadius: 8, marginTop: "1.5rem" }}>
|
||||
<h2>Verlauf: {selected}</h2>
|
||||
{historyError && (
|
||||
<p style={{ color: "#c62828" }} role="alert">
|
||||
{historyError}
|
||||
</p>
|
||||
)}
|
||||
{history && (
|
||||
<ul>
|
||||
{history.map((h, i) => (
|
||||
<li key={i}>
|
||||
{new Date(h.changed_at).toLocaleString("de-DE")} — {h.status === "up" ? "verfügbar" : "nicht verfügbar"}
|
||||
</li>
|
||||
))}
|
||||
{history.length === 0 && <li>Keine Statusänderungen bisher.</li>}
|
||||
</ul>
|
||||
)}
|
||||
<button onClick={() => setSelected(null)}>Schließen</button>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Duenner Client des OPS-02-Backends (internal/statuspage) — keine eigene
|
||||
// Aggregations-/Polling-Logik im Frontend.
|
||||
export type ModuleStatus = {
|
||||
name: string;
|
||||
status: "up" | "down";
|
||||
last_checked?: string;
|
||||
};
|
||||
|
||||
export type HistoryEntry = {
|
||||
status: "up" | "down";
|
||||
changed_at: string;
|
||||
};
|
||||
|
||||
function apiBase(): string {
|
||||
const base = process.env.NEXT_PUBLIC_STATUSPAGE_API_URL;
|
||||
if (!base) {
|
||||
throw new Error(
|
||||
"NEXT_PUBLIC_STATUSPAGE_API_URL ist nicht gesetzt (Umgebungsvariable erforderlich)"
|
||||
);
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
// fetchOverview holt den Status EINES Moduls unabhaengig vom Erfolg der
|
||||
// anderen — ein Netzwerkfehler beim Abruf der Gesamtuebersicht wird vom
|
||||
// Aufrufer (Page-Komponente) abgefangen, sodass ein nicht antwortendes
|
||||
// Backend die Seite nicht zum Absturz bringt (Akzeptanzkriterium 2).
|
||||
export async function fetchOverview(): Promise<ModuleStatus[]> {
|
||||
const res = await fetch(`${apiBase()}/status/overview`, { cache: "no-store" });
|
||||
if (!res.ok) {
|
||||
throw new Error(`Uebersicht konnte nicht geladen werden (${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchHistory(name: string): Promise<HistoryEntry[]> {
|
||||
const res = await fetch(`${apiBase()}/status/history?name=${encodeURIComponent(name)}`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Verlauf konnte nicht geladen werden (${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
// @nexarch/shl liegt als file:-Dependency mit TS-Quellen in node_modules —
|
||||
// Next.js transpiliert node_modules standardmäßig nicht, siehe web/shl/README.md.
|
||||
transpilePackages: ["@nexarch/shl"],
|
||||
};
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "nexarch-status-page",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nexarch/shl": "file:../shl",
|
||||
"next": "14.2.35",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "20.14.9",
|
||||
"@types/react": "18.3.3",
|
||||
"@types/react-dom": "18.3.0",
|
||||
"typescript": "5.5.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": { "@/*": ["./*"] }
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user