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 <cursoragent@cursor.com>
This commit is contained in:
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import html
|
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
@@ -19,8 +18,10 @@ from app.models import Event, Host
|
|||||||
from app.services.daily_report_format import (
|
from app.services.daily_report_format import (
|
||||||
RDP_BAN_TYPES,
|
RDP_BAN_TYPES,
|
||||||
SSH_BAN_TYPES,
|
SSH_BAN_TYPES,
|
||||||
|
body_to_report_html,
|
||||||
build_report_body,
|
build_report_body,
|
||||||
enrich_stats_for_storage,
|
enrich_stats_for_storage,
|
||||||
|
normalize_daily_report_details,
|
||||||
)
|
)
|
||||||
from app.services.ingest import ingest_event
|
from app.services.ingest import ingest_event
|
||||||
from app.services.notify_dispatch import notify_daily_report
|
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]:
|
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'<div class="agent-report">{escaped.replace(chr(10), "<br>")}</div>'
|
|
||||||
return {
|
return {
|
||||||
"stats": stats,
|
"stats": stats,
|
||||||
"report_body": body,
|
"report_body": body,
|
||||||
"report_format": "plain",
|
"report_format": "plain",
|
||||||
"report_html": report_html,
|
"report_html": body_to_report_html(body),
|
||||||
"generated_by": "sac",
|
"generated_by": "sac",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import html
|
||||||
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
@@ -12,8 +14,14 @@ Platform = Literal["ssh", "windows"]
|
|||||||
RDP_BAN_TYPES = frozenset({"rdp.ip.banned", "rdp.ip.ban"})
|
RDP_BAN_TYPES = frozenset({"rdp.ip.banned", "rdp.ip.ban"})
|
||||||
SSH_BAN_TYPES = frozenset({"ssh.ip.banned"})
|
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()
|
label = (host.display_name or host.hostname or "unknown").strip()
|
||||||
ip = (host.ipv4 or "").strip()
|
ip = (host.ipv4 or "").strip()
|
||||||
if ip:
|
if ip:
|
||||||
@@ -21,8 +29,115 @@ def host_server_line(host: Host) -> str:
|
|||||||
return label
|
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'<div class="agent-report">{escaped.replace(chr(10), "<br>")}</div>'
|
||||||
|
|
||||||
|
|
||||||
def _section_top_ips(top: list[str]) -> list[str]:
|
def _section_top_ips(top: list[str]) -> list[str]:
|
||||||
lines = ["", "🧾 ТОП-5 IP ПО НЕУДАЧНЫМ ПОПЫТКАМ:"]
|
lines = [" 🧾 ТОП-5 IP ПО НЕУДАЧНЫМ ПОПЫТКАМ:"]
|
||||||
if top:
|
if top:
|
||||||
lines.extend(f" {line}" if not line.startswith(" ") else line for line in top[:5])
|
lines.extend(f" {line}" if not line.startswith(" ") else line for line in top[:5])
|
||||||
else:
|
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]:
|
def _section_active_users(users: list[str], *, sac_generated: bool) -> list[str]:
|
||||||
count = len(users)
|
count = len(users)
|
||||||
lines = ["", f"👥 АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ ({count}):"]
|
lines = [f" 👥 АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ ({count}):"]
|
||||||
if users:
|
if users:
|
||||||
for line in users:
|
for line in users:
|
||||||
lines.append(f" {line}" if not line.startswith(" ") else line)
|
lines.append(f" {line}" if not line.startswith(" ") else line)
|
||||||
@@ -57,7 +172,7 @@ def build_report_body(
|
|||||||
server = host_server_line(host)
|
server = host_server_line(host)
|
||||||
time_str = when_local.strftime("%d.%m.%Y %H:%M:%S")
|
time_str = when_local.strftime("%d.%m.%Y %H:%M:%S")
|
||||||
top = list(stats.get("top_failed_ips") or [])[:5]
|
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":
|
if platform == "ssh":
|
||||||
title = "📊 ЕЖЕДНЕВНЫЙ ОТЧЕТ SSH МОНИТОРИНГА"
|
title = "📊 ЕЖЕДНЕВНЫЙ ОТЧЕТ SSH МОНИТОРИНГА"
|
||||||
@@ -70,7 +185,7 @@ def build_report_body(
|
|||||||
f"🖥️ Сервер: {server}",
|
f"🖥️ Сервер: {server}",
|
||||||
f"🕐 Время отчета: {time_str}",
|
f"🕐 Время отчета: {time_str}",
|
||||||
"",
|
"",
|
||||||
"📈 СТАТИСТИКА ЗА ПОСЛЕДНИЕ 24 ЧАСА:",
|
" 📈 СТАТИСТИКА ЗА ПОСЛЕДНИЕ 24 ЧАСА:",
|
||||||
f" ✅ Успешных SSH подключений: {ok}",
|
f" ✅ Успешных SSH подключений: {ok}",
|
||||||
f" ❌ Неудачных попыток SSH: {fail}",
|
f" ❌ Неудачных попыток SSH: {fail}",
|
||||||
f" ⚠️ Команд через sudo: {sudo}",
|
f" ⚠️ Команд через sudo: {sudo}",
|
||||||
@@ -86,13 +201,15 @@ def build_report_body(
|
|||||||
f"🖥️ Сервер: {server}",
|
f"🖥️ Сервер: {server}",
|
||||||
f"🕐 Время отчета: {time_str}",
|
f"🕐 Время отчета: {time_str}",
|
||||||
"",
|
"",
|
||||||
"📈 СТАТИСТИКА ЗА ПОСЛЕДНИЕ 24 ЧАСА:",
|
" 📈 СТАТИСТИКА ЗА ПОСЛЕДНИЕ 24 ЧАСА:",
|
||||||
f" ✅ Успешных RDP подключений: {ok}",
|
f" ✅ Успешных RDP подключений: {ok}",
|
||||||
f" ❌ Неудачных попыток RDP: {fail}",
|
f" ❌ Неудачных попыток RDP: {fail}",
|
||||||
f" 🚫 Активных банов: {bans}",
|
f" 🚫 Активных банов: {bans}",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
lines.extend(_section_top_ips(top))
|
lines.extend(_section_top_ips(top))
|
||||||
|
lines.append("")
|
||||||
lines.extend(_section_active_users(active_users, sac_generated=sac_generated))
|
lines.extend(_section_active_users(active_users, sac_generated=sac_generated))
|
||||||
|
|
||||||
if 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:
|
if "unique_users" in out and "active_users" not in out:
|
||||||
users = out.get("unique_users") or []
|
users = out.get("unique_users") or []
|
||||||
out["active_users"] = [f"👤 {u}" if not str(u).startswith("👤") else str(u) for u in users]
|
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
|
return out
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ from sqlalchemy.exc import IntegrityError
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models import Event, Host
|
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:
|
def _parse_dt(value: str) -> datetime:
|
||||||
@@ -72,6 +75,9 @@ def ingest_event(db: Session, payload: dict) -> tuple[Event, bool]:
|
|||||||
return existing, False
|
return existing, False
|
||||||
|
|
||||||
host = upsert_host(db, payload)
|
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 = Event(
|
||||||
event_id=event_id,
|
event_id=event_id,
|
||||||
host_id=host.id,
|
host_id=host.id,
|
||||||
@@ -81,7 +87,7 @@ def ingest_event(db: Session, payload: dict) -> tuple[Event, bool]:
|
|||||||
severity=payload["severity"],
|
severity=payload["severity"],
|
||||||
title=payload["title"],
|
title=payload["title"],
|
||||||
summary=payload["summary"],
|
summary=payload["summary"],
|
||||||
details=payload.get("details"),
|
details=details,
|
||||||
raw=payload.get("raw"),
|
raw=payload.get("raw"),
|
||||||
dedup_key=payload.get("dedup_key"),
|
dedup_key=payload.get("dedup_key"),
|
||||||
correlation_id=payload.get("correlation_id"),
|
correlation_id=payload.get("correlation_id"),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from datetime import datetime
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from app.models import Event, Host, Problem
|
from app.models import Event, Host, Problem
|
||||||
|
from app.services.daily_report_format import normalize_report_body
|
||||||
|
|
||||||
LOGON_TYPE_NAMES: dict[int, str] = {
|
LOGON_TYPE_NAMES: dict[int, str] = {
|
||||||
2: "Интерактивный (консоль)",
|
2: "Интерактивный (консоль)",
|
||||||
@@ -181,13 +182,26 @@ def format_generic_event_html(event: Event) -> str:
|
|||||||
|
|
||||||
def format_daily_report_html(event: Event) -> str:
|
def format_daily_report_html(event: Event) -> str:
|
||||||
details = _details_dict(event)
|
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"<b>{esc.strip()}</b>")
|
||||||
|
else:
|
||||||
|
parts.append(esc)
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
report_html = details.get("report_html")
|
report_html = details.get("report_html")
|
||||||
if isinstance(report_html, str) and report_html.strip():
|
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)
|
body = _detail(details, "report_body", default=event.summary)
|
||||||
if body != "-":
|
if body != "-":
|
||||||
escaped = html.escape(body)
|
return html.escape(body)
|
||||||
return f"<b>📊 {html_escape(event.title)}</b>\n{escaped}"
|
|
||||||
return format_generic_event_html(event)
|
return format_generic_event_html(event)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,12 @@
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from app.models import Host
|
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():
|
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)
|
body = build_report_body("ssh", host, stats, when, sac_generated=True)
|
||||||
assert "ЕЖЕДНЕВНЫЙ ОТЧЕТ SSH МОНИТОРИНГА" in body
|
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 "Команд через sudo: 5" in body
|
||||||
assert "АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ (1)" in body
|
assert " 👥 АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ (1)" in body
|
||||||
assert "papatramp" in body
|
assert "papatramp" in body
|
||||||
|
assert "\n\n\n" not in body
|
||||||
|
|
||||||
|
|
||||||
def test_build_report_body_windows_layout():
|
def test_build_report_body_windows_layout():
|
||||||
@@ -59,5 +66,38 @@ def test_build_report_body_windows_layout():
|
|||||||
assert "ЕЖЕДНЕВНЫЙ ОТЧЕТ МОНИТОРИНГА WINDOWS" in body
|
assert "ЕЖЕДНЕВНЫЙ ОТЧЕТ МОНИТОРИНГА WINDOWS" in body
|
||||||
assert "K6A-DC3 (10.0.0.10)" in body
|
assert "K6A-DC3 (10.0.0.10)" in body
|
||||||
assert "Успешных RDP подключений: 1" in body
|
assert "Успешных RDP подключений: 1" in body
|
||||||
assert "АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ (2)" in body
|
assert " 👥 АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ (2)" in body
|
||||||
assert "user2" 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
|
||||||
|
|||||||
@@ -7,13 +7,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul v-if="activeUserLines.length && !plainBody && !htmlContent" class="report-active-users">
|
<ul v-if="activeUserLines.length && !plainBody" class="report-active-users">
|
||||||
<li v-for="(line, idx) in activeUserLines" :key="idx">{{ line }}</li>
|
<li v-for="(line, idx) in activeUserLines" :key="idx">{{ line }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div v-if="htmlContent" class="report-body-html" v-html="htmlContent" />
|
<pre v-if="plainBody" class="report-body-plain">{{ plainBody }}</pre>
|
||||||
|
|
||||||
<pre v-else-if="plainBody" class="report-body-plain">{{ plainBody }}</pre>
|
<div v-else-if="htmlContent" class="report-body-html" v-html="htmlContent" />
|
||||||
|
|
||||||
<p v-else-if="summary && summary !== 'Отчёт за сутки'" class="report-fallback">{{ summary }}</p>
|
<p v-else-if="summary && summary !== 'Отчёт за сутки'" class="report-fallback">{{ summary }}</p>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user