Files
ecw-charge-validation/web/templates/dashboard.html
T
ManikandanandClaude Opus 5.5 120fd6808a Portal look: aligned header and footer, 7-day chart, today's reasons, pass rate
Header, breadcrumb, page and footer share one 1180px column; the header
marks the current page; a footer sits at the bottom of every page.
Dashboard: pass rate on Checked today, a Last 7 days Pass / Fail / Review
chart, today's Fail and Review reasons grouped with counts, and three
equal cards (Claim status, Runs, Find a claim). Motion is movement only
and off under prefers-reduced-motion. Context.md brought up to date.

Snapshot of local 9d5ac63 (tree scanned: no real claim ids, no patient
data).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 13:06:52 +05:30

386 lines
18 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; }
.tile .rate { display: block; margin-top: 4px; font-size: 12px;
color: var(--pass); font-weight: 600; }
/* Two columns, then three equal cards: every row the same edges. */
.split { display: grid; grid-template-columns: 3fr 2fr; gap: 18px;
margin-bottom: 18px; }
.split .card, .actions .card { margin-bottom: 0; }
.card-h { display: flex; justify-content: space-between;
align-items: baseline; margin-bottom: 12px; }
.card-h h2 { margin: 0; }
.week { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 10px; align-items: end; height: 170px; }
a.wd { display: flex; flex-direction: column; align-items: center;
justify-content: flex-end; height: 100%; color: var(--text);
text-decoration: none; }
a.wd .col { display: flex; flex-direction: column; width: 70%;
max-width: 42px; border-radius: 6px 6px 2px 2px;
overflow: hidden; background: var(--head); min-height: 4px;
height: 0; }
a.wd:hover .col { outline: 2px solid var(--accent); outline-offset: 2px; }
a.wd .col i { display: block; }
a.wd .col i.Pass { background: var(--pass); }
a.wd .col i.Fail { background: var(--fail); }
a.wd .col i.Review { background: var(--review-bar); }
a.wd .cnt { font-size: 12px; font-weight: 700; margin-bottom: 4px; }
a.wd .lbl { font-size: 12px; color: var(--muted); margin-top: 6px; }
.legend { display: flex; gap: 14px; margin-top: 10px; font-size: 12px;
color: var(--muted); }
.legend span::before { content: ""; display: inline-block; width: 9px;
height: 9px; border-radius: 2px; margin-right: 5px; }
.legend .Pass::before { background: var(--pass); }
.legend .Fail::before { background: var(--fail); }
.legend .Review::before { background: var(--review-bar); }
ol.reasons { list-style: none; margin: 0; padding: 0; }
ol.reasons li { display: flex; gap: 10px; align-items: baseline;
padding: 7px 0; border-bottom: 1px solid var(--line); }
ol.reasons li:last-child { border-bottom: 0; }
ol.reasons b { min-width: 30px; text-align: center; border-radius: 10px;
padding: 1px 8px; font-size: 12px; }
ol.reasons b.Fail { background: var(--fail-bg); color: var(--fail); }
ol.reasons b.Review { background: var(--review-bg); color: var(--review); }
ol.reasons span { overflow: hidden; text-overflow: ellipsis;
white-space: nowrap; }
.actions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 18px; align-items: stretch; }
a.card.go { display: flex; align-items: center; gap: 14px; color: var(--text); }
a.card.go:hover { text-decoration: none; border-color: var(--accent); }
a.card.go b, form.search > b { display: block; font-size: 15px; }
a.card.go .ic { display: grid; place-items: center; width: 40px; height: 40px;
border-radius: 10px; background: var(--hilite);
color: var(--accent); font-size: 20px; flex: none; }
a.card.go .arrow { margin-left: auto; color: var(--accent); font-size: 18px; }
a.card.go .note { display: block; }
form.search.card { display: block; }
form.search .find { display: flex; gap: 8px; margin-top: 8px; }
form.search .find input { flex: 1; min-width: 0; }
.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, .split { 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>{% if today.claims %}<span class="rate">{{ (100 * today.pass_count / today.claims) | round | int }}% pass</span>{% endif %}</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="split">
<section class="card" id="week">
<div class="card-h"><h2>Last 7 days</h2><a href="/days">All days →</a></div>
{% if week %}
<div class="week">
{% for d in week %}
<a class="wd" href="/day/{{ d.day.isoformat() }}" data-day="{{ d.day.isoformat() }}" title="{{ d.day | longdate }}: {{ d.pass_count }} Pass, {{ d.fail_count }} Fail, {{ d.review_count }} Review">
<span class="cnt">{{ d.claims }}</span>
<span class="col" data-n="{{ d.claims }}"><i class="Review" data-n="{{ d.review_count }}"></i><i class="Fail" data-n="{{ d.fail_count }}"></i><i class="Pass" data-n="{{ d.pass_count }}"></i></span>
<span class="lbl">{{ d.day.strftime("%a %d") }}</span>
</a>
{% endfor %}
</div>
<div class="legend"><span class="Pass">Pass</span><span class="Fail">Fail</span><span class="Review">Review</span></div>
{% else %}
<p class="note">No claims checked in the last 7 days.</p>
{% endif %}
</section>
<section class="card" id="reasons">
<div class="card-h"><h2>Today's Fail and Review reasons</h2>{% if today.claims %}<a href="/day/{{ today.day.isoformat() }}?category=Fail">Open →</a>{% endif %}</div>
{% if reasons %}
<ol class="reasons">
{% for r in reasons %}<li><b class="{{ r.category }}">{{ r.count }}</b><span>{{ r.comment }}</span></li>
{% endfor %}
</ol>
{% else %}
<p class="note">No Fail or Review today.</p>
{% endif %}
</section>
</div>
<div class="actions">
<a class="card go" href="/days" id="claim-status"><span class="ic" aria-hidden="true">▦</span><span><b>Claim status</b><span class="note">Every day's claims, by result</span></span><span class="arrow">→</span></a>
<a class="card go" href="/runs"><span class="ic" aria-hidden="true">◷</span><span><b>Runs</b><span class="note">Logins, timings, errors</span></span><span class="arrow">→</span></a>
<form class="search card" method="get" action="/claim">
<b>Find a claim</b>
<div class="find">
<input type="search" name="claim_no" placeholder="Claim #"
inputmode="numeric" aria-label="Claim number">
<button type="submit">Find</button>
</div>
</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>
(() => {
// Last 7 days: each bar as tall as its day's claims, its parts as its
// counts; today's numbers count up once (skipped when motion is off).
const calm = matchMedia('(prefers-reduced-motion: reduce)').matches;
const cols = [...document.querySelectorAll('.week .col')];
const top = Math.max(1, ...cols.map(c => Number(c.dataset.n) || 0));
cols.forEach(c => c.querySelectorAll('i').forEach(i => {
i.style.flexGrow = Number(i.dataset.n) || 0;
}));
cols.forEach(c => {
// 75% at most: the count above and the day below share the height.
c.style.height = Math.max(3, 75 * (Number(c.dataset.n) || 0) / top) + '%';
});
if (calm) return;
document.querySelectorAll('.tile .n').forEach(el => {
const to = Number(el.textContent) || 0, t0 = performance.now();
if (!to) return;
const step = t => {
const p = Math.min((t - t0) / 700, 1);
el.textContent = Math.round(to * (1 - Math.pow(1 - p, 3)));
if (p < 1) requestAnimationFrame(step);
};
requestAnimationFrame(step);
});
})();
</script>
<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 %}