Initial commit: plan, spec, tickets and dashboard skill templates

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
VasanthandClaude Opus 5.5 committed 2026-10-16 13:33:03 +05:30
commit 49f8f49b65
39 files changed
+10736

No files matched your search

+81
View File
@@ -0,0 +1,81 @@
"""Minimal PyQt6 host for dashboard.html (desktop mode). Pattern from Tebra main_window.py.
The bot runs as a child process that prints lines, and this window tails them.
Fill in STEP_MARKERS, FRIENDLY_RULES and BOT_CMD for the project.
"""
import json, subprocess, sys, threading
from pathlib import Path
from PyQt6.QtCore import QObject, pyqtSlot, pyqtSignal, QUrl, QSettings
from PyQt6.QtWidgets import QApplication, QMainWindow
from PyQt6.QtWebEngineWidgets import QWebEngineView
from PyQt6.QtWebChannel import QWebChannel
HTML_PATH = Path(__file__).with_name("dashboard.html")
BOT_CMD = [sys.executable, "-m", "{{BOT_MODULE}}"] # child process
STEPS = {{STEPS_PY}} # same list as STEPS in dashboard.html
STEP_MARKERS = [("[step 1]", 0, 10), ("[all done]", len(STEPS) - 1, 100)] # (substring, step idx, pct)
FRIENDLY_RULES = [("[login] done", "Logged in.", "success")] # (substring, friendly msg | None, type)
def classify(line):
s = line.lower()
return "error" if ("error" in s or "traceback" in s) else "warn" if "warn" in s else "info"
class Bridge(QObject):
_emit = pyqtSignal(str) # thread-safe hop to the GUI thread
def __init__(self, view):
super().__init__()
self.view = view
self._emit.connect(lambda js: self.view.page().runJavaScript(f"window.__pyMsg({js})"))
def send_to_js(self, event, data):
self._emit.emit(json.dumps(json.dumps({"event": event, "data": data})))
@pyqtSlot()
def load_dashboard(self):
self.send_to_js("dashboard_data", {"batches": []}) # TODO: read from DB on a worker thread
self.send_to_js("apply_dark", {"on": QSettings("{{ORG}}", "{{APP_NAME}}").value("dark", False, bool)})
@pyqtSlot(bool)
def set_dark(self, on):
QSettings("{{ORG}}", "{{APP_NAME}}").setValue("dark", on)
@pyqtSlot()
def start_automation(self):
threading.Thread(target=self._run, daemon=True).start()
def _run(self):
self.send_to_js("status", {"state": "running", "msg": "Running…"})
p = subprocess.Popen(BOT_CMD, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1)
for raw in p.stdout:
self._on_line(raw.rstrip())
ok = p.wait() == 0
self.send_to_js("status", {"state": "done" if ok else "error", "msg": "Completed" if ok else "Failed"})
def _on_line(self, line):
# ponytail: raw line only printed here; persist to file/DB if audit is needed
low, kind = line.lower(), classify(line)
for marker, idx, pct in STEP_MARKERS:
if marker in low:
self.send_to_js("step", {"label": STEPS[idx], "pct": pct})
break
for marker, msg, t in FRIENDLY_RULES:
if marker in low:
if msg:
self.send_to_js("log", {"msg": msg, "type": t})
return
if kind == "error": # never hide a real failure
self.send_to_js("log", {"msg": line, "type": "error"})
if __name__ == "__main__":
app = QApplication(sys.argv)
win, view = QMainWindow(), QWebEngineView()
bridge, channel = Bridge(view), QWebChannel()
channel.registerObject("pyBridge", bridge)
view.page().setWebChannel(channel)
view.setUrl(QUrl.fromLocalFile(str(HTML_PATH)))
win.setCentralWidget(view); win.resize(1280, 820); win.setWindowTitle("{{APP_NAME}}"); win.show()
sys.exit(app.exec())
+31
View File
@@ -0,0 +1,31 @@
# Dashboard data contract (no-AI)
All updates use one envelope: `{"event": "<name>", "data": {...}}`.
- **Desktop (PyQt):** Python calls `window.__pyMsg(json)` via `runJavaScript` (see `bridge.py`).
- **Web:** the page polls `GET {API_BASE}/dashboard` (returns `dashboard_data`) and
`GET {API_BASE}/run/status` → `{"running": bool, "events": [envelope, ...]}`. Poll fast (2s) only while running.
## Bot/backend → page
| event | data |
|---|---|
| `status` | `{state: "ready"\|"running"\|"done"\|"error", msg}` |
| `step` | `{label: <one of STEPS>, pct: 0-100}` |
| `log` | `{msg: <plain English>, type: "info"\|"success"\|"warn"\|"error"}` |
| `dashboard_data` | `{batches: [{batch_id, date, state: "completed"\|"inprogress"\|"notcompleted"\|"cleared", items_total, items_done, ...}]}` |
| `report_data` | `{rows: [{...REPORT_COLUMNS keys, status, bucket: "success"\|"attention"\|"failed"}]}` |
| `set_user` | `{username, emp_id, role}` |
| `apply_dark` | `{on: bool}` |
Optional (only if the section was chosen): `cpstate {kind: pending|active|done|stuck, cp: int}`, `admin_data`, `batch_detail`.
## Page → bot/backend (Qt `@pyqtSlot` names / web `POST {API_BASE}/<slot-with-dashes>`)
`load_dashboard`, `start_automation`, `request_stop`, `load_latest_report`, `download_report`, `set_dark(on)`, `logout`.
## Rules (learned on Tebra)
- Log the raw bot line to a file/DB; show only **friendly** text on screen. Never hide an error line.
- Step markers: match the bot's printed lines by **specific** substring (e.g. `[step 3]`), not a bare word. A bare word matches unrelated lines.
- Derive `state`: Pending + today = `inprogress`; Pending + older day = `notcompleted`.
- Report `bucket`: success = {processed successfully, posted, completed}; failed = {error, failed}; everything else = attention.
- Run the bot in a **separate process** that writes to a log file, and have the UI tail that file. When pywinauto/UIA runs inside a Qt worker thread, its reads are unreliable (Tebra bug-094).
@@ -0,0 +1,273 @@
<!DOCTYPE html>
<!--
Generic RPA dashboard (no AI). Derived from Tebra ERA (reference/tebra_dashboard.html).
One file: works inside PyQt6 QWebEngineView (QWebChannel bridge) OR served by any web
backend (falls back to polling GET {{API_BASE}}/dashboard and /run/status).
Replace every {{PLACEHOLDER}}. Theme = the :root / body.dark token blocks only.
-->
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{APP_NAME}}</title>
<script src="qrc:///qtwebchannel/qwebchannel.js"></script>
<style>
/* ===== THEME TOKENS (swap these for a preset/custom palette) ===== */
:root{
--bg-top:#8fc9e8; --bg-mid:#bfe3f2; --bg-bottom:#eaf6fb;
--glass:rgba(255,255,255,.60); --glass-strong:rgba(255,255,255,.78); --border:rgba(255,255,255,.9);
--ink:#1c2b33; --muted:#5c7382; --accent-dark:#16202a;
--accent:#3d9bd0; --accent-2:#5cb2e2; --accent-soft:#d4edfa; --accent-soft-2:#a9d8ee;
--ok:#1f9d57; --ok-bg:#e6f6ec; --warn:#b7791f; --warn-bg:#fbf1dd; --bad:#c0392b; --bad-bg:#fdecea;
--font:'Segoe UI',-apple-system,BlinkMacSystemFont,sans-serif;
}
body.dark{
--bg-top:#0f1b26; --bg-mid:#13212e; --bg-bottom:#182a38;
--glass:rgba(22,34,44,.72); --glass-strong:rgba(30,45,58,.85); --border:rgba(120,160,190,.22);
--ink:#e6eef4; --muted:#8fa6b4; --accent-dark:#2e5d80;
--accent-soft:#2e5d90; --accent-soft-2:#1d3d5e;
--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);
}
/* ===== BASE ===== */
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;font-family:var(--font);color:var(--ink);overflow:hidden;
background:linear-gradient(180deg,var(--bg-top) 0%,var(--bg-mid) 45%,var(--bg-bottom) 100%)}
.app{display:flex;height:100vh}
.hidden{display:none!important}
/* sidebar */
.sidebar{width:230px;min-width:230px;background:var(--glass);border-right:1px solid var(--border);backdrop-filter:blur(14px);display:flex;flex-direction:column}
.logo-area{padding:22px 20px;display:flex;align-items:center;gap:11px;border-bottom:1px solid var(--border)}
.logo-box{width:40px;height:40px;border-radius:12px;background:var(--glass-strong);display:flex;align-items:center;justify-content:center;border:1px solid var(--border)}
.logo-box img{width:28px;height:28px;object-fit:contain}
.logo-title{font-size:15px;font-weight:700}
.logo-sub{font-size:10px;color:var(--muted);margin-top:2px;letter-spacing:.5px;text-transform:uppercase;font-weight:600}
.nav{padding:16px 12px;flex:1}
.nav-item{display:flex;align-items:center;gap:11px;padding:11px 13px;border-radius:10px;cursor:pointer;margin-bottom:3px;color:var(--muted);font-size:13.5px;font-weight:600;transition:all .15s;user-select:none}
.nav-item svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:2}
.nav-item:hover{background:var(--glass-strong);color:var(--ink)}
.nav-item.active{background:linear-gradient(135deg,var(--accent),var(--accent-dark));color:#fff;box-shadow:0 4px 12px rgba(20,50,70,.22)}
.sidebar-foot{padding:14px;border-top:1px solid var(--border);display:flex;align-items:center;gap:10px}
.avatar{width:34px;height:34px;border-radius:50%;background:linear-gradient(135deg,var(--accent),var(--accent-dark));display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;color:#fff}
.u-name{font-size:13px;font-weight:700}.u-role{font-size:10.5px;color:var(--muted)}
/* main + topbar */
.main{flex:1;display:flex;flex-direction:column;overflow:hidden}
.topbar{background:var(--glass-strong);border-bottom:1px solid var(--border);backdrop-filter:blur(14px);padding:20px 28px;display:flex;align-items:center;gap:14px;box-shadow:0 6px 18px rgba(20,50,70,.10)}
.tb-title{font-size:21px;font-weight:800}.tb-sub{font-size:12.5px;color:var(--muted);margin-top:3px}
.tb-right{margin-left:auto;display:flex;align-items:center;gap:12px}
.indicator{display:flex;align-items:center;gap:7px;background:var(--glass-strong);padding:7px 13px;border-radius:20px;border:1px solid var(--border);font-size:12px;font-weight:700}
.dot{width:8px;height:8px;border-radius:50%}
.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.error{background:var(--bad);box-shadow:0 0 7px var(--bad)}
@keyframes blink{50%{opacity:.4}}
.mode-btn{width:38px;height:38px;border-radius:11px;border:1px solid var(--border);background:var(--glass-strong);cursor:pointer;font-size:16px}
.content{flex:1;overflow-y:auto;padding:26px 28px;border-top-left-radius:30px}
/* cards, buttons, pills */
.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)}
.c-title{font-size:15px;font-weight:700;margin-bottom:14px}
.btn{padding:9px 15px;border-radius:11px;border:1px solid var(--accent-soft-2);background:linear-gradient(180deg,var(--accent-soft),var(--accent-soft-2));color:var(--ink);font-size:12.5px;font-weight:600;cursor:pointer;transition:all .18s}
.btn:hover{transform:translateY(-1px);box-shadow:0 4px 12px rgba(20,50,70,.2)}
.btn.primary{background:linear-gradient(180deg,var(--accent-2),var(--accent));color:#fff;border-color:var(--accent)}
.btn:disabled{opacity:.45;cursor:not-allowed;transform:none;box-shadow:none}
.pill{display:inline-block;padding:4px 11px;border-radius:20px;font-size:11px;font-weight:700;color:#fff}
.pill.success{background:var(--ok)}.pill.attention{background:var(--warn)}.pill.failed{background:var(--bad)}
/* KPI */
.kpi-row{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:16px}
.kpi{display:flex;align-items:center;gap:13px;padding:16px 18px;border-radius:16px;background:var(--glass-strong);border:1px solid var(--border);box-shadow:0 4px 14px rgba(20,50,70,.10)}
.kpi-ic{width:42px;height:42px;border-radius:12px;position:relative}
.kpi.total .kpi-ic{background:linear-gradient(135deg,var(--accent-2),var(--accent))}
.kpi.done .kpi-ic{background:var(--ok)}.kpi.prog .kpi-ic{background:var(--warn)}.kpi.notdone .kpi-ic{background:var(--bad)}
.kpi.prog.live .kpi-ic::after{content:'';position:absolute;inset:-3px;border-radius:14px;border:2px solid var(--warn);animation:kpulse 1.6s ease-out infinite}
@keyframes kpulse{0%{transform:scale(.9);opacity:.6}100%{transform:scale(1.25);opacity:0}}
.kpi-val{font-size:26px;font-weight:800;line-height:1.1;font-variant-numeric:tabular-nums}
.kpi-lbl{font-size:11.5px;color:var(--muted);font-weight:600;margin-top:2px}
/* donut */
.dash-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}
.donut-wrap{display:flex;align-items:center;gap:26px;flex-wrap:wrap}
.donut{width:150px;height:150px;border-radius:50%;position:relative;background:conic-gradient(var(--border) 0 100%)}
.donut-hole{position:absolute;inset:22px;border-radius:50%;background:var(--bg-bottom);display:flex;flex-direction:column;align-items:center;justify-content:center}
.dh-num{font-size:26px;font-weight:800}.dh-lbl{font-size:10px;color:var(--muted);font-weight:700;text-transform:uppercase}
.dl-row{display:flex;align-items:center;gap:9px;font-size:12.5px;font-weight:600;margin-bottom:9px}
.dl-dot{width:10px;height:10px;border-radius:3px}
/* run panel */
.prog-head{display:flex;gap:12px;margin-bottom:8px;font-size:13px;font-weight:700}
.prog-head .pct{margin-left:auto;color:var(--accent)}
.prog-bg{height:8px;background:var(--glass-strong);border-radius:8px;overflow:hidden;margin-bottom:18px}
.prog-fill{height:100%;width:0;background:linear-gradient(90deg,var(--ok),var(--ok));transition:width .5s}
.step{display:flex;align-items:center;gap:12px;padding:10px 13px;border-radius:10px;margin-bottom:6px;font-size:13px;font-weight:600}
.step.done{background:var(--ok-bg);color:var(--ok)}.step.active{background:var(--warn-bg);color:var(--warn)}.step.pending{opacity:.5}
.step-dot{width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11px;background:var(--glass-strong)}
.step.done .step-dot{background:var(--ok);color:#fff}.step.active .step-dot{background:var(--warn);color:#fff}
.log{background:var(--accent-dark);border-radius:12px;padding:12px 14px;max-height:220px;overflow-y:auto;font-family:Consolas,'Courier New',monospace;font-size:13px;line-height:1.85;color:#9fb6c4}
.log .info{color:#7fc4f0}.log .success{color:#5fe0a0}.log .warn{color:#f5c563}.log .error{color:#f68c82}
/* report table */
.toolbar{display:flex;gap:10px;align-items:center;margin-bottom:14px;flex-wrap:wrap}
.toolbar input{padding:8px 10px;border:1px solid var(--border);border-radius:10px;background:var(--glass-strong);color:var(--ink);font:inherit;font-size:12.5px}
.tbl-wrap{border:1px solid var(--border);border-radius:14px;overflow:auto;max-height:60vh;background:var(--glass)}
table{border-collapse:collapse;width:100%;font-size:12px;white-space:nowrap}
th{background:var(--glass-strong);text-align:left;padding:11px 14px;position:sticky;top:0;font-weight:700}
td{padding:10px 14px;border-bottom:1px solid var(--border)}
.empty{text-align:center;padding:40px;color:var(--muted);font-size:13px}
@media (max-width:820px){.sidebar{width:66px;min-width:66px}.logo-title,.logo-sub,.nav-item span,.u-name,.u-role{display:none}.kpi-row{grid-template-columns:1fr 1fr}.dash-grid{grid-template-columns:1fr}}
</style>
</head>
<body>
<div class="app">
<aside class="sidebar">
<div class="logo-area">
<div class="logo-box"><img src="{{LOGO_SRC}}" alt=""></div>
<div><div class="logo-title">{{APP_NAME}}</div><div class="logo-sub">{{APP_SUBTITLE}}</div></div>
</div>
<nav class="nav">
<div class="nav-item active" data-tab="dashboard"><svg viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg><span>Dashboard</span></div>
<div class="nav-item" data-tab="run"><svg viewBox="0 0 24 24"><polygon points="6 4 20 12 6 20 6 4"/></svg><span>Run</span></div>
<div class="nav-item" data-tab="report"><svg viewBox="0 0 24 24"><path d="M4 4h16v16H4z"/><path d="M8 9h8M8 13h8M8 17h5"/></svg><span>Report</span></div>
<!-- OPTIONAL: Admin nav item (roles) goes here -->
</nav>
<div class="sidebar-foot"><div class="avatar" id="avatar">U</div><div><div class="u-name" id="u-name">User</div><div class="u-role" id="u-role">Operator</div></div></div>
</aside>
<main class="main">
<header class="topbar">
<div><div class="tb-title" id="tb-title">Dashboard</div><div class="tb-sub" id="tb-sub">Overview of every batch</div></div>
<div class="tb-right">
<button class="btn primary" id="run-btn" onclick="startRun()">▶ Run</button>
<button class="mode-btn" id="mode-btn" title="Dark mode" onclick="toggleDark()">🌙</button>
<div class="indicator"><span class="dot ready" id="st-dot"></span><span id="st-text">System ready</span></div>
</div>
</header>
<section class="content">
<!-- DASHBOARD -->
<div id="tab-dashboard">
<div class="kpi-row">
<div class="kpi total"><div class="kpi-ic"></div><div><div class="kpi-val" id="k-total">0</div><div class="kpi-lbl">Total batches</div></div></div>
<div class="kpi done"><div class="kpi-ic"></div><div><div class="kpi-val" id="k-done">0</div><div class="kpi-lbl">Completed</div></div></div>
<div class="kpi prog" id="kpi-prog"><div class="kpi-ic"></div><div><div class="kpi-val" id="k-prog">0</div><div class="kpi-lbl">In progress</div></div></div>
<div class="kpi notdone"><div class="kpi-ic"></div><div><div class="kpi-val" id="k-fail">0</div><div class="kpi-lbl">Not completed</div></div></div>
</div>
<div class="dash-grid">
<div class="card"><div class="c-title">Batch status split</div>
<div class="donut-wrap"><div class="donut" id="donut"><div class="donut-hole"><div class="dh-num" id="dh-num">0</div><div class="dh-lbl">batches</div></div></div><div id="legend"></div></div>
</div>
<div class="card"><div class="c-title">Recent batches</div><div id="recent" class="empty">No batches yet</div></div>
</div>
</div>
<!-- RUN -->
<div id="tab-run" class="hidden">
<div class="card"><div class="c-title">Run automation</div>
<div class="prog-head"><span id="p-label">Waiting to start</span><span id="p-time"></span><span class="pct" id="p-pct">0%</span></div>
<div class="prog-bg"><div class="prog-fill" id="p-fill"></div></div>
<div id="steps"></div>
<!-- OPTIONAL: checkpoint tracker (see reference/tebra_dashboard.html .cp-track) -->
<div class="log" id="log"></div>
</div>
</div>
<!-- REPORT -->
<div id="tab-report" class="hidden">
<div class="card">
<div class="toolbar"><input id="r-search" placeholder="Search…" oninput="renderReport()"><span style="margin-left:auto"></span><button class="btn primary" onclick="api('download_report')">⬇ Download Excel</button></div>
<div class="tbl-wrap"><table><thead id="r-head"></thead><tbody id="r-body"></tbody></table></div>
</div>
</div>
</section>
</main>
</div>
<script>
// ===== CONFIG (fill per project) =====
const STEPS = {{STEPS_JSON}}; // e.g. ["Initializing","Login","Process items","Completed"]
const REPORT_COLUMNS = {{REPORT_COLUMNS_JSON}}; // e.g. [{key:"item",label:"Item"},{key:"status",label:"Status"}]
const API_BASE = "{{API_BASE}}"; // web mode only, e.g. "/api"
const TABS = {dashboard:["Dashboard","Overview of every batch"],run:["Run","Live progress and log"],report:["Report","Result of every processed item"]};
// ===== TRANSPORT: Qt bridge if present, else HTTP polling =====
let bridge = null, reportRows = [];
function api(slot, ...args){
if(bridge) return bridge[slot](...args);
return fetch(`${API_BASE}/${slot.replace(/_/g,'-')}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(args)}).catch(()=>{});
}
window.__pyMsg = (json)=>{ const m=JSON.parse(json); handle(m.event,m.data); };
if(window.qt && window.qt.webChannelTransport){
new QWebChannel(qt.webChannelTransport, ch=>{ bridge=ch.objects.pyBridge; bridge.load_dashboard(); });
}else{
const poll = async()=>{
try{
const d = await (await fetch(`${API_BASE}/dashboard`)).json(); handle('dashboard_data',d);
const s = await (await fetch(`${API_BASE}/run/status`)).json();
(s.events||[]).forEach(e=>handle(e.event,e.data));
setTimeout(poll, s.running ? 2000 : 10000); // poll fast only while busy
}catch(e){ setTimeout(poll, 10000); }
};
poll();
}
// ===== EVENT DISPATCH (see contract.md) =====
function handle(event, d){
switch(event){
case 'status': setStatus(d.state, d.msg); break;
case 'step': setStep(d.label, d.pct); break;
case 'log': addLog(d.msg, d.type); break;
case 'dashboard_data': renderDashboard(d.batches||[]); break;
case 'report_data': reportRows = d.rows||[]; renderReport(); break;
case 'set_user': setUser(d); break;
case 'apply_dark': document.body.classList.toggle('dark', !!d.on); break;
}
}
// ===== UI =====
document.querySelectorAll('.nav-item').forEach(n=>n.onclick=()=>showTab(n.dataset.tab));
function showTab(t){
document.querySelectorAll('.nav-item').forEach(n=>n.classList.toggle('active',n.dataset.tab===t));
Object.keys(TABS).forEach(k=>document.getElementById('tab-'+k).classList.toggle('hidden',k!==t));
tbTitle.textContent=TABS[t][0]; tbSub.textContent=TABS[t][1];
if(t==='dashboard') api('load_dashboard');
if(t==='report') api('load_latest_report');
}
const $=id=>document.getElementById(id), tbTitle=$('tb-title'), tbSub=$('tb-sub');
function esc(s){return String(s??'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
function toggleDark(){ const on=document.body.classList.toggle('dark'); api('set_dark',on); }
function setUser(u){ $('u-name').textContent=u.username; $('u-role').textContent=u.role; $('avatar').textContent=(u.username||'U')[0].toUpperCase(); }
function setStatus(state,msg){ $('st-dot').className='dot '+state; $('st-text').textContent=msg||state; $('run-btn').disabled=state==='running'; }
function startRun(){ showTab('run'); $('log').innerHTML=''; setStep(STEPS[0],0); api('start_automation'); }
function renderSteps(active=-1){
$('steps').innerHTML=STEPS.map((s,i)=>{const c=i<active?'done':i===active?'active':'pending';
return `<div class="step ${c}"><div class="step-dot">${c==='done'?'✓':i+1}</div>${esc(s)}</div>`}).join('');
}
function setStep(label,pct){ $('p-label').textContent=label; $('p-pct').textContent=pct+'%'; $('p-fill').style.width=pct+'%';
renderSteps(pct>=100?STEPS.length:STEPS.indexOf(label)); }
function addLog(msg,type='info'){ const l=$('log'); l.insertAdjacentHTML('beforeend',`<div class="${type}">${esc(msg)}</div>`); l.scrollTop=l.scrollHeight; }
function countUp(el,to){ const from=+el.textContent||0,t0=performance.now();
const f=t=>{const p=Math.min((t-t0)/500,1); el.textContent=Math.round(from+(to-from)*p); if(p<1)requestAnimationFrame(f)}; requestAnimationFrame(f); }
function renderDashboard(b){
const n=s=>b.filter(x=>x.state===s).length;
const c={completed:n('completed'),inprogress:n('inprogress'),notcompleted:n('notcompleted')};
countUp($('k-total'),b.length); countUp($('k-done'),c.completed); countUp($('k-prog'),c.inprogress); countUp($('k-fail'),c.notcompleted);
$('kpi-prog').classList.toggle('live',c.inprogress>0); $('dh-num').textContent=b.length;
const css=getComputedStyle(document.body), col={completed:css.getPropertyValue('--ok'),inprogress:css.getPropertyValue('--warn'),notcompleted:css.getPropertyValue('--bad')};
const lbl={completed:'Completed',inprogress:'In progress',notcompleted:'Not completed'};
let acc=0; const stops=[];
Object.keys(c).forEach(k=>{ if(!c[k])return; const a=acc/b.length*100; acc+=c[k]; stops.push(`${col[k]} ${a}% ${acc/b.length*100}%`); });
$('donut').style.background = b.length?`conic-gradient(${stops.join(',')})`:'conic-gradient(var(--border) 0 100%)';
$('legend').innerHTML=Object.keys(c).map(k=>`<div class="dl-row"><span class="dl-dot" style="background:${col[k]}"></span>${lbl[k]} · ${c[k]}</div>`).join('');
$('recent').className = b.length?'':'empty';
$('recent').innerHTML = b.length? b.slice(0,6).map(x=>`<div class="dl-row">#${esc(x.batch_id)} · ${esc(x.date)} <span class="pill ${x.state==='completed'?'success':x.state==='inprogress'?'attention':'failed'}" style="margin-left:auto">${lbl[x.state]||esc(x.state)}</span></div>`).join('') : 'No batches yet';
}
function renderReport(){
const q=$('r-search').value.toLowerCase();
$('r-head').innerHTML='<tr>'+REPORT_COLUMNS.map(c=>`<th>${esc(c.label)}</th>`).join('')+'</tr>';
const rows=reportRows.filter(r=>!q||Object.values(r).join(' ').toLowerCase().includes(q));
$('r-body').innerHTML = rows.length? rows.map(r=>'<tr>'+REPORT_COLUMNS.map(c=>c.key==='status'
?`<td><span class="pill ${esc(r.bucket||'attention')}">${esc(r.status)}</span></td>`:`<td>${esc(r[c.key])}</td>`).join('')+'</tr>').join('')
: `<tr><td class="empty" colspan="${REPORT_COLUMNS.length}">No rows yet</td></tr>`;
}
renderSteps();
</script>
</body>
</html>
@@ -0,0 +1,251 @@
def categorize_status(status):
s = (status or "").strip().lower()
if s in SUCCESS_STATUSES:
return "success"
if s in FAILED_STATUSES:
return "failed"
return "attention"
# ── Progress heuristic: map printed markers -> step index + % ─────────────────
# (run_multi is one long call, so we infer progress from the lines it prints.)
STEP_MARKERS = [
("multi-company / multi-location", 0, 5),
("[input] login sheet", 1, 12),
("########## company", 2, 25),
("[choose company]", 2, 32),
("practice home", 3, 45),
("clearinghouse", 4, 55),
("unreviewed", 5, 62),
("posting", 5, 70),
("processed successfully", 5, 74),
("test-notposted", 5, 74),
("medicalperformer", 6, 82), # ERA -> EOB (PDF) phase
("era import ->", 6, 84),
("print-to-pdf", 6, 84),
("[all done]", 7, 100),
]
STEPS = [
"Initializing",
"Reading Excel",
"Login to Tebra",
"Select company",
"Open Clearinghouse",
"Post payments",
"ERA → EOB conversion",
"Completed",
]
# ── Friendly log translation ──────────────────────────────────────────────────
# The on-screen log is read by medical-billing users, not developers, so raw
# automation lines are translated to plain English here. Every RAW line is
# still stored in full in the BatchLog table (per batch) for debugging.
#
# Rules are (marker-substring, friendly-message, type) matched on the
# lowercased line, first match wins. friendly-message None = keep the line off
# the screen. Unmatched lines are hidden too, UNLESS they are errors — a real
# failure must never be invisible.
# IMPORTANT: markers are matched by SUBSTRING against the lowercased line, first
# match wins, so they MUST be specific enough not to catch unrelated lines. e.g.
# a bare "clearinghouse" wrongly matched "Total unreviewed clearinghouse reports:
# 10852" and produced a duplicate "Opening Clearinghouse" — always anchor on the
# distinct "[step N]" / "[medicalperformer] <verb>" prefix instead of a bare word.
_FRIENDLY_RULES = [
("multi-company / multi-location", "Starting the automation…", "info"),
("[input] login sheet", "Reading the Excel input file…", "info"),
("[choose company] screen", "Selecting the company in Tebra…", "info"),
("[login] done", "Logged in to Tebra.", "success"),
("[step 3] confirmed", "Practice opened.", "info"),
("[step 4+5] encounters", "Opening Clearinghouse Reports…", "info"),
("[step 6] checking", "Searching for unreviewed ERAs…", "info"),
("[step 9] downloading + posting", "Checking for ERAs to post…", "info"),
("no more unprocessed era rows", "No more ERAs to post here.", "info"),
("grid still not found", "The ERA list did not load — moving on.", "error"),
("grid not found", "The ERA list is slow to load — retrying…", "warn"),
("[step 9] downloaded:", "ERA file downloaded.", "info"),
("download failed", "Could not download the ERA file — continuing.", "warn"),
("didn't open", "The ERA is slow to open — trying again…", "warn"),
("still could not open view", "Could not open the ERA — marked as Error.", "error"),
("clicking 'post payment", "Clicking Post Payment…", "info"),
("post payment button not found", "Post Payment button not found.", "error"),
("-> era errors screen", "Tebra reported ERA Errors for this payment — cancelling it.", "warn"),
("-> payment screen", "Payment screen opened.", "info"),
("post-payment retry", "The payment screen didn't load — trying again…", "warn"),
("unrecognized screen after post payment", "Unexpected screen after Post Payment — retrying…", "warn"),
("batch # field / reference # not ready", "Batch # field not ready — retrying…", "warn"),
("batch# field or reference# value not found", "Batch # field not found — marked as Error.", "error"),
("filling batch #", "Entering the Batch number…", "info"),
("test mode — would have posted", "Test mode: payment ready — clicking Cancel instead of Save.", "warn"),
("save button not found", "Save button element not found.", "error"),
("[step 9] posted era", "Payment saved — ERA posted.", "success"),
("done posting era payments", "Finished posting for this practice.", "info"),
("[medicalperformer] launching", "Converting ERA files to EOB PDFs…", "info"),
("[medicalperformer] no .txt", "No ERA files to convert.", "info"),
("[done] no-location company complete", "Company finished.", "success"),
# The final line is emitted by Python (_run_automation) from the real run
# summary — hide the raw markers so a run that FAILED can never print a
# hardcoded green "completed".
("[run summary]", None, "info"),
("[all done]", None, "info"),
]
# Dynamic rules that pull a value out of the line (checked before the static
# list). Each is (compiled-regex, lambda match -> (msg, type)).
_FRIENDLY_DYNAMIC = [
(re.compile(r"#+\s*COMPANY\s+(\d+)/(\d+):\s*(.+?)\s*#*\s*$", re.I),
lambda m: (f"Working on company {m.group(1)} of {m.group(2)}: "
f"{m.group(3)}", "info")),
(re.compile(r"PROCESSING LOCATION:\s*(.+)", re.I),
lambda m: (f"Working on location: {m.group(1).strip()}", "info")),
# Login failure — make it clearly visible (the COM/network error the user
# sees when Tebra pops "connection error" and they click OK).
(re.compile(r"\[login\] FAILED for company '(.+?)':", re.I),
lambda m: (f"Could not log in to Tebra for {m.group(1)} — connection or "
f"Tebra error. This company was not processed.", "error")),
(re.compile(r"Unreviewed reports total\s*:\s*(\d+)", re.I),
lambda m: (f"Found {m.group(1)} unreviewed report(s) to check.", "info")),
(re.compile(r"\[Step 9\] Opening ERA row id=(\S+)", re.I),
lambda m: (f"Opening ERA {m.group(1)}…", "info")),
(re.compile(r"Check ID=(\S*)\s+Date=(\S*)\s+Method=.*Amount=(\S*)", re.I),
lambda m: (f"Check {m.group(1) or '—'} · Date {m.group(2) or '—'} · "
f"Amount {m.group(3) or '—'}", "info")),
(re.compile(r"\[Step 9\] Skipping\s+\S+:\s*(.+)", re.I),
lambda m: (f"Skipped — {m.group(1).strip()}", "warn")),
(re.compile(r"\[Done\] Location '(.+?)' complete", re.I),
lambda m: (f"Location finished: {m.group(1)}", "success")),
]
# Error-level lines that are pure developer noise — the friendly login-error
# line and the Python run-summary already tell the user what failed, so these
# raw dumps are kept in the BatchLog table but never shown on screen.
_ERROR_NOISE = (
"traceback (most recent call last)",
"during handling of the above",
"another exception occurred",
"comerror",
"[error] company '", # per-company COM dump — summary covers it
)
def _is_error_noise(low_line):
return any(marker in low_line for marker in _ERROR_NOISE)
def friendly_log_line(line):
"""Translate one raw automation line for the user-facing log.
Returns (message, type) to display, or None to keep it off the screen."""
for rx, fmt in _FRIENDLY_DYNAMIC:
m = rx.search(line)
if m:
return fmt(m)
low = line.lower()
for marker, msg, ltype in _FRIENDLY_RULES:
if marker in low:
return (msg, ltype) if msg else None
return None
class _StreamToBridge:
"""A stdout/stderr replacement that tees to the real stream AND forwards
every completed line to the UI as a log event."""
def __init__(self, bridge, original, level="info"):
self.bridge = bridge
self.original = original
self.level = level
self._buf = ""
def write(self, text):
try:
if self.original:
self.original.write(text)
except Exception:
pass
self._buf += text
while "\n" in self._buf:
line, self._buf = self._buf.split("\n", 1)
self._emit_line(line)
def _emit_line(self, line):
line = line.rstrip("\r")
if not line.strip():
return
self.bridge._on_automation_line(line, self.level)
def flush(self):
try:
if self.original:
self.original.flush()
except Exception:
pass
def isatty(self):
return False
class Bridge(QObject):
"""Exposed to JavaScript as 'pyBridge'."""
js_message = pyqtSignal(str)
def __init__(self, window):
super().__init__()
self.window = window
self.username = "User"
self._max_pct = 0
self._cur_step = 0
self.batch_id = None # set on a fresh Trigger or from a resume check
self._batch_log = None # BatchLogWriter while a run is active
self._run_log_file = None # plain-text run log (DB-independent) while running
self._last_friendly = None # dedupe consecutive identical friendly lines
self._auto_proc = None # the separate automation process (bug-094) while running
# ── Python -> JS ────────────────────────────────────────────────────────────
def send_to_js(self, event, data):
self.js_message.emit(json.dumps({"event": event, "data": data}))
def _log(self, msg, type="info"):
self.send_to_js("log", {"msg": msg, "type": type})
def _step(self, label, pct):
self.send_to_js("step", {"label": label, "pct": pct})
def _batch_chip(self, batch_id, live=False):
"""Persistent Run-tab chip showing the batch this run is tracked under.
`batch_id` None/0 CLEARS it. A stale number is worse than none, so every
site that assigns self.batch_id must call this in the same breath."""
self.send_to_js("batch_state", {
"batch_id": str(batch_id) if batch_id else "",
"live": bool(live)})
# ── JS -> Python ────────────────────────────────────────────────────────────
@pyqtSlot(str)
def log(self, msg):
print(f"[JS] {msg}")
@pyqtSlot(bool)
def set_dark(self, on):
"""Persist the dark-mode choice so the login page (a separate window)
can pick it up too."""
try:
from src.ui.prefs import set_dark
set_dark(on)
except Exception as e:
print(f"[prefs] could not save dark mode: {e}")
@pyqtSlot()
def logout(self):
"""JS clicked Logout — defer to the next event-loop tick so we don't
run a nested dialog from inside the web-channel callback."""
from PyQt6.QtCore import QTimer
QTimer.singleShot(0, self.window.do_logout)
@pyqtSlot()
def browse_file(self):
path, _ = QFileDialog.getOpenFileName(
self.window, "Select Input Excel File",
os.path.expanduser("~"),
"Excel files (*.xlsx *.xlsm *.xls)"
@@ -0,0 +1,171 @@
"""Drive the Run tab's 7-box checkpoint tracker.
kind: 'active' (box cp is now running) | 'done' (box cp finished) |
'stuck' (box cp overran its max time)."""
self.send_to_js("cpstate", {"kind": kind, "cp": int(cp)})
def _on_automation_line(self, line, level):
"""Called (from the worker thread) for every printed line."""
# Watchdog checkpoint state marker -> 7-box tracker in the Run tab
# (raw line still stored in the DB log below).
if "[CPSTATE]" in line:
try:
kind, cp = line.split("[CPSTATE]", 1)[1].split()
self._cpstate(kind, cp)
# Feed the parent-side stall watchdog. The automation runs in a
# CHILD process, so the in-process performer.WD_STATE the old
# watchdog read is ALWAYS empty here (it never fired — a stuck
# checkpoint never restarted). Track the active checkpoint + its
# start time from the STREAMED [CPSTATE] line instead.
if kind == "active":
self._wd_cp = int(cp)
self._wd_since = time.time()
except Exception:
pass
low = line.lower()
ltype = "info"
if "[error]" in low or "[fatal]" in low or "traceback" in low or "exception" in low:
ltype = "error"
elif "warning" in low or "[warn" in low:
ltype = "warn"
elif "processed successfully" in low or "[all done]" in low or "success" in low:
ltype = "success"
# The FULL raw technical line always goes to the per-batch DB log AND to
# the DB-independent file log (the file works even when the DB is down).
if self._batch_log is not None:
self._batch_log.log(ltype, line)
if self._run_log_file is not None:
try:
self._run_log_file.write(line + "\n")
except Exception:
pass
# The on-screen log gets the plain-English translation only; raw lines
# stay off the screen — except untranslated ERRORS, which are shown
# as-is (a failure must never be invisible to the user).
shown = friendly_log_line(line)
if shown is not None:
if shown[0] != self._last_friendly: # collapse repeated polling lines
self._log(shown[0], shown[1])
self._last_friendly = shown[0]
elif ltype == "error" and not _is_error_noise(low):
# An untranslated real error still shows (a failure must never be
# invisible) — but raw traceback/COM noise is filtered out.
self._log(line, "error")
self._last_friendly = None
# advance the progress bar / step list on known markers
for marker, step_idx, pct in STEP_MARKERS:
if marker in low:
if step_idx > self._cur_step:
self._cur_step = step_idx
if pct > self._max_pct:
self._max_pct = pct
self._step(STEPS[self._cur_step], self._max_pct)
break
def _resolve_input_path(self, p):
"""Drag-drop in QWebEngine often hides the real disk path, so we may only
get a bare filename. Resolve it against the known input folders so the
automation always receives a real, absolute path."""
if p and os.path.isabs(p) and os.path.exists(p):
return p
base = os.path.basename(p) if p else ""
for folder in (INPUT_DIR, _ROOT):
cand = os.path.join(folder, base) if base else ""
if cand and os.path.exists(cand):
return cand
return p # unresolved — caller will report a clear error
def _run_multi_subprocess(self, excel_path, emp):
"""Run performer.run_multi in a SEPARATE PROCESS (its own MAIN thread,
no Qt) and stream its stdout back through this run's log pipeline.
Why a subprocess: pywinauto/UIA reads are reliable on a process MAIN
thread but flaky on a Qt BACKGROUND thread (STA-without-a-message-pump
for cross-process UIA; forcing MTA dead-locks against the Qt main
thread's UIA object). The reference tool works because it is a separate
process with no Qt — this reproduces that. Every child stdout line is
re-printed here so it flows through _StreamToBridge → _on_automation_line
exactly as an in-process print did (friendly log, [CPSTATE] tracker,
per-batch DB log, file log). Returns the run_multi summary dict.
"""
import subprocess
import time as _time
proj_root = os.path.dirname(os.path.dirname(os.path.dirname(
os.path.abspath(__file__))))
# The child writes ALL its output to THIS file and we tail it live. A
# --windowed PyInstaller child's stdout PIPE is block-buffered/unreliable
# (v1.1.5: nothing streamed back), but a line-buffered file always
# flushes per line and can be read while the process runs.
try:
os.makedirs(LOGS_DIR, exist_ok=True)
except Exception:
pass
child_log = os.path.join(
LOGS_DIR,
f"auto_child_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
f"_{self.batch_id or 0}.log")
try:
open(child_log, "w", encoding="utf-8").close() # start empty
except Exception:
pass
if getattr(sys, "frozen", False):
cmd = [sys.executable, "--run-automation"]
else:
cmd = [sys.executable, os.path.join(proj_root, "run_app.py"),
"--run-automation"]
cmd += ["--excel", str(excel_path),
"--batch", str(self.batch_id or ""),
"--emp", str(emp or ""),
"--log", child_log,
# Parent-death watchdog: the child exits itself the instant this
# GUI process dies (crash, taskkill, or a closeEvent we couldn't
# run) so automation can never keep driving Tebra orphaned.
"--parent-pid", str(os.getpid())]
env = dict(os.environ)
env["PYTHONUNBUFFERED"] = "1"
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0) \
if sys.platform.startswith("win") else 0
print(f"[Automation] Launching automation in its own process "
f"(reliable UIA) — batch {self.batch_id} …")
try:
proc = subprocess.Popen(
cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
cwd=proj_root, env=env, creationflags=creationflags)
except Exception as e:
print(f"[Automation] Could not launch automation process: {e}")
return {"total": 0, "ok": [],
"failed": [("Automation", f"launch failed: {e}")],
"stopped": False}
self._auto_proc = proc
summary = {}
got_summary = False
def _handle(line):
nonlocal summary, got_summary
if line.startswith("__WF_SUMMARY__"):
try:
summary = json.loads(line[len("__WF_SUMMARY__"):]) or {}
got_summary = True
except Exception:
pass
elif line.strip():
print(line) # → _StreamToBridge tee → _on_automation_line
# Tail the child log until the process exits, then drain the rest.
try:
with open(child_log, "r", encoding="utf-8", errors="replace") as f:
while True:
pos = f.tell()
line = f.readline()
if line and line.endswith("\n"):
_handle(line.rstrip("\r\n"))
elif line: # partial line (no newline yet)
if proc.poll() is not None:
_handle(line.rstrip("\r\n"))
File diff suppressed because it is too large. Load diff