Run events in the database: Runs page and Agent run card on the Dokploy Portal
Snapshot of D:\Ecw Process 65c92c5 (2026-10-30). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d500f750da
commit
840272032a
9 files changed
+655
-46
No files matched your search
+16
-6
@@ -325,12 +325,15 @@ def create_app(repo, allowed_hosts=ALLOWED_HOSTS, runner=None,
|
||||
allowed_origins=ALLOWED_ORIGINS, logs_dir=None,
|
||||
screenshots_root=None, users_store=None,
|
||||
sessions=None, can_start=True,
|
||||
secure_cookie=False, ecare=None) -> FastAPI:
|
||||
secure_cookie=False, ecare=None, events=None) -> FastAPI:
|
||||
app = FastAPI(title="eCW Charge Validation — Results",
|
||||
docs_url=None, redoc_url=None, openapi_url=None)
|
||||
if runner is None:
|
||||
runner = BotRunner() if can_start else NoRunner()
|
||||
logs_dir = logs_dir if logs_dir is not None else cfg.LOGS
|
||||
# Where the Runs page and the last-run banner read Runs from: this PC's
|
||||
# Logs folder, or (the Portal on Dokploy, 2026-10-30) the database.
|
||||
events = events if events is not None else run_view.FileEvents(logs_dir)
|
||||
screenshots_root = (screenshots_root if screenshots_root is not None
|
||||
else cfg.ROOT / "Screenshots")
|
||||
# Made per server start and put in the page: a page on another site
|
||||
@@ -554,7 +557,7 @@ def create_app(repo, allowed_hosts=ALLOWED_HOSTS, runner=None,
|
||||
"""The latest Run, if it ended badly — the Dashboard's red banner
|
||||
(EEWA-33). A Run Error, or a Run that ended without a record
|
||||
(killed); never a Stop, a finished Run or one still going."""
|
||||
last = run_view.latest_run(logs_dir, _live_run_id())
|
||||
last = run_view.latest_run(events, _live_run_id())
|
||||
if last and last["status"] in ("Run Error", "Ended without a record"):
|
||||
return last
|
||||
return None
|
||||
@@ -580,7 +583,7 @@ def create_app(repo, allowed_hosts=ALLOWED_HOSTS, runner=None,
|
||||
raise BadFilter(f"Not a date: {value}")
|
||||
if status and status not in run_view.STATUSES:
|
||||
raise BadFilter(f"Not a result: {status}")
|
||||
rows = run_view.list_runs(logs_dir, _live_run_id(),
|
||||
rows = run_view.list_runs(events, _live_run_id(),
|
||||
day_from=day_from or None,
|
||||
day_to=day_to or None,
|
||||
status=status or None)
|
||||
@@ -592,7 +595,7 @@ def create_app(repo, allowed_hosts=ALLOWED_HOSTS, runner=None,
|
||||
|
||||
@app.get("/runs/{run_id}", response_class=HTMLResponse)
|
||||
def run_page(request: Request, run_id: str):
|
||||
run = run_view.load_run(logs_dir, run_id, _live_run_id())
|
||||
run = run_view.load_run(events, run_id, _live_run_id())
|
||||
if run is None:
|
||||
return HTMLResponse("<h1>No such run</h1>", status_code=404)
|
||||
return _page(request, "run.html",
|
||||
@@ -602,7 +605,7 @@ def create_app(repo, allowed_hosts=ALLOWED_HOSTS, runner=None,
|
||||
|
||||
@app.get("/runs/{run_id}/shot/{n}")
|
||||
def run_shot(run_id: str, n: int):
|
||||
path = run_view.screenshot_path(logs_dir, run_id, n, screenshots_root)
|
||||
path = run_view.screenshot_path(events, run_id, n, screenshots_root)
|
||||
if path is None:
|
||||
return HTMLResponse("<h1>No such screenshot</h1>",
|
||||
status_code=404)
|
||||
@@ -652,6 +655,10 @@ def create_app(repo, allowed_hosts=ALLOWED_HOSTS, runner=None,
|
||||
|
||||
@app.get("/api/bot/status")
|
||||
def bot_status():
|
||||
# A server Portal cannot see the bot PC's process or log: the
|
||||
# newest Run in the database instead (2026-10-30).
|
||||
if isinstance(events, run_view.DbEvents):
|
||||
return jsonable_encoder(run_view.live_status(events))
|
||||
return run_status.summarize(runner.status())
|
||||
|
||||
# ── Sign-in (Stage 2, EEWA-32) ───────────────────────────────────────
|
||||
@@ -873,8 +880,11 @@ def _safe_next(target) -> str:
|
||||
return target
|
||||
|
||||
|
||||
app = create_app(SqlResultsRepo(cfg.RESULTS_DB_CONN),
|
||||
_repo = SqlResultsRepo(cfg.RESULTS_DB_CONN)
|
||||
app = create_app(_repo,
|
||||
users_store=SqlUserStore(cfg.RESULTS_DB_CONN),
|
||||
# A server Portal has no Logs folder: Runs from the database.
|
||||
events=run_view.DbEvents(_repo) if cfg.PORTAL_SERVER else None,
|
||||
ecare=(EcareSignIn(cfg.AUTH_TOKEN_API_URL)
|
||||
if cfg.AUTH_TOKEN_API_URL else None),
|
||||
**portal_settings(cfg.PORTAL_URLS, cfg.PORTAL_SERVER))
|
||||
+18
@@ -136,3 +136,21 @@ class SqlResultsRepo:
|
||||
f"SELECT TOP 200 {_SELECT_COLUMNS} FROM {results_db.TABLE} "
|
||||
f"WHERE claim_no LIKE ? ORDER BY run_at DESC, s_no, id",
|
||||
[f"%{claim_no}%"])
|
||||
|
||||
# ── Run events (2026-10-30): the Runs page on a server Portal ─────────
|
||||
|
||||
def run_ids(self) -> list:
|
||||
"""Every Run with events, newest first (at most 500)."""
|
||||
return [r["run_id"] for r in self._query(
|
||||
"SELECT TOP 500 run_id FROM dbo.CV_RunEvent "
|
||||
"GROUP BY run_id ORDER BY run_id DESC")]
|
||||
|
||||
def run_event_lines(self, run_ids) -> list:
|
||||
"""(run_id, JSON line) for those Runs, in the order written."""
|
||||
run_ids = list(run_ids)[:500]
|
||||
if not run_ids:
|
||||
return []
|
||||
marks = ", ".join("?" * len(run_ids))
|
||||
return [(r["run_id"], r["body"]) for r in self._query(
|
||||
f"SELECT run_id, body FROM dbo.CV_RunEvent "
|
||||
f"WHERE run_id IN ({marks}) ORDER BY run_id, id", run_ids)]
|
||||
+124
-29
@@ -64,12 +64,17 @@ def read_events(logs_dir, run_id) -> list:
|
||||
if not valid_run_id(run_id):
|
||||
return []
|
||||
path = Path(logs_dir) / f"events_{run_id}.jsonl"
|
||||
out = []
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
return []
|
||||
for line in text.splitlines():
|
||||
return _parse(text.splitlines())
|
||||
|
||||
|
||||
def _parse(lines) -> list:
|
||||
"""Event dicts from JSON lines, a bad or untimed line skipped."""
|
||||
out = []
|
||||
for line in lines:
|
||||
try:
|
||||
e = json.loads(line)
|
||||
except ValueError:
|
||||
@@ -224,34 +229,79 @@ def _time_of_id(run_id):
|
||||
return None
|
||||
|
||||
|
||||
class FileEvents:
|
||||
"""Runs from this PC's `Logs/events_<run>.jsonl` files."""
|
||||
|
||||
def __init__(self, logs_dir):
|
||||
self.dir = Path(logs_dir)
|
||||
|
||||
def run_ids(self) -> list:
|
||||
try:
|
||||
files = sorted(self.dir.glob("events_*.jsonl"), reverse=True)
|
||||
except OSError:
|
||||
return []
|
||||
return [p.stem[len("events_"):] for p in files
|
||||
if valid_run_id(p.stem[len("events_"):])]
|
||||
|
||||
def events(self, run_ids) -> dict:
|
||||
return {r: read_events(self.dir, r) for r in run_ids
|
||||
if (self.dir / f"events_{r}.jsonl").is_file()}
|
||||
|
||||
|
||||
class DbEvents:
|
||||
"""Runs from `dbo.CV_RunEvent` (2026-10-30): the Portal on Dokploy,
|
||||
which cannot read the bot PC's Logs folder. One query per page."""
|
||||
|
||||
def __init__(self, repo):
|
||||
self.repo = repo
|
||||
|
||||
def run_ids(self) -> list:
|
||||
return [r for r in self.repo.run_ids() if valid_run_id(r)]
|
||||
|
||||
def events(self, run_ids) -> dict:
|
||||
run_ids = list(run_ids)
|
||||
if not run_ids:
|
||||
return {}
|
||||
lines = {}
|
||||
for run_id, body in self.repo.run_event_lines(run_ids):
|
||||
lines.setdefault(run_id, []).append(body)
|
||||
return {r: _parse(lines[r]) for r in run_ids if r in lines}
|
||||
|
||||
|
||||
def _source(src):
|
||||
"""A path (the old way, and the tests) or an events source."""
|
||||
return src if hasattr(src, "run_ids") else FileEvents(src)
|
||||
|
||||
|
||||
def load_run(logs_dir, run_id, live_run_id=None, now=None):
|
||||
"""One Run's detail, or None (bad id, no events file)."""
|
||||
"""One Run's detail, or None (bad id, no events)."""
|
||||
if not valid_run_id(run_id):
|
||||
return None
|
||||
if not (Path(logs_dir) / f"events_{run_id}.jsonl").is_file():
|
||||
found = _source(logs_dir).events([run_id])
|
||||
if run_id not in found:
|
||||
return None
|
||||
return summarize(run_id, read_events(logs_dir, run_id), live_run_id, now)
|
||||
return summarize(run_id, found[run_id], live_run_id, now)
|
||||
|
||||
|
||||
def list_runs(logs_dir, live_run_id=None, now=None, day_from=None,
|
||||
day_to=None, status=None) -> list:
|
||||
"""Every Run with an events file, newest first, filtered. `day_from`
|
||||
/ `day_to` are ISO dates (inclusive), `status` one of STATUSES."""
|
||||
"""Every Run with events, newest first, filtered. `day_from` /
|
||||
`day_to` are ISO dates (inclusive), `status` one of STATUSES."""
|
||||
d_from = date.fromisoformat(day_from) if day_from else None
|
||||
d_to = date.fromisoformat(day_to) if day_to else None
|
||||
out = []
|
||||
try:
|
||||
files = sorted(Path(logs_dir).glob("events_*.jsonl"), reverse=True)
|
||||
except OSError:
|
||||
files = []
|
||||
for path in files:
|
||||
run_id = path.stem[len("events_"):]
|
||||
if not valid_run_id(run_id):
|
||||
continue
|
||||
src = _source(logs_dir)
|
||||
ids = []
|
||||
for run_id in src.run_ids():
|
||||
day = _time_of_id(run_id).date()
|
||||
if (d_from and day < d_from) or (d_to and day > d_to):
|
||||
continue
|
||||
r = summarize(run_id, read_events(logs_dir, run_id), live_run_id, now)
|
||||
ids.append(run_id)
|
||||
found = src.events(ids)
|
||||
out = []
|
||||
for run_id in ids:
|
||||
if run_id not in found:
|
||||
continue
|
||||
r = summarize(run_id, found[run_id], live_run_id, now)
|
||||
if status and r["status"] != status:
|
||||
continue
|
||||
out.append(r)
|
||||
@@ -259,24 +309,22 @@ def list_runs(logs_dir, live_run_id=None, now=None, day_from=None,
|
||||
|
||||
|
||||
def latest_run(logs_dir, live_run_id=None, now=None):
|
||||
"""The newest Run's summary, or None — reads one file, not all."""
|
||||
try:
|
||||
files = sorted(Path(logs_dir).glob("events_*.jsonl"), reverse=True)
|
||||
except OSError:
|
||||
return None
|
||||
for path in files:
|
||||
run_id = path.stem[len("events_"):]
|
||||
if valid_run_id(run_id):
|
||||
return summarize(run_id, read_events(logs_dir, run_id),
|
||||
live_run_id, now)
|
||||
"""The newest Run's summary, or None — reads one Run, not all."""
|
||||
src = _source(logs_dir)
|
||||
for run_id in src.run_ids():
|
||||
found = src.events([run_id])
|
||||
if run_id in found:
|
||||
return summarize(run_id, found[run_id], live_run_id, now)
|
||||
return None
|
||||
|
||||
|
||||
def screenshot_path(logs_dir, run_id, n, screenshots_root):
|
||||
"""The n-th screenshot event's .png, if it is inside `screenshots_root`
|
||||
and exists; else None. Only paths a Run itself recorded are served."""
|
||||
shots = [e for e in read_events(logs_dir, run_id)
|
||||
if e["kind"] == "screenshot"]
|
||||
if not valid_run_id(run_id):
|
||||
return None
|
||||
events = _source(logs_dir).events([run_id]).get(run_id, [])
|
||||
shots = [e for e in events if e["kind"] == "screenshot"]
|
||||
if not 0 <= n < len(shots):
|
||||
return None
|
||||
try:
|
||||
@@ -290,3 +338,50 @@ def screenshot_path(logs_dir, run_id, n, screenshots_root):
|
||||
or not path.is_file()):
|
||||
return None
|
||||
return path
|
||||
|
||||
|
||||
LIVE_STALE_S = 600
|
||||
|
||||
|
||||
def live_status(source, now=None) -> dict:
|
||||
"""The Dashboard's Agent run card on a server Portal (2026-10-30): the
|
||||
newest Run in the events source, in `/api/bot/status`'s shape. Running
|
||||
= no end yet and an event in the last LIVE_STALE_S seconds (the server
|
||||
cannot see the bot PC's process). No log tail: that is the PC's log."""
|
||||
from .run_status import exit_text
|
||||
now = now or datetime.now()
|
||||
out = {"running": False, "starting": False, "started_at": None,
|
||||
"ended_at": None, "exit_code": None, "stopped_by_operator": False,
|
||||
"source": None, "started_by": None, "phase": "", "progress": None,
|
||||
"current": None, "counts": {"Pass": 0, "Fail": 0, "Review": 0},
|
||||
"tail": [], "claims": [], "results_run_at": None,
|
||||
"elapsed_s": None, "message": "No run yet"}
|
||||
try:
|
||||
ids = source.run_ids()
|
||||
except Exception:
|
||||
return out
|
||||
for run_id in ids:
|
||||
found = source.events([run_id])
|
||||
if run_id not in found or not found[run_id]:
|
||||
continue
|
||||
events = found[run_id]
|
||||
r = summarize(run_id, events, None, now)
|
||||
ended = any(e["kind"] in ("run_end", "stopped") for e in events)
|
||||
fresh = (now - events[-1]["_t"]).total_seconds() < LIVE_STALE_S
|
||||
running = not ended and fresh
|
||||
stopped = r["status"] == "Stopped"
|
||||
out.update(
|
||||
running=running, source=r["source"], started_by=r["started_by"],
|
||||
started_at=r["started"].isoformat() if r["started"] else None,
|
||||
ended_at=r["ended"].isoformat() if r["ended"] else None,
|
||||
exit_code=r["exit_code"], stopped_by_operator=stopped,
|
||||
progress=([r["claims_done"], r["claims_total"]]
|
||||
if r["claims_total"] else None),
|
||||
counts={"Pass": r["pass"], "Fail": r["fail"],
|
||||
"Review": r["review"]},
|
||||
elapsed_s=r["duration_s"],
|
||||
message=(f"Running on the bot PC · {r['claims_done']} of "
|
||||
f"{r['claims_total']} claims" if running
|
||||
else exit_text(r["exit_code"], stopped)))
|
||||
return out
|
||||
return out
|
||||
Reference in new issue
Block a user