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:
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
77
backend/app/routers/actions_router.py
Normal file
77
backend/app/routers/actions_router.py
Normal 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
|
||||
88
backend/app/routers/alerts_router.py
Normal file
88
backend/app/routers/alerts_router.py
Normal 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)
|
||||
99
backend/app/routers/auth_router.py
Normal file
99
backend/app/routers/auth_router.py
Normal 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
|
||||
99
backend/app/routers/cases_router.py
Normal file
99
backend/app/routers/cases_router.py
Normal 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)
|
||||
42
backend/app/routers/linkage_router.py
Normal file
42
backend/app/routers/linkage_router.py
Normal 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)
|
||||
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