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
+13 -5
View File
@@ -40,6 +40,14 @@ JWT signing uses a 4096-bit RSA key pair (`RS256`). Keys are generated by `scrip
| `GET` | `/api/admin/users` | List all users (admin only) | | `GET` | `/api/admin/users` | List all users (admin only) |
| `PATCH` | `/api/admin/users/{id}` | Update user (role, active flag) | | `PATCH` | `/api/admin/users/{id}` | Update user (role, active flag) |
### Services (`/api/services`)
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/services` | Returns health status of all registered feature services |
A background task (`service_health.py`) polls each service's `/health` endpoint every 30 s and stores the result in memory. The first check runs immediately on startup. Any authenticated user may call `GET /api/services`; the frontend uses it to drive app card visibility.
### Settings (`/api/settings`) ### Settings (`/api/settings`)
| Method | Path | Description | | Method | Path | Description |
@@ -80,11 +88,11 @@ Browser (port 5173 dev / 80 prod)
└── /api/* → backend:8000 (FastAPI) └── /api/* → backend:8000 (FastAPI)
┌───────────┼────────────┐ ┌───────────┼────────────┬──────────────
/auth /settings /documents/* /auth /settings /documents/* /services
/users (JSON │ /users (JSON │
/admin volume) └── proxy → doc-service:8001 /admin volume) └── proxy → health-check loop
/profile /profile doc-service:8001 (30s poll)
``` ```
--- ---
+1
View File
@@ -15,6 +15,7 @@ class Settings(BaseSettings):
CORS_ORIGINS: list[str] = ["http://localhost:5173"] CORS_ORIGINS: list[str] = ["http://localhost:5173"]
DOC_SERVICE_URL: str = "http://doc-service:8001"
AI_SERVICE_URL: str = "http://ai-service:8010" AI_SERVICE_URL: str = "http://ai-service:8010"
@field_validator("JWT_PRIVATE_KEY", "JWT_PUBLIC_KEY", mode="before") @field_validator("JWT_PRIVATE_KEY", "JWT_PUBLIC_KEY", mode="before")
+25 -2
View File
@@ -1,11 +1,33 @@
import asyncio
from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.core.config import settings from app.core.config import settings
from app.routers import admin, auth, categories_proxy, documents_proxy, profile, users from app.routers import admin, auth, categories_proxy, documents_proxy, profile, services, users
from app.routers import settings as settings_router from app.routers import settings as settings_router
from app.services.service_health import check_all, health_check_loop, register_services
app = FastAPI(title=settings.PROJECT_NAME, version="0.1.0")
@asynccontextmanager
async def lifespan(app: FastAPI):
register_services(
doc_service_url=settings.DOC_SERVICE_URL,
ai_service_url=settings.AI_SERVICE_URL,
)
# Run an initial check immediately so the first API response is accurate
await check_all()
task = asyncio.create_task(health_check_loop())
yield
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
app = FastAPI(title=settings.PROJECT_NAME, version="0.1.0", lifespan=lifespan)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
@@ -20,6 +42,7 @@ app.include_router(users.router, prefix="/api/users", tags=["users"])
app.include_router(profile.router, prefix="/api/profile", tags=["profile"]) app.include_router(profile.router, prefix="/api/profile", tags=["profile"])
app.include_router(admin.router, prefix="/api/admin", tags=["admin"]) app.include_router(admin.router, prefix="/api/admin", tags=["admin"])
app.include_router(settings_router.router, prefix="/api/settings", tags=["settings"]) app.include_router(settings_router.router, prefix="/api/settings", tags=["settings"])
app.include_router(services.router, prefix="/api/services", tags=["services"])
# categories_proxy MUST be registered before documents_proxy — # categories_proxy MUST be registered before documents_proxy —
# otherwise /api/documents/{path:path} swallows /api/documents/categories/* # otherwise /api/documents/{path:path} swallows /api/documents/categories/*
app.include_router( app.include_router(
+22
View File
@@ -0,0 +1,22 @@
"""
GET /api/services — returns health status for all registered feature services.
Available to any authenticated user so the frontend can drive app visibility.
"""
from fastapi import APIRouter, Depends
from app.deps import get_current_user
from app.models.user import User
from app.services.service_health import get_all_statuses
router = APIRouter()
@router.get("")
async def list_services(_: User = Depends(get_current_user)) -> list[dict]:
"""
Returns each registered service with its current health status.
healthy=true → service responded 200 on its last /health poll
healthy=false → unreachable, timed out, or not yet polled
"""
return get_all_statuses()
View File
+108
View File
@@ -0,0 +1,108 @@
"""
Background health-checker for registered feature services.
Polls each service's /health endpoint every POLL_INTERVAL seconds and stores
the result in an in-memory dict. The REST layer reads from that dict — no DB,
no blocking calls on the request path.
"""
import asyncio
import logging
from dataclasses import dataclass, field
import httpx
logger = logging.getLogger(__name__)
POLL_INTERVAL = 30 # seconds
@dataclass
class ServiceDefinition:
id: str
name: str
description: str
internal_url: str # e.g. http://doc-service:8001
health_path: str = "/health"
app_path: str = "" # frontend route; empty = no open button
settings_path: str = "" # frontend admin-settings route
# ── Registry ──────────────────────────────────────────────────────────────────
# Add new services here. The internal_url is filled in at startup from settings.
_REGISTRY: list[ServiceDefinition] = []
# id → True/False/None (None = not yet checked)
_health: dict[str, bool | None] = {}
def register_services(doc_service_url: str, ai_service_url: str) -> None:
"""Called once during app startup to populate the registry from config."""
global _REGISTRY, _health
_REGISTRY = [
ServiceDefinition(
id="doc-service",
name="Documents",
description="Upload PDF files, extract data, and organise them with categories.",
internal_url=doc_service_url,
health_path="/health",
app_path="/apps/documents",
settings_path="/apps/documents/settings/admin",
),
ServiceDefinition(
id="ai-service",
name="AI Service",
description="Shared AI provider for all features. Configure model, credentials, and connection.",
internal_url=ai_service_url,
health_path="/health",
app_path="",
settings_path="/apps/ai/settings/admin",
),
]
_health = {svc.id: None for svc in _REGISTRY}
# ── Health check logic ────────────────────────────────────────────────────────
async def _check_service(svc: ServiceDefinition) -> None:
url = f"{svc.internal_url}{svc.health_path}"
try:
async with httpx.AsyncClient(timeout=5.0) as client:
resp = await client.get(url)
_health[svc.id] = resp.status_code == 200
except Exception:
_health[svc.id] = False
async def check_all() -> None:
"""Run health checks for all registered services concurrently."""
await asyncio.gather(*[_check_service(svc) for svc in _REGISTRY])
async def health_check_loop() -> None:
"""Runs forever; polls every POLL_INTERVAL seconds."""
while True:
await check_all()
await asyncio.sleep(POLL_INTERVAL)
# ── Public read API ───────────────────────────────────────────────────────────
def get_all_statuses() -> list[dict]:
"""Return the current health snapshot for all registered services."""
return [
{
"id": svc.id,
"name": svc.name,
"description": svc.description,
"app_path": svc.app_path,
"settings_path": svc.settings_path,
# None means not yet checked; treat as unhealthy for the UI
"healthy": bool(_health.get(svc.id)),
}
for svc in _REGISTRY
]
@@ -0,0 +1,22 @@
# 2026-04-17 — Service health checks and dynamic Apps page
**Timestamp:** 2026-04-17T00:00:00Z
## Summary
Added a background health-check system to the backend that polls each registered feature service every 30 seconds. The Apps page now renders dynamically based on live service status — showing "Unavailable" when a service is registered but its container is unreachable.
## Files Added / Modified / Deleted
### Added
- `backend/app/services/__init__.py` — package init
- `backend/app/services/service_health.py` — service registry, background polling loop (`POLL_INTERVAL=30s`), `get_all_statuses()` read API
- `backend/app/routers/services.py``GET /api/services` endpoint (requires auth)
### Modified
- `backend/app/core/config.py` — added `DOC_SERVICE_URL` setting (default `http://doc-service:8001`)
- `backend/app/main.py` — added FastAPI `lifespan` context manager: registers services, runs initial health check, starts background polling loop; mounts `/api/services` router
- `frontend/src/api/client.ts` — added `ServiceStatus` interface and `getServices()` API function
- `frontend/src/pages/AppsPage.tsx` — replaced hardcoded `APPS` array with dynamic query to `GET /api/services`; adds "Unavailable" state with dimmed card and explanation text
- `backend/STATUS.md` — documented `/api/services` endpoint and health-check architecture
- `frontend/STATUS.md` — documented dynamic Apps page behaviour
+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`) ### Apps page (`/apps`)
Cards for each installed app: Cards are rendered dynamically from `GET /api/services` (polled every 30 s via TanStack Query):
- **Documents** — link to `/apps/documents`; admin gear icon → `/apps/documents/settings/admin` - **healthy=true + app_path set** — clickable card with "Available" badge
- **AI Service** — infrastructure card; admin gear icon → `/apps/ai/settings/admin`; no Open button (no user-facing UI) - **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 ### Sidebar navigation
+13
View File
@@ -224,6 +224,19 @@ export const updateDocumentLimits = (max_pdf_mb: number) =>
export const getDocumentLimits = () => export const getDocumentLimits = () =>
api.get<Record<string, unknown>>("/settings/documents/limits").then((r) => r.data); 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) --- // --- System Prompts (admin only) ---
export interface ServiceSystemPrompt { export interface ServiceSystemPrompt {
label: string; label: string;
+69 -70
View File
@@ -1,84 +1,84 @@
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { getMe } from "../api/client"; import { getMe, getServices } from "../api/client";
interface AppCard { const cardBase: React.CSSProperties = {
slug: string; backgroundColor: "rgb(var(--color-surface))",
name: string; border: "1px solid rgb(var(--color-border))",
description: string; borderRadius: 8,
status: "available" | "coming_soon"; padding: 24,
path: string; width: 280,
settingsPath?: string; display: "flex",
} flexDirection: "column",
gap: 12,
};
const APPS: AppCard[] = [ const clickableCard: React.CSSProperties = {
{ ...cardBase,
slug: "documents", cursor: "pointer",
name: "Documents", textDecoration: "none",
description: "Upload PDF files, extract data, and organise them with categories.", color: "inherit",
status: "available", transition: "box-shadow 150ms, border-color 150ms",
path: "/apps/documents", };
settingsPath: "/apps/documents/settings/admin",
}, const unavailableCard: React.CSSProperties = {
{ ...cardBase,
slug: "ai", opacity: 0.6,
name: "AI Service", };
description: "Shared AI provider for all features. Configure model, credentials, and connection.",
status: "available",
path: "",
settingsPath: "/apps/ai/settings/admin",
},
];
export default function AppsPage() { export default function AppsPage() {
const { data: user } = useQuery({ queryKey: ["me"], queryFn: getMe }); const { data: user } = useQuery({ queryKey: ["me"], queryFn: getMe });
const { data: services = [] } = useQuery({
queryKey: ["services"],
queryFn: getServices,
refetchInterval: 30_000,
});
return ( return (
<> <div style={{ padding: 32, maxWidth: 900, margin: "0 auto" }}>
<div style={{ padding: 32, maxWidth: 900, margin: "0 auto" }}> <h1>Apps</h1>
<h1>Apps</h1> <div style={{ display: "flex", gap: 24, flexWrap: "wrap", marginTop: 24 }}>
<div style={{ display: "flex", gap: 24, flexWrap: "wrap", marginTop: 24 }}> {services.map((svc) => {
{APPS.map((app) => ( const canOpen = svc.healthy && !!svc.app_path;
<div const CardWrapper = canOpen ? Link : "div";
key={app.slug} const wrapperProps = canOpen
style={{ ? {
border: "1px solid #ddd", to: svc.app_path,
borderRadius: 8, style: clickableCard,
padding: 24, onMouseEnter: (e: React.MouseEvent<HTMLAnchorElement>) => {
width: 280, e.currentTarget.style.boxShadow = "0 4px 12px rgb(0 0 0 / 0.12)";
display: "flex", e.currentTarget.style.borderColor = "rgb(var(--color-primary))";
flexDirection: "column", },
gap: 12, 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" }}> <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<h2 style={{ margin: 0, fontSize: 18 }}>{app.name}</h2> <h2 style={{ margin: 0, fontSize: 18 }}>{svc.name}</h2>
{app.status === "available" ? ( {svc.healthy ? (
<span style={{ fontSize: 12, color: "#2a9d8f", fontWeight: 600 }}>Available</span> <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> </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" }}> <div style={{ display: "flex", gap: 8, marginTop: "auto" }}>
{app.status === "available" && app.path && ( {user?.is_admin && svc.settings_path && (
<Link <Link
to={app.path} to={svc.settings_path}
style={{ onClick={(e) => e.stopPropagation()}
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}
style={{ style={{
padding: "6px 14px", padding: "6px 14px",
border: "1px solid #ccc", border: "1px solid #ccc",
@@ -93,11 +93,10 @@ export default function AppsPage() {
</Link> </Link>
)} )}
</div> </div>
</div> </CardWrapper>
))} );
</div> })}
</div> </div>
</> </div>
); );
} }