dashboard — colonnes par acquisition, checklist verticale hierarchique
This commit is contained in:
92
app/main.py
92
app/main.py
@@ -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)
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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 -%}
|
||||||
|
{%- 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>
|
|
||||||
<td>
|
|
||||||
{% if j.status in ['queued','extracting','running'] %}
|
|
||||||
<button hx-post="/jobs/{{ j.id }}/cancel" hx-target="#jobs-table" hx-swap="outerHTML">Stop</button>
|
|
||||||
{% elif j.status == 'error' %}
|
{% elif j.status == 'error' %}
|
||||||
<button hx-post="/jobs/{{ j.id }}/retry" hx-target="#jobs-table" hx-swap="outerHTML">Retry</button>
|
<button class="mini" hx-post="/jobs/{{ j.id }}/retry" hx-target="#jobs-table">↻</button>
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% if j.error %}
|
|
||||||
<tr class="err-row"><td colspan="10"><small>{{ j.error }}</small></td></tr>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
{% if j.error %}<li class="err-line">{{ j.error }}</li>{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</ul>
|
||||||
</table>
|
|
||||||
|
{# Stitch section (placeholder — wired up once multi-job stitching lands) #}
|
||||||
|
<div class="stitch-section">
|
||||||
|
<div class="stitch-title">
|
||||||
|
<span class="icon"><span class="sq">■</span></span>
|
||||||
|
<span>stitch</span>
|
||||||
|
</div>
|
||||||
|
<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 %}
|
||||||
|
|||||||
Reference in New Issue
Block a user