@@ -0,0 +1,171 @@
|
||||
"""Объединённая лента админки: 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
|
||||
Reference in New Issue
Block a user