From 24ca87a47aa672c84a65996cba97f2913060bda4 Mon Sep 17 00:00:00 2001 From: PTah Date: Mon, 22 Jun 2026 19:52:22 +1000 Subject: [PATCH] feat: RDG 302/303 display path labels and RDS session break (0.3.1) Show RDS access via RDG-Comp or Haproxy-RDG-Comp, enable qwinsta/logoff on any RDG event with internal_ip. Co-authored-by: Cursor --- backend/app/config.py | 2 + backend/app/schemas/list_models.py | 2 + backend/app/services/event_summary.py | 18 ++- backend/app/services/rdg_display.py | 132 +++++++++++++++++++++ backend/app/services/rdg_winrm_actions.py | 19 +-- backend/app/services/telegram_templates.py | 15 ++- backend/app/version.py | 2 +- backend/tests/test_health.py | 4 +- backend/tests/test_rdg_display.py | 127 ++++++++++++++++++++ backend/tests/test_rdg_winrm_actions.py | 76 ++++++++++++ deploy/env.native.example | 3 + frontend/src/api.ts | 2 + frontend/src/components/RdgAccessBadge.vue | 40 +++++++ frontend/src/composables/useRdgQwinsta.ts | 3 +- frontend/src/utils/rdgFlap.ts | 23 +++- frontend/src/version.ts | 2 +- frontend/src/views/DashboardView.vue | 9 +- frontend/src/views/EventDetailView.vue | 12 +- frontend/src/views/EventsView.vue | 9 +- 19 files changed, 468 insertions(+), 32 deletions(-) create mode 100644 backend/app/services/rdg_display.py create mode 100644 backend/tests/test_rdg_display.py create mode 100644 frontend/src/components/RdgAccessBadge.vue diff --git a/backend/app/config.py b/backend/app/config.py index 502f8cc..3afa3b9 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -106,6 +106,8 @@ class Settings(BaseSettings): sac_rdg_flap_window_min_sec: int = 1 sac_rdg_flap_window_max_sec: int = 10 sac_rdg_flap_dedup_sec: int = 30 + # Внешние IP HAProxy/RDG-прокси: если external_ip в списке — путь Haproxy-RDG-Comp, иначе RDG-Comp + sac_rdg_haproxy_external_ips: str = "" # Windows admin for agent qwinsta/logoff (domain-wide) sac_win_admin_user: str = "" diff --git a/backend/app/schemas/list_models.py b/backend/app/schemas/list_models.py index 07897f4..44ba739 100644 --- a/backend/app/schemas/list_models.py +++ b/backend/app/schemas/list_models.py @@ -81,6 +81,8 @@ class EventSummary(BaseModel): rdg_flap: bool = False rdg_flap_pair_event_id: int | None = None rdg_flap_qwinsta_event_id: int | None = None + rdg_access_path: str | None = None + rdg_qwinsta_enabled: bool = False model_config = {"from_attributes": True} diff --git a/backend/app/services/event_summary.py b/backend/app/services/event_summary.py index 605d89e..6d117c0 100644 --- a/backend/app/services/event_summary.py +++ b/backend/app/services/event_summary.py @@ -3,6 +3,7 @@ from sqlalchemy.orm import Session from app.models.event import Event from app.schemas.list_models import EventSummary from app.services.event_actor_user import extract_event_actor_user +from app.services.rdg_display import build_rdg_display from app.services.rdg_session_flap import resolve_rdg_flap_summary @@ -16,6 +17,17 @@ def event_to_summary(event: Event, db: Session | None = None) -> EventSummary: db, event ) + title = event.title + summary = event.summary + rdg_access_path: str | None = None + rdg_qwinsta_enabled = False + rdg_display = build_rdg_display(event, db) + if rdg_display is not None: + title = rdg_display.title + summary = rdg_display.summary + rdg_access_path = rdg_display.access_path + rdg_qwinsta_enabled = rdg_display.qwinsta_enabled + return EventSummary( id=event.id, event_id=event.event_id, @@ -28,10 +40,12 @@ def event_to_summary(event: Event, db: Session | None = None) -> EventSummary: category=event.category, type=event.type, severity=event.severity, - title=event.title, - summary=event.summary, + title=title, + summary=summary, actor_user=extract_event_actor_user(event.type, event.details), rdg_flap=rdg_flap, rdg_flap_pair_event_id=rdg_flap_pair_event_id, rdg_flap_qwinsta_event_id=rdg_flap_qwinsta_event_id, + rdg_access_path=rdg_access_path, + rdg_qwinsta_enabled=rdg_qwinsta_enabled, ) diff --git a/backend/app/services/rdg_display.py b/backend/app/services/rdg_display.py new file mode 100644 index 0000000..14e9049 --- /dev/null +++ b/backend/app/services/rdg_display.py @@ -0,0 +1,132 @@ +"""RD Gateway event labels (access path, UI title/summary).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from sqlalchemy.orm import Session + +from app.config import get_settings +from app.models import Event +from app.services.rdg_client_host import find_windows_host_by_ipv4 +from app.services.rdg_session_flap import event_internal_ip + +RDG_TYPES = frozenset( + { + "rdg.connection.success", + "rdg.connection.disconnected", + "rdg.connection.failed", + } +) + +ACCESS_PATH_HAPROXY = "Haproxy-RDG-Comp" +ACCESS_PATH_DIRECT = "RDG-Comp" + + +@dataclass(frozen=True) +class RdgDisplayInfo: + title: str + summary: str + access_path: str | None + internal_ip: str | None + qwinsta_enabled: bool + + +def _details_dict(event: Event) -> dict: + raw = event.details + return raw if isinstance(raw, dict) else {} + + +def _event_external_ip(event: Event) -> str: + details = _details_dict(event) + for key in ("external_ip", "source_ip", "ip_address"): + val = details.get(key) + if val is not None and str(val).strip(): + return str(val).strip() + return "" + + +def _parse_haproxy_ips() -> frozenset[str]: + raw = (get_settings().sac_rdg_haproxy_external_ips or "").strip() + if not raw: + return frozenset() + parts = [p.strip() for p in raw.replace(";", ",").split(",")] + return frozenset(p for p in parts if p) + + +def classify_rdg_access_path(external_ip: str) -> str | None: + ip = (external_ip or "").strip() + if not ip or ip in ("-", "N/A"): + return None + if ip in _parse_haproxy_ips(): + return ACCESS_PATH_HAPROXY + return ACCESS_PATH_DIRECT + + +def _windows_event_label(event: Event) -> str: + details = _details_dict(event) + win_id = details.get("event_id_windows") + if win_id is None: + return "" + text = str(win_id).strip() + return f"Win{text}" if text else "" + + +def _action_label(event: Event) -> str: + if event.type == "rdg.connection.success": + return "подключение" + if event.type == "rdg.connection.disconnected": + return "отключение" + return "ошибка" + + +def build_rdg_display(event: Event, db: Session | None = None) -> RdgDisplayInfo | None: + if event.type not in RDG_TYPES: + return None + + details = _details_dict(event) + internal_ip = event_internal_ip(event) + external_ip = _event_external_ip(event) + access_path = classify_rdg_access_path(external_ip) + user = str(details.get("user") or "").strip() + gateway = event.host.hostname if event.host else "—" + + client_label = internal_ip or "—" + if db and internal_ip: + client_host = find_windows_host_by_ipv4(db, internal_ip) + if client_host is not None: + client_label = f"{client_host.hostname} ({internal_ip})" + + path_note = f" ({access_path})" if access_path else "" + win_note = _windows_event_label(event) + action = _action_label(event) + + title = f"RDS {action} → {client_label}{path_note}" + if win_note: + title = f"{title} · {win_note}" + + summary_parts: list[str] = [] + if user: + summary_parts.append(user) + if external_ip: + summary_parts.append(f"внешний {external_ip}") + summary_parts.append(f"шлюз {gateway}") + if win_note: + summary_parts.append(win_note) + summary = " · ".join(summary_parts) + + qwinsta_enabled = bool(internal_ip) + + return RdgDisplayInfo( + title=title, + summary=summary, + access_path=access_path, + internal_ip=internal_ip or None, + qwinsta_enabled=qwinsta_enabled, + ) + + +def event_supports_rdg_client_qwinsta(event: Event) -> bool: + if event.type not in RDG_TYPES: + return False + return bool(event_internal_ip(event)) diff --git a/backend/app/services/rdg_winrm_actions.py b/backend/app/services/rdg_winrm_actions.py index 44ef254..137d0d8 100644 --- a/backend/app/services/rdg_winrm_actions.py +++ b/backend/app/services/rdg_winrm_actions.py @@ -10,7 +10,8 @@ from sqlalchemy.orm import Session from app.models import AgentCommand, Event, Host from app.services.rdg_client_host import ClientWorkstationNotFoundError, resolve_client_workstation -from app.services.rdg_session_flap import event_has_rdg_flap, event_internal_ip, resolve_rdg_flap_summary +from app.services.rdg_display import event_supports_rdg_client_qwinsta +from app.services.rdg_session_flap import event_internal_ip from app.services.win_admin_settings import get_effective_win_admin_config from app.services.winrm_connect import ( WinRmCmdResult, @@ -30,13 +31,13 @@ def _require_win_admin(db: Session): return cfg -def _require_rdg_flap(db: Session, event: Event) -> None: - flap, _, qwinsta_id = resolve_rdg_flap_summary(db, event) - if flap and qwinsta_id == event.id: +def _require_rdg_client_qwinsta(event: Event) -> None: + if event_supports_rdg_client_qwinsta(event): return - if event_has_rdg_flap(event): - return - raise HTTPException(status_code=400, detail="Event is not flagged as RDG session flap") + raise HTTPException( + status_code=400, + detail="Event is not an RD Gateway connection with internal_ip (client workstation)", + ) def _resolve_client(db: Session, event: Event) -> Host: @@ -91,7 +92,7 @@ def _persist_command( def execute_qwinsta_via_winrm(db: Session, event: Event, *, requested_by: str) -> AgentCommand: - _require_rdg_flap(db, event) + _require_rdg_client_qwinsta(event) cfg = _require_win_admin(db) client_host = _resolve_client(db, event) @@ -134,7 +135,7 @@ def execute_logoff_via_winrm( session_id: int, requested_by: str, ) -> AgentCommand: - _require_rdg_flap(db, event) + _require_rdg_client_qwinsta(event) cfg = _require_win_admin(db) client_host = _resolve_client(db, event) diff --git a/backend/app/services/telegram_templates.py b/backend/app/services/telegram_templates.py index 0391cfa..a46fe19 100644 --- a/backend/app/services/telegram_templates.py +++ b/backend/app/services/telegram_templates.py @@ -544,18 +544,23 @@ def format_event_mobile_push(event: Event) -> tuple[str, str]: def _format_rdg_html(event: Event) -> str: + from app.services.rdg_display import build_rdg_display + details = _details_dict(event) + display = build_rdg_display(event) if event.type.endswith(".success"): - header = "✅ RD Gateway: подключение" + header = "✅ RDS через RD Gateway" elif event.type.endswith(".disconnected"): - header = "ℹ️ RD Gateway: отключение" + header = "ℹ️ RDS отключение (RD Gateway)" else: - header = "❌ RD Gateway: ошибка" + header = "❌ RDS ошибка (RD Gateway)" msg = f"{header}\n" + if display is not None and display.access_path: + msg += _line("🔀", "Путь", html_escape(display.access_path)) msg += _line("👤", "Пользователь", html_escape(_detail(details, "user", "username"))) - msg += _line("🏢", "Хост", host_label(event.host)) + msg += _line("🚪", "Шлюз RDG", host_label(event.host)) msg += _line("🌐", "Внешний IP", html_escape(_detail(details, "external_ip", "ip_address"))) - msg += _line("🏠", "Внутренний IP", html_escape(_detail(details, "internal_ip", default="-"))) + msg += _line("🖥️", "Рабочий ПК", html_escape(_detail(details, "internal_ip", default="-"))) err = _detail(details, "gateway_error_code", "error_code", default="") if err != "-": msg += _line("⚠️", "Код ошибки", html_escape(err)) diff --git a/backend/app/version.py b/backend/app/version.py index 92e1c71..5d62f10 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.3.0" +APP_VERSION = "0.3.1" APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}" diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py index d14f6e5..b792ae5 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.3.0" + assert APP_VERSION == "0.3.1" assert APP_NAME == "Security Alert Center" - assert APP_VERSION_LABEL == "Security Alert Center v.0.3.0" + assert APP_VERSION_LABEL == "Security Alert Center v.0.3.1" diff --git a/backend/tests/test_rdg_display.py b/backend/tests/test_rdg_display.py new file mode 100644 index 0000000..791446b --- /dev/null +++ b/backend/tests/test_rdg_display.py @@ -0,0 +1,127 @@ +"""Tests for RDG event display (access path, title/summary).""" + +from datetime import datetime, timezone + +import pytest + +from app.models import Event, Host +from app.services.event_summary import event_to_summary +from app.services.rdg_display import ( + ACCESS_PATH_DIRECT, + ACCESS_PATH_HAPROXY, + build_rdg_display, + classify_rdg_access_path, + event_supports_rdg_client_qwinsta, +) + + +def _rdg_event( + db_session, + *, + gw: Host, + event_type: str = "rdg.connection.success", + external_ip: str = "10.0.0.5", + internal_ip: str = "192.168.160.3", + win_id: int = 302, +) -> Event: + event = Event( + event_id=f"ev-rdg-{win_id}", + host_id=gw.id, + occurred_at=datetime.now(timezone.utc), + received_at=datetime.now(timezone.utc), + category="auth", + type=event_type, + severity="info", + title="RD Gateway event 302", + summary="", + payload={}, + details={ + "user": r"B26\papatramp", + "external_ip": external_ip, + "internal_ip": internal_ip, + "event_id_windows": win_id, + }, + ) + db_session.add(event) + db_session.commit() + db_session.refresh(event) + return event + + +def test_classify_rdg_access_path_direct(monkeypatch): + monkeypatch.setenv("SAC_RDG_HAPROXY_EXTERNAL_IPS", "192.168.160.50") + from app.config import get_settings + + get_settings.cache_clear() + assert classify_rdg_access_path("10.0.0.5") == ACCESS_PATH_DIRECT + + +def test_classify_rdg_access_path_haproxy(monkeypatch): + monkeypatch.setenv("SAC_RDG_HAPROXY_EXTERNAL_IPS", "192.168.160.50,10.0.0.100") + from app.config import get_settings + + get_settings.cache_clear() + assert classify_rdg_access_path("10.0.0.100") == ACCESS_PATH_HAPROXY + + +def test_build_rdg_display_title_and_path(db_session, monkeypatch): + monkeypatch.setenv("SAC_RDG_HAPROXY_EXTERNAL_IPS", "") + from app.config import get_settings + + get_settings.cache_clear() + + gw = Host(hostname="K6A-DC3", os_family="windows", product="rdp-login-monitor", ipv4="192.168.160.40") + ws = Host(hostname="WS-PC", os_family="windows", product="rdp-login-monitor", ipv4="192.168.160.3") + db_session.add_all([gw, ws]) + db_session.commit() + + event = _rdg_event(db_session, gw=gw) + display = build_rdg_display(event, db_session) + assert display is not None + assert ACCESS_PATH_DIRECT in display.title + assert "WS-PC" in display.title + assert "192.168.160.3" in display.title + assert display.access_path == ACCESS_PATH_DIRECT + assert display.qwinsta_enabled is True + assert "papatramp" in display.summary + assert "шлюз K6A-DC3" in display.summary + + +def test_event_to_summary_enriches_rdg(db_session, monkeypatch): + monkeypatch.setenv("SAC_RDG_HAPROXY_EXTERNAL_IPS", "10.0.0.5") + from app.config import get_settings + + get_settings.cache_clear() + + gw = Host(hostname="K6A-DC3", os_family="windows", product="rdp-login-monitor", ipv4="192.168.160.40") + db_session.add(gw) + db_session.commit() + + event = _rdg_event(db_session, gw=gw, external_ip="10.0.0.5") + summary = event_to_summary(event, db_session) + assert summary.rdg_access_path == ACCESS_PATH_HAPROXY + assert summary.rdg_qwinsta_enabled is True + assert "RDS подключение" in summary.title + assert ACCESS_PATH_HAPROXY in summary.title + + +def test_event_supports_rdg_client_qwinsta_without_flap(db_session): + gw = Host(hostname="K6A-DC3", os_family="windows", product="rdp-login-monitor", ipv4="192.168.160.40") + db_session.add(gw) + db_session.commit() + event = _rdg_event(db_session, gw=gw) + assert event_supports_rdg_client_qwinsta(event) is True + + +@pytest.mark.parametrize( + "internal_ip", + ["", None], +) +def test_event_supports_rdg_client_qwinsta_requires_internal_ip(db_session, internal_ip): + gw = Host(hostname="K6A-DC3", os_family="windows", product="rdp-login-monitor", ipv4="192.168.160.40") + db_session.add(gw) + db_session.commit() + event = _rdg_event(db_session, gw=gw, internal_ip=internal_ip or "") + if internal_ip is None: + event.details = {k: v for k, v in event.details.items() if k != "internal_ip"} + assert event_supports_rdg_client_qwinsta(event) is False diff --git a/backend/tests/test_rdg_winrm_actions.py b/backend/tests/test_rdg_winrm_actions.py index 0cbb158..d79d279 100644 --- a/backend/tests/test_rdg_winrm_actions.py +++ b/backend/tests/test_rdg_winrm_actions.py @@ -109,3 +109,79 @@ def test_qwinsta_client_not_in_hosts(jwt_headers, client, db_session, monkeypatc response = client.post(f"/api/v1/events/{event.id}/actions/qwinsta", headers=jwt_headers) assert response.status_code == 404 assert "not found" in response.json()["detail"].lower() + + +def test_qwinsta_without_rdg_flap(jwt_headers, client, db_session, monkeypatch): + monkeypatch.setenv("SAC_WIN_ADMIN_USER", r"B26\admin") + monkeypatch.setenv("SAC_WIN_ADMIN_PASSWORD", "pw") + from app.config import get_settings + + get_settings.cache_clear() + + ws = Host( + hostname="Andrisonova-PC", + os_family="windows", + product="rdp-login-monitor", + ipv4="192.168.160.113", + ) + gw = Host(hostname="K6A-DC3", os_family="windows", product="rdp-login-monitor", ipv4="192.168.160.40") + db_session.add_all([ws, gw]) + db_session.commit() + + event = Event( + event_id="ev-rdg-plain", + host_id=gw.id, + occurred_at=datetime.now(timezone.utc), + received_at=datetime.now(timezone.utc), + category="auth", + type="rdg.connection.success", + severity="info", + title="302", + summary="", + payload={}, + details={"user": r"B26\papatramp", "internal_ip": ws.ipv4}, + ) + db_session.add(event) + db_session.commit() + + with patch("app.services.rdg_winrm_actions.run_winrm_on_host_targets") as mock_run: + mock_run.return_value = ( + WinRmCmdResult(ok=True, message="OK", target="Andrisonova-PC", stdout="ok", exit_code=0), + ["Andrisonova-PC=OK"], + ) + response = client.post(f"/api/v1/events/{event.id}/actions/qwinsta", headers=jwt_headers) + + assert response.status_code == 200 + mock_run.assert_called_once() + + +def test_qwinsta_rejects_rdg_without_internal_ip(jwt_headers, client, db_session, monkeypatch): + monkeypatch.setenv("SAC_WIN_ADMIN_USER", r"B26\admin") + monkeypatch.setenv("SAC_WIN_ADMIN_PASSWORD", "pw") + from app.config import get_settings + + get_settings.cache_clear() + + gw = Host(hostname="K6A-DC3", os_family="windows", product="rdp-login-monitor", ipv4="192.168.160.40") + db_session.add(gw) + db_session.commit() + + event = Event( + event_id="ev-rdg-no-ip", + host_id=gw.id, + occurred_at=datetime.now(timezone.utc), + received_at=datetime.now(timezone.utc), + category="auth", + type="rdg.connection.success", + severity="info", + title="302", + summary="", + payload={}, + details={"user": r"B26\user"}, + ) + db_session.add(event) + db_session.commit() + + response = client.post(f"/api/v1/events/{event.id}/actions/qwinsta", headers=jwt_headers) + assert response.status_code == 400 + assert "internal_ip" in response.json()["detail"].lower() diff --git a/deploy/env.native.example b/deploy/env.native.example index 5a048ae..94f4633 100644 --- a/deploy/env.native.example +++ b/deploy/env.native.example @@ -84,6 +84,9 @@ SAC_RDG_FLAP_WINDOW_MIN_SEC=1 SAC_RDG_FLAP_WINDOW_MAX_SEC=10 SAC_RDG_FLAP_DEDUP_SEC=30 +# IP HAProxy (или другого RDG-прокси), через который виден вход: external_ip в событии 302/303 +# SAC_RDG_HAPROXY_EXTERNAL_IPS=192.168.160.50,10.0.0.100 + # qwinsta/logoff на Windows-хостах (доменный admin) # SAC_WIN_ADMIN_USER=B26\\Administrator # SAC_WIN_ADMIN_PASSWORD= diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 64e0d07..b795f39 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -187,6 +187,8 @@ export interface EventSummary { rdg_flap?: boolean; rdg_flap_pair_event_id?: number | null; rdg_flap_qwinsta_event_id?: number | null; + rdg_access_path?: string | null; + rdg_qwinsta_enabled?: boolean; } export interface AgentCommandResponse { diff --git a/frontend/src/components/RdgAccessBadge.vue b/frontend/src/components/RdgAccessBadge.vue new file mode 100644 index 0000000..75a1885 --- /dev/null +++ b/frontend/src/components/RdgAccessBadge.vue @@ -0,0 +1,40 @@ + + + + + diff --git a/frontend/src/composables/useRdgQwinsta.ts b/frontend/src/composables/useRdgQwinsta.ts index 2c0d30c..129e8ec 100644 --- a/frontend/src/composables/useRdgQwinsta.ts +++ b/frontend/src/composables/useRdgQwinsta.ts @@ -6,6 +6,7 @@ import { type AgentCommandResponse, type EventSummary, } from "../api"; +import { rdgQwinstaEventId } from "../utils/rdgFlap"; export type QwinstaSessionRow = { sessionName: string; @@ -114,7 +115,7 @@ export function useRdgQwinsta() { } async function runQwinsta(event: EventSummary) { - const targetId = event.rdg_flap_qwinsta_event_id ?? event.id; + const targetId = rdgQwinstaEventId(event) ?? event.id; qwinstaLoadingId.value = event.id; qwinstaModal.value = { open: true, diff --git a/frontend/src/utils/rdgFlap.ts b/frontend/src/utils/rdgFlap.ts index fb10778..49eda75 100644 --- a/frontend/src/utils/rdgFlap.ts +++ b/frontend/src/utils/rdgFlap.ts @@ -4,12 +4,31 @@ export function hasRdgFlapUi(event: EventSummary): boolean { return Boolean(event.rdg_flap || event.rdg_flap_qwinsta_event_id); } -export function rdgFlapQwinstaEventId(event: EventSummary): number | null { +export function hasRdgAccessPath(event: EventSummary): boolean { + return Boolean(event.rdg_access_path); +} + +export function rdgAccessPathLabel(event: EventSummary): string { + return event.rdg_access_path || ""; +} + +export function rdgQwinstaEventId(event: EventSummary): number | null { if (event.rdg_flap_qwinsta_event_id) return event.rdg_flap_qwinsta_event_id; - return event.rdg_flap ? event.id : null; + if (event.rdg_qwinsta_enabled) return event.id; + if (event.rdg_flap) return event.id; + return null; +} + +/** @deprecated use rdgQwinstaEventId */ +export function rdgFlapQwinstaEventId(event: EventSummary): number | null { + return rdgQwinstaEventId(event); } export function rdgFlapPairLabel(event: EventSummary): string { if (!event.rdg_flap_pair_event_id) return ""; return event.type === "rdg.connection.success" ? "→" : "←"; } + +export function isRdgConnectionEvent(event: EventSummary): boolean { + return event.type.startsWith("rdg.connection."); +} diff --git a/frontend/src/version.ts b/frontend/src/version.ts index 56a34b0..3da26e5 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1,4 +1,4 @@ /** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */ export const APP_NAME = "Security Alert Center"; -export const APP_VERSION = "0.3.0"; +export const APP_VERSION = "0.3.1"; 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 8276ce4..17d19d0 100644 --- a/frontend/src/views/DashboardView.vue +++ b/frontend/src/views/DashboardView.vue @@ -282,6 +282,7 @@ {{ e.severity }} + {{ e.title }} @@ -289,13 +290,14 @@