Files
site/WESP_REL/app/services/admin_activity_feed.py
T
2026-07-17 12:57:18 +03:00

172 lines
5.8 KiB
Python

"""Объединённая лента админки: JSONL (действия UI) + хвост wesp.log (WARNING+)."""
from __future__ import annotations
import json
import re
import time
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
from flask import Flask
from app.services.admin_dashboard_service import tail_text_file
from app.services.admin_llm_activity_log import (
format_llm_log_line_text,
read_admin_llm_activity,
)
from app.services.admin_ui_activity_log import _activity_log_path, _line_unix_ts
_LOG_LINE_RE = re.compile(
r"^(?:(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) )?(DEBUG|INFO|WARNING|ERROR|CRITICAL):([^:]+):(.*)$"
)
def _resolve_path(app: Flask, raw: str) -> Path:
p = Path(raw)
if not p.is_absolute():
p = Path(app.config.get("BASE_DIR", ".")) / p
return p
def _tail_jsonl_objects(path: Path, max_lines: int, max_bytes: int = 384_000) -> List[Dict[str, Any]]:
if not path.is_file():
return []
lines, _err = tail_text_file(path, max_lines=max_lines + 80, max_bytes=max_bytes)
out: List[Dict[str, Any]] = []
for ln in lines:
raw = ln.strip()
if not raw:
continue
try:
obj = json.loads(raw)
except json.JSONDecodeError:
continue
if isinstance(obj, dict) and "u" in obj and "text" in obj:
out.append(obj)
return out[-max_lines:]
def _parse_log_line(line: str, fallback_ts_ms: int) -> Optional[Dict[str, Any]]:
m = _LOG_LINE_RE.match(line.strip())
if not m:
return None
time_s, sev, _name, msg = m.group(1), m.group(2), m.group(3), m.group(4)
if sev in ("DEBUG", "INFO"):
return None
level = "warn" if sev == "WARNING" else "err"
ts_ms = fallback_ts_ms
if time_s:
try:
dt = datetime.strptime(time_s, "%Y-%m-%d %H:%M:%S")
ts_ms = int(dt.timestamp() * 1000)
except ValueError:
pass
text = f"{sev}:{m.group(3)}:{msg}".strip()
return {
"ts": ts_ms,
"level": level,
"source": "log",
"text": text[:2000],
"user": "wesp.log",
}
def _dedupe_sorted(entries: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Один ключ (level + начало текста) — оставляем запись с большим ts (новее)."""
by_key: Dict[tuple[str, str], Dict[str, Any]] = {}
for e in entries:
lvl = str(e.get("level") or "ok")
txt = str(e.get("text") or "").strip()[:240]
key = (lvl, txt)
prev = by_key.get(key)
ts = int(e.get("ts") or 0)
if prev is None or ts >= int(prev.get("ts") or 0):
by_key[key] = e
out = list(by_key.values())
out.sort(key=lambda x: int(x.get("ts") or 0))
return out
def build_activity_feed(app: Flask) -> tuple[List[Dict[str, Any]], str]:
"""
События за окно retention UI (суток), без дублирования level+text.
ts — миллисекунды (Unix*1000 или синтетика для строк без asctime).
"""
days = int(app.config.get("WESP_ADMIN_UI_ACTIVITY_RETENTION_DAYS", 3) or 3)
days = max(1, min(days, 30))
cutoff_ms = int((time.time() - float(days * 86400)) * 1000)
items: List[Dict[str, Any]] = []
jsonl_raw = _activity_log_path(app)
if jsonl_raw and not app.config.get("TESTING"):
jpath = _resolve_path(app, jsonl_raw)
for obj in _tail_jsonl_objects(jpath, max_lines=400):
u = _line_unix_ts(obj)
if u is None:
continue
ts_ms = int(u * 1000)
if ts_ms < cutoff_ms:
continue
lvl = str(obj.get("level") or "ok")
if lvl not in ("ok", "err", "warn"):
lvl = "ok"
items.append(
{
"ts": ts_ms,
"level": lvl,
"source": "ui",
"text": str(obj.get("text") or "")[:2000],
"user": str(obj.get("user") or "")[:128] or None,
}
)
llm_days = int(app.config.get("WESP_ADMIN_LLM_LOG_RETENTION_DAYS", 7) or 7)
llm_days = max(1, min(llm_days, 30))
llm_cutoff_ms = int((time.time() - float(llm_days * 86400)) * 1000)
llm_rows, _llm_path = read_admin_llm_activity(app, max_lines=400)
for obj in llm_rows:
u = obj.get("u")
if not isinstance(u, (int, float)):
continue
ts_ms = int(float(u) * 1000)
if ts_ms < llm_cutoff_ms:
continue
lvl = str(obj.get("level") or "ok")
if lvl not in ("ok", "err", "warn"):
lvl = "ok"
items.append(
{
"ts": ts_ms,
"level": lvl,
"source": "llm",
"text": format_llm_log_line_text(obj),
"user": str(obj.get("user") or "")[:128] or None,
}
)
log_raw = (app.config.get("WESP_ADMIN_LOG_PATH") or "").strip()
if log_raw and not app.config.get("TESTING"):
lpath = _resolve_path(app, log_raw)
log_lines, _err = tail_text_file(lpath, max_lines=500, max_bytes=512_000)
base_ms = int(time.time() * 1000)
n = len(log_lines)
for i, line in enumerate(log_lines):
fb = base_ms - (n - 1 - i) * 15
parsed = _parse_log_line(line, fb)
if not parsed:
continue
if int(parsed["ts"]) < cutoff_ms:
continue
items.append(parsed)
merged = _dedupe_sorted(items)
llm_log_raw = (app.config.get("WESP_ADMIN_LLM_LOG_PATH") or "").strip()
meta = (
f"jsonl={bool(jsonl_raw)} llm={bool(llm_log_raw)} log={bool(log_raw)} "
f"days={days} llm_days={llm_days}"
)
return merged, meta