diff --git a/cmd/statuspage-devserver/main.go b/cmd/statuspage-devserver/main.go new file mode 100644 index 0000000..229e3c2 --- /dev/null +++ b/cmd/statuspage-devserver/main.go @@ -0,0 +1,66 @@ +// statuspage-devserver stellt das OPS-02-Backend (internal/statuspage) fuer +// die Next.js-Statusseite bereit und startet den periodischen Poller. +// Getrennt von cmd/core aus demselben Grund wie die anderen *-devserver. +package main + +import ( + "context" + "log" + "net/http" + "os" + "strconv" + "time" + + "gitea.perlbach24.de/scripte/nexarch/internal/db" + "gitea.perlbach24.de/scripte/nexarch/internal/statuspage" +) + +func main() { + dsn := os.Getenv("NEXARCH_REGISTRY_DSN") + if dsn == "" { + log.Fatal("NEXARCH_REGISTRY_DSN nicht gesetzt") + } + addr := os.Getenv("NEXARCH_STATUSPAGE_LISTEN_ADDR") + if addr == "" { + addr = ":8084" + } + intervalSeconds := 10 + if v := os.Getenv("NEXARCH_STATUSPAGE_POLL_INTERVAL_SECONDS"); v != "" { + if parsed, err := strconv.Atoi(v); err == nil { + intervalSeconds = parsed + } + } + + ctx := context.Background() + pool, err := db.Connect(ctx, dsn) + if err != nil { + log.Fatalf("db: %v", err) + } + defer pool.Close() + + store := statuspage.NewStore(pool) + checker := statuspage.NewHTTPChecker(2 * time.Second) + poller := statuspage.NewPoller(store, checker) + go poller.Run(ctx, time.Duration(intervalSeconds)*time.Second) + + mux := http.NewServeMux() + mux.HandleFunc("/status/overview", withCORS(store.OverviewHandler)) + mux.HandleFunc("/status/history", withCORS(store.HistoryHandler)) + mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) + + log.Printf("statuspage-devserver listening on %s (poll-intervall: %ds)", addr, intervalSeconds) + log.Fatal(http.ListenAndServe(addr, mux)) +} + +func withCORS(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusOK) + return + } + next(w, r) + } +} diff --git a/web/status-page/app/layout.tsx b/web/status-page/app/layout.tsx new file mode 100644 index 0000000..f44babd --- /dev/null +++ b/web/status-page/app/layout.tsx @@ -0,0 +1,17 @@ +export const metadata = { + title: "NEXARCH Systemstatus", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + {children} + + + ); +} diff --git a/web/status-page/app/page.tsx b/web/status-page/app/page.tsx new file mode 100644 index 0000000..c1436a2 --- /dev/null +++ b/web/status-page/app/page.tsx @@ -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([]); + const [error, setError] = useState(null); + const [selected, setSelected] = useState(null); + const [history, setHistory] = useState(null); + const [historyError, setHistoryError] = useState(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 ( +
+

Systemstatus

+ + {error && ( +

+ Uebersicht konnte gerade nicht aktualisiert werden: {error}. Zuletzt bekannter Stand wird weiter angezeigt. +

+ )} + + {!error && anyDown && ( +

+ Mindestens ein Modul ist derzeit nicht erreichbar. +

+ )} + +
    + {modules.map((m) => ( +
  • +
    + {m.name} +
    + {m.status === "up" ? "Verfügbar" : "Nicht verfügbar"} + {m.last_checked && ` — zuletzt geprüft ${new Date(m.last_checked).toLocaleString("de-DE")}`} +
    +
    + +
  • + ))} + {modules.length === 0 && !error &&
  • Lade Modulstatus…
  • } +
+ + {selected && ( +
+

Verlauf: {selected}

+ {historyError && ( +

+ {historyError} +

+ )} + {history && ( +
    + {history.map((h, i) => ( +
  • + {new Date(h.changed_at).toLocaleString("de-DE")} — {h.status === "up" ? "verfügbar" : "nicht verfügbar"} +
  • + ))} + {history.length === 0 &&
  • Keine Statusänderungen bisher.
  • } +
+ )} + +
+ )} +
+ ); +} diff --git a/web/status-page/lib/api.ts b/web/status-page/lib/api.ts new file mode 100644 index 0000000..660b80c --- /dev/null +++ b/web/status-page/lib/api.ts @@ -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 { + 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 { + 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(); +} diff --git a/web/status-page/next.config.mjs b/web/status-page/next.config.mjs new file mode 100644 index 0000000..f26ac37 --- /dev/null +++ b/web/status-page/next.config.mjs @@ -0,0 +1,3 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = {}; +export default nextConfig; diff --git a/web/status-page/package.json b/web/status-page/package.json new file mode 100644 index 0000000..9819947 --- /dev/null +++ b/web/status-page/package.json @@ -0,0 +1,21 @@ +{ + "name": "nexarch-status-page", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "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" + } +} diff --git a/web/status-page/tsconfig.json b/web/status-page/tsconfig.json new file mode 100644 index 0000000..26caf44 --- /dev/null +++ b/web/status-page/tsconfig.json @@ -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"] +}