"""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 json as json_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 _disk_pct() -> float: import shutil u = shutil.disk_usage(FILES_DIR) return u.used / u.total def _emergency_purge_screenshots() -> int: """Постепенно удаляет файлы когда диск > 80%. Порядок: сначала скрины (screenshots + screen_dumps) по одному от старых к новым, потом zip-дампы (entries.file_path) от старых к новым. """ if _disk_pct() < DISK_EMERGENCY_THRESHOLD: return 0 removed = 0 # 1. Скрины из таблицы screenshots (самые старые первыми) with _db() as c: rows = c.execute( "SELECT id, image_path FROM screenshots ORDER BY ts ASC" ).fetchall() for r in rows: if _disk_pct() < DISK_EMERGENCY_THRESHOLD: break _unlink_rel(r["image_path"]) with _db() as c: c.execute("DELETE FROM screenshots WHERE id=?", (r["id"],)) removed += 1 if _disk_pct() < DISK_EMERGENCY_THRESHOLD: if removed: LOG.warning("disk emergency: removed %d screenshots, disk now %.0f%%", removed, _disk_pct() * 100) return removed # 2. Скрины из таблицы screen_dumps (самые старые первыми) with _db() as c: rows = c.execute( "SELECT id, image_path, xml_path FROM screen_dumps ORDER BY ts ASC" ).fetchall() for r in rows: if _disk_pct() < DISK_EMERGENCY_THRESHOLD: break _unlink_rel(r["image_path"]) _unlink_rel(r["xml_path"]) with _db() as c: c.execute("DELETE FROM screen_dumps WHERE id=?", (r["id"],)) removed += 1 if _disk_pct() < DISK_EMERGENCY_THRESHOLD: LOG.warning("disk emergency: removed %d screen files, disk now %.0f%%", removed, _disk_pct() * 100) return removed # 3. Zip-дампы из entries (самые старые первыми) with _db() as c: rows = c.execute( "SELECT id, file_path FROM entries WHERE file_path IS NOT NULL ORDER BY ts ASC" ).fetchall() for r in rows: if _disk_pct() < DISK_EMERGENCY_THRESHOLD: break _unlink_rel(r["file_path"]) with _db() as c: c.execute("UPDATE entries SET file_path=NULL WHERE id=?", (r["id"],)) removed += 1 LOG.warning( "disk emergency purge: removed %d files total, disk now %.0f%%", removed, _disk_pct() * 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(120) @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().replace("T", " ") if not s: return None # Принимаем как дату, так и дату со временем (datetime-local), всё в UTC. for fmt, has_time in (("%Y-%m-%d %H:%M:%S", True), ("%Y-%m-%d %H:%M", True), ("%Y-%m-%d", False)): try: d = datetime.strptime(s, fmt).replace(tzinfo=timezone.utc) except ValueError: continue # Для верхней границы без времени берём весь день целиком. if end and not has_time: d += timedelta(days=1) return d.timestamp() return None 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 _pretty_json(raw: str) -> str: """Pretty-print JSON string; return original if not valid JSON.""" try: obj = json_lib.loads(html_lib.unescape(raw.strip())) return html_lib.escape(json_lib.dumps(obj, ensure_ascii=False, indent=2)) except (json_lib.JSONDecodeError, ValueError): return raw def _render_msg(message: str) -> str: """Клиент шлёт логи ошибок уже обёрнутыми в структурные теги , динамику внутри экранирует на стороне C++ (toHtmlEscaped). Прочие источники (AppLogger, task_success) шлют голый текст. Экранируем всё, затем возвращаем из белого списка только / — bold рендерится, инъекция HTML невозможна. Тело JSON (всё после маркера «JSON: » до конца сообщения, либо bare-JSON на последней строке) выносим в моноширинный
-блок с pretty-print.
    Содержимое уже экранировано, инъекций нет."""
    s = html_lib.escape(message or "")
    s = s.replace("<b>", "").replace("</b>", "")

    json_body = None
    head = s

    marker = "\nJSON: "
    idx = s.find(marker)
    if idx != -1:
        head = s[:idx]
        json_body = s[idx + len(marker):]
    else:
        # Bare JSON block at end of message (no "JSON: " prefix)
        nl = s.rfind("\n")
        if nl != -1:
            last = s[nl + 1:].lstrip()
            if last.startswith("{") or last.startswith("["):
                head = s[:nl]
                json_body = s[nl + 1:]

    if json_body is not None:
        s = f'{head}\n
{_pretty_json(json_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, q2=""): 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}%") if q2: where.append("message LIKE ?"); params.append(f"%{q2}%") 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 = "", q2: 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, q2) 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)}&q2={html_lib.escape(q2)}&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)}&q2={html_lib.escape(q2)}&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 = "", q2: 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)}&q2={html_lib.escape(q2)}&date_from={date_from}&date_to={date_to}&page={page}") rows, total, _ = _query_entries(kind, type, event, q, date_from, date_to, page, q2) 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)