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>
274 lines
12 KiB
HTML
274 lines
12 KiB
HTML
{% 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 %}
|