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:
+13
-5
@@ -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) |
|
||||
| `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`)
|
||||
|
||||
| Method | Path | Description |
|
||||
@@ -80,11 +88,11 @@ Browser (port 5173 dev / 80 prod)
|
||||
│
|
||||
└── /api/* → backend:8000 (FastAPI)
|
||||
│
|
||||
┌───────────┼────────────┐
|
||||
/auth /settings /documents/*
|
||||
/users (JSON │
|
||||
/admin volume) └── proxy → doc-service:8001
|
||||
/profile
|
||||
┌───────────┼────────────┬──────────────┐
|
||||
/auth /settings /documents/* /services
|
||||
/users (JSON │ │
|
||||
/admin volume) └── proxy → health-check loop
|
||||
/profile doc-service:8001 (30s poll)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -15,6 +15,7 @@ class Settings(BaseSettings):
|
||||
|
||||
CORS_ORIGINS: list[str] = ["http://localhost:5173"]
|
||||
|
||||
DOC_SERVICE_URL: str = "http://doc-service:8001"
|
||||
AI_SERVICE_URL: str = "http://ai-service:8010"
|
||||
|
||||
@field_validator("JWT_PRIVATE_KEY", "JWT_PUBLIC_KEY", mode="before")
|
||||
|
||||
+25
-2
@@ -1,11 +1,33 @@
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
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.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(
|
||||
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(admin.router, prefix="/api/admin", tags=["admin"])
|
||||
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 —
|
||||
# otherwise /api/documents/{path:path} swallows /api/documents/categories/*
|
||||
app.include_router(
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user