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:
1 parent
09a8829575
commit
163280078f
6 files changed
+140
-7
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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] }}">🗑</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 %}
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in new issue
Block a user