Snapshot of D:\Ecw Process a11c9ff (2026-10-30). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
416 lines
19 KiB
HTML
416 lines
19 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}Dashboard{% endblock %}
|
|
{% block style %}
|
|
/* The approved mockup (operator, 2026-10-30): greeting and claim search
|
|
on top, today's tiles, the lime Agent run card beside results by day,
|
|
then today's reasons beside today's runs. */
|
|
header.hello { display: flex; flex-wrap: wrap; gap: 14px;
|
|
align-items: center; justify-content: space-between;
|
|
margin-bottom: 18px; }
|
|
header.hello h1 { font-size: 34px; }
|
|
header.hello .sub { margin: 0; font-size: 15px; }
|
|
form.find { display: flex; gap: 8px; }
|
|
form.find input { width: 240px; height: 44px; background: var(--panel); }
|
|
form.find button { height: 44px; }
|
|
.tiles .tile .head { display: flex; justify-content: space-between;
|
|
align-items: center; }
|
|
.tile .pct { float: right; font-size: 12px; font-weight: 700;
|
|
padding: 2px 9px; border-radius: 999px; margin-top: 2px; }
|
|
.tile.Pass .pct { background: var(--pass-bg); color: var(--pass); }
|
|
.tile.Fail .pct { background: var(--fail-bg); color: var(--fail); }
|
|
.tile.Review .pct { background: var(--review-bg); color: var(--review); }
|
|
.tile .rate { display: block; margin-top: 6px; font-size: 12px;
|
|
color: var(--pass); font-weight: 600; }
|
|
.row2 { display: flex; flex-wrap: wrap; gap: 18px; margin-bottom: 18px; }
|
|
.row2 > .card { margin-bottom: 0; flex: 1 1 340px; min-width: 0; }
|
|
.row2 > #week, .row2 > #runs-today { flex: 2 1 420px; }
|
|
.card-h { display: flex; justify-content: space-between;
|
|
align-items: baseline; margin-bottom: 14px; gap: 10px; }
|
|
.card-h h2 { margin: 0; }
|
|
|
|
/* Agent run: the lime card. */
|
|
.botrun { background: var(--lime); color: var(--text); }
|
|
.botrun .row { display: flex; gap: 12px; align-items: center;
|
|
flex-wrap: wrap; }
|
|
.botrun .row + .row { margin-top: 12px; }
|
|
.botrun h2 { margin: 0; }
|
|
#br-message { color: var(--navy); font-weight: 600; }
|
|
.botrun button { background: var(--text); border-color: var(--text); }
|
|
.botrun button:hover { background: #000; }
|
|
.botrun button.plain { background: rgba(255, 255, 255, .6);
|
|
border-color: transparent; color: var(--text); }
|
|
.botrun button.stop { background: var(--fail-bar); border-color: var(--fail-bar); }
|
|
.botrun .note { color: var(--navy); }
|
|
.botrun progress { width: 220px; height: 10px; accent-color: var(--navy); }
|
|
.botrun pre { background: rgba(255, 255, 255, .6); padding: 10px;
|
|
border-radius: 12px; max-height: 260px; overflow: auto;
|
|
font-size: 12px; white-space: pre-wrap; }
|
|
.botrun summary { cursor: pointer; color: var(--navy); margin-top: 10px;
|
|
font-weight: 600; }
|
|
.chip { padding: 2px 9px; border-radius: 999px; font-size: 12px;
|
|
font-weight: 600; }
|
|
/* `.row { display: flex }` beats the browser's [hidden] rule. */
|
|
.botrun [hidden] { display: none; }
|
|
|
|
/* Results by day. */
|
|
.week { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr));
|
|
gap: 12px; align-items: end; height: 190px; }
|
|
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: 44px; border-radius: 8px; 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-bar); }
|
|
a.wd .col i.Fail { background: var(--fail-bar); }
|
|
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; font-size: 13px; color: var(--muted); }
|
|
.legend span::before { content: ""; display: inline-block; width: 10px;
|
|
height: 10px; border-radius: 3px; margin-right: 6px; }
|
|
.legend .Pass::before { background: var(--pass-bar); }
|
|
.legend .Fail::before { background: var(--fail-bar); }
|
|
.legend .Review::before { background: var(--review-bar); }
|
|
|
|
/* Today's reasons and runs. */
|
|
ol.reasons, ol.runs { list-style: none; margin: 0; padding: 0; }
|
|
ol.reasons li, ol.runs li { display: flex; gap: 12px; align-items: center;
|
|
padding: 9px 0; border-bottom: 1px solid var(--line); }
|
|
ol.reasons li:last-child, ol.runs li:last-child { border-bottom: 0; }
|
|
ol.reasons b { min-width: 34px; text-align: center; border-radius: 999px;
|
|
padding: 2px 9px; 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; }
|
|
ol.runs .t { font-weight: 600; width: 72px; }
|
|
ol.runs .m { font-size: 11px; font-weight: 700; padding: 2px 9px;
|
|
border-radius: 999px; background: var(--head); color: var(--navy); }
|
|
ol.runs .c { margin-left: auto; color: var(--muted); }
|
|
ol.runs .c b { color: var(--text); }
|
|
ol.runs .cat { min-width: 40px; text-align: center; }
|
|
|
|
#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) {
|
|
form.find, form.find input { width: 100%; }
|
|
}
|
|
/* The last Run ended badly (EEWA-33). */
|
|
.alert { background: var(--fail-bg); color: var(--fail);
|
|
border-radius: 16px; padding: 12px 16px; margin-bottom: 18px; }
|
|
.alert a { color: var(--fail); font-weight: 600; text-decoration: underline; }
|
|
{% endblock %}
|
|
{% block content %}
|
|
<header class="hello">
|
|
<div>
|
|
<h1>{{ greeting }}, {{ (user.display_name or "").split(" ")[0] }}</h1>
|
|
<p class="sub">{{ today.day | longdate }} · Medcor Ready queue</p>
|
|
</div>
|
|
<form class="find" method="get" action="/claim">
|
|
<input type="search" name="claim_no" placeholder="Search claim number"
|
|
inputmode="numeric" aria-label="Claim number">
|
|
<button type="submit">Find</button>
|
|
</form>
|
|
</header>
|
|
{% 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 %}
|
|
<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>{% if today.claims %}<span class="pct">{{ (100 * today.pass_count / today.claims) | round | int }}%</span>{% endif %}</a>
|
|
<a class="tile Fail" href="/day/{{ today.day.isoformat() }}?category=Fail"><span class="n">{{ today.fail_count }}</span><span class="l">Fail</span>{% if today.claims %}<span class="pct">{{ (100 * today.fail_count / today.claims) | round | int }}%</span>{% endif %}</a>
|
|
<a class="tile Review" href="/day/{{ today.day.isoformat() }}?category=Review"><span class="n">{{ today.review_count }}</span><span class="l">Review</span>{% if today.claims %}<span class="pct">{{ (100 * today.review_count / today.claims) | round | int }}%</span>{% endif %}</a>
|
|
</div>
|
|
|
|
<div class="row2">
|
|
<section class="card botrun" id="botrun">
|
|
<div class="row">
|
|
<h2>Agent run</h2>
|
|
<span id="br-message">…</span>
|
|
</div>
|
|
<div class="row">
|
|
<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>
|
|
|
|
<section class="card" id="week">
|
|
<div class="card-h"><h2>Results by day</h2>
|
|
<div class="legend"><span class="Pass">Pass</span><span class="Fail">Fail</span><span class="Review">Review</span></div>
|
|
<a href="/days" id="claim-status">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>
|
|
{% else %}
|
|
<p class="note">No claims checked in the last 7 days.</p>
|
|
{% endif %}
|
|
</section>
|
|
</div>
|
|
|
|
<div class="row2">
|
|
<section class="card" id="reasons">
|
|
<div class="card-h"><h2>Top reasons today</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>
|
|
|
|
<section class="card" id="runs-today">
|
|
<div class="card-h"><h2>Runs today</h2><a href="/runs">All runs →</a></div>
|
|
{% if runs_today %}
|
|
<ol class="runs">
|
|
{% for r in runs_today %}<li><span class="t">{{ r.run_at | clock }}</span><span class="m">{{ r.run_mode }}</span><span class="c"><b>{{ r.claims }}</b> claims</span><span class="cat Pass">{{ r.Pass }}</span><span class="cat Fail">{{ r.Fail }}</span><span class="cat Review">{{ r.Review }}</span></li>
|
|
{% endfor %}
|
|
</ol>
|
|
{% else %}
|
|
<p class="note">No runs today yet.</p>
|
|
{% endif %}
|
|
</section>
|
|
</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 %}
|