dashboard — colonnes par acquisition, checklist verticale hierarchique

This commit is contained in:
2026-04-21 10:08:38 +00:00
parent 47b082fd9d
commit 3b005a4994
3 changed files with 174 additions and 75 deletions

View File

@@ -5,10 +5,47 @@ import json
import os import os
import sqlite3 import sqlite3
from contextlib import asynccontextmanager, closing from contextlib import asynccontextmanager, closing
from datetime import datetime from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any 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 import FastAPI, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles 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") 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) @app.get("/", response_class=HTMLResponse)
async def index(request: Request): async def index(request: Request):
with closing(db()) as conn: acquisitions = _build_acquisitions()
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("index.html", { return templates.TemplateResponse("index.html", {
"request": request, "request": request,
"jobs": jobs, "acquisitions": acquisitions,
"workers": WORKERS, "workers": WORKERS,
}) })
@@ -105,15 +164,10 @@ async def list_jobs():
@app.get("/partials/jobs", response_class=HTMLResponse) @app.get("/partials/jobs", response_class=HTMLResponse)
async def partial_jobs(request: Request): async def partial_jobs(request: Request):
with closing(db()) as conn: return templates.TemplateResponse(
jobs = conn.execute(""" "_jobs_table.html",
SELECT j.*, a.name AS acquisition_name {"request": request, "acquisitions": _build_acquisitions()},
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})
@app.get("/partials/monitor", response_class=HTMLResponse) @app.get("/partials/monitor", response_class=HTMLResponse)

View File

@@ -46,21 +46,49 @@ progress::-webkit-progress-bar { background: #0a1020; }
progress::-webkit-progress-value { background: var(--accent); } progress::-webkit-progress-value { background: var(--accent); }
progress::-moz-progress-bar { background: var(--accent); } progress::-moz-progress-bar { background: var(--accent); }
table.jobs { width: 100%; border-collapse: collapse; font-size: 0.85rem; } .acq-grid { display: grid; gap: 1rem;
table.jobs th, table.jobs td { text-align: left; padding: 0.45rem 0.55rem; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); }
border-bottom: 1px solid var(--border); } .acq-col { background: rgba(255,255,255,0.02); border: 1px solid var(--border);
table.jobs th { color: var(--muted); font-weight: normal; text-transform: uppercase; border-radius: 8px; padding: 0.75rem; }
font-size: 0.72rem; letter-spacing: 0.04em; } .acq-title { color: var(--text); font-size: 0.95rem; margin: 0 0 0.75rem;
tr.status-done td { color: var(--ok); } padding-bottom: 0.5rem; border-bottom: 1px solid var(--border);
tr.status-error td { color: var(--err); } display: flex; justify-content: space-between; align-items: baseline; }
tr.err-row td { color: var(--err); padding-top: 0; border-top: none; } .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; .job-list { list-style: none; margin: 0; padding: 0; }
background: rgba(255,255,255,0.05); border: 1px solid var(--border); } .job-list .job-item { display: grid;
.pill.queued { color: var(--muted); } grid-template-columns: 20px 1fr auto 20px; gap: 0.5rem;
.pill.extracting, .pill.running { color: var(--warn); border-color: var(--warn); } align-items: center; padding: 0.25rem 0; font-size: 0.85rem; }
.pill.done { color: var(--ok); border-color: var(--ok); } .job-list .job-item .icon { display: inline-flex; align-items: center; justify-content: center;
.pill.error { color: var(--err); border-color: var(--err); } 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); 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; } padding: 0.2rem 0.6rem; border-radius: 6px; cursor: pointer; font-family: inherit; font-size: 0.75rem; }

View File

@@ -1,44 +1,61 @@
{% if not jobs %} {% macro duration(job) -%}
<p class="muted">Aucun job. Ingeste un dossier d'acquisition via <code>scripts/ingest.py</code>.</p> {%- if job.started_at and job.finished_at -%}
{{ job._duration }}
{%- elif job.started_at and not job.finished_at -%}
{{ job._duration }}
{%- else -%}&nbsp;
{%- endif -%}
{%- endmacro %}
{% if not acquisitions %}
<p class="muted">Aucune acquisition. Ingeste un dossier via <code>scripts/ingest.py</code>.</p>
{% else %} {% else %}
<table class="jobs"> <div class="acq-grid">
<thead> {% for acq in acquisitions %}
<tr> <div class="acq-col">
<th>#</th><th>Acquisition</th><th>AUV</th><th>GoPro</th><th>Segment</th> <h3 class="acq-title">
<th>Frames</th><th>Status</th><th>Worker</th><th>Progress</th><th>Actions</th> {{ acq.name }} <span class="total">{{ acq.total_duration }}</span>
</tr> </h3>
</thead>
<tbody> <ul class="job-list">
{% for j in jobs %} {% for j in acq.jobs %}
<tr class="status-{{ j.status }}"> <li class="job-item {{ j.status }}">
<td>{{ j.id }}</td> <span class="icon">
<td>{{ j.acquisition_name }}</td> {% if j.status == 'done' %}<span class="check"></span>
<td>{{ j.auv }}</td> {% elif j.status in ('running','extracting') %}<span class="spin"></span>
<td><code>{{ j.gopro_serial }}</code></td> {% elif j.status == 'error' %}<span class="err"></span>
<td>{{ j.segment_label }}</td> {% else %}<span class="sq"></span>{% endif %}
<td>{{ j.frame_count or "—" }}</td> </span>
<td><span class="pill {{ j.status }}">{{ j.status }}</span></td> <span class="label">
<td>{{ j.worker_host or "—" }}</td> {{ j.auv }}/{{ j.gopro_serial }}/{{ j.segment_label }}
<td> {% if j.status == 'done' and j.viser_url %}
{% if j.status == 'running' or j.status == 'extracting' %} <a class="ext" href="{{ j.viser_url }}" target="_blank">viser</a>
<progress value="{{ j.progress }}" max="100"></progress> {{ j.progress }}% {% endif %}
{% elif j.status == 'done' and j.viser_url %} </span>
<a href="{{ j.viser_url }}" target="_blank">viser</a> <span class="dur">{{ j._duration }}</span>
{% if j.ply_path %} · <a href="/api/jobs/{{ j.id }}/ply">PLY</a>{% endif %} {% if j.status in ('queued','extracting','running') %}
{% else %}—{% endif %} <button class="mini" hx-post="/jobs/{{ j.id }}/cancel" hx-target="#jobs-table">×</button>
</td> {% elif j.status == 'error' %}
<td> <button class="mini" hx-post="/jobs/{{ j.id }}/retry" hx-target="#jobs-table"></button>
{% if j.status in ['queued','extracting','running'] %} {% endif %}
<button hx-post="/jobs/{{ j.id }}/cancel" hx-target="#jobs-table" hx-swap="outerHTML">Stop</button> </li>
{% elif j.status == 'error' %} {% if j.error %}<li class="err-line">{{ j.error }}</li>{% endif %}
<button hx-post="/jobs/{{ j.id }}/retry" hx-target="#jobs-table" hx-swap="outerHTML">Retry</button> {% endfor %}
{% endif %} </ul>
</td>
</tr> {# Stitch section (placeholder — wired up once multi-job stitching lands) #}
{% if j.error %} <div class="stitch-section">
<tr class="err-row"><td colspan="10"><small>{{ j.error }}</small></td></tr> <div class="stitch-title">
{% endif %} <span class="icon"><span class="sq"></span></span>
{% endfor %} <span>stitch</span>
</tbody> </div>
</table> <ul class="stitch-children">
<li class="sub pending"><span class="sq"></span> pair GP1↔GP2 per AUV</li>
<li class="sub pending"><span class="sq"></span> cross-AUV merge</li>
<li class="sub pending"><span class="sq"></span> final PLY</li>
</ul>
</div>
</div>
{% endfor %}
</div>
{% endif %} {% endif %}