From 2501ecc4feb793207319a881bf790a6a2b979ae3 Mon Sep 17 00:00:00 2001 From: PTah Date: Sat, 30 May 2026 09:52:09 +1000 Subject: [PATCH] fix: normalize daily report layout for Telegram and UI Ensure server line, compact spacing, and one user per row when ingesting agent reports or sending SAC-generated daily reports. Co-authored-by: Cursor --- backend/app/services/daily_report.py | 8 +- backend/app/services/daily_report_format.py | 155 +++++++++++++++++++- backend/app/services/ingest.py | 8 +- backend/app/services/telegram_templates.py | 20 ++- backend/tests/test_daily_report_format.py | 48 +++++- frontend/src/components/ReportBodyCard.vue | 6 +- 6 files changed, 223 insertions(+), 22 deletions(-) diff --git a/backend/app/services/daily_report.py b/backend/app/services/daily_report.py index 471cba7..f81aabe 100644 --- a/backend/app/services/daily_report.py +++ b/backend/app/services/daily_report.py @@ -2,7 +2,6 @@ from __future__ import annotations -import html import logging import uuid from collections import Counter @@ -19,8 +18,10 @@ from app.models import Event, Host from app.services.daily_report_format import ( RDP_BAN_TYPES, SSH_BAN_TYPES, + body_to_report_html, build_report_body, enrich_stats_for_storage, + normalize_daily_report_details, ) from app.services.ingest import ingest_event from app.services.notify_dispatch import notify_daily_report @@ -234,14 +235,11 @@ def _build_report_body_rdp(host: Host, stats: dict[str, Any], when_local: dateti def _details_from_body(body: str, stats: dict[str, Any]) -> dict[str, Any]: - escaped = html.escape(body) - # UI (Vue) допускает div; для единообразия — обёртка без br (Telegram читает через sanitize) - report_html = f'
{escaped.replace(chr(10), "
")}
' return { "stats": stats, "report_body": body, "report_format": "plain", - "report_html": report_html, + "report_html": body_to_report_html(body), "generated_by": "sac", } diff --git a/backend/app/services/daily_report_format.py b/backend/app/services/daily_report_format.py index 9833175..b409802 100644 --- a/backend/app/services/daily_report_format.py +++ b/backend/app/services/daily_report_format.py @@ -2,6 +2,8 @@ from __future__ import annotations +import html +import re from datetime import datetime from typing import Any, Literal @@ -12,8 +14,14 @@ Platform = Literal["ssh", "windows"] RDP_BAN_TYPES = frozenset({"rdp.ip.banned", "rdp.ip.ban"}) SSH_BAN_TYPES = frozenset({"ssh.ip.banned"}) +_SERVER_LINE_RE = re.compile(r"(?m)^🖥️\s*Сервер\s*:") +_ACTIVE_USERS_HEADER_RE = re.compile(r"^👥\s*АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ") +_SECTION_HEADER_RE = re.compile(r"^[📈🧾👥🖥️🕐]") -def host_server_line(host: Host) -> str: + +def host_server_line(host: Host | None) -> str: + if host is None: + return "unknown" label = (host.display_name or host.hostname or "unknown").strip() ip = (host.ipv4 or "").strip() if ip: @@ -21,8 +29,115 @@ def host_server_line(host: Host) -> str: return label +def collapse_blank_lines(text: str, *, max_run: int = 1) -> str: + lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n") + out: list[str] = [] + blank_run = 0 + for line in lines: + if not line.strip(): + blank_run += 1 + if blank_run <= max_run: + out.append("") + continue + blank_run = 0 + out.append(line.rstrip()) + return "\n".join(out).strip() + + +def split_active_user_tokens(entry: str) -> list[str]: + """Разбивает «👤 u1 👤 u2» на отдельные строки (как в отчёте агента).""" + entry = entry.strip() + if not entry: + return [] + if entry.count("👤") <= 1: + line = entry if entry.startswith("👤") else f"👤 {entry}" + return [f" {line}"] + parts = re.split(r"(?=👤)", entry) + result: list[str] = [] + for part in parts: + part = part.strip() + if not part: + continue + if not part.startswith("👤"): + part = f"👤 {part}" + result.append(f" {part}") + return result + + +def normalize_active_users_list(users: list[Any] | None) -> list[str]: + out: list[str] = [] + seen: set[str] = set() + for raw in users or []: + for line in split_active_user_tokens(str(raw)): + key = line.strip().lower() + if key and key not in seen: + seen.add(key) + out.append(line) + return out + + +def ensure_server_line(body: str, host: Host | None) -> str: + server = host_server_line(host) + if not server or server == "unknown": + return body + if _SERVER_LINE_RE.search(body): + return body + lines = body.replace("\r\n", "\n").split("\n") + for i, line in enumerate(lines): + if "ЕЖЕДНЕВНЫЙ ОТЧЕТ" in line: + lines.insert(i + 1, f"🖥️ Сервер: {server}") + return "\n".join(lines) + return f"🖥️ Сервер: {server}\n{body}" + + +def normalize_active_users_in_body(body: str) -> str: + lines = body.replace("\r\n", "\n").split("\n") + out: list[str] = [] + i = 0 + while i < len(lines): + line = lines[i] + if _ACTIVE_USERS_HEADER_RE.match(line.strip()): + out.append(line) + i += 1 + user_lines: list[str] = [] + while i < len(lines): + cur = lines[i] + stripped = cur.strip() + if not stripped: + break + if stripped.startswith("Источник:"): + break + if _SECTION_HEADER_RE.match(stripped) and not stripped.startswith("👤"): + break + if stripped.startswith("(нет данных"): + out.append(cur) + i += 1 + break + user_lines.extend(split_active_user_tokens(cur)) + i += 1 + if user_lines: + out.extend(user_lines) + continue + out.append(line) + i += 1 + return "\n".join(out) + + +def normalize_report_body(body: str, host: Host | None, platform: Platform) -> str: + """Приводит текст отчёта (агент/SAC) к единому компактному виду.""" + text = collapse_blank_lines(body.replace("\r\n", "\n")) + text = ensure_server_line(text, host) + text = normalize_active_users_in_body(text) + return collapse_blank_lines(text) + + +def body_to_report_html(body: str) -> str: + escaped = html.escape(body) + return f'
{escaped.replace(chr(10), "
")}
' + + def _section_top_ips(top: list[str]) -> list[str]: - lines = ["", "🧾 ТОП-5 IP ПО НЕУДАЧНЫМ ПОПЫТКАМ:"] + lines = [" 🧾 ТОП-5 IP ПО НЕУДАЧНЫМ ПОПЫТКАМ:"] if top: lines.extend(f" {line}" if not line.startswith(" ") else line for line in top[:5]) else: @@ -32,7 +147,7 @@ def _section_top_ips(top: list[str]) -> list[str]: def _section_active_users(users: list[str], *, sac_generated: bool) -> list[str]: count = len(users) - lines = ["", f"👥 АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ ({count}):"] + lines = [f" 👥 АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ ({count}):"] if users: for line in users: lines.append(f" {line}" if not line.startswith(" ") else line) @@ -57,7 +172,7 @@ def build_report_body( server = host_server_line(host) time_str = when_local.strftime("%d.%m.%Y %H:%M:%S") top = list(stats.get("top_failed_ips") or [])[:5] - active_users = list(stats.get("active_users") or []) + active_users = normalize_active_users_list(list(stats.get("active_users") or [])) if platform == "ssh": title = "📊 ЕЖЕДНЕВНЫЙ ОТЧЕТ SSH МОНИТОРИНГА" @@ -70,7 +185,7 @@ def build_report_body( f"🖥️ Сервер: {server}", f"🕐 Время отчета: {time_str}", "", - "📈 СТАТИСТИКА ЗА ПОСЛЕДНИЕ 24 ЧАСА:", + " 📈 СТАТИСТИКА ЗА ПОСЛЕДНИЕ 24 ЧАСА:", f" ✅ Успешных SSH подключений: {ok}", f" ❌ Неудачных попыток SSH: {fail}", f" ⚠️ Команд через sudo: {sudo}", @@ -86,13 +201,15 @@ def build_report_body( f"🖥️ Сервер: {server}", f"🕐 Время отчета: {time_str}", "", - "📈 СТАТИСТИКА ЗА ПОСЛЕДНИЕ 24 ЧАСА:", + " 📈 СТАТИСТИКА ЗА ПОСЛЕДНИЕ 24 ЧАСА:", f" ✅ Успешных RDP подключений: {ok}", f" ❌ Неудачных попыток RDP: {fail}", f" 🚫 Активных банов: {bans}", ] + lines.append("") lines.extend(_section_top_ips(top)) + lines.append("") lines.extend(_section_active_users(active_users, sac_generated=sac_generated)) if sac_generated: @@ -122,4 +239,30 @@ def enrich_stats_for_storage(platform: Platform, stats: dict[str, Any]) -> dict[ if "unique_users" in out and "active_users" not in out: users = out.get("unique_users") or [] out["active_users"] = [f"👤 {u}" if not str(u).startswith("👤") else str(u) for u in users] + if "active_users" in out: + out["active_users"] = normalize_active_users_list(list(out.get("active_users") or [])) + return out + + +def normalize_daily_report_details( + details: dict[str, Any] | None, + host: Host | None, + report_type: str, +) -> dict[str, Any] | None: + if not isinstance(details, dict): + return details + platform: Platform = "windows" if report_type == "report.daily.rdp" else "ssh" + body = details.get("report_body") + if not isinstance(body, str) or not body.strip(): + return details + + normalized_body = normalize_report_body(body, host, platform) + out = dict(details) + out["report_body"] = normalized_body + out["report_html"] = body_to_report_html(normalized_body) + + stats = out.get("stats") + if isinstance(stats, dict): + stats = enrich_stats_for_storage(platform, dict(stats)) + out["stats"] = stats return out diff --git a/backend/app/services/ingest.py b/backend/app/services/ingest.py index 702c9de..747bad0 100644 --- a/backend/app/services/ingest.py +++ b/backend/app/services/ingest.py @@ -5,6 +5,9 @@ from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from app.models import Event, Host +from app.services.daily_report_format import normalize_daily_report_details + +DAILY_REPORT_TYPES = frozenset({"report.daily.ssh", "report.daily.rdp"}) def _parse_dt(value: str) -> datetime: @@ -72,6 +75,9 @@ def ingest_event(db: Session, payload: dict) -> tuple[Event, bool]: return existing, False host = upsert_host(db, payload) + details = payload.get("details") + if payload.get("type") in DAILY_REPORT_TYPES: + details = normalize_daily_report_details(details, host, payload["type"]) event = Event( event_id=event_id, host_id=host.id, @@ -81,7 +87,7 @@ def ingest_event(db: Session, payload: dict) -> tuple[Event, bool]: severity=payload["severity"], title=payload["title"], summary=payload["summary"], - details=payload.get("details"), + details=details, raw=payload.get("raw"), dedup_key=payload.get("dedup_key"), correlation_id=payload.get("correlation_id"), diff --git a/backend/app/services/telegram_templates.py b/backend/app/services/telegram_templates.py index 0907eeb..3906550 100644 --- a/backend/app/services/telegram_templates.py +++ b/backend/app/services/telegram_templates.py @@ -8,6 +8,7 @@ from datetime import datetime from typing import Any from app.models import Event, Host, Problem +from app.services.daily_report_format import normalize_report_body LOGON_TYPE_NAMES: dict[int, str] = { 2: "Интерактивный (консоль)", @@ -181,13 +182,26 @@ def format_generic_event_html(event: Event) -> str: def format_daily_report_html(event: Event) -> str: details = _details_dict(event) + platform = "windows" if event.type == "report.daily.rdp" else "ssh" + body = _detail(details, "report_body", default="") + if body and body != "-": + body = normalize_report_body(body, event.host, platform) + parts: list[str] = [] + for i, line in enumerate(body.split("\n")): + esc = html.escape(line) + if i == 0 and "ЕЖЕДНЕВНЫЙ ОТЧЕТ" in line: + parts.append(f"{esc.strip()}") + else: + parts.append(esc) + return "\n".join(parts) + report_html = details.get("report_html") if isinstance(report_html, str) and report_html.strip(): - return sanitize_telegram_html(report_html.strip()) + text = sanitize_telegram_html(report_html.strip()) + return re.sub(r"\n{3,}", "\n\n", text) body = _detail(details, "report_body", default=event.summary) if body != "-": - escaped = html.escape(body) - return f"📊 {html_escape(event.title)}\n{escaped}" + return html.escape(body) return format_generic_event_html(event) diff --git a/backend/tests/test_daily_report_format.py b/backend/tests/test_daily_report_format.py index 125beca..9e8ec1a 100644 --- a/backend/tests/test_daily_report_format.py +++ b/backend/tests/test_daily_report_format.py @@ -3,7 +3,12 @@ from datetime import datetime, timezone from app.models import Host -from app.services.daily_report_format import build_report_body, enrich_stats_for_storage +from app.services.daily_report_format import ( + build_report_body, + enrich_stats_for_storage, + normalize_active_users_list, + normalize_report_body, +) def test_build_report_body_ssh_matches_agent_layout(): @@ -30,10 +35,12 @@ def test_build_report_body_ssh_matches_agent_layout(): ) body = build_report_body("ssh", host, stats, when, sac_generated=True) assert "ЕЖЕДНЕВНЫЙ ОТЧЕТ SSH МОНИТОРИНГА" in body - assert "HaProxy Kalina (192.168.160.117)" in body + assert "🖥️ Сервер: HaProxy Kalina (192.168.160.117)" in body + assert " 📈 СТАТИСТИКА" in body assert "Команд через sudo: 5" in body - assert "АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ (1)" in body + assert " 👥 АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ (1)" in body assert "papatramp" in body + assert "\n\n\n" not in body def test_build_report_body_windows_layout(): @@ -59,5 +66,38 @@ def test_build_report_body_windows_layout(): assert "ЕЖЕДНЕВНЫЙ ОТЧЕТ МОНИТОРИНГА WINDOWS" in body assert "K6A-DC3 (10.0.0.10)" in body assert "Успешных RDP подключений: 1" in body - assert "АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ (2)" in body + assert " 👥 АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ (2)" in body assert "user2" in body + lines = [ln for ln in body.split("\n") if ln.strip().startswith("👤")] + assert len(lines) == 2 + + +def test_normalize_active_users_list_splits_combined_line(): + users = normalize_active_users_list(["👤 k.khodasevich 👤 papatramp"]) + assert len(users) == 2 + assert users[0].strip().startswith("👤 k.khodasevich") + assert users[1].strip().startswith("👤 papatramp") + + +def test_normalize_report_body_adds_server_and_collapses_blanks(): + host = Host(hostname="srv", display_name="Unimus Kalina", ipv4="192.168.160.17") + raw = "\n".join( + [ + "📊 ЕЖЕДНЕВНЫЙ ОТЧЕТ SSH МОНИТОРИНГА", + "", + "", + "🕐 Время отчета: 30.05.2026 09:00:00", + "", + "", + " 📈 СТАТИСТИКА ЗА ПОСЛЕДНИЕ 24 ЧАСА:", + " ✅ Успешных SSH подключений: 0", + "", + " 👥 АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ (2):", + " 👤 u1 👤 u2", + ] + ) + body = normalize_report_body(raw, host, "ssh") + assert "🖥️ Сервер: Unimus Kalina (192.168.160.17)" in body + assert "\n\n\n" not in body + user_lines = [ln for ln in body.split("\n") if ln.strip().startswith("👤")] + assert len(user_lines) == 2 diff --git a/frontend/src/components/ReportBodyCard.vue b/frontend/src/components/ReportBodyCard.vue index e1f97a1..965e758 100644 --- a/frontend/src/components/ReportBodyCard.vue +++ b/frontend/src/components/ReportBodyCard.vue @@ -7,13 +7,13 @@ -