fix: toggle preview display bug, add job deletion, optimize pipeline

- Fix: toggle ON now resets img display to block (was stuck on 'waiting')
- Toggle OFF sends API call to stop preview file writes
- Delete button on status page + jobs page cards
- DELETE /api/jobs/<id> endpoint removes job + output files
- PUT /api/jobs/<id>/preview endpoint toggles preview_enabled
- Pipeline skips resize/encode/write when preview_enabled=False
- Reduced preview defaults: 480px max, JPEG quality 60
- 62/62 tests pass
This commit is contained in:
jetson committed 2026-09-22 10:49:06 +07:00
1 parent 09a8829575
commit 163280078f
6 files changed
+140 -7

No files matched your search

+25
View File
@@ -255,6 +255,31 @@ def api_cancel_job(job_id):
return jsonify({"error": "cannot cancel job in state " + job.status.name}), 400 return jsonify({"error": "cannot cancel job in state " + job.status.name}), 400
@app.route("/api/jobs/<job_id>", methods=["DELETE"])
def api_delete_job(job_id):
"""Delete a completed/failed/cancelled job."""
success = job_queue.delete_job(job_id)
if success:
return jsonify({"status": "deleted"})
job = job_queue.get_job(job_id)
if job is None:
return jsonify({"error": "not found"}), 404
return jsonify({"error": "cannot delete job in state " + job.status.name}), 400
@app.route("/api/jobs/<job_id>/preview", methods=["PUT"])
def api_toggle_preview(job_id):
"""Toggle live preview on/off for a running job."""
job = job_queue.get_job(job_id)
if job is None:
return jsonify({"error": "not found"}), 404
data = request.get_json(silent=True) or {}
enabled = data.get("enabled", True)
with job_queue._lock:
job.preview_enabled = enabled
return jsonify({"status": "ok", "preview_enabled": enabled})
@app.route("/api/jobs/<job_id>") @app.route("/api/jobs/<job_id>")
def api_job_detail(job_id): def api_job_detail(job_id):
job = job_queue.get_job(job_id) job = job_queue.get_job(job_id)
+25
View File
@@ -60,6 +60,7 @@ class Job:
live_loading: int = 0 live_loading: int = 0
live_unloading: int = 0 live_unloading: int = 0
live_frame: int = 0 live_frame: int = 0
preview_enabled: bool = True
class JobQueue: class JobQueue:
@@ -122,6 +123,29 @@ class JobQueue:
return True return True
return False return False
def delete_job(self, job_id: str) -> bool:
"""Delete a completed/failed/cancelled job and its files."""
import shutil
with self._lock:
job = self._jobs.get(job_id)
if job is None:
return False
if job.status in (JobStatus.PENDING, JobStatus.RUNNING):
return False # can't delete running jobs
# Remove from dict
del self._jobs[job_id]
# Delete output directory
try:
shutil.rmtree(job.output_dir, ignore_errors=True)
except Exception:
pass
# Delete preview file
try:
os.remove(job.preview_path)
except OSError:
pass
return True
def status_counts(self) -> dict[str, int]: def status_counts(self) -> dict[str, int]:
"""Return counts by status: {pending: N, running: N, completed: N, ...}.""" """Return counts by status: {pending: N, running: N, completed: N, ...}."""
counts = {s.name.lower(): 0 for s in JobStatus} counts = {s.name.lower(): 0 for s in JobStatus}
@@ -183,6 +207,7 @@ class JobQueue:
class_filter=class_filter, class_filter=class_filter,
truck_model_config=truck_model_config, truck_model_config=truck_model_config,
preview_path=job.preview_path, preview_path=job.preview_path,
preview_enabled=job.preview_enabled,
preview_every_n=2, preview_every_n=2,
cancel_check=lambda: job.status == JobStatus.CANCELLED, cancel_check=lambda: job.status == JobStatus.CANCELLED,
live_stats_callback=_on_live_stats, live_stats_callback=_on_live_stats,
+4 -3
View File
@@ -65,10 +65,11 @@ def run_pipeline(
progress_callback: Callable[[int, int], None] | None = None, progress_callback: Callable[[int, int], None] | None = None,
frame_callback: Callable[[bytes], None] | None = None, frame_callback: Callable[[bytes], None] | None = None,
cancel_check: Callable[[], bool] | None = None, cancel_check: Callable[[], bool] | None = None,
preview_enabled: bool = True,
preview_path: str | None = None, preview_path: str | None = None,
preview_every_n: int = 2, preview_every_n: int = 2,
preview_max_dim: int = 640, preview_max_dim: int = 480,
preview_jpeg_quality: int = 70, preview_jpeg_quality: int = 60,
live_stats_callback: Callable[[dict], None] | None = None, live_stats_callback: Callable[[dict], None] | None = None,
) -> PipelineResult: ) -> PipelineResult:
"""Process a video file through the counting pipeline. """Process a video file through the counting pipeline.
@@ -263,7 +264,7 @@ def run_pipeline(
"frame": frame_idx, "frame": frame_idx,
}) })
if preview_path is not None and frame_idx % max(1, preview_every_n) == 0: if preview_enabled and preview_path is not None and frame_idx % max(1, preview_every_n) == 0:
h, w = viz.shape[:2] h, w = viz.shape[:2]
if max(h, w) > preview_max_dim: if max(h, w) > preview_max_dim:
scale = preview_max_dim / max(h, w) scale = preview_max_dim / max(h, w)
+31 -1
View File
@@ -369,7 +369,8 @@ function initStatusPage(jobId, initialStatus) {
if (previewToggle) { if (previewToggle) {
previewToggle.addEventListener('change', function() { previewToggle.addEventListener('change', function() {
if (this.checked) { if (this.checked) {
livePreviewImg.src = '/api/preview/' + jobId; livePreviewImg.style.display = 'block';
livePreviewImg.src = '/api/preview/' + jobId + '?t=' + Date.now();
if (previewPlaceholder) previewPlaceholder.style.display = ''; if (previewPlaceholder) previewPlaceholder.style.display = '';
livePreviewSection.style.display = ''; livePreviewSection.style.display = '';
if (toggleText) toggleText.textContent = 'ON'; if (toggleText) toggleText.textContent = 'ON';
@@ -379,6 +380,10 @@ function initStatusPage(jobId, initialStatus) {
if (previewPlaceholder) previewPlaceholder.style.display = ''; if (previewPlaceholder) previewPlaceholder.style.display = '';
livePreviewSection.style.display = ''; livePreviewSection.style.display = '';
if (toggleText) toggleText.textContent = 'OFF'; if (toggleText) toggleText.textContent = 'OFF';
fetch('/api/jobs/' + jobId + '/preview', {
method: 'PUT', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({enabled: false})
});
} }
}); });
} }
@@ -552,6 +557,31 @@ function initStatusPage(jobId, initialStatus) {
if (pollInterval) { clearInterval(pollInterval); pollInterval = null; } if (pollInterval) { clearInterval(pollInterval); pollInterval = null; }
} }
/* --- Delete button --- */
var deleteBtn = document.getElementById('delete-btn');
if (deleteBtn) {
deleteBtn.addEventListener('click', function() {
if (!confirm('Delete this job and all its output files?')) return;
deleteBtn.disabled = true;
deleteBtn.textContent = 'Deleting...';
fetch('/api/jobs/' + jobId, { method: 'DELETE' })
.then(function(res) { return res.json(); })
.then(function(data) {
if (data.status === 'deleted') {
window.location.href = '/jobs';
} else {
deleteBtn.disabled = false;
deleteBtn.textContent = 'Delete';
if (data.error) alert(data.error);
}
})
.catch(function() {
deleteBtn.disabled = false;
deleteBtn.textContent = 'Delete';
});
});
}
/* --- Boot --- */ /* --- Boot --- */
updateProgressBar(0); updateProgressBar(0);
if (videoThumb) { if (videoThumb) {
+54 -3
View File
@@ -199,8 +199,32 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: var(--space-3) var(--space-5); padding: var(--space-3) var(--space-4);
border-top: 1px solid var(--color-neutral-100); border-top: 1px solid var(--color-neutral-200);
font-size: var(--text-xs);
color: var(--color-neutral-500);
}
.job-card-actions {
display: flex;
align-items: center;
gap: var(--space-2);
}
.job-delete-btn {
padding: 2px 6px;
font-size: var(--text-xs);
color: var(--color-neutral-400);
border: none;
background: none;
cursor: pointer;
opacity: 0;
transition: opacity var(--transition-fast);
}
.job-card:hover .job-delete-btn {
opacity: 1;
}
.job-delete-btn:hover {
color: var(--color-error, #DC2626);
} }
.job-card-time { .job-card-time {
@@ -265,7 +289,12 @@
</div> </div>
<div class="job-card-footer"> <div class="job-card-footer">
<span class="job-card-time" data-timestamp="{{ job.created_at }}"></span> <span class="job-card-time" data-timestamp="{{ job.created_at }}"></span>
<a href="/status/{{ job.job_id }}" class="job-card-link">View Details</a> <div class="job-card-actions">
{% if job.status.name in ('COMPLETED', 'FAILED', 'CANCELLED') %}
<button class="btn btn-ghost btn-sm job-delete-btn" data-job-id="{{ job.job_id }}" title="Delete job" aria-label="Delete job {{ job.job_id[:8] }}">&#128465;</button>
{% endif %}
<a href="/status/{{ job.job_id }}" class="job-card-link">View Details</a>
</div>
</div> </div>
</article> </article>
{% endfor %} {% endfor %}
@@ -338,5 +367,27 @@ document.querySelectorAll('.copy-btn').forEach(function(btn) {
}); });
}); });
}); });
document.querySelectorAll('.job-delete-btn').forEach(function(btn) {
btn.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
var id = this.getAttribute('data-job-id');
if (!confirm('Delete this job and all its output files?')) return;
fetch('/api/jobs/' + id, { method: 'DELETE' })
.then(function(res) { return res.json(); })
.then(function(data) {
if (data.status === 'deleted') {
btn.closest('.job-card').remove();
var grid = document.querySelector('.jobs-grid');
if (grid && !grid.querySelector('.job-card')) {
grid.innerHTML = '<div class="empty-state"><h3>No jobs yet</h3><p>Upload a video to start processing.</p><a href="/" class="btn btn-primary">Upload a video</a></div>';
}
} else if (data.error) {
alert(data.error);
}
});
});
});
</script> </script>
{% endblock %} {% endblock %}
+1
View File
@@ -9,6 +9,7 @@
{{ job.status.name }} {{ job.status.name }}
</span> </span>
<button class="btn btn-danger btn-sm" id="cancel-btn" style="display:{{ 'inline-flex' if job.status.name in ('PENDING', 'RUNNING') else 'none' }}" aria-label="Abort this job">Abort</button> <button class="btn btn-danger btn-sm" id="cancel-btn" style="display:{{ 'inline-flex' if job.status.name in ('PENDING', 'RUNNING') else 'none' }}" aria-label="Abort this job">Abort</button>
<button class="btn btn-ghost btn-sm" id="delete-btn" style="display:{{ 'inline-flex' if job.status.name in ('COMPLETED', 'FAILED', 'CANCELLED') else 'none' }}" aria-label="Delete this job">Delete</button>
<time class="status-time" id="created-time" datetime="{{ job.created_at }}">{{ job.created_at | int }}</time> <time class="status-time" id="created-time" datetime="{{ job.created_at }}">{{ job.created_at | int }}</time>
</div> </div>
<h2 class="job-id" id="job-title">Job {{ job.job_id[:8] }}...</h2> <h2 class="job-id" id="job-title">Job {{ job.job_id[:8] }}...</h2>