680 lines
25 KiB
JavaScript
680 lines
25 KiB
JavaScript
/**
|
|
* Feedmill Recounter — Upload page interactive features
|
|
* Progressive enhancement: form works without JS (basic file input).
|
|
*/
|
|
|
|
(function () {
|
|
'use strict';
|
|
|
|
const form = document.getElementById('upload-form');
|
|
const zone = document.getElementById('upload-zone');
|
|
const fileInput = document.getElementById('video-input');
|
|
const previewWrap = document.getElementById('video-preview');
|
|
const previewVideo = document.getElementById('preview-video');
|
|
const fileInfo = document.getElementById('file-info');
|
|
const fileName = document.getElementById('file-name');
|
|
const fileSize = document.getElementById('file-size');
|
|
const fileDuration = document.getElementById('file-duration');
|
|
const submitBtn = document.getElementById('submit-btn');
|
|
const submitHint = document.getElementById('submit-hint');
|
|
const selectAllBtn = document.getElementById('select-all-btn');
|
|
const deselectAllBtn = document.getElementById('deselect-all-btn');
|
|
const modelGrid = document.getElementById('model-grid');
|
|
|
|
if (!form || !zone || !fileInput) return;
|
|
|
|
var videoPathInput = document.getElementById('video-path-input');
|
|
var existingSection = document.getElementById('existing-videos-section');
|
|
var videoList = document.getElementById('video-list');
|
|
var selectedExistingVideo = null;
|
|
var zoneSection = document.getElementById('zone-section');
|
|
var zoneFixedConfig = document.getElementById('zone-fixed-config');
|
|
var zonePresetSelect = document.getElementById('zone-preset-select');
|
|
var zoneRadios = document.querySelectorAll('input[name="zone_mode"]');
|
|
|
|
/* ------------------------------------------------
|
|
Upload Zone — click, drag-drop, keyboard
|
|
------------------------------------------------ */
|
|
function initUploadZone() {
|
|
// Click anywhere in zone (including the hidden input) triggers browse
|
|
zone.addEventListener('keydown', function (e) {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
fileInput.click();
|
|
}
|
|
});
|
|
|
|
// Drag events
|
|
['dragenter', 'dragover'].forEach(function (evt) {
|
|
zone.addEventListener(evt, function (e) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
zone.classList.add('dragover');
|
|
});
|
|
});
|
|
|
|
['dragleave', 'drop'].forEach(function (evt) {
|
|
zone.addEventListener(evt, function (e) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
zone.classList.remove('dragover');
|
|
});
|
|
});
|
|
|
|
zone.addEventListener('drop', function (e) {
|
|
var files = e.dataTransfer.files;
|
|
if (files.length > 0 && files[0].type.startsWith('video/')) {
|
|
fileInput.files = files;
|
|
handleFile(files[0]);
|
|
}
|
|
});
|
|
|
|
// File input change
|
|
fileInput.addEventListener('change', function () {
|
|
if (fileInput.files.length > 0) {
|
|
handleFile(fileInput.files[0]);
|
|
}
|
|
});
|
|
}
|
|
|
|
/* ------------------------------------------------
|
|
Video Preview & File Info
|
|
------------------------------------------------ */
|
|
function handleFile(file) {
|
|
if (!file || !file.type.startsWith('video/')) return;
|
|
|
|
// Clear existing video selection
|
|
selectedExistingVideo = null;
|
|
videoPathInput.value = '';
|
|
if (videoList) {
|
|
videoList.querySelectorAll('.video-item').forEach(function (el) {
|
|
el.classList.remove('selected');
|
|
el.querySelector('input[type="radio"]').checked = false;
|
|
});
|
|
}
|
|
|
|
// Show file info
|
|
fileName.textContent = file.name;
|
|
fileSize.textContent = formatBytes(file.size);
|
|
fileDuration.textContent = 'probing...';
|
|
fileInfo.classList.add('visible');
|
|
|
|
// Video preview via FileReader
|
|
var reader = new FileReader();
|
|
reader.onerror = function () {
|
|
fileDuration.textContent = 'unknown';
|
|
};
|
|
reader.onload = function (e) {
|
|
previewVideo.src = e.target.result;
|
|
previewVideo.load();
|
|
previewWrap.classList.add('visible');
|
|
|
|
previewVideo.onloadedmetadata = function () {
|
|
fileDuration.textContent = formatDuration(previewVideo.duration);
|
|
};
|
|
previewVideo.onerror = function () {
|
|
fileDuration.textContent = 'unknown';
|
|
};
|
|
};
|
|
reader.readAsDataURL(file);
|
|
|
|
updateSubmitState();
|
|
showZoneSection();
|
|
}
|
|
|
|
function formatBytes(bytes) {
|
|
if (bytes === 0) return '0 B';
|
|
var k = 1024;
|
|
var sizes = ['B', 'KB', 'MB', 'GB'];
|
|
var i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
|
}
|
|
|
|
function formatDuration(seconds) {
|
|
if (!isFinite(seconds)) return 'unknown';
|
|
var m = Math.floor(seconds / 60);
|
|
var s = Math.floor(seconds % 60);
|
|
return m + ':' + (s < 10 ? '0' : '') + s;
|
|
}
|
|
|
|
/* ------------------------------------------------
|
|
Model Cards — select/deselect, highlight
|
|
------------------------------------------------ */
|
|
function initModelCards() {
|
|
if (!modelGrid) return;
|
|
|
|
var cards = modelGrid.querySelectorAll('.model-card');
|
|
cards.forEach(function (card) {
|
|
var checkbox = card.querySelector('.model-card-check');
|
|
|
|
// <label> auto-toggles the checkbox on click; just sync the UI
|
|
card.addEventListener('click', function (e) {
|
|
if (e.target.tagName === 'SELECT') return;
|
|
requestAnimationFrame(function () {
|
|
card.classList.toggle('selected', checkbox.checked);
|
|
updateSubmitState();
|
|
});
|
|
});
|
|
|
|
checkbox.addEventListener('change', function () {
|
|
card.classList.toggle('selected', checkbox.checked);
|
|
updateSubmitState();
|
|
});
|
|
});
|
|
|
|
// Format dropdown change — update card data attribute
|
|
modelGrid.querySelectorAll('.model-card-format select').forEach(function (select) {
|
|
select.addEventListener('change', function () {
|
|
var card = select.closest('.model-card');
|
|
if (card) {
|
|
card.dataset.selectedFormat = select.value;
|
|
}
|
|
});
|
|
});
|
|
|
|
// Select All / Deselect All
|
|
if (selectAllBtn) {
|
|
selectAllBtn.addEventListener('click', function () {
|
|
cards.forEach(function (card) {
|
|
var cb = card.querySelector('.model-card-check');
|
|
cb.checked = true;
|
|
card.classList.add('selected');
|
|
});
|
|
updateSubmitState();
|
|
});
|
|
}
|
|
|
|
if (deselectAllBtn) {
|
|
deselectAllBtn.addEventListener('click', function () {
|
|
cards.forEach(function (card) {
|
|
var cb = card.querySelector('.model-card-check');
|
|
cb.checked = false;
|
|
card.classList.remove('selected');
|
|
});
|
|
updateSubmitState();
|
|
});
|
|
}
|
|
}
|
|
|
|
/* ------------------------------------------------
|
|
Existing Videos — fetch, render, select
|
|
------------------------------------------------ */
|
|
function loadExistingVideos() {
|
|
fetch('/api/videos')
|
|
.then(function (res) { return res.json(); })
|
|
.then(function (videos) {
|
|
if (!videos || videos.length === 0) return;
|
|
existingSection.style.display = '';
|
|
videoList.innerHTML = '';
|
|
videos.forEach(function (v) {
|
|
var item = document.createElement('label');
|
|
item.className = 'video-item';
|
|
item.innerHTML =
|
|
'<input type="radio" name="existing_video" class="video-item-radio" data-path="' + escapeHtml(v.path) + '" data-name="' + escapeHtml(v.filename) + '" data-size="' + v.size + '">' +
|
|
'<div class="video-item-info">' +
|
|
'<div class="video-item-name">' + escapeHtml(v.filename) + '</div>' +
|
|
'<div class="video-item-meta">' + formatBytes(v.size) + ' · ' + formatRelativeTime(v.mtime) + '</div>' +
|
|
'</div>' +
|
|
'<button type="button" class="video-delete-btn" title="Delete video" data-filename="' + escapeHtml(v.filename) + '">🗑</button>';
|
|
var radio = item.querySelector('input[type="radio"]');
|
|
radio.addEventListener('change', function () {
|
|
// Deselect all other items
|
|
videoList.querySelectorAll('.video-item').forEach(function (el) {
|
|
el.classList.remove('selected');
|
|
});
|
|
if (radio.checked) {
|
|
item.classList.add('selected');
|
|
selectedExistingVideo = v.path;
|
|
videoPathInput.value = v.path;
|
|
// Show in file-info
|
|
fileName.textContent = v.filename;
|
|
fileSize.textContent = formatBytes(v.size);
|
|
fileDuration.textContent = '—';
|
|
fileInfo.classList.add('visible');
|
|
// Hide upload zone, clear file input
|
|
fileInput.value = '';
|
|
previewWrap.classList.remove('visible');
|
|
showZoneSection();
|
|
} else {
|
|
selectedExistingVideo = null;
|
|
videoPathInput.value = '';
|
|
fileInfo.classList.remove('visible');
|
|
}
|
|
updateSubmitState();
|
|
});
|
|
item.addEventListener('click', function (e) {
|
|
if (e.target.tagName === 'INPUT') return;
|
|
if (e.target.classList.contains('video-delete-btn')) return;
|
|
radio.checked = true;
|
|
radio.dispatchEvent(new Event('change'));
|
|
});
|
|
var deleteBtn = item.querySelector('.video-delete-btn');
|
|
if (deleteBtn) {
|
|
deleteBtn.addEventListener('click', function (e) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (!confirm('Delete "' + v.filename + '"?')) return;
|
|
fetch('/api/videos/' + encodeURIComponent(v.filename), { method: 'DELETE' })
|
|
.then(function (res) { return res.json(); })
|
|
.then(function (data) {
|
|
if (data.status === 'deleted') {
|
|
item.remove();
|
|
if (!videoList.querySelector('.video-item')) {
|
|
existingSection.style.display = 'none';
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|
|
videoList.appendChild(item);
|
|
});
|
|
})
|
|
.catch(function () {});
|
|
}
|
|
|
|
/* ------------------------------------------------
|
|
Zone Mode — show/hide, fetch presets
|
|
------------------------------------------------ */
|
|
function showZoneSection() {
|
|
if (zoneSection) zoneSection.style.display = '';
|
|
}
|
|
|
|
function initZoneMode() {
|
|
if (!zoneSection) return;
|
|
|
|
// Fetch presets from API
|
|
fetch('/api/zones')
|
|
.then(function (res) { return res.json(); })
|
|
.then(function (presets) {
|
|
if (!presets || presets.length === 0) {
|
|
var fixedRadio = zoneSection.querySelector('input[value="fixed"]');
|
|
if (fixedRadio) fixedRadio.closest('label').style.display = 'none';
|
|
return;
|
|
}
|
|
zonePresetSelect.innerHTML = '';
|
|
presets.forEach(function (p) {
|
|
var opt = document.createElement('option');
|
|
opt.value = p.name;
|
|
opt.textContent = p.name + (p.description ? ' — ' + p.description : '');
|
|
zonePresetSelect.appendChild(opt);
|
|
});
|
|
})
|
|
.catch(function () {});
|
|
|
|
zoneRadios.forEach(function (radio) {
|
|
radio.addEventListener('change', function () {
|
|
var isFixed = zoneSection.querySelector('input[value="fixed"]').checked;
|
|
if (isFixed) {
|
|
zoneFixedConfig.classList.add('visible');
|
|
} else {
|
|
zoneFixedConfig.classList.remove('visible');
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
/* ------------------------------------------------
|
|
Submit — enable/disable, loading state
|
|
------------------------------------------------ */
|
|
function initFormSubmit() {
|
|
form.addEventListener('submit', function (e) {
|
|
var checked = modelGrid.querySelectorAll('.model-card-check:checked');
|
|
if (checked.length >= 3) {
|
|
if (!confirm('Running ' + checked.length + ' models simultaneously may use significant GPU memory on Jetson. Continue?')) {
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
}
|
|
if (videoPathInput.value) {
|
|
form.action = '/upload/reuse';
|
|
} else {
|
|
form.action = '/upload';
|
|
}
|
|
submitBtn.disabled = true;
|
|
submitBtn.textContent = 'Starting...';
|
|
submitHint.textContent = 'Starting analysis...';
|
|
});
|
|
}
|
|
|
|
function updateSubmitState() {
|
|
var hasVideo = selectedExistingVideo || (fileInput.files && fileInput.files.length > 0);
|
|
var hasModel = modelGrid && modelGrid.querySelector('.model-card-check:checked');
|
|
var enabled = hasVideo && !!hasModel;
|
|
submitBtn.disabled = !enabled;
|
|
submitHint.textContent = enabled
|
|
? 'Ready to start analysis.'
|
|
: 'Select a video and at least one model to begin.';
|
|
if (selectedExistingVideo) {
|
|
fileInput.removeAttribute('required');
|
|
} else if (fileInput.files && fileInput.files.length === 0) {
|
|
fileInput.setAttribute('required', '');
|
|
}
|
|
}
|
|
|
|
/* ------------------------------------------------
|
|
Init
|
|
------------------------------------------------ */
|
|
initUploadZone();
|
|
initModelCards();
|
|
initFormSubmit();
|
|
initZoneMode();
|
|
loadExistingVideos();
|
|
|
|
// Poll active jobs
|
|
function checkActiveJobs() {
|
|
fetch('/api/jobs/active')
|
|
.then(function(res) { return res.json(); })
|
|
.then(function(data) {
|
|
var banner = document.getElementById('active-jobs-banner');
|
|
var text = document.getElementById('active-jobs-text');
|
|
if (banner && text) {
|
|
if (data.count > 0) {
|
|
banner.style.display = '';
|
|
text.textContent = data.count + ' job' + (data.count > 1 ? 's' : '') + ' in progress';
|
|
} else {
|
|
banner.style.display = 'none';
|
|
}
|
|
}
|
|
})
|
|
.catch(function() {});
|
|
}
|
|
checkActiveJobs();
|
|
setInterval(checkActiveJobs, 5000);
|
|
})();
|
|
|
|
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');
|
|
}
|
|
|
|
/* ============================================================
|
|
Status Page — polling, live preview, result updates
|
|
============================================================ */
|
|
function escapeHtml(str) {
|
|
var div = document.createElement('div');
|
|
div.textContent = str;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function initStatusPage(jobId, initialStatus) {
|
|
var statusBadge = document.getElementById('status-badge');
|
|
var progressFill = document.getElementById('progress-fill');
|
|
var progressPct = document.getElementById('progress-pct');
|
|
var modelName = document.getElementById('model-name');
|
|
var livePreviewImg = document.getElementById('live-preview-img');
|
|
var previewPlaceholder = document.getElementById('preview-placeholder');
|
|
var livePreviewSection = document.getElementById('live-preview-section');
|
|
var resultsSection = document.getElementById('results-section');
|
|
var resultsGrid = document.getElementById('results-grid');
|
|
var errorAlert = document.getElementById('error-alert');
|
|
var errorMessage = document.getElementById('error-message');
|
|
var videoThumb = document.getElementById('video-thumb');
|
|
var videoFallback = document.getElementById('video-fallback');
|
|
|
|
var progressRegion = document.getElementById('progress-section');
|
|
var statusAnnouncer = document.getElementById('status-announcer');
|
|
var cancelBtn = document.getElementById('cancel-btn');
|
|
var terminalStates = ['COMPLETED', 'FAILED', 'CANCELLED'];
|
|
var pollInterval = null;
|
|
var consecutiveFailures = 0;
|
|
var connectionLostIndicator = null;
|
|
var lastStatus = initialStatus;
|
|
|
|
if (!progressFill) return;
|
|
|
|
var previewToggle = document.getElementById('preview-toggle');
|
|
var toggleText = document.getElementById('toggle-text');
|
|
var liveFps = document.getElementById('live-fps');
|
|
var liveCounts = document.getElementById('live-counts');
|
|
var liveLoading = document.getElementById('live-loading');
|
|
var liveUnloading = document.getElementById('live-unloading');
|
|
var liveNet = document.getElementById('live-net');
|
|
|
|
if (previewToggle) {
|
|
previewToggle.addEventListener('change', function() {
|
|
if (this.checked) {
|
|
livePreviewImg.style.display = 'block';
|
|
livePreviewImg.src = '/api/preview/' + jobId + '?t=' + Date.now();
|
|
if (previewPlaceholder) previewPlaceholder.style.display = '';
|
|
livePreviewSection.style.display = '';
|
|
if (toggleText) toggleText.textContent = 'ON';
|
|
} else {
|
|
livePreviewImg.src = '';
|
|
livePreviewImg.style.display = 'none';
|
|
if (previewPlaceholder) previewPlaceholder.style.display = '';
|
|
livePreviewSection.style.display = '';
|
|
if (toggleText) toggleText.textContent = 'OFF';
|
|
fetch('/api/jobs/' + jobId + '/preview', {
|
|
method: 'PUT', headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({enabled: false})
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
/* --- Cancel button --- */
|
|
if (cancelBtn) {
|
|
cancelBtn.addEventListener('click', function() {
|
|
if (!confirm('Abort this job?')) return;
|
|
cancelBtn.disabled = true;
|
|
cancelBtn.textContent = 'Cancelling...';
|
|
fetch('/api/jobs/' + jobId + '/cancel', { method: 'POST' })
|
|
.then(function(res) { return res.json(); })
|
|
.then(function(data) {
|
|
if (data.status === 'cancelled') {
|
|
cancelBtn.textContent = 'Cancelled';
|
|
cancelBtn.classList.add('cancelled');
|
|
} else {
|
|
cancelBtn.disabled = false;
|
|
cancelBtn.textContent = 'Abort';
|
|
if (data.error) alert(data.error);
|
|
}
|
|
})
|
|
.catch(function() {
|
|
cancelBtn.disabled = false;
|
|
cancelBtn.textContent = 'Abort';
|
|
});
|
|
});
|
|
}
|
|
|
|
/* --- Poll job status every 2s --- */
|
|
function pollJob() {
|
|
fetch('/api/jobs/' + jobId)
|
|
.then(function (res) { return res.json(); })
|
|
.then(function (data) {
|
|
consecutiveFailures = 0;
|
|
if (connectionLostIndicator) { connectionLostIndicator.style.display = 'none'; connectionLostIndicator = null; }
|
|
updateProgressBar(data.progress || 0);
|
|
updateStatusBadge(data.status);
|
|
updateModelName(data.current_model);
|
|
updateResults(data.results);
|
|
updateError(data.error);
|
|
announceStatusChange(data.status, data.progress);
|
|
|
|
if (data.live_fps !== undefined && liveFps) {
|
|
liveFps.textContent = data.live_fps > 0 ? data.live_fps.toFixed(1) + ' FPS' : 'Starting...';
|
|
}
|
|
if (data.live_loading !== undefined) {
|
|
if (liveCounts) liveCounts.style.display = '';
|
|
if (liveLoading) liveLoading.textContent = data.live_loading;
|
|
if (liveUnloading) liveUnloading.textContent = data.live_unloading;
|
|
if (liveNet) liveNet.textContent = (data.live_loading - data.live_unloading);
|
|
}
|
|
|
|
if (progressRegion) {
|
|
progressRegion.setAttribute('aria-busy', data.status === 'RUNNING' || data.status === 'PENDING' ? 'true' : 'false');
|
|
}
|
|
|
|
if (terminalStates.indexOf(data.status) !== -1) {
|
|
stopPolling();
|
|
}
|
|
})
|
|
.catch(function () {
|
|
consecutiveFailures++;
|
|
if (consecutiveFailures >= 5 && !connectionLostIndicator) {
|
|
connectionLostIndicator = document.createElement('div');
|
|
connectionLostIndicator.className = 'card';
|
|
connectionLostIndicator.style.cssText = 'padding:0.75rem 1rem;color:#b91c1c;background:#fef2f2;border-left:3px solid #b91c1c;font-size:0.875rem;';
|
|
connectionLostIndicator.textContent = 'Connection lost. Retrying...';
|
|
progressFill.closest('.card').parentNode.insertBefore(connectionLostIndicator, progressFill.closest('.card').nextSibling);
|
|
}
|
|
});
|
|
}
|
|
|
|
/* --- Update progress bar --- */
|
|
function updateProgressBar(progress) {
|
|
var pct = Math.min(100, Math.max(0, Math.round(progress * 100)));
|
|
progressFill.style.width = pct + '%';
|
|
if (progressPct) progressPct.textContent = pct + '%';
|
|
var progressBar = progressFill.parentElement;
|
|
if (progressBar && progressBar.getAttribute('role') === 'progressbar') {
|
|
progressBar.setAttribute('aria-valuenow', pct);
|
|
}
|
|
}
|
|
|
|
/* --- Update status badge --- */
|
|
function updateStatusBadge(status) {
|
|
if (!statusBadge) return;
|
|
statusBadge.textContent = status;
|
|
statusBadge.className = 'status-badge badge';
|
|
var colorMap = {
|
|
'PENDING': 'badge-warning',
|
|
'RUNNING': 'badge-info',
|
|
'COMPLETED': 'badge-success',
|
|
'FAILED': 'badge-error',
|
|
'CANCELLED': 'badge-neutral'
|
|
};
|
|
statusBadge.classList.add(colorMap[status] || 'badge-neutral');
|
|
if (cancelBtn) {
|
|
cancelBtn.style.display = (status === 'PENDING' || status === 'RUNNING') ? 'inline-flex' : 'none';
|
|
}
|
|
}
|
|
|
|
/* --- Update current model name --- */
|
|
function updateModelName(name) {
|
|
if (!modelName) return;
|
|
modelName.textContent = name || '—';
|
|
}
|
|
|
|
/* --- Render result cards --- */
|
|
function updateResults(results) {
|
|
if (!resultsGrid) return;
|
|
if (!results || results.length === 0) {
|
|
if (resultsSection) resultsSection.style.display = 'none';
|
|
return;
|
|
}
|
|
if (resultsSection) resultsSection.style.display = '';
|
|
resultsGrid.innerHTML = '';
|
|
results.forEach(function (r) {
|
|
var card = document.createElement('div');
|
|
card.className = 'result-card card';
|
|
card.innerHTML =
|
|
'<div class="result-model">' + escapeHtml(r.model_name) + '</div>' +
|
|
'<div class="result-stats">' +
|
|
'<div class="stat"><span class="stat-value stat-loading">' + r.loading_count + '</span><span class="stat-label">Sack In</span></div>' +
|
|
'<div class="stat"><span class="stat-value stat-unloading">' + r.unloading_count + '</span><span class="stat-label">Sack Out</span></div>' +
|
|
'<div class="stat"><span class="stat-value stat-loading">' + (r.box_loading_count || 0) + '</span><span class="stat-label">Box In</span></div>' +
|
|
'<div class="stat"><span class="stat-value stat-unloading">' + (r.box_unloading_count || 0) + '</span><span class="stat-label">Box Out</span></div>' +
|
|
'<div class="stat"><span class="stat-value stat-net">' + r.net_count + '</span><span class="stat-label">Sack Net</span></div>' +
|
|
'</div>' +
|
|
'<div class="result-meta">' +
|
|
'<span class="meta-item"><strong>Batches:</strong> ' + r.batch_count + '</span>' +
|
|
'<span class="meta-item"><strong>Frames:</strong> ' + r.frame_count + '</span>' +
|
|
'<span class="meta-item"><strong>Duration:</strong> ' + (r.duration_seconds || 0).toFixed(1) + 's</span>' +
|
|
'</div>';
|
|
if (r.output_path) {
|
|
var filename = r.output_path.split('/').pop();
|
|
card.innerHTML += '<a href="/download/' + jobId + '/' + escapeHtml(filename) + '" class="btn btn-primary result-download">Download</a>';
|
|
}
|
|
resultsGrid.appendChild(card);
|
|
});
|
|
}
|
|
|
|
/* --- Show error if present --- */
|
|
function updateError(err) {
|
|
if (!errorAlert) return;
|
|
if (err) {
|
|
errorAlert.style.display = '';
|
|
if (errorMessage) errorMessage.textContent = err;
|
|
} else {
|
|
errorAlert.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
/* --- Announce status changes to screen readers --- */
|
|
function announceStatusChange(status, progress) {
|
|
if (!statusAnnouncer) return;
|
|
if (status === lastStatus) return;
|
|
lastStatus = status;
|
|
var pct = Math.min(100, Math.max(0, Math.round(progress * 100)));
|
|
var msg = 'Job status changed to ' + status;
|
|
if (status === 'RUNNING') msg += ', ' + pct + '% complete';
|
|
if (status === 'COMPLETED') msg += '. Results are ready.';
|
|
if (status === 'FAILED') msg += '. Job has failed.';
|
|
statusAnnouncer.textContent = msg;
|
|
}
|
|
|
|
/* --- Start / stop polling --- */
|
|
function startPolling() {
|
|
pollInterval = setInterval(pollJob, 2000);
|
|
}
|
|
|
|
function stopPolling() {
|
|
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 --- */
|
|
updateProgressBar(0);
|
|
if (videoThumb) {
|
|
videoThumb.src = '/api/jobs/' + jobId + '/frame?t=0';
|
|
videoThumb.onloadeddata = function () {
|
|
videoThumb.style.display = 'block';
|
|
if (videoFallback) videoFallback.style.display = 'none';
|
|
};
|
|
videoThumb.onerror = function () {
|
|
videoThumb.style.display = 'none';
|
|
if (videoFallback) videoFallback.style.display = '';
|
|
};
|
|
}
|
|
if (terminalStates.indexOf(initialStatus) === -1) {
|
|
startPolling();
|
|
}
|
|
}
|