126 lines
4.1 KiB
Python
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"))
|