1
0
forked from BRT/arc
arc/monitoring/app.py

2062 lines
89 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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/<дата>/<ts>_<safe-name>, возвращает путь
относительно 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_<name>_<yyyy-MM-dd_HH-mm-ss>.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 (`<b>[TASK_ERROR]…</b>`). Их храним на
# сервере (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 = """
<style>
:root{color-scheme:light dark}
body{font:14px/1.4 system-ui,sans-serif;margin:0;background:#0f1115;color:#e6e6e6}
header{padding:10px 16px;background:#171a21;border-bottom:1px solid #2a2f3a;display:flex;align-items:center;gap:16px}
header b{font-size:16px}
a{color:#5aa9ff;text-decoration:none}
form.filters{padding:12px 16px;display:flex;flex-wrap:wrap;gap:10px;align-items:end;background:#13161c;border-bottom:1px solid #2a2f3a}
label{display:flex;flex-direction:column;font-size:11px;color:#9aa4b2;gap:3px}
input,select{background:#0f1115;color:#e6e6e6;border:1px solid #2a2f3a;border-radius:6px;padding:6px 8px;font:13px system-ui}
button{background:#2563eb;color:#fff;border:0;border-radius:6px;padding:7px 14px;cursor:pointer;font:13px system-ui}
button.sec{background:#2a2f3a}
table{width:100%;border-collapse:collapse}
th,td{padding:7px 10px;border-bottom:1px solid #20242e;text-align:left;vertical-align:top}
th{position:sticky;top:0;background:#171a21;font-size:11px;color:#9aa4b2;text-transform:uppercase}
tr:hover td{background:#161a22}
.msg{max-width:640px;white-space:pre-wrap;word-break:break-word}
.badge{display:inline-block;padding:2px 7px;border-radius:5px;font-size:11px;font-weight:600}
.ERROR{background:#7f1d1d;color:#fecaca}.INFO{background:#1e3a5f;color:#bfdbfe}
.DUMP{background:#3b2f00;color:#fde68a}.WARNING{background:#7c2d12;color:#fed7aa}
.LOGS{background:#14532d;color:#bbf7d0}.TASK_ERROR{background:#7f1d1d;color:#fecaca}
.kind{font-size:11px;color:#9aa4b2}
img.thumb{max-height:54px;border-radius:4px;border:1px solid #2a2f3a}
.pager{padding:14px 16px;display:flex;gap:12px;align-items:center}
.muted{color:#6b7280}
form.del{margin:0;display:inline}
button.del{background:#7f1d1d;padding:4px 10px;font-size:13px;line-height:1}
button.del:hover{background:#991b1b}
tr.grp td{background:#0e1726}
tr.grp td:first-child{border-left:3px solid #5aa9ff}
tr.grp1 td{background:#111d16}
.pairtag{display:inline-block;margin-left:6px;font-size:10px;color:#5aa9ff;border:1px solid #2d4f73;border-radius:4px;padding:1px 5px;vertical-align:middle}
img.thumb{cursor:zoom-in}
.modal{display:none;position:fixed;inset:0;background:rgba(0,0,0,.82);z-index:1000;align-items:center;justify-content:center;padding:24px}
.modal .box{display:flex;flex-direction:column;gap:8px;max-width:96vw;max-height:96vh}
.modal .bar{display:flex;gap:10px;align-items:center;justify-content:flex-end}
.modal img{max-width:96vw;max-height:86vh;object-fit:contain;border-radius:8px;border:1px solid #2a2f3a;background:#000}
a.dlbtn{background:#2563eb;color:#fff;border-radius:6px;padding:7px 14px;font:13px system-ui;cursor:pointer}
button.closebtn{background:#2a2f3a;padding:6px 12px;font-size:15px;line-height:1}
.bulkbar{display:flex;gap:8px;align-items:center;padding:10px 16px 0;flex-wrap:wrap}
.bulkbar .delbulk{background:#b91c1c;color:#fff;border:none;border-radius:6px;padding:6px 12px;font-size:13px;cursor:pointer}
.bulkbar .delbulk:hover{background:#dc2626}
input.selbox{width:16px;height:16px;cursor:pointer;accent-color:#ef4444;vertical-align:middle}
pre.json{margin:6px 0 0;padding:8px 10px;background:#0e1116;border:1px solid #2a2f3a;border-radius:6px;font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace;color:#cbd5e1;white-space:pre-wrap;word-break:break-word;overflow-x:auto}
</style>
"""
_IMG_MODAL = """
<div id=imgModal class=modal onclick="if(event.target===this)closeImg()">
<div class=box>
<div class=bar>
<a id=imgDl class=dlbtn download>⬇ скачать</a>
<button type=button class=closebtn onclick="closeImg()">✕</button>
</div>
<img id=imgFull alt="">
</div>
</div>
<script>
function openImg(el){
document.getElementById('imgFull').src=el.src;
var dl=document.getElementById('imgDl');
dl.href=el.src;dl.download=el.dataset.name||('screenshot-'+el.dataset.id+'.png');
document.getElementById('imgModal').style.display='flex';
}
function closeImg(){
document.getElementById('imgModal').style.display='none';
document.getElementById('imgFull').src='';
}
document.addEventListener('keydown',function(e){if(e.key==='Escape')closeImg();});
</script>
"""
# Заполняет <span class=loc data-ts="<epoch>"> локальным временем браузера и,
# если есть <* id=locth>, проставляет в него имя таймзоны. Общий для всех страниц.
_LOCAL_TIME_JS = """
<script>
(function(){
var tz=Intl.DateTimeFormat().resolvedOptions().timeZone||'локал.';
var th=document.getElementById('locth');
if(th)th.textContent='Время ('+tz+')';
var pad=function(n){return(n<10?'0':'')+n;};
document.querySelectorAll('span.loc').forEach(function(s){
var ts=parseFloat(s.dataset.ts);
if(isNaN(ts))return;
var d=new Date(ts*1000);
s.textContent=d.getFullYear()+'-'+pad(d.getMonth()+1)+'-'+pad(d.getDate())+' '
+pad(d.getHours())+':'+pad(d.getMinutes())+':'+pad(d.getSeconds());
});
})();
</script>
"""
# Выбор чекбоксов name=ids (form=bulkForm) + счётчик + подтверждение. Общий для
# страниц с массовым удалением (логи, дампы, скриншоты). Имя сущности — для confirm.
def _bulk_js(noun: str) -> str:
return """
<script>
function _bulkBoxes(){return document.querySelectorAll('input[name=ids]');}
function updCount(){
var n=0;_bulkBoxes().forEach(function(b){if(b.checked)n++;});
document.getElementById('selCount').textContent=n;
}
function selAll(v){_bulkBoxes().forEach(function(b){b.checked=v;});updCount();}
function confirmBulk(){
var n=0;_bulkBoxes().forEach(function(b){if(b.checked)n++;});
if(!n){alert('Ничего не выбрано');return false;}
return confirm('Удалить выбранные __NOUN__: '+n+'?');
}
updCount();
</script>
""".replace("__NOUN__", noun)
def _login_page(error: str = "") -> HTMLResponse:
err = f'<p style="color:#fca5a5">{html_lib.escape(error)}</p>' if error else ""
body = f"""<!doctype html><html><head><meta charset=utf-8>
<title>ARC monitoring — вход</title>{_STYLE}</head><body>
<div style="max-width:320px;margin:12vh auto;padding:24px;background:#171a21;border:1px solid #2a2f3a;border-radius:12px">
<h2 style="margin:0 0 16px">ARC monitoring</h2>{err}
<form method=post action="/admin/login">
<label>Логин<input name=username autofocus></label><br><br>
<label>Пароль<input name=password type=password></label><br><br>
<button type=submit style="width:100%">Войти</button>
</form>
</div></body></html>"""
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'<option value="{html_lib.escape(value)}"{sel}>{html_lib.escape(label or value or "все")}</option>'
_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:
"""Клиент шлёт логи ошибок уже обёрнутыми в структурные теги <b>…</b>,
динамику внутри экранирует на стороне C++ (toHtmlEscaped). Прочие источники
(AppLogger, task_success) шлют голый текст. Экранируем всё, затем возвращаем
из белого списка только <b>/</b> — bold рендерится, инъекция HTML невозможна.
Тело JSON (всё после маркера «JSON: » до конца сообщения, либо bare-JSON на
последней строке) выносим в моноширинный <pre>-блок с pretty-print.
Содержимое уже экранировано, инъекций нет."""
s = html_lib.escape(message or "")
s = s.replace("&lt;b&gt;", "<b>").replace("&lt;/b&gt;", "</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<pre class=json>{_pretty_json(json_body)}</pre>'
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: <id>»). Клиент
# оборачивает шапку в <b>…</b>, но сами поля идут отдельными строками — якорь ^
# в многострочном режиме их находит.
_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 '<div class=muted style="padding:12px">Нет записей</div>'
trs = []
for r in rows:
attach = []
if r["image_path"]:
attach.append(
f'<img class=thumb loading=lazy src="/admin/entry/{r["id"]}/image" '
f'data-id="{r["id"]}" onclick="openImg(this)">'
)
if r["file_path"]:
attach.append(f'<a href="/admin/entry/{r["id"]}/file">⬇ архив</a>')
t = html_lib.escape(r["type"] or "")
trs.append(
"<tr>"
f'<td class=muted style="white-space:nowrap">{_ts_str(r["ts"])}</td>'
f'<td><span class="badge {t}">{t}</span></td>'
f'<td>{html_lib.escape(r["event"] or "")}</td>'
f'<td class=msg>{_render_msg(r["message"])}</td>'
f'<td>{"<br>".join(attach)}</td>'
"</tr>"
)
return (
"<table class=frag><thead><tr><th>Время (UTC)</th><th>Тип</th>"
"<th>Event</th><th>Сообщение</th><th>Вложения</th></tr></thead><tbody>"
+ "".join(trs) + "</tbody></table>"
)
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'<span style="background:{bg};color:{color};border-radius:6px;'
f'padding:3px 10px;font-size:12px;white-space:nowrap">'
f'диск: {free_gb:.1f} ГБ свободно ({100-pct:.0f}%)</span>'
)
def _header(active: str) -> str:
def cls(p: str) -> str:
return ' style="color:#e6e6e6;font-weight:600"' if p == active else ""
clear_btn = (
'<form method=post action="/admin/clear-all" style="margin:0" '
'onsubmit="return confirm(\'Удалить ВСЕ логи, скриншоты и дампы? Это нельзя отменить.\')">'
'<button type=submit style="background:#7f1d1d;color:#fecaca;border:0;border-radius:6px;'
'padding:5px 12px;font:13px system-ui;cursor:pointer" '
'onmouseover="this.style.background=\'#991b1b\'" '
'onmouseout="this.style.background=\'#7f1d1d\'">Очистить всё</button></form>'
)
dl_btn = (
'<a href="/admin/logs/export" style="background:#1e3a5f;color:#bfdbfe;border-radius:6px;'
'padding:5px 12px;font:13px system-ui;text-decoration:none;white-space:nowrap">'
'⬇ Скачать архив</a>'
)
return (
"<header><b>ARC monitoring</b>"
'<nav style="display:flex;gap:14px">'
f'<a href="/admin"{cls("logs")}>Логи</a>'
f'<a href="/admin/desktops"{cls("desktops")}>Десктопы</a>'
f'<a href="/admin/screens"{cls("screens")}>Экраны</a>'
f'<a href="/admin/dumps"{cls("dumps")}>Дампы экрана</a>'
"</nav>"
f'<span class=muted>хранение {RETENTION_DAYS} сут · время UTC</span>'
f'{_disk_badge()}'
f'{dl_btn}'
f'{clear_btn}'
'<span style="margin-left:auto"><a href="/admin/logout">выход</a></span>'
"</header>"
)
# ──────────────────────────── роуты админки ────────────────────────────
@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<?"); params.append(dt)
clause = (" WHERE " + " AND ".join(where)) if where else ""
page = max(0, page)
with _db() as c:
total = c.execute(f"SELECT COUNT(*) n FROM entries{clause}", params).fetchone()["n"]
rows = c.execute(
f"SELECT * FROM entries{clause} ORDER BY ts DESC LIMIT ? OFFSET ?",
(*params, PAGE_SIZE, page * PAGE_SIZE),
).fetchall()
types = [r["type"] for r in c.execute(
"SELECT DISTINCT type FROM entries WHERE type<>'' 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'<img class=thumb loading=lazy src="/admin/entry/{r["id"]}/image" '
f'data-id="{r["id"]}" onclick="openImg(this)">'
)
if r["file_path"]:
attach.append(f'<a href="/admin/entry/{r["id"]}/file">⬇ архив</a>')
t = html_lib.escape(r["type"] or "")
del_form = (
f'<form class=del method=post action="/admin/entry/{r["id"]}/delete" '
f"onsubmit=\"return confirm('Удалить запись #{r['id']}?')\">"
f'<input type=hidden name=next value="{current_url}">'
f'<button class=del type=submit title="Удалить">✕</button></form>'
)
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'<span class=pairtag title="строки одной задачи">↕ пара ×{group_size[g]}</span>'
trs.append(
f"<tr{tr_cls}>"
f'<td class=muted>{r["id"]}</td>'
f"<td>{_ts_str(r['ts'])}</td>"
f'<td class=muted style="white-space:nowrap"><span class=loc data-ts="{r["ts"]}"></span></td>'
f'<td><span class="badge {t}">{t}</span>{pair_tag}<div class=kind>{html_lib.escape(r["kind"])}</div></td>'
f'<td>{html_lib.escape(r["event"] or "")}</td>'
f'<td class=muted>{html_lib.escape(r["internal_task_id"] or "")}</td>'
f'<td class=msg>{_render_msg(r["message"])}</td>'
f'<td>{"<br>".join(attach)}</td>'
f'<td style="white-space:nowrap"><input type=checkbox name=ids '
f'value="{r["id"]}" form=bulkForm class=selbox onchange="updCount()"> '
f'{del_form}</td>'
"</tr>"
)
if not rows:
trs.append('<tr><td colspan=9 class=muted style="padding:24px;text-align:center">Ничего не найдено</td></tr>')
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'<a href="{link(page - 1)}">← назад</a>' if page > 0 else '<span class=muted>← назад</span>'
nxt = (f'<a href="{link(page + 1)}">вперёд →</a>'
if shown_to < total else '<span class=muted>вперёд →</span>')
body = f"""<!doctype html><html><head><meta charset=utf-8>
<title>ARC monitoring</title>{_STYLE}</head><body>
{_header('logs')}
<form class=filters method=get action="/admin">
<label>Kind<select name=kind>{kind_opts}</select></label>
<label>Тип<select name=type>{type_opts}</select></label>
<label>Event<input name=event value="{html_lib.escape(event)}" placeholder="task_error…"></label>
<label>Поиск в тексте<input name=q value="{html_lib.escape(q)}" placeholder="подстрока"></label>
<label>Дата с<input name=date_from type=date value="{html_lib.escape(date_from)}"></label>
<label>Дата по<input name=date_to type=date value="{html_lib.escape(date_to)}"></label>
<button type=submit>Фильтр</button>
<a href="/admin"><button type=button class=sec>Сброс</button></a>
<button id=arBtn type=button class=sec onclick="_arToggle()" style="min-width:90px">⏸ Стоп</button>
<span id=arStatus class=muted style="font-size:12px;align-self:center"></span>
</form>
<form id=bulkForm class=bulkbar method=post action="/admin/entries/delete"
onsubmit="return confirmBulk()">
<input type=hidden name=next value="{current_url}">
<button type=button class=sec onclick="selAll(true)">Выбрать все</button>
<button type=button class=sec onclick="selAll(false)">Снять</button>
<button class=delbulk type=submit>Удалить выбранные (<span id=selCount>0</span>)</button>
</form>
<table>
<thead><tr><th>#</th><th>Время (UTC)</th><th id=locth>Время (локал.)</th><th>Тип</th><th>Event</th><th>task</th><th>Сообщение</th><th>Вложения</th><th><input type=checkbox onclick="selAll(this.checked)" title="Выбрать все"></th></tr></thead>
<tbody>{''.join(trs)}</tbody>
</table>
<div class=pager>{prev}<span class=muted>{shown_from}{shown_to} из {total}</span>{nxt}</div>
{_IMG_MODAL}
{_bulk_js("записи")}
{_LOCAL_TIME_JS}
<script>
(function(){{
var running = true;
var timer = null;
var btn = document.getElementById('arBtn');
var stat = document.getElementById('arStatus');
function applyLocalTimes(scope){{
var pad=function(n){{return(n<10?'0':'')+n;}};
(scope||document).querySelectorAll('span.loc').forEach(function(s){{
var ts=parseFloat(s.dataset.ts);
if(isNaN(ts))return;
var d=new Date(ts*1000);
s.textContent=d.getFullYear()+'-'+pad(d.getMonth()+1)+'-'+pad(d.getDate())+' '
+pad(d.getHours())+':'+pad(d.getMinutes())+':'+pad(d.getSeconds());
}});
}}
function buildQS(){{
var p=new URLSearchParams(window.location.search);
p.set('page','0');
return p.toString();
}}
function tick(){{
fetch('/admin/entries/fragment?'+buildQS(),{{cache:'no-store'}})
.then(function(r){{return r.json();}})
.then(function(d){{
var tbody=document.querySelector('table tbody');
tbody.innerHTML=d.rows_html;
applyLocalTimes(tbody);
stat.textContent='обновлено '+new Date().toLocaleTimeString();
}})
.catch(function(){{stat.textContent='ошибка обновления';}})
.finally(function(){{
if(running) timer=setTimeout(tick,3000);
}});
}}
window._arToggle=function(){{
running=!running;
if(running){{
btn.textContent='⏸ Стоп';
tick();
}}else{{
clearTimeout(timer);
btn.textContent='▶ Старт';
stat.textContent='на паузе';
}}
}};
tick();
}})();
</script>
</body></html>"""
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 = """
<style>
.wrap{padding:16px;display:flex;flex-direction:column;gap:16px}
.card{background:#13161c;border:1px solid #2a2f3a;border-radius:10px;overflow:hidden}
.card>.chead{display:flex;align-items:center;gap:12px;flex-wrap:wrap;padding:11px 14px;background:#171a21;border-bottom:1px solid #2a2f3a}
.chead .uuid{font:13px ui-monospace,monospace;color:#e6e6e6}
.chead .last{color:#9aa4b2;font-size:12px}
.stat{display:inline-flex;align-items:center;gap:5px;font-size:12px;color:#9aa4b2}
.stat b{color:#e6e6e6;font-size:13px}
.stat.err b{color:#fca5a5}
.chips{display:flex;gap:5px;flex-wrap:wrap}
.chip{font-size:11px;color:#bfdbfe;background:#1e3a5f;border-radius:5px;padding:1px 6px}
.chip.err{color:#fecaca;background:#7f1d1d}
table.dev{width:100%;border-collapse:collapse}
table.dev th,table.dev td{padding:7px 14px;border-bottom:1px solid #20242e;text-align:left;font-size:13px;vertical-align:top}
table.dev th{font-size:11px;color:#9aa4b2;text-transform:uppercase}
table.dev tr:last-child td{border-bottom:0}
table.dev .mono{font:12px ui-monospace,monospace;color:#cbd5e1}
.gobtn{background:#2a2f3a;color:#5aa9ff;border-radius:6px;padding:4px 10px;font-size:12px;white-space:nowrap;border:0;cursor:pointer;font-family:system-ui}
.gobtn:hover{background:#323845}
.ehl{color:#fca5a5;font-weight:600}
tr.detail>td{padding:0;background:#0d1017}
.deskdetail{display:none;border-top:1px solid #2a2f3a;background:#0d1017}
table.frag{width:100%;border-collapse:collapse}
table.frag th,table.frag td{padding:6px 14px;border-bottom:1px solid #20242e;text-align:left;font-size:12px;vertical-align:top}
table.frag th{position:sticky;top:0;font-size:10px;color:#9aa4b2;text-transform:uppercase;background:#13161c}
table.frag .msg{max-width:680px;white-space:pre-wrap;word-break:break-word}
.fragwrap{max-height:60vh;overflow:auto}
</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<?"); params.append(dt)
clause = (" WHERE " + " AND ".join(where)) if where else ""
with _db() as c:
rows = c.execute(
f"SELECT ts, type, message FROM entries{clause} ORDER BY ts DESC", params
).fetchall()
now = time.time()
# даты пробрасываем во фрагмент, чтобы разворот уважал фильтр периода
dates_qs = (f"&date_from={html_lib.escape(date_from)}"
f"&date_to={html_lib.escape(date_to)}")
def _new_dev() -> 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'<span class="chip{" err" if t in ("ERROR", "TASK_ERROR") else ""}">{html_lib.escape(t)}: {n}</span>'
for t, n in sorted(d["types"].items(), key=lambda kv: -kv[1])
)
err_stat = (f'<span class="stat err">ошибок <b>{d["errors"]}</b></span>'
if d["errors"] else '<span class=stat>ошибок <b>0</b></span>')
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'<span class=ehl>{dev["errors"]}</span>' 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(
"<tr>"
f'<td class=mono>{html_lib.escape(dev_id)}</td>'
f'<td class=mono>{html_lib.escape(androids)}</td>'
f"<td>{html_lib.escape(phones)}</td>"
f'<td>{dev["total"]}</td>'
f"<td>{errs}</td>"
f'<td class=muted>{_ts_str(dev["last_ts"])}<br><span style="font-size:11px">{_ago(dev["last_ts"], now)}</span></td>'
f'<td><button type=button class=gobtn '
f'onclick="return _toggle(this,document.getElementById(\'{sid}\'))">в логи ▸</button></td>'
"</tr>"
f'<tr class=detail id="{sid}"><td colspan=7>'
f'<div class="slot fragwrap" data-url="{url}"></div></td></tr>'
)
uid += 1
ksid = f"k{uid}"
all_url = f"/admin/desktops/rows?desktop={dk_q}{dates_qs}"
cards.append(
'<div class=card>'
'<div class=chead>'
f'<span class=uuid>🖥 {dk_esc}</span>'
f'<span class=last>{_ts_str(d["last_ts"])} · {_ago(d["last_ts"], now)}</span>'
f'<span class=stat>записей <b>{d["total"]}</b></span>'
f'{err_stat}'
f'<span class=stat>девайсов <b>{len([k for k in d["devices"] if k != ""])}</b></span>'
f'<span class=stat>профилей <b>{len(d["profiles"])}</b></span>'
f'<span style="margin-left:auto"><button type=button class=gobtn '
f'onclick="return _toggle(this,document.getElementById(\'{ksid}\'))">все логи ▸</button></span>'
"</div>"
f'<div style="padding:8px 14px"><div class=chips>{type_chips}</div></div>'
"<table class=dev><thead><tr>"
"<th>Девайс</th><th>Android ID</th><th>Телефоны</th><th>Записей</th><th>Ошибок</th><th>Последняя (UTC)</th><th></th>"
"</tr></thead><tbody>"
f'{"".join(dev_rows)}'
"</tbody></table>"
f'<div class="deskdetail slot fragwrap" id="{ksid}" data-url="{all_url}"></div>'
"</div>"
)
if not cards:
cards.append('<div class=muted style="padding:24px;text-align:center">Нет данных</div>')
body = f"""<!doctype html><html><head><meta charset=utf-8>
<title>ARC monitoring — десктопы</title>{_STYLE}{_DESK_STYLE}</head><body>
{_header('desktops')}
<form class=filters method=get action="/admin/desktops">
<label>Дата с<input name=date_from type=date value="{html_lib.escape(date_from)}"></label>
<label>Дата по<input name=date_to type=date value="{html_lib.escape(date_to)}"></label>
<button type=submit>Фильтр</button>
<a href="/admin/desktops"><button type=button class=sec>Сброс</button></a>
<button type=button class=sec onclick="return _collapseAll()">Свернуть всё</button>
</form>
<div class=wrap>{''.join(cards)}</div>
{_IMG_MODAL}
<script>
function _toggle(btn, el){{
var open = el.dataset.open === '1';
if(open){{
el.style.display='none'; el.dataset.open='0';
if(btn.dataset.lbl) btn.textContent=btn.dataset.lbl;
return false;
}}
el.style.display = (el.tagName === 'TR') ? 'table-row' : 'block';
el.dataset.open='1';
if(!btn.dataset.lbl) btn.dataset.lbl=btn.textContent;
btn.textContent='скрыть ▾';
var slot = el.classList.contains('slot') ? el : el.querySelector('.slot');
if(slot && slot.dataset.loaded !== '1'){{
slot.innerHTML='<div class=muted style="padding:10px">загрузка…</div>';
fetch(slot.dataset.url).then(function(r){{return r.text();}}).then(function(t){{
slot.innerHTML=t; slot.dataset.loaded='1';
}}).catch(function(){{
slot.innerHTML='<div class=muted style="padding:10px">ошибка загрузки</div>';
}});
}}
return false;
}}
function _collapseAll(){{
document.querySelectorAll('tr.detail,.deskdetail').forEach(function(el){{
el.style.display='none'; el.dataset.open='0';
}});
document.querySelectorAll('.gobtn').forEach(function(b){{
if(b.dataset.lbl) b.textContent=b.dataset.lbl;
}});
return false;
}}
</script>
</body></html>"""
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<?"); params.append(dt)
clause = (" WHERE " + " AND ".join(where)) if where else ""
with _db() as c:
rows = c.execute(
f"SELECT * FROM entries{clause} ORDER BY ts DESC", params
).fetchall()
out = []
for r in rows:
meta = _meta(r["message"])
if (meta.get("desktop") or "") != desktop:
continue
if device and (meta.get("device") or "") != device:
continue
out.append(r)
if len(out) >= 300:
break
return HTMLResponse(_entry_rows_html(out))
_SCREENS_STYLE = """
<style>
.gal{padding:16px;display:flex;flex-direction:row;gap:14px;overflow-x:auto}
.shot{position:relative;flex:0 0 220px;background:#13161c;border:1px solid #2a2f3a;border-radius:8px;overflow:hidden;display:flex;flex-direction:column}
.shot .sel{position:absolute;top:6px;left:6px;z-index:3;background:rgba(0,0,0,.55);border-radius:6px;padding:4px;line-height:0;cursor:pointer}
.shot .sel input{width:18px;height:18px;cursor:pointer;accent-color:#ef4444;margin:0}
.shot img{width:100%;height:auto;display:block;cursor:zoom-in;background:#000}
.shot .cap{padding:6px 8px;font-size:11px;color:#9aa4b2;border-top:1px solid #20242e;display:flex;align-items:center;justify-content:space-between;gap:6px}
.empty{padding:40px;text-align:center;color:#6b7280}
table.devs{width:100%;border-collapse:collapse}
table.devs th,table.devs td{padding:8px 14px;border-bottom:1px solid #20242e;text-align:left;font-size:13px;vertical-align:middle}
table.devs th{font-size:11px;color:#9aa4b2;text-transform:uppercase}
table.devs .mono{font:12px ui-monospace,monospace;color:#cbd5e1}
table.devs tr.sel td{background:#0e1726}
table.devs tr.sel td:first-child{border-left:3px solid #5aa9ff}
table.devs tr:hover td{background:#161a22}
a.viewbtn{background:#2a2f3a;color:#5aa9ff;border-radius:6px;padding:4px 10px;font-size:12px;white-space:nowrap}
a.viewbtn:hover{background:#323845}
</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 = ['<option value="">— выберите девайс —</option>']
for d in devices:
sel = " selected" if d["device_id"] == device else ""
label = d["name"] or d["device_id"] or ""
dev_opts.append(
f'<option value="{html_lib.escape(d["device_id"])}"{sel}>'
f'{html_lib.escape(label)} · {d["n"]} кадров · {_ago(d["last"], now)}</option>'
)
# Список девайсов с временем последней активности (последний кадр).
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"<tr{sel}>"
f'<td>{html_lib.escape(label)}</td>'
f'<td class=mono>{html_lib.escape(d["desktop_id"] or "")}</td>'
f'<td>{d["n"]}</td>'
f'<td class=muted>{_ts_str(d["last"])}<br>'
f'<span style="font-size:11px">{_ago(d["last"], now)}</span></td>'
f'<td><a class=viewbtn href="{view_url}">смотреть →</a></td>'
"</tr>"
)
if dev_trs:
dev_table = (
"<table class=devs><thead><tr>"
"<th>Девайс</th><th>Десктоп</th><th>Кадров</th>"
"<th>Последняя активность (UTC)</th><th></th>"
"</tr></thead><tbody>" + "".join(dev_trs) + "</tbody></table>"
)
else:
dev_table = '<div class=empty>Пока нет ни одного кадра</div>'
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<?"); params.append(dt)
clause = " WHERE " + " AND ".join(where)
with _db() as c:
shots = c.execute(
f"SELECT id, ts FROM screenshots{clause} ORDER BY ts DESC LIMIT 1000",
params,
).fetchall()
if shots:
current_url = f"/admin/screens?device={html_lib.escape(device)}{dates_qs}"
cells = "".join(
'<div class=shot>'
f'<label class=sel title="Выбрать для удаления">'
f'<input type=checkbox name=ids value="{s["id"]}" form=bulkForm '
f'onchange="updCount()"></label>'
f'<img loading=lazy src="/admin/screen/{s["id"]}/image" '
f'data-id="{s["id"]}" onclick="openImg(this)">'
f'<div class=cap><span>{_ts_str(s["ts"])} UTC<br>'
f'<span class=loc data-ts="{s["ts"]}"></span> локал.</span>'
f'<form class=del method=post action="/admin/screen/{s["id"]}/delete" '
f"onsubmit=\"return confirm('Удалить этот кадр?')\">"
f'<input type=hidden name=next value="{current_url}">'
f'<button class=del type=submit title="Удалить кадр">✕</button></form>'
"</div>"
"</div>"
for s in shots
)
bulk_bar = (
'<form id=bulkForm class=bulkbar method=post '
'action="/admin/screens/delete" onsubmit="return confirmBulk()">'
f'<input type=hidden name=next value="{current_url}">'
'<button type=button class=sec onclick="selAll(true)">Выбрать все</button>'
'<button type=button class=sec onclick="selAll(false)">Снять</button>'
'<button class=delbulk type=submit>Удалить выбранные '
'(<span id=selCount>0</span>)</button></form>'
)
gallery = f"{bulk_bar}<div class=gal>{cells}</div>"
else:
gallery = '<div class=empty>Нет кадров за выбранный период</div>'
else:
gallery = '<div class=empty>Выберите девайс, чтобы посмотреть историю экрана</div>'
body = f"""<!doctype html><html><head><meta charset=utf-8>
<title>ARC monitoring — экраны</title>{_STYLE}{_SCREENS_STYLE}</head><body>
{_header('screens')}
<form class=filters method=get action="/admin/screens">
<label>Девайс<select name=device onchange="this.form.submit()">{''.join(dev_opts)}</select></label>
<label>Дата с<input name=date_from type=date value="{html_lib.escape(date_from)}"></label>
<label>Дата по<input name=date_to type=date value="{html_lib.escape(date_to)}"></label>
<button type=submit>Фильтр</button>
<a href="/admin/screens"><button type=button class=sec>Сброс</button></a>
<span class=muted>история экрана · хранение {SCREENSHOT_RETENTION_HOURS} ч · время UTC</span>
</form>
{dev_table}
{gallery}
{_IMG_MODAL}
{_bulk_js("кадры")}
{_LOCAL_TIME_JS}
</body></html>"""
return HTMLResponse(body)
_DUMPS_STYLE = """
<style>
.dgal{padding:16px;display:grid;grid-template-columns:repeat(10,minmax(0,1fr));gap:14px}
.dcard{position:relative;background:#13161c;border:1px solid #2a2f3a;border-radius:8px;overflow:hidden;display:flex;flex-direction:column}
.dcard .sel{position:absolute;top:6px;left:6px;z-index:3;background:rgba(0,0,0,.55);border-radius:6px;padding:4px;line-height:0;cursor:pointer}
.dcard .sel input{width:18px;height:18px;cursor:pointer;accent-color:#ef4444;margin:0}
.bulkbar{display:flex;gap:8px;align-items:center;padding:10px 16px 0}
.bulkbar .delbulk{background:#b91c1c;color:#fff;border:none;border-radius:6px;padding:6px 12px;font-size:13px;cursor:pointer}
.bulkbar .delbulk:hover{background:#dc2626}
.dcard .ph{width:100%;aspect-ratio:9/16;background:#000;display:flex;align-items:center;justify-content:center}
.dcard img{width:100%;height:100%;object-fit:contain;display:block;cursor:zoom-in;background:#000}
.dcard .noimg{color:#6b7280;font-size:12px}
.dcard .meta{padding:8px 10px;display:flex;flex-direction:column;gap:6px;border-top:1px solid #20242e}
.dcard .dev{font-size:13px;color:#e6e6e6;font-weight:600;word-break:break-all}
.vchip{display:inline-block;font-size:11px;font-weight:600;color:#bbf7d0;background:#14532d;border-radius:5px;padding:1px 6px;vertical-align:middle;word-break:normal}
.dcard .when{font-size:11px;color:#9aa4b2}
.dcard .acts{display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-top:2px}
a.dl{background:#2563eb;color:#fff;border-radius:6px;padding:5px 10px;font-size:12px;white-space:nowrap}
a.dl.sec{background:#2a2f3a;color:#5aa9ff}
.empty{padding:40px;text-align:center;color:#6b7280}
</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 = ['<option value="">— все девайсы —</option>']
for d in devices:
sel = " selected" if d["device"] == device else ""
label = d["device"] or ""
dev_opts.append(
f'<option value="{html_lib.escape(d["device"])}"{sel}>'
f'{html_lib.escape(label)} · {d["n"]} · {_ago(d["last"], now)}</option>'
)
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<?"); params.append(dt)
clause = (" WHERE " + " AND ".join(where)) if where else ""
page = max(0, page)
with _db() as c:
total = c.execute(
f"SELECT COUNT(*) n FROM screen_dumps{clause}", params
).fetchone()["n"]
rows = c.execute(
f"SELECT * FROM screen_dumps{clause} ORDER BY ts DESC LIMIT ? OFFSET ?",
(*params, PAGE_SIZE, page * PAGE_SIZE),
).fetchall()
current_url = (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={page}")
cards = []
for r in rows:
# Общее базовое имя для пары: <девайс>[_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'<div class=ph><img loading=lazy src="/admin/dump/{r["id"]}/image" '
f'data-id="{r["id"]}" data-name="{html_lib.escape(base + img_ext)}" '
f'onclick="openImg(this)"></div>')
else:
thumb = '<div class=ph><span class=noimg>нет скриншота</span></div>'
acts = []
if r["image_path"]:
acts.append(
f'<a class="dl sec" href="/admin/dump/{r["id"]}/image" '
f'download="{html_lib.escape(base + img_ext)}">⬇ фото</a>'
)
if r["xml_path"]:
acts.append(
f'<a class=dl href="/admin/dump/{r["id"]}/xml" '
f'download="{html_lib.escape(base + ".xml")}">⬇ XML</a>'
)
acts.append(
f'<form class=del method=post action="/admin/dump/{r["id"]}/delete" '
f"onsubmit=\"return confirm('Удалить дамп #{r['id']}?')\">"
f'<input type=hidden name=next value="{current_url}">'
f'<button class=del type=submit title="Удалить">✕</button></form>'
)
cards.append(
"<div class=dcard>"
f'<label class=sel title="Выбрать для удаления">'
f'<input type=checkbox name=ids value="{r["id"]}" form=bulkForm '
f'onchange="updCount()"></label>'
f"{thumb}"
"<div class=meta>"
f'<span class=dev>{html_lib.escape(r["device"] or "")}'
f'{f" <span class=vchip>v{html_lib.escape(ver)}</span>" if ver else ""}</span>'
f'<span class=when>{_ts_str(r["ts"])} UTC · {_ago(r["ts"], now)}'
f'<br><span class=loc data-ts="{r["ts"]}"></span> локал.</span>'
f'<span class=acts>{"".join(acts)}</span>'
"</div></div>"
)
gallery = (f"<div class=dgal>{''.join(cards)}</div>" if cards
else '<div class=empty>Пока нет ни одного дампа экрана</div>')
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'<a href="{link(page - 1)}">← назад</a>' if page > 0
else '<span class=muted>← назад</span>')
nxt = (f'<a href="{link(page + 1)}">вперёд →</a>'
if shown_to < total else '<span class=muted>вперёд →</span>')
body = f"""<!doctype html><html><head><meta charset=utf-8>
<title>ARC monitoring — дампы экрана</title>{_STYLE}{_DUMPS_STYLE}</head><body>
{_header('dumps')}
<form class=filters method=get action="/admin/dumps">
<label>Девайс<select name=device onchange="this.form.submit()">{''.join(dev_opts)}</select></label>
<label>Дата с<input name=date_from type=date value="{html_lib.escape(date_from)}"></label>
<label>Дата по<input name=date_to type=date value="{html_lib.escape(date_to)}"></label>
<button type=submit>Фильтр</button>
<a href="/admin/dumps"><button type=button class=sec>Сброс</button></a>
<span class=muted>XML + скриншот · хранение {SCREEN_DUMP_RETENTION_DAYS} сут · время UTC</span>
</form>
<form id=bulkForm class=bulkbar method=post action="/admin/dumps/delete"
onsubmit="return confirmBulk()">
<input type=hidden name=next value="{current_url}">
<button type=button class=sec onclick="selAll(true)">Выбрать все</button>
<button type=button class=sec onclick="selAll(false)">Снять</button>
<button class=delbulk type=submit>Удалить выбранные (<span id=selCount>0</span>)</button>
</form>
{gallery}
<div class=pager>{prev}<span class=muted>{shown_from}{shown_to} из {total}</span>{nxt}</div>
{_IMG_MODAL}
{_bulk_js("дампы")}
{_LOCAL_TIME_JS}
</body></html>"""
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)