dashboard vrai tableau + probe viser_url alive + CSS propre
1. _jobs_table.html: remplace la liste <li>flex par un vrai <table> avec colonnes explicites: status · preview · #id · AUV+GP · label · duree video · frames · hors-eau · progression · temps · actions. Stitches restent en <ul> compacte. 2. main.py _build_acquisitions: probe TCP le viser_url avec cache 8s avant de le passer au template. Si port mort -> d[viser_url]=None -> pas de bouton affiche. Fini les liens qui mennent a rien. 3. style.css: purge des regles flex conflictuelles, rewrite propre pour table.jobs-table, badges, prog-bar, btn-viser direct link.
This commit is contained in:
72
CONTEXT.md
Normal file
72
CONTEXT.md
Normal file
@@ -0,0 +1,72 @@
|
||||
Keyboard-interactive authentication prompts from server:
|
||||
End of keyboard-interactive prompts from server
|
||||
# cosma-qc — Context de reprise
|
||||
|
||||
## TL;DR
|
||||
Pipeline QC plongées COSMA :
|
||||
1. ffmpeg extract frames (fps=2, 518x294)
|
||||
2. auto-trim head+tail hors-eau (detection R<G-5 && R<B-5 via opencv)
|
||||
3. demo.py lingbot-map windowed reconstruction GPU ? PLY
|
||||
4. viser natif (PointCloudViewer) kept alive pour visu
|
||||
5. per_auv stitch puis cross_auv (TODO)
|
||||
|
||||
## Dashboard
|
||||
http://192.168.0.82/cosma-qc/ — FastAPI + htmx, liste jobs avec:
|
||||
- #id, AUV GP1/GP2 (serial tooltip), segment, durée vidéo, frames, hors-eau trimé, thumbnail
|
||||
- Lien viser plain `<a href>` direct (pas de popup)
|
||||
|
||||
## Infra
|
||||
- **core (.82)** VM 101 Proxmox : FastAPI docker `cosma-qc` port 3849, SQLite `/home/floppyrj45/cosma-qc-data/jobs.db`, dispatcher Python systemd `cosma-qc-dispatcher.service`
|
||||
- **gpu (.87)** VM 105 Proxmox : RTX 3060 12GB, user floppyrj45, `~/ai-video/lingbot-map`
|
||||
- **ml-stack (.84)** host physique : RTX 3090 24GB, user root, `/root/ai-video/lingbot-map`
|
||||
- **z620 (.168)** Proxmox hyperviseur : SSD thin pool à surveiller (a été 100% ? VMs paused)
|
||||
- Source vidéos : z620:/mnt/portablessd/COSMA - La ctiotat 8 avril/raw_data/medias/videos/
|
||||
|
||||
## Credentials
|
||||
- ssh floppyrj45@192.168.0.82 password SuperTeam2026!
|
||||
- ssh gpu (alias ? floppyrj45@.87) + ssh ml-stack (alias ? root@.84), clés sur core
|
||||
- ssh root@192.168.0.168 password SuperTeam2026!
|
||||
- Gitea http://192.168.0.82:3000/floppyrj45/cosma-qc (token dans ~/cosma-qc-data/dispatcher.env)
|
||||
|
||||
## Service dispatcher
|
||||
- systemd : `sudo systemctl {status,restart,stop} cosma-qc-dispatcher`
|
||||
- Env : `/home/floppyrj45/cosma-qc-data/dispatcher.env` (DB, WORKERS JSON, FPS)
|
||||
- Log : `/home/floppyrj45/cosma-qc-data/dispatcher.log` (append) + `journalctl -u cosma-qc-dispatcher`
|
||||
- Code : `/home/floppyrj45/docker/cosma-qc/scripts/dispatcher.py` (user floppyrj45)
|
||||
- Backend FastAPI container : `docker restart cosma-qc` après edit `app/`
|
||||
|
||||
## Patches deja appliques
|
||||
- rm src_*.MP4 apres extract (thin pool LVM Proxmox tight)
|
||||
- fstrim /
|
||||
- stride adaptatif selon RAM worker (62/23 GB)
|
||||
- estimate_vram_mib = 6000 MiB fixe
|
||||
- budget RAM 0.35 (etait 0.55 trop optimiste ? OOM)
|
||||
- auto-trim frames hors-eau (prefix + suffix)
|
||||
- skip si <8 min video total (COSMA_QC_MIN_VIDEO_S=480)
|
||||
- window_size adaptatif : 16/32/64 selon eff frames
|
||||
- keep demo.py alive apres PLY saved ? viser natif persistant
|
||||
- load balance pick_worker : lower-load first (sinon tout sur .84)
|
||||
- set_status auto-clear error au status change
|
||||
- skipped status propage dans stitch per_auv
|
||||
|
||||
## TODO / issues connues
|
||||
- [ ] Frame preview thumbnail : scp frame (fait), endpoint `/jobs/{id}/thumbnail` (fait). Backfill jobs anciens : manuel.
|
||||
- [ ] CSS dashboard : layout à chier selon user. Passé a été cleane mais re-tester
|
||||
- [ ] GLB export (lingbot_map/vis/glb_export.py existe, pas expose dans demo.py — patch demo.py pour ajouter `--save_glb`)
|
||||
- [ ] Cross-AUV stitch final — code existe (stitch.py) mais pas encore testé
|
||||
- [ ] segment_label trompeur (timestamp 1er MP4, pas durée totale). Fix dans ingest.py
|
||||
- [ ] Dashboard header: dispatcher heartbeat last seen Xs ago peut afficher > 5s après restart si fichier heartbeat pas reset
|
||||
|
||||
## Etat courant jobs (snapshot)
|
||||
Jobs 9, 12, 13, 16, 19 done (anciens). 10 skipped (pull marron sur pont). 11/14 en cours extract. 15/17-21 queued.
|
||||
Per-AUV stitch AUV209 nécessite jobs 11/12/13/14/15 done (10 exclu). AUV210 : 16/17/18/19/20/21.
|
||||
Cross-AUV final = stitch id 6 (prédit) sur .84 port 9206.
|
||||
|
||||
## Workflow attendu (user)
|
||||
1. ffmpeg 2fps resize 518x294 + auto-trim hors-eau ? n frames
|
||||
2. Dispatch multi-GPU balance (.84 + .87)
|
||||
3. demo.py windowed 64 sur long (>3000 eff)
|
||||
4. Save PLY (+ GLB TODO)
|
||||
5. Viser natif via PointCloudViewer live
|
||||
6. Stitch per-AUV puis cross-AUV = puzzle géant
|
||||
7. Dashboard refresh avec thumbnail preview
|
||||
33
app/main.py
33
app/main.py
@@ -134,6 +134,34 @@ templates = Jinja2Templates(directory=Path(__file__).parent / "templates")
|
||||
app.mount("/static", StaticFiles(directory=Path(__file__).parent / "static"), name="static")
|
||||
|
||||
|
||||
_viser_probe_cache: dict[str, tuple[float, bool]] = {}
|
||||
_VISER_PROBE_TTL = 8.0 # seconds
|
||||
|
||||
|
||||
def _viser_alive(url: str) -> bool:
|
||||
"""Fast TCP check with short cache so we never surface a dead link in the dashboard."""
|
||||
import time as _t
|
||||
import socket
|
||||
now = _t.time()
|
||||
cached = _viser_probe_cache.get(url)
|
||||
if cached and now - cached[0] < _VISER_PROBE_TTL:
|
||||
return cached[1]
|
||||
try:
|
||||
from urllib.parse import urlparse
|
||||
p = urlparse(url)
|
||||
host, port = p.hostname, p.port
|
||||
if not host or not port:
|
||||
return False
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.settimeout(0.4)
|
||||
s.connect((host, port))
|
||||
alive = True
|
||||
except OSError:
|
||||
alive = False
|
||||
_viser_probe_cache[url] = (now, alive)
|
||||
return alive
|
||||
|
||||
|
||||
def _build_acquisitions():
|
||||
with closing(db()) as conn:
|
||||
acqs = conn.execute(
|
||||
@@ -168,8 +196,9 @@ def _build_acquisitions():
|
||||
d["video_duration_fmt"] = _fmt_dur(int(j["video_duration_s"] or 0)) if (j["video_duration_s"] or 0) > 0 else "—"
|
||||
d["trimmed_total"] = (j["trimmed_head"] or 0) + (j["trimmed_tail"] or 0)
|
||||
d["has_thumbnail"] = (DB_PATH.parent / "thumbnails" / f"job_{j['id']}.jpg").exists()
|
||||
# Only expose a native viser link when port is listening. Probed on render via TCP check.
|
||||
d["native_viser_url"] = None # filled below
|
||||
# Mask the viser link when the demo.py that was serving it has since died.
|
||||
if j["status"] == "done" and j["viser_url"] and not _viser_alive(j["viser_url"]):
|
||||
d["viser_url"] = None
|
||||
by_acq.setdefault(j["acquisition_id"], []).append(d)
|
||||
by_acq_total[j["acquisition_id"]] = by_acq_total.get(j["acquisition_id"], 0) + dur_s
|
||||
|
||||
|
||||
@@ -70,8 +70,7 @@ progress::-moz-progress-bar { background: var(--accent); }
|
||||
.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; display: flex; flex-direction: column; gap: 2px; }
|
||||
.job-list .label { color: var(--text); display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; min-width: 0; }
|
||||
.prog-wrap { display: flex; align-items: center; gap: 4px; height: 10px; position: relative; width: 100%; max-width: 160px; }
|
||||
.prog-fill { display: block; height: 100%; background: var(--accent); border-radius: 2px; transition: width 0.5s; min-width: 2px; }
|
||||
.prog-text { font-size: 0.7rem; color: var(--muted); white-space: nowrap; }
|
||||
@@ -116,16 +115,45 @@ button:hover { border-color: var(--accent); }
|
||||
a { color: var(--accent); }
|
||||
code { background: rgba(255,255,255,0.05); padding: 0 0.25rem; border-radius: 3px; }
|
||||
|
||||
/* Job row columns: id · AUV-GP · segment · meta · progress · viser */
|
||||
.job-item .label { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; font-size: 14px; }
|
||||
.job-item .job-id { font-family: monospace; color: var(--mut, #666); font-size: 12px; min-width: 32px; }
|
||||
.job-item .auv-gp { font-weight: 600; min-width: 100px; }
|
||||
.job-item .seg { color: var(--mut, #666); font-variant-numeric: tabular-nums; min-width: 90px; }
|
||||
.job-item .meta { color: var(--mut, #888); font-size: 12px; font-variant-numeric: tabular-nums; }
|
||||
.job-item .meta::before { content: '· '; opacity: 0.5; }
|
||||
.job-item .viser-link { text-decoration: none; padding: 2px 8px; border: 1px solid var(--accent, #4a9); border-radius: 3px; color: var(--accent, #4a9); font-size: 12px; }
|
||||
.job-item .viser-link:hover { background: var(--accent, #4a9); color: white; }
|
||||
.job-item.skipped { opacity: 0.55; }
|
||||
.job-item.skipped .label { font-style: italic; }
|
||||
/* ==== Jobs table ==== */
|
||||
.acq { margin-bottom: 1.5rem; }
|
||||
.acq-title { font-size: 1rem; margin: 0 0 0.5rem; }
|
||||
.acq-title .total { color: var(--muted); font-size: 0.85rem; margin-left: 0.5rem; }
|
||||
|
||||
.job-item .thumb { width: 48px; height: 27px; object-fit: cover; border-radius: 3px; margin-right: 4px; }
|
||||
.jobs-table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
||||
.jobs-table thead th { text-align: left; padding: 4px 8px; color: var(--muted); font-weight: 500; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.04em; border-bottom: 1px solid var(--border, #333); }
|
||||
.jobs-table tbody td { padding: 6px 8px; border-bottom: 1px solid rgba(255,255,255,0.04); vertical-align: middle; }
|
||||
.jobs-table tbody tr:hover { background: rgba(255,255,255,0.02); }
|
||||
|
||||
.col-status { width: 24px; text-align: center; }
|
||||
.col-thumb { width: 80px; }
|
||||
.col-thumb img { width: 72px; height: 40px; object-fit: cover; border-radius: 4px; display: block; }
|
||||
.thumb-placeholder { display: inline-block; width: 72px; height: 40px; background: rgba(255,255,255,0.04); border-radius: 4px; }
|
||||
.col-id { font-family: ui-monospace, monospace; color: var(--muted); font-size: 0.75rem; }
|
||||
.col-auv { min-width: 110px; }
|
||||
.col-seg { font-variant-numeric: tabular-nums; color: var(--muted); }
|
||||
.col-dur, .col-frames, .col-trim, .col-elapsed { font-variant-numeric: tabular-nums; color: var(--muted); font-size: 0.8rem; }
|
||||
.col-progress { min-width: 140px; }
|
||||
.col-actions { width: 24px; text-align: right; }
|
||||
|
||||
.badge { display: inline-block; width: 18px; height: 18px; line-height: 18px; text-align: center; border-radius: 3px; font-size: 0.72rem; font-weight: 600; }
|
||||
.badge.ok { background: var(--ok, #2d7); color: #062410; }
|
||||
.badge.busy { color: var(--accent, #4af); }
|
||||
.badge.err { background: var(--err, #d44); color: #fff; }
|
||||
.badge.muted { color: var(--muted, #888); }
|
||||
|
||||
.job-row.skipped { opacity: 0.45; }
|
||||
.job-row.skipped .col-seg, .job-row.skipped .col-auv { font-style: italic; }
|
||||
|
||||
.prog-bar { display: inline-block; width: 100px; height: 6px; background: rgba(255,255,255,0.08); border-radius: 3px; overflow: hidden; vertical-align: middle; }
|
||||
.prog-fill { display: block; height: 100%; background: var(--accent, #4af); transition: width 0.5s; }
|
||||
.prog-text { margin-left: 6px; color: var(--muted); font-size: 0.72rem; }
|
||||
|
||||
.btn-viser { display: inline-block; text-decoration: none; padding: 3px 10px; border: 1px solid var(--accent, #4af); border-radius: 3px; color: var(--accent, #4af); font-size: 0.72rem; background: transparent; cursor: pointer; font-family: inherit; }
|
||||
.btn-viser:hover { background: var(--accent, #4af); color: #062036; }
|
||||
|
||||
.stitch-section { margin-top: 0.75rem; padding: 0.5rem 0 0; border-top: 1px dashed var(--border, #333); }
|
||||
.stitch-title { color: var(--muted); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 0.35rem; }
|
||||
.stitch-list { list-style: none; padding: 0; margin: 0; }
|
||||
.stitch-item { display: flex; align-items: center; gap: 0.5rem; padding: 3px 0; font-size: 0.82rem; }
|
||||
.stitch-item .err-line { flex-basis: 100%; margin-left: 26px; color: var(--err, #d44); font-size: 0.72rem; }
|
||||
|
||||
@@ -1,89 +1,110 @@
|
||||
{% if not acquisitions %}
|
||||
<p class="muted">Aucune acquisition. Ingeste un dossier via <code>scripts/ingest.py</code>.</p>
|
||||
{% else %}
|
||||
<div class="acq-grid">
|
||||
<div class="acq-list">
|
||||
{% for acq in acquisitions %}
|
||||
<div class="acq-col">
|
||||
<section class="acq">
|
||||
<h3 class="acq-title">
|
||||
{{ acq.name }} <span class="total">{{ acq.total_duration }}</span>
|
||||
</h3>
|
||||
|
||||
<ul class="job-list">
|
||||
{% for j in acq.jobs %}
|
||||
<li class="job-item {{ j.status }}">
|
||||
<span class="icon">
|
||||
{% if j.status == 'done' %}<span class="check">✓</span>
|
||||
{% elif j.status in ('running','extracting') %}<span class="spin">↻</span>
|
||||
{% elif j.status == 'error' %}<span class="err">✕</span>
|
||||
{% else %}<span class="sq">■</span>{% endif %}
|
||||
</span>
|
||||
<span class="label">
|
||||
{% if j.has_thumbnail %}<img class="thumb" src="jobs/{{ j.id }}/thumbnail" alt="" loading="lazy">{% endif %}
|
||||
<span class="job-id">#{{ j.id }}</span>
|
||||
<span class="auv-gp" title="{{ j.gopro_serial }}">{{ j.auv }} {{ j.gp_label }}</span>
|
||||
<span class="seg">{{ j.segment_label }}</span>
|
||||
{% if j.video_duration_fmt != '—' %}<span class="meta">{{ j.video_duration_fmt }}{% if j.frame_count %} · {{ j.frame_count }} f{% if j.trimmed_total %} · −{{ j.trimmed_total }} hors-eau{% endif %}{% endif %}</span>{% endif %}
|
||||
{% if j.status in ('extracting','running') %}
|
||||
<span class="prog-wrap"><span class="prog-fill" style="width:{{ j.progress }}%"></span><span class="prog-text">{{ j.progress }}%</span></span>
|
||||
{% endif %}
|
||||
{% if j.status == 'done' and j.viser_url %}
|
||||
<a class="ext viser-link" href="{{ j.viser_url }}" target="_blank" rel="noopener" title="viser natif ({{ j.viser_url }})">viser</a>
|
||||
{% endif %}
|
||||
</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>
|
||||
{% elif j.status == 'error' %}
|
||||
<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>
|
||||
<table class="jobs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-status"></th>
|
||||
<th class="col-thumb">preview</th>
|
||||
<th class="col-id">#</th>
|
||||
<th class="col-auv">AUV · GP</th>
|
||||
<th class="col-seg">label</th>
|
||||
<th class="col-dur">durée</th>
|
||||
<th class="col-frames">frames</th>
|
||||
<th class="col-trim">hors-eau</th>
|
||||
<th class="col-progress">progression</th>
|
||||
<th class="col-elapsed">temps</th>
|
||||
<th class="col-actions"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for j in acq.jobs %}
|
||||
<tr class="job-row {{ j.status }}">
|
||||
<td class="col-status" title="{{ j.status }}">
|
||||
{% if j.status == 'done' %}<span class="badge ok">✓</span>
|
||||
{% elif j.status in ('running','extracting') %}<span class="badge busy">↻</span>
|
||||
{% elif j.status == 'error' %}<span class="badge err">✕</span>
|
||||
{% elif j.status == 'skipped' %}<span class="badge muted">⊘</span>
|
||||
{% else %}<span class="badge muted">■</span>{% endif %}
|
||||
</td>
|
||||
<td class="col-thumb">
|
||||
{% if j.has_thumbnail %}<img src="jobs/{{ j.id }}/thumbnail" alt="" loading="lazy">{% else %}<span class="thumb-placeholder"></span>{% endif %}
|
||||
</td>
|
||||
<td class="col-id">#{{ j.id }}</td>
|
||||
<td class="col-auv"><strong>{{ j.auv }}</strong> · <span title="{{ j.gopro_serial }}">{{ j.gp_label }}</span></td>
|
||||
<td class="col-seg">{{ j.segment_label }}</td>
|
||||
<td class="col-dur">{{ j.video_duration_fmt }}</td>
|
||||
<td class="col-frames">{% if j.frame_count %}{{ j.frame_count }}{% else %}—{% endif %}</td>
|
||||
<td class="col-trim">{% if j.trimmed_total %}−{{ j.trimmed_total }}{% else %}—{% endif %}</td>
|
||||
<td class="col-progress">
|
||||
{% if j.status in ('extracting','running') %}
|
||||
<span class="prog-bar"><span class="prog-fill" style="width:{{ j.progress }}%"></span></span>
|
||||
<span class="prog-text">{{ j.progress }}%</span>
|
||||
{% elif j.status == 'done' and j.viser_url %}
|
||||
<a class="btn-viser" href="{{ j.viser_url }}" target="_blank" rel="noopener">viser ↗</a>
|
||||
{% elif j.status == 'skipped' %}
|
||||
<span class="muted">skipped</span>
|
||||
{% elif j.status == 'error' %}
|
||||
<span class="muted" title="{{ j.error }}">failed</span>
|
||||
{% else %}
|
||||
<span class="muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="col-elapsed">{{ j._duration }}</td>
|
||||
<td class="col-actions">
|
||||
{% if j.status in ('queued','extracting','running') %}
|
||||
<button class="mini" hx-post="jobs/{{ j.id }}/cancel" hx-target="#jobs-table" title="annuler">×</button>
|
||||
{% elif j.status == 'error' %}
|
||||
<button class="mini" hx-post="jobs/{{ j.id }}/retry" hx-target="#jobs-table" title="retry">↻</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="stitch-section">
|
||||
<div class="stitch-title">
|
||||
<span class="icon"><span class="sq">■</span></span>
|
||||
<span>stitch</span>
|
||||
</div>
|
||||
<div class="stitch-title">stitch</div>
|
||||
{% if acq.stitches %}
|
||||
<ul class="stitch-children">
|
||||
<ul class="stitch-list">
|
||||
{% 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 %}
|
||||
<li class="stitch-item {{ s.status }}">
|
||||
<span class="badge {% if s.status == 'done' %}ok{% elif s.status == 'running' %}busy{% elif s.status == 'error' %}err{% else %}muted{% endif %}">
|
||||
{% if s.status == 'done' %}✓{% elif s.status == 'running' %}↻{% elif s.status == 'error' %}✕{% else %}■{% endif %}
|
||||
</span>
|
||||
<span>
|
||||
<span class="stitch-label">
|
||||
{% 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 %}
|
||||
<button class="ext viewer-btn" data-view-url="stitches/{{ s.id }}/view" title="{{ s.output_ply }}">viser</button>
|
||||
{% endif %}
|
||||
</span>
|
||||
{% if s._duration %}<span class="muted"> · {{ s._duration }}</span>{% endif %}
|
||||
{% if s.status == 'done' and s.output_ply %}
|
||||
<button class="btn-viser" data-view-url="stitches/{{ s.id }}/view">viser ↗</button>
|
||||
{% endif %}
|
||||
{% 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 %}
|
||||
{% if s.error %}<div class="err-line">{{ s.error[:140] }}</div>{% 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 class="stitch-list">
|
||||
<li class="stitch-item pending"><span class="badge muted">■</span> <span class="muted">pair GP1↔GP2 per AUV</span></li>
|
||||
<li class="stitch-item pending"><span class="badge muted">■</span> <span class="muted">cross-AUV merge</span></li>
|
||||
<li class="stitch-item pending"><span class="badge muted">■</span> <span class="muted">final PLY</span></li>
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
Reference in New Issue
Block a user