Move all three skills into skills/; add root README; fix paths
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
312742cfef
commit
d95e5d452d
84 files changed
+97
-31
No files matched your search
@@ -0,0 +1,31 @@
|
||||
"""Fake bot for checking the Run tab. Prints the same protocol lines a real bot does (ADR-0002).
|
||||
|
||||
FAKE_MODE: complete | partial | stuck | crash FAKE_CPS: how many checkpoints
|
||||
Never touches anything outside its own log.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import rt # noqa: E402
|
||||
|
||||
mode = os.environ.get("FAKE_MODE", "complete")
|
||||
cps = int(os.environ.get("FAKE_CPS", "3"))
|
||||
print("fake bot starting (this line stays in the log file only)")
|
||||
for n in range(1, cps + 1):
|
||||
rt.checkpoint(n)
|
||||
rt.ui(f"Working on checkpoint {n}")
|
||||
if mode == "stuck" and n == 2:
|
||||
time.sleep(3600) # the Run tab must kill this
|
||||
if mode == "crash" and n == 2:
|
||||
raise RuntimeError("Simulated crash")
|
||||
time.sleep(0.4)
|
||||
rt.ui(f"Checkpoint {n} finished", "ok")
|
||||
rt.checkpoint_done(n)
|
||||
|
||||
if mode == "partial":
|
||||
rt.ui("2 rows could not be processed", "warn")
|
||||
rt.summary(5, [("Row 4", "Account not found"), ("Row 5", "Timed out")])
|
||||
else:
|
||||
rt.summary(5)
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Theme the Run tab: a preset or one brand hex -> the THEME block of run_tab.html.
|
||||
|
||||
python palette.py sky|teal|"#7a3cff" print the block and a contrast table
|
||||
python palette.py "#7a3cff" --write run_tab/run_tab.html also write it into the page
|
||||
|
||||
Only the sky gradient and the accent change. ok/warn/bad and the log colours stay fixed (R9).
|
||||
A brand hex that is too light for white button text is darkened until it passes.
|
||||
Exits 1 if a contrast check still fails.
|
||||
"""
|
||||
import re
|
||||
import sys
|
||||
|
||||
WHITE, BLACK = "#ffffff", "#000000"
|
||||
INK = {"light": "#1c2b33", "dark": "#e6eef4"}
|
||||
PRESETS = {
|
||||
"sky": {"light": {"--sky-top": "#8fc9e8", "--sky-mid": "#bfe3f2", "--sky-bottom": "#eaf6fb",
|
||||
"--accent": "#3d9bd0", "--accent-lite": "#5cb2e2", "--accent-dark": "#16202a"},
|
||||
"dark": {"--sky-top": "#0f1b26", "--sky-mid": "#13212e", "--sky-bottom": "#182a38",
|
||||
"--accent": "#3d9bd0", "--accent-lite": "#5cb2e2", "--accent-dark": "#2e5d80"}},
|
||||
"teal": {"light": {"--sky-top": "#8fe8ad", "--sky-mid": "#bff2d0", "--sky-bottom": "#eafbf0",
|
||||
"--accent": "#0d9488", "--accent-lite": "#2dd4bf", "--accent-dark": "#16202a"},
|
||||
"dark": {"--sky-top": "#0f2617", "--sky-mid": "#132e1c", "--sky-bottom": "#183823",
|
||||
"--accent": "#0d9488", "--accent-lite": "#2dd4bf", "--accent-dark": "#2e5d80"}},
|
||||
}
|
||||
|
||||
|
||||
def rgb(h):
|
||||
return [int(h[i:i + 2], 16) for i in (1, 3, 5)]
|
||||
|
||||
|
||||
def mix(a, b, t):
|
||||
"""Blend hex colour a toward b by t (0..1)."""
|
||||
return "#" + "".join(f"{round(x + (y - x) * t):02x}" for x, y in zip(rgb(a), rgb(b)))
|
||||
|
||||
|
||||
def contrast(a, b):
|
||||
"""WCAG 2 contrast ratio between two hex colours."""
|
||||
def lum(h):
|
||||
c = [v / 255 for v in rgb(h)]
|
||||
c = [x / 12.92 if x <= 0.03928 else ((x + 0.055) / 1.055) ** 2.4 for x in c]
|
||||
return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]
|
||||
hi, lo = sorted((lum(a), lum(b)), reverse=True)
|
||||
return (hi + 0.05) / (lo + 0.05)
|
||||
|
||||
|
||||
def derive(brand):
|
||||
brand = brand.strip().lower()
|
||||
if len(brand) == 4: # #abc -> #aabbcc
|
||||
brand = "#" + "".join(ch * 2 for ch in brand[1:])
|
||||
accent = brand
|
||||
for _ in range(60): # white text on the Trigger button must stay readable
|
||||
if contrast(WHITE, accent) >= 3:
|
||||
break
|
||||
accent = mix(accent, BLACK, 0.06)
|
||||
base = {"--accent": accent, "--accent-lite": mix(accent, WHITE, 0.2)}
|
||||
return {
|
||||
"light": {"--sky-top": mix(accent, WHITE, 0.55), "--sky-mid": mix(accent, WHITE, 0.72),
|
||||
"--sky-bottom": mix(accent, WHITE, 0.9), **base, "--accent-dark": mix(accent, "#0b1218", 0.85)},
|
||||
"dark": {"--sky-top": mix(accent, BLACK, 0.85), "--sky-mid": mix(accent, BLACK, 0.82),
|
||||
"--sky-bottom": mix(accent, BLACK, 0.78), **base, "--accent-dark": mix(accent, BLACK, 0.4)},
|
||||
}
|
||||
|
||||
|
||||
def checks(t):
|
||||
rows = []
|
||||
for mode, m in t.items():
|
||||
rows += [(mode, "white text on the Trigger button", contrast(WHITE, m["--accent"]), 3),
|
||||
(mode, "ink on the top of the sky", contrast(INK[mode], m["--sky-top"]), 4.5),
|
||||
(mode, "ink on the bottom of the sky", contrast(INK[mode], m["--sky-bottom"]), 4.5)]
|
||||
rows.append(("light", "progress % on the sky", contrast(t["light"]["--accent-dark"], t["light"]["--sky-bottom"]), 4.5))
|
||||
return rows
|
||||
|
||||
|
||||
def block(t):
|
||||
def line(m):
|
||||
r = ",".join(str(v) for v in rgb(m["--accent"]))
|
||||
return "; ".join(f"{k}:{v}" for k, v in m.items()) + f"; --accent-rgb:{r};"
|
||||
return ("/* THEME:START (palette.py --write replaces everything down to THEME:END) */\n"
|
||||
f":root{{\n {line(t['light'])}\n}}\nbody.dark{{\n {line(t['dark'])}\n}}\n/* THEME:END */")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2 or not (sys.argv[1] in PRESETS or sys.argv[1].startswith("#")):
|
||||
sys.exit(__doc__)
|
||||
t = PRESETS.get(sys.argv[1]) or derive(sys.argv[1])
|
||||
css = block(t)
|
||||
print(css + "\n")
|
||||
rows = checks(t)
|
||||
for mode, what, ratio, need in rows:
|
||||
print(f"{mode:<6} {what:<34} {ratio:5.2f}:1 needs {need}:1 {'PASS' if ratio >= need else 'FAIL'}")
|
||||
if "--write" in sys.argv:
|
||||
page = sys.argv[sys.argv.index("--write") + 1]
|
||||
html = open(page, encoding="utf-8").read()
|
||||
new, n = re.subn(r"/\* THEME:START.*?THEME:END \*/", lambda _: css, html, flags=re.S)
|
||||
if n != 1:
|
||||
sys.exit(f"THEME:START/END markers not found in {page}")
|
||||
open(page, "w", encoding="utf-8").write(new)
|
||||
print(f"\nwrote the theme into {page}")
|
||||
sys.exit(0 if all(r >= n for *_, r, n in rows) else 1)
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Run tab protocol for the bot (ADR-0002). Copy this file next to the bot.
|
||||
|
||||
import rt
|
||||
rt.checkpoint(1) # checkpoint 1 starts (its time limit starts counting)
|
||||
rt.ui("Signed in", "ok") # a line the operator sees: kind "", "ok", "warn" or "error"
|
||||
rt.checkpoint_done(1)
|
||||
...
|
||||
return rt.result(total=40, failed=[("Row 7", "Account not found")])
|
||||
|
||||
The bot's entry function returns rt.result(...), and the Run tab prints the summary line.
|
||||
A bot that is a plain script (no entry function) calls rt.summary(...) as its last line.
|
||||
"""
|
||||
import json
|
||||
|
||||
SUMMARY = "__RUN_SUMMARY__"
|
||||
|
||||
|
||||
def checkpoint(n):
|
||||
print(f"[CPSTATE] active {n}", flush=True)
|
||||
|
||||
|
||||
def checkpoint_done(n):
|
||||
print(f"[CPSTATE] done {n}", flush=True)
|
||||
|
||||
|
||||
def ui(msg, kind=""):
|
||||
print(f"[UI{':' + kind if kind else ''}] {msg}", flush=True)
|
||||
|
||||
|
||||
def result(total, failed=()):
|
||||
failed = [[str(item), str(reason)] for item, reason in failed]
|
||||
return {"total": total, "ok": total - len(failed), "failed": failed}
|
||||
|
||||
|
||||
def summary(total, failed=()):
|
||||
print(SUMMARY + json.dumps(result(total, failed)), flush=True)
|
||||
@@ -0,0 +1,592 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<script src="qrc:///qtwebchannel/qwebchannel.js"></script>
|
||||
<style>
|
||||
/* THEME:START (palette.py --write replaces everything down to THEME:END) */
|
||||
:root{
|
||||
--sky-top:#8fc9e8; --sky-mid:#bfe3f2; --sky-bottom:#eaf6fb;
|
||||
--accent:#3d9bd0; --accent-lite:#5cb2e2; --accent-rgb:61,155,208; --accent-dark:#16202a;
|
||||
}
|
||||
body.dark{
|
||||
--sky-top:#0f1b26; --sky-mid:#13212e; --sky-bottom:#182a38;
|
||||
--accent:#3d9bd0; --accent-lite:#5cb2e2; --accent-rgb:61,155,208; --accent-dark:#2e5d80;
|
||||
}
|
||||
/* THEME:END */
|
||||
:root{
|
||||
--glass:rgba(255,255,255,0.60); --glass-strong:rgba(255,255,255,0.78);
|
||||
--border:rgba(255,255,255,0.9);
|
||||
--ink:#1c2b33; --muted:#5c7382;
|
||||
--ok:#1f9d57; --ok-bg:#e6f6ec; --warn:#b7791f; --warn-bg:#fbf1dd;
|
||||
--bad:#c0392b; --bad-bg:#fdecea;
|
||||
}
|
||||
body.dark{
|
||||
--glass:rgba(22,34,44,.72); --glass-strong:rgba(30,45,58,.85);
|
||||
--border:rgba(120,160,190,.22);
|
||||
--ink:#e6eef4; --muted:#8fa6b4;
|
||||
--ok:#2fbf72; --ok-bg:rgba(31,157,87,.16);
|
||||
--warn:#d9a13c; --warn-bg:rgba(183,121,31,.16);
|
||||
--bad:#e05a4b; --bad-bg:rgba(192,57,43,.16);
|
||||
}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
html,body{height:100%;font-family:'Segoe UI',-apple-system,BlinkMacSystemFont,sans-serif;
|
||||
color:var(--ink);overflow:hidden;
|
||||
background:linear-gradient(180deg,var(--sky-top) 0%,var(--sky-mid) 45%,var(--sky-bottom) 100%)}
|
||||
.hidden{display:none!important}
|
||||
input[type=file]{display:none}
|
||||
@keyframes blink{0%,100%{opacity:1}50%{opacity:.4}}
|
||||
|
||||
/* RUN-TAB:CSS:START */
|
||||
.main{height:100vh;display:flex;flex-direction:column;overflow:hidden}
|
||||
.topbar{background:linear-gradient(180deg,rgba(255,255,255,.82),rgba(255,255,255,.55));
|
||||
border-bottom:1px solid var(--border);backdrop-filter:blur(14px);
|
||||
padding:20px 28px;display:flex;align-items:center;gap:14px;flex-shrink:0;flex-wrap:wrap;
|
||||
box-shadow:0 6px 18px rgba(20,50,70,.10)}
|
||||
.tb-title{font-size:21px;font-weight:800;color:var(--ink)}
|
||||
.tb-sub{font-size:12.5px;color:var(--muted);margin-top:3px}
|
||||
.tb-right{margin-left:auto;display:flex;align-items:center;gap:12px}
|
||||
.top-run-btn{display:flex;align-items:center;gap:8px;padding:10px 20px;border-radius:11px;border:none;
|
||||
font-size:13px;font-weight:600;cursor:pointer;transition:all .18s ease;font-family:inherit}
|
||||
.top-run-btn svg{width:16px;height:16px;stroke:currentColor}
|
||||
.top-run-btn.off{background:rgba(255,255,255,.55);color:var(--muted);cursor:not-allowed}
|
||||
.top-run-btn.on{background:linear-gradient(180deg,var(--accent-lite),var(--accent));color:#fff;
|
||||
box-shadow:0 2px 6px rgba(var(--accent-rgb),.35)}
|
||||
.top-run-btn.on:hover{box-shadow:0 6px 16px rgba(var(--accent-rgb),.4);transform:translateY(-1px)}
|
||||
.top-run-btn.on:active{transform:scale(.97);box-shadow:0 1px 3px rgba(var(--accent-rgb),.3)}
|
||||
.top-run-btn.going{background:var(--warn-bg);color:var(--warn);cursor:not-allowed}
|
||||
.mode-btn{width:38px;height:38px;border-radius:11px;border:1px solid var(--border);
|
||||
background:var(--glass-strong);cursor:pointer;font-size:16px;line-height:1;
|
||||
display:flex;align-items:center;justify-content:center;transition:all .18s ease;
|
||||
box-shadow:0 1px 3px rgba(20,50,70,.12)}
|
||||
.mode-btn:hover{transform:translateY(-1px);box-shadow:0 4px 10px rgba(20,50,70,.2)}
|
||||
.mode-btn:active{transform:scale(.94)}
|
||||
.indicator{display:flex;align-items:center;gap:7px;background:var(--glass-strong);padding:7px 13px;border-radius:20px;border:1px solid var(--border)}
|
||||
.dot{width:8px;height:8px;border-radius:50%;transition:all .3s}
|
||||
.dot.ready,.dot.done{background:var(--ok);box-shadow:0 0 7px var(--ok)}
|
||||
.dot.running{background:var(--warn);box-shadow:0 0 7px var(--warn);animation:blink 1.2s infinite}
|
||||
.dot.partial{background:var(--warn);box-shadow:0 0 7px var(--warn)}
|
||||
.dot.error{background:var(--bad);box-shadow:0 0 7px var(--bad)}
|
||||
.ind-text{font-size:12px;font-weight:700;color:var(--ink)}
|
||||
|
||||
.content{flex:1;overflow-y:auto;padding:26px 28px;border-top-left-radius:30px;
|
||||
background:linear-gradient(180deg,rgba(255,255,255,.18),rgba(255,255,255,0));
|
||||
box-shadow:inset 2px 2px 0 rgba(255,255,255,.35)}
|
||||
.content::-webkit-scrollbar{width:8px}
|
||||
.content::-webkit-scrollbar-thumb{background:rgba(255,255,255,.8);border-radius:8px}
|
||||
.section-label{font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--muted);font-weight:700;margin-bottom:14px}
|
||||
.card{background:var(--glass);border:1px solid var(--border);border-radius:16px;padding:22px;margin-bottom:16px;
|
||||
backdrop-filter:blur(14px);box-shadow:0 10px 30px rgba(20,50,70,.08)}
|
||||
.card-head{display:flex;align-items:center;gap:12px;margin-bottom:18px}
|
||||
.c-icon{width:40px;height:40px;border-radius:11px;display:flex;align-items:center;justify-content:center;flex-shrink:0;
|
||||
background:var(--glass-strong)}
|
||||
.c-icon svg{width:20px;height:20px;stroke:var(--accent-dark)}
|
||||
.c-title{font-size:15px;font-weight:700;color:var(--ink)}
|
||||
.c-sub{font-size:12px;color:var(--muted);margin-top:2px}
|
||||
.btn{padding:9px 15px;border-radius:11px;border:1px solid rgba(var(--accent-rgb),.45);
|
||||
background:linear-gradient(180deg,rgba(var(--accent-rgb),.16),rgba(var(--accent-rgb),.32));
|
||||
color:var(--ink);font-size:12.5px;font-weight:600;cursor:pointer;font-family:inherit;
|
||||
transition:all .18s ease;box-shadow:0 1px 3px rgba(20,50,70,.14)}
|
||||
.btn:hover{background:linear-gradient(180deg,rgba(var(--accent-rgb),.24),rgba(var(--accent-rgb),.42));transform:translateY(-1px);
|
||||
box-shadow:0 4px 12px rgba(var(--accent-rgb),.28)}
|
||||
.btn:active{transform:translateY(0) scale(.97);box-shadow:0 1px 2px rgba(20,50,70,.12)}
|
||||
|
||||
.drop-zone{border:2px dashed rgba(var(--accent-rgb),.45);border-radius:14px;padding:36px 20px;text-align:center;cursor:pointer;transition:all .2s;background:rgba(255,255,255,.35)}
|
||||
.drop-zone:hover,.drop-zone.over{border-color:var(--accent-dark);background:rgba(255,255,255,.6)}
|
||||
.drop-icon{width:44px;height:44px;stroke:rgba(var(--accent-rgb),.8);margin:0 auto 12px;display:block}
|
||||
.drop-zone:hover .drop-icon,.drop-zone.over .drop-icon{stroke:var(--accent-dark)}
|
||||
.drop-title{font-size:15px;font-weight:700;color:var(--ink);margin-bottom:5px}
|
||||
.drop-sub{font-size:12.5px;color:var(--muted)}
|
||||
.browse-link{color:var(--accent-dark);cursor:pointer;font-weight:700;text-decoration:underline}
|
||||
.tags{display:flex;gap:7px;justify-content:center;margin-top:14px;flex-wrap:wrap}
|
||||
.tag{font-size:11px;color:var(--muted);background:rgba(255,255,255,.6);border:1px solid var(--border);padding:4px 11px;border-radius:6px;font-weight:600}
|
||||
|
||||
.file-row{display:flex;align-items:center;gap:14px;padding:15px 16px;background:var(--ok-bg);border-radius:12px;border:1px solid #bfe6cc;margin-top:14px}
|
||||
.f-details{flex:1;min-width:0}
|
||||
.f-name{font-size:13.5px;font-weight:700;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.f-path{font-size:11px;color:var(--muted);margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.f-badge{display:inline-flex;align-items:center;gap:5px;margin-top:5px;font-size:11px;color:var(--ok);font-weight:700}
|
||||
.f-badge::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--ok);display:inline-block}
|
||||
.f-remove{color:var(--muted);cursor:pointer;font-size:20px;padding:3px 9px;border-radius:8px;flex-shrink:0}
|
||||
.f-remove:hover{color:var(--bad);background:var(--bad-bg)}
|
||||
|
||||
.sheet-tabs{display:flex;gap:8px;margin-top:16px;flex-wrap:wrap}
|
||||
.sheet-btn{padding:9px 15px;border-radius:10px;border:1px solid var(--border);background:rgba(255,255,255,.5);color:var(--ink);font-size:12.5px;font-weight:600;cursor:pointer;transition:all .18s ease;box-shadow:0 1px 3px rgba(20,50,70,.08)}
|
||||
.sheet-btn:hover{background:rgba(255,255,255,.8);transform:translateY(-1px);box-shadow:0 4px 10px rgba(20,50,70,.15)}
|
||||
.sheet-btn:active{transform:scale(.97)}
|
||||
.sheet-btn.active{background:linear-gradient(180deg,var(--accent-lite),var(--accent));border-color:var(--accent);color:#fff}
|
||||
.sheet-preview{margin-top:14px;display:none}
|
||||
.sheet-preview.show{display:block}
|
||||
.preview-head{display:flex;align-items:center;gap:8px;margin-bottom:10px}
|
||||
.preview-title{font-size:13px;font-weight:700;color:var(--ink)}
|
||||
.preview-count{font-size:11px;color:var(--muted);font-weight:600}
|
||||
.tbl-wrap{border:1px solid var(--border);border-radius:12px;overflow:auto;max-height:280px;background:rgba(255,255,255,.5)}
|
||||
.tbl-wrap::-webkit-scrollbar{width:8px;height:8px}
|
||||
.tbl-wrap::-webkit-scrollbar-thumb{background:rgba(var(--accent-rgb),.3);border-radius:8px}
|
||||
table.data-tbl{width:100%;border-collapse:collapse;font-size:12px}
|
||||
table.data-tbl th{background:rgba(255,255,255,.85);color:var(--ink);font-weight:700;text-align:left;padding:9px 12px;position:sticky;top:0;border-bottom:1px solid var(--border);white-space:nowrap}
|
||||
table.data-tbl td{padding:8px 12px;border-bottom:1px solid rgba(255,255,255,.7);color:#2b3f4a;white-space:nowrap}
|
||||
table.data-tbl .mask{color:#9fb0b8;letter-spacing:2px}
|
||||
.preview-empty{padding:24px;text-align:center;color:var(--muted);font-size:12px}
|
||||
.clear-bar{display:none;align-items:center;gap:12px;margin-top:14px;padding-top:14px;border-top:1px solid rgba(255,255,255,.7)}
|
||||
.clear-bar.show{display:flex}
|
||||
.clear-btn{padding:9px 16px;border-radius:10px;border:1px solid #f0c6c0;background:var(--bad-bg);color:var(--bad);font-size:12.5px;font-weight:600;cursor:pointer;font-family:inherit;transition:all .18s ease;box-shadow:0 1px 3px rgba(192,57,43,.12)}
|
||||
.clear-btn:hover{background:var(--bad);color:#fff;transform:translateY(-1px);box-shadow:0 4px 10px rgba(192,57,43,.25)}
|
||||
.clear-btn:active{transform:scale(.97)}
|
||||
.clear-hint{font-size:11.5px;color:var(--muted)}
|
||||
|
||||
.prog-wrap{display:none;margin-bottom:6px}
|
||||
.prog-wrap.show{display:block}
|
||||
.prog-head{display:flex;align-items:center;gap:12px;margin-bottom:8px}
|
||||
.prog-lbl{font-size:13px;font-weight:700;color:var(--ink);margin-right:auto}
|
||||
.prog-time{font-size:12.5px;font-weight:700;color:var(--muted);
|
||||
background:rgba(var(--accent-rgb),.12);padding:2px 9px;border-radius:8px;white-space:nowrap}
|
||||
.prog-pct{font-size:13px;color:var(--accent-dark);font-weight:800}
|
||||
.prog-bg{height:8px;background:rgba(255,255,255,.6);border-radius:8px;overflow:hidden;margin-bottom:18px}
|
||||
.prog-fill{height:100%;background:linear-gradient(90deg,#28c26f,#1f9d57);border-radius:8px;width:0%;transition:width .5s ease}
|
||||
.prog-fill.partial{background:linear-gradient(90deg,#e0a53c,#b7791f)}
|
||||
.prog-fill.failed{background:linear-gradient(90deg,#e8695b,#c0392b)}
|
||||
.steps{display:flex;flex-direction:column;gap:6px;margin-bottom:16px}
|
||||
.step{display:flex;align-items:center;gap:12px;padding:10px 13px;border-radius:10px;border:1px solid transparent}
|
||||
.step.done{background:var(--ok-bg);border-color:#cdeed8}
|
||||
.step.active{background:var(--warn-bg);border-color:#f0dcae}
|
||||
.step.stuck{background:var(--bad-bg);border-color:#f0b6b0}
|
||||
.step.pending{opacity:.5}
|
||||
.step-dot{width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11px;flex-shrink:0;font-weight:700}
|
||||
.step.done .step-dot{background:var(--ok);color:#fff}
|
||||
.step.active .step-dot{background:var(--warn);color:#fff}
|
||||
.step.stuck .step-dot{background:var(--bad);color:#fff}
|
||||
.step.pending .step-dot{background:rgba(255,255,255,.7);color:var(--muted)}
|
||||
.step-lbl{font-size:13px;font-weight:600}
|
||||
.step.done .step-lbl{color:var(--ok)}
|
||||
.step.active .step-lbl{color:var(--warn)}
|
||||
.step.stuck .step-lbl{color:var(--bad)}
|
||||
.step.pending .step-lbl{color:var(--muted)}
|
||||
.log{background:var(--accent-dark);border-radius:12px;padding:12px 14px;max-height:200px;overflow-y:auto;
|
||||
font-family:'Consolas','Courier New',monospace;font-size:13.5px;line-height:1.85}
|
||||
.log::-webkit-scrollbar{width:6px}
|
||||
.log::-webkit-scrollbar-thumb{background:#3a4a55;border-radius:6px}
|
||||
.log-line{color:#9fb6c4;overflow-wrap:anywhere}
|
||||
.log-line.info{color:#7fc4f0}
|
||||
.log-line.success{color:#5fe0a0}
|
||||
.log-line.warn{color:#f5c563}
|
||||
.log-line.error{color:#f68c82}
|
||||
|
||||
.cp-wrap{margin-top:14px}
|
||||
#cp-elapsed{margin-left:auto;font-weight:700;color:var(--accent-dark)}
|
||||
.cp-title{font-size:12px;font-weight:800;letter-spacing:.4px;text-transform:uppercase;
|
||||
color:var(--muted);margin-bottom:10px;display:flex;align-items:center;gap:8px}
|
||||
.cp-track{display:grid;grid-template-columns:repeat(auto-fit,minmax(110px,1fr));gap:8px}
|
||||
.cp-box{position:relative;border-radius:11px;padding:11px 10px 10px;
|
||||
border:1.5px solid rgba(var(--accent-rgb),.20);background:rgba(var(--accent-rgb),.06);
|
||||
display:flex;flex-direction:column;gap:5px;min-height:74px;transition:all .25s}
|
||||
.cp-box .cp-num{width:20px;height:20px;border-radius:50%;display:flex;align-items:center;
|
||||
justify-content:center;font-size:11px;font-weight:800;flex-shrink:0;
|
||||
background:rgba(255,255,255,.6);color:var(--muted)}
|
||||
.cp-box .cp-name{font-size:12px;font-weight:700;color:var(--ink);line-height:1.25}
|
||||
.cp-box .cp-meta{font-size:10.5px;font-weight:700;color:var(--muted);margin-top:auto}
|
||||
.cp-box.pending{opacity:.55}
|
||||
.cp-box.active{border-color:var(--warn);background:var(--warn-bg);box-shadow:0 0 0 3px rgba(245,197,99,.14)}
|
||||
.cp-box.active .cp-num{background:var(--warn);color:#fff;animation:cp-pulse 1.2s ease-in-out infinite}
|
||||
.cp-box.active .cp-meta{color:var(--warn)}
|
||||
.cp-box.done{border-color:#cdeed8;background:var(--ok-bg)}
|
||||
.cp-box.done .cp-num{background:var(--ok);color:#fff}
|
||||
.cp-box.done .cp-meta{color:var(--ok)}
|
||||
.cp-box.stuck{border-color:#f0b6b0;background:var(--bad-bg)}
|
||||
.cp-box.stuck .cp-num{background:var(--bad);color:#fff}
|
||||
.cp-box.stuck .cp-meta{color:var(--bad)}
|
||||
@keyframes cp-pulse{0%,100%{box-shadow:0 0 0 0 rgba(245,197,99,.5)}50%{box-shadow:0 0 0 6px rgba(245,197,99,0)}}
|
||||
|
||||
body.dark .topbar{background:linear-gradient(180deg,rgba(30,45,58,.92),rgba(22,34,44,.7));box-shadow:0 6px 18px rgba(0,0,0,.28)}
|
||||
body.dark .content{background:linear-gradient(180deg,rgba(255,255,255,.05),rgba(255,255,255,0));box-shadow:inset 2px 2px 0 rgba(255,255,255,.08)}
|
||||
body.dark .content::-webkit-scrollbar-thumb{background:rgba(255,255,255,.18)}
|
||||
body.dark .btn{background:linear-gradient(180deg,rgba(var(--accent-rgb),.22),rgba(var(--accent-rgb),.12));color:var(--ink)}
|
||||
body.dark .drop-zone{background:rgba(255,255,255,.04);border-color:rgba(var(--accent-rgb),.35)}
|
||||
body.dark .drop-zone:hover,body.dark .drop-zone.over{background:rgba(255,255,255,.08);border-color:var(--accent-lite)}
|
||||
body.dark .tag{background:rgba(255,255,255,.07)}
|
||||
body.dark .top-run-btn.off{background:rgba(255,255,255,.08)}
|
||||
body.dark .file-row{background:var(--ok-bg);border-color:rgba(47,191,114,.35)}
|
||||
body.dark .sheet-btn{background:rgba(255,255,255,.07)}
|
||||
body.dark .sheet-btn:hover{background:rgba(255,255,255,.14)}
|
||||
body.dark .tbl-wrap{background:rgba(255,255,255,.03)}
|
||||
body.dark table.data-tbl th{background:#1d2f3c;color:var(--ink)}
|
||||
body.dark table.data-tbl td{color:#c9d8e2;border-bottom:1px solid rgba(255,255,255,.06)}
|
||||
body.dark .clear-bar{border-top-color:rgba(120,160,190,.15)}
|
||||
body.dark .clear-btn{border-color:rgba(224,90,75,.4)}
|
||||
body.dark .prog-bg{background:rgba(255,255,255,.12)}
|
||||
body.dark .step.pending .step-dot{background:rgba(255,255,255,.12)}
|
||||
body.dark .step.done{border-color:rgba(47,191,114,.3)}
|
||||
body.dark .step.active{border-color:rgba(217,161,60,.3)}
|
||||
body.dark .step.stuck{border-color:rgba(224,90,75,.35)}
|
||||
body.dark .log{background:#0d161e}
|
||||
body.dark .prog-pct,body.dark .browse-link,body.dark #cp-elapsed{color:var(--accent-lite)}
|
||||
body.dark .c-icon svg{stroke:var(--accent-lite)}
|
||||
body.dark .cp-box .cp-name{color:#cfe0ec}
|
||||
body.dark .cp-box.pending .cp-num{background:rgba(255,255,255,.12)}
|
||||
body.dark .cp-box.done{border-color:rgba(47,191,114,.3)}
|
||||
body.dark .cp-box.stuck{border-color:rgba(224,90,75,.35)}
|
||||
@media (max-width:820px){
|
||||
.topbar{padding:16px}
|
||||
.content{padding:18px 16px}
|
||||
}
|
||||
/* RUN-TAB:CSS:END */
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="main">
|
||||
<!-- RUN-TAB:TOPBAR:START -->
|
||||
<div class="topbar">
|
||||
<div>
|
||||
<div class="tb-title" id="tb-title">Run automation</div>
|
||||
<div class="tb-sub" id="tb-sub"></div>
|
||||
</div>
|
||||
<div class="tb-right">
|
||||
<button class="top-run-btn off" id="top-run-btn" onclick="doRun()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
<span id="top-run-lbl">Upload a file to continue</span>
|
||||
</button>
|
||||
<button class="mode-btn" id="mode-btn" onclick="toggleDark()" title="Toggle dark mode">🌙</button>
|
||||
<div class="indicator">
|
||||
<div class="dot ready" id="sys-dot"></div>
|
||||
<div class="ind-text" id="sys-txt">System ready</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- RUN-TAB:TOPBAR:END -->
|
||||
|
||||
<div class="content">
|
||||
<!-- RUN-TAB:MARKUP:START -->
|
||||
<div id="tab-run">
|
||||
<div id="input-card">
|
||||
<div class="section-label">Input configuration</div>
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<div class="c-icon"><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg></div>
|
||||
<div>
|
||||
<div class="c-title">Input Excel upload</div>
|
||||
<div class="c-sub" id="input-hint"></div>
|
||||
</div>
|
||||
<button class="btn" id="sample-btn" style="margin-left:auto;flex-shrink:0"
|
||||
onclick="pyBridge&&pyBridge.download_sample_input()"
|
||||
title="Save an example workbook with the columns this bot reads">⬇ Sample input</button>
|
||||
</div>
|
||||
|
||||
<div class="drop-zone" id="dz" ondragover="dzOver(event)" ondragleave="dzLeave()" ondrop="dzDrop(event)"
|
||||
onclick="pyBridge&&pyBridge.browse_file()">
|
||||
<svg class="drop-icon" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
||||
<div class="drop-title">Drop your Excel file here</div>
|
||||
<div class="drop-sub">or <span class="browse-link" onclick="event.stopPropagation();pyBridge&&pyBridge.browse_file()">browse files</span></div>
|
||||
<div class="tags"><span class="tag">.xlsx</span><span class="tag">.xlsm</span><span class="tag">.xls</span></div>
|
||||
</div>
|
||||
<input type="file" id="fi" accept=".xlsx,.xlsm,.xls" onchange="fileChosen(this.files[0])">
|
||||
|
||||
<div class="file-row hidden" id="file-row">
|
||||
<div class="c-icon"><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg></div>
|
||||
<div class="f-details">
|
||||
<div class="f-name" id="f-name">—</div>
|
||||
<div class="f-path" id="f-path">—</div>
|
||||
<div class="f-badge">Ready to process</div>
|
||||
</div>
|
||||
<div class="f-remove" onclick="removeFile()" title="Remove">×</div>
|
||||
</div>
|
||||
|
||||
<div class="sheet-tabs hidden" id="sheet-tabs"></div>
|
||||
<div class="sheet-preview" id="sheet-preview">
|
||||
<div class="preview-head">
|
||||
<div class="preview-title" id="preview-title">Sheet</div>
|
||||
<div class="preview-count" id="preview-count"></div>
|
||||
</div>
|
||||
<div class="tbl-wrap" id="tbl-wrap"></div>
|
||||
</div>
|
||||
|
||||
<div class="clear-bar" id="clear-bar">
|
||||
<button class="clear-btn" onclick="removeFile()">Clear file</button>
|
||||
<span class="clear-hint">Uploaded the wrong file? Clear and choose again.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<div class="c-icon"><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg></div>
|
||||
<div>
|
||||
<div class="c-title">Run automation</div>
|
||||
<div class="c-sub" id="run-flow"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="prog-wrap" id="prog-wrap">
|
||||
<div class="prog-head">
|
||||
<span class="prog-lbl" id="prog-lbl">Starting...</span>
|
||||
<span class="prog-time" id="prog-time"></span>
|
||||
<span class="prog-pct" id="prog-pct">0%</span>
|
||||
</div>
|
||||
<div class="prog-bg"><div class="prog-fill" id="prog-fill"></div></div>
|
||||
<div class="steps" id="steps"></div>
|
||||
<div class="log" id="log"></div>
|
||||
|
||||
<div class="cp-wrap">
|
||||
<div class="cp-title">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||
Progress checkpoints
|
||||
<span id="cp-elapsed"></span>
|
||||
</div>
|
||||
<div class="cp-track" id="cp-track"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- RUN-TAB:MARKUP:END -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/* Filled by run_tab.py from its config: {title, sub, input, inputHint, sample, darkKey,
|
||||
checkpoints:[{name, max}]}. max = seconds before the checkpoint counts as stuck, 0 = no limit. */
|
||||
const CFG = %%CONFIG%%;
|
||||
let pyBridge=null;
|
||||
|
||||
/* RUN-TAB:JS:START */
|
||||
let fileReady=false, filePath='', running=false;
|
||||
const CPS=CFG.checkpoints;
|
||||
|
||||
function escapeHtml(s){ const d=document.createElement('div'); d.textContent=(s==null?'':String(s)); return d.innerHTML; }
|
||||
|
||||
/* Python -> page. Messages arrive ONLY through window.__pyMsg (run_tab.py calls it).
|
||||
Never also connect js_message in QWebChannel: Tebra did both and every line showed twice. */
|
||||
window.__pyMsg = function(s){ const m=JSON.parse(s); handlePyMessage(m.event,m.data); };
|
||||
function handlePyMessage(event,data){
|
||||
if(event==='file_selected') setFile(data.name,data.path,data.sheets);
|
||||
else if(event==='sheet_data') renderSheetData(data);
|
||||
else if(event==='log') addLog(data.msg,data.type);
|
||||
else if(event==='cpstate') cpState(data.kind, data.cp);
|
||||
else if(event==='status') onStatus(data.state, data.msg);
|
||||
else if(event==='apply_dark') applyDark(data.on, true);
|
||||
}
|
||||
|
||||
function initRunTab(){
|
||||
document.getElementById('tb-title').textContent=CFG.title;
|
||||
document.getElementById('tb-sub').textContent=CFG.sub;
|
||||
document.getElementById('run-flow').textContent=CPS.map(c=>c.name).join(' → ');
|
||||
document.getElementById('input-hint').textContent=CFG.inputHint||'';
|
||||
document.getElementById('input-card').classList.toggle('hidden', !CFG.input);
|
||||
document.getElementById('sample-btn').classList.toggle('hidden', !CFG.sample);
|
||||
fileReady=!CFG.input;
|
||||
renderCpTrack(); renderSteps(); updateRunBtn();
|
||||
}
|
||||
|
||||
/* ---- dark mode: saved in localStorage and, through the bridge, in QSettings ---- */
|
||||
function applyDark(on, fromPy){
|
||||
document.body.classList.toggle('dark', !!on);
|
||||
const b=document.getElementById('mode-btn'); if(b) b.textContent=on?'☀️':'🌙';
|
||||
try{ localStorage.setItem(CFG.darkKey, on?'1':'0'); }catch(e){}
|
||||
if(pyBridge && !fromPy) pyBridge.set_dark(!!on);
|
||||
}
|
||||
function toggleDark(){ applyDark(!document.body.classList.contains('dark')); }
|
||||
|
||||
/* ---- input file ---- */
|
||||
function dzOver(e){e.preventDefault();document.getElementById('dz').classList.add('over');}
|
||||
function dzLeave(){document.getElementById('dz').classList.remove('over');}
|
||||
function dzDrop(e){e.preventDefault();dzLeave();const f=e.dataTransfer.files[0];if(f)fileChosen(f);}
|
||||
function fileChosen(f){
|
||||
if(!f||running)return;
|
||||
const ext='.'+f.name.split('.').pop().toLowerCase();
|
||||
if(!['.xlsx','.xlsm','.xls'].includes(ext)){addLog('Invalid file type — use .xlsx .xlsm .xls','warn');return;}
|
||||
// QWebEngine hides the real path of a dropped file, so Python resolves the name.
|
||||
if(pyBridge) pyBridge.process_dropped_path(f.path || f.name);
|
||||
else setFile(f.name, f.path || f.name, []);
|
||||
}
|
||||
function setFile(name,path,sheets){
|
||||
if(!path){ addLog('Could not find "'+name+'" on disk — use browse files instead.','warn'); return; }
|
||||
fileReady=true; filePath=path;
|
||||
document.getElementById('dz').style.display='none';
|
||||
document.getElementById('file-row').classList.remove('hidden');
|
||||
document.getElementById('f-name').textContent=name;
|
||||
document.getElementById('f-path').textContent=path;
|
||||
document.getElementById('clear-bar').classList.add('show');
|
||||
updateRunBtn();
|
||||
renderSheetButtons(sheets||[]);
|
||||
}
|
||||
function renderSheetButtons(sheets){
|
||||
const tabs=document.getElementById('sheet-tabs');
|
||||
document.getElementById('sheet-preview').classList.remove('show');
|
||||
if(!sheets.length){tabs.classList.add('hidden');tabs.innerHTML='';return;}
|
||||
tabs.classList.remove('hidden');
|
||||
tabs.innerHTML=sheets.map((s,i)=>`<div class="sheet-btn" id="sb-${i}" data-sheet="${escapeHtml(s)}" onclick="openSheet(this.dataset.sheet,${i})">${escapeHtml(s)}</div>`).join('');
|
||||
openSheet(sheets[0], 0); // show the first sheet right after upload
|
||||
}
|
||||
function openSheet(name,idx){
|
||||
document.querySelectorAll('.sheet-btn').forEach(b=>b.classList.remove('active'));
|
||||
const b=document.getElementById('sb-'+idx); if(b)b.classList.add('active');
|
||||
document.getElementById('tbl-wrap').innerHTML='<div class="preview-empty">Loading...</div>';
|
||||
document.getElementById('sheet-preview').classList.add('show');
|
||||
document.getElementById('preview-title').textContent=name;
|
||||
document.getElementById('preview-count').textContent='';
|
||||
if(pyBridge)pyBridge.read_sheet(filePath,name);
|
||||
}
|
||||
function renderSheetData(data){
|
||||
const wrap=document.getElementById('tbl-wrap');
|
||||
if(data.error){wrap.innerHTML='<div class="preview-empty">Error: '+escapeHtml(data.error)+'</div>';return;}
|
||||
document.getElementById('preview-count').textContent=data.total+' row(s)'+(data.more?' — first '+data.rows.length+' shown':'');
|
||||
if(!data.header.length){wrap.innerHTML='<div class="preview-empty">Sheet is empty</div>';return;}
|
||||
const secret=data.header.map(x=>/pass(word)?|pwd|secret|pin\b/i.test(String(x)));
|
||||
let h='<table class="data-tbl"><thead><tr>';
|
||||
data.header.forEach(x=>h+='<th>'+escapeHtml(x)+'</th>');
|
||||
h+='</tr></thead><tbody>';
|
||||
data.rows.forEach(row=>{
|
||||
h+='<tr>';
|
||||
row.forEach((cell,ci)=>{
|
||||
if(secret[ci] && String(cell)) h+='<td class="mask">'+'•'.repeat(Math.min(String(cell).length,10))+'</td>';
|
||||
else h+='<td>'+escapeHtml(cell)+'</td>';
|
||||
});
|
||||
h+='</tr>';
|
||||
});
|
||||
wrap.innerHTML=h+'</tbody></table>';
|
||||
}
|
||||
function removeFile(){
|
||||
if(running) return;
|
||||
fileReady=!CFG.input; filePath='';
|
||||
document.getElementById('dz').style.display='';
|
||||
document.getElementById('file-row').classList.add('hidden');
|
||||
document.getElementById('fi').value='';
|
||||
document.getElementById('sheet-tabs').classList.add('hidden');
|
||||
document.getElementById('sheet-tabs').innerHTML='';
|
||||
document.getElementById('sheet-preview').classList.remove('show');
|
||||
document.getElementById('clear-bar').classList.remove('show');
|
||||
document.getElementById('tbl-wrap').innerHTML='';
|
||||
updateRunBtn();
|
||||
}
|
||||
|
||||
/* ---- run ---- */
|
||||
function updateRunBtn(){
|
||||
const btn=document.getElementById('top-run-btn'),lbl=document.getElementById('top-run-lbl');
|
||||
if(running){btn.className='top-run-btn going';lbl.textContent='Running...';}
|
||||
else if(fileReady){btn.className='top-run-btn on';lbl.textContent='Trigger automation';}
|
||||
else{btn.className='top-run-btn off';lbl.textContent='Upload a file to continue';}
|
||||
}
|
||||
function doRun(){
|
||||
if(!fileReady||running)return;
|
||||
running=true; maxPct=0;
|
||||
document.getElementById('prog-wrap').classList.add('show');
|
||||
document.getElementById('log').innerHTML='';
|
||||
document.getElementById('prog-fill').className='prog-fill';
|
||||
setProgress(0,'Starting...');
|
||||
resetCheckpoints(); startRunTimer(); updateRunBtn();
|
||||
updateStatus('running','Running automation...');
|
||||
if(pyBridge)pyBridge.start_automation(filePath);
|
||||
else addLog('No Python bridge — page preview only','warn');
|
||||
}
|
||||
|
||||
/* ---- elapsed timers ---- */
|
||||
let runTimer=null, runStart=0;
|
||||
function fmtElapsed(sec){ if(sec<60) return sec+'s'; return Math.floor(sec/60)+'m '+(sec%60)+'s ('+sec+'s)'; }
|
||||
function fmtClock(sec){ return String(Math.floor(sec/60)).padStart(2,'0')+':'+String(sec%60).padStart(2,'0'); }
|
||||
function tickRunTimer(){
|
||||
const secs=Math.round((Date.now()-runStart)/1000);
|
||||
document.getElementById('prog-time').textContent='⏱ '+fmtElapsed(secs);
|
||||
document.getElementById('cp-elapsed').textContent='⏱ '+fmtClock(secs);
|
||||
tickCpTimer();
|
||||
}
|
||||
function startRunTimer(){ runStart=Date.now(); if(runTimer) clearInterval(runTimer); tickRunTimer(); runTimer=setInterval(tickRunTimer,1000); }
|
||||
function stopRunTimer(prefix){
|
||||
if(runTimer){ clearInterval(runTimer); runTimer=null; }
|
||||
if(runStart) document.getElementById('prog-time').textContent=prefix+fmtElapsed(Math.round((Date.now()-runStart)/1000));
|
||||
}
|
||||
|
||||
/* ---- checkpoints: one list drives the boxes, the step list and the progress % (ADR-0001) ---- */
|
||||
let cpActive=0, cpActiveStart=0, maxPct=0;
|
||||
const cpStates=()=>CPS.map((_,i)=>{ const b=cpBox(i+1); return b?b.className.replace('cp-box ',''):'pending'; });
|
||||
function fmtMax(sec){ if(!sec) return 'no limit'; return 'max '+(sec%60===0 ? (sec/60)+' min' : sec+'s'); }
|
||||
function renderCpTrack(){
|
||||
document.getElementById('cp-track').innerHTML=CPS.map((d,i)=>
|
||||
'<div class="cp-box pending" id="cpbox-'+(i+1)+'">'+
|
||||
'<div class="cp-num">'+(i+1)+'</div>'+
|
||||
'<div class="cp-name">'+escapeHtml(d.name)+'</div>'+
|
||||
'<div class="cp-meta" id="cpmeta-'+(i+1)+'">'+fmtMax(d.max)+'</div>'+
|
||||
'</div>').join('');
|
||||
}
|
||||
function resetCheckpoints(){ cpActive=0; cpActiveStart=0; renderCpTrack(); renderSteps(); document.getElementById('cp-elapsed').textContent=''; }
|
||||
function cpBox(n){ return document.getElementById('cpbox-'+n); }
|
||||
function setCpMeta(n,txt){ const m=document.getElementById('cpmeta-'+n); if(m) m.textContent=txt; }
|
||||
function cpState(kind,n){
|
||||
if(!n||n>CPS.length) return;
|
||||
if(kind==='active'){
|
||||
// below n = done, n = active, above n = pending (a loop back to an earlier checkpoint resets the later ones)
|
||||
CPS.forEach((d,i)=>{
|
||||
const k=i+1, b=cpBox(k);
|
||||
if(k<n){ b.className='cp-box done'; setCpMeta(k,'✓ done'); }
|
||||
else if(k===n){ b.className='cp-box active'; }
|
||||
else { b.className='cp-box pending'; setCpMeta(k,fmtMax(d.max)); }
|
||||
});
|
||||
cpActive=n; cpActiveStart=Date.now(); tickCpTimer();
|
||||
setProgress(Math.round(100*(n-1)/CPS.length), CPS[n-1].name+'...');
|
||||
} else if(kind==='done'){
|
||||
cpBox(n).className='cp-box done'; setCpMeta(n,'✓ done');
|
||||
if(cpActive===n) cpActive=0;
|
||||
setProgress(Math.round(100*n/CPS.length), CPS[n-1].name+' done');
|
||||
} else if(kind==='stuck'){
|
||||
cpBox(n).className='cp-box stuck'; setCpMeta(n,'⚠ stuck — stopped'); cpActive=0;
|
||||
}
|
||||
renderSteps();
|
||||
}
|
||||
function tickCpTimer(){
|
||||
if(!cpActive||!cpActiveStart) return;
|
||||
const secs=Math.round((Date.now()-cpActiveStart)/1000), def=CPS[cpActive-1];
|
||||
setCpMeta(cpActive, def.max ? fmtClock(secs)+' / '+fmtClock(def.max) : fmtClock(secs));
|
||||
}
|
||||
function renderSteps(){
|
||||
const st=cpStates();
|
||||
document.getElementById('steps').innerHTML=CPS.map((d,i)=>{
|
||||
const s=st[i], dot=s==='done'?'✓':s==='stuck'?'!':(i+1);
|
||||
return `<div class="step ${s}"><div class="step-dot">${dot}</div><div class="step-lbl">${escapeHtml(d.name)}</div></div>`;
|
||||
}).join('');
|
||||
}
|
||||
function setProgress(pct,label){
|
||||
maxPct=Math.max(maxPct,pct); // the bar only moves forward, even when a loop restarts a checkpoint
|
||||
document.getElementById('prog-fill').style.width=maxPct+'%';
|
||||
document.getElementById('prog-pct').textContent=maxPct+'%';
|
||||
if(label)document.getElementById('prog-lbl').textContent=label;
|
||||
}
|
||||
|
||||
/* ---- log: no timestamps on screen, by design; the raw log file has everything ---- */
|
||||
function addLog(msg,type){
|
||||
const box=document.getElementById('log'), last=box.lastElementChild;
|
||||
if(last && last.textContent===msg) return; // drop consecutive duplicates
|
||||
const d=document.createElement('div');
|
||||
d.className='log-line '+(type||'');
|
||||
d.textContent=msg;
|
||||
box.appendChild(d); box.scrollTop=box.scrollHeight;
|
||||
}
|
||||
function updateStatus(state,msg){
|
||||
document.getElementById('sys-dot').className='dot '+state;
|
||||
document.getElementById('sys-txt').textContent=msg;
|
||||
}
|
||||
|
||||
/* ---- endings: green completed / amber partial / red failed (ADR-0003, R11) ---- */
|
||||
function onStatus(state,msg){
|
||||
if(state==='running'){ updateStatus('running',msg||'Running automation...'); return; }
|
||||
running=false; cpActive=0;
|
||||
const fill=document.getElementById('prog-fill');
|
||||
if(state==='done'){
|
||||
CPS.forEach((_,i)=>{ cpBox(i+1).className='cp-box done'; setCpMeta(i+1,'✓ done'); });
|
||||
renderSteps(); maxPct=0; setProgress(100,'Completed');
|
||||
stopRunTimer('✓ Completed in '); updateStatus('done',msg||'Completed');
|
||||
} else if(state==='partial'){
|
||||
fill.className='prog-fill partial'; maxPct=0; setProgress(100,msg);
|
||||
stopRunTimer('⚠ Finished in '); updateStatus('partial',msg);
|
||||
} else {
|
||||
fill.className='prog-fill failed'; setProgress(maxPct,msg);
|
||||
stopRunTimer('✕ Failed after '); updateStatus('error',msg); addLog(msg,'error');
|
||||
}
|
||||
updateRunBtn();
|
||||
}
|
||||
|
||||
if(window.qt && window.QWebChannel){
|
||||
new QWebChannel(qt.webChannelTransport, function(channel){ pyBridge = channel.objects.pyBridge; });
|
||||
}
|
||||
initRunTab();
|
||||
(function(){ let on=false; try{ on=localStorage.getItem(CFG.darkKey)==='1'; }catch(e){} if(on) applyDark(true, true); })();
|
||||
/* RUN-TAB:JS:END */
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,345 @@
|
||||
"""Run tab: PyQt6 window + bridge for the page in run_tab.html.
|
||||
|
||||
python run_tab/run_tab.py open the Run tab
|
||||
python run_tab/run_tab.py --selfcheck run the fake bot through every ending, save screenshots
|
||||
|
||||
The same file is also the bot's process: the window starts `run_tab.py --run-bot ...`
|
||||
(or `<app>.exe --run-bot ...` when frozen). That branch loads no Qt (ADR-0002).
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
# ---- CONFIG: the rpa-run-tab skill fills these in ------------------------------------
|
||||
TITLE = "Run automation"
|
||||
SUBTITLE = "Upload the input Excel and start the bot"
|
||||
BOT = "bot.py" # "path/to/bot.py" (from the project root) or "package.module:function"
|
||||
HAS_INPUT = True # False hides the input card; Trigger is ready straight away
|
||||
INPUT_HINT = "Excel workbook with the input sheets"
|
||||
CHECKPOINTS = [ # (name, seconds before it counts as stuck; 0 = no limit)
|
||||
("Sign in", 300),
|
||||
("Process items", 0),
|
||||
("Finish up", 300),
|
||||
]
|
||||
SAMPLE_SHEETS = {} # {"Sheet name": ["Column", ...]}; empty hides the Sample input button
|
||||
KILL_ALSO = [] # extra .exe names to kill with the bot, e.g. ["chromedriver.exe"]
|
||||
APP_ID = "RunTab" # QSettings name and the page's dark-mode key
|
||||
# ---------------------------------------------------------------------------------------
|
||||
|
||||
FROZEN = getattr(sys, "frozen", False)
|
||||
HERE = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent))
|
||||
ROOT = Path(sys.executable).parent if FROZEN else Path(__file__).resolve().parent.parent
|
||||
LOGS = ROOT / "logs"
|
||||
|
||||
|
||||
def _arg(flag, default=""):
|
||||
return sys.argv[sys.argv.index(flag) + 1] if flag in sys.argv else default
|
||||
|
||||
|
||||
def _watch_parent(ppid):
|
||||
"""Exit the bot the moment the window's process dies, so it never runs orphaned."""
|
||||
try:
|
||||
if sys.platform.startswith("win"):
|
||||
import ctypes
|
||||
k32 = ctypes.windll.kernel32
|
||||
h = k32.OpenProcess(0x00100000, False, ppid) # SYNCHRONIZE
|
||||
if h:
|
||||
k32.WaitForSingleObject(h, 0xFFFFFFFF)
|
||||
else:
|
||||
import time
|
||||
while os.getppid() == ppid:
|
||||
time.sleep(1)
|
||||
except Exception:
|
||||
return # can't watch: keep running
|
||||
os._exit(1)
|
||||
|
||||
|
||||
def run_bot_child():
|
||||
"""The bot's process: all output goes to --log, the bot runs, its result becomes the summary line."""
|
||||
log = open(_arg("--log"), "a", encoding="utf-8", buffering=1)
|
||||
sys.stdout = sys.stderr = log
|
||||
threading.Thread(target=_watch_parent, args=(int(_arg("--parent-pid", "0")),), daemon=True).start()
|
||||
excel, bot = _arg("--excel"), _arg("--bot", BOT)
|
||||
sys.path.insert(0, str(ROOT))
|
||||
os.chdir(ROOT)
|
||||
if bot.endswith(".py"):
|
||||
import runpy
|
||||
sys.argv = [bot] + ([excel] if excel else [])
|
||||
sys.path.insert(0, str((ROOT / bot).parent))
|
||||
runpy.run_path(str(ROOT / bot), run_name="__main__") # a plain script prints its own summary
|
||||
else:
|
||||
import importlib
|
||||
mod, func = bot.split(":")
|
||||
sys.path.insert(0, str(HERE))
|
||||
result = getattr(importlib.import_module(mod), func)(*([excel] if excel else []))
|
||||
if isinstance(result, dict):
|
||||
print("__RUN_SUMMARY__" + json.dumps(result))
|
||||
log.flush()
|
||||
os._exit(0)
|
||||
|
||||
|
||||
if "--run-bot" in sys.argv:
|
||||
run_bot_child()
|
||||
|
||||
# ---- everything below runs only in the window's process --------------------------------
|
||||
sys.path.insert(0, str(HERE))
|
||||
from PyQt6.QtCore import QObject, QSettings, Qt, QTimer, QUrl, pyqtSignal, pyqtSlot # noqa: E402
|
||||
from PyQt6.QtWebChannel import QWebChannel # noqa: E402
|
||||
from PyQt6.QtWebEngineWidgets import QWebEngineView # noqa: E402
|
||||
from PyQt6.QtWidgets import QApplication, QFileDialog, QMainWindow, QMessageBox # noqa: E402
|
||||
|
||||
import runner # noqa: E402
|
||||
|
||||
SEARCH_DIRS = [ROOT, ROOT / "input", Path.home() / "Downloads", Path.home() / "Desktop", Path.home() / "Documents"]
|
||||
|
||||
|
||||
def page_html():
|
||||
cfg = {"title": TITLE, "sub": SUBTITLE, "input": HAS_INPUT, "inputHint": INPUT_HINT,
|
||||
"sample": bool(SAMPLE_SHEETS), "darkKey": APP_ID + "_dark",
|
||||
"checkpoints": [{"name": n, "max": m} for n, m in CHECKPOINTS]}
|
||||
return (HERE / "run_tab.html").read_text(encoding="utf-8").replace("%%CONFIG%%", json.dumps(cfg))
|
||||
|
||||
|
||||
def bot_cmd(excel, log, bot=BOT):
|
||||
me = [sys.executable] if FROZEN else [sys.executable, str(Path(__file__).resolve())]
|
||||
return me + ["--run-bot", "--bot", bot, "--excel", excel or "", "--log", str(log),
|
||||
"--parent-pid", str(os.getpid())]
|
||||
|
||||
|
||||
def build_sample(path, sheets):
|
||||
"""Headers-only sample workbook with one fake row. Never real data (R13)."""
|
||||
from openpyxl import Workbook
|
||||
wb = Workbook()
|
||||
wb.remove(wb.active)
|
||||
for name, cols in sheets.items():
|
||||
ws = wb.create_sheet(name[:31])
|
||||
ws.append(cols)
|
||||
ws.append([f"Example {c}".strip() for c in cols])
|
||||
wb.save(path)
|
||||
|
||||
|
||||
def read_sheet_rows(path, sheet, cap=200):
|
||||
"""(header, rows, more): first `cap` non-empty rows as text, Excel date serials as m/d/yyyy."""
|
||||
from datetime import date, timedelta
|
||||
from openpyxl import load_workbook
|
||||
|
||||
def cell(c):
|
||||
if c is None:
|
||||
return ""
|
||||
if isinstance(c, datetime):
|
||||
c = c.date()
|
||||
if isinstance(c, date):
|
||||
return f"{c.month}/{c.day}/{c.year}"
|
||||
if isinstance(c, (int, float)) and not isinstance(c, bool) and 36526 <= c <= 73050: # 2000..2099
|
||||
d = date(1899, 12, 30) + timedelta(days=int(c))
|
||||
return f"{d.month}/{d.day}/{d.year}"
|
||||
return str(c)
|
||||
|
||||
wb = load_workbook(path, read_only=True, data_only=True)
|
||||
try:
|
||||
ws = wb[sheet] if sheet in wb.sheetnames else wb.active
|
||||
rows, more = [], False
|
||||
for r in ws.iter_rows(values_only=True):
|
||||
cells = [cell(c) for c in r]
|
||||
if not any(x.strip() for x in cells):
|
||||
continue
|
||||
if len(rows) >= cap:
|
||||
more = True
|
||||
break
|
||||
rows.append(cells)
|
||||
finally:
|
||||
wb.close()
|
||||
return (rows[0] if rows else []), rows[1:], more
|
||||
|
||||
|
||||
class Bridge(QObject):
|
||||
"""Exposed to the page as pyBridge."""
|
||||
js_message = pyqtSignal(str)
|
||||
|
||||
def __init__(self, window, bot=BOT, checkpoints=CHECKPOINTS):
|
||||
super().__init__()
|
||||
self.window, self.bot, self.checkpoints = window, bot, checkpoints
|
||||
self.run = None
|
||||
|
||||
@property
|
||||
def running(self):
|
||||
return self.run is not None and self.run.thread.is_alive()
|
||||
|
||||
def send(self, event, data):
|
||||
self.js_message.emit(json.dumps({"event": event, "data": data}))
|
||||
|
||||
def _file(self, path):
|
||||
sheets = []
|
||||
try:
|
||||
from openpyxl import load_workbook
|
||||
wb = load_workbook(path, read_only=True)
|
||||
sheets = list(wb.sheetnames)
|
||||
wb.close()
|
||||
except Exception as e:
|
||||
self.send("log", {"msg": f"Could not open the workbook: {e}", "type": "warn"})
|
||||
self.send("file_selected", {"path": path, "name": os.path.basename(path), "sheets": sheets})
|
||||
|
||||
@pyqtSlot()
|
||||
def browse_file(self):
|
||||
path, _ = QFileDialog.getOpenFileName(self.window, "Select input Excel file", str(Path.home()),
|
||||
"Excel files (*.xlsx *.xlsm *.xls)")
|
||||
if path:
|
||||
self._file(path)
|
||||
|
||||
@pyqtSlot(str)
|
||||
def process_dropped_path(self, path):
|
||||
# The page only knows the dropped file's name, so look for it in the usual folders.
|
||||
if not os.path.isabs(path):
|
||||
path = next((str(d / path) for d in SEARCH_DIRS if (d / path).is_file()), "")
|
||||
if path and os.path.isfile(path):
|
||||
self._file(path)
|
||||
else:
|
||||
self.send("file_selected", {"path": "", "name": os.path.basename(path) or "file", "sheets": []})
|
||||
|
||||
@pyqtSlot(str, str)
|
||||
def read_sheet(self, path, sheet):
|
||||
try:
|
||||
header, rows, more = read_sheet_rows(path, sheet)
|
||||
self.send("sheet_data", {"sheet": sheet, "header": header, "rows": rows,
|
||||
"total": len(rows), "more": more})
|
||||
except Exception as e:
|
||||
self.send("sheet_data", {"sheet": sheet, "error": str(e), "header": [], "rows": [], "total": 0})
|
||||
|
||||
@pyqtSlot()
|
||||
def download_sample_input(self):
|
||||
path, _ = QFileDialog.getSaveFileName(self.window, "Save sample input",
|
||||
str(Path.home() / f"{APP_ID}_sample_input.xlsx"),
|
||||
"Excel files (*.xlsx)")
|
||||
if path:
|
||||
build_sample(path, SAMPLE_SHEETS)
|
||||
self.send("log", {"msg": f"Sample saved: {path}", "type": "success"})
|
||||
|
||||
@pyqtSlot(str)
|
||||
def start_automation(self, excel):
|
||||
if self.running: # double-run guard
|
||||
return
|
||||
LOGS.mkdir(exist_ok=True)
|
||||
log = LOGS / f"run_{datetime.now():%Y%m%d_%H%M%S}.log"
|
||||
self.run = runner.Run(bot_cmd(excel if HAS_INPUT else "", log, self.bot), log, self.checkpoints,
|
||||
self.send, KILL_ALSO, cwd=str(ROOT)).start()
|
||||
|
||||
@pyqtSlot(bool)
|
||||
def set_dark(self, on):
|
||||
QSettings("RPA", APP_ID).setValue("dark", on)
|
||||
|
||||
|
||||
class RunTabWindow(QMainWindow):
|
||||
def __init__(self, bot=BOT, checkpoints=CHECKPOINTS):
|
||||
super().__init__()
|
||||
self.setWindowTitle(TITLE)
|
||||
self.setMinimumSize(940, 620)
|
||||
self.resize(1320, 820)
|
||||
self.view = QWebEngineView()
|
||||
self.view.setContextMenuPolicy(Qt.ContextMenuPolicy.NoContextMenu) # no Reload mid-run
|
||||
self.bridge = Bridge(self, bot, checkpoints)
|
||||
self.channel = QWebChannel()
|
||||
self.channel.registerObject("pyBridge", self.bridge)
|
||||
self.view.page().setWebChannel(self.channel)
|
||||
self.bridge.js_message.connect(self._push)
|
||||
self.view.loadFinished.connect(self._loaded)
|
||||
self.view.setHtml(page_html(), QUrl("qrc:///"))
|
||||
self.setCentralWidget(self.view)
|
||||
|
||||
def _push(self, s):
|
||||
self.view.page().runJavaScript(f"window.__pyMsg && window.__pyMsg({json.dumps(s)})")
|
||||
|
||||
def _loaded(self, ok):
|
||||
if ok:
|
||||
on = QSettings("RPA", APP_ID).value("dark", False, type=bool)
|
||||
self.bridge.send("apply_dark", {"on": on})
|
||||
|
||||
def closeEvent(self, event):
|
||||
if self.bridge.running:
|
||||
ans = QMessageBox.question(
|
||||
self, f"Close {TITLE}?",
|
||||
"The automation is still running.\n\nClosing will STOP it now. Close anyway?",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No)
|
||||
if ans != QMessageBox.StandardButton.Yes:
|
||||
event.ignore()
|
||||
return
|
||||
self.bridge.run.kill()
|
||||
event.accept()
|
||||
|
||||
|
||||
def selfcheck():
|
||||
"""Fake bot through every ending with the real runner, then window screenshots of a green
|
||||
run (light), an amber run (dark) and a stuck run (light), saved to logs/ (R14).
|
||||
Never runs the real bot."""
|
||||
import time
|
||||
fake = str((HERE / "fake_bot.py").relative_to(ROOT))
|
||||
os.environ["FAKE_CPS"] = str(len(CHECKPOINTS))
|
||||
quick = [(n, 2 if i == 1 else 0) for i, (n, _) in enumerate(CHECKPOINTS)] # checkpoint 2 sticks after 2 s
|
||||
want = {"complete": "done", "partial": "partial", "stuck": "error", "crash": "error"}
|
||||
LOGS.mkdir(exist_ok=True)
|
||||
for mode, state in want.items():
|
||||
os.environ["FAKE_MODE"] = mode
|
||||
events = []
|
||||
log = LOGS / f"selfcheck_{mode}.log"
|
||||
r = runner.Run(bot_cmd("", log, fake), log, quick, lambda e, d: events.append((e, d)), cwd=str(ROOT)).start()
|
||||
r.thread.join(60)
|
||||
end = events[-1][1] if events and events[-1][0] == "status" else {}
|
||||
assert end.get("state") == state, f"{mode}: expected {state}, got {end} (log: {log})"
|
||||
if mode == "stuck":
|
||||
assert ("cpstate", {"kind": "stuck", "cp": 2}) in events, f"stuck box missing: {events}"
|
||||
print(f"ok {mode:<8} -> {end['state']}: {end['msg']}")
|
||||
|
||||
# The window: one green run in light, one amber run in dark, saved as PNGs to look at.
|
||||
app = QApplication.instance() or QApplication(sys.argv)
|
||||
w = RunTabWindow(fake, quick)
|
||||
w.resize(1280, 900)
|
||||
w.show()
|
||||
shots = [("complete", False), ("partial", True), ("stuck", False)]
|
||||
|
||||
def wait_for_end(then):
|
||||
def poll():
|
||||
if w.bridge.running or w.bridge.run is None:
|
||||
QTimer.singleShot(200, poll)
|
||||
else:
|
||||
QTimer.singleShot(800, then)
|
||||
poll()
|
||||
|
||||
def next_shot():
|
||||
if not shots:
|
||||
app.quit()
|
||||
return
|
||||
mode, dark = shots.pop(0)
|
||||
os.environ["FAKE_MODE"] = mode
|
||||
w.bridge.run = None
|
||||
w.bridge.send("apply_dark", {"on": dark})
|
||||
w.view.page().runJavaScript("fileReady=true; filePath=''; doRun();")
|
||||
|
||||
def save():
|
||||
out = LOGS / f"selfcheck_{mode}_{'dark' if dark else 'light'}.png"
|
||||
w.view.grab().save(str(out))
|
||||
print(f"ok screenshot {out}")
|
||||
next_shot()
|
||||
|
||||
def scrolled_save():
|
||||
w.view.page().runJavaScript("document.getElementById('prog-wrap').scrollIntoView()")
|
||||
QTimer.singleShot(400, save)
|
||||
QTimer.singleShot(300, lambda: wait_for_end(scrolled_save))
|
||||
|
||||
w.view.loadFinished.connect(lambda ok: QTimer.singleShot(500, next_shot))
|
||||
QTimer.singleShot(90_000, app.quit) # never hang
|
||||
t = time.monotonic()
|
||||
app.exec()
|
||||
print(f"selfcheck done in {time.monotonic() - t:.0f}s")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if "--selfcheck" in sys.argv:
|
||||
selfcheck()
|
||||
sys.exit(0)
|
||||
app = QApplication(sys.argv)
|
||||
app.setApplicationName(TITLE)
|
||||
w = RunTabWindow()
|
||||
w.show()
|
||||
sys.exit(app.exec())
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Runs the bot in its own process and turns its log file into Run tab events.
|
||||
|
||||
No Qt in here (ADR-0002): the bot runs as a separate process with no Qt, writes every line
|
||||
to a log file, and this module reads that file every 0.2 s. It does not read a stdout pipe,
|
||||
because a windowed PyInstaller child's pipe is block-buffered and lines arrive late.
|
||||
|
||||
Bot lines it understands:
|
||||
[CPSTATE] active N / [CPSTATE] done N checkpoint progress (ADR-0001)
|
||||
[UI] text, [UI:ok] / [UI:warn] / [UI:error] text lines the operator sees
|
||||
Error / exception lines always shown, even untagged
|
||||
__RUN_SUMMARY__{"total":n,"ok":n,"failed":[[item, reason], ...]} the outcome
|
||||
Everything else stays in the log file only.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
SUMMARY = "__RUN_SUMMARY__"
|
||||
_CP = re.compile(r"^\[CPSTATE\]\s+(active|done)\s+(\d+)")
|
||||
_UI = re.compile(r"^\[UI(?::(ok|warn|error))?\]\s?(.*)$")
|
||||
# Untagged lines that still reach the screen: the last line of a traceback
|
||||
# ("ValueError: ...") and lines starting with ERROR.
|
||||
# ponytail: a regex guess, so add a [UI:error] line to the bot when an error shows badly.
|
||||
_ERR = re.compile(r"^([A-Za-z_][\w.]*(Error|Exception)\b|ERROR\b)")
|
||||
_UI_TYPE = {None: "info", "ok": "success", "warn": "warn", "error": "error"}
|
||||
_NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||
|
||||
|
||||
def parse(line):
|
||||
"""One bot line -> (event, data), or None when it stays off screen."""
|
||||
m = _CP.match(line)
|
||||
if m:
|
||||
return "cpstate", {"kind": m[1], "cp": int(m[2])}
|
||||
m = _UI.match(line)
|
||||
if m:
|
||||
return "log", {"msg": m[2], "type": _UI_TYPE[m[1]]}
|
||||
if _ERR.match(line):
|
||||
return "log", {"msg": line, "type": "error"}
|
||||
return None
|
||||
|
||||
|
||||
def outcome(summary, stuck_name=None):
|
||||
"""(state, message) for the page: done (green), partial (amber) or error (red)."""
|
||||
if stuck_name:
|
||||
return "error", f"Stuck at {stuck_name}, run again"
|
||||
if summary is None:
|
||||
return "error", "Failed: the bot stopped without a summary (see the run log)"
|
||||
total = int(summary.get("total") or 0)
|
||||
failed = summary.get("failed") or []
|
||||
if failed and len(failed) >= total:
|
||||
return "error", f"Failed: all {len(failed)} item(s) failed"
|
||||
if failed:
|
||||
return "partial", f"Finished: {len(failed)} of {total} failed"
|
||||
return "done", f"Completed: {total} item(s)" if total else "Completed"
|
||||
|
||||
|
||||
def kill_tree(proc, kill_also=()):
|
||||
"""Kill the bot and everything it started, then any extra .exe names (e.g. a driver)."""
|
||||
if proc is not None and proc.poll() is None:
|
||||
if sys.platform.startswith("win"):
|
||||
subprocess.run(["taskkill", "/PID", str(proc.pid), "/T", "/F"],
|
||||
capture_output=True, timeout=15, creationflags=_NO_WINDOW)
|
||||
else:
|
||||
proc.kill() # ponytail: direct child only off Windows; use a process group if bots spawn children there
|
||||
for image in kill_also:
|
||||
subprocess.run(["taskkill", "/F", "/IM", image],
|
||||
capture_output=True, timeout=10, creationflags=_NO_WINDOW)
|
||||
|
||||
|
||||
class Run:
|
||||
"""One bot run. emit(event, data) is called from the run thread."""
|
||||
|
||||
def __init__(self, cmd, log_path, checkpoints, emit, kill_also=(), cwd=None):
|
||||
self.cmd, self.log_path, self.emit = cmd, str(log_path), emit
|
||||
self.checkpoints = checkpoints # [(name, max_seconds)], 0 = no limit
|
||||
self.kill_also, self.cwd = kill_also, cwd
|
||||
self.proc = None
|
||||
self.summary = None
|
||||
self.active, self.since, self.stuck = 0, 0.0, None
|
||||
self.thread = threading.Thread(target=self._run, daemon=True)
|
||||
|
||||
def start(self):
|
||||
self.thread.start()
|
||||
return self
|
||||
|
||||
def kill(self):
|
||||
kill_tree(self.proc, self.kill_also)
|
||||
|
||||
def _line(self, line):
|
||||
if line.startswith(SUMMARY):
|
||||
try:
|
||||
self.summary = json.loads(line[len(SUMMARY):])
|
||||
except ValueError:
|
||||
self.emit("log", {"msg": "Could not read the bot's summary line", "type": "warn"})
|
||||
return
|
||||
ev = parse(line)
|
||||
if not ev:
|
||||
return
|
||||
if ev[0] == "cpstate":
|
||||
kind, n = ev[1]["kind"], ev[1]["cp"]
|
||||
if kind == "active":
|
||||
self.active, self.since = n, time.monotonic()
|
||||
elif n == self.active:
|
||||
self.active = 0
|
||||
self.emit(*ev)
|
||||
|
||||
def _check_stuck(self):
|
||||
if not self.active or self.active > len(self.checkpoints):
|
||||
return False
|
||||
name, limit = self.checkpoints[self.active - 1]
|
||||
if limit and time.monotonic() - self.since > limit:
|
||||
self.stuck = name
|
||||
self.emit("cpstate", {"kind": "stuck", "cp": self.active})
|
||||
self.emit("log", {"msg": f"{name} took longer than {limit}s, stopping the run", "type": "error"})
|
||||
self.kill()
|
||||
return True
|
||||
return False
|
||||
|
||||
def _run(self):
|
||||
self.emit("status", {"state": "running", "msg": "Running automation..."})
|
||||
open(self.log_path, "w", encoding="utf-8").close()
|
||||
try:
|
||||
self.proc = subprocess.Popen(self.cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
cwd=self.cwd, creationflags=_NO_WINDOW)
|
||||
except OSError as e:
|
||||
self.emit("status", {"state": "error", "msg": f"Could not start the bot: {e}"})
|
||||
return
|
||||
buf = ""
|
||||
with open(self.log_path, encoding="utf-8", errors="replace") as f:
|
||||
while True:
|
||||
exited = self.proc.poll() is not None # check before reading, so the last lines are never lost
|
||||
chunk = f.read()
|
||||
if chunk:
|
||||
buf += chunk
|
||||
*lines, buf = buf.split("\n")
|
||||
for ln in lines:
|
||||
self._line(ln.rstrip("\r"))
|
||||
elif exited or self.stuck:
|
||||
break
|
||||
elif self._check_stuck():
|
||||
self.proc.wait(timeout=30)
|
||||
else:
|
||||
time.sleep(0.2)
|
||||
if buf.strip():
|
||||
self._line(buf.rstrip("\r"))
|
||||
for item, reason in (self.summary or {}).get("failed") or []:
|
||||
self.emit("log", {"msg": f"✕ {item}: {reason}", "type": "error"})
|
||||
state, msg = outcome(None if self.stuck else self.summary, self.stuck)
|
||||
self.emit("status", {"state": state, "msg": msg})
|
||||
Reference in new issue
Block a user