- Add TaskDumpRecorder to save task-related XML dumps and upload failure info to Telegram. - Integrate TaskDumpRecorder into `PayByPhoneScript` and `PayByCardScript` for Dushanbe and Ozon banks. - Extend ADB utilities to record XML dumps with TaskDumpRecorder and refine keyboard dismissal logic. - Enhance freeze detection with consecutive dump checks in CommonScript and payment scripts. - Update mock server profiles and scenario configurations with new material IDs.
1315 lines
46 KiB
Python
1315 lines
46 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Mock ARC backend for task-delivery integration tests.
|
||
|
||
Stubs all endpoints NetworkService hits so a real ARC desktop app can connect,
|
||
run real devices, and process tasks that you inject from the outside.
|
||
|
||
At startup loads:
|
||
- default_profile.json → served as /current_profile (seed bank profiles
|
||
with their materials so ARC's sync links them
|
||
to real local device/material rows)
|
||
- scenarios/pool/*.json → pool of FETCH_TRANSACTIONS tasks, grouped
|
||
by material_id for auto-bootstrap
|
||
|
||
On the FIRST /auth/login of a run the mock bootstraps the task queue:
|
||
- 1 × FETCH_PROFILE per material seen in default_profile.json
|
||
- N × FETCH_TRANSACTIONS per material, picked randomly from the pool
|
||
(controlled by --ft-per-material, default 3)
|
||
|
||
Every injected task gets a unique `_task_id`. Results posted via
|
||
POST /task/task_result are paired (FIFO per material+type) to the matching
|
||
injected task. GET /_admin/report returns the full list of pairs so you can
|
||
see exactly which task JSON went out and what the app returned.
|
||
|
||
Usage:
|
||
pip install flask
|
||
python3 mock_server.py --port 8888
|
||
|
||
# Point ARC at it: in config.ini set
|
||
# [net]
|
||
# apiBase=http://127.0.0.1:8888
|
||
|
||
# Start ARC → it logs in, mock auto-queues bootstrap tasks, ARC processes
|
||
# them on real devices and posts results back.
|
||
|
||
# Inspect the full report of task ↔ result pairs:
|
||
curl -s http://127.0.0.1:8888/_admin/report | jq
|
||
"""
|
||
|
||
import argparse
|
||
import copy
|
||
import json
|
||
import os
|
||
import random
|
||
import re
|
||
import shutil
|
||
import threading
|
||
import time
|
||
import uuid
|
||
from collections import defaultdict, deque
|
||
from datetime import datetime, timedelta, timezone
|
||
from pathlib import Path
|
||
|
||
from flask import Flask, Response, jsonify, request
|
||
|
||
app = Flask(__name__)
|
||
app.config["JSON_SORT_KEYS"] = False
|
||
|
||
HERE = Path(__file__).parent
|
||
|
||
_lock = threading.Lock()
|
||
|
||
_task_queue: deque = deque()
|
||
# _injected: list of {id, task, injected_at, result, result_at, status}
|
||
# status: "pending" | "completed"
|
||
_injected: list = []
|
||
# Per (material_id, type) → FIFO of _injected indices still waiting for a result
|
||
_pending_by_key: dict = defaultdict(deque)
|
||
|
||
_profile_id = "11111111-1111-1111-1111-111111111111"
|
||
_desktop_id = "22222222-2222-2222-2222-222222222222"
|
||
|
||
_bank_profiles: list = [] # seeded from default_profile.json
|
||
_devices_by_desktop: dict = {}
|
||
_pool_by_material: dict = defaultdict(list)
|
||
_device_label_by_material: dict = {} # material_id -> "TECNO KM4" etc.
|
||
_in_flight: set = set() # material_ids with a delivered-but-not-yet-resulted task
|
||
_bootstrap_done = False
|
||
_bootstrap_ft_count = 3
|
||
_bootstrap_banks: list | None = None # None = all banks
|
||
_bootstrap_types: list | None = None # None = all types (FETCH_PROFILE + FETCH_TRANSACTIONS)
|
||
|
||
_STATE_FILE = HERE / ".mock_state.json"
|
||
|
||
|
||
def _save_state():
|
||
"""Persist task state to disk so debug reloader doesn't lose it."""
|
||
try:
|
||
state = {
|
||
"injected": _injected,
|
||
"task_queue": list(_task_queue),
|
||
"in_flight": list(_in_flight),
|
||
"bootstrap_done": _bootstrap_done,
|
||
"pending_by_key": {f"{k[0]}|{k[1]}": v for k, v in _pending_by_key.items()},
|
||
}
|
||
_STATE_FILE.write_text(json.dumps(state, default=str), encoding="utf-8")
|
||
except Exception as e:
|
||
print(f"[mock] failed to save state: {e}")
|
||
|
||
|
||
def _load_state() -> bool:
|
||
"""Restore state from disk. Returns True if loaded."""
|
||
global _bootstrap_done
|
||
if not _STATE_FILE.exists():
|
||
return False
|
||
try:
|
||
state = json.loads(_STATE_FILE.read_text(encoding="utf-8"))
|
||
_injected.clear()
|
||
_injected.extend(state.get("injected", []))
|
||
_task_queue.clear()
|
||
_task_queue.extend(state.get("task_queue", []))
|
||
_in_flight.clear()
|
||
_in_flight.update(state.get("in_flight", []))
|
||
_bootstrap_done = state.get("bootstrap_done", False)
|
||
_pending_by_key.clear()
|
||
for k, v in state.get("pending_by_key", {}).items():
|
||
parts = k.split("|", 1)
|
||
if len(parts) == 2:
|
||
_pending_by_key[(parts[0], parts[1])] = v
|
||
print(f"[mock] restored state: {len(_injected)} injected, "
|
||
f"{len(_task_queue)} queued, bootstrap={'done' if _bootstrap_done else 'pending'}")
|
||
return True
|
||
except Exception as e:
|
||
print(f"[mock] failed to load state: {e}")
|
||
return False
|
||
|
||
|
||
def ok(data=None):
|
||
return jsonify({"success": True, "data": data if data is not None else {}})
|
||
|
||
|
||
def log(tag, obj):
|
||
text = json.dumps(obj, ensure_ascii=False)
|
||
if len(text) > 400:
|
||
text = text[:400] + "…"
|
||
print(f"[{time.strftime('%H:%M:%S')}] {tag}: {text}")
|
||
|
||
|
||
# ---------------------------------------------------------------- startup load
|
||
def load_default_profile():
|
||
global _bank_profiles
|
||
p = HERE / "default_profile.json"
|
||
if not p.exists():
|
||
print(f"[mock] no {p.name} found — profile starts empty")
|
||
return
|
||
data = json.loads(p.read_text(encoding="utf-8"))
|
||
_bank_profiles = data.get("bank_profiles", [])
|
||
mats = sum(len(bp.get("materials", [])) for bp in _bank_profiles)
|
||
|
||
# Build material_id → device_label map for report grouping
|
||
_device_label_by_material.clear()
|
||
for bp in _bank_profiles:
|
||
label = bp.get("device_label") or (bp.get("device_id", "")[:8] + "…")
|
||
for mat in bp.get("materials", []):
|
||
mid = mat.get("id")
|
||
if mid:
|
||
_device_label_by_material[mid] = label
|
||
|
||
print(f"[mock] loaded {p.name}: {len(_bank_profiles)} bank_profiles, {mats} materials")
|
||
|
||
|
||
def load_pool():
|
||
pool_dir = HERE / "scenarios" / "pool"
|
||
if not pool_dir.exists():
|
||
print(f"[mock] pool dir {pool_dir} missing — bootstrap disabled")
|
||
return
|
||
count = 0
|
||
for f in sorted(pool_dir.glob("*.json")):
|
||
try:
|
||
task = json.loads(f.read_text(encoding="utf-8"))
|
||
except Exception as e:
|
||
print(f"[mock] skipping {f.name}: {e}")
|
||
continue
|
||
if task.get("type") != "FETCH_TRANSACTIONS":
|
||
continue
|
||
mid = task.get("material_id")
|
||
if not mid:
|
||
continue
|
||
task["_source"] = f.name
|
||
_pool_by_material[mid].append(task)
|
||
count += 1
|
||
print(f"[mock] loaded pool: {count} FETCH_TRANSACTIONS across "
|
||
f"{len(_pool_by_material)} materials")
|
||
for mid, lst in _pool_by_material.items():
|
||
print(f" {mid}: {len(lst)} tasks")
|
||
|
||
|
||
def enqueue_task(task: dict) -> dict:
|
||
"""Attach _task_id, store in queue, track in _injected."""
|
||
task = copy.deepcopy(task)
|
||
tid = str(uuid.uuid4())
|
||
task["_task_id"] = tid
|
||
entry = {
|
||
"id": tid,
|
||
"task": task,
|
||
"injected_at": time.time(),
|
||
"result": None,
|
||
"result_at": None,
|
||
"status": "pending",
|
||
}
|
||
_injected.append(entry)
|
||
idx = len(_injected) - 1
|
||
key = (task.get("material_id"), task.get("type"))
|
||
_pending_by_key[key].append(idx)
|
||
_task_queue.append(task)
|
||
return entry
|
||
|
||
|
||
def bootstrap_tasks():
|
||
"""Queue 1 FETCH_PROFILE + N FETCH_TRANSACTIONS per seeded material."""
|
||
global _bootstrap_done
|
||
if _bootstrap_done:
|
||
return
|
||
queued = 0
|
||
for bp in _bank_profiles:
|
||
bank = bp.get("bank_name")
|
||
if _bootstrap_banks and bank not in _bootstrap_banks:
|
||
continue
|
||
for mat in bp.get("materials", []):
|
||
mid = mat.get("id")
|
||
if not mid:
|
||
continue
|
||
# 1 × FETCH_PROFILE
|
||
if not _bootstrap_types or "FETCH_PROFILE" in _bootstrap_types:
|
||
enqueue_task({
|
||
"bank_name": bank,
|
||
"material_id": mid,
|
||
"type": "FETCH_PROFILE",
|
||
"body": {},
|
||
"_source": "bootstrap",
|
||
})
|
||
queued += 1
|
||
|
||
# N × random FETCH_TRANSACTIONS from pool
|
||
if not _bootstrap_types or "FETCH_TRANSACTIONS" in _bootstrap_types:
|
||
pool = _pool_by_material.get(mid, [])
|
||
if not pool:
|
||
print(f"[mock] bootstrap: no pool tasks for {mid} ({bank})")
|
||
continue
|
||
pick = random.sample(pool, min(_bootstrap_ft_count, len(pool)))
|
||
for t in pick:
|
||
enqueue_task(t)
|
||
queued += 1
|
||
_bootstrap_done = True
|
||
_save_state()
|
||
print(f"[mock] bootstrap queued {queued} tasks")
|
||
|
||
|
||
# ---------------------------------------------------------------- auth
|
||
@app.post("/api/v1/auth/login")
|
||
def auth_login():
|
||
body = request.get_json(silent=True) or {}
|
||
log("POST /auth/login", body)
|
||
with _lock:
|
||
bootstrap_tasks()
|
||
return ok({
|
||
"access_token": "mock-access-token",
|
||
"refresh_token": "mock-refresh-token",
|
||
"expires_in": 3600,
|
||
})
|
||
|
||
|
||
@app.post("/api/v1/auth/refresh_token")
|
||
def auth_refresh():
|
||
return ok({
|
||
"access_token": "mock-access-token",
|
||
"refresh_token": "mock-refresh-token",
|
||
"expires_in": 3600,
|
||
})
|
||
|
||
|
||
# ---------------------------------------------------------------- desktop
|
||
@app.get("/api/v1/desktop/")
|
||
def desktop_list():
|
||
return ok([{
|
||
"id": _desktop_id,
|
||
"profile_id": _profile_id,
|
||
"hidden": False,
|
||
"name": "mock-desktop",
|
||
}])
|
||
|
||
|
||
@app.post("/api/v1/desktop/")
|
||
def desktop_create():
|
||
body = request.get_json(silent=True) or {}
|
||
log("POST /desktop/", body)
|
||
return ok({"id": _desktop_id, "profile_id": _profile_id})
|
||
|
||
|
||
# ---------------------------------------------------------------- device
|
||
@app.get("/api/v1/device/<desktop_id>")
|
||
def device_list(desktop_id):
|
||
return ok(_devices_by_desktop.get(desktop_id, []))
|
||
|
||
|
||
@app.post("/api/v1/device/")
|
||
def device_create():
|
||
body = request.get_json(silent=True) or {}
|
||
log("POST /device/", body)
|
||
api_id = str(uuid.uuid4())
|
||
dev = {
|
||
"id": api_id,
|
||
"name": body.get("name", "unknown"),
|
||
"status": body.get("status", "OFFLINE"),
|
||
"screen_width": body.get("screen_width", 0),
|
||
"screen_height": body.get("screen_height", 0),
|
||
"battery": body.get("battery", 0),
|
||
}
|
||
_devices_by_desktop.setdefault(_desktop_id, []).append(dev)
|
||
return ok(dev)
|
||
|
||
|
||
@app.patch("/api/v1/device/<api_id>")
|
||
def device_patch(api_id):
|
||
return ok({"id": api_id})
|
||
|
||
|
||
@app.delete("/api/v1/device/<api_id>")
|
||
def device_delete(api_id):
|
||
return ok({"id": api_id})
|
||
|
||
|
||
@app.post("/api/v1/device/add_screenshot/<api_id>")
|
||
def device_screenshot(api_id):
|
||
return ok({"id": api_id})
|
||
|
||
|
||
# ---------------------------------------------------------------- profile
|
||
@app.get("/api/v1/profile/current_profile")
|
||
def current_profile():
|
||
return ok({
|
||
"id": _profile_id,
|
||
"bank_profiles": _bank_profiles,
|
||
})
|
||
|
||
|
||
@app.post("/api/v1/profile/bank_profile")
|
||
def bank_profile_create():
|
||
body = request.get_json(silent=True) or {}
|
||
bp = dict(body)
|
||
bp["id"] = bp.get("id") or str(uuid.uuid4())
|
||
bp.setdefault("materials", [])
|
||
_bank_profiles.append(bp)
|
||
return ok(bp)
|
||
|
||
|
||
@app.patch("/api/v1/profile/bank_profile/<bp_id>/<state>")
|
||
def bank_profile_toggle(bp_id, state):
|
||
return ok({"id": bp_id, "state": state})
|
||
|
||
|
||
@app.patch("/api/v1/profile/bank_profile/<bp_id>")
|
||
def bank_profile_update(bp_id):
|
||
body = request.get_json(silent=True) or {}
|
||
log("PATCH /bank_profile", {"id": bp_id, **body})
|
||
for bp in _bank_profiles:
|
||
if bp.get("id") == bp_id:
|
||
bp.update(body)
|
||
return ok(bp)
|
||
# Не найден — создаём
|
||
bp = dict(body)
|
||
bp["id"] = bp_id
|
||
bp.setdefault("materials", [])
|
||
_bank_profiles.append(bp)
|
||
return ok(bp)
|
||
|
||
|
||
@app.post("/api/v1/profile/material")
|
||
def material_create():
|
||
body = request.get_json(silent=True) or {}
|
||
mat = dict(body)
|
||
mat["id"] = mat.get("id") or str(uuid.uuid4())
|
||
return ok(mat)
|
||
|
||
|
||
@app.patch("/api/v1/profile/material/<mat_id>/<state>")
|
||
def material_toggle(mat_id, state):
|
||
return ok({"id": mat_id, "state": state})
|
||
|
||
|
||
# ---------------------------------------------------------------- tasks
|
||
@app.get("/api/v1/task/get_tasks")
|
||
def get_tasks():
|
||
"""Deliver at most 1 task per material per poll.
|
||
|
||
A material is blocked until the previous task's result arrives via
|
||
POST /task/task_result. This prevents the 5-min expire window in
|
||
EventHandler from killing tasks that were just waiting in queue.
|
||
"""
|
||
material_ids = request.args.getlist("materials_id")
|
||
with _lock:
|
||
if not _task_queue:
|
||
return ok([])
|
||
delivered = []
|
||
delivered_mats = set()
|
||
kept = deque()
|
||
while _task_queue:
|
||
t = _task_queue.popleft()
|
||
mid = t.get("material_id")
|
||
# Filter by requested materials
|
||
if material_ids and mid not in material_ids:
|
||
kept.append(t)
|
||
continue
|
||
# Skip if this material already has an in-flight task
|
||
if mid in _in_flight or mid in delivered_mats:
|
||
kept.append(t)
|
||
continue
|
||
delivered.append(t)
|
||
delivered_mats.add(mid)
|
||
_in_flight.add(mid)
|
||
_task_queue.extend(kept)
|
||
if delivered:
|
||
_save_state()
|
||
log("GET /task/get_tasks -> delivered",
|
||
[{"type": t.get("type"), "material_id": t.get("material_id")}
|
||
for t in delivered])
|
||
return ok(delivered)
|
||
|
||
|
||
@app.post("/api/v1/task/task_result")
|
||
def task_result():
|
||
body = request.get_json(silent=True) or {}
|
||
mid = body.get("material_id")
|
||
log("POST /task/task_result",
|
||
{"type": body.get("type"), "material_id": mid})
|
||
|
||
with _lock:
|
||
# Unblock this material for the next task delivery
|
||
_in_flight.discard(mid)
|
||
|
||
# Клиент присылает ошибки с type="ERROR", но оригинальный тип задачи
|
||
# лежит в data.event_type — им и матчимся, иначе ошибка повиснет
|
||
# как unsolicited без привязки к задаче/устройству.
|
||
rtype = body.get("type")
|
||
if rtype == "ERROR":
|
||
data = body.get("data") or {}
|
||
rtype = data.get("event_type") or rtype
|
||
|
||
key = (mid, rtype)
|
||
idx = None
|
||
if _pending_by_key[key]:
|
||
idx = _pending_by_key[key].popleft()
|
||
if idx is not None:
|
||
_injected[idx]["result"] = body
|
||
_injected[idx]["result_at"] = time.time()
|
||
_injected[idx]["status"] = "completed"
|
||
else:
|
||
_injected.append({
|
||
"id": str(uuid.uuid4()),
|
||
"task": None,
|
||
"injected_at": None,
|
||
"result": body,
|
||
"result_at": time.time(),
|
||
"status": "unsolicited",
|
||
})
|
||
_save_state()
|
||
return ok({})
|
||
|
||
|
||
# ---------------------------------------------------------------- monitoring
|
||
_monitoring_logs: list = [] # {type, event, message, has_image, image_id}
|
||
_SCREENSHOTS_DIR = HERE / ".mock_screenshots"
|
||
|
||
|
||
@app.post("/monitoring/log")
|
||
def monitoring_log():
|
||
mtype = request.form.get("type", "")
|
||
mevent = request.form.get("event", "")
|
||
message = request.form.get("message", "")
|
||
image_file = request.files.get("image")
|
||
|
||
image_id = None
|
||
if image_file:
|
||
image_id = str(uuid.uuid4())
|
||
_SCREENSHOTS_DIR.mkdir(exist_ok=True)
|
||
(_SCREENSHOTS_DIR / f"{image_id}.png").write_bytes(image_file.read())
|
||
|
||
entry = {
|
||
"type": mtype,
|
||
"event": mevent,
|
||
"message": message,
|
||
"has_image": image_id is not None,
|
||
"image_id": image_id,
|
||
"timestamp": time.time(),
|
||
}
|
||
with _lock:
|
||
_monitoring_logs.append(entry)
|
||
|
||
# Привязываем скриншот к задаче по internal_task_id
|
||
internal_tid = request.form.get("internal_task_id", "")
|
||
if image_id:
|
||
if not internal_tid:
|
||
print(f"[mock] WARNING: monitoring_log without internal_task_id, screenshot not linked")
|
||
else:
|
||
matched = False
|
||
with _lock:
|
||
for e in reversed(_injected):
|
||
r = e.get("result")
|
||
if r and str(r.get("internal_task_id", "")) == internal_tid:
|
||
e["screenshot_id"] = image_id
|
||
matched = True
|
||
break
|
||
if matched:
|
||
_save_state()
|
||
else:
|
||
print(f"[mock] WARNING: no task found for internal_task_id={internal_tid}")
|
||
|
||
log("POST /monitoring/log", {"type": mtype, "event": mevent,
|
||
"has_image": image_id is not None,
|
||
"message": message[:100]})
|
||
return ok({})
|
||
|
||
|
||
@app.get("/_admin/screenshot/<image_id>")
|
||
def get_screenshot(image_id):
|
||
path = _SCREENSHOTS_DIR / f"{image_id}.png"
|
||
if not path.exists():
|
||
return "not found", 404
|
||
return Response(path.read_bytes(), mimetype="image/png")
|
||
|
||
|
||
# ---------------------------------------------------------------- admin
|
||
@app.post("/_admin/inject")
|
||
def admin_inject():
|
||
body = request.get_json(silent=True)
|
||
if body is None:
|
||
return jsonify({"error": "json body required"}), 400
|
||
tasks = body if isinstance(body, list) else [body]
|
||
with _lock:
|
||
for t in tasks:
|
||
enqueue_task(t)
|
||
log("_admin/inject", {"queued": len(tasks)})
|
||
return ok({"queued": len(tasks)})
|
||
|
||
|
||
@app.post("/_admin/seed_profile")
|
||
def admin_seed_profile():
|
||
body = request.get_json(silent=True) or {}
|
||
global _bank_profiles
|
||
_bank_profiles = body.get("bank_profiles", [])
|
||
return ok({"bank_profiles": len(_bank_profiles)})
|
||
|
||
|
||
@app.post("/_admin/seed_devices")
|
||
def admin_seed_devices():
|
||
body = request.get_json(silent=True) or []
|
||
_devices_by_desktop[_desktop_id] = body
|
||
return ok({"devices": len(body)})
|
||
|
||
|
||
@app.post("/_admin/bootstrap")
|
||
def admin_bootstrap():
|
||
"""Re-run bootstrap manually (useful if ARC was already logged in)."""
|
||
global _bootstrap_done
|
||
with _lock:
|
||
_bootstrap_done = False
|
||
bootstrap_tasks()
|
||
return ok({"queued": sum(1 for e in _injected if e["status"] == "pending")})
|
||
|
||
|
||
@app.get("/_admin/report")
|
||
def admin_report():
|
||
"""Full task ↔ result pairing report."""
|
||
with _lock:
|
||
errors = sum(1 for e in _injected if e["status"] == "completed" and _is_error_entry(e))
|
||
summary = {
|
||
"total": len(_injected),
|
||
"pending": sum(1 for e in _injected if e["status"] == "pending"),
|
||
"completed": sum(1 for e in _injected if e["status"] == "completed") - errors,
|
||
"errors": errors,
|
||
"unsolicited": sum(1 for e in _injected if e["status"] == "unsolicited"),
|
||
}
|
||
return jsonify({
|
||
"summary": summary,
|
||
"entries": _injected,
|
||
})
|
||
|
||
|
||
@app.get("/_admin/report/pending")
|
||
def admin_report_pending():
|
||
with _lock:
|
||
return jsonify([e for e in _injected if e["status"] == "pending"])
|
||
|
||
|
||
def _is_error_entry(e) -> bool:
|
||
"""Check if a completed entry has an error in result data."""
|
||
result = e.get("result")
|
||
if not result:
|
||
return False
|
||
data = result.get("data")
|
||
if isinstance(data, dict) and data.get("status") == "error":
|
||
return True
|
||
return False
|
||
|
||
|
||
def _snapshot_entries_and_summary():
|
||
entries = list(_injected)
|
||
errors = sum(1 for e in entries if e["status"] == "completed" and _is_error_entry(e))
|
||
summary = {
|
||
"total": len(entries),
|
||
"pending": sum(1 for e in entries if e["status"] == "pending"),
|
||
"completed": sum(1 for e in entries if e["status"] == "completed") - errors,
|
||
"errors": errors,
|
||
"unsolicited": sum(1 for e in entries if e["status"] == "unsolicited"),
|
||
}
|
||
return entries, summary
|
||
|
||
|
||
@app.get("/_admin/report/html")
|
||
def admin_report_html():
|
||
"""Pretty HTML view of task ↔ result pairs. Auto-refreshes via JS."""
|
||
with _lock:
|
||
entries, summary = _snapshot_entries_and_summary()
|
||
return Response(render_report_html(entries, summary), mimetype="text/html")
|
||
|
||
|
||
@app.get("/_admin/report/fragment")
|
||
def admin_report_fragment():
|
||
"""JSON fragment for JS auto-refresh: rendered body HTML + summary counts."""
|
||
with _lock:
|
||
entries, summary = _snapshot_entries_and_summary()
|
||
body = _render_grouped_blocks(entries) or '<div class="empty">Отчёт пуст</div>'
|
||
return jsonify({"html": body, "summary": summary})
|
||
|
||
|
||
def _fmt_ts(ts):
|
||
if not ts:
|
||
return "—"
|
||
return time.strftime("%H:%M:%S", time.localtime(ts))
|
||
|
||
|
||
def _esc(s: str) -> str:
|
||
return (s.replace("&", "&").replace("<", "<")
|
||
.replace(">", ">").replace('"', """))
|
||
|
||
|
||
def render_entry_html(e) -> str:
|
||
import html as _html
|
||
status = e["status"]
|
||
is_error = _is_error_entry(e)
|
||
display_status = "error" if is_error else status
|
||
task = e.get("task") or {}
|
||
result = e.get("result")
|
||
ttype = _html.escape(task.get("type", "?"))
|
||
mid = _html.escape((task.get("material_id") or "")[:8])
|
||
src = _html.escape(task.get("_source", ""))
|
||
bank = _html.escape(task.get("bank_name", ""))
|
||
injected_at = _fmt_ts(e.get("injected_at"))
|
||
result_at = _fmt_ts(e.get("result_at"))
|
||
|
||
tid = _html.escape(e.get("id", ""))
|
||
|
||
def json_block(obj):
|
||
if obj is None:
|
||
return '<span class="muted">—</span>'
|
||
text = json.dumps(obj, ensure_ascii=False, indent=2)
|
||
escaped = _html.escape(text)
|
||
# data-json с escaped JSON для копирования
|
||
raw = _html.escape(text, quote=True)
|
||
return (f'<div class="json-wrap">'
|
||
f'<button class="copy-btn" onclick="copyJson(this)" data-json="{raw}">Копировать</button>'
|
||
f'<pre>{escaped}</pre></div>')
|
||
|
||
result_summary = ""
|
||
if is_error:
|
||
d = result.get("data", {}) if result else {}
|
||
result_summary = f'<div class="err">ERROR: {_html.escape(str(d.get("description", "")))}</div>'
|
||
elif result and isinstance(result.get("data"), list):
|
||
result_summary = f'<div class="muted">{len(result["data"])} items</div>'
|
||
|
||
scr_id = e.get("screenshot_id")
|
||
scr_pane = ""
|
||
if scr_id:
|
||
scr_pane = f"""
|
||
<div class="pane screenshot-pane">
|
||
<div class="label">Screenshot</div>
|
||
<img src="/_admin/screenshot/{scr_id}" class="screenshot" loading="lazy">
|
||
</div>"""
|
||
|
||
has_screenshot = bool(scr_id)
|
||
panes_cls = "panes panes-3" if has_screenshot else "panes"
|
||
|
||
# Успешные — вся задача свёрнута, ошибки — развёрнуты
|
||
if display_status == "completed":
|
||
panes_html = f"""
|
||
<div class="toggle-wrap" data-tid="{tid}">
|
||
<button class="toggle-btn" onclick="this.parentElement.classList.toggle('open')">Показать JSON</button>
|
||
<div class="toggle-content">
|
||
<div class="{panes_cls}">
|
||
<div class="pane task"><div class="label">Task JSON</div>{json_block(task)}</div>
|
||
<div class="pane result"><div class="label">Result JSON</div>{result_summary}{json_block(result)}</div>
|
||
{scr_pane}
|
||
</div>
|
||
</div>
|
||
</div>"""
|
||
else:
|
||
panes_html = f"""
|
||
<div class="{panes_cls}">
|
||
<div class="pane task"><div class="label">Task JSON</div>{json_block(task)}</div>
|
||
<div class="pane result"><div class="label">Result JSON</div>{result_summary}{json_block(result)}</div>
|
||
{scr_pane}
|
||
</div>"""
|
||
|
||
return f"""
|
||
<div class="entry {display_status}">
|
||
<div class="head">
|
||
<span class="badge {display_status}">{display_status}</span>
|
||
<span class="type">{ttype}</span>
|
||
<span class="bank">{bank}</span>
|
||
<span class="mid">{mid}…</span>
|
||
<span class="src">{src}</span>
|
||
<span class="times">inj {injected_at} → res {result_at}</span>
|
||
</div>
|
||
{panes_html}
|
||
</div>
|
||
"""
|
||
|
||
|
||
def render_profile_section() -> str:
|
||
"""Render loaded bank profiles / materials as an HTML info block."""
|
||
import html as _html
|
||
if not _bank_profiles:
|
||
return '<div class="profile-section muted">Профиль не загружен</div>'
|
||
|
||
# Group by device
|
||
devices: dict = {}
|
||
for bp in _bank_profiles:
|
||
dev_id = bp.get("device_id", "?")
|
||
label = bp.get("device_label") or (dev_id[:8] + "…")
|
||
if dev_id not in devices:
|
||
devices[dev_id] = {"label": label, "profiles": []}
|
||
devices[dev_id]["profiles"].append(bp)
|
||
|
||
device_blocks = []
|
||
for dev_id, dev in devices.items():
|
||
label = _html.escape(dev["label"])
|
||
dev_mats = sum(len(bp.get("materials", [])) for bp in dev["profiles"])
|
||
bp_cards = []
|
||
for bp in dev["profiles"]:
|
||
bank = _html.escape(bp.get("bank_name", "?"))
|
||
first = bp.get("first_name") or ""
|
||
last = bp.get("last_name") or ""
|
||
name = _html.escape(f"{first} {last}".strip() or "—")
|
||
phone = _html.escape(bp.get("phone_number") or "—")
|
||
mats = bp.get("materials", [])
|
||
mat_rows = []
|
||
for m in mats:
|
||
mid = _html.escape(m.get("id", "?")[:12])
|
||
card = _html.escape(m.get("card_number") or "—")
|
||
cur = m.get("currency", "")
|
||
state = _html.escape(m.get("state", "?"))
|
||
# Count pool tasks for this material
|
||
pool_n = len(_pool_by_material.get(m.get("id", ""), []))
|
||
pool_badge = (f'<span class="pool-ok">{pool_n}</span>'
|
||
if pool_n > 0 else '<span class="muted">0</span>')
|
||
mat_rows.append(
|
||
f'<tr><td class="mono">{mid}…</td>'
|
||
f'<td>{card}</td><td>{cur}</td>'
|
||
f'<td><span class="badge-sm {state}">{state}</span></td>'
|
||
f'<td>{pool_badge}</td></tr>'
|
||
)
|
||
mat_table = ""
|
||
if mat_rows:
|
||
mat_table = (
|
||
'<table class="mat-table">'
|
||
'<tr><th>Material ID</th><th>Карта</th><th>Валюта</th>'
|
||
'<th>Статус</th><th>Пул</th></tr>'
|
||
+ "".join(mat_rows)
|
||
+ '</table>'
|
||
)
|
||
bp_cards.append(
|
||
f'<div class="bp-card">'
|
||
f'<div class="bp-header">'
|
||
f'<span class="bp-bank">{bank}</span>'
|
||
f'<span class="bp-name">{name}</span>'
|
||
f'<span class="bp-phone">{phone}</span>'
|
||
f'</div>'
|
||
f'{mat_table}'
|
||
f'</div>'
|
||
)
|
||
|
||
device_blocks.append(
|
||
f'<div class="dev-group">'
|
||
f'<div class="dev-group-header">'
|
||
f'<span class="dev-label">{label}</span>'
|
||
f'<span class="muted">{len(dev["profiles"])} профилей, {dev_mats} материалов</span>'
|
||
f'</div>'
|
||
f'<div class="bp-grid">{"".join(bp_cards)}</div>'
|
||
f'</div>'
|
||
)
|
||
|
||
pool_count = sum(len(v) for v in _pool_by_material.values())
|
||
pool_info = (f'<span class="pool-ok">{pool_count} задач в пуле</span>'
|
||
if pool_count > 0
|
||
else '<span class="pool-empty">пул пуст</span>')
|
||
total_mats = sum(len(bp.get("materials", [])) for bp in _bank_profiles)
|
||
|
||
return (
|
||
'<details class="profile-section" open>'
|
||
'<summary>Загруженные данные '
|
||
f'<span class="muted">({len(devices)} устройств, '
|
||
f'{len(_bank_profiles)} профилей, '
|
||
f'{total_mats} материалов, '
|
||
f'{pool_info})</span></summary>'
|
||
+ "".join(device_blocks)
|
||
+ '</details>'
|
||
)
|
||
|
||
|
||
def _render_grouped_blocks(entries) -> str:
|
||
import html as _html
|
||
|
||
# Group by device label → bank_name
|
||
devices: dict = defaultdict(lambda: defaultdict(list))
|
||
for e in entries:
|
||
task = e.get("task") or {}
|
||
mid = task.get("material_id")
|
||
label = _device_label_by_material.get(mid) or (
|
||
"(без устройства)" if mid is None else f"{mid[:8]}…"
|
||
)
|
||
bank = task.get("bank_name", "unknown")
|
||
devices[label][bank].append(e)
|
||
|
||
group_blocks = []
|
||
for label in sorted(devices.keys()):
|
||
banks = devices[label]
|
||
# Device-level stats
|
||
all_entries = [e for bl in banks.values() for e in bl]
|
||
g_total = len(all_entries)
|
||
g_err = sum(1 for e in all_entries if _is_error_entry(e))
|
||
g_done = sum(1 for e in all_entries if e["status"] == "completed") - g_err
|
||
g_pend = sum(1 for e in all_entries if e["status"] == "pending")
|
||
err_badge = f' <span style="color:var(--unsolicited)">ошибки: {g_err}</span>' if g_err else ''
|
||
|
||
bank_blocks = []
|
||
for bank in sorted(banks.keys()):
|
||
bentries = banks[bank]
|
||
b_total = len(bentries)
|
||
b_err = sum(1 for e in bentries if _is_error_entry(e))
|
||
b_done = sum(1 for e in bentries if e["status"] == "completed") - b_err
|
||
b_pend = sum(1 for e in bentries if e["status"] == "pending")
|
||
b_err_badge = f' <span style="color:var(--unsolicited)">{b_err}</span>' if b_err else ''
|
||
rows_html = "\n".join(render_entry_html(e) for e in bentries)
|
||
bank_blocks.append(
|
||
f'<div class="bank-group">'
|
||
f'<h3><span class="bank">{_html.escape(bank)}</span>'
|
||
f'<span class="g-stats">'
|
||
f'<span class="g-done">{b_done}</span>'
|
||
f' / <span class="g-total">{b_total}</span>'
|
||
f'{b_err_badge}'
|
||
f' <span class="muted">(ожидают: {b_pend})</span>'
|
||
f'</span></h3>'
|
||
f'{rows_html}</div>'
|
||
)
|
||
|
||
group_blocks.append(f"""
|
||
<section class="group">
|
||
<h2>
|
||
<span class="dev">{_html.escape(label)}</span>
|
||
<span class="g-stats">
|
||
<span class="g-done">{g_done}</span>
|
||
/ <span class="g-total">{g_total}</span>
|
||
{err_badge}
|
||
<span class="muted">(ожидают: {g_pend})</span>
|
||
</span>
|
||
</h2>
|
||
{"".join(bank_blocks)}
|
||
</section>
|
||
""")
|
||
|
||
return "\n".join(group_blocks)
|
||
|
||
|
||
def render_report_html(entries, summary) -> str:
|
||
body_rows = _render_grouped_blocks(entries) or '<div class="empty">Отчёт пуст — бутстрап ещё не сработал или результаты ещё не пришли.</div>'
|
||
|
||
return f"""<!doctype html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<title>ARC mock — отчёт</title>
|
||
<style>
|
||
:root {{
|
||
--bg: #0f1115;
|
||
--panel: #1a1d25;
|
||
--muted: #7d8594;
|
||
--text: #e6e8ec;
|
||
--task: #1f2430;
|
||
--result: #1b2420;
|
||
--pending: #c9a227;
|
||
--completed: #4caf50;
|
||
--unsolicited: #d04343;
|
||
--border: #2a2f3a;
|
||
}}
|
||
* {{ box-sizing: border-box; }}
|
||
body {{
|
||
margin: 0; padding: 20px;
|
||
background: var(--bg); color: var(--text);
|
||
font-family: -apple-system, "SF Pro Text", Helvetica, Arial, sans-serif;
|
||
font-size: 13px;
|
||
}}
|
||
h1 {{ margin: 0 0 8px; font-size: 18px; }}
|
||
.summary {{
|
||
display: flex; gap: 12px; flex-wrap: wrap;
|
||
margin-bottom: 16px; font-size: 13px;
|
||
}}
|
||
.summary .cell {{
|
||
background: var(--panel); padding: 6px 12px;
|
||
border: 1px solid var(--border); border-radius: 4px;
|
||
}}
|
||
.summary .cell .n {{ font-size: 16px; font-weight: bold; }}
|
||
.summary .pending .n {{ color: var(--pending); }}
|
||
.summary .completed .n {{ color: var(--completed); }}
|
||
.summary .unsolicited .n {{ color: var(--unsolicited); }}
|
||
.entry {{
|
||
background: var(--panel);
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
margin-bottom: 12px;
|
||
overflow: hidden;
|
||
}}
|
||
.entry.completed {{ border-left: 4px solid var(--completed); }}
|
||
.entry.error {{ border-left: 4px solid var(--unsolicited); }}
|
||
.entry.pending {{ border-left: 4px solid var(--pending); }}
|
||
.entry.unsolicited {{ border-left: 4px solid var(--unsolicited); }}
|
||
.head {{
|
||
display: flex; align-items: center; gap: 10px;
|
||
padding: 8px 12px; background: #161921; font-size: 12px;
|
||
flex-wrap: wrap;
|
||
}}
|
||
.badge {{
|
||
padding: 2px 8px; border-radius: 10px; font-weight: 600;
|
||
text-transform: uppercase; font-size: 10px;
|
||
}}
|
||
.badge.pending {{ background: var(--pending); color: #1a1600; }}
|
||
.badge.completed {{ background: var(--completed); color: #0a1a0a; }}
|
||
.badge.error {{ background: var(--unsolicited); color: #200; }}
|
||
.badge.unsolicited {{ background: var(--unsolicited); color: #200; }}
|
||
.type {{ font-weight: 600; color: #9cdcfe; }}
|
||
.bank {{ color: #ffb86c; }}
|
||
.mid {{ color: var(--muted); font-family: monospace; }}
|
||
.src {{ color: var(--muted); font-style: italic; }}
|
||
.times {{ color: var(--muted); margin-left: auto; font-family: monospace; }}
|
||
.panes {{ display: grid; grid-template-columns: 1fr 1fr; gap: 1px; background: var(--border); }}
|
||
.panes-3 {{ grid-template-columns: 1fr 1fr auto; }}
|
||
.pane {{ padding: 10px 12px; }}
|
||
.pane.task {{ background: var(--task); }}
|
||
.pane.result {{ background: var(--result); }}
|
||
.pane .label {{
|
||
font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em;
|
||
color: var(--muted); margin-bottom: 4px;
|
||
}}
|
||
pre {{
|
||
margin: 0; white-space: pre-wrap; word-break: break-word;
|
||
font-family: "SF Mono", Menlo, Consolas, monospace; font-size: 12px;
|
||
line-height: 1.45;
|
||
}}
|
||
.muted {{ color: var(--muted); font-style: italic; }}
|
||
.err {{ color: #ff6b6b; font-weight: 600; margin-bottom: 4px; }}
|
||
.toggle-wrap .toggle-content {{ display: none; }}
|
||
.toggle-wrap.open .toggle-content {{ display: block; }}
|
||
.toggle-btn {{
|
||
background: var(--panel); color: var(--muted);
|
||
border: 1px solid var(--border); border-radius: 4px;
|
||
padding: 4px 12px; margin: 8px 12px;
|
||
cursor: pointer; font-size: 11px;
|
||
transition: background 0.15s, color 0.15s;
|
||
}}
|
||
.toggle-btn:hover {{ background: var(--border); color: var(--text); }}
|
||
.json-wrap {{ position: relative; }}
|
||
.copy-btn {{
|
||
position: absolute; top: 6px; right: 6px;
|
||
background: var(--border); color: var(--muted);
|
||
border: none; border-radius: 4px;
|
||
padding: 5px 12px; font-size: 12px;
|
||
cursor: pointer; opacity: 0; transition: opacity 0.15s;
|
||
}}
|
||
.json-wrap:hover .copy-btn {{ opacity: 1; }}
|
||
.copy-btn:hover {{ color: var(--text); background: #3a3f4a; }}
|
||
.copy-btn.copied {{ color: var(--completed); }}
|
||
.screenshot-pane {{ background: var(--panel); max-width: 280px; }}
|
||
.screenshot {{ max-height: 200px; width: auto; border-radius: 4px; margin-top: 4px; cursor: pointer; transition: opacity 0.15s; }}
|
||
.screenshot:hover {{ opacity: 0.8; }}
|
||
.lightbox {{
|
||
display: none; position: fixed; inset: 0; z-index: 1000;
|
||
background: rgba(0,0,0,0.85); align-items: center; justify-content: center;
|
||
cursor: zoom-out;
|
||
}}
|
||
.lightbox.open {{ display: flex; }}
|
||
.lightbox img {{ max-width: 90vw; max-height: 90vh; border-radius: 6px; }}
|
||
.empty {{ color: var(--muted); text-align: center; padding: 40px; }}
|
||
a {{ color: #9cdcfe; }}
|
||
.group {{
|
||
margin-bottom: 24px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
background: #13161d;
|
||
padding: 12px 16px;
|
||
}}
|
||
.group h2 {{
|
||
margin: 0 0 12px;
|
||
font-size: 15px;
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 10px;
|
||
padding-bottom: 8px;
|
||
border-bottom: 1px solid var(--border);
|
||
}}
|
||
.group h2 .dev {{ color: #ffb86c; font-weight: 700; }}
|
||
.group h2 .g-stats {{ margin-left: auto; font-family: monospace; font-size: 13px; }}
|
||
.group h2 .g-done {{ color: var(--completed); font-weight: 700; }}
|
||
.group h2 .g-total {{ color: var(--muted); }}
|
||
.bank-group {{
|
||
margin: 8px 0 16px;
|
||
padding-left: 12px;
|
||
border-left: 2px solid var(--border);
|
||
}}
|
||
.bank-group h3 {{
|
||
margin: 0 0 8px;
|
||
font-size: 13px;
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 10px;
|
||
}}
|
||
.bank-group h3 .bank {{ color: #9cdcfe; font-weight: 600; }}
|
||
.bank-group h3 .g-stats {{ margin-left: auto; font-family: monospace; font-size: 12px; }}
|
||
.bank-group h3 .g-done {{ color: var(--completed); font-weight: 700; }}
|
||
.bank-group h3 .g-total {{ color: var(--muted); }}
|
||
.updating {{ opacity: 0.6; }}
|
||
/* ─── Profile section ─── */
|
||
.profile-section {{
|
||
margin-bottom: 16px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
background: var(--panel);
|
||
padding: 10px 14px;
|
||
}}
|
||
.profile-section summary {{
|
||
cursor: pointer;
|
||
font-weight: 600;
|
||
font-size: 14px;
|
||
padding: 4px 0;
|
||
user-select: none;
|
||
}}
|
||
.bp-grid {{
|
||
display: flex; flex-wrap: wrap; gap: 10px;
|
||
margin-top: 10px;
|
||
}}
|
||
.bp-card {{
|
||
background: #161921;
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
padding: 10px 12px;
|
||
min-width: 320px;
|
||
flex: 1;
|
||
}}
|
||
.bp-header {{
|
||
display: flex; gap: 10px; align-items: center;
|
||
margin-bottom: 8px; flex-wrap: wrap;
|
||
}}
|
||
.dev-label {{ color: #ffb86c; font-weight: 700; }}
|
||
.bp-bank {{ color: #9cdcfe; font-weight: 600; }}
|
||
.bp-name {{ color: var(--text); }}
|
||
.bp-phone {{ color: var(--muted); font-family: monospace; font-size: 12px; }}
|
||
.mat-table {{
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
font-size: 12px;
|
||
}}
|
||
.mat-table th {{
|
||
text-align: left;
|
||
color: var(--muted);
|
||
font-weight: 500;
|
||
border-bottom: 1px solid var(--border);
|
||
padding: 3px 6px;
|
||
font-size: 10px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.04em;
|
||
}}
|
||
.mat-table td {{
|
||
padding: 4px 6px;
|
||
border-bottom: 1px solid #1f232d;
|
||
}}
|
||
.mono {{ font-family: "SF Mono", Menlo, Consolas, monospace; }}
|
||
.badge-sm {{
|
||
padding: 1px 6px; border-radius: 8px;
|
||
font-size: 10px; font-weight: 600; text-transform: uppercase;
|
||
}}
|
||
.badge-sm.enabled {{ background: var(--completed); color: #0a1a0a; }}
|
||
.badge-sm.disabled {{ background: var(--muted); color: #1a1d25; }}
|
||
.pool-ok {{ color: var(--completed); }}
|
||
.pool-empty {{ color: var(--pending); }}
|
||
.dev-group {{
|
||
margin-top: 12px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
background: #13161d;
|
||
padding: 10px 14px;
|
||
}}
|
||
.dev-group-header {{
|
||
display: flex; align-items: baseline; gap: 10px;
|
||
padding-bottom: 8px; margin-bottom: 10px;
|
||
border-bottom: 1px solid var(--border);
|
||
}}
|
||
.dev-group-header .dev-label {{ font-size: 14px; }}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<h1>ARC mock — отчёт <span class="muted" id="refresh-status">(автообновление 2 сек)</span></h1>
|
||
<div class="summary">
|
||
<div class="cell"><div class="n">{summary['total']}</div>всего</div>
|
||
<div class="cell completed"><div class="n">{summary['completed']}</div>выполнено</div>
|
||
<div class="cell unsolicited"><div class="n">{summary['errors']}</div>ошибки</div>
|
||
<div class="cell pending"><div class="n">{summary['pending']}</div>в очереди</div>
|
||
</div>
|
||
{render_profile_section()}
|
||
<div id="report-body">
|
||
{body_rows}
|
||
</div>
|
||
<script>
|
||
(function() {{
|
||
const REFRESH_MS = 2000;
|
||
const bodyEl = document.getElementById('report-body');
|
||
const statusEl = document.getElementById('refresh-status');
|
||
const summaryEls = {{
|
||
total: document.querySelector('.summary .cell:nth-child(1) .n'),
|
||
completed: document.querySelector('.summary .cell.completed .n'),
|
||
errors: document.querySelector('.summary .cell.unsolicited .n'),
|
||
pending: document.querySelector('.summary .cell.pending .n'),
|
||
}};
|
||
|
||
function saveToggleState() {{
|
||
const state = new Set();
|
||
bodyEl.querySelectorAll('.toggle-wrap[data-tid]').forEach(el => {{
|
||
if (el.classList.contains('open')) state.add(el.dataset.tid);
|
||
}});
|
||
return state;
|
||
}}
|
||
|
||
function restoreToggleState(state) {{
|
||
bodyEl.querySelectorAll('.toggle-wrap[data-tid]').forEach(el => {{
|
||
if (state.has(el.dataset.tid)) el.classList.add('open');
|
||
}});
|
||
updateToggleLabels();
|
||
}}
|
||
|
||
function updateToggleLabels() {{
|
||
bodyEl.querySelectorAll('.toggle-wrap').forEach(el => {{
|
||
const btn = el.querySelector('.toggle-btn');
|
||
if (btn) btn.textContent = el.classList.contains('open') ? 'Свернуть JSON' : 'Показать JSON';
|
||
}});
|
||
}}
|
||
|
||
document.addEventListener('click', e => {{
|
||
if (e.target.classList.contains('toggle-btn')) updateToggleLabels();
|
||
}});
|
||
|
||
async function tick() {{
|
||
try {{
|
||
const resp = await fetch('/_admin/report/fragment', {{cache: 'no-store'}});
|
||
if (!resp.ok) throw new Error(resp.status);
|
||
const data = await resp.json();
|
||
const saved = saveToggleState();
|
||
bodyEl.innerHTML = data.html;
|
||
restoreToggleState(saved);
|
||
summaryEls.total.textContent = data.summary.total;
|
||
summaryEls.completed.textContent = data.summary.completed;
|
||
summaryEls.errors.textContent = data.summary.errors;
|
||
summaryEls.pending.textContent = data.summary.pending;
|
||
statusEl.textContent = '(обновлено ' + new Date().toLocaleTimeString() + ')';
|
||
}} catch (e) {{
|
||
statusEl.textContent = '(ошибка обновления: ' + e + ')';
|
||
}}
|
||
}}
|
||
|
||
setInterval(tick, REFRESH_MS);
|
||
}})();
|
||
</script>
|
||
<script>
|
||
function copyJson(btn) {{
|
||
const text = btn.dataset.json;
|
||
navigator.clipboard.writeText(text).then(() => {{
|
||
btn.textContent = 'Скопировано';
|
||
btn.classList.add('copied');
|
||
setTimeout(() => {{ btn.textContent = 'Копировать'; btn.classList.remove('copied'); }}, 1500);
|
||
}});
|
||
}}
|
||
</script>
|
||
<div class="lightbox" id="lightbox" onclick="this.classList.remove('open')">
|
||
<img id="lightbox-img" src="" alt="">
|
||
</div>
|
||
<script>
|
||
document.addEventListener('click', function(e) {{
|
||
if (e.target.classList.contains('screenshot')) {{
|
||
const lb = document.getElementById('lightbox');
|
||
document.getElementById('lightbox-img').src = e.target.src;
|
||
lb.classList.add('open');
|
||
}}
|
||
}});
|
||
document.addEventListener('keydown', function(e) {{
|
||
if (e.key === 'Escape') document.getElementById('lightbox').classList.remove('open');
|
||
}});
|
||
</script>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
|
||
@app.post("/_admin/reset")
|
||
def admin_reset():
|
||
global _bootstrap_done
|
||
with _lock:
|
||
_task_queue.clear()
|
||
_injected.clear()
|
||
_pending_by_key.clear()
|
||
_in_flight.clear()
|
||
_monitoring_logs.clear()
|
||
_bootstrap_done = False
|
||
if _SCREENSHOTS_DIR.exists():
|
||
shutil.rmtree(_SCREENSHOTS_DIR)
|
||
if _STATE_FILE.exists():
|
||
_STATE_FILE.unlink()
|
||
return ok({})
|
||
|
||
|
||
@app.get("/_admin/state")
|
||
def admin_state():
|
||
with _lock:
|
||
return jsonify({
|
||
"queued_tasks": len(_task_queue),
|
||
"injected": len(_injected),
|
||
"completed": sum(1 for e in _injected if e["status"] == "completed"),
|
||
"pending": sum(1 for e in _injected if e["status"] == "pending"),
|
||
"bank_profiles": len(_bank_profiles),
|
||
"pool_materials": len(_pool_by_material),
|
||
"bootstrap_done": _bootstrap_done,
|
||
})
|
||
|
||
|
||
if __name__ == "__main__":
|
||
parser = argparse.ArgumentParser()
|
||
parser.add_argument("--host", default="127.0.0.1")
|
||
parser.add_argument("--port", type=int, default=8888)
|
||
parser.add_argument("--ft-per-material", type=int, default=3,
|
||
help="How many random FETCH_TRANSACTIONS to bootstrap per material")
|
||
parser.add_argument("--banks", nargs="+", default=None,
|
||
help="Only bootstrap tasks for these banks (e.g. --banks ozon dushanbe_city_bank)")
|
||
parser.add_argument("--seed", type=int, default=None,
|
||
help="RNG seed for reproducible bootstrap picks")
|
||
parser.add_argument("--types", nargs="+", default=None,
|
||
help="Only bootstrap these task types (e.g. --types FETCH_TRANSACTIONS)")
|
||
parser.add_argument("--debug", action="store_true",
|
||
help="Run Flask in debug mode (auto-reload on code changes)")
|
||
args = parser.parse_args()
|
||
|
||
_bootstrap_ft_count = args.ft_per_material
|
||
_bootstrap_banks = args.banks
|
||
_bootstrap_types = args.types
|
||
if _bootstrap_banks:
|
||
print(f"[mock] bootstrap limited to banks: {_bootstrap_banks}")
|
||
if _bootstrap_types:
|
||
print(f"[mock] bootstrap limited to types: {_bootstrap_types}")
|
||
if args.seed is not None:
|
||
random.seed(args.seed)
|
||
|
||
load_default_profile()
|
||
load_pool()
|
||
|
||
is_reload = os.environ.get("WERKZEUG_RUN_MAIN") == "true"
|
||
|
||
if args.debug and is_reload and _load_state():
|
||
# Auto-reload в debug — восстанавливаем состояние
|
||
print("[mock] auto-reload: state restored from disk")
|
||
else:
|
||
# Первый запуск (или без --debug) — чистим всё
|
||
_task_queue.clear()
|
||
_injected.clear()
|
||
_pending_by_key.clear()
|
||
_in_flight.clear()
|
||
_monitoring_logs.clear()
|
||
_bootstrap_done = False
|
||
if _STATE_FILE.exists():
|
||
_STATE_FILE.unlink()
|
||
import shutil
|
||
if _SCREENSHOTS_DIR.exists():
|
||
shutil.rmtree(_SCREENSHOTS_DIR)
|
||
# Чистим events в БД приложения чтобы не было "App restarted" при старте
|
||
try:
|
||
import configparser
|
||
import sqlite3
|
||
project_root = (HERE / ".." / "..").resolve()
|
||
for cfg in [project_root / "config.ini", project_root / "build_test" / "config.ini"]:
|
||
if cfg.exists():
|
||
cp = configparser.ConfigParser()
|
||
cp.read(str(cfg), encoding="utf-8")
|
||
raw = cp.get("db", "dbPath", fallback="")
|
||
if raw:
|
||
db = Path(os.path.expanduser(raw))
|
||
if not db.is_absolute():
|
||
db = cfg.parent / db
|
||
if db.exists():
|
||
conn = sqlite3.connect(str(db))
|
||
n = conn.execute("SELECT count(*) FROM events").fetchone()[0]
|
||
if n > 0:
|
||
conn.execute("DELETE FROM events")
|
||
conn.commit()
|
||
print(f"[mock] cleaned {n} events from {db.name}")
|
||
conn.close()
|
||
break
|
||
except Exception as e:
|
||
print(f"[mock] warning: could not clean events DB: {e}")
|
||
print("[mock] starting fresh (clean slate)")
|
||
|
||
app.run(host=args.host, port=args.port, threaded=True, debug=args.debug)
|