Skip to content

Commit 5dd41bc

Browse files
committed
feat: add alert lifecycle, bulk ops, settings, inline editing, and UX improvements
- Add alert occurrence tracking, ticket URL linking, runbook URL support - Add archived status with enum casing migration (uppercase → lowercase) - Add bulk acknowledge/resolve with multi-select UI - Add tag search filtering with clickable tags - Add inline editing for silences, notification channels, and notes - Add settings page with app config display and archive controls - Add keyboard shortcuts (?, Esc, a, r, j/k navigation) - Add incident phase badges (detection/investigation/mitigation/resolution) - Fix URL normalization for ticket links (auto-prepend https://) - Move settings into main nav bar alongside other views
1 parent 585d52e commit 5dd41bc

26 files changed

Lines changed: 1607 additions & 268 deletions

backend/api/routes/alerts.py

Lines changed: 115 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,33 @@
11
import uuid
2+
from datetime import UTC, datetime
23

34
from fastapi import APIRouter, Depends, HTTPException, Query
45
from sqlalchemy import select
56
from sqlalchemy.ext.asyncio import AsyncSession
67

78
from backend.database import get_db
8-
from backend.models import Alert
9+
from backend.models import Alert, AlertOccurrence
910
from backend.schemas import (
1011
AlertAckRequest,
1112
AlertListResponse,
1213
AlertNoteCreate,
1314
AlertNoteListResponse,
1415
AlertNoteResponse,
1516
AlertNoteUpdate,
17+
AlertOccurrenceListResponse,
18+
AlertOccurrenceResponse,
1619
AlertResponse,
1720
AlertTagsUpdate,
21+
AlertTicketUpdate,
22+
BulkAlertActionRequest,
23+
BulkAlertActionResponse,
1824
)
1925
from backend.services import (
2026
acknowledge_alert,
2127
add_alert_tag,
28+
archive_alerts,
29+
bulk_acknowledge_alerts,
30+
bulk_resolve_alerts,
2231
create_alert_note,
2332
delete_alert_note,
2433
get_alert_notes,
@@ -67,6 +76,52 @@ async def remove_note(
6776
raise HTTPException(status_code=404, detail="Note not found")
6877

6978

79+
# ─── Bulk actions ──────────────────────────────────────────
80+
81+
82+
@router.post(
83+
"/bulk/acknowledge",
84+
response_model=BulkAlertActionResponse,
85+
summary="Bulk acknowledge alerts",
86+
)
87+
async def bulk_acknowledge(
88+
body: BulkAlertActionRequest,
89+
db: AsyncSession = Depends(get_db),
90+
) -> BulkAlertActionResponse:
91+
"""Acknowledge multiple alerts at once."""
92+
updated_ids = await bulk_acknowledge_alerts(db, body.alert_ids)
93+
return BulkAlertActionResponse(updated=len(updated_ids), alert_ids=updated_ids)
94+
95+
96+
@router.post(
97+
"/bulk/resolve",
98+
response_model=BulkAlertActionResponse,
99+
summary="Bulk resolve alerts",
100+
)
101+
async def bulk_resolve(
102+
body: BulkAlertActionRequest,
103+
db: AsyncSession = Depends(get_db),
104+
) -> BulkAlertActionResponse:
105+
"""Resolve multiple alerts at once."""
106+
updated_ids = await bulk_resolve_alerts(db, body.alert_ids)
107+
return BulkAlertActionResponse(updated=len(updated_ids), alert_ids=updated_ids)
108+
109+
110+
@router.post(
111+
"/archive",
112+
summary="Archive old resolved alerts",
113+
)
114+
async def archive_old_alerts(
115+
older_than_days: int = Query(
116+
default=30, ge=1, description="Archive resolved alerts older than N days",
117+
),
118+
db: AsyncSession = Depends(get_db),
119+
) -> dict:
120+
"""Archive resolved alerts that are older than the specified number of days."""
121+
count = await archive_alerts(db, older_than_days=older_than_days)
122+
return {"archived": count}
123+
124+
70125
# ─── Alert list & detail ──────────────────────────────────
71126

72127

@@ -79,7 +134,8 @@ async def list_alerts(
79134
status: str | None = Query(default=None, description="Filter by status"),
80135
severity: str | None = Query(default=None, description="Filter by severity"),
81136
service: str | None = Query(default=None, description="Filter by service"),
82-
q: str | None = Query(default=None, description="Search name, service, host, description"),
137+
q: str | None = Query(default=None, description="Search across name, service, host, tags"),
138+
tag: str | None = Query(default=None, description="Filter by exact tag"),
83139
sort_by: str = Query(default="created_at", description="Sort field"),
84140
sort_order: str = Query(default="desc", pattern="^(asc|desc)$", description="Sort order"),
85141
page: int = Query(default=1, ge=1, description="Page number"),
@@ -89,7 +145,7 @@ async def list_alerts(
89145
"""List alerts with optional filtering, search, sorting, and pagination."""
90146
alerts, total = await get_alerts(
91147
db, status=status, severity=severity, service=service,
92-
search=q, sort_by=sort_by, sort_order=sort_order,
148+
search=q, tag=tag, sort_by=sort_by, sort_order=sort_order,
93149
page=page, page_size=page_size,
94150
)
95151

@@ -253,3 +309,59 @@ async def add_note(
253309
except ValueError:
254310
raise HTTPException(status_code=404, detail="Alert not found")
255311
return AlertNoteResponse.model_validate(note)
312+
313+
314+
# ─── Ticket URL ────────────────────────────────────────────
315+
316+
317+
@router.put(
318+
"/{alert_id}/ticket",
319+
response_model=AlertResponse,
320+
summary="Set or update external ticket URL",
321+
)
322+
async def set_ticket_url(
323+
alert_id: uuid.UUID,
324+
body: AlertTicketUpdate,
325+
db: AsyncSession = Depends(get_db),
326+
) -> AlertResponse:
327+
"""Link an alert to an external ticket (Jira, GitHub issue, etc)."""
328+
stmt = select(Alert).where(Alert.id == alert_id)
329+
result = await db.execute(stmt)
330+
alert = result.scalar_one_or_none()
331+
if not alert:
332+
raise HTTPException(status_code=404, detail="Alert not found")
333+
url = body.ticket_url.strip()
334+
if url and not url.startswith(("http://", "https://")):
335+
url = "https://" + url
336+
alert.ticket_url = url
337+
alert.updated_at = datetime.now(UTC)
338+
await db.flush()
339+
await db.refresh(alert)
340+
return AlertResponse.model_validate(alert)
341+
342+
343+
# ─── Occurrence History ────────────────────────────────────
344+
345+
346+
@router.get(
347+
"/{alert_id}/history",
348+
response_model=AlertOccurrenceListResponse,
349+
summary="Get alert occurrence history",
350+
)
351+
async def get_alert_history(
352+
alert_id: uuid.UUID,
353+
db: AsyncSession = Depends(get_db),
354+
) -> AlertOccurrenceListResponse:
355+
"""Get the timeline of when this alert (and its duplicates) were received."""
356+
stmt = (
357+
select(AlertOccurrence)
358+
.where(AlertOccurrence.alert_id == alert_id)
359+
.order_by(AlertOccurrence.received_at.desc())
360+
.limit(100)
361+
)
362+
result = await db.execute(stmt)
363+
occurrences = list(result.scalars().all())
364+
return AlertOccurrenceListResponse(
365+
occurrences=[AlertOccurrenceResponse.model_validate(o) for o in occurrences],
366+
total=len(occurrences),
367+
)

backend/api/routes/settings.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from fastapi import APIRouter
2+
3+
from backend.config import get_settings
4+
5+
router = APIRouter(prefix="/settings", tags=["settings"])
6+
7+
8+
@router.get("", summary="Get application settings")
9+
async def get_app_settings() -> dict:
10+
"""Return read-only application configuration."""
11+
s = get_settings()
12+
return {
13+
"app_name": s.app_name,
14+
"app_env": s.app_env,
15+
"dedup_window_seconds": s.dedup_window_seconds,
16+
"correlation_window_seconds": s.correlation_window_seconds,
17+
"notification_cooldown_seconds": s.notification_cooldown_seconds,
18+
"solace_dashboard_url": s.solace_dashboard_url,
19+
}

backend/integrations/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ def __init__(
4040
starts_at: datetime | None = None,
4141
ends_at: datetime | None = None,
4242
generator_url: str | None = None,
43+
runbook_url: str | None = None,
44+
ticket_url: str | None = None,
4345
raw_payload: dict | None = None,
4446
):
4547
self.name = name
@@ -57,6 +59,8 @@ def __init__(
5759
self.starts_at = starts_at or datetime.now(UTC)
5860
self.ends_at = ends_at
5961
self.generator_url = generator_url
62+
self.runbook_url = runbook_url
63+
self.ticket_url = ticket_url
6064
self.raw_payload = raw_payload
6165

6266

@@ -111,6 +115,8 @@ def normalize(self, payload: dict) -> list[NormalizedAlert]:
111115
starts_at=data.starts_at,
112116
ends_at=data.ends_at,
113117
generator_url=data.generator_url,
118+
runbook_url=data.runbook_url,
119+
ticket_url=data.ticket_url,
114120
raw_payload=payload,
115121
)
116122

backend/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from backend.api.deps import require_api_key
77
from backend.api.routes import alerts, health, incidents, notifications, silences, stats, webhooks
8+
from backend.api.routes import settings as settings_routes
89
from backend.api.routes.ws import router as ws_router
910
from backend.config import get_settings
1011

@@ -56,6 +57,7 @@
5657
app.include_router(stats.router, prefix=settings.api_prefix, dependencies=api_deps)
5758
app.include_router(silences.router, prefix=settings.api_prefix, dependencies=api_deps)
5859
app.include_router(notifications.router, prefix=settings.api_prefix, dependencies=api_deps)
60+
app.include_router(settings_routes.router, prefix=settings.api_prefix, dependencies=api_deps)
5961

6062

6163
# ─── Startup / Shutdown ──────────────────────────────────
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""add runbook_url, ticket_url, archived_at, alert_occurrences, incident phase
2+
3+
Revision ID: d4e5f6a7b8c9
4+
Revises: c3d4e5f6a7b8
5+
Create Date: 2026-02-13 12:00:00.000000
6+
7+
"""
8+
from collections.abc import Sequence
9+
10+
import sqlalchemy as sa
11+
from alembic import op
12+
13+
# revision identifiers, used by Alembic.
14+
revision: str = 'd4e5f6a7b8c9'
15+
down_revision: str | None = 'c3d4e5f6a7b8'
16+
branch_labels: str | Sequence[str] | None = None
17+
depends_on: str | Sequence[str] | None = None
18+
19+
20+
def upgrade() -> None:
21+
# Add 'archived' value to the alertstatus enum
22+
op.execute("ALTER TYPE alertstatus ADD VALUE IF NOT EXISTS 'archived'")
23+
24+
# Add new columns to alerts table
25+
op.add_column('alerts', sa.Column('runbook_url', sa.Text(), nullable=True))
26+
op.add_column('alerts', sa.Column('ticket_url', sa.Text(), nullable=True))
27+
op.add_column('alerts', sa.Column('archived_at', sa.DateTime(timezone=True), nullable=True))
28+
29+
# Add phase column to incidents table
30+
op.add_column('incidents', sa.Column('phase', sa.String(length=50), nullable=True))
31+
32+
# Create alert_occurrences table
33+
op.create_table('alert_occurrences',
34+
sa.Column('id', sa.UUID(), nullable=False),
35+
sa.Column('alert_id', sa.UUID(), nullable=False),
36+
sa.Column('received_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
37+
sa.ForeignKeyConstraint(['alert_id'], ['alerts.id'], ondelete='CASCADE'),
38+
sa.PrimaryKeyConstraint('id'),
39+
)
40+
op.create_index('idx_alert_occurrences_alert_id', 'alert_occurrences', ['alert_id'])
41+
op.create_index('idx_alert_occurrences_received_at', 'alert_occurrences', ['received_at'])
42+
43+
44+
def downgrade() -> None:
45+
op.drop_index('idx_alert_occurrences_received_at', table_name='alert_occurrences')
46+
op.drop_index('idx_alert_occurrences_alert_id', table_name='alert_occurrences')
47+
op.drop_table('alert_occurrences')
48+
49+
op.drop_column('incidents', 'phase')
50+
op.drop_column('alerts', 'archived_at')
51+
op.drop_column('alerts', 'ticket_url')
52+
op.drop_column('alerts', 'runbook_url')
53+
54+
# Note: PostgreSQL does not support removing enum values.
55+
# The 'archived' value will remain in the enum type after downgrade.
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
"""fix enum casing: uppercase to lowercase values
2+
3+
PostgreSQL enum values were created as UPPERCASE (FIRING, CRITICAL, etc.)
4+
but SQLAlchemy with values_callable expects lowercase (firing, critical, etc.).
5+
6+
Strategy: Drop enum constraints, convert columns to text, update values to
7+
lowercase, recreate enum types with only lowercase values, cast back.
8+
9+
Revision ID: e5f6a7b8c9d0
10+
Revises: d4e5f6a7b8c9
11+
Create Date: 2026-02-13 18:00:00.000000
12+
13+
"""
14+
from collections.abc import Sequence
15+
16+
from alembic import op
17+
18+
# revision identifiers, used by Alembic.
19+
revision: str = 'e5f6a7b8c9d0'
20+
down_revision: str | None = 'd4e5f6a7b8c9'
21+
branch_labels: str | Sequence[str] | None = None
22+
depends_on: str | Sequence[str] | None = None
23+
24+
25+
def upgrade() -> None:
26+
# ── alertstatus enum ──────────────────────────────────
27+
# Convert column to text, update values, recreate enum, cast back
28+
op.execute("ALTER TABLE alerts ALTER COLUMN status TYPE text")
29+
op.execute("UPDATE alerts SET status = LOWER(status)")
30+
op.execute("DROP TYPE alertstatus")
31+
op.execute("CREATE TYPE alertstatus AS ENUM ('firing', 'acknowledged', 'resolved', 'suppressed', 'archived')")
32+
op.execute("ALTER TABLE alerts ALTER COLUMN status TYPE alertstatus USING status::alertstatus")
33+
34+
# ── severity enum ─────────────────────────────────────
35+
# Used by both alerts and incidents tables
36+
op.execute("ALTER TABLE alerts ALTER COLUMN severity TYPE text")
37+
op.execute("ALTER TABLE incidents ALTER COLUMN severity TYPE text")
38+
op.execute("UPDATE alerts SET severity = LOWER(severity)")
39+
op.execute("UPDATE incidents SET severity = LOWER(severity)")
40+
op.execute("DROP TYPE severity")
41+
op.execute("CREATE TYPE severity AS ENUM ('critical', 'high', 'warning', 'low', 'info')")
42+
op.execute("ALTER TABLE alerts ALTER COLUMN severity TYPE severity USING severity::severity")
43+
op.execute("ALTER TABLE incidents ALTER COLUMN severity TYPE severity USING severity::severity")
44+
45+
# ── incidentstatus enum ───────────────────────────────
46+
op.execute("ALTER TABLE incidents ALTER COLUMN status TYPE text")
47+
op.execute("UPDATE incidents SET status = LOWER(status)")
48+
op.execute("DROP TYPE incidentstatus")
49+
op.execute("CREATE TYPE incidentstatus AS ENUM ('open', 'acknowledged', 'resolved')")
50+
op.execute("ALTER TABLE incidents ALTER COLUMN status TYPE incidentstatus USING status::incidentstatus")
51+
52+
# ── channeltype enum ──────────────────────────────────
53+
op.execute("ALTER TABLE notification_channels ALTER COLUMN channel_type TYPE text")
54+
op.execute("UPDATE notification_channels SET channel_type = LOWER(channel_type)")
55+
op.execute("DROP TYPE channeltype")
56+
op.execute("CREATE TYPE channeltype AS ENUM ('slack', 'email')")
57+
op.execute("ALTER TABLE notification_channels ALTER COLUMN channel_type TYPE channeltype USING channel_type::channeltype")
58+
59+
# ── notificationstatus enum ───────────────────────────
60+
op.execute("ALTER TABLE notification_logs ALTER COLUMN status TYPE text")
61+
op.execute("UPDATE notification_logs SET status = LOWER(status)")
62+
op.execute("DROP TYPE notificationstatus")
63+
op.execute("CREATE TYPE notificationstatus AS ENUM ('pending', 'sent', 'failed')")
64+
op.execute("ALTER TABLE notification_logs ALTER COLUMN status TYPE notificationstatus USING status::notificationstatus")
65+
66+
67+
def downgrade() -> None:
68+
# Reverse: recreate with uppercase values
69+
70+
# ── alertstatus ───────────────────────────────────────
71+
op.execute("ALTER TABLE alerts ALTER COLUMN status TYPE text")
72+
op.execute("UPDATE alerts SET status = UPPER(status)")
73+
op.execute("DROP TYPE alertstatus")
74+
op.execute("CREATE TYPE alertstatus AS ENUM ('FIRING', 'ACKNOWLEDGED', 'RESOLVED', 'SUPPRESSED', 'ARCHIVED')")
75+
op.execute("ALTER TABLE alerts ALTER COLUMN status TYPE alertstatus USING status::alertstatus")
76+
77+
# ── severity ──────────────────────────────────────────
78+
op.execute("ALTER TABLE alerts ALTER COLUMN severity TYPE text")
79+
op.execute("ALTER TABLE incidents ALTER COLUMN severity TYPE text")
80+
op.execute("UPDATE alerts SET severity = UPPER(severity)")
81+
op.execute("UPDATE incidents SET severity = UPPER(severity)")
82+
op.execute("DROP TYPE severity")
83+
op.execute("CREATE TYPE severity AS ENUM ('CRITICAL', 'HIGH', 'WARNING', 'LOW', 'INFO')")
84+
op.execute("ALTER TABLE alerts ALTER COLUMN severity TYPE severity USING severity::severity")
85+
op.execute("ALTER TABLE incidents ALTER COLUMN severity TYPE severity USING severity::severity")
86+
87+
# ── incidentstatus ────────────────────────────────────
88+
op.execute("ALTER TABLE incidents ALTER COLUMN status TYPE text")
89+
op.execute("UPDATE incidents SET status = UPPER(status)")
90+
op.execute("DROP TYPE incidentstatus")
91+
op.execute("CREATE TYPE incidentstatus AS ENUM ('OPEN', 'ACKNOWLEDGED', 'RESOLVED')")
92+
op.execute("ALTER TABLE incidents ALTER COLUMN status TYPE incidentstatus USING status::incidentstatus")
93+
94+
# ── channeltype ───────────────────────────────────────
95+
op.execute("ALTER TABLE notification_channels ALTER COLUMN channel_type TYPE text")
96+
op.execute("UPDATE notification_channels SET channel_type = UPPER(channel_type)")
97+
op.execute("DROP TYPE channeltype")
98+
op.execute("CREATE TYPE channeltype AS ENUM ('SLACK', 'EMAIL')")
99+
op.execute("ALTER TABLE notification_channels ALTER COLUMN channel_type TYPE channeltype USING channel_type::channeltype")
100+
101+
# ── notificationstatus ────────────────────────────────
102+
op.execute("ALTER TABLE notification_logs ALTER COLUMN status TYPE text")
103+
op.execute("UPDATE notification_logs SET status = UPPER(status)")
104+
op.execute("DROP TYPE notificationstatus")
105+
op.execute("CREATE TYPE notificationstatus AS ENUM ('PENDING', 'SENT', 'FAILED')")
106+
op.execute("ALTER TABLE notification_logs ALTER COLUMN status TYPE notificationstatus USING status::notificationstatus")

0 commit comments

Comments
 (0)