feat: hide event types from SAC UI via Settings checkbox (0.20.23)

Per-type «Показывать в событиях» in severity settings; filters lists, dashboard, hosts, and problems.
This commit is contained in:
2026-06-21 20:13:40 +10:00
parent 87ac1e09b0
commit bf7c83c408
15 changed files with 313 additions and 60 deletions
@@ -0,0 +1,34 @@
"""event type visibility settings
Revision ID: 023_event_type_visibility
Revises: 022_host_remote_action
Create Date: 2026-06-21
"""
from alembic import op
import sqlalchemy as sa
revision = "023_event_type_visibility"
down_revision = "022_host_remote_action"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"event_type_visibility",
sa.Column("event_type", sa.String(length=128), nullable=False),
sa.Column("show_in_events", sa.Boolean(), 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_type_visibility")
+29 -18
View File
@@ -1,4 +1,4 @@
from datetime import datetime, timedelta, timezone
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
@@ -11,6 +11,7 @@ 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.event_type_visibility import get_hidden_event_types, visibility_type_filter
from app.services.host_health import DAILY_REPORT_TYPES, HEARTBEAT_TYPE, count_stale_hosts
router = APIRouter(prefix="/dashboards", tags=["dashboards"])
@@ -18,13 +19,18 @@ router = APIRouter(prefix="/dashboards", tags=["dashboards"])
def fetch_recent_events(db: Session, *, limit: int = 8) -> list[EventSummary]:
"""Последние события по времени приёма ingest (received_at)."""
rows = db.scalars(
hidden = get_hidden_event_types(db)
type_filter = visibility_type_filter(hidden)
stmt = (
select(Event)
.join(Host)
.options(joinedload(Event.host))
.order_by(Event.received_at.desc())
.limit(limit)
).all()
)
if type_filter is not None:
stmt = stmt.where(type_filter)
rows = db.scalars(stmt).all()
return [event_to_summary(e, db) for e in rows]
@@ -61,10 +67,13 @@ def dashboard_summary(
_user: str = Depends(get_current_user),
) -> DashboardSummary:
since = datetime.now(timezone.utc) - timedelta(hours=24)
hidden = get_hidden_event_types(db)
type_filter = visibility_type_filter(hidden)
events_24h = db.scalar(
select(func.count()).select_from(Event).where(Event.received_at >= since)
) or 0
events_24h_stmt = select(func.count()).select_from(Event).where(Event.received_at >= since)
if type_filter is not None:
events_24h_stmt = events_24h_stmt.where(type_filter)
events_24h = db.scalar(events_24h_stmt) or 0
hosts_total = db.scalar(select(func.count()).select_from(Host)) or 0
settings = get_settings()
hosts_stale = count_stale_hosts(db, settings.sac_heartbeat_stale_minutes)
@@ -100,12 +109,16 @@ def dashboard_summary(
or 0
)
top_host_rows = db.execute(
top_host_stmt = (
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, Host.display_name)
)
if type_filter is not None:
top_host_stmt = top_host_stmt.where(type_filter)
top_host_rows = db.execute(
top_host_stmt.group_by(Host.id, Host.hostname, Host.display_name)
.order_by(func.count().desc())
.limit(10)
).all()
@@ -113,20 +126,18 @@ def dashboard_summary(
TopHostItem(host_id=row[0], hostname=row[1], display_name=row[2], count=row[3]) for row in top_host_rows
]
top_type_stmt = select(Event.type, func.count()).where(Event.received_at >= since)
if type_filter is not None:
top_type_stmt = top_type_stmt.where(type_filter)
top_type_rows = db.execute(
select(Event.type, func.count())
.where(Event.received_at >= since)
.group_by(Event.type)
.order_by(func.count().desc())
.limit(10)
top_type_stmt.group_by(Event.type).order_by(func.count().desc()).limit(10)
).all()
top_event_types = [TopTypeItem(type=row[0], count=row[1]) for row in top_type_rows]
severity_rows = db.execute(
select(Event.severity, func.count())
.where(Event.received_at >= since)
.group_by(Event.severity)
).all()
severity_stmt = select(Event.severity, func.count()).where(Event.received_at >= since)
if type_filter is not None:
severity_stmt = severity_stmt.where(type_filter)
severity_rows = db.execute(severity_stmt.group_by(Event.severity)).all()
severity_24h = {row[0]: row[1] for row in severity_rows}
recent_events = fetch_recent_events(db, limit=8)
+10 -1
View File
@@ -1,4 +1,4 @@
import logging
import logging
from datetime import datetime, timezone
from typing import Any
@@ -22,6 +22,7 @@ from app.services.agent_commands import (
from app.services.rdg_winrm_actions import execute_logoff_via_winrm, execute_qwinsta_via_winrm
from app.services.ingest import ingest_event
from app.services.event_summary import event_to_summary
from app.services.event_type_visibility import get_hidden_event_types, visibility_type_filter
from app.services.problems import maybe_create_problem
from app.services.schema_validate import validate_event_payload
from app.services.notify_dispatch import (
@@ -134,8 +135,13 @@ def list_events(
db: Session = Depends(get_db),
_user: str = Depends(get_current_user),
) -> EventListResponse:
hidden = get_hidden_event_types(db)
type_filter = visibility_type_filter(hidden)
stmt = select(Event).join(Host).options(joinedload(Event.host))
count_stmt = select(func.count()).select_from(Event).join(Host)
if type_filter is not None:
stmt = stmt.where(type_filter)
count_stmt = count_stmt.where(type_filter)
if severity:
stmt = stmt.where(Event.severity == severity)
@@ -261,11 +267,14 @@ def get_event(
db: Session = Depends(get_db),
_user: str = Depends(get_current_user),
) -> EventDetail:
hidden = get_hidden_event_types(db)
event = db.scalar(
select(Event).where(Event.id == event_db_id).options(joinedload(Event.host))
)
if event is None:
raise HTTPException(status_code=404, detail="Event not found")
if event.type in hidden:
raise HTTPException(status_code=404, detail="Event not found")
base = event_to_summary(event, db)
return EventDetail(
**base.model_dump(),
+14 -7
View File
@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.orm import Session
@@ -56,10 +56,19 @@ from app.services.host_health import (
max_daily_report_time_by_host,
max_event_time_by_host,
)
from app.services.event_type_visibility import get_hidden_event_types, visibility_type_filter
router = APIRouter(prefix="/hosts", tags=["hosts"])
def _count_visible_events(db: Session, host_id: int, hidden: frozenset[str]) -> int:
stmt = select(func.count()).select_from(Event).where(Event.host_id == host_id)
type_filter = visibility_type_filter(hidden)
if type_filter is not None:
stmt = stmt.where(type_filter)
return int(db.scalar(stmt) or 0)
@router.get("", response_model=HostListResponse)
def list_hosts(
page: int = Query(1, ge=1),
@@ -108,12 +117,11 @@ def list_hosts(
latest_map,
git_versions=git_release.versions,
)
hidden = get_hidden_event_types(db)
items: list[HostSummary] = []
for host in rows:
event_count = db.scalar(
select(func.count()).select_from(Event).where(Event.host_id == host.id)
)
event_count = _count_visible_events(db, host.id, hidden)
last_hb = hb_map.get(host.id)
items.append(
HostSummary(
@@ -161,9 +169,8 @@ def _host_detail_from_model(
) -> HostDetail:
hb_map = max_event_time_by_host(db, HEARTBEAT_TYPE)
report_map = max_daily_report_time_by_host(db)
event_count = db.scalar(
select(func.count()).select_from(Event).where(Event.host_id == host.id)
)
hidden = get_hidden_event_types(db)
event_count = _count_visible_events(db, host.id, hidden)
last_hb = hb_map.get(host.id)
latest_map = latest_agent_versions_by_product(db)
update_cfg = get_effective_agent_update_config(db)
+4 -1
View File
@@ -1,4 +1,4 @@
from datetime import datetime, timedelta, timezone
from datetime import datetime, timedelta, timezone
@@ -17,6 +17,7 @@ from app.auth.jwt_auth import get_current_user
from app.database import get_db
from app.models import Event, Host, Problem, ProblemEvent
from app.services.event_type_visibility import get_hidden_event_types
@@ -269,6 +270,8 @@ def get_problem(
.order_by(Event.occurred_at.desc())
).all()
hidden = get_hidden_event_types(db)
event_rows = [event for event in event_rows if event.type not in hidden]
+30 -18
View File
@@ -1,4 +1,4 @@
from dataclasses import replace
from dataclasses import replace
from datetime import datetime
@@ -37,6 +37,7 @@ from app.services.event_severity_overrides import (
list_severity_override_items,
replace_severity_overrides,
)
from app.services.event_type_visibility import replace_event_visibility
from app.services.ui_settings import (
get_effective_ui_settings,
upsert_ui_settings,
@@ -375,6 +376,7 @@ class EventSeverityOverrideRow(BaseModel):
event_type: str
default_severity: str
override_severity: str | None = None
show_in_events: bool = True
class EventSeverityOverridesResponse(BaseModel):
@@ -384,8 +386,25 @@ class EventSeverityOverridesResponse(BaseModel):
class EventSeverityOverridesUpdate(BaseModel):
overrides: dict[str, str | None] = Field(
default_factory=dict,
description="event_type → severity; null или пустая строка сбрасывает override",
)
visibility: dict[str, bool | None] = Field(
default_factory=dict,
description="event_type → show_in_events; null или true сбрасывает скрытие (показывать)",
)
def _severity_override_rows(items) -> list[EventSeverityOverrideRow]:
return [
EventSeverityOverrideRow(
event_type=i.event_type,
default_severity=i.default_severity,
override_severity=i.override_severity,
show_in_events=i.show_in_events,
)
for i in items
]
@router.get("/notifications/severity-overrides", response_model=EventSeverityOverridesResponse)
@@ -395,14 +414,7 @@ def get_severity_overrides(
) -> 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
],
items=_severity_override_rows(items),
source=SOURCE_DB,
)
@@ -414,19 +426,19 @@ def update_severity_overrides(
_user=Depends(require_admin),
) -> EventSeverityOverridesResponse:
try:
items = replace_severity_overrides(db, body.overrides)
items = list_severity_override_items(db)
if body.overrides:
items = replace_severity_overrides(db, body.overrides)
if body.visibility:
replace_event_visibility(db, body.visibility)
items = list_severity_override_items(db)
if not body.overrides and not body.visibility:
raise HTTPException(status_code=422, detail="Нет изменений для сохранения")
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
],
items=_severity_override_rows(items),
source=SOURCE_DB,
)
+3 -1
View File
@@ -1,4 +1,4 @@
from app.models.agent_command import AgentCommand
from app.models.agent_command import AgentCommand
from app.models.api_key import ApiKey
from app.models.event import Event
from app.models.host import Host
@@ -9,6 +9,7 @@ from app.models.problem import Problem, ProblemEvent
from app.models.login_attempt import LoginAttempt
from app.models.ui_settings import UiSettings
from app.models.event_severity_override import EventSeverityOverride
from app.models.event_type_visibility import EventTypeVisibility
from app.models.user import User
from app.models.mobile_settings import MobileSettings
from app.models.mobile_enrollment_code import MobileEnrollmentCode
@@ -27,6 +28,7 @@ __all__ = [
"ProblemEvent",
"User",
"EventSeverityOverride",
"EventTypeVisibility",
"LoginAttempt",
"UiSettings",
"MobileSettings",
@@ -0,0 +1,21 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class EventTypeVisibility(Base):
"""Per event-type UI visibility (default: show in events lists)."""
__tablename__ = "event_type_visibility"
event_type: Mapped[str] = mapped_column(String(128), primary_key=True)
show_in_events: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
@@ -1,4 +1,4 @@
"""Per event-type severity overrides (admin Settings UI)."""
"""Per event-type severity overrides (admin Settings UI)."""
from __future__ import annotations
@@ -9,6 +9,7 @@ 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.event_type_visibility import get_visibility_map
from app.services.notification_settings import VALID_SEVERITIES
SOURCE_DB = "db"
@@ -19,6 +20,7 @@ class EventSeverityOverrideItem:
event_type: str
default_severity: str
override_severity: str | None
show_in_events: bool = True
def get_override_map(db: Session) -> dict[str, str]:
@@ -48,7 +50,8 @@ def apply_severity_override(payload: dict, db: Session) -> dict:
def list_severity_override_items(db: Session) -> list[EventSeverityOverrideItem]:
override_map = get_override_map(db)
types = set(DEFAULT_EVENT_SEVERITIES) | set(override_map)
visibility_map = get_visibility_map(db)
types = set(DEFAULT_EVENT_SEVERITIES) | set(override_map) | set(visibility_map)
items: list[EventSeverityOverrideItem] = []
for event_type in sorted(types):
default = DEFAULT_EVENT_SEVERITIES.get(event_type, "info")
@@ -57,6 +60,7 @@ def list_severity_override_items(db: Session) -> list[EventSeverityOverrideItem]
event_type=event_type,
default_severity=default,
override_severity=override_map.get(event_type),
show_in_events=visibility_map.get(event_type, True),
)
)
return items
@@ -0,0 +1,58 @@
"""Hide selected event types from SAC UI/API event lists."""
from __future__ import annotations
from sqlalchemy import delete, select
from sqlalchemy.orm import Session
from sqlalchemy.sql import ColumnElement
from app.models.event import Event
from app.models.event_type_visibility import EventTypeVisibility
def get_hidden_event_types(db: Session) -> frozenset[str]:
rows = db.scalars(
select(EventTypeVisibility.event_type).where(EventTypeVisibility.show_in_events.is_(False))
).all()
return frozenset(rows)
def get_visibility_map(db: Session) -> dict[str, bool]:
rows = db.scalars(select(EventTypeVisibility)).all()
return {row.event_type: row.show_in_events for row in rows}
def show_in_events_for(event_type: str, *, hidden: frozenset[str]) -> bool:
return event_type not in hidden
def visibility_type_filter(hidden: frozenset[str]) -> ColumnElement[bool] | None:
if not hidden:
return None
return Event.type.not_in(tuple(sorted(hidden)))
def replace_event_visibility(db: Session, visibility: dict[str, bool | None]) -> dict[str, bool]:
stored = get_visibility_map(db)
for event_type, show in visibility.items():
normalized_type = event_type.strip()
if not normalized_type:
raise ValueError("event_type is required")
if show is None or show is True:
db.execute(
delete(EventTypeVisibility).where(EventTypeVisibility.event_type == normalized_type)
)
stored.pop(normalized_type, None)
continue
if show is False:
row = db.get(EventTypeVisibility, normalized_type)
if row is None:
row = EventTypeVisibility(event_type=normalized_type, show_in_events=False)
db.add(row)
else:
row.show_in_events = False
stored[normalized_type] = False
else:
raise ValueError("show_in_events must be true, false, or null")
db.flush()
return stored
+1 -1
View File
@@ -1,5 +1,5 @@
"""Единый источник версии SAC (API, health, логи, OpenAPI)."""
APP_NAME = "Security Alert Center"
APP_VERSION = "0.20.22"
APP_VERSION = "0.20.23"
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
@@ -0,0 +1,76 @@
"""Tests for hiding event types from SAC UI."""
import uuid
from app.models.event_type_visibility import EventTypeVisibility
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": "agent.heartbeat",
"severity": "info",
"title": "Heartbeat",
"summary": "pytest",
}
LOGIN_EVENT = {
**VALID_EVENT,
"type": "rdp.login.success",
"title": "RDP login",
"summary": "login pytest",
}
def _ingest(client, auth_headers, payload):
event_id = str(uuid.uuid4())
r = client.post("/api/v1/events", json={**payload, "event_id": event_id}, headers=auth_headers)
assert r.status_code == 201
return event_id
def test_hidden_event_type_excluded_from_list(client, auth_headers, jwt_headers, db_session):
db_session.add(EventTypeVisibility(event_type="agent.heartbeat", show_in_events=False))
db_session.commit()
hb_id = _ingest(client, auth_headers, VALID_EVENT)
login_id = _ingest(client, auth_headers, LOGIN_EVENT)
listed = client.get("/api/v1/events", headers=jwt_headers)
assert listed.status_code == 200
ids = {item["event_id"] for item in listed.json()["items"]}
assert hb_id not in ids
assert login_id in ids
def test_hidden_event_type_returns_404_on_detail(client, auth_headers, jwt_headers, db_session):
db_session.add(EventTypeVisibility(event_type="agent.heartbeat", show_in_events=False))
db_session.commit()
_ingest(client, auth_headers, VALID_EVENT)
listed = client.get("/api/v1/events", headers=jwt_headers, params={"type": "agent.heartbeat"})
assert listed.json()["total"] == 0
def test_visibility_settings_api(client, jwt_headers, db_session):
put = client.put(
"/api/v1/settings/notifications/severity-overrides",
headers=jwt_headers,
json={"visibility": {"agent.heartbeat": False}},
)
assert put.status_code == 200
items = {i["event_type"]: i for i in put.json()["items"]}
assert items["agent.heartbeat"]["show_in_events"] is False
assert items["rdp.login.success"]["show_in_events"] is True
reset = client.put(
"/api/v1/settings/notifications/severity-overrides",
headers=jwt_headers,
json={"visibility": {"agent.heartbeat": None}},
)
assert reset.status_code == 200
items = {i["event_type"]: i for i in reset.json()["items"]}
assert items["agent.heartbeat"]["show_in_events"] is True
+2 -2
View File
@@ -1,7 +1,7 @@
{
{
"name": "sac-ui",
"private": true,
"version": "0.11.0",
"version": "0.11.1",
"type": "module",
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -1,4 +1,4 @@
/** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */
export const APP_NAME = "Security Alert Center";
export const APP_VERSION = "0.20.22";
export const APP_VERSION = "0.20.23";
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
+24 -8
View File
@@ -397,6 +397,7 @@
<p class="settings-hint settings-hint-top">
Переопределение значимости для новых событий после ingest. Исходное severity агента сохраняется в
<code>details.severity_agent</code>. Пустое значение как прислал агент (см. «По умолчанию»).
Снимите «Показывать в событиях», чтобы скрыть тип из списков SAC (например <code>agent.heartbeat</code>).
</p>
<div class="settings-severity-toolbar">
<input v-model="severityFilter" type="search" placeholder="Фильтр по type…" />
@@ -411,6 +412,7 @@
<th>Тип события</th>
<th>По умолчанию</th>
<th>Effective</th>
<th>Показывать в событиях</th>
</tr>
</thead>
<tbody>
@@ -426,6 +428,12 @@
<option value="critical">critical</option>
</select>
</td>
<td class="settings-severity-show-cell">
<label>
<input v-model="row.show_in_events" type="checkbox" />
показывать
</label>
</td>
</tr>
</tbody>
</table>
@@ -678,10 +686,12 @@ interface SeverityOverrideRowApi {
event_type: string;
default_severity: string;
override_severity: string | null;
show_in_events: boolean;
}
interface SeverityOverrideRowUi extends SeverityOverrideRowApi {
selected: string;
override_show_in_events: boolean;
}
const loading = ref(true);
@@ -868,6 +878,7 @@ function mapSeverityRows(items: SeverityOverrideRowApi[]): SeverityOverrideRowUi
return items.map((row) => ({
...row,
selected: row.override_severity ?? "",
override_show_in_events: row.show_in_events,
}));
}
@@ -884,25 +895,30 @@ async function saveSeverityOverrides() {
success.value = "";
try {
const overrides: Record<string, string | null> = {};
const visibility: Record<string, boolean | null> = {};
for (const row of severityRows.value) {
const prev = row.override_severity ?? "";
const next = row.selected.trim();
if (next === prev) continue;
overrides[row.event_type] = next || null;
const prevSeverity = row.override_severity ?? "";
const nextSeverity = row.selected.trim();
if (nextSeverity !== prevSeverity) {
overrides[row.event_type] = nextSeverity || null;
}
if (row.show_in_events !== (row.override_show_in_events ?? true)) {
visibility[row.event_type] = row.show_in_events ? null : false;
}
}
if (Object.keys(overrides).length === 0) {
error.value = "Нет изменений severity для сохранения";
if (Object.keys(overrides).length === 0 && Object.keys(visibility).length === 0) {
error.value = "Нет изменений для сохранения";
return;
}
const data = await apiFetch<{ items: SeverityOverrideRowApi[] }>(
"/api/v1/settings/notifications/severity-overrides",
{
method: "PUT",
body: JSON.stringify({ overrides }),
body: JSON.stringify({ overrides, visibility }),
},
);
severityRows.value = mapSeverityRows(data.items);
success.value = "Severity по типам событий сохранён.";
success.value = "Severity и видимость событий сохранены.";
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка сохранения severity";
} finally {