agentic-os/agents/bernard/bernard/dashboard/api.py

126 lines
4.1 KiB
Python

"""Bernard dashboard API — FastAPI server serving alerts from Postgres."""
from __future__ import annotations
import json
import os
from contextlib import asynccontextmanager
import psycopg
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
DB_DSN: str = ""
@asynccontextmanager
async def lifespan(app: FastAPI):
global DB_DSN
DB_DSN = os.environ["BERNARD_DB_DSN"]
# Ensure tables exist
conn = await psycopg.AsyncConnection.connect(DB_DSN)
try:
async with conn.cursor() as cur:
await cur.execute("""
CREATE TABLE IF NOT EXISTS bernard_alerts (
id SERIAL PRIMARY KEY,
alert_type TEXT NOT NULL,
title TEXT NOT NULL,
body TEXT NOT NULL,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW()
)
""")
await cur.execute("""
CREATE TABLE IF NOT EXISTS bernard_reviewed_prs (
id SERIAL PRIMARY KEY,
pr_key TEXT UNIQUE NOT NULL,
reviewed_at TIMESTAMPTZ DEFAULT NOW()
)
""")
await conn.commit()
finally:
await conn.close()
yield
app = FastAPI(title="Bernard Dashboard API", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/api/alerts")
async def get_alerts(limit: int = 50, alert_type: str | None = None):
conn = await psycopg.AsyncConnection.connect(DB_DSN)
try:
async with conn.cursor() as cur:
if alert_type:
await cur.execute(
"SELECT id, alert_type, title, body, metadata, created_at FROM bernard_alerts WHERE alert_type=%s ORDER BY created_at DESC LIMIT %s",
(alert_type, limit),
)
else:
await cur.execute(
"SELECT id, alert_type, title, body, metadata, created_at FROM bernard_alerts ORDER BY created_at DESC LIMIT %s",
(limit,),
)
rows = await cur.fetchall()
return [
{
"id": r[0],
"alert_type": r[1],
"title": r[2],
"body": r[3],
"metadata": r[4],
"created_at": r[5].isoformat() if r[5] else None,
}
for r in rows
]
finally:
await conn.close()
@app.get("/api/stats")
async def get_stats():
conn = await psycopg.AsyncConnection.connect(DB_DSN)
try:
async with conn.cursor() as cur:
await cur.execute("SELECT COUNT(*) FROM bernard_alerts")
total = (await cur.fetchone())[0]
await cur.execute("SELECT COUNT(*) FROM bernard_alerts WHERE alert_type='pr_review'")
pr_reviews = (await cur.fetchone())[0]
await cur.execute("SELECT COUNT(*) FROM bernard_alerts WHERE alert_type='workflow_failure'")
wf_failures = (await cur.fetchone())[0]
await cur.execute("SELECT COUNT(*) FROM bernard_reviewed_prs")
reviewed_prs = (await cur.fetchone())[0]
return {
"total_alerts": total,
"pr_reviews": pr_reviews,
"workflow_failures": wf_failures,
"reviewed_prs": reviewed_prs,
}
finally:
await conn.close()
@app.get("/health")
async def health():
return {"status": "ok", "agent": "bernard"}
@app.get("/{full_path:path}")
async def serve_spa(full_path: str):
static_dir = os.path.join(os.path.dirname(__file__), "static")
# If the user asks for a static file like JS/CSS, try to serve it directly
file_path = os.path.join(static_dir, full_path)
if os.path.isfile(file_path) and full_path:
return FileResponse(file_path)
# Otherwise return the SPA index.html
return FileResponse(os.path.join(static_dir, "index.html"))