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

@@ -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]}