Add job detail template with execution history and metrics display

- Created a new job_detail.html template extending base.html
- Implemented a macro for rendering nested data structures
- Added sections for job identification, schedule & timing, status, configuration, execution history, and results
- Included a timeline for execution history with visual indicators for job status
- Displayed job metrics including total executions, success rate, and average duration
- Handled cases for displaying results or indicating absence of results based on job status
This commit is contained in:
2026-05-25 19:07:56 +12:00
parent 5bf6b98ccc
commit 82c7712613
11 changed files with 1432 additions and 79 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ from app.api.reporting import router as reporting_router
from app.api.transactions import router as transactions_router
from app.core.settings import get_settings
from app.views.auth import router as auth_router
from app.views.dashboard import router as dashboard_router
from app.views.views import router as dashboard_router
from app.views.docs import router as docs_router
+14
View File
@@ -0,0 +1,14 @@
"""Fake reference-data enums.
The real `app.core.refdata` lives in the production repo. For this workspace
we only need `ReconJobStatus` (consumed by `app.models.recon_job`).
"""
from enum import Enum
class ReconJobStatus(str, Enum):
CREATED = "created"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
CANCELLED = "cancelled"
+30
View File
@@ -0,0 +1,30 @@
"""Fake recon-config data so dashboard metrics can run without a DB."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta
@dataclass
class FakeReconConfig:
reference: str
name: str
status: str # "draft" | "published" | "archived"
frequency: str # "Ad Hoc" | "Intra Day" | "Daily" | "Weekly" | "Monthly" | "Quarterly"
business_process: str
start_datetime: datetime | None = None
def get_fake_configs(now: datetime) -> list[FakeReconConfig]:
base = now - timedelta(days=120)
return [
FakeReconConfig("fx-settlement", "FX Settlement Recon", "published", "Daily", "Treasury", base),
FakeReconConfig("cash-vs-ledger", "Cash vs Ledger", "published", "Daily", "Finance", base),
FakeReconConfig("intraday-liquidity", "Intraday Liquidity Sweep", "published", "Intra Day", "Treasury", base),
FakeReconConfig("eod-positions", "EOD Position Recon", "published", "Daily", "Trading", base),
FakeReconConfig("weekly-customers", "Customer Master Recon", "published", "Weekly", "Onboarding", base),
FakeReconConfig("monthly-reg", "Regulatory Reporting Recon", "published", "Monthly", "Compliance", base),
FakeReconConfig("ad-hoc-aml", "AML Spot Check", "published", "Ad Hoc", "FinCrime", None),
FakeReconConfig("crypto-wallet", "Crypto Wallet Recon (POC)", "draft", "Daily", "Digital", None),
FakeReconConfig("legacy-loans", "Legacy Loans Recon", "archived", "Monthly", "Lending", None),
]
+178
View File
@@ -0,0 +1,178 @@
"""In-memory fake of recon-job data so the dashboard/results views can run
without a database or the production `app.core.config` / `app.core.refdata`
modules. Replace with real DB-backed code when wiring up to the real repo.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import date, datetime, time, timedelta
from typing import Optional
@dataclass
class FakeReconJob:
id: int
name: str
as_at_date: date
status: str = "completed"
status_reason: str = ""
recon_config_reference: str = "demo-config"
username: str = "demo.user"
due_datetime: Optional[datetime] = None
start_datetime: Optional[datetime] = None
finish_datetime: Optional[datetime] = None
results: Optional[dict] = None
def _at(day: date, hour: int, minute: int = 0) -> datetime:
return datetime.combine(day, time(hour, minute))
def get_fake_jobs(now: datetime) -> list[FakeReconJob]:
today = now.date()
yesterday = today - timedelta(days=1)
sample_result_a = {
"Job": "FX Settlement Recon",
"As-at": yesterday.isoformat(),
"Matched": 1284,
"Unmatched": 12,
"Status": "Matched",
"Flag": "None",
}
sample_result_b = {
"Job": "Cash vs Ledger",
"As-at": yesterday.isoformat(),
"Matched": 980,
"Unmatched": 47,
"Status": "Unmatched",
"Flag": "Threshold Breach",
}
sample_result_c = {
"Job": "Intraday Liquidity Sweep",
"As-at": today.isoformat(),
"Matched": 311,
"Unmatched": 0,
"Status": "Matched",
"Flag": "None",
}
jobs: list[FakeReconJob] = [
# Yesterday — completed runs and one failure
FakeReconJob(
id=101, name="FX Settlement Recon", as_at_date=yesterday,
recon_config_reference="fx-settlement",
due_datetime=_at(yesterday, 6, 0),
start_datetime=_at(yesterday, 6, 2),
finish_datetime=_at(yesterday, 6, 47),
status="completed", results=sample_result_a,
),
FakeReconJob(
id=102, name="Cash vs Ledger", as_at_date=yesterday,
recon_config_reference="cash-vs-ledger",
due_datetime=_at(yesterday, 9, 30),
start_datetime=_at(yesterday, 9, 33),
finish_datetime=_at(yesterday, 10, 12),
status="completed", results=sample_result_b,
),
FakeReconJob(
id=103, name="Intraday Liquidity Sweep", as_at_date=yesterday,
recon_config_reference="intraday-liquidity",
due_datetime=_at(yesterday, 13, 0),
start_datetime=_at(yesterday, 13, 4),
finish_datetime=_at(yesterday, 13, 35),
status="completed",
results={"Job": "Intraday Liquidity Sweep", "As-at": yesterday.isoformat(),
"Matched": 290, "Unmatched": 4, "Status": "Matched", "Flag": "None"},
),
FakeReconJob(
id=104, name="EOD Position Recon", as_at_date=yesterday,
recon_config_reference="eod-positions",
due_datetime=_at(yesterday, 18, 0),
start_datetime=_at(yesterday, 18, 5),
finish_datetime=_at(yesterday, 19, 50),
status="completed",
),
FakeReconJob(
id=105, name="Regulatory Reporting Recon", as_at_date=yesterday,
recon_config_reference="monthly-reg",
due_datetime=_at(yesterday, 22, 0),
start_datetime=_at(yesterday, 22, 3),
finish_datetime=_at(yesterday, 22, 9),
status="failed", status_reason="Source feed unavailable",
),
# Today — mix of completed, running, scheduled, and a failure
FakeReconJob(
id=201, name="FX Settlement Recon", as_at_date=today,
recon_config_reference="fx-settlement",
due_datetime=_at(today, 6, 0),
start_datetime=_at(today, 6, 3),
finish_datetime=_at(today, 6, 51),
status="completed", results=sample_result_c,
),
FakeReconJob(
id=202, name="Cash vs Ledger", as_at_date=today,
recon_config_reference="cash-vs-ledger",
due_datetime=_at(today, 9, 30),
start_datetime=max(now - timedelta(minutes=18), _at(today, 0, 0)),
finish_datetime=None,
status="running",
),
FakeReconJob(
id=203, name="Intraday Liquidity Sweep", as_at_date=today,
recon_config_reference="intraday-liquidity",
due_datetime=_at(today, 13, 0) if now < _at(today, 13, 0) else now + timedelta(hours=1),
status="created",
),
FakeReconJob(
id=204, name="EOD Position Recon", as_at_date=today,
recon_config_reference="eod-positions",
due_datetime=_at(today, 18, 0) if now < _at(today, 18, 0) else now + timedelta(hours=2),
status="created",
),
FakeReconJob(
id=205, name="AML Spot Check", as_at_date=today,
recon_config_reference="ad-hoc-aml",
due_datetime=_at(today, 8, 0),
start_datetime=_at(today, 8, 2),
finish_datetime=_at(today, 8, 5),
status="failed", status_reason="Schema mismatch",
),
]
# Add a 7-day history (excluding the two days above) so totals look real.
history_template = [
("FX Settlement Recon", "fx-settlement", 6, 45, "completed"),
("Cash vs Ledger", "cash-vs-ledger", 9, 42, "completed"),
("Intraday Liquidity Sweep","intraday-liquidity", 13, 30, "completed"),
("EOD Position Recon", "eod-positions", 18, 105, "completed"),
]
next_id = 300
for d in range(2, 8):
day = today - timedelta(days=d)
for name, ref, hour, duration_min, status in history_template:
start = _at(day, hour, 5)
jobs.append(FakeReconJob(
id=next_id, name=name, as_at_date=day,
recon_config_reference=ref,
due_datetime=_at(day, hour, 0),
start_datetime=start,
finish_datetime=start + timedelta(minutes=duration_min),
status=status,
results={"Matched": 800 + next_id % 500, "Unmatched": (next_id * 3) % 60,
"Status": "Matched" if (next_id % 5) else "Unmatched", "Flag": "None"},
))
next_id += 1
# Sprinkle in occasional failures
if d in (3, 5):
jobs.append(FakeReconJob(
id=next_id, name="Customer Master Recon", as_at_date=day,
recon_config_reference="weekly-customers",
due_datetime=_at(day, 4, 0),
start_datetime=_at(day, 4, 1),
finish_datetime=_at(day, 4, 7),
status="failed", status_reason="Upstream timeout",
))
next_id += 1
return jobs
+439 -69
View File
@@ -1,110 +1,480 @@
import logging
from typing import Annotated
from datetime import date
"""User-interface views.
from sqlalchemy.ext.asyncio import async_sessionmaker
from sqlalchemy.future import select
from fastapi import APIRouter, Request, Depends
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.core.config import templates
from app.db.schema import get_async_sessionmaker
from app.models.recon_job import ReconJob
from app.db.schema import (
ReconJob as ReconJobSchema,
ReconConfig as ReconConfigSchema
)
from app.models.recon_config import config_from_schema
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"]
)
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.
GAP = 0.2 # % units
MIN_COLLISION_WIDTH = 1.2
lane_ends: list[float] = []
for e in entries:
coll_width = max(e["width"], MIN_COLLISION_WIDTH)
coll_end = e["left"] + coll_width
placed = False
for i, end in enumerate(lane_ends):
if e["left"] >= end + GAP:
lane_ends[i] = coll_end
e["lane"] = i
placed = True
break
if not placed:
e["lane"] = len(lane_ends)
lane_ends.append(coll_end)
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
if today_jobs:
finished_today = [
j for j in today_jobs if j.start_datetime and j.finish_datetime
]
if finished_today:
longest_today = max(
finished_today,
key=lambda j: (j.finish_datetime - j.start_datetime).total_seconds(),
)
# 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 and j.due_datetime >= now),
key=lambda j: j.due_datetime,
)[: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.finish_datetime - longest_today.start_datetime).total_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": (j.finish_datetime or j.start_datetime).isoformat(timespec="minutes")
if (j.finish_datetime or j.start_datetime) else "",
}
for j in recent_failures
],
"upcoming_today": [
{
"id": j.id,
"name": j.name,
"due": j.due_datetime.strftime("%H:%M"),
}
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"},
context={
"title": "Dashboard",
"timelines": timelines,
"metrics": metrics,
},
)
@router.get("/results", response_class=HTMLResponse)
async def results_view(
request: Request,
sessionmaker: Annotated[async_sessionmaker, Depends(get_async_sessionmaker)],
cursor: int = 999999,
recon_job_name: str|None = None,
as_at_date: str|None = None
):
recon_job_name: str | None = None,
as_at_date: str | None = None,
):
PAGE_SIZE = 20
async with sessionmaker() as session:
query = (
select(ReconJobSchema)
.order_by(ReconJobSchema.id.desc())
.filter(ReconJobSchema.id < cursor)
.limit(PAGE_SIZE)
)
if recon_job_name:
query = query.filter(ReconJobSchema.name == recon_job_name)
if as_at_date:
query = query.filter(ReconJobSchema.as_at_date == as_at_date)
now = datetime.now()
jobs = get_fake_jobs(now)
result = await session.execute(query)
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]
dbjobs = result.scalars().all()
jobs.sort(key=lambda j: j.id, reverse=True)
page = [j for j in jobs if j.id < cursor][:PAGE_SIZE]
next_cursor = None
prev_cursor = None
results = []
for dbjob in dbjobs:
job = ReconJob.model_validate(dbjob)
if job.results:
results.append(job.results)
if len(dbjobs) > 0:
prev_cursor = dbjobs[0].id + PAGE_SIZE
next_cursor = dbjobs[-1].id - 1
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",
"results": 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,
},
)
@router.get("/config_helper", response_class=HTMLResponse)
async def config_helper_view(
request: Request,
sessionmaker: Annotated[async_sessionmaker, Depends(get_async_sessionmaker)],
reference: str|None = None,
as_at_date: str = f"{date.today().isoformat()}"
):
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": []}
config = None
if reference is not None:
async with sessionmaker() as session:
query = (
select(ReconConfigSchema)
.filter(ReconConfigSchema.reference == reference)
)
result = await session.execute(query)
if result:
config = config_from_schema(result.scalar_one_or_none())
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"<h1>Job #{job_id} not found</h1>"
f"<p><a href='/'>Back to dashboard</a></p>",
status_code=404,
)
# Group related executions by recon_config_reference (fallback to name)
related = [
j for j in jobs
if j.recon_config_reference == job.recon_config_reference
or j.name == job.name
]
history = _build_history_timeline(related, now)
config = next(
(c for c in configs if c.reference == job.recon_config_reference), None
)
# Duration (if finished or currently running)
duration_str = None
if job.start_datetime:
end = job.finish_datetime or now
duration_str = _fmt_duration((end - job.start_datetime).total_seconds())
# Lateness vs due
lateness_str = None
if job.start_datetime and job.due_datetime:
delta = (job.start_datetime - job.due_datetime).total_seconds()
if delta <= 0:
lateness_str = f"on time ({_fmt_duration(-delta)} early)"
else:
lateness_str = f"{_fmt_duration(delta)} late"
# Aggregate stats across history
finished_related = [j for j in related if j.start_datetime and j.finish_datetime]
avg_dur = (
sum((j.finish_datetime - j.start_datetime).total_seconds() for j in finished_related)
/ len(finished_related)
) if finished_related else None
success_count = sum(1 for j in related if j.status == "completed")
fail_count = sum(1 for j in related if j.status == "failed")
finished_count = success_count + fail_count
success_rate = (success_count / finished_count * 100.0) if finished_count else None
return templates.TemplateResponse(
request=request,
name="config_helper.html",
name="job_detail.html",
context={
"title": "Config Helper",
"title": f"{job.name} #{job.id}",
"job": job,
"config": config,
"as_at_date": as_at_date,
}
"history": history,
"duration_str": duration_str,
"lateness_str": lateness_str,
"stats": {
"total": len(related),
"completed": success_count,
"failed": fail_count,
"success_rate": success_rate,
"avg_duration": _fmt_duration(avg_dur) if avg_dur is not None else None,
},
},
)
File diff suppressed because one or more lines are too long
+389
View File
@@ -275,3 +275,392 @@ footer {
margin-top: 8px;
flex-shrink: 0;
}
/* ── Dashboard timelines ────────────────────────────────── */
.timeline-wrap {
display: flex;
flex-direction: column;
gap: 22px;
overflow-y: auto;
flex: 1;
min-height: 0;
padding-right: 4px;
}
.timeline-day {
background: rgba(255, 255, 255, 0.04);
border: 1px solid #2d2d3a;
border-radius: 10px;
padding: 14px 18px 22px;
}
.timeline-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
flex-wrap: wrap;
gap: 10px;
}
.timeline-header h2 {
font-size: 16px;
font-weight: 600;
color: #f1f5f9;
letter-spacing: 0.02em;
}
.timeline-today-tag {
display: inline-block;
margin-left: 8px;
padding: 2px 8px;
border-radius: 999px;
background: rgba(99, 102, 241, 0.18);
color: #a5b4fc;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.timeline-legend {
display: flex;
gap: 14px;
font-size: 12px;
color: #94a3b8;
}
.legend-item {
display: inline-flex;
align-items: center;
gap: 6px;
}
.swatch {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 3px;
}
.swatch-completed { background: #4ade80; }
.swatch-running { background: #facc15; }
.swatch-scheduled { background: #818cf8; }
.timeline-axis {
position: relative;
height: 18px;
margin: 0 0 4px;
}
.tick {
position: absolute;
top: 0;
transform: translateX(-50%);
color: #64748b;
font-size: 10px;
letter-spacing: 0.05em;
}
.tick-label { user-select: none; }
.timeline-track {
--lane-count: 1;
--lane-height: 26px;
position: relative;
background: #0f1117;
border: 1px solid #22222e;
border-radius: 6px;
min-height: 32px;
height: calc(var(--lane-count) * var(--lane-height) + 6px);
padding: 3px 0;
overflow: hidden;
}
.gridline {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
background: rgba(148, 163, 184, 0.08);
pointer-events: none;
}
.now-marker {
position: absolute;
top: 0;
bottom: 0;
width: 2px;
background: #ef4444;
box-shadow: 0 0 6px rgba(239, 68, 68, 0.6);
pointer-events: none;
z-index: 3;
}
.timeline-empty {
color: #475569;
font-size: 12px;
text-align: center;
padding: 30px 0;
}
.job-bar {
--lane: 0;
position: absolute;
height: 22px;
top: calc(var(--lane) * var(--lane-height, 26px) + 3px);
border-radius: 4px;
padding: 0 6px;
display: flex;
align-items: center;
overflow: hidden;
color: #0f172a;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
z-index: 2;
min-width: 4px;
}
.job-bar-label {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.job-bar-completed { background: rgba(74, 222, 128, 0.85); }
.job-bar-running { background: rgba(250, 204, 21, 0.85); }
.job-bar-scheduled {
background: rgba(129, 140, 248, 0.85);
border: 1px dashed rgba(255, 255, 255, 0.25);
color: #1e1b4b;
}
/* ── Dashboard metrics cards ────────────────────────────── */
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 14px;
margin-top: 18px;
padding-bottom: 4px;
}
.metric-card {
background: rgba(255, 255, 255, 0.04);
border: 1px solid #2d2d3a;
border-radius: 10px;
padding: 14px 16px 16px;
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.metric-card h3 {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #94a3b8;
margin: 0;
padding-bottom: 6px;
border-bottom: 1px solid #2d2d3a;
}
.metric-card .num { font-variant-numeric: tabular-nums; text-align: right; }
.metric-card .good { color: #4ade80; }
.metric-card .bad { color: #f87171; }
.metric-card .warn { color: #facc15; }
.metric-card .muted { color: #64748b; font-size: 12px; }
.big-stat {
display: flex;
align-items: baseline;
gap: 8px;
}
.big-stat-value {
font-size: 36px;
font-weight: 700;
color: #f1f5f9;
letter-spacing: -0.01em;
line-height: 1;
}
.big-stat-sub { color: #94a3b8; font-size: 12px; }
.kv-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.kv-list li {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 10px;
font-size: 13px;
color: #cbd5e1;
line-height: 1.35;
}
.kv-list li span:first-child {
overflow: hidden;
text-overflow: ellipsis;
}
.metric-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.metric-table th, .metric-table td {
padding: 6px 6px;
text-align: right;
color: #cbd5e1;
}
.metric-table thead th {
color: #94a3b8;
font-weight: 600;
font-size: 11px;
letter-spacing: 0.04em;
border-bottom: 1px solid #2d2d3a;
}
.metric-table tbody th {
text-align: left;
color: #94a3b8;
font-weight: 500;
}
.metric-table tbody tr + tr td,
.metric-table tbody tr + tr th { border-top: 1px solid rgba(45, 45, 58, 0.6); }
.empty-note {
font-size: 13px;
padding: 6px 0;
}
/* Dashboard variant: allow content to flow & page to scroll naturally. */
.dashboard-container.dashboard-stack {
height: auto;
min-height: calc(100vh - 86px);
}
.dashboard-container.dashboard-stack .timeline-wrap {
flex: 0 0 auto;
overflow: visible;
}
/* ── Clickable rows / job links ─────────────────────────── */
.row-link {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 10px;
width: 100%;
color: inherit;
text-decoration: none;
padding: 2px 4px;
margin: -2px -4px;
border-radius: 4px;
transition: background 0.15s ease;
}
.row-link:hover { background: rgba(99, 102, 241, 0.12); }
.job-link {
color: #a5b4fc;
text-decoration: none;
font-weight: 600;
}
.job-link:hover { text-decoration: underline; }
.job-link .muted { font-weight: 400; }
a.job-bar { text-decoration: none; cursor: pointer; }
a.job-bar:hover { filter: brightness(1.1); box-shadow: 0 0 0 1px rgba(255,255,255,0.4); }
/* ── Job detail page ────────────────────────────────────── */
.detail-header {
margin-bottom: 14px;
padding-bottom: 10px;
border-bottom: 2px solid #2d2d3a;
}
.detail-header h1 {
border-bottom: none;
margin-bottom: 4px;
padding-bottom: 0;
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.detail-crumbs {
font-size: 13px;
color: #94a3b8;
margin-bottom: 6px;
}
.detail-crumbs a {
color: #a5b4fc;
text-decoration: none;
}
.detail-crumbs a:hover { text-decoration: underline; }
.detail-subtitle {
font-size: 13px;
margin-top: 4px;
}
.detail-subtitle code,
.kv-list code,
.metric-table code {
background: rgba(255,255,255,0.06);
padding: 1px 6px;
border-radius: 4px;
font-size: 12px;
color: #cbd5e1;
}
.swatch-failed { background: #f87171; }
.badge-status-completed { background: rgba(34,197,94,0.18); color: #4ade80; font-size: 13px; padding: 4px 12px; }
.badge-status-failed { background: rgba(239,68,68,0.18); color: #f87171; font-size: 13px; padding: 4px 12px; }
.badge-status-running { background: rgba(234,179,8,0.18); color: #facc15; font-size: 13px; padding: 4px 12px; }
.badge-status-created { background: rgba(129,140,248,0.18); color: #a5b4fc; font-size: 13px; padding: 4px 12px; }
.badge-status-cancelled { background: rgba(100,116,139,0.18); color: #94a3b8; font-size: 13px; padding: 4px 12px; }
.history-timeline { padding: 14px 18px 16px; }
.history-track {
position: relative;
background: #0f1117;
border: 1px solid #22222e;
border-radius: 6px;
height: 56px;
margin-bottom: 6px;
overflow: hidden;
}
.history-dot {
position: absolute;
top: 50%;
width: 14px;
height: 14px;
margin-left: -7px;
margin-top: -7px;
border-radius: 50%;
background: #4ade80;
box-shadow: 0 0 0 2px rgba(15, 17, 23, 0.9);
transition: transform 0.15s ease, box-shadow 0.15s ease;
z-index: 2;
}
.history-dot:hover {
transform: scale(1.35);
z-index: 3;
}
.history-dot-completed { background: #4ade80; }
.history-dot-failed { background: #f87171; }
.history-dot-running { background: #facc15; }
.history-dot-scheduled {
background: #818cf8;
border: 1px dashed rgba(255,255,255,0.35);
}
.history-dot.is-current {
width: 18px;
height: 18px;
margin-left: -9px;
margin-top: -9px;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.55), 0 0 12px rgba(99,102,241,0.6);
z-index: 4;
}
.history-footnote {
font-size: 12px;
padding-top: 4px;
}
.metric-card-wide {
grid-column: 1 / -1;
}
.results-table tbody th {
width: 30%;
padding-right: 14px;
}
.results-table tbody td {
text-align: left;
}
.nested-item { display: block; font-size: 13px; }
.nested-key { color: #94a3b8; margin-right: 6px; }
+3 -3
View File
@@ -5,8 +5,8 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Recon Ranger</title>
<link id="favicon" rel="icon" type="image/x-icon" href="static/images/favicon.ico">
<link rel="stylesheet" href="static/css/styles.css">
<link id="favicon" rel="icon" type="image/x-icon" href="/static/images/favicon.ico">
<link rel="stylesheet" href="/static/css/styles.css">
</head>
<body>
@@ -19,7 +19,7 @@
<ul>
<li><a href="/">Home</a></li>
<li><a href="/results">Results</a></li>
<li><a href="api/docs/">API</a></li>
<li><a href="/api/docs/">API</a></li>
<li><a href="mailto:someone@example.com">Contact</a></li>
</ul>
</div>
+185 -2
View File
@@ -1,8 +1,191 @@
{% extends "base.html" %}
{% block content %}
<div class="dashboard-container">
<div class="dashboard-container dashboard-stack">
<h1>{{ title }}</h1>
<p>Dashboard coming soon.</p>
<div class="timeline-wrap">
{% for tl in timelines %}
<section class="timeline-day">
<header class="timeline-header">
<h2>{{ tl.date.strftime('%a %d %b %Y') }}{% if tl.is_today %} <span class="timeline-today-tag">Today</span>{% endif %}</h2>
<div class="timeline-legend">
<span class="legend-item"><span class="swatch swatch-completed"></span>Completed</span>
<span class="legend-item"><span class="swatch swatch-running"></span>Running</span>
<span class="legend-item"><span class="swatch swatch-scheduled"></span>Scheduled</span>
</div>
</header>
<div class="timeline-axis">
{% for h in tl.hours %}
<span class="tick" style="left: {{ (h / 24 * 100) }}%">
<span class="tick-label">{{ '%02d' % h }}</span>
</span>
{% endfor %}
</div>
<div class="timeline-track" style="--lane-count: {{ tl.lane_count }}">
{% for h in tl.hours %}
<span class="gridline" style="left: {{ (h / 24 * 100) }}%"></span>
{% endfor %}
{% if tl.now_pct is not none %}
<span class="now-marker" style="left: {{ tl.now_pct }}%" title="Now"></span>
{% endif %}
{% if tl.entries | length == 0 %}
<div class="timeline-empty">No jobs</div>
{% else %}
{% for e in tl.entries %}
<a class="job-bar job-bar-{{ e.kind }}"
href="/jobs/{{ e.id }}"
style="left: {{ e.left }}%; width: {{ e.width }}%; --lane: {{ e.lane }}"
title="{{ e.name }} — {{ e.kind }}{% if e.start %} | start {{ e.start }}{% endif %}{% if e.finish %} | finish {{ e.finish }}{% endif %}{% if e.due and not e.start %} | due {{ e.due }}{% endif %}">
<span class="job-bar-label">{{ e.name }}</span>
</a>
{% endfor %}
{% endif %}
</div>
</section>
{% endfor %}
</div>
{# ── Metrics mini-reports ─────────────────────────────── #}
{% set m = metrics %}
<div class="metrics-grid">
<section class="metric-card">
<h3>Job Outcomes</h3>
<table class="metric-table">
<thead>
<tr><th></th><th>✓ Done</th><th>✗ Failed</th><th>Running</th><th>Pending</th><th>Success</th></tr>
</thead>
<tbody>
<tr>
<th>Today</th>
<td class="num good">{{ m.jobs.today.completed }}</td>
<td class="num bad">{{ m.jobs.today.failed }}</td>
<td class="num warn">{{ m.jobs.today.running }}</td>
<td class="num">{{ m.jobs.today.created }}</td>
<td class="num">{% if m.jobs.success_rate_today is not none %}{{ '%.0f' % m.jobs.success_rate_today }}%{% else %}—{% endif %}</td>
</tr>
<tr>
<th>Yesterday</th>
<td class="num good">{{ m.jobs.yesterday.completed }}</td>
<td class="num bad">{{ m.jobs.yesterday.failed }}</td>
<td class="num">{{ m.jobs.yesterday.running }}</td>
<td class="num">{{ m.jobs.yesterday.created }}</td>
<td class="num">{% if m.jobs.success_rate_yesterday is not none %}{{ '%.0f' % m.jobs.success_rate_yesterday }}%{% else %}—{% endif %}</td>
</tr>
<tr>
<th>Last 7 days</th>
<td class="num good">{{ m.jobs.week.completed }}</td>
<td class="num bad">{{ m.jobs.week.failed }}</td>
<td class="num">{{ m.jobs.week.running }}</td>
<td class="num">{{ m.jobs.week.created }}</td>
<td class="num">{% if m.jobs.success_rate_week is not none %}{{ '%.0f' % m.jobs.success_rate_week }}%{% else %}—{% endif %}</td>
</tr>
<tr>
<th>All time</th>
<td class="num good">{{ m.jobs.total.completed }}</td>
<td class="num bad">{{ m.jobs.total.failed }}</td>
<td class="num">{{ m.jobs.total.running }}</td>
<td class="num">{{ m.jobs.total.created }}</td>
<td class="num">{% if m.jobs.success_rate_total is not none %}{{ '%.0f' % m.jobs.success_rate_total }}%{% else %}—{% endif %}</td>
</tr>
</tbody>
</table>
</section>
<section class="metric-card">
<h3>Active Configs</h3>
<div class="big-stat">
<span class="big-stat-value">{{ m.configs.active }}</span>
<span class="big-stat-sub">of {{ m.configs.total }} total</span>
</div>
<ul class="kv-list">
{% for freq, n in m.configs.by_frequency %}
<li><span>{{ freq }}</span><span class="num">{{ n }}</span></li>
{% endfor %}
<li class="muted"><span>Drafts</span><span class="num">{{ m.configs.draft }}</span></li>
{% if m.configs.archived %}
<li class="muted"><span>Archived</span><span class="num">{{ m.configs.archived }}</span></li>
{% endif %}
</ul>
</section>
<section class="metric-card">
<h3>Performance — Today</h3>
<ul class="kv-list">
<li><span>Avg duration</span><span class="num">{{ m.performance.avg_duration_today or '—' }}</span></li>
<li><span>Longest run</span>
<span class="num" title="{{ m.performance.longest_today_name or '' }}">
{{ m.performance.longest_today_duration or '—' }}
</span>
</li>
{% if m.performance.longest_today_name %}
<li class="muted"><span>↳ {{ m.performance.longest_today_name }}</span><span></span></li>
{% endif %}
<li>
<span>On-time start</span>
<span class="num">
{% if m.performance.on_time_rate_today is not none %}
{{ '%.0f' % m.performance.on_time_rate_today }}%
<span class="muted">({{ m.performance.on_time }}/{{ m.performance.on_time + m.performance.late }})</span>
{% else %}—{% endif %}
</span>
</li>
</ul>
</section>
<section class="metric-card">
<h3>Match Quality</h3>
<ul class="kv-list">
<li><span>Matched (today)</span><span class="num good">{{ '{:,}'.format(m.match_quality.matched_today) }}</span></li>
<li><span>Unmatched (today)</span><span class="num bad">{{ '{:,}'.format(m.match_quality.unmatched_today) }}</span></li>
<li><span>Match rate (today)</span>
<span class="num">{% if m.match_quality.match_rate_today is not none %}{{ '%.1f' % m.match_quality.match_rate_today }}%{% else %}—{% endif %}</span>
</li>
<li class="muted"><span>Match rate (7d)</span>
<span class="num">{% if m.match_quality.match_rate_week is not none %}{{ '%.1f' % m.match_quality.match_rate_week }}%{% else %}—{% endif %}</span>
</li>
</ul>
</section>
<section class="metric-card">
<h3>Recent Failures</h3>
{% if m.recent_failures %}
<ul class="kv-list">
{% for f in m.recent_failures %}
<li>
<a class="row-link" href="/jobs/{{ f.id }}">
<span><strong>{{ f.name }}</strong><br><span class="muted">{{ f.when }} · {{ f.reason }}</span></span>
</a>
</li>
{% endfor %}
</ul>
{% else %}
<p class="empty-note good">No recent failures 🎉</p>
{% endif %}
</section>
<section class="metric-card">
<h3>Upcoming Today</h3>
{% if m.upcoming_today %}
<ul class="kv-list">
{% for u in m.upcoming_today %}
<li>
<a class="row-link" href="/jobs/{{ u.id }}">
<span>{{ u.name }}</span><span class="num">{{ u.due }}</span>
</a>
</li>
{% endfor %}
</ul>
{% else %}
<p class="empty-note muted">Nothing scheduled for the rest of today.</p>
{% endif %}
</section>
</div>
</div>
{% endblock %}
+182
View File
@@ -0,0 +1,182 @@
{% extends "base.html" %}
{% macro render_nested(value) %}
{% if value is mapping %}
{% for k, v in value.items() %}
<div class="nested-item">
<span class="nested-key">{{ k }}:</span>
<span class="nested-value">{{ render_nested(v) }}</span>
</div>
{% endfor %}
{% elif value is iterable and not value is string %}
{% for item in value %}{{ render_nested(item) }}{% endfor %}
{% else %}
{{ value }}
{% endif %}
{% endmacro %}
{% block content %}
<div class="dashboard-container dashboard-stack">
<div class="detail-header">
<div class="detail-crumbs">
<a href="/">Dashboard</a>
<a href="/results">Results</a>
<span class="muted">Job #{{ job.id }}</span>
</div>
<h1>
{{ job.name }}
<span class="badge badge-status-{{ job.status }}">{{ job.status }}</span>
</h1>
<div class="detail-subtitle muted">
As-at {{ job.as_at_date.isoformat() }}
· config <code>{{ job.recon_config_reference }}</code>
· ran by {{ job.username }}
</div>
</div>
{# ── Execution history timeline ─────────────────────── #}
<section class="timeline-day history-timeline">
<header class="timeline-header">
<h2>Execution history</h2>
<div class="timeline-legend">
<span class="legend-item"><span class="swatch swatch-completed"></span>Completed</span>
<span class="legend-item"><span class="swatch swatch-failed"></span>Failed</span>
<span class="legend-item"><span class="swatch swatch-running"></span>Running</span>
<span class="legend-item"><span class="swatch swatch-scheduled"></span>Scheduled</span>
</div>
</header>
{% if history.entries | length == 0 %}
<p class="empty-note muted">No executions on record.</p>
{% else %}
<div class="timeline-axis">
{% for t in history.ticks %}
<span class="tick" style="left: {{ t.left }}%">
<span class="tick-label">{{ t.label }}</span>
</span>
{% endfor %}
</div>
<div class="history-track">
{% for t in history.ticks %}
<span class="gridline" style="left: {{ t.left }}%"></span>
{% endfor %}
{% for e in history.entries %}
<a class="history-dot history-dot-{{ e.kind }}{% if e.id == job.id %} is-current{% endif %}"
href="/jobs/{{ e.id }}"
style="left: {{ e.left }}%"
title="{{ e.name }} · {{ e.as_at }}{% if e.when %} · {{ e.when }}{% endif %} · {{ e.status }}">
</a>
{% endfor %}
</div>
<div class="history-footnote muted">
{{ history.entries | length }} executions
from {{ history.first_date.isoformat() }} to {{ history.last_date.isoformat() }}
({{ history.days }} days)
</div>
{% endif %}
</section>
{# ── Attribute / result cards ───────────────────────── #}
<div class="metrics-grid">
<section class="metric-card">
<h3>Identification</h3>
<ul class="kv-list">
<li><span>Job ID</span><span class="num">#{{ job.id }}</span></li>
<li><span>Name</span><span>{{ job.name }}</span></li>
<li><span>Config ref</span><span><code>{{ job.recon_config_reference }}</code></span></li>
<li><span>Username</span><span>{{ job.username }}</span></li>
<li><span>As-at date</span><span>{{ job.as_at_date.isoformat() }}</span></li>
</ul>
</section>
<section class="metric-card">
<h3>Schedule &amp; Timing</h3>
<ul class="kv-list">
<li><span>Due</span><span>{{ job.due_datetime.isoformat(timespec='minutes') if job.due_datetime else '—' }}</span></li>
<li><span>Started</span><span>{{ job.start_datetime.isoformat(timespec='minutes') if job.start_datetime else '—' }}</span></li>
<li><span>Finished</span><span>{{ job.finish_datetime.isoformat(timespec='minutes') if job.finish_datetime else '—' }}</span></li>
<li><span>Duration</span><span class="num">{{ duration_str or '—' }}</span></li>
<li><span>Lateness</span><span>{{ lateness_str or '—' }}</span></li>
</ul>
</section>
<section class="metric-card">
<h3>Status</h3>
<div class="big-stat">
<span class="big-stat-value badge badge-status-{{ job.status }}">{{ job.status }}</span>
</div>
{% if job.status_reason %}
<p class="empty-note muted">Reason: {{ job.status_reason }}</p>
{% endif %}
</section>
<section class="metric-card">
<h3>Config</h3>
{% if config %}
<ul class="kv-list">
<li><span>Name</span><span>{{ config.name }}</span></li>
<li><span>Reference</span><span><code>{{ config.reference }}</code></span></li>
<li><span>Status</span><span>{{ config.status }}</span></li>
<li><span>Frequency</span><span>{{ config.frequency }}</span></li>
<li><span>Business process</span><span>{{ config.business_process }}</span></li>
</ul>
{% else %}
<p class="empty-note muted">Config <code>{{ job.recon_config_reference }}</code> not found.</p>
{% endif %}
</section>
<section class="metric-card">
<h3>History — this recon</h3>
<ul class="kv-list">
<li><span>Total executions</span><span class="num">{{ stats.total }}</span></li>
<li><span>Completed</span><span class="num good">{{ stats.completed }}</span></li>
<li><span>Failed</span><span class="num bad">{{ stats.failed }}</span></li>
<li><span>Success rate</span>
<span class="num">
{% if stats.success_rate is not none %}{{ '%.0f' % stats.success_rate }}%{% else %}—{% endif %}
</span>
</li>
<li><span>Avg duration</span><span class="num">{{ stats.avg_duration or '—' }}</span></li>
</ul>
</section>
{% if job.results %}
<section class="metric-card metric-card-wide">
<h3>Results</h3>
<table class="metric-table results-table">
<tbody>
{% for k, v in job.results.items() %}
<tr>
<th>{{ k }}</th>
<td>
{% if k == 'Status' %}
<span class="badge badge-{{ v | lower }}">{{ v }}</span>
{% elif k == 'Flag' %}
<span class="badge badge-{{ 'none' if v == 'None' else 'flag' }}">{{ v }}</span>
{% else %}
{{ render_nested(v) }}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
{% else %}
<section class="metric-card">
<h3>Results</h3>
<p class="empty-note muted">
{% if job.status in ('created', 'running') %}
This job has not produced results yet.
{% else %}
No results recorded for this run.
{% endif %}
</p>
</section>
{% endif %}
</div>
</div>
{% endblock %}
+8 -4
View File
@@ -55,20 +55,24 @@
})();
</script>
{% if results | length > 0 %}
{% if result_rows | length > 0 %}
<div class="table-scroll">
<table class="data-table">
<thead>
<tr>
{% for key in results[0].keys() %}
<th>Job</th>
{% for key in result_rows[0].data.keys() %}
<th>{{ key }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for row in results %}
{% for row in result_rows %}
<tr>
{% for key, value in row.items() %}
<td>
<a class="job-link" href="/jobs/{{ row.job_id }}">{{ row.name }}<span class="muted"> #{{ row.job_id }}</span></a>
</td>
{% for key, value in row.data.items() %}
<td>
{% if key == 'Status' %}
<span class="badge badge-{{ value | lower }}">{{ value }}</span>