stitch pipeline câblé : DB + dispatcher + UI + fix subpath Caddy

- Table stitches (per_auv + cross_auv) avec cancel/retry API
- Dispatcher : PLY export auto (--save_ply), trigger stitch en cascade
  quand tous les jobs d'un AUV sont done
- UI : section stitch live depuis DB avec statuts/durées/boutons
- Fix : <base href="/cosma-qc/"> + chemins relatifs pour Caddy subpath
- open3d 0.19.0 installé sur gpu (.87)
- SSH key .82→.87 configurée, alias gpu ajouté sur .82

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Poulpe
2026-04-21 10:32:05 +00:00
parent 3b005a4994
commit 26e5bfc05b
5 changed files with 281 additions and 48 deletions

View File

@@ -102,6 +102,24 @@ def init_schema() -> None:
CREATE INDEX IF NOT EXISTS jobs_status_idx ON jobs(status);
CREATE INDEX IF NOT EXISTS jobs_acq_idx ON jobs(acquisition_id);
CREATE TABLE IF NOT EXISTS stitches (
id INTEGER PRIMARY KEY,
acquisition_id INTEGER NOT NULL REFERENCES acquisitions(id) ON DELETE CASCADE,
level TEXT NOT NULL DEFAULT 'per_auv',
auv TEXT,
input_job_ids TEXT NOT NULL DEFAULT '[]',
input_stitch_ids TEXT NOT NULL DEFAULT '[]',
output_ply TEXT,
status TEXT NOT NULL DEFAULT 'queued',
worker_host TEXT,
started_at TEXT,
finished_at TEXT,
error TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS stitches_acq_idx ON stitches(acquisition_id);
""")
@@ -124,6 +142,10 @@ def _build_acquisitions():
jobs = conn.execute(
"SELECT * FROM jobs ORDER BY auv, gopro_serial, segment_label"
).fetchall()
stitches = conn.execute(
"SELECT * FROM stitches ORDER BY level DESC, auv"
).fetchall()
by_acq: dict[int, list[dict]] = {}
by_acq_total: dict[int, int] = {}
for j in jobs:
@@ -133,12 +155,30 @@ def _build_acquisitions():
by_acq.setdefault(j["acquisition_id"], []).append(d)
by_acq_total[j["acquisition_id"]] = by_acq_total.get(j["acquisition_id"], 0) + dur_s
stitches_by_acq: dict[int, list[dict]] = {}
for s in stitches:
d = dict(s)
start = _parse_ts(s["started_at"])
end = _parse_ts(s["finished_at"]) or (
datetime.now(timezone.utc) if s["status"] == "running" else None
)
if start and end:
if start.tzinfo is None:
start = start.replace(tzinfo=timezone.utc)
if end.tzinfo is None:
end = end.replace(tzinfo=timezone.utc)
d["_duration"] = _fmt_dur(int((end - start).total_seconds()))
else:
d["_duration"] = ""
stitches_by_acq.setdefault(s["acquisition_id"], []).append(d)
return [
{
"id": acq["id"],
"name": acq["name"],
"source_path": acq["source_path"],
"jobs": by_acq.get(acq["id"], []),
"stitches": stitches_by_acq.get(acq["id"], []),
"total_duration": _fmt_dur(by_acq_total.get(acq["id"], 0)),
}
for acq in acqs
@@ -220,3 +260,25 @@ async def retry_job(job_id: int):
(job_id,),
)
return {"ok": True}
@app.post("/stitches/{stitch_id}/cancel")
async def cancel_stitch(stitch_id: int):
with closing(db()) as conn:
conn.execute(
"UPDATE stitches SET status='error', error='cancelled by user', finished_at=datetime('now') "
"WHERE id=? AND status IN ('queued','running')",
(stitch_id,),
)
return {"ok": True}
@app.post("/stitches/{stitch_id}/retry")
async def retry_stitch(stitch_id: int):
with closing(db()) as conn:
conn.execute(
"UPDATE stitches SET status='queued', error=NULL, output_ply=NULL, "
"started_at=NULL, finished_at=NULL, worker_host=NULL WHERE id=? AND status='error'",
(stitch_id,),
)
return {"ok": True}

1
app/static/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -1,12 +1,3 @@
{% 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 -%}&nbsp;
{%- endif -%}
{%- endmacro %}
{% if not acquisitions %}
<p class="muted">Aucune acquisition. Ingeste un dossier via <code>scripts/ingest.py</code>.</p>
{% else %}
@@ -34,26 +25,56 @@
</span>
<span class="dur">{{ j._duration }}</span>
{% if j.status in ('queued','extracting','running') %}
<button class="mini" hx-post="/jobs/{{ j.id }}/cancel" hx-target="#jobs-table">×</button>
<button class="mini" hx-post="jobs/{{ j.id }}/cancel" hx-target="#jobs-table">×</button>
{% elif j.status == 'error' %}
<button class="mini" hx-post="/jobs/{{ j.id }}/retry" hx-target="#jobs-table"></button>
<button class="mini" hx-post="jobs/{{ j.id }}/retry" hx-target="#jobs-table"></button>
{% else %}
<span></span>
{% endif %}
</li>
{% if j.error %}<li class="err-line">{{ j.error }}</li>{% endif %}
{% endfor %}
</ul>
{# 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>
{% if acq.stitches %}
<ul class="stitch-children">
{% for s in acq.stitches %}
<li class="sub {{ s.status }}">
<span class="icon stitch-icon">
{% if s.status == 'done' %}<span class="check ok"></span>
{% elif s.status == 'running' %}<span class="spin"></span>
{% elif s.status == 'error' %}<span class="err"></span>
{% else %}<span class="sq"></span>{% endif %}
</span>
<span>
{% if s.level == 'per_auv' %}pair GP1↔GP2 {{ s.auv }}
{% else %}merge final{% endif %}
{% if s._duration %}<span class="dur muted"> — {{ s._duration }}</span>{% endif %}
{% if s.status == 'done' and s.output_ply %}
<span class="ext" title="{{ s.output_ply }}">PLY</span>
{% endif %}
</span>
{% if s.status in ('queued','running') %}
<button class="mini" hx-post="stitches/{{ s.id }}/cancel" hx-target="#jobs-table">×</button>
{% elif s.status == 'error' %}
<button class="mini" hx-post="stitches/{{ s.id }}/retry" hx-target="#jobs-table"></button>
{% endif %}
</li>
{% if s.error %}<li class="err-line" style="padding-left:42px">{{ s.error[:120] }}</li>{% endif %}
{% endfor %}
</ul>
{% else %}
<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>
{% endif %}
</div>
</div>
{% endfor %}

View File

@@ -4,8 +4,9 @@
<meta charset="utf-8">
<title>cosma-qc — dashboard</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<link rel="stylesheet" href="/static/style.css">
<base href="/cosma-qc/">
<script src="static/htmx.min.js"></script>
<link rel="stylesheet" href="static/style.css">
</head>
<body>
<header>
@@ -13,13 +14,13 @@
<span class="sub">post-acquisition QC · lingbot-map pipeline</span>
</header>
<section id="monitor" hx-get="/partials/monitor" hx-trigger="load, every 5s" hx-swap="innerHTML">
<section id="monitor" hx-get="partials/monitor" hx-trigger="load, every 5s" hx-swap="innerHTML">
<p class="muted">Chargement des workers…</p>
</section>
<section id="jobs">
<h2>Jobs</h2>
<div id="jobs-table" hx-get="/partials/jobs" hx-trigger="load, every 3s" hx-swap="innerHTML">
<div id="jobs-table" hx-get="partials/jobs" hx-trigger="load, every 3s" hx-swap="innerHTML">
<p class="muted">Chargement…</p>
</div>
</section>