From 985d51c4ba521a9cdc1032c1b5449beda47de13c Mon Sep 17 00:00:00 2001 From: PTah Date: Tue, 14 Jul 2026 10:30:13 +1000 Subject: [PATCH] feat: show RDP session duration in Overview and Events (0.5.16) Add session_duration_sec to EventSummary and a Duration column (HH:MM:SS / Nd HH:MM:SS). --- README.md | 2 +- README_en.md | 2 +- backend/app/schemas/list_models.py | 1 + backend/app/services/event_summary.py | 4 +++ backend/app/services/session_duration.py | 37 ++++++++++++++++++++++ backend/app/services/telegram_templates.py | 8 +++-- backend/app/version.py | 2 +- backend/tests/test_session_duration.py | 20 ++++++++++++ frontend/src/api.ts | 1 + frontend/src/utils/sessionDuration.ts | 17 ++++++++++ frontend/src/version.ts | 2 +- frontend/src/views/DashboardView.vue | 5 +++ frontend/src/views/EventsView.vue | 3 ++ 13 files changed, 97 insertions(+), 7 deletions(-) create mode 100644 backend/app/services/session_duration.py create mode 100644 backend/tests/test_session_duration.py create mode 100644 frontend/src/utils/sessionDuration.ts diff --git a/README.md b/README.md index c272dce..dffac32 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ | **security-alert-center** | Сервер SAC (Ubuntu 24.04) | | [seaca](https://git.kalinamall.ru/PapaTramp/seaca) | Android-клиент | -**Версия:** `0.5.15` · **Деплой:** `sudo /opt/sac-deploy.sh` +**Версия:** `0.5.16` · **Деплой:** `sudo /opt/sac-deploy.sh` ## Возможности diff --git a/README_en.md b/README_en.md index d30cab4..da713f1 100644 --- a/README_en.md +++ b/README_en.md @@ -13,7 +13,7 @@ Self-hosted hub for security events from Linux and Windows agents: ingest, corre | **security-alert-center** | SAC server (Ubuntu 24.04) | | [seaca](https://git.kalinamall.ru/PapaTramp/seaca) | Android client | -**Version:** `0.5.15` · **Deploy:** `sudo /opt/sac-deploy.sh` +**Version:** `0.5.16` · **Deploy:** `sudo /opt/sac-deploy.sh` ## Features diff --git a/backend/app/schemas/list_models.py b/backend/app/schemas/list_models.py index 793a4df..665d92e 100644 --- a/backend/app/schemas/list_models.py +++ b/backend/app/schemas/list_models.py @@ -78,6 +78,7 @@ class EventSummary(BaseModel): title: str summary: str actor_user: str | None = None + session_duration_sec: int | None = None rdg_flap: bool = False rdg_flap_pair_event_id: int | None = None rdg_flap_qwinsta_event_id: int | None = None diff --git a/backend/app/services/event_summary.py b/backend/app/services/event_summary.py index 0c49bf9..24453d3 100644 --- a/backend/app/services/event_summary.py +++ b/backend/app/services/event_summary.py @@ -6,6 +6,7 @@ from app.services.event_actor_user import extract_event_actor_user from app.services.host_sessions import event_session_terminated from app.services.rdg_display import build_rdg_display from app.services.rdg_session_flap import resolve_rdg_flap_summary +from app.services.session_duration import extract_session_duration_sec def event_to_summary(event: Event, db: Session | None = None) -> EventSummary: @@ -44,6 +45,9 @@ def event_to_summary(event: Event, db: Session | None = None) -> EventSummary: title=title, summary=summary, actor_user=extract_event_actor_user(event.type, event.details), + session_duration_sec=extract_session_duration_sec( + event.details if isinstance(event.details, dict) else None + ), rdg_flap=rdg_flap, rdg_flap_pair_event_id=rdg_flap_pair_event_id, rdg_flap_qwinsta_event_id=rdg_flap_qwinsta_event_id, diff --git a/backend/app/services/session_duration.py b/backend/app/services/session_duration.py new file mode 100644 index 0000000..c2ae3ed --- /dev/null +++ b/backend/app/services/session_duration.py @@ -0,0 +1,37 @@ +"""Format and extract RDP/RDG session_duration_sec for UI.""" + +from __future__ import annotations + +from typing import Any + + +def extract_session_duration_sec(details: dict[str, Any] | None) -> int | None: + if not isinstance(details, dict): + return None + raw = details.get("session_duration_sec") + if raw is None or raw == "": + return None + try: + value = int(raw) + except (TypeError, ValueError): + return None + if value < 0: + return None + return value + + +def format_session_duration(seconds: int) -> str: + """ + Human-readable session length for lists: + - under 24h → HH:MM:SS (05:05:24) + - 24h+ → Nд HH:MM:SS (1д 00:01:25) + """ + if seconds < 0: + return "—" + days, rem = divmod(int(seconds), 86_400) + hours, rem = divmod(rem, 3600) + minutes, secs = divmod(rem, 60) + clock = f"{hours:02d}:{minutes:02d}:{secs:02d}" + if days: + return f"{days}д {clock}" + return clock diff --git a/backend/app/services/telegram_templates.py b/backend/app/services/telegram_templates.py index a46fe19..9475ba6 100644 --- a/backend/app/services/telegram_templates.py +++ b/backend/app/services/telegram_templates.py @@ -564,9 +564,11 @@ def _format_rdg_html(event: Event) -> str: err = _detail(details, "gateway_error_code", "error_code", default="") if err != "-": msg += _line("⚠️", "Код ошибки", html_escape(err)) - dur = _detail(details, "session_duration_sec", default="") - if dur not in ("-", "0", ""): - msg += _line("⏱️", "Длительность", f"{html_escape(dur)} с") + from app.services.session_duration import extract_session_duration_sec, format_session_duration + + dur_sec = extract_session_duration_sec(details if isinstance(details, dict) else None) + if dur_sec is not None and dur_sec > 0: + msg += _line("⏱️", "Длительность", html_escape(format_session_duration(dur_sec))) msg += _line("🕐", "Время", format_time(event.occurred_at)) win_id = _detail(details, "event_id_windows", default="") if win_id != "-": diff --git a/backend/app/version.py b/backend/app/version.py index 5d31c16..8fd3b02 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.5.15" +APP_VERSION = "0.5.16" APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}" diff --git a/backend/tests/test_session_duration.py b/backend/tests/test_session_duration.py new file mode 100644 index 0000000..9827450 --- /dev/null +++ b/backend/tests/test_session_duration.py @@ -0,0 +1,20 @@ +"""Tests for session_duration_sec extract/format.""" + +from app.services.session_duration import extract_session_duration_sec, format_session_duration + + +def test_extract_session_duration_sec(): + assert extract_session_duration_sec({"session_duration_sec": 18324}) == 18324 + assert extract_session_duration_sec({"session_duration_sec": "42"}) == 42 + assert extract_session_duration_sec({"session_duration_sec": ""}) is None + assert extract_session_duration_sec({}) is None + assert extract_session_duration_sec(None) is None + assert extract_session_duration_sec({"session_duration_sec": -1}) is None + + +def test_format_session_duration(): + assert format_session_duration(0) == "00:00:00" + assert format_session_duration(18324) == "05:05:24" + assert format_session_duration(2428) == "00:40:28" + assert format_session_duration(86400 + 85) == "1д 00:01:25" + assert format_session_duration(2 * 86400 + 3661) == "2д 01:01:01" diff --git a/frontend/src/api.ts b/frontend/src/api.ts index a3c51ce..ff23332 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -192,6 +192,7 @@ export interface EventSummary { title: string; summary: string; actor_user?: string | null; + session_duration_sec?: number | null; rdg_flap?: boolean; rdg_flap_pair_event_id?: number | null; rdg_flap_qwinsta_event_id?: number | null; diff --git a/frontend/src/utils/sessionDuration.ts b/frontend/src/utils/sessionDuration.ts new file mode 100644 index 0000000..a23607b --- /dev/null +++ b/frontend/src/utils/sessionDuration.ts @@ -0,0 +1,17 @@ +"""Human-readable session_duration_sec for event tables.""" + +export function formatSessionDuration(seconds: number | null | undefined): string { + if (seconds == null || !Number.isFinite(seconds) || seconds < 0) { + return "—"; + } + const total = Math.floor(seconds); + const days = Math.floor(total / 86_400); + const hours = Math.floor((total % 86_400) / 3600); + const minutes = Math.floor((total % 3600) / 60); + const secs = total % 60; + const clock = `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(secs).padStart(2, "0")}`; + if (days > 0) { + return `${days}д ${clock}`; + } + return clock; +} diff --git a/frontend/src/version.ts b/frontend/src/version.ts index 0ba83bb..8b21f6a 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.5.15"; +export const APP_VERSION = "0.5.16"; 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 dba02f1..91402cc 100644 --- a/frontend/src/views/DashboardView.vue +++ b/frontend/src/views/DashboardView.vue @@ -238,6 +238,8 @@ Пользователь + Длительность + Версия агента Severity @@ -276,6 +278,8 @@ {{ e.actor_user || "—" }} + {{ formatSessionDuration(e.session_duration_sec) }} + {{ e.product_version || "—" }} {{ e.severity }} @@ -354,6 +358,7 @@ import RdgQwinstaModal from "../components/RdgQwinstaModal.vue"; import { useRdgQwinsta } from "../composables/useRdgQwinsta"; import { formatServerName } from "../utils/hostDisplay"; import { hasRdgFlapUi, rdgQwinstaEventId } from "../utils/rdgFlap"; +import { formatSessionDuration } from "../utils/sessionDuration"; import { markEventSessionTerminatedInList, showEventSessionTerminateButton } from "../utils/sessionActions"; const { qwinstaLoadingId, qwinstaModal, closeQwinstaModal, runQwinsta, runLogoff } = useRdgQwinsta(); diff --git a/frontend/src/views/EventsView.vue b/frontend/src/views/EventsView.vue index 1c6bfc4..513cc27 100644 --- a/frontend/src/views/EventsView.vue +++ b/frontend/src/views/EventsView.vue @@ -34,6 +34,7 @@ Хост Имя сервера Пользователь + Длительность Версия агента Severity Type @@ -50,6 +51,7 @@ {{ e.hostname }} {{ formatServerName(e.display_name) }} {{ e.actor_user || "—" }} + {{ formatSessionDuration(e.session_duration_sec) }} {{ e.product_version || "—" }} {{ e.severity }} {{ e.type }} @@ -120,6 +122,7 @@ import { type EventsListState, } from "../utils/eventsListQuery"; import { formatServerName } from "../utils/hostDisplay"; +import { formatSessionDuration } from "../utils/sessionDuration"; import { markEventSessionTerminatedInList, showEventSessionTerminateButton } from "../utils/sessionActions"; const route = useRoute();