diff --git a/backend/STATUS.md b/backend/STATUS.md index ba7c97f..2de3705 100644 --- a/backend/STATUS.md +++ b/backend/STATUS.md @@ -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) ``` --- diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 0e85644..9adb0e5 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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") diff --git a/backend/app/main.py b/backend/app/main.py index 76f5849..c6be537 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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( diff --git a/backend/app/routers/services.py b/backend/app/routers/services.py new file mode 100644 index 0000000..83e989f --- /dev/null +++ b/backend/app/routers/services.py @@ -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() diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/service_health.py b/backend/app/services/service_health.py new file mode 100644 index 0000000..b5928e0 --- /dev/null +++ b/backend/app/services/service_health.py @@ -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 + ] diff --git a/changelog/2026-04-17_service-health-checks.md b/changelog/2026-04-17_service-health-checks.md new file mode 100644 index 0000000..696b2b0 --- /dev/null +++ b/changelog/2026-04-17_service-health-checks.md @@ -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 diff --git a/frontend/STATUS.md b/frontend/STATUS.md index ab8a7a4..1073f32 100644 --- a/frontend/STATUS.md +++ b/frontend/STATUS.md @@ -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 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 60bf326..e90254e 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -224,6 +224,19 @@ export const updateDocumentLimits = (max_pdf_mb: number) => export const getDocumentLimits = () => api.get>("/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("/services").then((r) => r.data); + // --- System Prompts (admin only) --- export interface ServiceSystemPrompt { label: string; diff --git a/frontend/src/pages/AppsPage.tsx b/frontend/src/pages/AppsPage.tsx index b3ab819..8f73c23 100644 --- a/frontend/src/pages/AppsPage.tsx +++ b/frontend/src/pages/AppsPage.tsx @@ -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 ( - <> -
-

Apps

-
- {APPS.map((app) => ( -
+
+

Apps

+
+ {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) => { + 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) => { + e.currentTarget.style.boxShadow = ""; + e.currentTarget.style.borderColor = "rgb(var(--color-border))"; + }, + } + : { style: svc.healthy ? cardBase : unavailableCard }; + + return ( +
-

{app.name}

- {app.status === "available" ? ( +

{svc.name}

+ {svc.healthy ? ( Available ) : ( - Coming soon + Unavailable )}
-

{app.description}

+

+ {svc.description} +

+ {!svc.healthy && ( +

+ This service is currently unavailable. Please try again later or contact your administrator. +

+ )}
- {app.status === "available" && app.path && ( + {user?.is_admin && svc.settings_path && ( - Open - - )} - {user?.is_admin && app.settingsPath && app.status === "available" && ( - e.stopPropagation()} style={{ padding: "6px 14px", border: "1px solid #ccc", @@ -93,11 +93,10 @@ export default function AppsPage() { )}
-
- ))} -
+ + ); + })}
- +
); } -