diff --git a/app/main.py b/app/main.py index 87e2c52..ed8bc9c 100644 --- a/app/main.py +++ b/app/main.py @@ -5,10 +5,47 @@ import json import os import sqlite3 from contextlib import asynccontextmanager, closing -from datetime import datetime +from datetime import datetime, timezone from pathlib import Path from typing import Any + +def _fmt_dur(seconds: float) -> str: + if seconds is None or seconds < 0: + return "" + s = int(seconds) + if s < 60: + return f"{s}s" + m, s = divmod(s, 60) + if m < 60: + return f"{m}m{s:02d}s" if s else f"{m}m" + h, m = divmod(m, 60) + if h < 24: + return f"{h}h{m:02d}m" if m else f"{h}h" + d, h = divmod(h, 24) + return f"{d}d{h:02d}h" + + +def _parse_ts(s: str | None) -> datetime | None: + if not s: + return None + try: + return datetime.fromisoformat(s.replace("Z", "+00:00")) + except Exception: + return None + + +def _job_duration_s(job: sqlite3.Row) -> int: + start = _parse_ts(job["started_at"]) + end = _parse_ts(job["finished_at"]) or datetime.now(timezone.utc) + if not start: + return 0 + if start.tzinfo is None: + start = start.replace(tzinfo=timezone.utc) + if end.tzinfo is None: + end = end.replace(tzinfo=timezone.utc) + return int((end - start).total_seconds()) + from fastapi import FastAPI, Form, HTTPException, Request from fastapi.responses import HTMLResponse, JSONResponse from fastapi.staticfiles import StaticFiles @@ -79,19 +116,41 @@ templates = Jinja2Templates(directory=Path(__file__).parent / "templates") app.mount("/static", StaticFiles(directory=Path(__file__).parent / "static"), name="static") +def _build_acquisitions(): + with closing(db()) as conn: + acqs = conn.execute( + "SELECT * FROM acquisitions ORDER BY created_at DESC" + ).fetchall() + jobs = conn.execute( + "SELECT * FROM jobs ORDER BY auv, gopro_serial, segment_label" + ).fetchall() + by_acq: dict[int, list[dict]] = {} + by_acq_total: dict[int, int] = {} + for j in jobs: + d = dict(j) + dur_s = _job_duration_s(j) + d["_duration"] = _fmt_dur(dur_s) + by_acq.setdefault(j["acquisition_id"], []).append(d) + by_acq_total[j["acquisition_id"]] = by_acq_total.get(j["acquisition_id"], 0) + dur_s + + return [ + { + "id": acq["id"], + "name": acq["name"], + "source_path": acq["source_path"], + "jobs": by_acq.get(acq["id"], []), + "total_duration": _fmt_dur(by_acq_total.get(acq["id"], 0)), + } + for acq in acqs + ] + + @app.get("/", response_class=HTMLResponse) async def index(request: Request): - with closing(db()) as conn: - jobs = conn.execute(""" - SELECT j.*, a.name AS acquisition_name - FROM jobs j - LEFT JOIN acquisitions a ON a.id = j.acquisition_id - ORDER BY j.created_at DESC - LIMIT 200 - """).fetchall() + acquisitions = _build_acquisitions() return templates.TemplateResponse("index.html", { "request": request, - "jobs": jobs, + "acquisitions": acquisitions, "workers": WORKERS, }) @@ -105,15 +164,10 @@ async def list_jobs(): @app.get("/partials/jobs", response_class=HTMLResponse) async def partial_jobs(request: Request): - with closing(db()) as conn: - jobs = conn.execute(""" - SELECT j.*, a.name AS acquisition_name - FROM jobs j - LEFT JOIN acquisitions a ON a.id = j.acquisition_id - ORDER BY j.created_at DESC - LIMIT 200 - """).fetchall() - return templates.TemplateResponse("_jobs_table.html", {"request": request, "jobs": jobs}) + return templates.TemplateResponse( + "_jobs_table.html", + {"request": request, "acquisitions": _build_acquisitions()}, + ) @app.get("/partials/monitor", response_class=HTMLResponse) diff --git a/app/static/style.css b/app/static/style.css index 9dd500a..c8ab06f 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -46,21 +46,49 @@ progress::-webkit-progress-bar { background: #0a1020; } progress::-webkit-progress-value { background: var(--accent); } progress::-moz-progress-bar { background: var(--accent); } -table.jobs { width: 100%; border-collapse: collapse; font-size: 0.85rem; } -table.jobs th, table.jobs td { text-align: left; padding: 0.45rem 0.55rem; - border-bottom: 1px solid var(--border); } -table.jobs th { color: var(--muted); font-weight: normal; text-transform: uppercase; - font-size: 0.72rem; letter-spacing: 0.04em; } -tr.status-done td { color: var(--ok); } -tr.status-error td { color: var(--err); } -tr.err-row td { color: var(--err); padding-top: 0; border-top: none; } +.acq-grid { display: grid; gap: 1rem; + grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); } +.acq-col { background: rgba(255,255,255,0.02); border: 1px solid var(--border); + border-radius: 8px; padding: 0.75rem; } +.acq-title { color: var(--text); font-size: 0.95rem; margin: 0 0 0.75rem; + padding-bottom: 0.5rem; border-bottom: 1px solid var(--border); + display: flex; justify-content: space-between; align-items: baseline; } +.acq-title .total { color: var(--muted); font-size: 0.8rem; font-weight: normal; } -.pill { padding: 0.15rem 0.5rem; border-radius: 999px; font-size: 0.7rem; - background: rgba(255,255,255,0.05); border: 1px solid var(--border); } -.pill.queued { color: var(--muted); } -.pill.extracting, .pill.running { color: var(--warn); border-color: var(--warn); } -.pill.done { color: var(--ok); border-color: var(--ok); } -.pill.error { color: var(--err); border-color: var(--err); } +.job-list { list-style: none; margin: 0; padding: 0; } +.job-list .job-item { display: grid; + grid-template-columns: 20px 1fr auto 20px; gap: 0.5rem; + align-items: center; padding: 0.25rem 0; font-size: 0.85rem; } +.job-list .job-item .icon { display: inline-flex; align-items: center; justify-content: center; + width: 18px; height: 18px; border-radius: 3px; } +.job-list .job-item.done .icon { background: var(--ok); color: #062410; font-weight: bold; } +.job-list .job-item.running .icon, +.job-list .job-item.extracting .icon { color: var(--accent); } +.job-list .job-item.running .icon .spin, +.job-list .job-item.extracting .icon .spin { display: inline-block; + animation: spin 1.2s linear infinite; } +.job-list .job-item.queued .icon { color: var(--muted); } +.job-list .job-item.error .icon { background: var(--err); color: #fff; font-weight: bold; } + +.job-list .label { color: var(--text); overflow: hidden; text-overflow: ellipsis; + white-space: nowrap; } +.job-list .dur { color: var(--muted); font-size: 0.78rem; } +.job-list .ext { margin-left: 0.5rem; color: var(--accent); font-size: 0.75rem; } + +.err-line { color: var(--err); font-size: 0.75rem; + padding-left: 28px; padding-bottom: 0.25rem; } + +.stitch-section { margin-top: 0.75rem; padding-top: 0.6rem; + border-top: 1px dashed var(--border); } +.stitch-title { display: flex; align-items: center; gap: 0.5rem; + color: var(--text); font-size: 0.85rem; margin-bottom: 0.25rem; } +.stitch-children { list-style: none; padding: 0 0 0 1.4rem; margin: 0; } +.stitch-children .sub { color: var(--muted); font-size: 0.8rem; + padding: 0.15rem 0; display: flex; align-items: center; gap: 0.4rem; } + +button.mini { padding: 0 0.4rem; font-size: 0.75rem; line-height: 1.4; } + +@keyframes spin { to { transform: rotate(360deg); } } button { background: transparent; color: var(--accent); border: 1px solid var(--border); padding: 0.2rem 0.6rem; border-radius: 6px; cursor: pointer; font-family: inherit; font-size: 0.75rem; } diff --git a/app/templates/_jobs_table.html b/app/templates/_jobs_table.html index 0063b96..7a0826b 100644 --- a/app/templates/_jobs_table.html +++ b/app/templates/_jobs_table.html @@ -1,44 +1,61 @@ -{% if not jobs %} -

Aucun job. Ingeste un dossier d'acquisition via scripts/ingest.py.

+{% macro duration(job) -%} +{%- if job.started_at and job.finished_at -%} + {{ job._duration }} +{%- elif job.started_at and not job.finished_at -%} + {{ job._duration }} +{%- else -%}  +{%- endif -%} +{%- endmacro %} + +{% if not acquisitions %} +

Aucune acquisition. Ingeste un dossier via scripts/ingest.py.

{% else %} - - - - - - - - - {% for j in jobs %} - - - - - - - - - - - - - {% if j.error %} - - {% endif %} - {% endfor %} - -
#AcquisitionAUVGoProSegmentFramesStatusWorkerProgressActions
{{ j.id }}{{ j.acquisition_name }}{{ j.auv }}{{ j.gopro_serial }}{{ j.segment_label }}{{ j.frame_count or "—" }}{{ j.status }}{{ j.worker_host or "—" }} - {% if j.status == 'running' or j.status == 'extracting' %} - {{ j.progress }}% - {% elif j.status == 'done' and j.viser_url %} - viser - {% if j.ply_path %} · PLY{% endif %} - {% else %}—{% endif %} - - {% if j.status in ['queued','extracting','running'] %} - - {% elif j.status == 'error' %} - - {% endif %} -
{{ j.error }}
+
+ {% for acq in acquisitions %} +
+

+ {{ acq.name }} {{ acq.total_duration }} +

+ + + + {# Stitch section (placeholder — wired up once multi-job stitching lands) #} +
+
+ + stitch +
+
    +
  • pair GP1↔GP2 per AUV
  • +
  • cross-AUV merge
  • +
  • final PLY
  • +
+
+
+ {% endfor %} +
{% endif %}