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 @@
- {{ plainBody }}
- {{ plainBody }}
+
{{ summary }}