Split into a FastAPI backend and a React frontend, add case state and SSO
Some checks failed
build-and-deploy / test (push) Has been cancelled
build-and-deploy / image (push) Has been cancelled
build-and-deploy / deploy (push) Has been cancelled

The single-file stdlib server became the limit: no way to track what had been
done about an alert, no accounts, and a UI that had to be hand-rolled in
template strings. This restructures it into something deployable.

Backend (FastAPI)
- app/ holds config, database, auth, delivery and the routers; triagelib keeps
  the triage engine unchanged, so the validated screening and runbook logic is
  untouched.
- Cases persist per alert fingerprint with a status workflow (investigating,
  customer contacted, escalated to Infra, waiting, remediated, resolved, won't
  fix, false positive), an assignee, notes and an append-only history. An alert
  that stops and re-fires lands back on the same case and counts as a reopen.
- Suppression rules move from a JSON file into the database.

Auth
- Signed session cookies over PBKDF2 local accounts, plus an OIDC flow ready for
  Authentik: users are created on first login and admin follows a group claim.
  Local login can be switched off entirely once SSO is live.

Zendesk and Jira
- Delivery is now implemented, behind three gates: the integration must be
  configured, its feature flag on, and CX_FEATURE_SEND_ENABLED on. A demo
  instance leaves the last off and cannot mail anyone. Both search before
  creating, so re-diagnosing an alert updates one ticket rather than opening
  several, and a rolling daily cap stops a loop mailing everybody.

Deployment
- Multi-stage Dockerfile builds the bundle and serves it from the API origin.
- docker-compose for local and single-host use; Gitea Actions runs the tests,
  builds the image and renders deploy/k8s with envsubst.

Two fixes found while testing: assigning a case returned a null assignee, and
add_event could leave an already-loaded history collection stale.

Known gap: the engine reaches OpenStack via `docker exec <region>-osc`, which
does not work in a pod without the CX-Tools containers alongside it.
docs/DEPLOYMENT.md sets out the three ways to close that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 07:11:28 +01:00
parent a039e0b5fd
commit 1262690276
68 changed files with 3839 additions and 2223 deletions

View File

View File

@@ -0,0 +1,77 @@
"""Outbound actions: contact the customer, escalate to Infrastructure."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.orm import Session
from ..auth import current_user
from ..config import get_settings
from ..db import get_db
from ..delivery import DeliveryError, create_jira, send_zendesk, sends_today
from ..models import Case, User
router = APIRouter(prefix="/api/actions", tags=["actions"])
settings = get_settings()
class ZendeskBody(BaseModel):
fingerprint: str
to: str
subject: str
body: str
priority: str = "normal"
tags: list[str] = []
public: bool | None = None
class JiraBody(BaseModel):
fingerprint: str
summary: str
description: str
project: str = ""
issue_type: str = ""
labels: list[str] = []
def _case(db: Session, fingerprint: str) -> Case:
found = db.query(Case).filter(Case.fingerprint == fingerprint).one_or_none()
if not found:
raise HTTPException(status.HTTP_404_NOT_FOUND,
"Open the case first - nothing is tracked for that alert yet.")
return found
@router.get("/status")
def action_status(db: Session = Depends(get_db), user: User = Depends(current_user)):
return {
"zendesk_ready": settings.zendesk_ready,
"jira_ready": settings.jira_ready,
"send_enabled": settings.feature_send_enabled,
"sends_today": sends_today(db),
"daily_cap": settings.send_daily_cap,
}
@router.post("/zendesk")
async def zendesk(body: ZendeskBody, db: Session = Depends(get_db),
user: User = Depends(current_user)):
case = _case(db, body.fingerprint)
try:
return await send_zendesk(db, case, user, to=body.to, subject=body.subject,
body=body.body, priority=body.priority,
tags=body.tags or None, public=body.public)
except DeliveryError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
@router.post("/jira")
async def jira(body: JiraBody, db: Session = Depends(get_db),
user: User = Depends(current_user)):
case = _case(db, body.fingerprint)
try:
return await create_jira(db, case, user, summary=body.summary,
description=body.description, project=body.project,
issue_type=body.issue_type, labels=body.labels or None)
except DeliveryError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc

View File

@@ -0,0 +1,88 @@
"""The alert queue and per-alert diagnosis."""
from __future__ import annotations
import threading
import time
import traceback
import uuid
from concurrent.futures import ThreadPoolExecutor
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.orm import Session
from triagelib import runbooks
from ..auth import current_user
from ..db import SessionLocal, get_db
from ..models import User
from ..services import Engine, RuleAdapter, get_or_create_case
router = APIRouter(prefix="/api", tags=["alerts"])
engine = Engine()
_jobs: dict[str, dict[str, Any]] = {}
_jobs_lock = threading.Lock()
_pool = ThreadPoolExecutor(max_workers=3, thread_name_prefix="triage")
JOB_TTL = 30 * 60
class TriageBody(BaseModel):
fingerprint: str
force: bool = False
@router.get("/alerts")
def alert_queue(force: bool = False, db: Session = Depends(get_db),
user: User = Depends(current_user)):
return engine.queue(db, force=force)
@router.post("/triage")
def start_triage(body: TriageBody, db: Session = Depends(get_db),
user: User = Depends(current_user)):
alert = engine.find_alert(db, body.fingerprint)
if alert is None:
raise HTTPException(status.HTTP_404_NOT_FOUND,
"That alert is no longer firing. Refresh the queue.")
case = get_or_create_case(db, alert, user)
job_id = uuid.uuid4().hex[:12]
with _jobs_lock:
_reap()
_jobs[job_id] = {"id": job_id, "state": "running", "created": time.time(),
"result": None, "error": ""}
_pool.submit(_run, job_id, alert, body.force)
return {"job_id": job_id, "alert": alert.to_json(), "case": case.to_json(with_events=True)}
@router.get("/jobs/{job_id}")
def job(job_id: str, user: User = Depends(current_user)):
with _jobs_lock:
found = _jobs.get(job_id)
if not found:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Unknown job")
return found
def _run(job_id: str, alert: Any, force: bool) -> None:
started = time.monotonic()
db = SessionLocal()
try:
diagnosis = runbooks.diagnose(alert, engine.prom, engine.snapshot.get(), force,
RuleAdapter(db))
payload = diagnosis.to_json()
payload["elapsed_seconds"] = round(time.monotonic() - started, 1)
with _jobs_lock:
_jobs[job_id].update({"state": "done", "result": payload})
except Exception:
with _jobs_lock:
_jobs[job_id].update({"state": "error", "error": traceback.format_exc(limit=4)})
finally:
db.close()
def _reap() -> None:
cutoff = time.time() - JOB_TTL
for key in [k for k, v in _jobs.items() if v["created"] < cutoff]:
_jobs.pop(key, None)

View File

@@ -0,0 +1,99 @@
"""Sign-in: local accounts and the Authentik OIDC round trip."""
from __future__ import annotations
import datetime as dt
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from fastapi.responses import RedirectResponse
from pydantic import BaseModel
from sqlalchemy.orm import Session
from .. import auth as auth_mod
from ..config import get_settings
from ..db import get_db
from ..models import User
router = APIRouter(prefix="/api/auth", tags=["auth"])
settings = get_settings()
class LoginBody(BaseModel):
email: str
password: str
def _set_cookie(response: Response, user: User) -> None:
response.set_cookie(
auth_mod.SESSION_COOKIE, auth_mod.issue_session(user.id),
max_age=settings.session_hours * 3600, httponly=True, samesite="lax",
secure=settings.base_url.startswith("https://"), path="/",
)
@router.get("/me")
def me(user: User | None = Depends(auth_mod.optional_user)):
return {"user": user.to_json() if user else None, "config": settings.public_flags()}
@router.post("/login")
def login(body: LoginBody, response: Response, db: Session = Depends(get_db)):
if not settings.auth_local_enabled:
raise HTTPException(status.HTTP_403_FORBIDDEN, "Local login is disabled; use SSO.")
user = auth_mod.authenticate_local(db, body.email, body.password)
if not user:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Incorrect email or password")
user.last_login = dt.datetime.now(dt.timezone.utc)
db.commit()
_set_cookie(response, user)
return {"user": user.to_json()}
@router.post("/logout")
def logout(response: Response):
response.delete_cookie(auth_mod.SESSION_COOKIE, path="/")
return {"ok": True}
@router.get("/oidc/start")
async def oidc_start(request: Request):
if not settings.oidc_enabled:
raise HTTPException(status.HTTP_404_NOT_FOUND, "SSO is not enabled")
try:
meta = await auth_mod.oidc_discovery()
except auth_mod.OIDCError as exc:
raise HTTPException(status.HTTP_502_BAD_GATEWAY, str(exc)) from exc
redirect_uri = f"{settings.base_url.rstrip('/')}/api/auth/oidc/callback"
state = auth_mod.oidc_state()
url = (f"{meta['authorization_endpoint']}?response_type=code"
f"&client_id={settings.oidc_client_id}"
f"&redirect_uri={redirect_uri}"
f"&scope={settings.oidc_scopes.replace(' ', '%20')}"
f"&state={state}")
response = RedirectResponse(url, status_code=302)
response.set_cookie("cx_oidc_state", state, max_age=600, httponly=True, samesite="lax", path="/")
return response
@router.get("/oidc/callback")
async def oidc_callback(request: Request, code: str = "", state: str = "",
db: Session = Depends(get_db)):
if not settings.oidc_enabled:
raise HTTPException(status.HTTP_404_NOT_FOUND, "SSO is not enabled")
# The state must match the cookie we set *and* still verify - one guards
# against a swapped browser, the other against a forged value.
if not code or not state or state != request.cookies.get("cx_oidc_state") \
or not auth_mod.oidc_state_valid(state):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid or expired SSO state")
redirect_uri = f"{settings.base_url.rstrip('/')}/api/auth/oidc/callback"
try:
claims = await auth_mod.oidc_exchange(code, redirect_uri)
user = auth_mod.upsert_oidc_user(db, claims)
except auth_mod.OIDCError as exc:
raise HTTPException(status.HTTP_502_BAD_GATEWAY, str(exc)) from exc
response = RedirectResponse("/", status_code=302)
_set_cookie(response, user)
response.delete_cookie("cx_oidc_state", path="/")
return response

View File

@@ -0,0 +1,99 @@
"""Case state: the human layer over the alert queue."""
from __future__ import annotations
import datetime as dt
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.orm import Session
from ..auth import current_user
from ..db import get_db
from ..models import Case, CaseStatus, User
from ..services import add_event, set_status
router = APIRouter(prefix="/api/cases", tags=["cases"])
class StatusBody(BaseModel):
status: str
note: str = ""
class NoteBody(BaseModel):
note: str
class SnoozeBody(BaseModel):
hours: int = 24
note: str = ""
def _case(db: Session, fingerprint: str) -> Case:
found = db.query(Case).filter(Case.fingerprint == fingerprint).one_or_none()
if not found:
raise HTTPException(status.HTTP_404_NOT_FOUND, "No case for that alert yet")
return found
@router.get("")
def list_cases(open_only: bool = True, limit: int = 200, db: Session = Depends(get_db),
user: User = Depends(current_user)):
query = db.query(Case).order_by(Case.last_seen_at.desc())
rows = [c for c in query.limit(max(1, min(limit, 1000))).all()
if (c.is_open or not open_only)]
return {"cases": [c.to_json() for c in rows], "statuses": [s.value for s in CaseStatus]}
@router.get("/{fingerprint}")
def get_case(fingerprint: str, db: Session = Depends(get_db), user: User = Depends(current_user)):
return _case(db, fingerprint).to_json(with_events=True)
@router.post("/{fingerprint}/status")
def change_status(fingerprint: str, body: StatusBody, db: Session = Depends(get_db),
user: User = Depends(current_user)):
try:
new_status = CaseStatus(body.status)
except ValueError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST,
f"Unknown status '{body.status}'") from exc
case = set_status(db, _case(db, fingerprint), new_status, user, body.note)
return case.to_json(with_events=True)
@router.post("/{fingerprint}/assign")
def assign(fingerprint: str, db: Session = Depends(get_db), user: User = Depends(current_user)):
case = _case(db, fingerprint)
# Set the relationship, not just the id: the response is serialised from
# this same object and a bare id leaves `assignee` null in the payload.
case.assignee = user
if case.status == CaseStatus.NEW:
case.status = CaseStatus.INVESTIGATING
add_event(db, case, user, "assigned", f"Picked up by {user.email}")
db.commit()
db.refresh(case)
return case.to_json(with_events=True)
@router.post("/{fingerprint}/note")
def add_note(fingerprint: str, body: NoteBody, db: Session = Depends(get_db),
user: User = Depends(current_user)):
if not body.note.strip():
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Empty note")
case = _case(db, fingerprint)
case.notes = (case.notes + "\n" if case.notes else "") + body.note.strip()
add_event(db, case, user, "note", body.note.strip())
db.commit()
return case.to_json(with_events=True)
@router.post("/{fingerprint}/snooze")
def snooze(fingerprint: str, body: SnoozeBody, db: Session = Depends(get_db),
user: User = Depends(current_user)):
case = _case(db, fingerprint)
until = dt.datetime.now(dt.timezone.utc) + dt.timedelta(hours=max(1, body.hours))
case.snooze_until = until
add_event(db, case, user, "snoozed", f"Snoozed for {body.hours}h. {body.note}".strip())
db.commit()
return case.to_json(with_events=True)

View File

@@ -0,0 +1,42 @@
"""Linkage scan endpoints."""
from __future__ import annotations
import threading
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from triagelib import linkage as linkage_mod
from ..auth import current_user
from ..config import get_settings
from ..models import User
from ..routers.alerts_router import engine
router = APIRouter(prefix="/api/linkage", tags=["linkage"])
settings = get_settings()
class EnrichBody(BaseModel):
region: str
openstack_id: str
@router.get("")
def scan_state(user: User = Depends(current_user)):
return engine.scan.to_json()
@router.post("/scan")
def start_scan(user: User = Depends(current_user)):
if not settings.feature_linkage_scan:
raise HTTPException(status.HTTP_403_FORBIDDEN, "The linkage scan is disabled on this instance.")
if engine.scan.state == "running":
return {"started": False, "reason": "already running"}
threading.Thread(target=engine.scan.run, args=(engine.snapshot.get(),), daemon=True).start()
return {"started": True}
@router.post("/enrich")
def enrich(body: EnrichBody, user: User = Depends(current_user)):
return linkage_mod.enrich(body.region, body.openstack_id)

View File

@@ -0,0 +1,103 @@
"""Suppression rules and per-instance preferences."""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.orm import Session
from triagelib import settings as legacy
from ..auth import current_user, require_admin
from ..config import get_settings
from ..db import get_db
from ..models import AppSetting, SuppressionRule, User
from ..routers.alerts_router import engine
router = APIRouter(prefix="/api/settings", tags=["settings"])
app_settings = get_settings()
class RuleBody(BaseModel):
id: str | None = None
name: str
reason: str = ""
enabled: bool = True
conditions: dict[str, Any] = {}
class GeneralBody(BaseModel):
agent_name: str | None = None
chronic_days: int | None = None
@router.get("")
def read_settings(db: Session = Depends(get_db), user: User = Depends(current_user)):
row = db.get(AppSetting, "general")
general = (row.value if row else {}) or {}
return {
"rules": [r.to_json() for r in db.query(SuppressionRule).order_by(SuppressionRule.id).all()],
"conditions": legacy.CONDITIONS,
"agent_name": general.get("agent_name") or "",
"chronic_days": general.get("chronic_days") or 3,
"config": app_settings.public_flags(),
}
@router.post("/general")
def save_general(body: GeneralBody, db: Session = Depends(get_db),
user: User = Depends(current_user)):
row = db.get(AppSetting, "general") or AppSetting(key="general", value={})
value = dict(row.value or {})
if body.agent_name is not None:
value["agent_name"] = body.agent_name.strip()
if body.chronic_days is not None:
value["chronic_days"] = max(1, int(body.chronic_days))
row.value = value
db.merge(row)
db.commit()
return read_settings(db, user)
@router.post("/rules")
def save_rule(body: RuleBody, db: Session = Depends(get_db), user: User = Depends(require_admin)):
conditions = {k: v for k, v in (body.conditions or {}).items() if k in legacy.CONDITIONS and v}
if not conditions:
raise HTTPException(status.HTTP_400_BAD_REQUEST,
"A rule needs at least one condition, otherwise it would hide everything.")
rule = db.get(SuppressionRule, int(body.id)) if (body.id or "").isdigit() else None
if rule is None:
rule = SuppressionRule(created_by=user.email)
db.add(rule)
rule.name = body.name.strip() or "Untitled rule"
rule.reason = body.reason.strip()
rule.enabled = body.enabled
rule.conditions = conditions
db.commit()
return read_settings(db, user)
@router.delete("/rules/{rule_id}")
def delete_rule(rule_id: int, db: Session = Depends(get_db), user: User = Depends(require_admin)):
rule = db.get(SuppressionRule, rule_id)
if rule:
db.delete(rule)
db.commit()
return read_settings(db, user)
@router.post("/rules/preview")
def preview_rule(body: RuleBody, db: Session = Depends(get_db), user: User = Depends(current_user)):
"""Show which firing alerts a rule would hide, before it is saved."""
from triagelib import alerts as alertlib
raw, _error, _age = engine.cache.get()
candidates = [a for a in (alertlib.from_prometheus(x, engine.rules) for x in raw)
if not alertlib.is_excluded(a) and alertlib.cx_relevant(a)]
rule = legacy._normalize_rule({"name": body.name, "conditions": body.conditions})
hits = [{
"kind": a.kind, "title": a.title, "instance_name": a.instance_name,
"host": a.host, "org_name": a.org_name, "region": a.region,
} for a in candidates if legacy.rule_matches(rule, a)]
return {"count": len(hits), "matches": hits[:60]}