969 lines
32 KiB
Python
969 lines
32 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 random
|
||
import threading
|
||
import time
|
||
import uuid
|
||
from collections import defaultdict, deque
|
||
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
|
||
|
||
|
||
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")
|
||
for mat in bp.get("materials", []):
|
||
mid = mat.get("id")
|
||
if not mid:
|
||
continue
|
||
# 1 × FETCH_PROFILE
|
||
enqueue_task({
|
||
"bank_name": bank,
|
||
"material_id": mid,
|
||
"type": "FETCH_PROFILE",
|
||
"body": {},
|
||
"_source": "bootstrap",
|
||
})
|
||
queued += 1
|
||
|
||
# N × random FETCH_TRANSACTIONS from pool
|
||
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
|
||
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.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:
|
||
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)
|
||
|
||
key = (mid, body.get("type"))
|
||
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",
|
||
})
|
||
return ok({})
|
||
|
||
|
||
# ---------------------------------------------------------------- monitoring
|
||
@app.post("/monitoring/log")
|
||
def monitoring_log():
|
||
return ok({})
|
||
|
||
|
||
# ---------------------------------------------------------------- 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:
|
||
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"),
|
||
"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 _snapshot_entries_and_summary():
|
||
entries = list(_injected)
|
||
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"),
|
||
"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()
|
||
# Render just the grouped body (without the full page shell)
|
||
from collections import defaultdict as _dd
|
||
import html as _html
|
||
groups = _dd(list)
|
||
for e in entries:
|
||
mid = (e.get("task") or {}).get("material_id")
|
||
label = _device_label_by_material.get(mid) or (
|
||
"(без устройства)" if mid is None else f"{mid[:8]}…"
|
||
)
|
||
groups[label].append(e)
|
||
|
||
blocks = []
|
||
for label in sorted(groups.keys()):
|
||
gentries = groups[label]
|
||
g_done = sum(1 for e in gentries if e["status"] == "completed")
|
||
g_pend = sum(1 for e in gentries if e["status"] == "pending")
|
||
rows_html = "\n".join(render_entry_html(e) for e in gentries)
|
||
blocks.append(
|
||
f'<section class="group">'
|
||
f'<h2><span class="dev">{_html.escape(label)}</span>'
|
||
f'<span class="g-stats">'
|
||
f'<span class="g-done">{g_done}</span>'
|
||
f' / <span class="g-total">{len(gentries)}</span>'
|
||
f' <span class="muted">(ожидают: {g_pend})</span>'
|
||
f'</span></h2>{rows_html}</section>'
|
||
)
|
||
body = "\n".join(blocks) 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"]
|
||
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"))
|
||
|
||
def json_block(obj):
|
||
if obj is None:
|
||
return '<span class="muted">—</span>'
|
||
text = json.dumps(obj, ensure_ascii=False, indent=2)
|
||
return f'<pre>{_html.escape(text)}</pre>'
|
||
|
||
result_summary = ""
|
||
if result and isinstance(result.get("data"), dict):
|
||
d = result["data"]
|
||
if d.get("status") == "error":
|
||
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>'
|
||
|
||
return f"""
|
||
<div class="entry {status}">
|
||
<div class="head">
|
||
<span class="badge {status}">{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>
|
||
<div class="panes">
|
||
<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>
|
||
</div>
|
||
</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_report_html(entries, summary) -> str:
|
||
import html as _html
|
||
|
||
# Group by device label
|
||
groups: dict = defaultdict(list)
|
||
for e in entries:
|
||
mid = (e.get("task") or {}).get("material_id")
|
||
label = _device_label_by_material.get(mid) or (
|
||
"(без устройства)" if mid is None else f"{mid[:8]}…"
|
||
)
|
||
groups[label].append(e)
|
||
|
||
group_blocks = []
|
||
for label in sorted(groups.keys()):
|
||
gentries = groups[label]
|
||
g_total = len(gentries)
|
||
g_done = sum(1 for e in gentries if e["status"] == "completed")
|
||
g_pend = sum(1 for e in gentries if e["status"] == "pending")
|
||
rows_html = "\n".join(render_entry_html(e) for e in gentries)
|
||
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>
|
||
<span class="muted">(ожидают: {g_pend})</span>
|
||
</span>
|
||
</h2>
|
||
{rows_html}
|
||
</section>
|
||
""")
|
||
|
||
body_rows = "\n".join(group_blocks) 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.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.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); }}
|
||
.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; }}
|
||
.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); }}
|
||
.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 pending"><div class="n">{summary['pending']}</div>в очереди</div>
|
||
<div class="cell unsolicited"><div class="n">{summary['unsolicited']}</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'),
|
||
pending: document.querySelector('.summary .cell.pending .n'),
|
||
unsolicited: document.querySelector('.summary .cell.unsolicited .n'),
|
||
}};
|
||
|
||
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();
|
||
bodyEl.innerHTML = data.html;
|
||
summaryEls.total.textContent = data.summary.total;
|
||
summaryEls.completed.textContent = data.summary.completed;
|
||
summaryEls.pending.textContent = data.summary.pending;
|
||
summaryEls.unsolicited.textContent = data.summary.unsolicited;
|
||
statusEl.textContent = '(обновлено ' + new Date().toLocaleTimeString() + ')';
|
||
}} catch (e) {{
|
||
statusEl.textContent = '(ошибка обновления: ' + e + ')';
|
||
}}
|
||
}}
|
||
|
||
setInterval(tick, REFRESH_MS);
|
||
}})();
|
||
</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()
|
||
_bootstrap_done = False
|
||
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("--seed", type=int, default=None,
|
||
help="RNG seed for reproducible bootstrap picks")
|
||
args = parser.parse_args()
|
||
|
||
_bootstrap_ft_count = args.ft_per_material
|
||
if args.seed is not None:
|
||
random.seed(args.seed)
|
||
|
||
# Fresh start: drop any leftover state from a previous run in this process.
|
||
_task_queue.clear()
|
||
_injected.clear()
|
||
_pending_by_key.clear()
|
||
_in_flight.clear()
|
||
_bootstrap_done = False
|
||
print("[mock] state cleared — starting fresh")
|
||
|
||
load_default_profile()
|
||
load_pool()
|
||
|
||
app.run(host=args.host, port=args.port, threaded=True)
|