Add rpa-run-tab skill
A Tebra-style Run tab for desktop PyQt6 bots, built in about 5 minutes: Trigger button, Excel input card, progress with timer, live log, checkpoint boxes with time limits, and green/amber/red endings. It is checked with a fake bot (run_tab.py --selfcheck). The design decisions are in rpa-run-tab/docs/adr. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
a57b32e8cb
12 files changed
+1435
No files matched your search
@@ -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>
|
||||
Reference in new issue
Block a user