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).
This commit is contained in:
PTah
2026-07-14 10:30:13 +10:00
parent 52125a5a98
commit 985d51c4ba
13 changed files with 97 additions and 7 deletions
+1 -1
View File
@@ -13,7 +13,7 @@
| **security-alert-center** | Сервер SAC (Ubuntu 24.04) | | **security-alert-center** | Сервер SAC (Ubuntu 24.04) |
| [seaca](https://git.kalinamall.ru/PapaTramp/seaca) | Android-клиент | | [seaca](https://git.kalinamall.ru/PapaTramp/seaca) | Android-клиент |
**Версия:** `0.5.15` · **Деплой:** `sudo /opt/sac-deploy.sh` **Версия:** `0.5.16` · **Деплой:** `sudo /opt/sac-deploy.sh`
## Возможности ## Возможности
+1 -1
View File
@@ -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) | | **security-alert-center** | SAC server (Ubuntu 24.04) |
| [seaca](https://git.kalinamall.ru/PapaTramp/seaca) | Android client | | [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 ## Features
+1
View File
@@ -78,6 +78,7 @@ class EventSummary(BaseModel):
title: str title: str
summary: str summary: str
actor_user: str | None = None actor_user: str | None = None
session_duration_sec: int | None = None
rdg_flap: bool = False rdg_flap: bool = False
rdg_flap_pair_event_id: int | None = None rdg_flap_pair_event_id: int | None = None
rdg_flap_qwinsta_event_id: int | None = None rdg_flap_qwinsta_event_id: int | None = None
+4
View File
@@ -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.host_sessions import event_session_terminated
from app.services.rdg_display import build_rdg_display from app.services.rdg_display import build_rdg_display
from app.services.rdg_session_flap import resolve_rdg_flap_summary 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: 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, title=title,
summary=summary, summary=summary,
actor_user=extract_event_actor_user(event.type, event.details), 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=rdg_flap,
rdg_flap_pair_event_id=rdg_flap_pair_event_id, rdg_flap_pair_event_id=rdg_flap_pair_event_id,
rdg_flap_qwinsta_event_id=rdg_flap_qwinsta_event_id, rdg_flap_qwinsta_event_id=rdg_flap_qwinsta_event_id,
+37
View File
@@ -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
+5 -3
View File
@@ -564,9 +564,11 @@ def _format_rdg_html(event: Event) -> str:
err = _detail(details, "gateway_error_code", "error_code", default="") err = _detail(details, "gateway_error_code", "error_code", default="")
if err != "-": if err != "-":
msg += _line("⚠️", "Код ошибки", html_escape(err)) msg += _line("⚠️", "Код ошибки", html_escape(err))
dur = _detail(details, "session_duration_sec", default="") from app.services.session_duration import extract_session_duration_sec, format_session_duration
if dur not in ("-", "0", ""):
msg += _line("⏱️", "Длительность", f"{html_escape(dur)} с") 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)) msg += _line("🕐", "Время", format_time(event.occurred_at))
win_id = _detail(details, "event_id_windows", default="") win_id = _detail(details, "event_id_windows", default="")
if win_id != "-": if win_id != "-":
+1 -1
View File
@@ -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.5.15" APP_VERSION = "0.5.16"
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}" APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
+20
View File
@@ -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"
+1
View File
@@ -192,6 +192,7 @@ export interface EventSummary {
title: string; title: string;
summary: string; summary: string;
actor_user?: string | null; actor_user?: string | null;
session_duration_sec?: number | null;
rdg_flap?: boolean; rdg_flap?: boolean;
rdg_flap_pair_event_id?: number | null; rdg_flap_pair_event_id?: number | null;
rdg_flap_qwinsta_event_id?: number | null; rdg_flap_qwinsta_event_id?: number | null;
+17
View File
@@ -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;
}
+1 -1
View File
@@ -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.5.15"; export const APP_VERSION = "0.5.16";
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`; export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
+5
View File
@@ -238,6 +238,8 @@
<th>Пользователь</th> <th>Пользователь</th>
<th title="Длительность сессии (из session_duration_sec)">Длительность</th>
<th>Версия агента</th> <th>Версия агента</th>
<th>Severity</th> <th>Severity</th>
@@ -276,6 +278,8 @@
<td>{{ e.actor_user || "—" }}</td> <td>{{ e.actor_user || "—" }}</td>
<td>{{ formatSessionDuration(e.session_duration_sec) }}</td>
<td>{{ e.product_version || "—" }}</td> <td>{{ e.product_version || "—" }}</td>
<td :class="'sev-' + e.severity">{{ e.severity }}</td> <td :class="'sev-' + e.severity">{{ e.severity }}</td>
@@ -354,6 +358,7 @@ import RdgQwinstaModal from "../components/RdgQwinstaModal.vue";
import { useRdgQwinsta } from "../composables/useRdgQwinsta"; import { useRdgQwinsta } from "../composables/useRdgQwinsta";
import { formatServerName } from "../utils/hostDisplay"; import { formatServerName } from "../utils/hostDisplay";
import { hasRdgFlapUi, rdgQwinstaEventId } from "../utils/rdgFlap"; import { hasRdgFlapUi, rdgQwinstaEventId } from "../utils/rdgFlap";
import { formatSessionDuration } from "../utils/sessionDuration";
import { markEventSessionTerminatedInList, showEventSessionTerminateButton } from "../utils/sessionActions"; import { markEventSessionTerminatedInList, showEventSessionTerminateButton } from "../utils/sessionActions";
const { qwinstaLoadingId, qwinstaModal, closeQwinstaModal, runQwinsta, runLogoff } = useRdgQwinsta(); const { qwinstaLoadingId, qwinstaModal, closeQwinstaModal, runQwinsta, runLogoff } = useRdgQwinsta();
+3
View File
@@ -34,6 +34,7 @@
<th>Хост</th> <th>Хост</th>
<th>Имя сервера</th> <th>Имя сервера</th>
<th>Пользователь</th> <th>Пользователь</th>
<th title="Длительность сессии (из session_duration_sec)">Длительность</th>
<th>Версия агента</th> <th>Версия агента</th>
<th>Severity</th> <th>Severity</th>
<th>Type</th> <th>Type</th>
@@ -50,6 +51,7 @@
<td>{{ e.hostname }}</td> <td>{{ e.hostname }}</td>
<td>{{ formatServerName(e.display_name) }}</td> <td>{{ formatServerName(e.display_name) }}</td>
<td>{{ e.actor_user || "—" }}</td> <td>{{ e.actor_user || "—" }}</td>
<td>{{ formatSessionDuration(e.session_duration_sec) }}</td>
<td>{{ e.product_version || "—" }}</td> <td>{{ e.product_version || "—" }}</td>
<td :class="'sev-' + e.severity">{{ e.severity }}</td> <td :class="'sev-' + e.severity">{{ e.severity }}</td>
<td><code>{{ e.type }}</code></td> <td><code>{{ e.type }}</code></td>
@@ -120,6 +122,7 @@ import {
type EventsListState, type EventsListState,
} from "../utils/eventsListQuery"; } from "../utils/eventsListQuery";
import { formatServerName } from "../utils/hostDisplay"; import { formatServerName } from "../utils/hostDisplay";
import { formatSessionDuration } from "../utils/sessionDuration";
import { markEventSessionTerminatedInList, showEventSessionTerminateButton } from "../utils/sessionActions"; import { markEventSessionTerminatedInList, showEventSessionTerminateButton } from "../utils/sessionActions";
const route = useRoute(); const route = useRoute();