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,83 @@
|
||||
{# One claim's side panel — for the day page and a claim's history. A row
|
||||
carries its data in data-fields (tojson: escaped for the attribute) and
|
||||
data-details; the panel writes them with textContent only. #}
|
||||
<aside class="drawer" id="claim-panel" hidden>
|
||||
<div class="top"><h2 id="cp-title"></h2>
|
||||
<button type="button" class="close">Close</button></div>
|
||||
<div class="body">
|
||||
<p><span id="cp-cat"></span> <span class="note" id="cp-when"></span></p>
|
||||
<dl id="cp-fields"></dl>
|
||||
<div id="cp-details-box" hidden><h3>Conditions and changes</h3>
|
||||
<pre id="cp-details"></pre></div>
|
||||
</div>
|
||||
</aside>
|
||||
<script>
|
||||
function showClaim(tr) {
|
||||
document.querySelectorAll('tr.sel').forEach(t => t.classList.remove('sel'));
|
||||
tr.classList.add('sel');
|
||||
$('cp-title').textContent = 'Claim ' + tr.dataset.claim;
|
||||
const cat = tr.dataset.category || '';
|
||||
$('cp-cat').className = CATEGORIES.includes(cat) ? 'cat ' + cat : '';
|
||||
$('cp-cat').textContent = cat;
|
||||
$('cp-when').textContent = tr.dataset.when || '';
|
||||
const dl = $('cp-fields');
|
||||
dl.replaceChildren();
|
||||
// Only the fields that hold something: an empty field says nothing.
|
||||
for (const [header, value] of JSON.parse(tr.dataset.fields || '[]')) {
|
||||
if (!value) continue;
|
||||
const dt = document.createElement('dt');
|
||||
dt.textContent = header;
|
||||
const dd = document.createElement('dd');
|
||||
dd.textContent = value;
|
||||
dl.append(dt, dd);
|
||||
}
|
||||
const det = tr.dataset.details || '';
|
||||
$('cp-details').textContent = det;
|
||||
$('cp-details-box').hidden = !det;
|
||||
openPanel('claim-panel');
|
||||
}
|
||||
|
||||
// 10 claims a page (operator, 2026-10-15), pagers above and below.
|
||||
const PAGE_SIZE = 10;
|
||||
const allRows = [...document.querySelectorAll('tbody tr[data-claim]')];
|
||||
allRows.forEach(tr => tr.addEventListener('click', () => showClaim(tr)));
|
||||
let page = 0;
|
||||
const pageCount = () => Math.max(1, Math.ceil(allRows.length / PAGE_SIZE));
|
||||
function goToPage(n) {
|
||||
page = Math.min(Math.max(n, 0), pageCount() - 1);
|
||||
allRows.forEach((tr, i) =>
|
||||
tr.hidden = Math.floor(i / PAGE_SIZE) !== page);
|
||||
drawPagers();
|
||||
}
|
||||
function drawPagers() {
|
||||
const total = allRows.length;
|
||||
for (const id of ['pager-top', 'pager-bottom']) {
|
||||
const box = $(id);
|
||||
if (!box) continue;
|
||||
box.replaceChildren();
|
||||
if (pageCount() < 2) continue;
|
||||
const btn = (text, to, cur) => {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button'; b.textContent = text;
|
||||
if (cur) b.className = 'cur';
|
||||
b.disabled = cur || to < 0 || to >= pageCount();
|
||||
b.onclick = () => {
|
||||
goToPage(to);
|
||||
// From the bottom pager the new page starts above the window.
|
||||
if (id === 'pager-bottom')
|
||||
$('pager-top').scrollIntoView({block: 'nearest'});
|
||||
};
|
||||
box.append(b);
|
||||
};
|
||||
btn('◀ Prev', page - 1, false);
|
||||
for (let i = 0; i < pageCount(); i++) btn(String(i + 1), i, i === page);
|
||||
btn('Next ▶', page + 1, false);
|
||||
const info = document.createElement('span');
|
||||
info.className = 'info';
|
||||
info.textContent = `Showing ${page * PAGE_SIZE + 1}–` +
|
||||
`${Math.min((page + 1) * PAGE_SIZE, total)} of ${total}`;
|
||||
box.append(info);
|
||||
}
|
||||
}
|
||||
goToPage(0);
|
||||
</script>
|
||||
@@ -0,0 +1,192 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="run-token" content="{{ run_token }}">
|
||||
<title>{% block title %}Charge Validation{% endblock %} · eCW</title>
|
||||
{% block head %}{% endblock %}
|
||||
<style>
|
||||
/* Navy & blue (operator, 2026-10-19): navy header, white cards on light
|
||||
grey, blue buttons; Pass green, Fail red, Review amber everywhere. */
|
||||
:root {
|
||||
--navy: #12355b; --navy-2: #0f2a47; --bg: #f4f6fa; --panel: #ffffff;
|
||||
--text: #1f2a37; --muted: #6b7785; --line: #dde3ec; --head: #e8edf4;
|
||||
--accent: #1565c0; --accent-dark: #0d4f9e;
|
||||
--pass: #2e7d32; --pass-bg: #e8f5e9;
|
||||
--fail: #c62828; --fail-bg: #fdecea;
|
||||
--review: #b45309; --review-bg: #fff3e0; --review-bar: #ef6c00;
|
||||
--stripe: #f9fafc; --hilite: #eef4ff; --sel: #dbe7fb;
|
||||
--shadow: 0 1px 2px rgba(18, 53, 91, .06), 0 2px 8px rgba(18, 53, 91, .05);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: var(--bg); color: var(--text);
|
||||
font: 14px/1.45 "Segoe UI", system-ui, sans-serif; }
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
header.top { background: var(--navy); color: #fff; padding: 12px 24px;
|
||||
display: flex; align-items: center; gap: 14px; }
|
||||
header.top .brand { color: #fff; font-size: 17px; font-weight: 600;
|
||||
letter-spacing: .2px; }
|
||||
header.top .brand:hover { text-decoration: none; }
|
||||
.pill { border-radius: 12px; padding: 2px 10px; font-size: 11px;
|
||||
font-weight: 700; letter-spacing: .4px; background: var(--pass);
|
||||
color: #fff; }
|
||||
.pill.LIVE-WRITE { background: var(--fail); }
|
||||
header.top nav.links { margin-left: auto; display: flex; gap: 18px;
|
||||
align-items: center; }
|
||||
header.top nav.links a { color: #e3ecf7; font-weight: 600; }
|
||||
header.top .who { color: #b9c6d6; font-size: 13px; }
|
||||
header.top .who b { color: #fff; }
|
||||
header.top form.out { margin: 0; }
|
||||
header.top form.out button { background: transparent; border-color: #6f86a3;
|
||||
font-weight: 400; padding: 4px 10px; }
|
||||
.msg { border-radius: 8px; padding: 10px 14px; margin-bottom: 14px; }
|
||||
.msg.bad { background: var(--fail-bg); color: var(--fail); }
|
||||
.msg.good { background: var(--pass-bg); color: var(--pass); }
|
||||
nav.crumbs { background: var(--navy-2); color: #b9c6d6; padding: 7px 24px;
|
||||
font-size: 13px; }
|
||||
nav.crumbs a { color: #e3ecf7; }
|
||||
nav.crumbs .sep { margin: 0 8px; color: #7d8ea3; }
|
||||
nav.crumbs .here { color: #fff; font-weight: 600; }
|
||||
main { max-width: 1180px; margin: 0 auto; padding: 22px 24px 40px; }
|
||||
h1 { font-size: 20px; margin: 0 0 4px; }
|
||||
.sub { color: var(--muted); margin: 0 0 16px; }
|
||||
.card { background: var(--panel); border: 1px solid var(--line);
|
||||
border-radius: 10px; padding: 16px 18px; box-shadow: var(--shadow);
|
||||
margin-bottom: 18px; }
|
||||
.card h2 { font-size: 15px; margin: 0 0 10px; }
|
||||
.tiles { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px; margin-bottom: 18px; }
|
||||
.tile { background: var(--panel); border: 1px solid var(--line);
|
||||
border-radius: 10px; padding: 12px 16px; box-shadow: var(--shadow);
|
||||
color: var(--text); display: block; }
|
||||
.tile .n { font-size: 26px; font-weight: 700; display: block; }
|
||||
.tile .l { color: var(--muted); font-size: 12px; text-transform: uppercase;
|
||||
letter-spacing: .5px; }
|
||||
.tile.Pass .n { color: var(--pass); }
|
||||
.tile.Fail .n { color: var(--fail); }
|
||||
.tile.Review .n { color: var(--review); }
|
||||
a.tile:hover { text-decoration: none; border-color: var(--accent); }
|
||||
a.tile.on { border: 2px solid var(--accent); padding: 11px 15px; }
|
||||
select, input, button { font: inherit; padding: 7px 10px;
|
||||
border: 1px solid #c9d2de; border-radius: 6px;
|
||||
background: var(--panel); color: var(--text); }
|
||||
button, .btn { background: var(--accent); color: #fff;
|
||||
border: 1px solid var(--accent); border-radius: 6px;
|
||||
padding: 7px 14px; cursor: pointer; font-weight: 600;
|
||||
display: inline-block; }
|
||||
button:hover, .btn:hover { background: var(--accent-dark);
|
||||
text-decoration: none; }
|
||||
button.plain { background: var(--panel); color: var(--text);
|
||||
border-color: #c9d2de; font-weight: 400; }
|
||||
button.plain:hover { background: var(--hilite); }
|
||||
button[disabled] { opacity: .5; cursor: default; }
|
||||
.cat { display: inline-block; padding: 2px 9px; border-radius: 10px;
|
||||
font-weight: 600; font-size: 12px; }
|
||||
.cat.Pass { background: var(--pass-bg); color: var(--pass); }
|
||||
.cat.Fail { background: var(--fail-bg); color: var(--fail); }
|
||||
.cat.Review { background: var(--review-bg); color: var(--review); }
|
||||
.empty { background: var(--panel); border: 1px dashed #c9d2de;
|
||||
border-radius: 10px; padding: 26px; color: var(--muted);
|
||||
text-align: center; }
|
||||
.note { color: var(--muted); font-size: 12px; }
|
||||
footer.note { max-width: 1180px; margin: 0 auto; padding: 0 24px 24px; }
|
||||
/* Tables: short, no sideways scroll; long text cut on one line. */
|
||||
table.list { width: 100%; border-collapse: separate; border-spacing: 0;
|
||||
background: var(--panel); border: 1px solid var(--line);
|
||||
border-radius: 10px; overflow: hidden; table-layout: fixed; }
|
||||
table.list th, table.list td { padding: 9px 12px; text-align: left;
|
||||
border-bottom: 1px solid var(--line);
|
||||
overflow: hidden; text-overflow: ellipsis;
|
||||
white-space: nowrap; }
|
||||
table.list th { background: var(--head); font-size: 12px; color: #3b4757;
|
||||
font-weight: 600; text-transform: uppercase;
|
||||
letter-spacing: .4px; }
|
||||
table.list tbody tr { cursor: pointer; }
|
||||
table.list tbody tr:nth-child(even) td { background: var(--stripe); }
|
||||
table.list tbody tr:hover td { background: var(--hilite); }
|
||||
table.list tbody tr.sel td { background: var(--sel); }
|
||||
table.list tbody tr:last-child td { border-bottom: 0; }
|
||||
td.claim { font-weight: 600; }
|
||||
.pager { display: flex; gap: 6px; align-items: center; flex-wrap: wrap;
|
||||
margin: 12px 0; }
|
||||
.pager button { padding: 4px 10px; background: var(--panel);
|
||||
color: var(--text); border-color: #c9d2de; font-weight: 400; }
|
||||
.pager button.cur { background: var(--accent); color: #fff;
|
||||
border-color: var(--accent); }
|
||||
.pager button[disabled]:not(.cur) { opacity: .4; }
|
||||
.pager .info { color: var(--muted); margin-left: 8px; }
|
||||
form.search { display: flex; gap: 8px; align-items: center;
|
||||
flex-wrap: wrap; }
|
||||
form.search input { min-width: 220px; }
|
||||
/* Side panels. The page under an open panel does not scroll: one scroll
|
||||
bar, the panel's (operator, 2026-10-15; review 2026-10-19). */
|
||||
body.drawer-open { overflow: hidden; }
|
||||
.drawer { position: fixed; top: 0; right: 0; height: 100vh;
|
||||
width: min(480px, 100vw); background: var(--panel);
|
||||
border-left: 1px solid var(--line); z-index: 20;
|
||||
box-shadow: -8px 0 24px rgba(18, 53, 91, .12);
|
||||
padding: 0 0 30px; overflow-y: auto; }
|
||||
.drawer[hidden] { display: none; }
|
||||
.drawer .top { display: flex; align-items: center; gap: 10px;
|
||||
background: var(--navy); color: #fff; padding: 12px 18px;
|
||||
position: sticky; top: 0; }
|
||||
.drawer .top h2 { font-size: 16px; margin: 0; }
|
||||
.drawer .top .close { margin-left: auto; background: transparent;
|
||||
border-color: #6f86a3; font-weight: 400; }
|
||||
.drawer .body { padding: 14px 18px; }
|
||||
.drawer dl { display: grid; grid-template-columns: 140px 1fr;
|
||||
gap: 6px 12px; margin: 0; font-size: 13px; }
|
||||
.drawer dt { color: var(--muted); }
|
||||
.drawer dd { margin: 0; white-space: pre-wrap; word-break: break-word; }
|
||||
.drawer h3 { font-size: 14px; margin: 16px 0 6px; }
|
||||
.drawer pre { background: var(--head); padding: 10px; border-radius: 6px;
|
||||
white-space: pre-wrap; font-size: 12px; margin: 0; }
|
||||
@media (max-width: 760px) {
|
||||
.tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
main { padding: 16px; }
|
||||
header.top, nav.crumbs { padding-left: 16px; padding-right: 16px; }
|
||||
}
|
||||
{% block style %}{% endblock %}
|
||||
</style>
|
||||
<script>
|
||||
// Shared by every page, defined before any page script runs: one side
|
||||
// panel open at a time; Escape and every Close button shut it.
|
||||
const $ = id => document.getElementById(id);
|
||||
const CATEGORIES = {{ categories | list | tojson }};
|
||||
function openPanel(id) {
|
||||
document.querySelectorAll('.drawer').forEach(p => p.hidden = p.id !== id);
|
||||
document.body.classList.toggle('drawer-open', id !== null);
|
||||
}
|
||||
const closePanels = () => openPanel(null);
|
||||
document.addEventListener('click', e => {
|
||||
if (e.target.closest('.drawer .close')) closePanels();
|
||||
});
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape') closePanels();
|
||||
});
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="top">
|
||||
<a class="brand" href="/">eCW Charge Validation</a>
|
||||
<span class="pill {{ mode }}" title="SAVE_CORRECTIONS mode">{{ mode }}</span>
|
||||
{% if user %}<nav class="links">
|
||||
<a href="/days">Claim status</a><a href="/runs">Runs</a>
|
||||
{% if is_admin %}<a href="/admin/users">Users</a><a href="/admin/signins">Sign-ins</a>{% endif %}
|
||||
<a class="who" href="/account/password" title="Change password"><b>{{ user.display_name }}</b> · {{ user.role }}</a>
|
||||
<form class="out" method="post" action="/signout"><input type="hidden" name="token" value="{{ run_token }}"><button type="submit">Sign out</button></form>
|
||||
</nav>{% endif %}
|
||||
</header>
|
||||
<nav class="crumbs">
|
||||
{% for label, link in crumbs %}{% if not loop.first %}<span class="sep">›</span>{% endif %}{% if link %}<a href="{{ link }}">{{ label }}</a>{% else %}<span class="here">{{ label }}</span>{% endif %}{% endfor %}
|
||||
</nav>
|
||||
<main>
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
<footer class="note">Local page (127.0.0.1) — contains patient data; do not
|
||||
share screenshots outside the team.</footer>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,54 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Find a claim{% endblock %}
|
||||
{% block style %}
|
||||
table.list col.date { width: 150px; }
|
||||
table.list col.claim { width: 120px; }
|
||||
table.list col.time { width: 100px; }
|
||||
table.list col.mode { width: 96px; }
|
||||
table.list col.result { width: 96px; }
|
||||
/* A phone: the key columns only — the rest is in the side panel. */
|
||||
@media (max-width: 760px) {
|
||||
table.list th:nth-child(4), table.list td:nth-child(4), table.list col:nth-child(4), table.list th:nth-child(6), table.list td:nth-child(6), table.list col:nth-child(6) { display: none; }
|
||||
}
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Find a claim</h1>
|
||||
<p class="sub">Every time the bot checked a claim, newest first.</p>
|
||||
<form class="search card" method="get" action="/claim">
|
||||
<input type="search" name="claim_no" value="{{ claim_no }}" autofocus
|
||||
placeholder="Claim #" inputmode="numeric" aria-label="Claim number">
|
||||
<button type="submit">Find</button>
|
||||
</form>
|
||||
{% if rows %}
|
||||
<div class="pager" id="pager-top"></div>
|
||||
<table class="list">
|
||||
<colgroup><col class="date"><col class="claim"><col class="time"><col class="mode"><col class="result"><col></colgroup>
|
||||
<thead><tr><th>Date</th><th>Claim #</th><th>Time</th><th>Mode</th><th>Result</th><th>Comments</th></tr></thead>
|
||||
<tbody>
|
||||
{% for row in rows %}
|
||||
<tr data-claim="{{ row.claim_no or '' }}" data-category="{{ row.category or '' }}"
|
||||
data-when="{{ row.run_at | usdate }} · {{ row.run_mode or '' }}"
|
||||
data-fields='{{ row.fields | tojson }}' data-details="{{ row.details_pretty }}">
|
||||
<td><a href="/day/{{ row.run_at.date().isoformat() }}?claim={{ (row.claim_no or '') | urlencode }}">{{ row.run_at.date() | longdate }}</a></td>
|
||||
<td class="claim">{{ row.claim_no or "" }}</td>
|
||||
<td>{{ row.run_at | clock }}</td>
|
||||
<td>{{ row.run_mode or "" }}</td>
|
||||
<td>{% if row.category %}<span class="cat {{ row.category }}">{{ row.category }}</span>{% endif %}</td>
|
||||
<td title="{{ row.comments or '' }}">{{ row.comments or "" }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="pager" id="pager-bottom"></div>
|
||||
{% elif claim_no %}
|
||||
<div class="empty">No saved check of a claim matching “{{ claim_no }}”.</div>
|
||||
{% endif %}
|
||||
{% include "_claim_panel.html" %}
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// The date link goes to the day; the rest of the row opens the panel.
|
||||
document.querySelectorAll('tbody a')
|
||||
.forEach(a => a.addEventListener('click', e => e.stopPropagation()));
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -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 %}
|
||||
@@ -0,0 +1,86 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ day | longdate }}{% endblock %}
|
||||
{% block style %}
|
||||
table.list col.n { width: 52px; }
|
||||
table.list col.claim { width: 120px; }
|
||||
table.list col.time { width: 100px; }
|
||||
table.list col.payer { width: 22%; }
|
||||
table.list col.result { width: 96px; }
|
||||
.bar { display: flex; justify-content: space-between; align-items: end;
|
||||
gap: 12px; flex-wrap: wrap; margin-bottom: 14px; }
|
||||
/* A phone: the key columns only — the rest is in the side panel. */
|
||||
@media (max-width: 760px) {
|
||||
table.list th:nth-child(4), table.list td:nth-child(4), table.list col:nth-child(4), table.list th:nth-child(6), table.list td:nth-child(6), table.list col:nth-child(6) { display: none; }
|
||||
table.list col.n { width: 36px; }
|
||||
table.list col.claim { width: 96px; }
|
||||
table.list col.time { width: 84px; }
|
||||
}
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="bar">
|
||||
<div>
|
||||
<h1>{{ day | longdate }}</h1>
|
||||
<p class="sub">{{ counts.claims }} claims checked · {{ counts.runs }}
|
||||
run{{ "" if counts.runs == 1 else "s" }} — every run of the day together</p>
|
||||
</div>
|
||||
<form class="search" method="get" action="/day/{{ day.isoformat() }}">
|
||||
{% if category %}<input type="hidden" name="category" value="{{ category }}">{% endif %}
|
||||
<input type="search" name="claim_no" value="{{ claim_no }}"
|
||||
placeholder="Search claim #" inputmode="numeric"
|
||||
aria-label="Search claim number">
|
||||
<button type="submit">Search</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% set q = ("&claim_no=" ~ claim_no) if claim_no else "" %}
|
||||
<div class="tiles">
|
||||
<a class="tile{{ ' on' if not category }}" href="/day/{{ day.isoformat() }}{{ ('?claim_no=' ~ claim_no) if claim_no }}"><span class="n">{{ counts.claims }}</span><span class="l">All</span></a>
|
||||
<a class="tile Pass{{ ' on' if category == 'Pass' }}" href="/day/{{ day.isoformat() }}?category=Pass{{ q }}"><span class="n">{{ counts.pass_count }}</span><span class="l">Pass</span></a>
|
||||
<a class="tile Fail{{ ' on' if category == 'Fail' }}" href="/day/{{ day.isoformat() }}?category=Fail{{ q }}"><span class="n">{{ counts.fail_count }}</span><span class="l">Fail</span></a>
|
||||
<a class="tile Review{{ ' on' if category == 'Review' }}" href="/day/{{ day.isoformat() }}?category=Review{{ q }}"><span class="n">{{ counts.review_count }}</span><span class="l">Review</span></a>
|
||||
</div>
|
||||
|
||||
{% if rows %}
|
||||
<div class="pager" id="pager-top"></div>
|
||||
<table class="list">
|
||||
<colgroup><col class="n"><col class="claim"><col class="time"><col class="payer"><col class="result"><col></colgroup>
|
||||
<thead><tr><th>#</th><th>Claim #</th><th>Time</th><th>Payer</th><th>Result</th><th>Comments</th></tr></thead>
|
||||
<tbody>
|
||||
{% for row in rows %}
|
||||
<tr data-claim="{{ row.claim_no or '' }}" data-category="{{ row.category or '' }}"
|
||||
data-when="{{ row.run_at | usdate }} · {{ row.run_mode or '' }}"
|
||||
data-fields='{{ row.fields | tojson }}' data-details="{{ row.details_pretty }}">
|
||||
<td>{{ loop.index }}</td>
|
||||
<td class="claim">{{ row.claim_no or "" }}</td>
|
||||
<td>{{ row.run_at | clock }}</td>
|
||||
<td title="{{ row.payer or '' }}">{{ row.payer or "" }}</td>
|
||||
<td>{% if row.category %}<span class="cat {{ row.category }}">{{ row.category }}</span>{% endif %}</td>
|
||||
<td title="{{ row.comments or '' }}">{{ row.comments or "" }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="pager" id="pager-bottom"></div>
|
||||
{% elif counts.claims %}
|
||||
<div class="empty">No claims match this filter. <a href="/day/{{ day.isoformat() }}">Show all</a></div>
|
||||
{% else %}
|
||||
<div class="empty">No claims were checked on this day. <a href="/days">Back to Claim status</a></div>
|
||||
{% endif %}
|
||||
{% include "_claim_panel.html" %}
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// ?claim=<no> (from the Live claims panel or a claim's history): open
|
||||
// that claim — the last check of it that day — on its page.
|
||||
(() => {
|
||||
const want = {{ opened | tojson }};
|
||||
if (!want) return;
|
||||
let i = -1;
|
||||
allRows.forEach((tr, k) => { if (tr.dataset.claim === want) i = k; });
|
||||
if (i < 0) return;
|
||||
goToPage(Math.floor(i / PAGE_SIZE));
|
||||
allRows[i].scrollIntoView({block: 'center'});
|
||||
showClaim(allRows[i]);
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,57 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Claim status{% endblock %}
|
||||
{% block style %}
|
||||
.days { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 14px; }
|
||||
a.daycard { display: block; background: var(--panel); color: var(--text);
|
||||
border: 1px solid var(--line); border-left: 5px solid var(--accent);
|
||||
border-radius: 10px; padding: 14px 16px; box-shadow: var(--shadow); }
|
||||
a.daycard:hover { text-decoration: none; border-color: var(--accent);
|
||||
background: var(--hilite); }
|
||||
.daycard .d { font-size: 16px; font-weight: 700; }
|
||||
.daycard .m { color: var(--muted); font-size: 12px; margin: 2px 0 8px; }
|
||||
.daycard .c { display: flex; gap: 12px; font-weight: 600; font-size: 13px; }
|
||||
.daycard .c .Pass { color: var(--pass); }
|
||||
.daycard .c .Fail { color: var(--fail); }
|
||||
.daycard .c .Review { color: var(--review); }
|
||||
.stack { display: flex; height: 7px; border-radius: 4px; overflow: hidden;
|
||||
background: var(--head); margin-top: 10px; }
|
||||
.stack i { display: block; }
|
||||
.stack i.Pass { background: var(--pass); }
|
||||
.stack i.Fail { background: var(--fail); }
|
||||
.stack i.Review { background: var(--review-bar); }
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Claim status</h1>
|
||||
<p class="sub">Every day the bot checked claims, newest first — all runs of a
|
||||
day together. Pick a day to see its claims.</p>
|
||||
{% if days %}
|
||||
<div class="days">
|
||||
{% for d in days %}
|
||||
<a class="daycard" href="/day/{{ d.day.isoformat() }}">
|
||||
<div class="d">{{ d.day | longdate }}</div>
|
||||
<div class="m">{{ d.claims }} claims checked · {{ d.runs }} run{{ "" if d.runs == 1 else "s" }}</div>
|
||||
<div class="c">
|
||||
<span class="Pass">{{ d.pass_count }} Pass</span>
|
||||
<span class="Fail">{{ d.fail_count }} Fail</span>
|
||||
<span class="Review">{{ d.review_count }} Review</span>
|
||||
</div>
|
||||
<div class="stack" aria-hidden="true">
|
||||
<i class="Pass" data-n="{{ d.pass_count }}"></i><i class="Fail" data-n="{{ d.fail_count }}"></i><i class="Review" data-n="{{ d.review_count }}"></i>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty">No results saved yet. Run the bot with <code>RESULTS_DB=1</code>
|
||||
in <code>.env</code>; each day's claims appear here.</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// Each part of a day's colour bar as wide as its count.
|
||||
document.querySelectorAll('.stack i').forEach(i => {
|
||||
i.style.flexGrow = Number(i.dataset.n) || 0;
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,23 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Change password{% endblock %}
|
||||
{% block style %}
|
||||
.pw { max-width: 420px; }
|
||||
.pw label { display: block; margin: 0 0 12px; font-weight: 600; }
|
||||
.pw input { display: block; width: 100%; margin-top: 4px; }
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="pw">
|
||||
<h1>Change password</h1>
|
||||
{% if user.must_change %}<p class="sub">Choose your own password before
|
||||
going on (an Admin set the one you signed in with).</p>
|
||||
{% else %}<p class="sub">At least 10 characters, not your username.</p>{% endif %}
|
||||
{% if error %}<div class="msg bad" role="alert">{{ error }}</div>{% endif %}
|
||||
<form class="card" method="post" action="/account/password" autocomplete="off">
|
||||
<input type="hidden" name="token" value="{{ run_token }}">
|
||||
<label>Current password <input name="current" type="password" autocomplete="current-password" required></label>
|
||||
<label>New password <input name="new" type="password" autocomplete="new-password" minlength="10" required></label>
|
||||
<label>New password again <input name="confirm" type="password" autocomplete="new-password" minlength="10" required></label>
|
||||
<button type="submit">Change password</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,126 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Run {{ run.run_id }}{% endblock %}
|
||||
{% block head %}{% if run.status == "Running" %}<meta http-equiv="refresh" content="5">{% endif %}{% endblock %}
|
||||
{% block style %}
|
||||
.st { display: inline-block; padding: 2px 9px; border-radius: 10px;
|
||||
font-weight: 600; font-size: 12px; background: var(--head); }
|
||||
.st.Done { background: var(--pass-bg); color: var(--pass); }
|
||||
.st.Run-Error, .st.Ended-without-a-record { background: var(--fail-bg);
|
||||
color: var(--fail); }
|
||||
.st.Stopped { background: var(--review-bg); color: var(--review); }
|
||||
.st.Running { background: var(--hilite); color: var(--accent); }
|
||||
dl.facts { display: grid; grid-template-columns: 150px 1fr; gap: 6px 12px;
|
||||
margin: 0; }
|
||||
dl.facts dt { color: var(--muted); }
|
||||
dl.facts dd { margin: 0; }
|
||||
.card.error { border-left: 5px solid var(--fail); }
|
||||
.card.warn { border-left: 5px solid var(--review-bar); }
|
||||
table.plain { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
table.plain th, table.plain td { text-align: left; padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
vertical-align: top; }
|
||||
table.plain th { color: #3b4757; font-size: 12px; text-transform: uppercase;
|
||||
letter-spacing: .4px; background: var(--head); }
|
||||
td.t { white-space: nowrap; color: var(--muted); width: 90px; }
|
||||
.ok { color: var(--pass); font-weight: 600; }
|
||||
.no { color: var(--fail); font-weight: 600; }
|
||||
pre.tb { background: var(--head); padding: 10px; border-radius: 6px;
|
||||
white-space: pre-wrap; font-size: 12px; margin: 6px 0 0; }
|
||||
ul.claims { columns: 4; margin: 0; padding-left: 18px; }
|
||||
tr.k-run_error td, tr.k-claim_error td { color: var(--fail); }
|
||||
tr.k-warning td { color: var(--review); }
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Run {{ run.started | usdate }} <span class="st {{ run.status | replace(' ', '-') }}">{{ run.status }}</span></h1>
|
||||
<p class="sub">{% if run.status == "Running" %}Still running — this page refreshes every 5 seconds.{% else %}Ran {{ run.duration_s | dur }}.{% endif %}</p>
|
||||
|
||||
<div class="card">
|
||||
<dl class="facts">
|
||||
<dt>Started By</dt><dd>{{ run.started_by }}</dd>
|
||||
<dt>Source</dt><dd>{{ run.source | capitalize }}</dd>
|
||||
<dt>Mode</dt><dd>{{ run.mode }}{% if run.test_sheet %} · claims from {{ run.test_sheet }}{% endif %}</dd>
|
||||
<dt>Started</dt><dd>{{ run.started | usdate }}</dd>
|
||||
<dt>Ended</dt><dd>{{ run.ended | usdate }}</dd>
|
||||
<dt>Result</dt><dd>{{ run.status }}{% if run.exit_code is not none %} (exit {{ run.exit_code }}){% endif %}{% if run.error_step %} — {{ run.error_step }}: {{ run.error_message }}{% endif %}</dd>
|
||||
{% if run.status == "Stopped" %}<dt>Stopped By</dt><dd>{{ run.stopped_by or "—" }}</dd>{% endif %}
|
||||
<dt>Claims</dt><dd>{{ run.claims_done }} of {{ run.claims_total }} checked ·
|
||||
{{ run.pass }} Pass · {{ run.fail }} Fail · {{ run.review }} Review{% if run.not_found %} · {{ run.not_found }} not found{% endif %}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{% if run.run_errors %}
|
||||
<div class="card error">
|
||||
<h2>Run Error</h2>
|
||||
{% for e in run.run_errors %}
|
||||
<p><strong>{{ e.t[11:19] }}</strong> · {{ e.step }} · exit {{ e.exit_code }} — {{ e.message }}</p>
|
||||
{% if e.traceback %}<details><summary>Traceback</summary><pre class="tb">{{ e.traceback }}</pre></details>{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card">
|
||||
<h2>eCW Login Attempts{% if run.login_attempts %} · {{ run.login_total_s | dur }} in all{% endif %}</h2>
|
||||
{% if run.login_attempts %}
|
||||
<table class="plain">
|
||||
<thead><tr><th>#</th><th>Start</th><th>End</th><th>Took</th><th>2FA mail wait</th><th>Result</th><th>Steps</th></tr></thead>
|
||||
<tbody>
|
||||
{% for a in run.login_attempts %}
|
||||
<tr>
|
||||
<td>{{ a.attempt }}</td>
|
||||
<td>{{ a.start | usdate }}</td>
|
||||
<td>{{ a.end | usdate }}</td>
|
||||
<td>{{ a.duration_s | dur }}</td>
|
||||
<td>{{ a.twofa_wait_s | dur if a.twofa_wait_s is not none else "—" }}</td>
|
||||
<td class="{{ 'ok' if a.result == 'ok' else 'no' }}">{{ a.result_label }}{% if a.message %} — {{ a.message }}{% endif %}</td>
|
||||
<td>{% for label, time in a.step_rows %}{{ time }} {{ label }}{% if not loop.last %}<br>{% endif %}{% endfor %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}<p class="note">No eCW login in this run.</p>{% endif %}
|
||||
</div>
|
||||
|
||||
{% if run.claim_errors %}
|
||||
<div class="card error">
|
||||
<h2>Claim Errors ({{ run.claim_errors | length }})</h2>
|
||||
<p class="note">The bot could not check these claims; the Charge Summary shows them as Review.</p>
|
||||
<table class="plain">
|
||||
<thead><tr><th>Time</th><th>Claim</th><th>Step</th><th>Message</th></tr></thead>
|
||||
<tbody>{% for e in run.claim_errors %}<tr><td class="t">{{ e.t[11:19] }}</td><td>{{ e.claim }}</td><td>{{ e.step }}</td><td>{{ e.message }}</td></tr>{% endfor %}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if run.not_reached %}
|
||||
<div class="card error">
|
||||
<h2>Not Reached ({{ run.not_reached | length }})</h2>
|
||||
<p class="note">Queued, but the run ended before it got to them.</p>
|
||||
<ul class="claims">{% for c in run.not_reached %}<li>{{ c }}</li>{% endfor %}</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if run.warnings %}
|
||||
<div class="card warn">
|
||||
<h2>Warnings ({{ run.warnings | length }})</h2>
|
||||
<table class="plain">
|
||||
<thead><tr><th>Time</th><th>Kind</th><th>Claim</th><th>Message</th></tr></thead>
|
||||
<tbody>{% for e in run.warnings %}<tr><td class="t">{{ e.t[11:19] }}</td><td>{{ e.code }}</td><td>{{ e.claim or "" }}</td><td>{{ e.message }}</td></tr>{% endfor %}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if run.screenshots %}
|
||||
<div class="card">
|
||||
<h2>Failure screenshots</h2>
|
||||
<ul>{% for e in run.screenshots %}<li>{{ e.t[11:19] }} <a href="/runs/{{ run.run_id }}/shot/{{ loop.index0 }}" target="_blank" rel="noopener">{{ e.tag }}</a></li>{% endfor %}</ul>
|
||||
<p class="note">Each one's page HTML, when saved, is beside it in Screenshots\ (open it there, not here).</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card">
|
||||
<h2>Timeline</h2>
|
||||
<table class="plain">
|
||||
<tbody>{% for e in run.timeline %}<tr class="k-{{ e.kind }}"><td class="t">{{ e.time }}</td><td>{{ e.text }}</td></tr>{% endfor %}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,83 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Runs{% endblock %}
|
||||
{% block style %}
|
||||
.st { display: inline-block; padding: 2px 9px; border-radius: 10px;
|
||||
font-weight: 600; font-size: 12px; background: var(--head); }
|
||||
.st.Done { background: var(--pass-bg); color: var(--pass); }
|
||||
.st.Run-Error, .st.Ended-without-a-record { background: var(--fail-bg);
|
||||
color: var(--fail); }
|
||||
.st.Stopped { background: var(--review-bg); color: var(--review); }
|
||||
.st.Running { background: var(--hilite); color: var(--accent); }
|
||||
td .Pass { color: var(--pass); font-weight: 600; }
|
||||
td .Fail { color: var(--fail); font-weight: 600; }
|
||||
td .Review { color: var(--review); font-weight: 600; }
|
||||
td.bad { color: var(--fail); font-weight: 600; }
|
||||
td.warn { color: var(--review); font-weight: 600; }
|
||||
table.list tbody tr a { color: inherit; display: block; }
|
||||
/* Fixed layout takes the header's widths: the time and the login column
|
||||
must not be cut at 1180 px. */
|
||||
#runs th.w-start { width: 160px; } #runs th.w-who { width: 100px; }
|
||||
#runs th.w-src { width: 85px; } #runs th.w-mode { width: 80px; }
|
||||
#runs th.w-dur { width: 85px; } #runs th.w-res { width: 135px; }
|
||||
#runs th.w-login { width: 150px; } #runs th.w-cat { width: 125px; }
|
||||
#runs th.w-err { width: 95px; } #runs th.w-warn { width: 80px; }
|
||||
table.list tbody tr a:hover { text-decoration: none; }
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Runs</h1>
|
||||
<p class="sub">Every bot run since run tracking started (2026-10-22), newest
|
||||
first: who started it, how long the eCW login took, and what went wrong.
|
||||
Pick a run for its login steps, errors and timeline.</p>
|
||||
<form class="search card" method="get" action="/runs">
|
||||
<label>From <input type="date" name="day_from" value="{{ day_from }}"></label>
|
||||
<label>To <input type="date" name="day_to" value="{{ day_to }}"></label>
|
||||
<label>Result
|
||||
<select name="status">
|
||||
<option value="">All</option>
|
||||
{% for s in statuses %}<option value="{{ s }}"{% if s == status %} selected{% endif %}>{{ s }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit">Show</button>
|
||||
{% if day_from or day_to or status %}<a href="/runs">Clear</a>{% endif %}
|
||||
</form>
|
||||
{% if runs %}
|
||||
<table class="list" id="runs">
|
||||
<thead><tr>
|
||||
<th class="w-start">Started</th><th class="w-who">Started By</th>
|
||||
<th class="w-src">Source</th><th class="w-mode">Mode</th>
|
||||
<th class="w-dur">Duration</th><th class="w-res">Result</th>
|
||||
<th class="w-login">eCW login</th><th class="w-cat">Pass / Fail / Review</th>
|
||||
<th class="w-err">Claim Errors</th><th class="w-warn">Warnings</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for r in runs %}
|
||||
<tr>
|
||||
<td><a href="/runs/{{ r.run_id }}">{{ r.started | usdate }}</a></td>
|
||||
<td>{{ r.started_by }}</td>
|
||||
<td>{{ r.source | capitalize }}</td>
|
||||
<td>{{ r.mode }}</td>
|
||||
<td>{{ r.duration_s | dur }}</td>
|
||||
<td><span class="st {{ r.status | replace(' ', '-') }}">{{ r.status }}</span>{% if r.status == "Run Error" %} exit {{ r.exit_code }}{% endif %}</td>
|
||||
<td>{{ r.login_attempts | length }} attempt{{ "" if r.login_attempts | length == 1 else "s" }}{% if r.login_attempts %} · {{ r.login_total_s | dur }}{% endif %}</td>
|
||||
<td><span class="Pass">{{ r.pass }}</span> / <span class="Fail">{{ r.fail }}</span> / <span class="Review">{{ r.review }}</span></td>
|
||||
<td class="{{ 'bad' if r.claim_errors else '' }}">{{ r.claim_errors | length }}</td>
|
||||
<td class="{{ 'warn' if r.warnings else '' }}">{{ r.warnings | length }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="empty">No runs{% if day_from or day_to or status %} match{% else %} tracked yet{% endif %}.
|
||||
Runs from before 2026-10-22 are not tracked; every run from now on is.</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// A click anywhere on a row opens that run.
|
||||
document.querySelectorAll('#runs tbody tr').forEach(tr => {
|
||||
tr.addEventListener('click', () => {
|
||||
const a = tr.querySelector('a'); if (a) location.href = a.href;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,25 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Sign in{% endblock %}
|
||||
{% block style %}
|
||||
.signin { max-width: 380px; margin: 40px auto; }
|
||||
.signin label { display: block; margin: 0 0 12px; font-weight: 600; }
|
||||
.signin input { display: block; width: 100%; margin-top: 4px; }
|
||||
.signin button { width: 100%; margin-top: 6px; }
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="signin">
|
||||
<h1>Sign in</h1>
|
||||
<p class="sub">The Portal shows patient data: every Portal User signs in,
|
||||
and every Run records who started it.</p>
|
||||
{% if error %}<div class="msg bad" role="alert">{{ error }}</div>{% endif %}
|
||||
{% if no_users %}<div class="msg bad">No Portal Users yet. On this PC run
|
||||
<code>python tools\add_portal_user.py</code> to add the first Admin.</div>{% endif %}
|
||||
<form class="card" method="post" action="/signin" autocomplete="off">
|
||||
<input type="hidden" name="token" value="{{ run_token }}">
|
||||
<input type="hidden" name="next" value="{{ next_url }}">
|
||||
<label>Username <input name="username" autocomplete="username" required autofocus></label>
|
||||
<label>Password <input name="password" type="password" autocomplete="current-password" required></label>
|
||||
<button type="submit">Sign in</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,23 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Sign-in history{% endblock %}
|
||||
{% block style %}
|
||||
table.plain { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
table.plain th, table.plain td { text-align: left; padding: 7px 8px;
|
||||
border-bottom: 1px solid var(--line); }
|
||||
table.plain th { color: #3b4757; font-size: 12px; text-transform: uppercase;
|
||||
letter-spacing: .4px; background: var(--head); }
|
||||
tr.wrong_password td, tr.locked td, tr.disabled_attempt td { color: var(--fail); }
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Sign-in history</h1>
|
||||
<p class="sub">The latest 300: Sign-ins, Sign-outs, wrong passwords, locks,
|
||||
timeouts and every change an Admin made, newest first.</p>
|
||||
{% if events %}
|
||||
<div class="card">
|
||||
<table class="plain">
|
||||
<thead><tr><th>When</th><th>User</th><th>What</th><th>By</th></tr></thead>
|
||||
<tbody>{% for e in events %}<tr class="{{ e.event }}"><td>{{ e.at | usdate }}</td><td>{{ e.username }}</td><td>{{ e.event | replace('_', ' ') }}</td><td>{{ e.by_user or "" }}</td></tr>{% endfor %}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}<div class="empty">Nothing yet.</div>{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,89 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Portal Users{% endblock %}
|
||||
{% block style %}
|
||||
form.add { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)) auto;
|
||||
gap: 10px; align-items: end; }
|
||||
form.add label { font-weight: 600; font-size: 13px; }
|
||||
form.add input, form.add select { display: block; width: 100%; margin-top: 4px; }
|
||||
table.plain { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
table.plain th, table.plain td { text-align: left; padding: 8px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
vertical-align: middle; }
|
||||
table.plain th { color: #3b4757; font-size: 12px; text-transform: uppercase;
|
||||
letter-spacing: .4px; background: var(--head); }
|
||||
td form { display: inline-flex; gap: 6px; margin: 0 8px 0 0; }
|
||||
td form input { width: 150px; padding: 4px 8px; }
|
||||
td form select, td form button { padding: 4px 8px; }
|
||||
.tag { display: inline-block; padding: 1px 8px; border-radius: 10px;
|
||||
font-size: 12px; font-weight: 600; background: var(--head); }
|
||||
.tag.off { background: var(--fail-bg); color: var(--fail); }
|
||||
.tag.lock, .tag.change { background: var(--review-bg); color: var(--review); }
|
||||
.tag.on { background: var(--pass-bg); color: var(--pass); }
|
||||
@media (max-width: 760px) { form.add { grid-template-columns: 1fr; } }
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Portal Users</h1>
|
||||
<p class="sub">Admin: everything, and this page. Operator: see everything,
|
||||
Start / Stop a Run. Viewer: see only. Users are disabled, never deleted.</p>
|
||||
{% if error %}<div class="msg bad" role="alert">{{ error }}</div>{% endif %}
|
||||
{% if message %}<div class="msg good">{{ message }}</div>{% endif %}
|
||||
|
||||
<div class="card">
|
||||
<h2>Add a user</h2>
|
||||
<form class="add" method="post" action="/admin/users" autocomplete="off">
|
||||
<input type="hidden" name="token" value="{{ run_token }}">
|
||||
<label>Username <input name="username" required></label>
|
||||
<label>Name <input name="display_name"></label>
|
||||
<label>Role
|
||||
<select name="role">{% for r in roles %}<option{% if r == "Viewer" %} selected{% endif %}>{{ r }}</option>{% endfor %}</select>
|
||||
</label>
|
||||
<label>First password <input name="password" type="password" autocomplete="new-password" minlength="10" required></label>
|
||||
<button type="submit">Add</button>
|
||||
</form>
|
||||
<p class="note">They change the first password at their first Sign-in.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<table class="plain">
|
||||
<thead><tr><th>User</th><th>Role</th><th>Status</th><th>Added</th><th>Change</th></tr></thead>
|
||||
<tbody>
|
||||
{% for u in users %}
|
||||
<tr>
|
||||
<td><b>{{ u.username }}</b><br><span class="note">{{ u.display_name }}</span></td>
|
||||
<td>{{ u.role }}</td>
|
||||
<td>
|
||||
{% if u.disabled %}<span class="tag off">Disabled</span>
|
||||
{% else %}<span class="tag on">Active</span>{% endif %}
|
||||
{% if u.must_change %}<span class="tag change">Must change password</span>{% endif %}
|
||||
{% if u.locked_until %}<span class="tag lock">Locked until {{ u.locked_until | usdate }}</span>{% endif %}
|
||||
</td>
|
||||
<td>{{ u.created_at | usdate }}<br><span class="note">by {{ u.created_by }}</span></td>
|
||||
<td>
|
||||
<form method="post" action="/admin/users/{{ u.username }}/role">
|
||||
<input type="hidden" name="token" value="{{ run_token }}">
|
||||
<select name="role">{% for r in roles %}<option{% if r == u.role %} selected{% endif %}>{{ r }}</option>{% endfor %}</select>
|
||||
<button type="submit" class="plain">Set role</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/users/{{ u.username }}/reset" autocomplete="off">
|
||||
<input type="hidden" name="token" value="{{ run_token }}">
|
||||
<input name="password" type="password" placeholder="New first password" autocomplete="new-password" minlength="10" required>
|
||||
<button type="submit" class="plain">Reset</button>
|
||||
</form>
|
||||
{% if u.disabled %}
|
||||
<form method="post" action="/admin/users/{{ u.username }}/enable">
|
||||
<input type="hidden" name="token" value="{{ run_token }}">
|
||||
<button type="submit" class="plain">Enable</button>
|
||||
</form>
|
||||
{% elif u.username != user.username %}
|
||||
<form method="post" action="/admin/users/{{ u.username }}/disable">
|
||||
<input type="hidden" name="token" value="{{ run_token }}">
|
||||
<button type="submit" class="plain">Disable</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in new issue
Block a user