diff --git a/monitoring/app.py b/monitoring/app.py new file mode 100644 index 0000000..a529924 --- /dev/null +++ b/monitoring/app.py @@ -0,0 +1,1984 @@ +"""ARC monitoring — приём логов/дампов от клиента, хранение на сервере с +ретеншном RETENTION_DAYS суток и веб-админка для поиска по типу/дате. + +POST /monitoring/log multipart: type, event, message, internal_task_id?, image? + Логи хранятся на сервере (ретеншн RETENTION_DAYS суток). +POST /monitoring/dump multipart: text, file?, image? + Дампы экранов (кнопка на превью девайса) пересылаются в Telegram и НЕ + сохраняются на сервере. Telegram-креды: $ARC_TG_BOT_TOKEN / $ARC_TG_CHAT_ID. + Auth приёма: header X-Monitoring-Token == $ARC_MONITORING_TOKEN. + +GET /admin веб-админка (логин: $ARC_ADMIN_USER / $ARC_ADMIN_PASSWORD) + +Хранилище: SQLite ($ARC_DATA_DIR/monitoring.db) + файлы в $ARC_DATA_DIR/files/<дата>/. +Всё время — UTC. +""" +from __future__ import annotations + +import asyncio +import hashlib +import hmac +import html as html_lib +import logging +import os +import re +import socket +import sqlite3 +import time +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Optional + +import httpx +from fastapi import FastAPI, File, Form, Header, HTTPException, Request, UploadFile +from fastapi.responses import ( + FileResponse, + HTMLResponse, + JSONResponse, + RedirectResponse, + Response, +) + +LOG = logging.getLogger("arc-monitoring") +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s | %(message)s") + +SECRET = os.environ["ARC_MONITORING_TOKEN"] +ADMIN_USER = os.environ.get("ARC_ADMIN_USER", "admin") +ADMIN_PASSWORD = os.environ["ARC_ADMIN_PASSWORD"] +RETENTION_DAYS = int(os.environ.get("ARC_RETENTION_DAYS", "3")) +# Тяжёлые ZIP-дампы ошибок храним короче логов (диск дроплета ограничен). +DUMP_RETENTION_DAYS = int(os.environ.get("ARC_DUMP_RETENTION_DAYS", "1")) +# История экрана девайсов — только последние N часов. +SCREENSHOT_RETENTION_HOURS = int(os.environ.get("ARC_SCREENSHOT_RETENTION_HOURS", "5")) +# Ручные дампы экрана (XML + скриншот с кнопки превью) — храним дольше: их снимают +# осознанно под конкретный кейс и потом скачивают парой. +SCREEN_DUMP_RETENTION_DAYS = int(os.environ.get("ARC_SCREEN_DUMP_RETENTION_DAYS", "7")) + +TG_TOKEN = os.environ["ARC_TG_BOT_TOKEN"] +TG_CHAT = os.environ["ARC_TG_CHAT_ID"] +TG_BASE = f"https://api.telegram.org/bot{TG_TOKEN}" +TG_TEXT_LIMIT = 4096 +TG_CAPTION_LIMIT = 1024 + +DATA_DIR = Path(os.environ.get("ARC_DATA_DIR", "/opt/arc-monitoring/data")) +FILES_DIR = DATA_DIR / "files" +DB_PATH = DATA_DIR / "monitoring.db" +HOSTNAME = socket.gethostname() +PAGE_SIZE = 50 + +app = FastAPI(title="ARC monitoring") +_tg_client: Optional[httpx.AsyncClient] = None + + +# ──────────────────────────── хранилище ──────────────────────────── + +def _db() -> sqlite3.Connection: + conn = sqlite3.connect(DB_PATH, timeout=30) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + return conn + + +def _init_db() -> None: + DATA_DIR.mkdir(parents=True, exist_ok=True) + FILES_DIR.mkdir(parents=True, exist_ok=True) + with _db() as c: + c.execute( + """CREATE TABLE IF NOT EXISTS entries( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts REAL NOT NULL, + kind TEXT NOT NULL, + type TEXT NOT NULL DEFAULT '', + event TEXT NOT NULL DEFAULT '', + message TEXT NOT NULL DEFAULT '', + internal_task_id TEXT, + image_path TEXT, + file_path TEXT + )""" + ) + c.execute("CREATE INDEX IF NOT EXISTS idx_entries_ts ON entries(ts)") + c.execute("CREATE INDEX IF NOT EXISTS idx_entries_type ON entries(type)") + c.execute("CREATE INDEX IF NOT EXISTS idx_entries_kind ON entries(kind)") + c.execute( + """CREATE TABLE IF NOT EXISTS screenshots( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts REAL NOT NULL, + desktop_id TEXT NOT NULL DEFAULT '', + device_id TEXT NOT NULL DEFAULT '', + name TEXT NOT NULL DEFAULT '', + image_path TEXT NOT NULL + )""" + ) + c.execute("CREATE INDEX IF NOT EXISTS idx_screens_ts ON screenshots(ts)") + c.execute("CREATE INDEX IF NOT EXISTS idx_screens_dev ON screenshots(device_id)") + c.execute( + """CREATE TABLE IF NOT EXISTS screen_dumps( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts REAL NOT NULL, + device TEXT NOT NULL DEFAULT '', + app_version TEXT NOT NULL DEFAULT '', + image_path TEXT, + xml_path TEXT + )""" + ) + c.execute("CREATE INDEX IF NOT EXISTS idx_dumps_ts ON screen_dumps(ts)") + c.execute("CREATE INDEX IF NOT EXISTS idx_dumps_dev ON screen_dumps(device)") + # миграция: добавить app_version в уже существующую таблицу + dump_cols = {r["name"] for r in c.execute("PRAGMA table_info(screen_dumps)")} + if "app_version" not in dump_cols: + c.execute( + "ALTER TABLE screen_dumps ADD COLUMN app_version TEXT NOT NULL DEFAULT ''" + ) + + +_SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._-]+") + + +def _save_upload(now: float, data: bytes, name: str) -> str: + """Сохраняет вложение в files/<дата>/_, возвращает путь + относительно FILES_DIR (его и кладём в БД).""" + day = datetime.fromtimestamp(now, timezone.utc).strftime("%Y-%m-%d") + day_dir = FILES_DIR / day + day_dir.mkdir(parents=True, exist_ok=True) + safe = _SAFE_NAME_RE.sub("_", name) or "file.bin" + fname = f"{int(now * 1000)}_{safe}" + (day_dir / fname).write_bytes(data) + return f"{day}/{fname}" + + +def _insert(now: float, kind: str, type_: str, event: str, message: str, + internal_task_id: Optional[str], image_path: Optional[str], + file_path: Optional[str]) -> int: + with _db() as c: + cur = c.execute( + """INSERT INTO entries(ts, kind, type, event, message, + internal_task_id, image_path, file_path) + VALUES(?,?,?,?,?,?,?,?)""", + (now, kind, type_, event, message, internal_task_id, image_path, file_path), + ) + return int(cur.lastrowid) + + +def _unlink_rel(rel: Optional[str]) -> None: + if not rel: + return + try: + (FILES_DIR / rel).unlink(missing_ok=True) + except OSError as e: + LOG.warning("purge: cannot unlink %s: %s", rel, e) + + +def _purge_old() -> int: + now = time.time() + log_cutoff = now - RETENTION_DAYS * 86400 + dump_cutoff = now - DUMP_RETENTION_DAYS * 86400 + screen_cutoff = now - SCREENSHOT_RETENTION_HOURS * 3600 + removed = 0 + with _db() as c: + # Логи (kind='log') — ретеншн RETENTION_DAYS; дампы (kind='dump') — короче. + for clause, cutoff in (("kind='log'", log_cutoff), ("kind='dump'", dump_cutoff)): + rows = c.execute( + f"SELECT image_path, file_path FROM entries WHERE {clause} AND ts < ?", + (cutoff,), + ).fetchall() + for r in rows: + _unlink_rel(r["image_path"]) + _unlink_rel(r["file_path"]) + c.execute(f"DELETE FROM entries WHERE {clause} AND ts < ?", (cutoff,)) + removed += len(rows) + # История экрана — ретеншн SCREENSHOT_RETENTION_HOURS. + srows = c.execute( + "SELECT image_path FROM screenshots WHERE ts < ?", (screen_cutoff,) + ).fetchall() + for r in srows: + _unlink_rel(r["image_path"]) + c.execute("DELETE FROM screenshots WHERE ts < ?", (screen_cutoff,)) + removed += len(srows) + # Ручные дампы экрана — свой (более длинный) ретеншн. + dump_screen_cutoff = now - SCREEN_DUMP_RETENTION_DAYS * 86400 + drows = c.execute( + "SELECT image_path, xml_path FROM screen_dumps WHERE ts < ?", + (dump_screen_cutoff,), + ).fetchall() + for r in drows: + _unlink_rel(r["image_path"]) + _unlink_rel(r["xml_path"]) + c.execute("DELETE FROM screen_dumps WHERE ts < ?", (dump_screen_cutoff,)) + removed += len(drows) + # подчищаем пустые каталоги дат старше ретеншна + for d in FILES_DIR.glob("*"): + if d.is_dir(): + try: + next(d.iterdir()) + except StopIteration: + d.rmdir() + return removed + + +DISK_EMERGENCY_THRESHOLD = 0.80 # чистить все скрины если диск > 80% + + +def _emergency_purge_screenshots() -> int: + """Удаляет ВСЕ скриншоты истории экрана когда диск переполнен.""" + import shutil + usage = shutil.disk_usage(FILES_DIR) + if usage.used / usage.total < DISK_EMERGENCY_THRESHOLD: + return 0 + removed = 0 + with _db() as c: + rows = c.execute("SELECT image_path FROM screenshots").fetchall() + for r in rows: + _unlink_rel(r["image_path"]) + removed = c.execute("DELETE FROM screenshots").rowcount + LOG.warning( + "disk emergency purge: removed %d screenshots (disk %.0f%% full)", + removed, usage.used / usage.total * 100, + ) + return removed + + +async def _retention_loop() -> None: + while True: + try: + emergency = await asyncio.to_thread(_emergency_purge_screenshots) + if not emergency: + n = await asyncio.to_thread(_purge_old) + if n: + LOG.info("retention: removed %d entries older than %dd", n, RETENTION_DAYS) + except Exception as e: # noqa: BLE001 + LOG.warning("retention loop error: %s", e) + await asyncio.sleep(600) + + +@app.on_event("startup") +async def _startup() -> None: + global _tg_client + _tg_client = httpx.AsyncClient(timeout=httpx.Timeout(30.0, connect=10.0)) + await asyncio.to_thread(_init_db) + asyncio.create_task(_retention_loop()) + LOG.info("monitoring up on %s, data=%s, retention=%dd", HOSTNAME, DATA_DIR, RETENTION_DAYS) + + +@app.on_event("shutdown") +async def _shutdown() -> None: + if _tg_client is not None: + await _tg_client.aclose() + + +# ──────────────────────────── Telegram-релей (дампы) ──────────────────────────── + +def _truncate(text: str, limit: int) -> str: + if len(text) <= limit: + return text + return text[: limit - 20] + "\n…[truncated]" + + +async def _tg_post(method: str, *, data: dict, files: Optional[dict] = None) -> dict: + assert _tg_client is not None + url = f"{TG_BASE}/{method}" + for attempt in range(3): + try: + r = await _tg_client.post(url, data=data, files=files) + body = r.json() + if r.status_code == 200 and body.get("ok"): + return body + LOG.warning("tg %s attempt %d failed: status=%d body=%s", + method, attempt, r.status_code, body) + if r.status_code == 429: + await asyncio.sleep(int(body.get("parameters", {}).get("retry_after", 2))) + continue + if 500 <= r.status_code < 600: + await asyncio.sleep(1 + attempt) + continue + return body + except httpx.HTTPError as e: + LOG.warning("tg %s attempt %d transport error: %s", method, attempt, e) + await asyncio.sleep(1 + attempt) + raise HTTPException(status_code=502, detail=f"telegram {method} unreachable") + + +async def _tg_send_photo(photo: bytes, filename: str, caption: str) -> None: + await _tg_post( + "sendPhoto", + data={"chat_id": TG_CHAT, "caption": _truncate(caption, TG_CAPTION_LIMIT)}, + files={"photo": (filename or "image.png", photo, "image/png")}, + ) + + +async def _tg_send_document(doc: bytes, filename: str, caption: str, mime: str) -> None: + await _tg_post( + "sendDocument", + data={"chat_id": TG_CHAT, "caption": _truncate(caption, TG_CAPTION_LIMIT)}, + files={"document": (filename or "file.bin", doc, mime)}, + ) + + +async def _tg_send_text(text: str) -> None: + await _tg_post("sendMessage", data={"chat_id": TG_CHAT, "text": _truncate(text, TG_TEXT_LIMIT)}) + + +# ──────────────────────────── приём ──────────────────────────── + +def _require_token(token: Optional[str]) -> None: + if not token or not hmac.compare_digest(token, SECRET): + raise HTTPException(status_code=401, detail="bad token") + + +@app.get("/health") +async def health() -> dict: + return {"ok": True, "host": HOSTNAME} + + +@app.post("/monitoring/log") +async def monitoring_log( + type: str = Form(...), + event: str = Form(...), + message: str = Form(""), + api_token: Optional[str] = Form(None), + internal_task_id: Optional[str] = Form(None), + image: Optional[UploadFile] = File(None), + x_monitoring_token: Optional[str] = Header(None, alias="X-Monitoring-Token"), +) -> JSONResponse: + _require_token(x_monitoring_token) + now = time.time() + + image_path = None + if image is not None: + data = await image.read() + if data: + image_path = await asyncio.to_thread( + _save_upload, now, data, image.filename or "screenshot.png" + ) + + await asyncio.to_thread( + _insert, now, "log", (type or "").upper(), event, message, + internal_task_id, image_path, None, + ) + return JSONResponse({"success": True}) + + +# Имя девайса клиент зашивает в имя XML-файла: DUMP__.xml +_DUMP_FNAME_RE = re.compile( + r"^DUMP_(.+)_\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}\.xml$", re.IGNORECASE +) + + +def _dump_device_name(file_name: str) -> str: + m = _DUMP_FNAME_RE.match(file_name or "") + return m.group(1) if m else "" + + +def _insert_screen_dump(now: float, device: str, app_version: str, + image_path: Optional[str], xml_path: Optional[str]) -> int: + with _db() as c: + cur = c.execute( + """INSERT INTO screen_dumps(ts, device, app_version, image_path, xml_path) + VALUES(?,?,?,?,?)""", + (now, device, app_version, image_path, xml_path), + ) + return int(cur.lastrowid) + + +@app.post("/monitoring/dump") +async def monitoring_dump( + text: str = Form(""), + app_version: str = Form(""), + file: Optional[UploadFile] = File(None), + image: Optional[UploadFile] = File(None), + x_monitoring_token: Optional[str] = Header(None, alias="X-Monitoring-Token"), +) -> JSONResponse: + _require_token(x_monitoring_token) + now = time.time() + + img_bytes = await image.read() if image is not None else b"" + file_bytes = await file.read() if file is not None else b"" + + # На /monitoring/dump приходят два разных потока: + # • дампы экранов с кнопки превью девайса — text это голый api_token (без + # тега), file это XML-иерархия. Их шлём в Telegram и НЕ храним. + # • логи и дампы ошибок задач (postMonitoringDump из TaskDumpRecorder / + # LogArchiveSender) — text начинается с тега [LOGS]/[TASK_ERROR]/…, причём + # клиент оборачивает его в HTML (`[TASK_ERROR]…`). Их храним на + # сервере (3-дневный ретеншн), в Telegram не шлём. + # Поэтому снимаем ведущие пробелы/HTML-теги перед проверкой тега. + stripped = re.sub(r"^(?:\s|<[^>]+>)+", "", text) + is_screen_dump = not stripped.startswith("[") + + if is_screen_dump: + caption = text + img_name = (image.filename if image is not None else "") or "final_screen.png" + file_name = (file.filename if file is not None else "") or "dump.xml" + file_mime = (file.content_type if file is not None else "") or "application/xml" + + # Сохраняем пару (скриншот + XML) на сервере — для страницы /admin/dumps. + device = _dump_device_name(file_name) + image_path = None + if img_bytes: + image_path = await asyncio.to_thread(_save_upload, now, img_bytes, img_name) + xml_path = None + if file_bytes: + xml_path = await asyncio.to_thread(_save_upload, now, file_bytes, file_name) + if image_path or xml_path: + await asyncio.to_thread( + _insert_screen_dump, now, device, app_version, image_path, xml_path + ) + + if img_bytes and file_bytes: + await _tg_send_photo(img_bytes, img_name, caption) + await _tg_send_document(file_bytes, file_name, "", file_mime) + elif img_bytes: + await _tg_send_photo(img_bytes, img_name, caption) + elif file_bytes: + await _tg_send_document(file_bytes, file_name, caption, file_mime) + else: + await _tg_send_text(caption or "[DUMP]") + return JSONResponse({"success": True}) + + # Логи / дампы ошибок задач — сохраняем. Тип берём из ведущего тега текста + # ([TASK_ERROR], [LOGS], …), чтобы в админке они не сваливались все в DUMP. + tag_match = re.match(r"\[([A-Za-z0-9_]+)\]", stripped) + type_ = tag_match.group(1).upper() if tag_match else "DUMP" + + image_path = None + if img_bytes: + image_path = await asyncio.to_thread( + _save_upload, now, img_bytes, + (image.filename if image is not None else None) or "final_screen.png", + ) + + file_path = None + if file_bytes: + file_path = await asyncio.to_thread( + _save_upload, now, file_bytes, + (file.filename if file is not None else None) or "dump.zip", + ) + + await asyncio.to_thread( + _insert, now, "dump", type_, type_.lower(), text, + None, image_path, file_path, + ) + return JSONResponse({"success": True}) + + +def _insert_screenshot(now: float, desktop_id: str, device_id: str, + name: str, image_path: str) -> int: + with _db() as c: + cur = c.execute( + """INSERT INTO screenshots(ts, desktop_id, device_id, name, image_path) + VALUES(?,?,?,?,?)""", + (now, desktop_id, device_id, name, image_path), + ) + return int(cur.lastrowid) + + +@app.post("/monitoring/screenshot") +async def monitoring_screenshot( + desktop_id: str = Form(""), + device_id: str = Form(""), + name: str = Form(""), + image: UploadFile = File(...), + x_monitoring_token: Optional[str] = Header(None, alias="X-Monitoring-Token"), +) -> JSONResponse: + """Кадр истории экрана девайса. Хранится SCREENSHOT_RETENTION_HOURS часов.""" + _require_token(x_monitoring_token) + now = time.time() + + data = await image.read() + if not data: + return JSONResponse({"success": False, "error": "empty image"}) + + image_path = await asyncio.to_thread( + _save_upload, now, data, image.filename or "screen.jpg" + ) + await asyncio.to_thread( + _insert_screenshot, now, desktop_id, device_id, name, image_path + ) + return JSONResponse({"success": True}) + + +# ──────────────────────────── авторизация админки ──────────────────────────── + +_COOKIE_NAME = "arc_admin" +_COOKIE_VALUE = hmac.new(SECRET.encode(), b"arc-admin-session-v1", hashlib.sha256).hexdigest() + + +def _is_authed(request: Request) -> bool: + return hmac.compare_digest(request.cookies.get(_COOKIE_NAME, ""), _COOKIE_VALUE) + + +# ──────────────────────────── HTML ──────────────────────────── + +_STYLE = """ + +""" + + +_IMG_MODAL = """ + + +""" + +# Заполняет локальным временем браузера и, +# если есть <* id=locth>, проставляет в него имя таймзоны. Общий для всех страниц. +_LOCAL_TIME_JS = """ + +""" + +# Выбор чекбоксов name=ids (form=bulkForm) + счётчик + подтверждение. Общий для +# страниц с массовым удалением (логи, дампы, скриншоты). Имя сущности — для confirm. +def _bulk_js(noun: str) -> str: + return """ + +""".replace("__NOUN__", noun) + + +def _login_page(error: str = "") -> HTMLResponse: + err = f'

{html_lib.escape(error)}

' if error else "" + body = f""" + ARC monitoring — вход{_STYLE} +
+

ARC monitoring

{err} +
+

+

+ +
+
""" + return HTMLResponse(body, status_code=200 if not error else 401) + + +def _ts_str(ts: float) -> str: + return datetime.fromtimestamp(ts, timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + + +def _parse_date(s: str, end: bool = False) -> Optional[float]: + s = (s or "").strip() + if not s: + return None + try: + d = datetime.strptime(s, "%Y-%m-%d").replace(tzinfo=timezone.utc) + except ValueError: + return None + if end: + d += timedelta(days=1) + return d.timestamp() + + +def _opt(value: str, current: str, label: str = "") -> str: + sel = " selected" if value == current else "" + return f'' + + +_TAG_HTML_RE = re.compile(r"<[^>]+>") +# api_token больше не участвует в корреляции — склейка идёт по Profile+Material. +# _TOKEN_RE = re.compile(r"\b[0-9a-fA-F]{32,40}\b") +_ISO_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T") +_META_PREFIX_RE = re.compile(r"^(Device|Material|Amount|Phone|Card):", re.IGNORECASE) + + +def _render_msg(message: str) -> str: + """Клиент шлёт логи ошибок уже обёрнутыми в структурные теги , + динамику внутри экранирует на стороне C++ (toHtmlEscaped). Прочие источники + (AppLogger, task_success) шлют голый текст. Экранируем всё, затем возвращаем + из белого списка только / — bold рендерится, инъекция HTML невозможна. + Тело JSON (всё после маркера «JSON: » до конца сообщения) выносим в + моноширинный
-блок. Саму подпись «JSON:» не показываем — и так понятно.
+    Содержимое уже экранировано, инъекции нет."""
+    s = html_lib.escape(message or "")
+    s = s.replace("<b>", "").replace("</b>", "")
+    marker = "\nJSON: "
+    idx = s.find(marker)
+    if idx != -1:
+        head = s[:idx]
+        body = s[idx + len(marker):]
+        s = f'{head}\n
{body}
' + return s + + +# Идентичность задачи — Profile + Material (кто работает и по какому материалу). +# api_token намеренно игнорируется: склейка всегда идёт по этой паре. +_CORR_ID_RE = { + "profile": re.compile(r"(?mi)^\s*Profile:\s*(\S+)"), + "material": re.compile(r"(?mi)^\s*Material:\s*(\S+)"), +} + + +def _corr_key(message: str) -> Optional[str]: + """Ключ корреляции пары ERROR-лог ↔ DUMP по одной задаче. У дампа нет + internal_task_id, поэтому связываем по общим данным из текста: пара + Profile+Material + сам текст ошибки. api_token не используется. Близость по + времени проверяется отдельно в группировке (PAIR_WINDOW). + Итог: склейка = Profile+Material + Error + время. + + СКЛЕЙКА ОТКЛЮЧЕНА: возвращаем None всегда, чтобы строки не группировались. + Чтобы вернуть — убрать строку ниже.""" + return None + text = _TAG_HTML_RE.sub("", message or "") + parts = [] + for rx in _CORR_ID_RE.values(): + m = rx.search(text) + if m: + parts.append(m.group(1).strip().lower()) + token = "|".join(parts) + + m = re.search(r"Error:\s*(.+)", text) + if m: + err = m.group(1) + else: + err = "" + for line in reversed(text.splitlines()): + s = line.strip() + if not s or _ISO_RE.match(s) or _META_PREFIX_RE.match(s): + continue + if s.startswith("[") and s.endswith("]"): + continue + err = s + break + err = re.sub(r"\s+", " ", err).strip().lower()[:200] + return f"{token}|{err}" if token and err else None + + +# ──────────────────────────── метаданные / навигация ──────────────────────────── + +# Поля метаданных в тексте сообщений (строки вида «Desktop: »). Клиент +# оборачивает шапку в , но сами поля идут отдельными строками — якорь ^ +# в многострочном режиме их находит. +_META_FIELD_RE = { + "desktop": re.compile(r"(?mi)^\s*Desktop:\s*(\S+)"), + "device": re.compile(r"(?mi)^\s*Device:\s*(\S+)"), + "android": re.compile(r"(?mi)^\s*AndroidId:\s*(\S+)"), + "profile": re.compile(r"(?mi)^\s*Profile:\s*(\S+)"), + "phone": re.compile(r"(?mi)^\s*Phone:\s*(\S+)"), +} + + +def _meta(message: str) -> dict: + out: dict = {} + for key, rx in _META_FIELD_RE.items(): + m = rx.search(message or "") + if m: + out[key] = m.group(1).strip() + return out + + +def _ago(ts: float, now: Optional[float] = None) -> str: + """Человекочитаемая давность: «3 мин назад», «2 ч назад», «1 д назад».""" + sec = max(0.0, (now if now is not None else time.time()) - ts) + if sec < 90: + return "только что" + mins = sec / 60 + if mins < 90: + return f"{int(round(mins))} мин назад" + hours = mins / 60 + if hours < 36: + return f"{int(round(hours))} ч назад" + return f"{int(round(hours / 24))} д назад" + + +def _entry_rows_html(rows) -> str: + """Компактная таблица записей для инлайн-разворота на странице десктопов.""" + if not rows: + return '
Нет записей
' + trs = [] + for r in rows: + attach = [] + if r["image_path"]: + attach.append( + f'' + ) + if r["file_path"]: + attach.append(f'⬇ архив') + t = html_lib.escape(r["type"] or "") + trs.append( + "" + f'{_ts_str(r["ts"])}' + f'{t}' + f'{html_lib.escape(r["event"] or "")}' + f'{_render_msg(r["message"])}' + f'{"
".join(attach)}' + "" + ) + return ( + "" + "" + + "".join(trs) + "
Время (UTC)ТипEventСообщениеВложения
" + ) + + +def _clear_all() -> int: + """Удаляет все записи из всех таблиц и все файлы.""" + total = 0 + with _db() as c: + rows = c.execute("SELECT image_path, file_path FROM entries").fetchall() + for r in rows: + _unlink_rel(r["image_path"]) + _unlink_rel(r["file_path"]) + total += c.execute("DELETE FROM entries").rowcount + srows = c.execute("SELECT image_path FROM screenshots").fetchall() + for r in srows: + _unlink_rel(r["image_path"]) + total += c.execute("DELETE FROM screenshots").rowcount + drows = c.execute("SELECT image_path, xml_path FROM screen_dumps").fetchall() + for r in drows: + _unlink_rel(r["image_path"]) + _unlink_rel(r["xml_path"]) + total += c.execute("DELETE FROM screen_dumps").rowcount + for d in FILES_DIR.glob("*"): + if d.is_dir(): + try: + next(d.iterdir()) + except StopIteration: + d.rmdir() + return total + + +def _disk_badge() -> str: + import shutil + usage = shutil.disk_usage(FILES_DIR) + pct = usage.used / usage.total * 100 + free_gb = usage.free / (1024 ** 3) + if pct >= 85: + color = "#fecaca"; bg = "#7f1d1d" + elif pct >= 70: + color = "#fde68a"; bg = "#78350f" + else: + color = "#bbf7d0"; bg = "#14532d" + return ( + f'' + f'диск: {free_gb:.1f} ГБ свободно ({100-pct:.0f}%)' + ) + + +def _header(active: str) -> str: + def cls(p: str) -> str: + return ' style="color:#e6e6e6;font-weight:600"' if p == active else "" + clear_btn = ( + '
' + '
' + ) + dl_btn = ( + '' + '⬇ Скачать архив' + ) + return ( + "
ARC monitoring" + '" + f'хранение {RETENTION_DAYS} сут · время UTC' + f'{_disk_badge()}' + f'{dl_btn}' + f'{clear_btn}' + 'выход' + "
" + ) + + +# ──────────────────────────── роуты админки ──────────────────────────── + +@app.get("/admin/login") +def admin_login_form() -> HTMLResponse: + return _login_page() + + +@app.post("/admin/login") +def admin_login(username: str = Form(""), password: str = Form("")) -> Response: + ok = hmac.compare_digest(username, ADMIN_USER) and hmac.compare_digest(password, ADMIN_PASSWORD) + if not ok: + return _login_page("Неверный логин или пароль") + resp = RedirectResponse("/admin", status_code=303) + resp.set_cookie(_COOKIE_NAME, _COOKIE_VALUE, httponly=True, samesite="lax", max_age=30 * 86400) + return resp + + +@app.get("/admin/logout") +def admin_logout() -> Response: + resp = RedirectResponse("/admin/login", status_code=303) + resp.delete_cookie(_COOKIE_NAME) + return resp + + +def _query_entries(kind, type_, event, q, date_from, date_to, page): + where, params = [], [] + if kind: + where.append("kind=?"); params.append(kind) + if type_: + where.append("type=?"); params.append(type_) + if event: + where.append("event LIKE ?"); params.append(f"%{event}%") + if q: + where.append("message LIKE ?"); params.append(f"%{q}%") + df = _parse_date(date_from) + if df is not None: + where.append("ts>=?"); params.append(df) + dt = _parse_date(date_to, end=True) + if dt is not None: + where.append("ts'' ORDER BY type" + ).fetchall()] + return rows, total, types + + +def _build_entry_trs(rows, current_url): + keys = [_corr_key(r["message"]) for r in rows] + row_group = [0] * len(rows) + group_size: dict[int, int] = {} + PAIR_WINDOW = 300.0 + gid = 0 + i = 0 + while i < len(rows): + if keys[i] is None: + i += 1 + continue + seen_tasks = set() + if rows[i]["internal_task_id"]: + seen_tasks.add(rows[i]["internal_task_id"]) + j = i + 1 + while j < len(rows): + if keys[j] != keys[i]: + break + if abs(rows[j]["ts"] - rows[j - 1]["ts"]) > PAIR_WINDOW: + break + tid = rows[j]["internal_task_id"] + if tid and seen_tasks and tid not in seen_tasks: + break + if tid: + seen_tasks.add(tid) + j += 1 + if j - i >= 2: + gid += 1 + for x in range(i, j): + row_group[x] = gid + group_size[gid] = j - i + i = j + + trs = [] + for idx, r in enumerate(rows): + attach = [] + if r["image_path"]: + attach.append( + f'' + ) + if r["file_path"]: + attach.append(f'⬇ архив') + t = html_lib.escape(r["type"] or "") + del_form = ( + f'
" + f'' + f'
' + ) + g = row_group[idx] + tr_cls = "" + pair_tag = "" + if g: + tr_cls = f" class=\"grp grp{g % 2}\"" + if idx == 0 or row_group[idx - 1] != g: + pair_tag = f'↕ пара ×{group_size[g]}' + trs.append( + f"" + f'{r["id"]}' + f"{_ts_str(r['ts'])}" + f'' + f'{t}{pair_tag}
{html_lib.escape(r["kind"])}
' + f'{html_lib.escape(r["event"] or "")}' + f'{html_lib.escape(r["internal_task_id"] or "")}' + f'{_render_msg(r["message"])}' + f'{"
".join(attach)}' + f' ' + f'{del_form}' + "" + ) + if not rows: + trs.append('Ничего не найдено') + return trs + + +@app.get("/admin", response_class=HTMLResponse) +def admin_dashboard( + request: Request, + kind: str = "", + type: str = "", + event: str = "", + q: str = "", + date_from: str = "", + date_to: str = "", + page: int = 0, +) -> Response: + if not _is_authed(request): + return RedirectResponse("/admin/login", status_code=303) + + rows, total, types = _query_entries(kind, type, event, q, date_from, date_to, page) + page = max(0, page) + + current_url = (f"/admin?kind={kind}&type={html_lib.escape(type)}&event={html_lib.escape(event)}" + f"&q={html_lib.escape(q)}&date_from={date_from}&date_to={date_to}&page={page}") + + trs = _build_entry_trs(rows, current_url) + + type_opts = _opt("", type) + "".join(_opt(tp, type) for tp in types) + kind_opts = _opt("", kind, "все") + _opt("log", kind, "log") + _opt("dump", kind, "dump") + + def link(p: int) -> str: + qs = (f"kind={kind}&type={html_lib.escape(type)}&event={html_lib.escape(event)}" + f"&q={html_lib.escape(q)}&date_from={date_from}&date_to={date_to}&page={p}") + return f"/admin?{qs}" + + shown_from = page * PAGE_SIZE + 1 if total else 0 + shown_to = min((page + 1) * PAGE_SIZE, total) + prev = f'← назад' if page > 0 else '← назад' + nxt = (f'вперёд →' + if shown_to < total else 'вперёд →') + + body = f""" +ARC monitoring{_STYLE} +{_header('logs')} +
+ + + + + + + + + + +
+
+ + + + +
+ + + {''.join(trs)} +
#Время (UTC)Время (локал.)ТипEventtaskСообщениеВложения
+
{prev}{shown_from}–{shown_to} из {total}{nxt}
+{_IMG_MODAL} +{_bulk_js("записи")} +{_LOCAL_TIME_JS} + +""" + return HTMLResponse(body) + + +@app.get("/admin/entries/fragment") +def admin_entries_fragment( + request: Request, + kind: str = "", + type: str = "", + event: str = "", + q: str = "", + date_from: str = "", + date_to: str = "", + page: int = 0, +) -> JSONResponse: + if not _is_authed(request): + raise HTTPException(status_code=401, detail="auth required") + page = max(0, page) + current_url = (f"/admin?kind={kind}&type={html_lib.escape(type)}&event={html_lib.escape(event)}" + f"&q={html_lib.escape(q)}&date_from={date_from}&date_to={date_to}&page={page}") + rows, total, _ = _query_entries(kind, type, event, q, date_from, date_to, page) + trs = _build_entry_trs(rows, current_url) + return JSONResponse({"rows_html": "".join(trs), "total": total}) + + +def _serve_attachment(request: Request, entry_id: int, column: str, download: bool) -> Response: + if not _is_authed(request): + return RedirectResponse("/admin/login", status_code=303) + with _db() as c: + row = c.execute(f"SELECT {column} AS p FROM entries WHERE id=?", (entry_id,)).fetchone() + if not row or not row["p"]: + raise HTTPException(status_code=404, detail="not found") + path = (FILES_DIR / row["p"]).resolve() + if not str(path).startswith(str(FILES_DIR.resolve()) + os.sep) or not path.exists(): + raise HTTPException(status_code=404, detail="not found") + return FileResponse( + path, + filename=path.name if download else None, + media_type="application/zip" if download else None, + ) + + +@app.get("/admin/entry/{entry_id}/image") +def admin_image(request: Request, entry_id: int) -> Response: + return _serve_attachment(request, entry_id, "image_path", download=False) + + +@app.get("/admin/entry/{entry_id}/file") +def admin_file(request: Request, entry_id: int) -> Response: + return _serve_attachment(request, entry_id, "file_path", download=True) + + +@app.get("/admin/screen/{sid}/image") +def admin_screen_image(request: Request, sid: int) -> Response: + if not _is_authed(request): + return RedirectResponse("/admin/login", status_code=303) + with _db() as c: + row = c.execute("SELECT image_path FROM screenshots WHERE id=?", (sid,)).fetchone() + if not row or not row["image_path"]: + raise HTTPException(status_code=404, detail="not found") + path = (FILES_DIR / row["image_path"]).resolve() + if not str(path).startswith(str(FILES_DIR.resolve()) + os.sep) or not path.exists(): + raise HTTPException(status_code=404, detail="not found") + return FileResponse(path) + + +@app.post("/admin/screen/{sid}/delete") +def admin_screen_delete(request: Request, sid: int, next: str = Form("/admin/screens")) -> Response: + if not _is_authed(request): + return RedirectResponse("/admin/login", status_code=303) + with _db() as c: + row = c.execute("SELECT image_path FROM screenshots WHERE id=?", (sid,)).fetchone() + if row and row["image_path"]: + path = (FILES_DIR / row["image_path"]).resolve() + if str(path).startswith(str(FILES_DIR.resolve()) + os.sep): + try: + path.unlink(missing_ok=True) + except OSError as e: + LOG.warning("delete: cannot unlink %s: %s", row["image_path"], e) + c.execute("DELETE FROM screenshots WHERE id=?", (sid,)) + # защита от open-redirect: возвращаемся только на внутренние /admin-пути + target = next if next.startswith("/admin") else "/admin/screens" + return RedirectResponse(target, status_code=303) + + +@app.post("/admin/screens/delete") +def admin_screens_bulk_delete(request: Request, + ids: list[int] = Form(default=[]), + next: str = Form("/admin/screens")) -> Response: + """Массовое удаление кадров истории экрана: список id из чекбоксов галереи.""" + if not _is_authed(request): + return RedirectResponse("/admin/login", status_code=303) + if ids: + ph = ",".join("?" * len(ids)) + with _db() as c: + rows = c.execute( + f"SELECT image_path FROM screenshots WHERE id IN ({ph})", ids + ).fetchall() + for row in rows: + rel = row["image_path"] + if not rel: + continue + path = (FILES_DIR / rel).resolve() + if str(path).startswith(str(FILES_DIR.resolve()) + os.sep): + try: + path.unlink(missing_ok=True) + except OSError as e: + LOG.warning("bulk delete: cannot unlink %s: %s", rel, e) + c.execute(f"DELETE FROM screenshots WHERE id IN ({ph})", ids) + LOG.info("bulk delete: removed %d screenshots", len(rows)) + target = next if next.startswith("/admin") else "/admin/screens" + return RedirectResponse(target, status_code=303) + + +def _serve_dump(request: Request, dump_id: int, column: str, + download: bool, media_type: Optional[str]) -> Response: + if not _is_authed(request): + return RedirectResponse("/admin/login", status_code=303) + with _db() as c: + row = c.execute( + f"SELECT {column} AS p FROM screen_dumps WHERE id=?", (dump_id,) + ).fetchone() + if not row or not row["p"]: + raise HTTPException(status_code=404, detail="not found") + path = (FILES_DIR / row["p"]).resolve() + if not str(path).startswith(str(FILES_DIR.resolve()) + os.sep) or not path.exists(): + raise HTTPException(status_code=404, detail="not found") + return FileResponse( + path, + filename=path.name if download else None, + media_type=media_type, + ) + + +@app.get("/admin/dump/{dump_id}/image") +def admin_dump_image(request: Request, dump_id: int) -> Response: + return _serve_dump(request, dump_id, "image_path", download=False, media_type=None) + + +@app.get("/admin/dump/{dump_id}/xml") +def admin_dump_xml(request: Request, dump_id: int) -> Response: + return _serve_dump( + request, dump_id, "xml_path", download=True, media_type="application/xml" + ) + + +@app.post("/admin/dump/{dump_id}/delete") +def admin_dump_delete(request: Request, dump_id: int, + next: str = Form("/admin/dumps")) -> Response: + if not _is_authed(request): + return RedirectResponse("/admin/login", status_code=303) + with _db() as c: + row = c.execute( + "SELECT image_path, xml_path FROM screen_dumps WHERE id=?", (dump_id,) + ).fetchone() + if row: + for rel in (row["image_path"], row["xml_path"]): + if not rel: + continue + path = (FILES_DIR / rel).resolve() + if str(path).startswith(str(FILES_DIR.resolve()) + os.sep): + try: + path.unlink(missing_ok=True) + except OSError as e: + LOG.warning("delete: cannot unlink %s: %s", rel, e) + c.execute("DELETE FROM screen_dumps WHERE id=?", (dump_id,)) + target = next if next.startswith("/admin") else "/admin/dumps" + return RedirectResponse(target, status_code=303) + + +@app.post("/admin/dumps/delete") +def admin_dumps_bulk_delete(request: Request, + ids: list[int] = Form(default=[]), + next: str = Form("/admin/dumps")) -> Response: + """Массовое удаление дампов экрана: список id из чекбоксов галереи.""" + if not _is_authed(request): + return RedirectResponse("/admin/login", status_code=303) + if ids: + ph = ",".join("?" * len(ids)) + with _db() as c: + rows = c.execute( + f"SELECT image_path, xml_path FROM screen_dumps WHERE id IN ({ph})", + ids, + ).fetchall() + for row in rows: + for rel in (row["image_path"], row["xml_path"]): + if not rel: + continue + path = (FILES_DIR / rel).resolve() + if str(path).startswith(str(FILES_DIR.resolve()) + os.sep): + try: + path.unlink(missing_ok=True) + except OSError as e: + LOG.warning("bulk delete: cannot unlink %s: %s", rel, e) + c.execute(f"DELETE FROM screen_dumps WHERE id IN ({ph})", ids) + LOG.info("bulk delete: removed %d screen_dumps", len(rows)) + target = next if next.startswith("/admin") else "/admin/dumps" + return RedirectResponse(target, status_code=303) + + +@app.post("/admin/entry/{entry_id}/delete") +def admin_delete(request: Request, entry_id: int, next: str = Form("/admin")) -> Response: + if not _is_authed(request): + return RedirectResponse("/admin/login", status_code=303) + with _db() as c: + row = c.execute( + "SELECT image_path, file_path FROM entries WHERE id=?", (entry_id,) + ).fetchone() + if row: + for rel in (row["image_path"], row["file_path"]): + if not rel: + continue + path = (FILES_DIR / rel).resolve() + if str(path).startswith(str(FILES_DIR.resolve()) + os.sep): + try: + path.unlink(missing_ok=True) + except OSError as e: + LOG.warning("delete: cannot unlink %s: %s", rel, e) + c.execute("DELETE FROM entries WHERE id=?", (entry_id,)) + # защита от open-redirect: возвращаемся только на внутренние /admin-пути + target = next if next.startswith("/admin") else "/admin" + return RedirectResponse(target, status_code=303) + + +@app.post("/admin/entries/delete") +def admin_entries_bulk_delete(request: Request, + ids: list[int] = Form(default=[]), + next: str = Form("/admin")) -> Response: + """Массовое удаление записей логов: список id из чекбоксов таблицы.""" + if not _is_authed(request): + return RedirectResponse("/admin/login", status_code=303) + if ids: + ph = ",".join("?" * len(ids)) + with _db() as c: + rows = c.execute( + f"SELECT image_path, file_path FROM entries WHERE id IN ({ph})", ids + ).fetchall() + for row in rows: + for rel in (row["image_path"], row["file_path"]): + if not rel: + continue + path = (FILES_DIR / rel).resolve() + if str(path).startswith(str(FILES_DIR.resolve()) + os.sep): + try: + path.unlink(missing_ok=True) + except OSError as e: + LOG.warning("bulk delete: cannot unlink %s: %s", rel, e) + c.execute(f"DELETE FROM entries WHERE id IN ({ph})", ids) + LOG.info("bulk delete: removed %d entries", len(rows)) + target = next if next.startswith("/admin") else "/admin" + return RedirectResponse(target, status_code=303) + + +@app.post("/admin/clear-all") +def admin_clear_all(request: Request, next: str = Form("/admin")) -> Response: + """Удаляет все записи и файлы из всех таблиц.""" + if not _is_authed(request): + return RedirectResponse("/admin/login", status_code=303) + n = _clear_all() + LOG.info("clear-all: removed %d total records by admin", n) + target = next if next.startswith("/admin") else "/admin" + return RedirectResponse(target, status_code=303) + + +_DESK_STYLE = """ + +""" + + +@app.get("/admin/desktops", response_class=HTMLResponse) +def admin_desktops(request: Request, date_from: str = "", date_to: str = "") -> Response: + if not _is_authed(request): + return RedirectResponse("/admin/login", status_code=303) + + where, params = [], [] + df = _parse_date(date_from) + if df is not None: + where.append("ts>=?"); params.append(df) + dt = _parse_date(date_to, end=True) + if dt is not None: + where.append("ts dict: + return {"total": 0, "errors": 0, "last_ts": 0.0, "phones": set(), "androids": set()} + + def _new_desk() -> dict: + return {"total": 0, "errors": 0, "last_ts": 0.0, "types": {}, + "phones": set(), "profiles": set(), "devices": {}} + + desks: dict = {} + for r in rows: + meta = _meta(r["message"]) + dk = meta.get("desktop") or "—" + d = desks.get(dk) + if d is None: + d = desks[dk] = _new_desk() + is_err = (r["type"] or "") in ("ERROR", "TASK_ERROR") + d["total"] += 1 + d["errors"] += int(is_err) + d["last_ts"] = max(d["last_ts"], r["ts"]) + d["types"][r["type"] or "?"] = d["types"].get(r["type"] or "?", 0) + 1 + if meta.get("profile"): + d["profiles"].add(meta["profile"]) + dev_id = meta.get("device") or "—" + dev = d["devices"].get(dev_id) + if dev is None: + dev = d["devices"][dev_id] = _new_dev() + dev["total"] += 1 + dev["errors"] += int(is_err) + dev["last_ts"] = max(dev["last_ts"], r["ts"]) + if meta.get("phone"): + dev["phones"].add(meta["phone"]) + d["phones"].add(meta["phone"]) + if meta.get("android"): + dev["androids"].add(meta["android"]) + + def _desk_param(dk: str) -> str: + # десктоп/девайс в query фрагмента; бакет «—» передаём как есть + from urllib.parse import quote + return quote(dk, safe="") + + cards = [] + uid = 0 + for dk, d in sorted(desks.items(), key=lambda kv: kv[1]["last_ts"], reverse=True): + dk_esc = html_lib.escape(dk) + dk_q = _desk_param(dk) + type_chips = "".join( + f'{html_lib.escape(t)}: {n}' + for t, n in sorted(d["types"].items(), key=lambda kv: -kv[1]) + ) + err_stat = (f'ошибок {d["errors"]}' + if d["errors"] else 'ошибок 0') + + dev_rows = [] + for dev_id, dev in sorted(d["devices"].items(), key=lambda kv: kv[1]["last_ts"], reverse=True): + phones = ", ".join(sorted(dev["phones"])) or "—" + androids = ", ".join(sorted(dev["androids"])) or "—" + errs = f'{dev["errors"]}' if dev["errors"] else "0" + uid += 1 + sid = f"r{uid}" + url = (f"/admin/desktops/rows?desktop={dk_q}" + f"&device={_desk_param(dev_id)}{dates_qs}") + dev_rows.append( + "" + f'{html_lib.escape(dev_id)}' + f'{html_lib.escape(androids)}' + f"{html_lib.escape(phones)}" + f'{dev["total"]}' + f"{errs}" + f'{_ts_str(dev["last_ts"])}
{_ago(dev["last_ts"], now)}' + f'' + "" + f'' + f'
' + ) + + uid += 1 + ksid = f"k{uid}" + all_url = f"/admin/desktops/rows?desktop={dk_q}{dates_qs}" + cards.append( + '
' + '
' + f'🖥 {dk_esc}' + f'{_ts_str(d["last_ts"])} · {_ago(d["last_ts"], now)}' + f'записей {d["total"]}' + f'{err_stat}' + f'девайсов {len([k for k in d["devices"] if k != "—"])}' + f'профилей {len(d["profiles"])}' + f'' + "
" + f'
{type_chips}
' + "" + "" + "" + f'{"".join(dev_rows)}' + "
ДевайсAndroid IDТелефоныЗаписейОшибокПоследняя (UTC)
" + f'
' + "
" + ) + + if not cards: + cards.append('
Нет данных
') + + body = f""" +ARC monitoring — десктопы{_STYLE}{_DESK_STYLE} +{_header('desktops')} +
+ + + + + +
+
{''.join(cards)}
+{_IMG_MODAL} + +""" + return HTMLResponse(body) + + +@app.get("/admin/desktops/rows", response_class=HTMLResponse) +def admin_desktop_rows(request: Request, desktop: str = "", device: str = "", + date_from: str = "", date_to: str = "") -> Response: + """Фрагмент: записи конкретного десктопа (и опц. девайса) для инлайн-разворота.""" + if not _is_authed(request): + raise HTTPException(status_code=401, detail="auth required") + + where, params = [], [] + df = _parse_date(date_from) + if df is not None: + where.append("ts>=?"); params.append(df) + dt = _parse_date(date_to, end=True) + if dt is not None: + where.append("ts= 300: + break + return HTMLResponse(_entry_rows_html(out)) + + +_SCREENS_STYLE = """ + +""" + + +@app.get("/admin/screens", response_class=HTMLResponse) +def admin_screens(request: Request, device: str = "", + date_from: str = "", date_to: str = "") -> Response: + """История экрана по девайсу: список девайсов + галерея кадров выбранного.""" + if not _is_authed(request): + return RedirectResponse("/admin/login", status_code=303) + + now = time.time() + with _db() as c: + devices = c.execute( + """SELECT device_id, MAX(name) name, MAX(desktop_id) desktop_id, + MAX(ts) last, COUNT(*) n + FROM screenshots GROUP BY device_id ORDER BY last DESC""" + ).fetchall() + + dev_opts = [''] + for d in devices: + sel = " selected" if d["device_id"] == device else "" + label = d["name"] or d["device_id"] or "—" + dev_opts.append( + f'' + ) + + # Список девайсов с временем последней активности (последний кадр). + dates_qs = (f"&date_from={html_lib.escape(date_from)}" + f"&date_to={html_lib.escape(date_to)}") + dev_trs = [] + for d in devices: + sel = " class=sel" if d["device_id"] == device else "" + label = d["name"] or d["device_id"] or "—" + view_url = f"/admin/screens?device={html_lib.escape(d['device_id'])}{dates_qs}" + dev_trs.append( + f"" + f'{html_lib.escape(label)}' + f'{html_lib.escape(d["desktop_id"] or "—")}' + f'{d["n"]}' + f'{_ts_str(d["last"])}
' + f'{_ago(d["last"], now)}' + f'смотреть →' + "" + ) + if dev_trs: + dev_table = ( + "" + "" + "" + "" + "".join(dev_trs) + "
ДевайсДесктопКадровПоследняя активность (UTC)
" + ) + else: + dev_table = '
Пока нет ни одного кадра
' + + gallery = "" + if device: + where = ["device_id=?"] + params: list = [device] + df = _parse_date(date_from) + if df is not None: + where.append("ts>=?"); params.append(df) + dt = _parse_date(date_to, end=True) + if dt is not None: + where.append("ts' + f'' + f'' + f'
{_ts_str(s["ts"])} UTC
' + f' локал.
' + f'
" + f'' + f'
' + "
" + "" + for s in shots + ) + bulk_bar = ( + '
' + f'' + '' + '' + '
' + ) + gallery = f"{bulk_bar}
{cells}
" + else: + gallery = '
Нет кадров за выбранный период
' + else: + gallery = '
Выберите девайс, чтобы посмотреть историю экрана
' + + body = f""" +ARC monitoring — экраны{_STYLE}{_SCREENS_STYLE} +{_header('screens')} +
+ + + + + + история экрана · хранение {SCREENSHOT_RETENTION_HOURS} ч · время UTC +
+{dev_table} +{gallery} +{_IMG_MODAL} +{_bulk_js("кадры")} +{_LOCAL_TIME_JS} +""" + return HTMLResponse(body) + + +_DUMPS_STYLE = """ + +""" + + +@app.get("/admin/dumps", response_class=HTMLResponse) +def admin_dumps(request: Request, device: str = "", + date_from: str = "", date_to: str = "", page: int = 0) -> Response: + """Ручные дампы экрана (XML + скриншот) с кнопки превью девайса.""" + if not _is_authed(request): + return RedirectResponse("/admin/login", status_code=303) + + now = time.time() + with _db() as c: + devices = c.execute( + """SELECT device, MAX(ts) last, COUNT(*) n + FROM screen_dumps GROUP BY device ORDER BY last DESC""" + ).fetchall() + + dev_opts = [''] + for d in devices: + sel = " selected" if d["device"] == device else "" + label = d["device"] or "—" + dev_opts.append( + f'' + ) + + where, params = [], [] + if device: + where.append("device=?"); params.append(device) + df = _parse_date(date_from) + if df is not None: + where.append("ts>=?"); params.append(df) + dt = _parse_date(date_to, end=True) + if dt is not None: + where.append("ts[_v<версия>]_<время> — у фото и XML + # совпадает, различаются только расширением (атрибут download переопределяет + # имя файла при скачивании same-origin, в т.ч. из зум-модалки через data-name). + ver = r["app_version"] or "" + fname_ts = datetime.fromtimestamp(r["ts"], timezone.utc).strftime("%Y-%m-%d_%H-%M-%S") + name_parts = [r["device"] or "device"] + if ver: + name_parts.append("v" + ver) + name_parts.append(fname_ts) + base = _SAFE_NAME_RE.sub("_", "_".join(name_parts)) + img_ext = os.path.splitext(r["image_path"])[1] or ".jpg" if r["image_path"] else ".jpg" + if r["image_path"]: + thumb = (f'
') + else: + thumb = '
нет скриншота
' + acts = [] + if r["image_path"]: + acts.append( + f'⬇ фото' + ) + if r["xml_path"]: + acts.append( + f'⬇ XML' + ) + acts.append( + f'
" + f'' + f'
' + ) + cards.append( + "
" + f'' + f"{thumb}" + "
" + f'{html_lib.escape(r["device"] or "—")}' + f'{f" v{html_lib.escape(ver)}" if ver else ""}' + f'{_ts_str(r["ts"])} UTC · {_ago(r["ts"], now)}' + f'
локал.
' + f'{"".join(acts)}' + "
" + ) + gallery = (f"
{''.join(cards)}
" if cards + else '
Пока нет ни одного дампа экрана
') + + def link(p: int) -> str: + return (f"/admin/dumps?device={html_lib.escape(device)}" + f"&date_from={html_lib.escape(date_from)}" + f"&date_to={html_lib.escape(date_to)}&page={p}") + + shown_from = page * PAGE_SIZE + 1 if total else 0 + shown_to = min((page + 1) * PAGE_SIZE, total) + prev = (f'← назад' if page > 0 + else '← назад') + nxt = (f'вперёд →' + if shown_to < total else 'вперёд →') + + body = f""" +ARC monitoring — дампы экрана{_STYLE}{_DUMPS_STYLE} +{_header('dumps')} +
+ + + + + + XML + скриншот · хранение {SCREEN_DUMP_RETENTION_DAYS} сут · время UTC +
+
+ + + + +
+{gallery} +
{prev}{shown_from}–{shown_to} из {total}{nxt}
+{_IMG_MODAL} +{_bulk_js("дампы")} +{_LOCAL_TIME_JS} +""" + return HTMLResponse(body) + + +@app.get("/admin/logs/export") +async def admin_logs_export(request: Request) -> Response: + """Скачать ZIP с дампом entries (JSON) + вложенными архивами логов. + Скриншоты (image_path) и данные screen_dumps/screenshots не включаются.""" + import io + import json + import zipfile + + if not _is_authed(request): + return RedirectResponse("/admin/login", status_code=303) + + with _db() as c: + rows = c.execute("SELECT * FROM entries ORDER BY ts DESC").fetchall() + + entries_data = [dict(r) for r in rows] + + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr("entries.json", json.dumps(entries_data, ensure_ascii=False, indent=2)) + added: set[str] = set() + for r in rows: + rel = r["file_path"] + if not rel or rel in added: + continue + path = (FILES_DIR / rel).resolve() + files_root = str(FILES_DIR.resolve()) + os.sep + if str(path).startswith(files_root) and path.exists(): + zf.write(path, f"files/{path.name}") + added.add(rel) + + buf.seek(0) + stamp = datetime.now(timezone.utc).strftime("%Y-%m-%d_%H-%M-%S") + filename = f"arc_logs_{stamp}.zip" + return Response( + content=buf.getvalue(), + media_type="application/zip", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +@app.get("/") +def root() -> Response: + return RedirectResponse("/admin", status_code=303) \ No newline at end of file