"""User-interface views. The original codebase pulls recon jobs from a database. For this workspace we fake the data so the dashboard/results pages run without `app.core.config`, `app.core.refdata`, or a database. """ from pathlib import Path from datetime import date, datetime, time, timedelta from fastapi import APIRouter, Request from fastapi.responses import HTMLResponse from fastapi.templating import Jinja2Templates from app.models.recon_job import ReconJob # noqa: F401 (validates that the real model imports cleanly) from app.service.fake_jobs import FakeReconJob, get_fake_jobs from app.service.fake_configs import FakeReconConfig, get_fake_configs router = APIRouter(prefix="", tags=["User_Interface"]) _project_root = Path(__file__).resolve().parents[2] templates = Jinja2Templates(directory=str(_project_root / "data" / "templates")) SECONDS_IN_DAY = 24 * 60 * 60 def _frac(dt: datetime, day_start: datetime) -> float: delta = (dt - day_start).total_seconds() / SECONDS_IN_DAY return max(0.0, min(1.0, delta)) def _build_day_timeline( jobs: list[FakeReconJob], day: date, now: datetime, ) -> dict: """Per-day timeline entries with fractional left/width (%) and a kind: "completed", "running", or "scheduled". """ day_start = datetime.combine(day, time.min) day_end = day_start + timedelta(days=1) entries = [] for j in jobs: start = j.start_datetime finish = j.finish_datetime due = j.due_datetime if start is not None and start < day_end: run_end = finish if finish is not None else now if run_end <= day_start: continue left = _frac(start, day_start) right = _frac(run_end, day_start) if right <= left: right = min(1.0, left + 0.005) entries.append({ "id": j.id, "name": j.name, "kind": "completed" if finish is not None else "running", "status": j.status, "left": left * 100.0, "width": (right - left) * 100.0, "start": start.isoformat(timespec="minutes"), "finish": finish.isoformat(timespec="minutes") if finish else None, "due": due.isoformat(timespec="minutes") if due else None, }) continue if due is not None and day_start <= due < day_end and due >= now: left = _frac(due, day_start) entries.append({ "id": j.id, "name": j.name, "kind": "scheduled", "status": j.status, "left": left * 100.0, "width": 0.6, "start": None, "finish": None, "due": due.isoformat(timespec="minutes"), }) entries.sort(key=lambda e: (e["left"], e["kind"] == "scheduled")) # Lane (row) assignment so overlapping/adjacent bars stack vertically. # An entry occupies [left, left+width); for collision purposes scheduled # markers are widened slightly so back-to-back scheduled jobs separate. # Lanes are capped at MAX_LANES — additional collisions are pushed to the # right of the last entry on the bottom lane (visualising "and N more # serialised after this one") rather than growing the track vertically. GAP = 0.2 # % units MIN_COLLISION_WIDTH = 1.2 MAX_LANES = 3 lane_ends: list[float] = [] lane_visible_ends: list[float] = [] for e in entries: coll_width = max(e["width"], MIN_COLLISION_WIDTH) placed = False for i, end in enumerate(lane_ends): if e["left"] >= end + GAP: lane_ends[i] = e["left"] + coll_width e["lane"] = i placed = True break if not placed: if len(lane_ends) < MAX_LANES: e["lane"] = len(lane_ends) lane_ends.append(e["left"] + coll_width) lane_visible_ends.append(e["left"] + e["width"]) else: # All lanes occupied — shunt this entry horizontally onto the # last lane, immediately after whatever is already there. We # advance by the previous entry's *visible* width (tracked # separately) rather than the inflated collision width, so # scheduled markers sit flush against each other. last = MAX_LANES - 1 new_left = lane_visible_ends[last] + GAP e["left"] = new_left e["lane"] = last lane_ends[last] = new_left + coll_width lane_visible_ends[last] = new_left + e["width"] continue # Track visible end too (used only for chaining on the last lane). lane_visible_ends[e["lane"]] = e["left"] + e["width"] return { "date": day, "is_today": day == now.date(), "now_pct": _frac(now, day_start) * 100.0 if day_start <= now < day_end else None, "hours": list(range(0, 25)), "entries": entries, "lane_count": max(1, len(lane_ends)), } def _fmt_duration(seconds: float) -> str: seconds = int(max(0, seconds)) h, rem = divmod(seconds, 3600) m, s = divmod(rem, 60) if h: return f"{h}h {m:02d}m" return f"{m}m {s:02d}s" def _jobs_for_day(jobs: list[FakeReconJob], day: date) -> list[FakeReconJob]: return [j for j in jobs if j.as_at_date == day] def _status_counts(jobs: list[FakeReconJob]) -> dict[str, int]: counts = {"completed": 0, "failed": 0, "running": 0, "created": 0, "other": 0} for j in jobs: key = j.status if j.status in counts else "other" counts[key] += 1 return counts def _success_rate(counts: dict[str, int]) -> float | None: finished = counts["completed"] + counts["failed"] return (counts["completed"] / finished * 100.0) if finished else None def _compute_metrics( jobs: list[FakeReconJob], configs: list[FakeReconConfig], now: datetime, ) -> dict: today = now.date() yesterday = today - timedelta(days=1) week_ago = today - timedelta(days=6) today_jobs = _jobs_for_day(jobs, today) yesterday_jobs = _jobs_for_day(jobs, yesterday) week_jobs = [j for j in jobs if week_ago <= j.as_at_date <= today] today_counts = _status_counts(today_jobs) yesterday_counts = _status_counts(yesterday_jobs) total_counts = _status_counts(jobs) week_counts = _status_counts(week_jobs) # --- Config metrics --- active_configs = [c for c in configs if c.status != "draft"] by_freq: dict[str, int] = {} for c in active_configs: by_freq[c.frequency] = by_freq.get(c.frequency, 0) + 1 by_freq_sorted = sorted(by_freq.items(), key=lambda kv: -kv[1]) config_status_counts: dict[str, int] = {} for c in configs: config_status_counts[c.status] = config_status_counts.get(c.status, 0) + 1 # --- Performance --- durations_today = [ (j.finish_datetime - j.start_datetime).total_seconds() for j in today_jobs if j.start_datetime and j.finish_datetime ] avg_duration_today = (sum(durations_today) / len(durations_today)) if durations_today else None longest_today = None longest_today_seconds = 0.0 if today_jobs: for j in today_jobs: if j.start_datetime and j.finish_datetime: secs = (j.finish_datetime - j.start_datetime).total_seconds() if longest_today is None or secs > longest_today_seconds: longest_today = j longest_today_seconds = secs # On-time = job started within 15 min of due_datetime (when both known) on_time = late = 0 for j in today_jobs: if j.due_datetime and j.start_datetime: if (j.start_datetime - j.due_datetime).total_seconds() <= 15 * 60: on_time += 1 else: late += 1 on_time_rate_today = (on_time / (on_time + late) * 100.0) if (on_time + late) else None # --- Match quality (from results) --- def _matched_unmatched(js: list[FakeReconJob]) -> tuple[int, int]: m = u = 0 for j in js: r = j.results or {} m += int(r.get("Matched") or 0) u += int(r.get("Unmatched") or 0) return m, u m_today, u_today = _matched_unmatched(today_jobs) m_week, u_week = _matched_unmatched(week_jobs) match_rate_today = (m_today / (m_today + u_today) * 100.0) if (m_today + u_today) else None match_rate_week = (m_week / (m_week + u_week) * 100.0) if (m_week + u_week) else None # --- Failures --- recent_failures = sorted( (j for j in jobs if j.status == "failed"), key=lambda j: (j.finish_datetime or j.start_datetime or datetime.min), reverse=True, )[:3] # --- Upcoming today --- upcoming_today = sorted( (j for j in today_jobs if j.start_datetime is None and j.due_datetime is not None and j.due_datetime >= now), key=lambda j: j.due_datetime or datetime.max, )[:5] return { "jobs": { "today": today_counts, "yesterday": yesterday_counts, "week": week_counts, "total": total_counts, "success_rate_today": _success_rate(today_counts), "success_rate_yesterday": _success_rate(yesterday_counts), "success_rate_week": _success_rate(week_counts), "success_rate_total": _success_rate(total_counts), }, "configs": { "active": len(active_configs), "total": len(configs), "draft": config_status_counts.get("draft", 0), "archived": config_status_counts.get("archived", 0), "by_frequency": by_freq_sorted, }, "performance": { "avg_duration_today": _fmt_duration(avg_duration_today) if avg_duration_today is not None else None, "longest_today_name": longest_today.name if longest_today else None, "longest_today_duration": ( _fmt_duration(longest_today_seconds) if longest_today else None ), "on_time_rate_today": on_time_rate_today, "on_time": on_time, "late": late, }, "match_quality": { "matched_today": m_today, "unmatched_today": u_today, "match_rate_today": match_rate_today, "matched_week": m_week, "unmatched_week": u_week, "match_rate_week": match_rate_week, }, "recent_failures": [ { "id": j.id, "name": j.name, "as_at": j.as_at_date.isoformat(), "reason": j.status_reason or "—", "when": ( _when.isoformat(timespec="minutes") if (_when := j.finish_datetime or j.start_datetime) is not None else "" ), } for j in recent_failures ], "upcoming_today": [ { "id": j.id, "name": j.name, "due": j.due_datetime.strftime("%H:%M") if j.due_datetime else "", } for j in upcoming_today ], } @router.get("/", response_class=HTMLResponse) async def dashboard_view(request: Request): now = datetime.now() today = now.date() yesterday = today - timedelta(days=1) jobs = get_fake_jobs(now) configs = get_fake_configs(now) timelines = [ _build_day_timeline(jobs, yesterday, now), _build_day_timeline(jobs, today, now), ] metrics = _compute_metrics(jobs, configs, now) return templates.TemplateResponse( request=request, name="dashboard.html", context={ "title": "Dashboard", "timelines": timelines, "metrics": metrics, }, ) @router.get("/results", response_class=HTMLResponse) async def results_view( request: Request, cursor: int = 999999, recon_job_name: str | None = None, as_at_date: str | None = None, ): PAGE_SIZE = 20 now = datetime.now() jobs = get_fake_jobs(now) if recon_job_name: jobs = [j for j in jobs if j.name == recon_job_name] if as_at_date: jobs = [j for j in jobs if j.as_at_date.isoformat() == as_at_date] jobs.sort(key=lambda j: j.id, reverse=True) page = [j for j in jobs if j.id < cursor][:PAGE_SIZE] result_rows = [ {"job_id": j.id, "name": j.name, "data": j.results} for j in page if j.results ] next_cursor = page[-1].id - 1 if page else None prev_cursor = page[0].id + PAGE_SIZE if page else None return templates.TemplateResponse( request=request, name="results.html", context={ "title": "Results", "result_rows": result_rows, "next_cursor": next_cursor, "prev_cursor": prev_cursor, "recon_job_name": recon_job_name, "as_at_date": as_at_date, }, ) def _build_history_timeline( related: list[FakeReconJob], now: datetime, ) -> dict: """Timeline of executions of a single recon-config from the first run to now. Each entry is a marker positioned by ``as_at_date`` along a [first, today] axis. """ if not related: return {"entries": [], "first_date": None, "last_date": now.date(), "days": 0, "ticks": []} dates = sorted({j.as_at_date for j in related}) first = dates[0] last = now.date() span_days = max((last - first).days, 1) entries = [] for j in sorted(related, key=lambda x: (x.as_at_date, x.start_datetime or datetime.min)): days_in = (j.as_at_date - first).days left = (days_in / span_days) * 100.0 when = j.finish_datetime or j.start_datetime or j.due_datetime entries.append({ "id": j.id, "name": j.name, "status": j.status, "kind": ( "running" if j.status == "running" else "scheduled" if (j.start_datetime is None) else "failed" if j.status == "failed" else "completed" ), "left": max(0.0, min(100.0, left)), "as_at": j.as_at_date.isoformat(), "when": when.isoformat(timespec="minutes") if when else None, }) # axis ticks: spread up to ~6 evenly-spaced day labels across the span tick_count = min(6, span_days + 1) ticks = [] for i in range(tick_count): frac = i / max(tick_count - 1, 1) day = first + timedelta(days=int(round(frac * span_days))) ticks.append({"left": frac * 100.0, "label": day.strftime("%d %b")}) return { "entries": entries, "first_date": first, "last_date": last, "days": span_days, "ticks": ticks, } @router.get("/jobs/{job_id}", response_class=HTMLResponse) async def job_detail_view(request: Request, job_id: int): now = datetime.now() jobs = get_fake_jobs(now) configs = get_fake_configs(now) job = next((j for j in jobs if j.id == job_id), None) if job is None: return HTMLResponse( f"