Initial import: eCW Charge Validation bot and results Portal
Snapshot of the working project as of 2026-10-23 (local 2d4d4fc), one commit with no earlier history: the tracked files were de-identified first (synthetic claim ids and patient data only; see Context.md section 7). Credentials, run logs, screenshots, claim reports and client workbooks are gitignored and not included. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
ba15a574ab
205 files changed
+56772
No files matched your search
@@ -0,0 +1,273 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Dashboard{% endblock %}
|
||||
{% block style %}
|
||||
.botrun .row { display: flex; gap: 12px; align-items: center;
|
||||
flex-wrap: wrap; }
|
||||
.botrun .row + .row { margin-top: 10px; }
|
||||
.botrun h2 { margin: 0; }
|
||||
#br-message { color: var(--muted); }
|
||||
.botrun button.stop { background: var(--fail); border-color: var(--fail); }
|
||||
.botrun progress { width: 260px; height: 10px; accent-color: var(--accent); }
|
||||
.botrun pre { background: var(--head); padding: 10px; border-radius: 6px;
|
||||
max-height: 260px; overflow: auto; font-size: 12px;
|
||||
white-space: pre-wrap; }
|
||||
.botrun summary { cursor: pointer; color: var(--accent); margin-top: 10px; }
|
||||
.chip { padding: 2px 9px; border-radius: 10px; font-size: 12px;
|
||||
font-weight: 600; }
|
||||
/* `.row { display: flex }` beats the browser's [hidden] rule. */
|
||||
.botrun [hidden] { display: none; }
|
||||
.actions { display: grid; grid-template-columns: 1fr 1fr; gap: 18px;
|
||||
align-items: stretch; }
|
||||
.actions .card { margin-bottom: 0; }
|
||||
a.big { display: flex; align-items: center; justify-content: center;
|
||||
gap: 10px; font-size: 18px; padding: 22px; border-radius: 10px;
|
||||
height: 100%; }
|
||||
.today-h { display: flex; justify-content: space-between; align-items: baseline;
|
||||
margin: 4px 0 10px; }
|
||||
.today-h h2 { font-size: 15px; margin: 0; }
|
||||
#live-summary { color: var(--muted); margin: 0 0 10px; }
|
||||
#live-list { list-style: none; margin: 0; padding: 0; }
|
||||
#live-list li { display: flex; gap: 10px; align-items: center;
|
||||
padding: 7px 8px; border-bottom: 1px solid var(--line); }
|
||||
#live-list li.opens { cursor: pointer; }
|
||||
#live-list li.opens:hover { background: var(--hilite); }
|
||||
#live-list .n { color: var(--muted); width: 34px; }
|
||||
#live-list .id { font-weight: 600; width: 100px; }
|
||||
#live-list .st { margin-left: auto; }
|
||||
#live-list li.st-checking { background: var(--hilite); }
|
||||
#live-list li.st-checking .st { color: var(--accent); font-weight: 600; }
|
||||
#live-list li.st-idle .st { color: var(--muted); }
|
||||
@media (max-width: 760px) { .actions { grid-template-columns: 1fr; } }
|
||||
/* The last Run ended badly (EEWA-33). */
|
||||
.alert { background: var(--fail-bg); color: var(--fail);
|
||||
border: 1px solid #f5c2bd; border-left: 5px solid var(--fail);
|
||||
border-radius: 10px; padding: 12px 16px; margin-bottom: 18px; }
|
||||
.alert a { color: var(--fail); font-weight: 600; text-decoration: underline; }
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
{% if alert %}
|
||||
<div class="alert" id="last-run-alert" role="alert">
|
||||
<b>The last run {{ "ended in a Run Error" if alert.status == "Run Error" else "ended without a record (killed or crashed)" }}</b>
|
||||
— {{ alert.started | usdate }}, started by {{ alert.started_by or "?" }}{% if alert.error_step %} · {{ alert.error_step }}{% if alert.exit_code is not none %} (exit {{ alert.exit_code }}){% endif %}: {{ alert.error_message }}{% endif %}{% if alert.not_reached %} · {{ alert.not_reached | length }} claim(s) not reached{% endif %}.
|
||||
<a href="/runs/{{ alert.run_id }}">See the run</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
<section class="card botrun" id="botrun">
|
||||
<div class="row">
|
||||
<h2>Bot run</h2>
|
||||
<span id="br-message">…</span>
|
||||
<button type="button" id="br-start"{% if not can_run %} hidden{% endif %}>▶ Start run</button>
|
||||
<button type="button" id="br-stop" class="stop" hidden>Stop</button>
|
||||
<span id="br-terminal" hidden>Run started in the terminal — stop it
|
||||
there</span>
|
||||
<button type="button" class="plain" id="live-open">Live claims</button>
|
||||
</div>
|
||||
<p class="note" id="br-verify">VERIFY mode — nothing is saved to eCW.
|
||||
CAPTCHA and 2FA boxes still come up on this PC when the login needs a
|
||||
person.</p>
|
||||
<div class="row" id="br-progress" hidden>
|
||||
<progress id="br-bar" max="1" value="0"></progress>
|
||||
<span id="br-count"></span>
|
||||
<span id="br-current"></span>
|
||||
<span class="chip cat Pass" id="br-pass"></span>
|
||||
<span class="chip cat Fail" id="br-fail"></span>
|
||||
<span class="chip cat Review" id="br-review"></span>
|
||||
</div>
|
||||
<details id="br-logbox" hidden><summary>Live log</summary>
|
||||
<pre id="br-log"></pre></details>
|
||||
</section>
|
||||
|
||||
<div class="today-h">
|
||||
<h2>Today · {{ today.day | longdate }}</h2>
|
||||
{% if today.claims %}<a href="/day/{{ today.day.isoformat() }}">Open today's claims →</a>{% endif %}
|
||||
</div>
|
||||
<div class="tiles">
|
||||
<a class="tile" href="/day/{{ today.day.isoformat() }}"><span class="n">{{ today.claims }}</span><span class="l">Checked today</span></a>
|
||||
<a class="tile Pass" href="/day/{{ today.day.isoformat() }}?category=Pass"><span class="n">{{ today.pass_count }}</span><span class="l">Pass</span></a>
|
||||
<a class="tile Fail" href="/day/{{ today.day.isoformat() }}?category=Fail"><span class="n">{{ today.fail_count }}</span><span class="l">Fail</span></a>
|
||||
<a class="tile Review" href="/day/{{ today.day.isoformat() }}?category=Review"><span class="n">{{ today.review_count }}</span><span class="l">Review</span></a>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<a class="btn big" href="/days" id="claim-status">Claim status →</a>
|
||||
<form class="search card" method="get" action="/claim">
|
||||
<strong>Find a claim</strong>
|
||||
<input type="search" name="claim_no" placeholder="Claim #"
|
||||
inputmode="numeric" aria-label="Claim number">
|
||||
<button type="submit">Find</button>
|
||||
</form>
|
||||
</div>
|
||||
{% if not has_runs %}
|
||||
<p class="note">No results saved yet. Run the bot with <code>RESULTS_DB=1</code>
|
||||
in <code>.env</code>; each day's claims appear under Claim status.</p>
|
||||
{% endif %}
|
||||
|
||||
<aside class="drawer" id="live-panel" hidden>
|
||||
<div class="top"><h2>Live claims</h2>
|
||||
<button type="button" class="close">Close</button></div>
|
||||
<div class="body">
|
||||
<p id="live-summary">…</p>
|
||||
<ol id="live-list"></ol>
|
||||
</div>
|
||||
</aside>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
(() => {
|
||||
const token = document.querySelector('meta[name="run-token"]').content;
|
||||
// A Viewer sees the run, but gets no Start / Stop (the server refuses
|
||||
// them too).
|
||||
const CAN_RUN = {{ can_run | tojson }};
|
||||
let wasRunning = null;
|
||||
// One poll loop: a poll started later (after a Start) supersedes any
|
||||
// earlier one still waiting on its fetch or its timer.
|
||||
let timer = null, pollGen = 0;
|
||||
const mins = s => s == null ? '' :
|
||||
` in ${Math.floor(s / 60)}m ${s % 60}s`;
|
||||
|
||||
// Shown when the Portal server itself is gone (run_web.bat closed): the
|
||||
// fetch throws, and a Start used to do nothing at all (2026-10-22).
|
||||
const UNREACHABLE = 'The Portal is not reachable — is run_web.bat still ' +
|
||||
'running? Start it again, then reload this page.';
|
||||
|
||||
async function post(path) {
|
||||
let r;
|
||||
try {
|
||||
r = await fetch(path, {method: 'POST',
|
||||
headers: {'X-Run-Token': token}});
|
||||
} catch (e) {
|
||||
alert(UNREACHABLE);
|
||||
return false;
|
||||
}
|
||||
let body = {};
|
||||
try { body = await r.json(); } catch (e) {}
|
||||
if (!r.ok) alert(body.detail || ('Failed: HTTP ' + r.status));
|
||||
return r.ok;
|
||||
}
|
||||
|
||||
// Page text from the log goes in with textContent only, never innerHTML.
|
||||
function show(s) {
|
||||
// A terminal run may be LIVE-WRITE: the page neither stops it nor
|
||||
// calls it VERIFY.
|
||||
const terminal = s.running && s.source !== 'web';
|
||||
$('br-start').hidden = !CAN_RUN || s.running;
|
||||
$('br-stop').hidden = !CAN_RUN || !s.running || terminal;
|
||||
$('br-terminal').hidden = !terminal;
|
||||
$('br-verify').hidden = terminal;
|
||||
const ended = s.started_at || s.exit_code != null ||
|
||||
s.stopped_by_operator;
|
||||
const who = s.started_by ? ` · started by ${s.started_by}` : '';
|
||||
$('br-message').textContent = s.running ? s.message + who
|
||||
: (ended ? s.message + mins(s.elapsed_s) +
|
||||
(s.exit_code != null ? ` — exit ${s.exit_code}` : '') + who
|
||||
: 'No run yet');
|
||||
const p = s.progress;
|
||||
$('br-progress').hidden = !p;
|
||||
if (p) {
|
||||
$('br-bar').max = p[1]; $('br-bar').value = p[0];
|
||||
$('br-count').textContent = `${p[0]} / ${p[1]}`;
|
||||
$('br-current').textContent = s.running && s.current
|
||||
? `claim ${s.current}` : '';
|
||||
$('br-pass').textContent = `Pass ${s.counts.Pass || 0}`;
|
||||
$('br-fail').textContent = `Fail ${s.counts.Fail || 0}`;
|
||||
$('br-review').textContent = `Review ${s.counts.Review || 0}`;
|
||||
}
|
||||
$('br-logbox').hidden = !(s.tail && s.tail.length);
|
||||
$('br-log').textContent = (s.tail || []).join('\n');
|
||||
drawClaims(s);
|
||||
}
|
||||
|
||||
// ── every claim of the running (or last) run, from the status poll ──────
|
||||
const STATUS_CLASS = {'Checking': 'st-checking', 'Waiting': 'st-idle',
|
||||
'Not checked': 'st-idle', 'Not finished': 'st-idle',
|
||||
'Not found': 'st-idle'};
|
||||
let lastStatus = null;
|
||||
function drawClaims(s) {
|
||||
lastStatus = s;
|
||||
const cs = s.claims || [];
|
||||
const done = cs.filter(c => c.done).length;
|
||||
const waiting = cs.filter(c => c.status === 'Waiting').length;
|
||||
const checking = cs.find(c => c.status === 'Checking');
|
||||
let summary = s.running
|
||||
? `Checking ${done + (checking ? 1 : 0)} of ${cs.length} · ` +
|
||||
`${done} done · ${waiting} waiting`
|
||||
: `${cs.length} claims · ${done} done` +
|
||||
(done < cs.length ? ` · ${cs.length - done} not finished` : '');
|
||||
// A claim opens on the day its run is saved under — none when the run
|
||||
// saves no results.
|
||||
const day = s.results_run_at ? s.results_run_at.slice(0, 10) : null;
|
||||
summary += day ? ` · saved under ${day}`
|
||||
: ' · not saved to the results table';
|
||||
$('live-summary').textContent = cs.length ? summary
|
||||
: (s.running ? 'Starting — the claims appear once the bot reaches them'
|
||||
: 'No claims in the last run');
|
||||
const list = $('live-list');
|
||||
list.replaceChildren();
|
||||
for (const c of cs) {
|
||||
const li = document.createElement('li');
|
||||
li.className = STATUS_CLASS[c.status] || '';
|
||||
for (const [cls, text] of [['n', '#' + c.n], ['id', c.id]]) {
|
||||
const span = document.createElement('span');
|
||||
span.className = cls; span.textContent = text; li.append(span);
|
||||
}
|
||||
// A result has a saved row to open; Not found (done too) has none.
|
||||
const result = c.done && c.status !== 'Not found';
|
||||
const st = document.createElement('span');
|
||||
st.className = 'st' + (result && CATEGORIES.includes(c.status)
|
||||
? ' cat ' + c.status : '');
|
||||
st.textContent = c.status === 'Checking' ? 'Checking…' : c.status;
|
||||
li.append(st);
|
||||
if (result && day) {
|
||||
li.classList.add('opens');
|
||||
li.onclick = () => {
|
||||
location.href = '/day/' + day + '?claim=' +
|
||||
encodeURIComponent(c.id);
|
||||
};
|
||||
}
|
||||
list.append(li);
|
||||
}
|
||||
}
|
||||
$('live-open').onclick = () => {
|
||||
if (lastStatus) drawClaims(lastStatus);
|
||||
openPanel('live-panel');
|
||||
};
|
||||
|
||||
async function poll() {
|
||||
clearTimeout(timer);
|
||||
const gen = ++pollGen;
|
||||
let s = null, failed = false;
|
||||
try {
|
||||
const r = await fetch('/api/bot/status');
|
||||
s = await r.json();
|
||||
} catch (e) {
|
||||
failed = true;
|
||||
}
|
||||
if (gen !== pollGen) return; // a newer poll took over
|
||||
if (failed) $('br-message').textContent = UNREACHABLE;
|
||||
else show(s);
|
||||
// A run just ended: today's counts have changed.
|
||||
if (s && wasRunning && !s.running) { location.reload(); return; }
|
||||
if (s) wasRunning = s.running;
|
||||
timer = setTimeout(poll, s && s.running ? 2000 : 10000);
|
||||
}
|
||||
|
||||
$('br-start').onclick = async () => {
|
||||
if (!confirm('Start a VERIFY run now? The bot logs in to eCW ' +
|
||||
'(2FA approval mail) and checks the claims.')) return;
|
||||
$('br-start').disabled = true;
|
||||
let ok = false;
|
||||
try { ok = await post('/api/bot/start'); }
|
||||
finally { $('br-start').disabled = false; }
|
||||
if (ok) { wasRunning = true; poll(); }
|
||||
};
|
||||
$('br-stop').onclick = async () => {
|
||||
if (!confirm('Stop the run? The claim it is on is left as it is ' +
|
||||
'(nothing is saved in VERIFY mode).')) return;
|
||||
$('br-stop').disabled = true;
|
||||
try { await post('/api/bot/stop'); }
|
||||
finally { $('br-stop').disabled = false; }
|
||||
};
|
||||
poll();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in new issue
Block a user