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:
@@ -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")
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@@ -11,6 +11,7 @@ from app.database import get_db
|
|||||||
from app.models import Event, Host, Problem
|
from app.models import Event, Host, Problem
|
||||||
from app.schemas.list_models import EventSummary
|
from app.schemas.list_models import EventSummary
|
||||||
from app.services.event_summary import event_to_summary
|
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
|
from app.services.host_health import DAILY_REPORT_TYPES, HEARTBEAT_TYPE, count_stale_hosts
|
||||||
|
|
||||||
router = APIRouter(prefix="/dashboards", tags=["dashboards"])
|
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]:
|
def fetch_recent_events(db: Session, *, limit: int = 8) -> list[EventSummary]:
|
||||||
"""Последние события по времени приёма ingest (received_at)."""
|
"""Последние события по времени приёма ingest (received_at)."""
|
||||||
rows = db.scalars(
|
hidden = get_hidden_event_types(db)
|
||||||
|
type_filter = visibility_type_filter(hidden)
|
||||||
|
stmt = (
|
||||||
select(Event)
|
select(Event)
|
||||||
.join(Host)
|
.join(Host)
|
||||||
.options(joinedload(Event.host))
|
.options(joinedload(Event.host))
|
||||||
.order_by(Event.received_at.desc())
|
.order_by(Event.received_at.desc())
|
||||||
.limit(limit)
|
.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]
|
return [event_to_summary(e, db) for e in rows]
|
||||||
|
|
||||||
|
|
||||||
@@ -61,10 +67,13 @@ def dashboard_summary(
|
|||||||
_user: str = Depends(get_current_user),
|
_user: str = Depends(get_current_user),
|
||||||
) -> DashboardSummary:
|
) -> DashboardSummary:
|
||||||
since = datetime.now(timezone.utc) - timedelta(hours=24)
|
since = datetime.now(timezone.utc) - timedelta(hours=24)
|
||||||
|
hidden = get_hidden_event_types(db)
|
||||||
|
type_filter = visibility_type_filter(hidden)
|
||||||
|
|
||||||
events_24h = db.scalar(
|
events_24h_stmt = select(func.count()).select_from(Event).where(Event.received_at >= since)
|
||||||
select(func.count()).select_from(Event).where(Event.received_at >= since)
|
if type_filter is not None:
|
||||||
) or 0
|
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
|
hosts_total = db.scalar(select(func.count()).select_from(Host)) or 0
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
hosts_stale = count_stale_hosts(db, settings.sac_heartbeat_stale_minutes)
|
hosts_stale = count_stale_hosts(db, settings.sac_heartbeat_stale_minutes)
|
||||||
@@ -100,12 +109,16 @@ def dashboard_summary(
|
|||||||
or 0
|
or 0
|
||||||
)
|
)
|
||||||
|
|
||||||
top_host_rows = db.execute(
|
top_host_stmt = (
|
||||||
select(Host.id, Host.hostname, Host.display_name, func.count())
|
select(Host.id, Host.hostname, Host.display_name, func.count())
|
||||||
.select_from(Event)
|
.select_from(Event)
|
||||||
.join(Host, Event.host_id == Host.id)
|
.join(Host, Event.host_id == Host.id)
|
||||||
.where(Event.received_at >= since)
|
.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())
|
.order_by(func.count().desc())
|
||||||
.limit(10)
|
.limit(10)
|
||||||
).all()
|
).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
|
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(
|
top_type_rows = db.execute(
|
||||||
select(Event.type, func.count())
|
top_type_stmt.group_by(Event.type).order_by(func.count().desc()).limit(10)
|
||||||
.where(Event.received_at >= since)
|
|
||||||
.group_by(Event.type)
|
|
||||||
.order_by(func.count().desc())
|
|
||||||
.limit(10)
|
|
||||||
).all()
|
).all()
|
||||||
top_event_types = [TopTypeItem(type=row[0], count=row[1]) for row in top_type_rows]
|
top_event_types = [TopTypeItem(type=row[0], count=row[1]) for row in top_type_rows]
|
||||||
|
|
||||||
severity_rows = db.execute(
|
severity_stmt = select(Event.severity, func.count()).where(Event.received_at >= since)
|
||||||
select(Event.severity, func.count())
|
if type_filter is not None:
|
||||||
.where(Event.received_at >= since)
|
severity_stmt = severity_stmt.where(type_filter)
|
||||||
.group_by(Event.severity)
|
severity_rows = db.execute(severity_stmt.group_by(Event.severity)).all()
|
||||||
).all()
|
|
||||||
severity_24h = {row[0]: row[1] for row in severity_rows}
|
severity_24h = {row[0]: row[1] for row in severity_rows}
|
||||||
|
|
||||||
recent_events = fetch_recent_events(db, limit=8)
|
recent_events = fetch_recent_events(db, limit=8)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
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.rdg_winrm_actions import execute_logoff_via_winrm, execute_qwinsta_via_winrm
|
||||||
from app.services.ingest import ingest_event
|
from app.services.ingest import ingest_event
|
||||||
from app.services.event_summary import event_to_summary
|
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.problems import maybe_create_problem
|
||||||
from app.services.schema_validate import validate_event_payload
|
from app.services.schema_validate import validate_event_payload
|
||||||
from app.services.notify_dispatch import (
|
from app.services.notify_dispatch import (
|
||||||
@@ -134,8 +135,13 @@ def list_events(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_user: str = Depends(get_current_user),
|
_user: str = Depends(get_current_user),
|
||||||
) -> EventListResponse:
|
) -> EventListResponse:
|
||||||
|
hidden = get_hidden_event_types(db)
|
||||||
|
type_filter = visibility_type_filter(hidden)
|
||||||
stmt = select(Event).join(Host).options(joinedload(Event.host))
|
stmt = select(Event).join(Host).options(joinedload(Event.host))
|
||||||
count_stmt = select(func.count()).select_from(Event).join(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:
|
if severity:
|
||||||
stmt = stmt.where(Event.severity == severity)
|
stmt = stmt.where(Event.severity == severity)
|
||||||
@@ -261,11 +267,14 @@ def get_event(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_user: str = Depends(get_current_user),
|
_user: str = Depends(get_current_user),
|
||||||
) -> EventDetail:
|
) -> EventDetail:
|
||||||
|
hidden = get_hidden_event_types(db)
|
||||||
event = db.scalar(
|
event = db.scalar(
|
||||||
select(Event).where(Event.id == event_db_id).options(joinedload(Event.host))
|
select(Event).where(Event.id == event_db_id).options(joinedload(Event.host))
|
||||||
)
|
)
|
||||||
if event is None:
|
if event is None:
|
||||||
raise HTTPException(status_code=404, detail="Event not found")
|
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)
|
base = event_to_summary(event, db)
|
||||||
return EventDetail(
|
return EventDetail(
|
||||||
**base.model_dump(),
|
**base.model_dump(),
|
||||||
|
|||||||
@@ -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 pydantic import BaseModel
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -56,10 +56,19 @@ from app.services.host_health import (
|
|||||||
max_daily_report_time_by_host,
|
max_daily_report_time_by_host,
|
||||||
max_event_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"])
|
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)
|
@router.get("", response_model=HostListResponse)
|
||||||
def list_hosts(
|
def list_hosts(
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
@@ -108,12 +117,11 @@ def list_hosts(
|
|||||||
latest_map,
|
latest_map,
|
||||||
git_versions=git_release.versions,
|
git_versions=git_release.versions,
|
||||||
)
|
)
|
||||||
|
hidden = get_hidden_event_types(db)
|
||||||
|
|
||||||
items: list[HostSummary] = []
|
items: list[HostSummary] = []
|
||||||
for host in rows:
|
for host in rows:
|
||||||
event_count = db.scalar(
|
event_count = _count_visible_events(db, host.id, hidden)
|
||||||
select(func.count()).select_from(Event).where(Event.host_id == host.id)
|
|
||||||
)
|
|
||||||
last_hb = hb_map.get(host.id)
|
last_hb = hb_map.get(host.id)
|
||||||
items.append(
|
items.append(
|
||||||
HostSummary(
|
HostSummary(
|
||||||
@@ -161,9 +169,8 @@ def _host_detail_from_model(
|
|||||||
) -> HostDetail:
|
) -> HostDetail:
|
||||||
hb_map = max_event_time_by_host(db, HEARTBEAT_TYPE)
|
hb_map = max_event_time_by_host(db, HEARTBEAT_TYPE)
|
||||||
report_map = max_daily_report_time_by_host(db)
|
report_map = max_daily_report_time_by_host(db)
|
||||||
event_count = db.scalar(
|
hidden = get_hidden_event_types(db)
|
||||||
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)
|
last_hb = hb_map.get(host.id)
|
||||||
latest_map = latest_agent_versions_by_product(db)
|
latest_map = latest_agent_versions_by_product(db)
|
||||||
update_cfg = get_effective_agent_update_config(db)
|
update_cfg = get_effective_agent_update_config(db)
|
||||||
|
|||||||
@@ -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.database import get_db
|
||||||
|
|
||||||
from app.models import Event, Host, Problem, ProblemEvent
|
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())
|
.order_by(Event.occurred_at.desc())
|
||||||
|
|
||||||
).all()
|
).all()
|
||||||
|
hidden = get_hidden_event_types(db)
|
||||||
|
event_rows = [event for event in event_rows if event.type not in hidden]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
@@ -37,6 +37,7 @@ from app.services.event_severity_overrides import (
|
|||||||
list_severity_override_items,
|
list_severity_override_items,
|
||||||
replace_severity_overrides,
|
replace_severity_overrides,
|
||||||
)
|
)
|
||||||
|
from app.services.event_type_visibility import replace_event_visibility
|
||||||
from app.services.ui_settings import (
|
from app.services.ui_settings import (
|
||||||
get_effective_ui_settings,
|
get_effective_ui_settings,
|
||||||
upsert_ui_settings,
|
upsert_ui_settings,
|
||||||
@@ -375,6 +376,7 @@ class EventSeverityOverrideRow(BaseModel):
|
|||||||
event_type: str
|
event_type: str
|
||||||
default_severity: str
|
default_severity: str
|
||||||
override_severity: str | None = None
|
override_severity: str | None = None
|
||||||
|
show_in_events: bool = True
|
||||||
|
|
||||||
|
|
||||||
class EventSeverityOverridesResponse(BaseModel):
|
class EventSeverityOverridesResponse(BaseModel):
|
||||||
@@ -384,8 +386,25 @@ class EventSeverityOverridesResponse(BaseModel):
|
|||||||
|
|
||||||
class EventSeverityOverridesUpdate(BaseModel):
|
class EventSeverityOverridesUpdate(BaseModel):
|
||||||
overrides: dict[str, str | None] = Field(
|
overrides: dict[str, str | None] = Field(
|
||||||
|
default_factory=dict,
|
||||||
description="event_type → severity; null или пустая строка сбрасывает override",
|
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)
|
@router.get("/notifications/severity-overrides", response_model=EventSeverityOverridesResponse)
|
||||||
@@ -395,14 +414,7 @@ def get_severity_overrides(
|
|||||||
) -> EventSeverityOverridesResponse:
|
) -> EventSeverityOverridesResponse:
|
||||||
items = list_severity_override_items(db)
|
items = list_severity_override_items(db)
|
||||||
return EventSeverityOverridesResponse(
|
return EventSeverityOverridesResponse(
|
||||||
items=[
|
items=_severity_override_rows(items),
|
||||||
EventSeverityOverrideRow(
|
|
||||||
event_type=i.event_type,
|
|
||||||
default_severity=i.default_severity,
|
|
||||||
override_severity=i.override_severity,
|
|
||||||
)
|
|
||||||
for i in items
|
|
||||||
],
|
|
||||||
source=SOURCE_DB,
|
source=SOURCE_DB,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -414,19 +426,19 @@ def update_severity_overrides(
|
|||||||
_user=Depends(require_admin),
|
_user=Depends(require_admin),
|
||||||
) -> EventSeverityOverridesResponse:
|
) -> EventSeverityOverridesResponse:
|
||||||
try:
|
try:
|
||||||
|
items = list_severity_override_items(db)
|
||||||
|
if body.overrides:
|
||||||
items = replace_severity_overrides(db, 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()
|
db.commit()
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
return EventSeverityOverridesResponse(
|
return EventSeverityOverridesResponse(
|
||||||
items=[
|
items=_severity_override_rows(items),
|
||||||
EventSeverityOverrideRow(
|
|
||||||
event_type=i.event_type,
|
|
||||||
default_severity=i.default_severity,
|
|
||||||
override_severity=i.override_severity,
|
|
||||||
)
|
|
||||||
for i in items
|
|
||||||
],
|
|
||||||
source=SOURCE_DB,
|
source=SOURCE_DB,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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.api_key import ApiKey
|
||||||
from app.models.event import Event
|
from app.models.event import Event
|
||||||
from app.models.host import Host
|
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.login_attempt import LoginAttempt
|
||||||
from app.models.ui_settings import UiSettings
|
from app.models.ui_settings import UiSettings
|
||||||
from app.models.event_severity_override import EventSeverityOverride
|
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.user import User
|
||||||
from app.models.mobile_settings import MobileSettings
|
from app.models.mobile_settings import MobileSettings
|
||||||
from app.models.mobile_enrollment_code import MobileEnrollmentCode
|
from app.models.mobile_enrollment_code import MobileEnrollmentCode
|
||||||
@@ -27,6 +28,7 @@ __all__ = [
|
|||||||
"ProblemEvent",
|
"ProblemEvent",
|
||||||
"User",
|
"User",
|
||||||
"EventSeverityOverride",
|
"EventSeverityOverride",
|
||||||
|
"EventTypeVisibility",
|
||||||
"LoginAttempt",
|
"LoginAttempt",
|
||||||
"UiSettings",
|
"UiSettings",
|
||||||
"MobileSettings",
|
"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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -9,6 +9,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.constants.event_types import DEFAULT_EVENT_SEVERITIES
|
from app.constants.event_types import DEFAULT_EVENT_SEVERITIES
|
||||||
from app.models.event_severity_override import EventSeverityOverride
|
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
|
from app.services.notification_settings import VALID_SEVERITIES
|
||||||
|
|
||||||
SOURCE_DB = "db"
|
SOURCE_DB = "db"
|
||||||
@@ -19,6 +20,7 @@ class EventSeverityOverrideItem:
|
|||||||
event_type: str
|
event_type: str
|
||||||
default_severity: str
|
default_severity: str
|
||||||
override_severity: str | None
|
override_severity: str | None
|
||||||
|
show_in_events: bool = True
|
||||||
|
|
||||||
|
|
||||||
def get_override_map(db: Session) -> dict[str, str]:
|
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]:
|
def list_severity_override_items(db: Session) -> list[EventSeverityOverrideItem]:
|
||||||
override_map = get_override_map(db)
|
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] = []
|
items: list[EventSeverityOverrideItem] = []
|
||||||
for event_type in sorted(types):
|
for event_type in sorted(types):
|
||||||
default = DEFAULT_EVENT_SEVERITIES.get(event_type, "info")
|
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,
|
event_type=event_type,
|
||||||
default_severity=default,
|
default_severity=default,
|
||||||
override_severity=override_map.get(event_type),
|
override_severity=override_map.get(event_type),
|
||||||
|
show_in_events=visibility_map.get(event_type, True),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return items
|
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,5 +1,5 @@
|
|||||||
"""Единый источник версии SAC (API, health, логи, OpenAPI)."""
|
"""Единый источник версии SAC (API, health, логи, OpenAPI)."""
|
||||||
|
|
||||||
APP_NAME = "Security Alert Center"
|
APP_NAME = "Security Alert Center"
|
||||||
APP_VERSION = "0.20.22"
|
APP_VERSION = "0.20.23"
|
||||||
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
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
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "sac-ui",
|
"name": "sac-ui",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.11.0",
|
"version": "0.11.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */
|
/** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */
|
||||||
export const APP_NAME = "Security Alert Center";
|
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}`;
|
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
|
||||||
|
|||||||
@@ -397,6 +397,7 @@
|
|||||||
<p class="settings-hint settings-hint-top">
|
<p class="settings-hint settings-hint-top">
|
||||||
Переопределение значимости для новых событий после ingest. Исходное severity агента сохраняется в
|
Переопределение значимости для новых событий после ingest. Исходное severity агента сохраняется в
|
||||||
<code>details.severity_agent</code>. Пустое значение — как прислал агент (см. «По умолчанию»).
|
<code>details.severity_agent</code>. Пустое значение — как прислал агент (см. «По умолчанию»).
|
||||||
|
Снимите «Показывать в событиях», чтобы скрыть тип из списков SAC (например <code>agent.heartbeat</code>).
|
||||||
</p>
|
</p>
|
||||||
<div class="settings-severity-toolbar">
|
<div class="settings-severity-toolbar">
|
||||||
<input v-model="severityFilter" type="search" placeholder="Фильтр по type…" />
|
<input v-model="severityFilter" type="search" placeholder="Фильтр по type…" />
|
||||||
@@ -411,6 +412,7 @@
|
|||||||
<th>Тип события</th>
|
<th>Тип события</th>
|
||||||
<th>По умолчанию</th>
|
<th>По умолчанию</th>
|
||||||
<th>Effective</th>
|
<th>Effective</th>
|
||||||
|
<th>Показывать в событиях</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -426,6 +428,12 @@
|
|||||||
<option value="critical">critical</option>
|
<option value="critical">critical</option>
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
|
<td class="settings-severity-show-cell">
|
||||||
|
<label>
|
||||||
|
<input v-model="row.show_in_events" type="checkbox" />
|
||||||
|
показывать
|
||||||
|
</label>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -678,10 +686,12 @@ interface SeverityOverrideRowApi {
|
|||||||
event_type: string;
|
event_type: string;
|
||||||
default_severity: string;
|
default_severity: string;
|
||||||
override_severity: string | null;
|
override_severity: string | null;
|
||||||
|
show_in_events: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SeverityOverrideRowUi extends SeverityOverrideRowApi {
|
interface SeverityOverrideRowUi extends SeverityOverrideRowApi {
|
||||||
selected: string;
|
selected: string;
|
||||||
|
override_show_in_events: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
@@ -868,6 +878,7 @@ function mapSeverityRows(items: SeverityOverrideRowApi[]): SeverityOverrideRowUi
|
|||||||
return items.map((row) => ({
|
return items.map((row) => ({
|
||||||
...row,
|
...row,
|
||||||
selected: row.override_severity ?? "",
|
selected: row.override_severity ?? "",
|
||||||
|
override_show_in_events: row.show_in_events,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -884,25 +895,30 @@ async function saveSeverityOverrides() {
|
|||||||
success.value = "";
|
success.value = "";
|
||||||
try {
|
try {
|
||||||
const overrides: Record<string, string | null> = {};
|
const overrides: Record<string, string | null> = {};
|
||||||
|
const visibility: Record<string, boolean | null> = {};
|
||||||
for (const row of severityRows.value) {
|
for (const row of severityRows.value) {
|
||||||
const prev = row.override_severity ?? "";
|
const prevSeverity = row.override_severity ?? "";
|
||||||
const next = row.selected.trim();
|
const nextSeverity = row.selected.trim();
|
||||||
if (next === prev) continue;
|
if (nextSeverity !== prevSeverity) {
|
||||||
overrides[row.event_type] = next || null;
|
overrides[row.event_type] = nextSeverity || null;
|
||||||
}
|
}
|
||||||
if (Object.keys(overrides).length === 0) {
|
if (row.show_in_events !== (row.override_show_in_events ?? true)) {
|
||||||
error.value = "Нет изменений severity для сохранения";
|
visibility[row.event_type] = row.show_in_events ? null : false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(overrides).length === 0 && Object.keys(visibility).length === 0) {
|
||||||
|
error.value = "Нет изменений для сохранения";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = await apiFetch<{ items: SeverityOverrideRowApi[] }>(
|
const data = await apiFetch<{ items: SeverityOverrideRowApi[] }>(
|
||||||
"/api/v1/settings/notifications/severity-overrides",
|
"/api/v1/settings/notifications/severity-overrides",
|
||||||
{
|
{
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify({ overrides }),
|
body: JSON.stringify({ overrides, visibility }),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
severityRows.value = mapSeverityRows(data.items);
|
severityRows.value = mapSeverityRows(data.items);
|
||||||
success.value = "Severity по типам событий сохранён.";
|
success.value = "Severity и видимость событий сохранены.";
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e instanceof Error ? e.message : "Ошибка сохранения severity";
|
error.value = e instanceof Error ? e.message : "Ошибка сохранения severity";
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
Reference in New Issue
Block a user