From de29270a2573dd151701bdcb4a906e6ad59716ee Mon Sep 17 00:00:00 2001
From: PTah
Date: Mon, 1 Jun 2026 10:21:43 +1000
Subject: [PATCH] feat: SAC 0.7.0 display_name columns and event severity
overrides
Co-authored-by: Cursor
---
.../versions/010_event_severity_overrides.py | 29 +
backend/app/api/v1/dashboards.py | 25 +-
backend/app/api/v1/events.py | 23 +-
backend/app/api/v1/problems.py | 525 ++++++++++++------
backend/app/api/v1/settings.py | 66 ++-
backend/app/constants/event_types.py | 37 ++
backend/app/models/__init__.py | 2 +
backend/app/models/event_severity_override.py | 19 +
backend/app/schemas/list_models.py | 1 +
.../app/services/event_severity_overrides.py | 84 +++
backend/app/services/event_summary.py | 20 +
backend/app/services/ingest.py | 2 +
backend/app/version.py | 2 +-
.../tests/test_event_severity_overrides.py | 53 ++
backend/tests/test_health.py | 4 +-
docs/testing-e2e-checklist.md | 2 +-
frontend/package.json | 2 +-
frontend/src/api.ts | 3 +
frontend/src/utils/hostDisplay.ts | 5 +
frontend/src/version.ts | 2 +-
frontend/src/views/DashboardView.vue | 5 +
frontend/src/views/EventsView.vue | 5 +-
frontend/src/views/ProblemsView.vue | 5 +-
frontend/src/views/ReportsView.vue | 5 +-
frontend/src/views/SettingsView.vue | 146 ++++-
25 files changed, 850 insertions(+), 222 deletions(-)
create mode 100644 backend/alembic/versions/010_event_severity_overrides.py
create mode 100644 backend/app/constants/event_types.py
create mode 100644 backend/app/models/event_severity_override.py
create mode 100644 backend/app/services/event_severity_overrides.py
create mode 100644 backend/app/services/event_summary.py
create mode 100644 backend/tests/test_event_severity_overrides.py
create mode 100644 frontend/src/utils/hostDisplay.ts
diff --git a/backend/alembic/versions/010_event_severity_overrides.py b/backend/alembic/versions/010_event_severity_overrides.py
new file mode 100644
index 0000000..00810ca
--- /dev/null
+++ b/backend/alembic/versions/010_event_severity_overrides.py
@@ -0,0 +1,29 @@
+"""event_severity_overrides table
+
+Revision ID: 010
+Revises: 009
+"""
+
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "010"
+down_revision: Union[str, None] = "009"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "event_severity_overrides",
+ sa.Column("event_type", sa.String(length=128), nullable=False),
+ sa.Column("severity", sa.String(length=16), nullable=False),
+ sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
+ sa.PrimaryKeyConstraint("event_type"),
+ )
+
+
+def downgrade() -> None:
+ op.drop_table("event_severity_overrides")
diff --git a/backend/app/api/v1/dashboards.py b/backend/app/api/v1/dashboards.py
index e5ae341..7bd35d8 100644
--- a/backend/app/api/v1/dashboards.py
+++ b/backend/app/api/v1/dashboards.py
@@ -10,6 +10,7 @@ from app.config import get_settings
from app.database import get_db
from app.models import Event, Host, Problem
from app.schemas.list_models import EventSummary
+from app.services.event_summary import event_to_summary
from app.services.host_health import DAILY_REPORT_TYPES, HEARTBEAT_TYPE, count_stale_hosts
router = APIRouter(prefix="/dashboards", tags=["dashboards"])
@@ -24,27 +25,13 @@ def fetch_recent_events(db: Session, *, limit: int = 8) -> list[EventSummary]:
.order_by(Event.received_at.desc())
.limit(limit)
).all()
- return [
- EventSummary(
- id=e.id,
- event_id=e.event_id,
- host_id=e.host_id,
- hostname=e.host.hostname,
- occurred_at=e.occurred_at,
- received_at=e.received_at,
- category=e.category,
- type=e.type,
- severity=e.severity,
- title=e.title,
- summary=e.summary,
- )
- for e in rows
- ]
+ return [event_to_summary(e) for e in rows]
class TopHostItem(BaseModel):
host_id: int
hostname: str
+ display_name: str | None = None
count: int
@@ -108,16 +95,16 @@ def dashboard_summary(
)
top_host_rows = db.execute(
- select(Host.id, Host.hostname, func.count())
+ select(Host.id, Host.hostname, Host.display_name, func.count())
.select_from(Event)
.join(Host, Event.host_id == Host.id)
.where(Event.received_at >= since)
- .group_by(Host.id, Host.hostname)
+ .group_by(Host.id, Host.hostname, Host.display_name)
.order_by(func.count().desc())
.limit(10)
).all()
top_hosts = [
- TopHostItem(host_id=row[0], hostname=row[1], count=row[2]) for row in top_host_rows
+ TopHostItem(host_id=row[0], hostname=row[1], display_name=row[2], count=row[3]) for row in top_host_rows
]
top_type_rows = db.execute(
diff --git a/backend/app/api/v1/events.py b/backend/app/api/v1/events.py
index 48fc293..13aacd4 100644
--- a/backend/app/api/v1/events.py
+++ b/backend/app/api/v1/events.py
@@ -15,6 +15,7 @@ from app.database import get_db
from app.models import Event, Host
from app.schemas.list_models import EventDetail, EventListResponse, EventSummary
from app.services.ingest import ingest_event
+from app.services.event_summary import event_to_summary
from app.services.problems import maybe_create_problem
from app.services.schema_validate import validate_event_payload
from app.services.notify_dispatch import (
@@ -133,8 +134,8 @@ def list_events(
count_stmt = count_stmt.where(Event.host_id == host_id)
if hostname:
like = f"%{hostname}%"
- stmt = stmt.where(Host.hostname.ilike(like))
- count_stmt = count_stmt.where(Host.hostname.ilike(like))
+ stmt = stmt.where(Host.hostname.ilike(like) | Host.display_name.ilike(like))
+ count_stmt = count_stmt.where(Host.hostname.ilike(like) | Host.display_name.ilike(like))
dt_from = _parse_optional_dt(from_time)
dt_to = _parse_optional_dt(to_time)
if dt_from:
@@ -155,22 +156,7 @@ def list_events(
.limit(page_size)
).all()
- items = [
- EventSummary(
- id=e.id,
- event_id=e.event_id,
- host_id=e.host_id,
- hostname=e.host.hostname,
- occurred_at=e.occurred_at,
- received_at=e.received_at,
- category=e.category,
- type=e.type,
- severity=e.severity,
- title=e.title,
- summary=e.summary,
- )
- for e in rows
- ]
+ items = [event_to_summary(e) for e in rows]
return EventListResponse(items=items, total=total, page=page, page_size=page_size)
@@ -190,6 +176,7 @@ def get_event(
event_id=event.event_id,
host_id=event.host_id,
hostname=event.host.hostname,
+ display_name=event.host.display_name,
occurred_at=event.occurred_at,
received_at=event.received_at,
category=event.category,
diff --git a/backend/app/api/v1/problems.py b/backend/app/api/v1/problems.py
index f4b56a9..2765cb3 100644
--- a/backend/app/api/v1/problems.py
+++ b/backend/app/api/v1/problems.py
@@ -1,174 +1,351 @@
-from datetime import datetime
-
-from fastapi import APIRouter, Depends, HTTPException, Query
-from pydantic import BaseModel
-from sqlalchemy import func, select
-from sqlalchemy.orm import Session, joinedload
-
-from app.auth.jwt_auth import get_current_user
-from app.database import get_db
-from app.models import Event, Host, Problem, ProblemEvent
-
-router = APIRouter(prefix="/problems", tags=["problems"])
-
-
-class ProblemSummary(BaseModel):
- id: int
- host_id: int | None
- hostname: str | None
- title: str
- summary: str
- severity: str
- status: str
- rule_id: str | None
- fingerprint: str
- event_count: int
- last_seen_at: datetime
- created_at: datetime
- updated_at: datetime
-
-
-class ProblemListResponse(BaseModel):
- items: list[ProblemSummary]
- total: int
- page: int
- page_size: int
-
-
-class ProblemEventItem(BaseModel):
- id: int
- event_id: str
- occurred_at: datetime
- type: str
- severity: str
- title: str
- summary: str
-
-
-class ProblemDetail(ProblemSummary):
- events: list[ProblemEventItem]
-
-
-def _problem_summary(p: Problem) -> ProblemSummary:
- return ProblemSummary(
- id=p.id,
- host_id=p.host_id,
- hostname=p.host.hostname if p.host else None,
- title=p.title,
- summary=p.summary,
- severity=p.severity,
- status=p.status,
- rule_id=p.rule_id,
- fingerprint=p.fingerprint,
- event_count=p.event_count,
- last_seen_at=p.last_seen_at,
- created_at=p.created_at,
- updated_at=p.updated_at,
- )
-
-
-@router.get("", response_model=ProblemListResponse)
-def list_problems(
- page: int = Query(1, ge=1),
- page_size: int = Query(50, ge=1, le=200),
- status: str | None = Query(None),
- severity: str | None = Query(None),
- host_id: int | None = Query(None),
- hostname: str | None = Query(None),
- db: Session = Depends(get_db),
- _user: str = Depends(get_current_user),
-) -> ProblemListResponse:
- stmt = select(Problem).join(Host, isouter=True).options(joinedload(Problem.host))
- count_stmt = select(func.count()).select_from(Problem).join(Host, isouter=True)
-
- if status:
- stmt = stmt.where(Problem.status == status)
- count_stmt = count_stmt.where(Problem.status == status)
- if severity:
- stmt = stmt.where(Problem.severity == severity)
- count_stmt = count_stmt.where(Problem.severity == severity)
- if host_id is not None:
- stmt = stmt.where(Problem.host_id == host_id)
- count_stmt = count_stmt.where(Problem.host_id == host_id)
- if hostname:
- like = f"%{hostname}%"
- stmt = stmt.where(Host.hostname.ilike(like) | Host.display_name.ilike(like))
- count_stmt = count_stmt.where(Host.hostname.ilike(like) | Host.display_name.ilike(like))
-
- total = db.scalar(count_stmt) or 0
- rows = db.scalars(
- stmt.order_by(Problem.last_seen_at.desc()).offset((page - 1) * page_size).limit(page_size)
- ).all()
-
- items = [_problem_summary(p) for p in rows]
- return ProblemListResponse(items=items, total=total, page=page, page_size=page_size)
-
-
-@router.get("/{problem_id}", response_model=ProblemDetail)
-def get_problem(
- problem_id: int,
- db: Session = Depends(get_db),
- _user: str = Depends(get_current_user),
-) -> ProblemDetail:
- problem = db.scalar(
- select(Problem).where(Problem.id == problem_id).options(joinedload(Problem.host))
- )
- if problem is None:
- raise HTTPException(status_code=404, detail="Problem not found")
-
- event_rows = db.scalars(
- select(Event)
- .join(ProblemEvent, ProblemEvent.event_id == Event.id)
- .where(ProblemEvent.problem_id == problem_id)
- .order_by(Event.occurred_at.desc())
- ).all()
-
- base = _problem_summary(problem)
- return ProblemDetail(
- **base.model_dump(),
- events=[
- ProblemEventItem(
- id=e.id,
- event_id=e.event_id,
- occurred_at=e.occurred_at,
- type=e.type,
- severity=e.severity,
- title=e.title,
- summary=e.summary,
- )
- for e in event_rows
- ],
- )
-
-
-@router.post("/{problem_id}/ack")
-def ack_problem(
- problem_id: int,
- db: Session = Depends(get_db),
- _user: str = Depends(get_current_user),
-) -> dict:
- problem = db.get(Problem, problem_id)
- if problem is None:
- raise HTTPException(status_code=404, detail="Problem not found")
- if problem.status == "resolved":
- raise HTTPException(status_code=409, detail="Problem already resolved")
- problem.status = "acknowledged"
- db.commit()
- return {"id": problem.id, "status": problem.status}
-
-
-@router.post("/{problem_id}/resolve")
-def resolve_problem(
- problem_id: int,
- db: Session = Depends(get_db),
- _user: str = Depends(get_current_user),
-) -> dict:
- problem = db.get(Problem, problem_id)
- if problem is None:
- raise HTTPException(status_code=404, detail="Problem not found")
- if problem.status == "resolved":
- raise HTTPException(status_code=409, detail="Problem already resolved")
- problem.status = "resolved"
- db.commit()
- return {"id": problem.id, "status": problem.status}
-
\ No newline at end of file
+from datetime import datetime
+
+
+
+from fastapi import APIRouter, Depends, HTTPException, Query
+
+from pydantic import BaseModel
+
+from sqlalchemy import func, select
+
+from sqlalchemy.orm import Session, joinedload
+
+
+
+from app.auth.jwt_auth import get_current_user
+
+from app.database import get_db
+
+from app.models import Event, Host, Problem, ProblemEvent
+
+
+
+router = APIRouter(prefix="/problems", tags=["problems"])
+
+
+
+
+
+class ProblemSummary(BaseModel):
+
+ id: int
+
+ host_id: int | None
+
+ hostname: str | None
+
+ display_name: str | None = None
+
+ title: str
+
+ summary: str
+
+ severity: str
+
+ status: str
+
+ rule_id: str | None
+
+ fingerprint: str
+
+ event_count: int
+
+ last_seen_at: datetime
+
+ created_at: datetime
+
+ updated_at: datetime
+
+
+
+
+
+class ProblemListResponse(BaseModel):
+
+ items: list[ProblemSummary]
+
+ total: int
+
+ page: int
+
+ page_size: int
+
+
+
+
+
+class ProblemEventItem(BaseModel):
+
+ id: int
+
+ event_id: str
+
+ occurred_at: datetime
+
+ type: str
+
+ severity: str
+
+ title: str
+
+ summary: str
+
+
+
+
+
+class ProblemDetail(ProblemSummary):
+
+ events: list[ProblemEventItem]
+
+
+
+
+
+def _problem_summary(p: Problem) -> ProblemSummary:
+
+ return ProblemSummary(
+
+ id=p.id,
+
+ host_id=p.host_id,
+
+ hostname=p.host.hostname if p.host else None,
+
+ display_name=p.host.display_name if p.host else None,
+
+ title=p.title,
+
+ summary=p.summary,
+
+ severity=p.severity,
+
+ status=p.status,
+
+ rule_id=p.rule_id,
+
+ fingerprint=p.fingerprint,
+
+ event_count=p.event_count,
+
+ last_seen_at=p.last_seen_at,
+
+ created_at=p.created_at,
+
+ updated_at=p.updated_at,
+
+ )
+
+
+
+
+
+@router.get("", response_model=ProblemListResponse)
+
+def list_problems(
+
+ page: int = Query(1, ge=1),
+
+ page_size: int = Query(50, ge=1, le=200),
+
+ status: str | None = Query(None),
+
+ severity: str | None = Query(None),
+
+ host_id: int | None = Query(None),
+
+ hostname: str | None = Query(None),
+
+ db: Session = Depends(get_db),
+
+ _user: str = Depends(get_current_user),
+
+) -> ProblemListResponse:
+
+ stmt = select(Problem).join(Host, isouter=True).options(joinedload(Problem.host))
+
+ count_stmt = select(func.count()).select_from(Problem).join(Host, isouter=True)
+
+
+
+ if status:
+
+ stmt = stmt.where(Problem.status == status)
+
+ count_stmt = count_stmt.where(Problem.status == status)
+
+ if severity:
+
+ stmt = stmt.where(Problem.severity == severity)
+
+ count_stmt = count_stmt.where(Problem.severity == severity)
+
+ if host_id is not None:
+
+ stmt = stmt.where(Problem.host_id == host_id)
+
+ count_stmt = count_stmt.where(Problem.host_id == host_id)
+
+ if hostname:
+
+ like = f"%{hostname}%"
+
+ stmt = stmt.where(Host.hostname.ilike(like) | Host.display_name.ilike(like))
+
+ count_stmt = count_stmt.where(Host.hostname.ilike(like) | Host.display_name.ilike(like))
+
+
+
+ total = db.scalar(count_stmt) or 0
+
+ rows = db.scalars(
+
+ stmt.order_by(Problem.last_seen_at.desc()).offset((page - 1) * page_size).limit(page_size)
+
+ ).all()
+
+
+
+ items = [_problem_summary(p) for p in rows]
+
+ return ProblemListResponse(items=items, total=total, page=page, page_size=page_size)
+
+
+
+
+
+@router.get("/{problem_id}", response_model=ProblemDetail)
+
+def get_problem(
+
+ problem_id: int,
+
+ db: Session = Depends(get_db),
+
+ _user: str = Depends(get_current_user),
+
+) -> ProblemDetail:
+
+ problem = db.scalar(
+
+ select(Problem).where(Problem.id == problem_id).options(joinedload(Problem.host))
+
+ )
+
+ if problem is None:
+
+ raise HTTPException(status_code=404, detail="Problem not found")
+
+
+
+ event_rows = db.scalars(
+
+ select(Event)
+
+ .join(ProblemEvent, ProblemEvent.event_id == Event.id)
+
+ .where(ProblemEvent.problem_id == problem_id)
+
+ .order_by(Event.occurred_at.desc())
+
+ ).all()
+
+
+
+ base = _problem_summary(problem)
+
+ return ProblemDetail(
+
+ **base.model_dump(),
+
+ events=[
+
+ ProblemEventItem(
+
+ id=e.id,
+
+ event_id=e.event_id,
+
+ occurred_at=e.occurred_at,
+
+ type=e.type,
+
+ severity=e.severity,
+
+ title=e.title,
+
+ summary=e.summary,
+
+ )
+
+ for e in event_rows
+
+ ],
+
+ )
+
+
+
+
+
+@router.post("/{problem_id}/ack")
+
+def ack_problem(
+
+ problem_id: int,
+
+ db: Session = Depends(get_db),
+
+ _user: str = Depends(get_current_user),
+
+) -> dict:
+
+ problem = db.get(Problem, problem_id)
+
+ if problem is None:
+
+ raise HTTPException(status_code=404, detail="Problem not found")
+
+ if problem.status == "resolved":
+
+ raise HTTPException(status_code=409, detail="Problem already resolved")
+
+ problem.status = "acknowledged"
+
+ db.commit()
+
+ return {"id": problem.id, "status": problem.status}
+
+
+
+
+
+@router.post("/{problem_id}/resolve")
+
+def resolve_problem(
+
+ problem_id: int,
+
+ db: Session = Depends(get_db),
+
+ _user: str = Depends(get_current_user),
+
+) -> dict:
+
+ problem = db.get(Problem, problem_id)
+
+ if problem is None:
+
+ raise HTTPException(status_code=404, detail="Problem not found")
+
+ if problem.status == "resolved":
+
+ raise HTTPException(status_code=409, detail="Problem already resolved")
+
+ problem.status = "resolved"
+
+ db.commit()
+
+ return {"id": problem.id, "status": problem.status}
+
+
diff --git a/backend/app/api/v1/settings.py b/backend/app/api/v1/settings.py
index bca5da2..c19c997 100644
--- a/backend/app/api/v1/settings.py
+++ b/backend/app/api/v1/settings.py
@@ -23,7 +23,11 @@ from app.services.notification_settings import (
upsert_webhook_channel,
)
from app.services.telegram_notify import TelegramNotConfiguredError, TelegramSendError, send_telegram_test_message
-from app.services.webhook_notify import WebhookNotConfiguredError, WebhookSendError, send_webhook_test_message
+from app.services.event_severity_overrides import (
+ SOURCE_DB,
+ list_severity_override_items,
+ replace_severity_overrides,
+)
router = APIRouter(prefix="/settings", tags=["settings"])
@@ -328,3 +332,63 @@ def test_email_settings(
except EmailSendError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return ChannelTestResponse(message="Тестовое письмо отправлено по SMTP")
+
+
+class EventSeverityOverrideRow(BaseModel):
+ event_type: str
+ default_severity: str
+ override_severity: str | None = None
+
+
+class EventSeverityOverridesResponse(BaseModel):
+ items: list[EventSeverityOverrideRow]
+ source: str = Field(description="db — overrides хранятся в PostgreSQL")
+
+
+class EventSeverityOverridesUpdate(BaseModel):
+ overrides: dict[str, str | None] = Field(
+ description="event_type → severity; null или пустая строка сбрасывает override",
+ )
+
+
+@router.get("/notifications/severity-overrides", response_model=EventSeverityOverridesResponse)
+def get_severity_overrides(
+ db: Session = Depends(get_db),
+ _user=Depends(require_admin),
+) -> EventSeverityOverridesResponse:
+ items = list_severity_override_items(db)
+ return EventSeverityOverridesResponse(
+ items=[
+ EventSeverityOverrideRow(
+ event_type=i.event_type,
+ default_severity=i.default_severity,
+ override_severity=i.override_severity,
+ )
+ for i in items
+ ],
+ source=SOURCE_DB,
+ )
+
+
+@router.put("/notifications/severity-overrides", response_model=EventSeverityOverridesResponse)
+def update_severity_overrides(
+ body: EventSeverityOverridesUpdate,
+ db: Session = Depends(get_db),
+ _user=Depends(require_admin),
+) -> EventSeverityOverridesResponse:
+ try:
+ items = replace_severity_overrides(db, body.overrides)
+ db.commit()
+ except ValueError as exc:
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
+ return EventSeverityOverridesResponse(
+ items=[
+ EventSeverityOverrideRow(
+ event_type=i.event_type,
+ default_severity=i.default_severity,
+ override_severity=i.override_severity,
+ )
+ for i in items
+ ],
+ source=SOURCE_DB,
+ )
diff --git a/backend/app/constants/event_types.py b/backend/app/constants/event_types.py
new file mode 100644
index 0000000..58a9a26
--- /dev/null
+++ b/backend/app/constants/event_types.py
@@ -0,0 +1,37 @@
+"""Known SAC event types and default severities from agents (schema v1 / agent-integration)."""
+
+from __future__ import annotations
+
+DEFAULT_EVENT_SEVERITIES: dict[str, str] = {
+ # Agent / lifecycle
+ "agent.heartbeat": "info",
+ "agent.lifecycle": "info",
+ "agent.test": "info",
+ "agent.recovered": "info",
+ # SSH
+ "ssh.login.success": "info",
+ "ssh.login.failed": "warning",
+ "ssh.ip.banned": "high",
+ "ssh.ip.bruteforce.threshold": "warning",
+ "ssh.bruteforce.mass": "high",
+ "privilege.sudo.command": "warning",
+ "session.logind.new": "info",
+ "session.logind.removed": "info",
+ "session.logind.failed": "warning",
+ "report.daily.ssh": "info",
+ # RDP / Windows
+ "rdp.login.success": "info",
+ "rdp.login.failed": "warning",
+ "rdp.shadow.control.started": "warning",
+ "rdp.shadow.control.stopped": "info",
+ "rdp.shadow.control.permission": "warning",
+ "winrm.session.started": "warning",
+ "auth.explicit.credentials": "warning",
+ "report.daily.rdp": "info",
+ "rdp.ip.banned": "high",
+ "rdp.ip.ban": "high",
+ # RD Gateway
+ "rdg.connection.success": "info",
+ "rdg.connection.disconnected": "info",
+ "rdg.connection.failed": "warning",
+}
diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py
index ec12872..641d9b4 100644
--- a/backend/app/models/__init__.py
+++ b/backend/app/models/__init__.py
@@ -5,6 +5,7 @@ from app.models.notification_channel import NotificationChannel
from app.models.notification_cooldown import NotificationCooldown
from app.models.notification_policy import NotificationPolicy
from app.models.problem import Problem, ProblemEvent
+from app.models.event_severity_override import EventSeverityOverride
from app.models.user import User
__all__ = [
@@ -17,4 +18,5 @@ __all__ = [
"Problem",
"ProblemEvent",
"User",
+ "EventSeverityOverride",
]
diff --git a/backend/app/models/event_severity_override.py b/backend/app/models/event_severity_override.py
new file mode 100644
index 0000000..b448f3d
--- /dev/null
+++ b/backend/app/models/event_severity_override.py
@@ -0,0 +1,19 @@
+from datetime import datetime
+
+from sqlalchemy import DateTime, String, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from app.database import Base
+
+
+class EventSeverityOverride(Base):
+ __tablename__ = "event_severity_overrides"
+
+ event_type: Mapped[str] = mapped_column(String(128), primary_key=True)
+ severity: Mapped[str] = mapped_column(String(16), nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True),
+ server_default=func.now(),
+ onupdate=func.now(),
+ nullable=False,
+ )
diff --git a/backend/app/schemas/list_models.py b/backend/app/schemas/list_models.py
index c6d276f..280027b 100644
--- a/backend/app/schemas/list_models.py
+++ b/backend/app/schemas/list_models.py
@@ -32,6 +32,7 @@ class EventSummary(BaseModel):
event_id: str
host_id: int
hostname: str
+ display_name: str | None = None
occurred_at: datetime
received_at: datetime
category: str
diff --git a/backend/app/services/event_severity_overrides.py b/backend/app/services/event_severity_overrides.py
new file mode 100644
index 0000000..770fd9c
--- /dev/null
+++ b/backend/app/services/event_severity_overrides.py
@@ -0,0 +1,84 @@
+"""Per event-type severity overrides (admin Settings UI)."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from sqlalchemy import delete, select
+from sqlalchemy.orm import Session
+
+from app.constants.event_types import DEFAULT_EVENT_SEVERITIES
+from app.models.event_severity_override import EventSeverityOverride
+from app.services.notification_settings import VALID_SEVERITIES
+
+SOURCE_DB = "db"
+
+
+@dataclass(frozen=True)
+class EventSeverityOverrideItem:
+ event_type: str
+ default_severity: str
+ override_severity: str | None
+
+
+def get_override_map(db: Session) -> dict[str, str]:
+ rows = db.scalars(select(EventSeverityOverride)).all()
+ return {row.event_type: row.severity for row in rows}
+
+
+def apply_severity_override(payload: dict, db: Session) -> dict:
+ """Return payload copy with severity replaced when override exists."""
+ event_type = str(payload.get("type") or "")
+ if not event_type:
+ return payload
+ override_map = get_override_map(db)
+ override = override_map.get(event_type)
+ if not override:
+ return payload
+ agent_severity = str(payload.get("severity") or "info")
+ if agent_severity == override:
+ return payload
+ out = dict(payload)
+ details = dict(out.get("details") or {})
+ details["severity_agent"] = agent_severity
+ out["details"] = details
+ out["severity"] = override
+ return out
+
+
+def list_severity_override_items(db: Session) -> list[EventSeverityOverrideItem]:
+ override_map = get_override_map(db)
+ types = set(DEFAULT_EVENT_SEVERITIES) | set(override_map)
+ items: list[EventSeverityOverrideItem] = []
+ for event_type in sorted(types):
+ default = DEFAULT_EVENT_SEVERITIES.get(event_type, "info")
+ items.append(
+ EventSeverityOverrideItem(
+ event_type=event_type,
+ default_severity=default,
+ override_severity=override_map.get(event_type),
+ )
+ )
+ return items
+
+
+def replace_severity_overrides(db: Session, overrides: dict[str, str | None]) -> list[EventSeverityOverrideItem]:
+ for event_type, severity in overrides.items():
+ normalized_type = event_type.strip()
+ if not normalized_type:
+ raise ValueError("event_type is required")
+ if severity is None or severity == "":
+ db.execute(
+ delete(EventSeverityOverride).where(EventSeverityOverride.event_type == normalized_type)
+ )
+ continue
+ if severity not in VALID_SEVERITIES:
+ raise ValueError(f"severity must be one of: {sorted(VALID_SEVERITIES)}")
+ row = db.get(EventSeverityOverride, normalized_type)
+ if row is None:
+ row = EventSeverityOverride(event_type=normalized_type, severity=severity)
+ db.add(row)
+ else:
+ row.severity = severity
+ db.flush()
+ return list_severity_override_items(db)
diff --git a/backend/app/services/event_summary.py b/backend/app/services/event_summary.py
new file mode 100644
index 0000000..7d1c768
--- /dev/null
+++ b/backend/app/services/event_summary.py
@@ -0,0 +1,20 @@
+from app.models.event import Event
+from app.schemas.list_models import EventSummary
+
+
+def event_to_summary(event: Event) -> EventSummary:
+ host = event.host
+ return EventSummary(
+ id=event.id,
+ event_id=event.event_id,
+ host_id=event.host_id,
+ hostname=host.hostname,
+ display_name=host.display_name,
+ occurred_at=event.occurred_at,
+ received_at=event.received_at,
+ category=event.category,
+ type=event.type,
+ severity=event.severity,
+ title=event.title,
+ summary=event.summary,
+ )
diff --git a/backend/app/services/ingest.py b/backend/app/services/ingest.py
index 747bad0..087416e 100644
--- a/backend/app/services/ingest.py
+++ b/backend/app/services/ingest.py
@@ -6,6 +6,7 @@ from sqlalchemy.orm import Session
from app.models import Event, Host
from app.services.daily_report_format import normalize_daily_report_details
+from app.services.event_severity_overrides import apply_severity_override
DAILY_REPORT_TYPES = frozenset({"report.daily.ssh", "report.daily.rdp"})
@@ -75,6 +76,7 @@ def ingest_event(db: Session, payload: dict) -> tuple[Event, bool]:
return existing, False
host = upsert_host(db, payload)
+ payload = apply_severity_override(payload, db)
details = payload.get("details")
if payload.get("type") in DAILY_REPORT_TYPES:
details = normalize_daily_report_details(details, host, payload["type"])
diff --git a/backend/app/version.py b/backend/app/version.py
index 54c9262..37fd662 100644
--- a/backend/app/version.py
+++ b/backend/app/version.py
@@ -1,5 +1,5 @@
"""Единый источник версии SAC (API, health, логи, OpenAPI)."""
APP_NAME = "Security Alert Center"
-APP_VERSION = "0.6.0"
+APP_VERSION = "0.7.0"
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
diff --git a/backend/tests/test_event_severity_overrides.py b/backend/tests/test_event_severity_overrides.py
new file mode 100644
index 0000000..ea1bdd0
--- /dev/null
+++ b/backend/tests/test_event_severity_overrides.py
@@ -0,0 +1,53 @@
+"""Tests for per-event-type severity overrides."""
+
+import uuid
+
+from app.models.event_severity_override import EventSeverityOverride
+
+VALID_EVENT = {
+ "schema_version": "1.0",
+ "event_id": "550e8400-e29b-41d4-a716-446655440000",
+ "occurred_at": "2026-05-27T10:00:00+03:00",
+ "source": {"product": "rdp-login-monitor", "product_version": "1.2.3-SAC"},
+ "host": {"hostname": "test-host", "os_family": "windows"},
+ "category": "auth",
+ "type": "rdp.login.success",
+ "severity": "info",
+ "title": "RDP login",
+ "summary": "pytest",
+}
+
+
+def test_severity_override_applied_on_ingest(client, auth_headers, jwt_headers, db_session):
+ db_session.add(EventSeverityOverride(event_type="rdp.login.success", severity="warning"))
+ db_session.commit()
+
+ event_id = str(uuid.uuid4())
+ payload = {**VALID_EVENT, "event_id": event_id}
+ r = client.post("/api/v1/events", json=payload, headers=auth_headers)
+ assert r.status_code == 201
+
+ listed = client.get("/api/v1/events", headers=jwt_headers, params={"type": "rdp.login.success"})
+ row = next(i for i in listed.json()["items"] if i["event_id"] == event_id)
+ assert row["severity"] == "warning"
+
+
+def test_severity_overrides_settings_api(client, jwt_headers, db_session):
+ db_session.add(EventSeverityOverride(event_type="ssh.login.failed", severity="high"))
+ db_session.commit()
+
+ r = client.get("/api/v1/settings/notifications/severity-overrides", headers=jwt_headers)
+ assert r.status_code == 200
+ items = {i["event_type"]: i for i in r.json()["items"]}
+ assert items["ssh.login.failed"]["override_severity"] == "high"
+ assert items["rdp.login.success"]["default_severity"] == "info"
+
+ put = client.put(
+ "/api/v1/settings/notifications/severity-overrides",
+ headers=jwt_headers,
+ json={"overrides": {"rdp.login.success": "warning", "ssh.login.failed": None}},
+ )
+ assert put.status_code == 200
+ updated = {i["event_type"]: i for i in put.json()["items"]}
+ assert updated["rdp.login.success"]["override_severity"] == "warning"
+ assert updated["ssh.login.failed"]["override_severity"] is None
diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py
index b4ec9f4..3ecb3ba 100644
--- a/backend/tests/test_health.py
+++ b/backend/tests/test_health.py
@@ -4,6 +4,6 @@ from app.version import APP_NAME, APP_VERSION, APP_VERSION_LABEL
def test_version_constants():
- assert APP_VERSION == "0.6.0"
+ assert APP_VERSION == "0.7.0"
assert APP_NAME == "Security Alert Center"
- assert APP_VERSION_LABEL == "Security Alert Center v.0.6.0"
+ assert APP_VERSION_LABEL == "Security Alert Center v.0.7.0"
diff --git a/docs/testing-e2e-checklist.md b/docs/testing-e2e-checklist.md
index 33387ca..eeff8ed 100644
--- a/docs/testing-e2e-checklist.md
+++ b/docs/testing-e2e-checklist.md
@@ -106,4 +106,4 @@ curl -sS -X POST "$BASE/api/v1/events" \
|-----------|--------|
| ssh-monitor | 1.2.6-SAC |
| RDP-login-monitor | 1.2.20-SAC |
-| security-alert-center | 0.6.0 (`backend/app/version.py`) |
+| security-alert-center | 0.7.0 (`backend/app/version.py`) |
diff --git a/frontend/package.json b/frontend/package.json
index da167dc..3fd9200 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "sac-ui",
"private": true,
- "version": "0.6.0",
+ "version": "0.7.0",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
index e9e7af0..557e563 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -132,6 +132,7 @@ export interface EventSummary {
event_id: string;
host_id: number;
hostname: string;
+ display_name?: string | null;
occurred_at: string;
received_at: string;
category: string;
@@ -180,6 +181,7 @@ export interface ProblemSummary {
id: number;
host_id: number | null;
hostname: string | null;
+ display_name?: string | null;
title: string;
summary: string;
severity: string;
@@ -228,6 +230,7 @@ export async function resolveProblem(problemId: number): Promise<{ id: number; s
export interface TopHostItem {
host_id: number;
hostname: string;
+ display_name?: string | null;
count: number;
}
diff --git a/frontend/src/utils/hostDisplay.ts b/frontend/src/utils/hostDisplay.ts
new file mode 100644
index 0000000..91f97e4
--- /dev/null
+++ b/frontend/src/utils/hostDisplay.ts
@@ -0,0 +1,5 @@
+/** Display name from host card (SAC «Имя сервера»). */
+export function formatServerName(displayName?: string | null): string {
+ const name = displayName?.trim();
+ return name || "—";
+}
diff --git a/frontend/src/version.ts b/frontend/src/version.ts
index 4960971..36d05c4 100644
--- a/frontend/src/version.ts
+++ b/frontend/src/version.ts
@@ -1,3 +1,3 @@
export const APP_NAME = "Security Alert Center";
-export const APP_VERSION = "0.6.0";
+export const APP_VERSION = "0.7.0";
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
diff --git a/frontend/src/views/DashboardView.vue b/frontend/src/views/DashboardView.vue
index 6dbe751..ce8e50c 100644
--- a/frontend/src/views/DashboardView.vue
+++ b/frontend/src/views/DashboardView.vue
@@ -234,6 +234,8 @@
| Хост |
+ Имя сервера |
+
Severity |
Title |
@@ -264,6 +266,8 @@
+ {{ formatServerName(e.display_name) }} |
+
{{ e.severity }} |
{{ e.title }} |
@@ -293,6 +297,7 @@
import { onMounted, onUnmounted, ref } from "vue";
import { apiFetch, getToken, type DashboardSummary, type EventSummary } from "../api";
+import { formatServerName } from "../utils/hostDisplay";
diff --git a/frontend/src/views/EventsView.vue b/frontend/src/views/EventsView.vue
index 7b69177..be4d350 100644
--- a/frontend/src/views/EventsView.vue
+++ b/frontend/src/views/EventsView.vue
@@ -10,7 +10,7 @@
-
+
{{ error }}
@@ -23,6 +23,7 @@
ID |
Время |
Хост |
+ Имя сервера |
Severity |
Type |
Title |
@@ -35,6 +36,7 @@
{{ formatDt(e.occurred_at) }} |
{{ e.hostname }} |
+ {{ formatServerName(e.display_name) }} |
{{ e.severity }} |
{{ e.type }} |
{{ e.title }} |
@@ -60,6 +62,7 @@
import { onMounted, ref } from "vue";
import { useRoute } from "vue-router";
import { apiFetch, type EventListResponse } from "../api";
+import { formatServerName } from "../utils/hostDisplay";
const route = useRoute();
diff --git a/frontend/src/views/ProblemsView.vue b/frontend/src/views/ProblemsView.vue
index 3a71440..1980a6e 100644
--- a/frontend/src/views/ProblemsView.vue
+++ b/frontend/src/views/ProblemsView.vue
@@ -14,7 +14,7 @@
-
+
{{ error }}
@@ -27,6 +27,7 @@
ID |
Обновлено |
Хост |
+ Имя сервера |
Severity |
Status |
Rule |
@@ -41,6 +42,7 @@
{{ formatDt(p.updated_at) }} |
{{ p.hostname ?? "—" }} |
+ {{ formatServerName(p.display_name) }} |
{{ p.severity }} |
{{ p.status }} |
{{ p.rule_id ?? "—" }} |
@@ -88,6 +90,7 @@
import { onMounted, ref, watch } from "vue";
import { useRoute } from "vue-router";
import { ackProblem, apiFetch, resolveProblem, type ProblemListResponse } from "../api";
+import { formatServerName } from "../utils/hostDisplay";
const route = useRoute();
diff --git a/frontend/src/views/ReportsView.vue b/frontend/src/views/ReportsView.vue
index 9f4e2f9..c3d0c44 100644
--- a/frontend/src/views/ReportsView.vue
+++ b/frontend/src/views/ReportsView.vue
@@ -6,7 +6,7 @@
-
+