"""Объединённая лента админки: 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