1
0
forked from BRT/arc
arc/tests/mock_server/mock_server.py
trnsmkot ce14d5eef3 Refine transaction fetching and parsing:
- Add date header parsing and swipe functionality for streamlined `Ozon` transactions list processing.
- Improve deduplication logic, date assignment, and scroll behavior in `Dushanbe` and `Ozon` scripts.
- Introduce screenshot handling for debugging transaction fetching workflows.
- Add cancellation logic by `materialId` in `EventDAO`.
- Update mock server for enhanced filename generation and PATCH support for bank profiles.
2026-04-13 22:41:15 +07:00

994 lines
33 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

#!/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 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
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
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.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:
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("&", "&amp;").replace("<", "&lt;")
.replace(">", "&gt;").replace('"', "&quot;"))
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("--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")
args = parser.parse_args()
_bootstrap_ft_count = args.ft_per_material
_bootstrap_banks = args.banks
if _bootstrap_banks:
print(f"[mock] bootstrap limited to banks: {_bootstrap_banks}")
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)