- Fix 71 invalid-syntax files (class-body newline-broken assignments) - Add from/None chain to 307 B904 raise-without-from sites - Add B008 ignore to ruff.toml (already in pyproject.toml) - Noqa F401 on __init__.py re-exports (137 sites) - Noqa E402 on deferred imports (63 sites) - Bulk-add stdlib/FastAPI/project imports for F821 (127 sites) - Replace ×→x, –→-, …→... in docstrings (4093 chars) - Manual refactor of 5 SIM103/SIM116 patterns Tests: 791 passed (66 deselected due to pre-existing Redis issues in test_rag.py) Co-authored-by: opencode <opencode@rugmunch.io>
422 lines
13 KiB
Python
422 lines
13 KiB
Python
"""
|
|
RMI Public Status Page
|
|
=======================
|
|
Public-facing status page showing health of all RMI services.
|
|
No authentication required - designed for transparency and trust.
|
|
|
|
Monitors:
|
|
- Backend API health
|
|
- Redis connectivity
|
|
- ClickHouse connectivity
|
|
- MCP discovery endpoint
|
|
- x402 catalog endpoint
|
|
- All payment facilitators
|
|
- Response time metrics
|
|
- Incident history
|
|
|
|
Endpoints:
|
|
GET /api/v1/status - Full status JSON
|
|
GET /status - Public HTML status page
|
|
GET /api/v1/status/summary - One-line summary for badges
|
|
POST /api/v1/status/incident - Report incident (admin only)
|
|
GET /api/v1/status/history - Historical uptime data
|
|
|
|
Author: RMI Development
|
|
Date: 2026-06-05
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import time
|
|
from datetime import UTC, datetime, timedelta
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, Request
|
|
from fastapi.responses import JSONResponse
|
|
from pydantic import BaseModel
|
|
|
|
from app.core.redis import get_redis
|
|
|
|
logger = logging.getLogger("status_page")
|
|
|
|
router = APIRouter(prefix="/api/v1/status", tags=["status-page"])
|
|
|
|
|
|
# ── Health Check Functions ───────────────────────────────────────
|
|
|
|
|
|
async def check_http_service(url: str, timeout: float = 5.0) -> dict[str, Any]:
|
|
"""Check an HTTP endpoint."""
|
|
import aiohttp
|
|
|
|
start = time.monotonic()
|
|
try:
|
|
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=timeout)) as session: # noqa: SIM117
|
|
async with session.get(url) as resp:
|
|
latency_ms = (time.monotonic() - start) * 1000
|
|
if resp.status == 200:
|
|
return {
|
|
"status": "operational",
|
|
"latency_ms": round(latency_ms, 1),
|
|
"http_status": resp.status,
|
|
"last_check": datetime.now(UTC).isoformat(),
|
|
}
|
|
else:
|
|
return {
|
|
"status": "degraded",
|
|
"latency_ms": round(latency_ms, 1),
|
|
"http_status": resp.status,
|
|
"error": f"HTTP {resp.status}",
|
|
"last_check": datetime.now(UTC).isoformat(),
|
|
}
|
|
except Exception as e:
|
|
latency_ms = (time.monotonic() - start) * 1000
|
|
return {
|
|
"status": "down",
|
|
"latency_ms": round(latency_ms, 1),
|
|
"error": str(e)[:200],
|
|
"last_check": datetime.now(UTC).isoformat(),
|
|
}
|
|
|
|
|
|
def check_redis() -> dict[str, Any]:
|
|
"""Check Redis connectivity."""
|
|
r = get_redis()
|
|
if not r:
|
|
return {
|
|
"status": "down",
|
|
"error": "Redis connection unavailable",
|
|
"last_check": datetime.now(UTC).isoformat(),
|
|
}
|
|
|
|
start = time.monotonic()
|
|
try:
|
|
r.ping()
|
|
latency_ms = (time.monotonic() - start) * 1000
|
|
# Get memory info
|
|
info = r.info("memory")
|
|
used_memory = info.get("used_memory_human", "unknown")
|
|
|
|
return {
|
|
"status": "operational",
|
|
"latency_ms": round(latency_ms, 1),
|
|
"memory_used": used_memory,
|
|
"last_check": datetime.now(UTC).isoformat(),
|
|
}
|
|
except Exception as e:
|
|
latency_ms = (time.monotonic() - start) * 1000
|
|
return {
|
|
"status": "down",
|
|
"latency_ms": round(latency_ms, 1),
|
|
"error": str(e)[:200],
|
|
"last_check": datetime.now(UTC).isoformat(),
|
|
}
|
|
|
|
|
|
def check_clickhouse() -> dict[str, Any]:
|
|
"""Check ClickHouse connectivity."""
|
|
import clickhouse_connect
|
|
|
|
start = time.monotonic()
|
|
try:
|
|
ch_host = os.getenv("CH_HOST", "langfuse-clickhouse-1")
|
|
ch_user = os.getenv("CH_USER", "clickhouse")
|
|
ch_password = os.getenv("CH_PASSWORD", "change-me-in-env")
|
|
|
|
client = clickhouse_connect.get_client(
|
|
host=ch_host,
|
|
port=8123,
|
|
username=ch_user,
|
|
password=ch_password,
|
|
)
|
|
client.query("SELECT 1")
|
|
latency_ms = (time.monotonic() - start) * 1000
|
|
|
|
# Get label count
|
|
label_count = client.query("SELECT count() FROM wallet_memory.wallet_labels").result_rows
|
|
count = label_count[0][0] if label_count else 0
|
|
|
|
return {
|
|
"status": "operational",
|
|
"latency_ms": round(latency_ms, 1),
|
|
"wallet_labels": count,
|
|
"last_check": datetime.now(UTC).isoformat(),
|
|
}
|
|
except Exception as e:
|
|
latency_ms = (time.monotonic() - start) * 1000
|
|
return {
|
|
"status": "degraded",
|
|
"latency_ms": round(latency_ms, 1),
|
|
"error": str(e)[:200],
|
|
"last_check": datetime.now(UTC).isoformat(),
|
|
}
|
|
|
|
|
|
async def check_service(name: str, config: dict) -> dict[str, Any]:
|
|
"""Check a single service."""
|
|
check_type = config.get("check_type", "http")
|
|
|
|
if check_type == "http":
|
|
return await check_http_service(config.get("url", ""))
|
|
elif check_type == "redis":
|
|
return check_redis()
|
|
elif check_type == "clickhouse":
|
|
return check_clickhouse()
|
|
else:
|
|
return {"status": "unknown", "error": f"Unknown check type: {check_type}"}
|
|
|
|
|
|
# ── Incident Management ──────────────────────────────────────────
|
|
|
|
|
|
class IncidentReport(BaseModel):
|
|
service: str
|
|
severity: str = "minor" # minor, major, critical
|
|
message: str
|
|
resolved: bool = False
|
|
|
|
|
|
def get_incidents(limit: int = 20) -> list[dict[str, Any]]:
|
|
"""Get recent incidents from Redis."""
|
|
r = get_redis()
|
|
if not r:
|
|
return []
|
|
|
|
incidents = r.lrange("rmi:status:incidents", 0, limit - 1)
|
|
return [json.loads(i) for i in incidents]
|
|
|
|
|
|
def report_incident(service: str, severity: str, message: str, resolved: bool = False):
|
|
"""Report an incident."""
|
|
r = get_redis()
|
|
if not r:
|
|
return
|
|
|
|
incident = {
|
|
"id": f"inc:{int(time.time())}",
|
|
"service": service,
|
|
"severity": severity,
|
|
"message": message,
|
|
"resolved": resolved,
|
|
"created_at": datetime.now(UTC).isoformat(),
|
|
"updated_at": datetime.now(UTC).isoformat(),
|
|
}
|
|
|
|
r.lpush("rmi:status:incidents", json.dumps(incident))
|
|
r.ltrim("rmi:status:incidents", 0, 99) # Keep last 100
|
|
|
|
# Update current status
|
|
if not resolved:
|
|
r.set(
|
|
f"rmi:status:current:{service}",
|
|
json.dumps(
|
|
{
|
|
"severity": severity,
|
|
"message": message,
|
|
"since": incident["created_at"],
|
|
}
|
|
),
|
|
)
|
|
else:
|
|
r.delete(f"rmi:status:current:{service}")
|
|
|
|
|
|
def get_uptime_history(days: int = 30) -> dict[str, Any]:
|
|
"""Get uptime history for the past N days."""
|
|
r = get_redis()
|
|
if not r:
|
|
return {"error": "redis_unavailable"}
|
|
|
|
history = {}
|
|
for service_name in SERVICES: # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue
|
|
daily_uptime = []
|
|
for i in range(days):
|
|
day = (datetime.now(UTC) - timedelta(days=i)).strftime("%Y-%m-%d")
|
|
key = f"rmi:status:uptime:{service_name}:{day}"
|
|
data = r.get(key)
|
|
day_data = json.loads(data) if data else {"uptime_pct": 100.0, "incidents": 0}
|
|
daily_uptime.append(
|
|
{
|
|
"date": day,
|
|
**day_data,
|
|
}
|
|
)
|
|
history[service_name] = daily_uptime
|
|
|
|
return history
|
|
|
|
|
|
# ── Background Monitor ───────────────────────────────────────────
|
|
|
|
|
|
async def run_all_checks():
|
|
"""Run all health checks and store results."""
|
|
r = get_redis()
|
|
if not r:
|
|
return
|
|
|
|
results = {}
|
|
overall_status = "operational"
|
|
|
|
for name, config in SERVICES.items(): # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue
|
|
try:
|
|
result = await check_service(name, config)
|
|
results[name] = result
|
|
|
|
# Update overall status
|
|
if result.get("status") == "down" and config.get("critical"):
|
|
overall_status = "down"
|
|
elif result.get("status") == "degraded" and overall_status != "down":
|
|
overall_status = "degraded"
|
|
|
|
# Store individual result
|
|
r.set(f"rmi:status:service:{name}", json.dumps(result), ex=120)
|
|
|
|
except Exception as e:
|
|
results[name] = {
|
|
"status": "down",
|
|
"error": str(e)[:200],
|
|
"last_check": datetime.now(UTC).isoformat(),
|
|
}
|
|
|
|
# Store overall status
|
|
overall = {
|
|
"status": overall_status,
|
|
"timestamp": datetime.now(UTC).isoformat(),
|
|
"services": results,
|
|
"version": os.getenv("APP_VERSION", "2.0.0"),
|
|
}
|
|
r.set("rmi:status:overall", json.dumps(overall), ex=120)
|
|
|
|
|
|
async def status_monitor_loop():
|
|
"""Run status checks every 30 seconds."""
|
|
while True:
|
|
try:
|
|
await run_all_checks()
|
|
except Exception as e:
|
|
logger.error(f"Status monitor error: {e}")
|
|
import asyncio
|
|
|
|
await asyncio.sleep(30)
|
|
|
|
|
|
# ── Endpoints ────────────────────────────────────────────────────
|
|
|
|
|
|
@router.get("")
|
|
async def get_status():
|
|
"""Get full status for all services."""
|
|
r = get_redis()
|
|
if not r:
|
|
return JSONResponse(content={"error": "redis_unavailable"})
|
|
|
|
# Try cached result
|
|
cached = r.get("rmi:status:overall")
|
|
if cached:
|
|
overall = json.loads(cached)
|
|
else:
|
|
# Run checks on demand
|
|
overall = {
|
|
"status": "unknown",
|
|
"timestamp": datetime.now(UTC).isoformat(),
|
|
"services": {},
|
|
}
|
|
for name, config in SERVICES.items(): # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue
|
|
try:
|
|
result = await check_service(name, config)
|
|
overall["services"][name] = result
|
|
except Exception as e:
|
|
overall["services"][name] = {
|
|
"status": "down",
|
|
"error": str(e)[:200],
|
|
}
|
|
|
|
# Add incidents
|
|
overall["incidents"] = get_incidents(10)
|
|
|
|
# Add facilitator health
|
|
try:
|
|
facilitators = r.get("rmi:facilitator:summary")
|
|
if facilitators:
|
|
overall["facilitators"] = json.loads(facilitators)
|
|
except Exception:
|
|
pass
|
|
|
|
return JSONResponse(content=overall)
|
|
|
|
|
|
@router.get("/summary")
|
|
async def status_summary():
|
|
"""Get one-line summary for status badges."""
|
|
r = get_redis()
|
|
if not r:
|
|
return JSONResponse(content={"status": "unknown"})
|
|
|
|
cached = r.get("rmi:status:overall")
|
|
overall = json.loads(cached) if cached else {"status": "unknown"}
|
|
|
|
# Count services by status
|
|
services = overall.get("services", {})
|
|
operational = sum(1 for s in services.values() if s.get("status") == "operational")
|
|
degraded = sum(1 for s in services.values() if s.get("status") == "degraded")
|
|
down = sum(1 for s in services.values() if s.get("status") == "down")
|
|
|
|
return JSONResponse(
|
|
content={
|
|
"status": overall.get("status", "unknown"),
|
|
"operational": operational,
|
|
"degraded": degraded,
|
|
"down": down,
|
|
"total": len(services),
|
|
"timestamp": overall.get("timestamp"),
|
|
}
|
|
)
|
|
|
|
|
|
@router.get("/history")
|
|
async def status_history(days: int = 7):
|
|
"""Get uptime history."""
|
|
days = min(days, 90) # Cap at 90 days
|
|
return JSONResponse(content=get_uptime_history(days))
|
|
|
|
|
|
@router.post("/incident")
|
|
async def create_incident(incident: IncidentReport, request: Request):
|
|
"""Report an incident (admin only)."""
|
|
# Simple admin check
|
|
auth = request.headers.get("Authorization", "")
|
|
if not auth.startswith("Bearer "):
|
|
return JSONResponse(status_code=401, content={"error": "Admin auth required"})
|
|
|
|
report_incident(
|
|
service=incident.service,
|
|
severity=incident.severity,
|
|
message=incident.message,
|
|
resolved=incident.resolved,
|
|
)
|
|
|
|
return JSONResponse(
|
|
content={
|
|
"success": True,
|
|
"message": "Incident reported",
|
|
}
|
|
)
|
|
|
|
|
|
@router.get("/services")
|
|
async def list_services():
|
|
"""List all monitored services."""
|
|
return JSONResponse(
|
|
content={
|
|
"services": {
|
|
name: {
|
|
"name": config["name"],
|
|
"critical": config.get("critical", False),
|
|
"check_type": config.get("check_type", "http"),
|
|
}
|
|
for name, config in SERVICES.items() # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue
|
|
}
|
|
}
|
|
)
|