2062 lines
89 KiB
Python
2062 lines
89 KiB
Python
"""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("<b>", "<b>").replace("</b>", "</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) |