Commit 9640e4df authored by Kantz's avatar Kantz
Browse files

health check anzeige

parent b857e4c4
...@@ -74,9 +74,10 @@ def health() -> Dict[str, Any]: ...@@ -74,9 +74,10 @@ def health() -> Dict[str, Any]:
"openai": _check_openai(), "openai": _check_openai(),
"postgres": _check_postgres(), "postgres": _check_postgres(),
} }
overall = "ok" required_statuses = {"ok"}
if any(value["status"] in ("error", "unauthorized") for value in services.values()): overall = "ok" if all(
overall = "degraded" service.get("status") in required_statuses for service in services.values()
) else "degraded"
return {"status": overall, "services": services} return {"status": overall, "services": services}
......
export type HealthStatus = "ok" | "degraded" | "error" | "unknown";
export type HealthResponse = {
status: HealthStatus;
services?: Record<string, { status?: string; [key: string]: unknown }>;
};
const normalizeHealthStatus = (value: unknown): HealthStatus => {
if (value === "ok" || value === "degraded" || value === "error") {
return value;
}
return "unknown";
};
export async function fetchHealth(): Promise<HealthResponse> {
const response = await fetch("/api/health");
if (!response.ok) {
throw new Error(`Health check failed: ${response.status}`);
}
const payload = await response.json();
return {
status: normalizeHealthStatus(payload?.status),
services:
payload && typeof payload === "object" && payload.services
? (payload.services as HealthResponse["services"])
: undefined,
};
}
import { useCallback, useEffect, useMemo, useState, type PropsWithChildren } from "react";
import { fetchHealth, type HealthResponse } from "../../api/healthApi";
import { t } from "../../i18n";
import "../../styles/theme.css";
type GateState = "checking" | "healthy" | "unhealthy";
const HEALTH_POLL_INTERVAL_MS = 5000;
const formatServiceStatus = (health: HealthResponse | null): string | null => {
if (!health?.services) {
return null;
}
const failing = Object.entries(health.services)
.filter(([, value]) => {
const status = String(value?.status || "").toLowerCase();
return status && status !== "ok";
})
.map(([service, value]) => `${service}: ${String(value?.status || "unknown")}`);
return failing.length ? failing.join(" | ") : null;
};
export default function BackendHealthGate({ children }: PropsWithChildren) {
const [gateState, setGateState] = useState<GateState>("checking");
const [lastError, setLastError] = useState<string | null>(null);
const [lastHealth, setLastHealth] = useState<HealthResponse | null>(null);
const [isRetrying, setIsRetrying] = useState(false);
const runHealthCheck = useCallback(async () => {
try {
const health = await fetchHealth();
setLastHealth(health);
if (health.status === "ok") {
setGateState("healthy");
setLastError(null);
} else {
setGateState("unhealthy");
setLastError(`status=${health.status}`);
}
} catch (error) {
setGateState("unhealthy");
setLastHealth(null);
if (error instanceof Error && error.message) {
setLastError(error.message);
} else {
setLastError("network_error");
}
}
}, []);
useEffect(() => {
void runHealthCheck();
}, [runHealthCheck]);
useEffect(() => {
if (gateState !== "unhealthy") {
return;
}
const timer = window.setInterval(() => {
void runHealthCheck();
}, HEALTH_POLL_INTERVAL_MS);
return () => {
window.clearInterval(timer);
};
}, [gateState, runHealthCheck]);
const handleRetry = async () => {
setIsRetrying(true);
try {
await runHealthCheck();
} finally {
setIsRetrying(false);
}
};
const failureDetails = useMemo(() => {
const serviceDetails = formatServiceStatus(lastHealth);
if (serviceDetails) {
return serviceDetails;
}
return lastError;
}, [lastError, lastHealth]);
if (gateState === "healthy") {
return <>{children}</>;
}
return (
<div className="backend-health-shell">
<header className="app-header backend-health-header">
<div className="brand">
<span className="brand-mark">SUM</span>
<div className="brand-text">
<div className="brand-title">Math Tutor</div>
<div className="brand-subtitle">{t("prototypeWorkspace")}</div>
</div>
</div>
<div className="header-meta">
<span className="pill">Postgres + pgvector</span>
<span className="pill">Python FastAPI</span>
<span className="pill">Mathpix</span>
</div>
</header>
<main className="backend-health-main">
<section className="backend-health-card">
{gateState === "checking" ? (
<p className="backend-health-loading">{t("backendChecking")}</p>
) : (
<>
<h1 className="backend-health-title">{t("backendUnavailableTitle")}</h1>
<p className="backend-health-text">{t("backendUnavailableBody")}</p>
{failureDetails ? (
<p className="backend-health-detail">
{t("lastCheckFailed", { detail: failureDetails })}
</p>
) : null}
<button
type="button"
className="btn primary"
onClick={handleRetry}
disabled={isRetrying}
>
{isRetrying ? t("loading") : t("retryConnection")}
</button>
</>
)}
</section>
</main>
</div>
);
}
...@@ -62,6 +62,12 @@ ...@@ -62,6 +62,12 @@
changeTaskArea: "Change Task Area", changeTaskArea: "Change Task Area",
previousTask: "Previous Task", previousTask: "Previous Task",
nextTask: "Next Task", nextTask: "Next Task",
backendChecking: "Checking backend availability...",
backendUnavailableTitle: "Backend not reachable",
backendUnavailableBody:
"The tutor backend is currently unavailable or still starting up. We will keep trying automatically.",
retryConnection: "Retry now",
lastCheckFailed: "Last check: {detail}",
}, },
de: { de: {
chats: "Chats", chats: "Chats",
...@@ -131,6 +137,12 @@ ...@@ -131,6 +137,12 @@
changeTaskArea: "Aufgabengebiet ändern", changeTaskArea: "Aufgabengebiet ändern",
previousTask: "Vorherige Aufgabe", previousTask: "Vorherige Aufgabe",
nextTask: "Nächste Aufgabe", nextTask: "Nächste Aufgabe",
backendChecking: "Backend-Verbindung wird geprüft...",
backendUnavailableTitle: "Backend nicht erreichbar",
backendUnavailableBody:
"Das Tutor-Backend ist aktuell nicht erreichbar oder startet noch. Wir versuchen es automatisch weiter.",
retryConnection: "Erneut prüfen",
lastCheckFailed: "Letzte Prüfung: {detail}",
}, },
} as const; } as const;
......
...@@ -3,11 +3,14 @@ import { createRoot } from "react-dom/client"; ...@@ -3,11 +3,14 @@ import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom"; import { BrowserRouter } from "react-router-dom";
import "./index.css"; import "./index.css";
import App from "./pages/App.tsx"; import App from "./pages/App.tsx";
import BackendHealthGate from "./components/System/BackendHealthGate.tsx";
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<BrowserRouter> <BrowserRouter>
<App /> <BackendHealthGate>
<App />
</BackendHealthGate>
</BrowserRouter> </BrowserRouter>
</StrictMode> </StrictMode>
); );
...@@ -704,6 +704,61 @@ body { ...@@ -704,6 +704,61 @@ body {
color: #6f675d; color: #6f675d;
} }
.backend-health-shell {
min-height: 100vh;
padding: 24px;
background: radial-gradient(circle at top left, #fff7e6, #f4f1ea);
display: flex;
flex-direction: column;
}
.backend-health-header {
margin-bottom: 24px;
}
.backend-health-main {
flex: 1;
display: grid;
place-items: center;
}
.backend-health-card {
width: min(620px, 100%);
background: #fef9f0;
border: 1px solid #d8d1c4;
border-radius: 16px;
padding: 18px;
display: flex;
flex-direction: column;
gap: 10px;
}
.backend-health-title {
margin: 0;
font-size: 20px;
}
.backend-health-text {
margin: 0;
color: #6f675d;
}
.backend-health-loading {
margin: 0;
color: #6f675d;
}
.backend-health-detail {
margin: 0;
padding: 8px 10px;
border-radius: 10px;
background: #f3d9d6;
color: #8a3b2f;
border: 1px solid #e2b4ae;
font-size: 12px;
overflow-wrap: anywhere;
}
.task-select-main { .task-select-main {
flex: 1; flex: 1;
display: grid; display: grid;
...@@ -769,6 +824,14 @@ body { ...@@ -769,6 +824,14 @@ body {
} }
@media (max-width: 900px) { @media (max-width: 900px) {
.backend-health-shell {
padding: 16px;
}
.backend-health-card {
width: 100%;
}
.task-select-controls { .task-select-controls {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment