Split into a FastAPI backend and a React frontend, add case state and SSO
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:
103
backend/app/routers/settings_router.py
Normal file
103
backend/app/routers/settings_router.py
Normal 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]}
|
||||
Reference in New Issue
Block a user