Files
feedmill-recounter/templates/jobs.html
T
jetson 163280078f 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
2026-09-22 10:49:06 +07:00

394 lines
12 KiB
HTML

{% extends "base.html" %}
{% block title %}Jobs - Feedmill Recounter{% endblock %}
{% block content %}
<style>
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-8);
gap: var(--space-4);
}
.page-header h2 {
font-size: var(--text-2xl);
color: var(--color-neutral-900);
}
/* Empty state */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: var(--space-16) var(--space-6);
color: var(--color-neutral-500);
}
.empty-state-icon {
width: 120px;
height: 120px;
margin-bottom: var(--space-6);
color: var(--color-neutral-300);
}
.empty-state h3 {
font-size: var(--text-xl);
color: var(--color-neutral-700);
margin-bottom: var(--space-2);
}
.empty-state p {
font-size: var(--text-sm);
margin-bottom: var(--space-6);
}
/* Card grid */
.jobs-grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-6);
}
@media (min-width: 640px) {
.jobs-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 1024px) {
.jobs-grid {
grid-template-columns: repeat(3, 1fr);
}
}
/* Job card */
.job-card {
display: flex;
flex-direction: column;
overflow: hidden;
padding: 0;
border-radius: var(--radius-lg);
}
.job-card:hover {
box-shadow: var(--shadow-lg);
}
.job-card-thumb {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
background: var(--color-neutral-900);
overflow: hidden;
}
.job-card-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.job-card-thumb-placeholder {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-neutral-500);
font-size: var(--text-2xl);
}
.job-card-status {
position: absolute;
top: var(--space-3);
right: var(--space-3);
z-index: 1;
}
.job-card-body {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-5);
}
.job-card-header {
display: flex;
align-items: center;
gap: var(--space-2);
}
.job-card-id {
font-family: var(--font-mono);
font-size: var(--text-sm);
font-weight: var(--font-semibold);
color: var(--color-neutral-800);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.copy-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: 1px solid var(--color-neutral-200);
border-radius: var(--radius-sm);
background: var(--color-neutral-50);
color: var(--color-neutral-500);
cursor: pointer;
transition: all var(--transition-fast);
flex-shrink: 0;
}
.copy-btn:hover {
background: var(--color-neutral-100);
color: var(--color-neutral-700);
}
.copy-btn.copied {
background: var(--color-success-50);
color: var(--color-success);
border-color: var(--color-success);
}
.copy-btn svg {
width: 14px;
height: 14px;
}
.job-card-video {
font-size: var(--text-sm);
color: var(--color-neutral-600);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.job-card-meta {
display: flex;
align-items: center;
justify-content: space-between;
font-size: var(--text-xs);
color: var(--color-neutral-500);
}
/* Thin progress bar */
.job-card-progress {
width: 100%;
height: 4px;
background: var(--color-neutral-200);
border-radius: 2px;
overflow: hidden;
}
.job-card-progress-fill {
height: 100%;
background: var(--color-primary-500);
border-radius: 2px;
transition: width 0.4s ease;
}
.job-card-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) var(--space-4);
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 {
font-size: var(--text-xs);
color: var(--color-neutral-400);
}
.job-card-link {
font-size: var(--text-sm);
font-weight: var(--font-semibold);
color: var(--color-primary);
transition: color var(--transition-fast);
}
.job-card-link:hover {
color: var(--color-primary-700);
}
</style>
<div class="page-header">
<h2>Processing Jobs</h2>
</div>
{% if jobs %}
<div class="jobs-grid">
{% for job in jobs %}
<article class="card job-card">
<div class="job-card-thumb">
<img src="/preview/thumb/{{ job.job_id }}" alt="Thumbnail for job {{ job.job_id }}" loading="lazy"
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">
<div class="job-card-thumb-placeholder" style="display:none;" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18"/><line x1="7" y1="2" x2="7" y2="22"/><line x1="17" y1="2" x2="17" y2="22"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="2" y1="7" x2="7" y2="7"/><line x1="2" y1="17" x2="7" y2="17"/><line x1="17" y1="7" x2="22" y2="7"/><line x1="17" y1="17" x2="22" y2="17"/></svg>
</div>
<div class="job-card-status">
<span class="badge
{% if job.status.name == 'COMPLETED' %}badge-success
{% elif job.status.name == 'RUNNING' %}badge-info
{% elif job.status.name == 'PENDING' %}badge-warning
{% elif job.status.name == 'FAILED' %}badge-error
{% else %}badge-neutral{% endif %}">
{{ job.status.name }}
</span>
</div>
</div>
<div class="job-card-body">
<div class="job-card-header">
<span class="job-card-id" title="{{ job.job_id }}">{{ job.job_id[:12] }}{% if job.job_id|length > 12 %}&hellip;{% endif %}</span>
<button class="copy-btn" data-job-id="{{ job.job_id }}" title="Copy Job ID" aria-label="Copy Job ID">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
</button>
</div>
<div class="job-card-video" title="{{ job.video_path }}">
{{ job.video_path.split('/')[-1] }}
</div>
<div class="job-card-progress" role="progressbar" aria-valuenow="{{ job.progress * 100 }}" aria-valuemin="0" aria-valuemax="100">
<div class="job-card-progress-fill" style="width: {{ job.progress * 100 }}%"></div>
</div>
<div class="job-card-meta">
<span>{{ job.model_configs|length }} model{{ 's' if job.model_configs|length != 1 else '' }}</span>
<span>{{ "%.0f"|format(job.progress * 100) }}%</span>
</div>
</div>
<div class="job-card-footer">
<span class="job-card-time" data-timestamp="{{ job.created_at }}"></span>
<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>
</article>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<svg class="empty-state-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18"/>
<line x1="7" y1="2" x2="7" y2="22"/>
<line x1="17" y1="2" x2="17" y2="22"/>
<line x1="2" y1="12" x2="22" y2="12"/>
<line x1="2" y1="7" x2="7" y2="7"/>
<line x1="2" y1="17" x2="7" y2="17"/>
<line x1="17" y1="7" x2="22" y2="7"/>
<line x1="17" y1="17" x2="22" y2="17"/>
</svg>
<h3>No jobs yet</h3>
<p>Upload a video to start processing.</p>
<a href="/" class="btn btn-primary">Upload a video</a>
</div>
{% endif %}
<script>
(function() {
function formatRelativeTime(timestamp) {
var now = Math.floor(Date.now() / 1000);
var diff = now - timestamp;
if (diff < 0) diff = 0;
if (diff < 60) return 'just now';
if (diff < 3600) {
var mins = Math.floor(diff / 60);
return mins + (mins === 1 ? ' minute ago' : ' minutes ago');
}
if (diff < 86400) {
var hrs = Math.floor(diff / 3600);
return hrs + (hrs === 1 ? ' hour ago' : ' hours ago');
}
var days = Math.floor(diff / 86400);
return days + (days === 1 ? ' day ago' : ' days ago');
}
function updateTimes() {
var els = document.querySelectorAll('[data-timestamp]');
for (var i = 0; i < els.length; i++) {
var ts = parseFloat(els[i].getAttribute('data-timestamp'));
if (!isNaN(ts)) {
els[i].textContent = formatRelativeTime(ts);
}
}
}
updateTimes();
setInterval(updateTimes, 60000);
})();
var copySvg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
var checkSvg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
document.querySelectorAll('.copy-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
var id = this.getAttribute('data-job-id');
var el = this;
navigator.clipboard.writeText(id).then(function() {
el.classList.add('copied');
el.innerHTML = checkSvg;
setTimeout(function() {
el.classList.remove('copied');
el.innerHTML = copySvg;
}, 1500);
});
});
});
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>
{% endblock %}