Add service health checks and dynamic Apps page

Backend polls each registered service's /health endpoint every 30 s via a
background asyncio task. GET /api/services exposes the live status snapshot.
The Apps page now renders from this endpoint — showing "Unavailable" (dimmed,
non-clickable) when a service is registered but its container is unreachable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
curo1305
2026-04-17 17:31:36 +02:00
parent 1f8f866414
commit 3248607790
10 changed files with 278 additions and 80 deletions
+5 -3
View File
@@ -35,9 +35,11 @@ All API calls go through `src/api/client.ts` (single Axios instance, JWT injecte
### Apps page (`/apps`)
Cards for each installed app:
- **Documents** — link to `/apps/documents`; admin gear icon → `/apps/documents/settings/admin`
- **AI Service** — infrastructure card; admin gear icon → `/apps/ai/settings/admin`; no Open button (no user-facing UI)
Cards are rendered dynamically from `GET /api/services` (polled every 30 s via TanStack Query):
- **healthy=true + app_path set** — clickable card with "Available" badge
- **healthy=true + no app_path** — non-clickable card (e.g. AI Service — no user UI)
- **healthy=false** — non-clickable, dimmed card with "Unavailable" badge and explanation text
- Admin settings link shown for admins regardless of health status
### Sidebar navigation
+13
View File
@@ -224,6 +224,19 @@ export const updateDocumentLimits = (max_pdf_mb: number) =>
export const getDocumentLimits = () =>
api.get<Record<string, unknown>>("/settings/documents/limits").then((r) => r.data);
// --- Services ---
export interface ServiceStatus {
id: string;
name: string;
description: string;
healthy: boolean;
app_path: string;
settings_path: string;
}
export const getServices = () =>
api.get<ServiceStatus[]>("/services").then((r) => r.data);
// --- System Prompts (admin only) ---
export interface ServiceSystemPrompt {
label: string;
+69 -70
View File
@@ -1,84 +1,84 @@
import { Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { getMe } from "../api/client";
import { getMe, getServices } from "../api/client";
interface AppCard {
slug: string;
name: string;
description: string;
status: "available" | "coming_soon";
path: string;
settingsPath?: string;
}
const cardBase: React.CSSProperties = {
backgroundColor: "rgb(var(--color-surface))",
border: "1px solid rgb(var(--color-border))",
borderRadius: 8,
padding: 24,
width: 280,
display: "flex",
flexDirection: "column",
gap: 12,
};
const APPS: AppCard[] = [
{
slug: "documents",
name: "Documents",
description: "Upload PDF files, extract data, and organise them with categories.",
status: "available",
path: "/apps/documents",
settingsPath: "/apps/documents/settings/admin",
},
{
slug: "ai",
name: "AI Service",
description: "Shared AI provider for all features. Configure model, credentials, and connection.",
status: "available",
path: "",
settingsPath: "/apps/ai/settings/admin",
},
];
const clickableCard: React.CSSProperties = {
...cardBase,
cursor: "pointer",
textDecoration: "none",
color: "inherit",
transition: "box-shadow 150ms, border-color 150ms",
};
const unavailableCard: React.CSSProperties = {
...cardBase,
opacity: 0.6,
};
export default function AppsPage() {
const { data: user } = useQuery({ queryKey: ["me"], queryFn: getMe });
const { data: services = [] } = useQuery({
queryKey: ["services"],
queryFn: getServices,
refetchInterval: 30_000,
});
return (
<>
<div style={{ padding: 32, maxWidth: 900, margin: "0 auto" }}>
<h1>Apps</h1>
<div style={{ display: "flex", gap: 24, flexWrap: "wrap", marginTop: 24 }}>
{APPS.map((app) => (
<div
key={app.slug}
style={{
border: "1px solid #ddd",
borderRadius: 8,
padding: 24,
width: 280,
display: "flex",
flexDirection: "column",
gap: 12,
}}
>
<div style={{ padding: 32, maxWidth: 900, margin: "0 auto" }}>
<h1>Apps</h1>
<div style={{ display: "flex", gap: 24, flexWrap: "wrap", marginTop: 24 }}>
{services.map((svc) => {
const canOpen = svc.healthy && !!svc.app_path;
const CardWrapper = canOpen ? Link : "div";
const wrapperProps = canOpen
? {
to: svc.app_path,
style: clickableCard,
onMouseEnter: (e: React.MouseEvent<HTMLAnchorElement>) => {
e.currentTarget.style.boxShadow = "0 4px 12px rgb(0 0 0 / 0.12)";
e.currentTarget.style.borderColor = "rgb(var(--color-primary))";
},
onMouseLeave: (e: React.MouseEvent<HTMLAnchorElement>) => {
e.currentTarget.style.boxShadow = "";
e.currentTarget.style.borderColor = "rgb(var(--color-border))";
},
}
: { style: svc.healthy ? cardBase : unavailableCard };
return (
<CardWrapper key={svc.id} {...(wrapperProps as any)}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<h2 style={{ margin: 0, fontSize: 18 }}>{app.name}</h2>
{app.status === "available" ? (
<h2 style={{ margin: 0, fontSize: 18 }}>{svc.name}</h2>
{svc.healthy ? (
<span style={{ fontSize: 12, color: "#2a9d8f", fontWeight: 600 }}>Available</span>
) : (
<span style={{ fontSize: 12, color: "#aaa" }}>Coming soon</span>
<span style={{ fontSize: 12, color: "#e76f51", fontWeight: 600 }}>Unavailable</span>
)}
</div>
<p style={{ margin: 0, color: "#555", fontSize: 14 }}>{app.description}</p>
<p style={{ margin: 0, color: "rgb(var(--color-text-muted))", fontSize: 14 }}>
{svc.description}
</p>
{!svc.healthy && (
<p style={{ margin: 0, fontSize: 12, color: "#e76f51" }}>
This service is currently unavailable. Please try again later or contact your administrator.
</p>
)}
<div style={{ display: "flex", gap: 8, marginTop: "auto" }}>
{app.status === "available" && app.path && (
{user?.is_admin && svc.settings_path && (
<Link
to={app.path}
style={{
padding: "6px 14px",
background: "#222",
color: "#fff",
borderRadius: 4,
textDecoration: "none",
fontSize: 14,
}}
>
Open
</Link>
)}
{user?.is_admin && app.settingsPath && app.status === "available" && (
<Link
to={app.settingsPath}
to={svc.settings_path}
onClick={(e) => e.stopPropagation()}
style={{
padding: "6px 14px",
border: "1px solid #ccc",
@@ -93,11 +93,10 @@ export default function AppsPage() {
</Link>
)}
</div>
</div>
))}
</div>
</CardWrapper>
);
})}
</div>
</>
</div>
);
}