diff --git a/webui/backend/data/tasks.db b/webui/backend/data/tasks.db index 96f9121..7e5cf42 100644 Binary files a/webui/backend/data/tasks.db and b/webui/backend/data/tasks.db differ diff --git a/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc index 011e571..2633015 100644 Binary files a/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc and b/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc differ diff --git a/webui/backend/tests/golden/test_ui_smoke_golden.py b/webui/backend/tests/golden/test_ui_smoke_golden.py index aa9b09d..ae633a0 100644 --- a/webui/backend/tests/golden/test_ui_smoke_golden.py +++ b/webui/backend/tests/golden/test_ui_smoke_golden.py @@ -151,6 +151,7 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('id="settings-download-scan-timeout"', body) self.assertIn('id="settings-download-symlink-policy"', body) self.assertIn("ZIP download limits are shown for reference and cannot be changed here.", body) + self.assertIn('id="settings-tasks-list"', body) self.assertIn('id="settings-logs-list"', body) self.assertIn('id="viewer-content"', body) self.assertIn('id="editor-modal"', body) @@ -237,6 +238,13 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('function archiveTaskCountText(task)', app_js) self.assertIn('function archiveTaskCurrentItemText(task)', app_js) self.assertIn('function archiveTaskProgressPercent(task)', app_js) + self.assertIn('function formatTaskStatusLabel(task)', app_js) + self.assertIn('function inferDownloadTaskContext(task)', app_js) + self.assertIn('function formatTaskLine(task)', app_js) + self.assertIn('function renderTaskItems(items)', app_js) + self.assertIn('async function loadTasksForSettings()', app_js) + self.assertIn('async function loadLogsAndTasksForSettings()', app_js) + self.assertIn('function scheduleSettingsLogsPolling()', app_js) self.assertIn('function openZipDownloadModal(selectedItems)', app_js) self.assertIn('function openSingleFileDownloadModal(selectedItem)', app_js) self.assertIn('function markZipDownloadReady(fileName)', app_js) @@ -285,6 +293,11 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('`/api/tasks/${encodeURIComponent(taskId)}`', app_js) self.assertIn('`/api/files/download/archive/${encodeURIComponent(taskId)}`', app_js) self.assertIn('`/api/files/download/archive/${encodeURIComponent(taskId)}/cancel`', app_js) + self.assertIn('const data = await apiRequest("GET", "/api/tasks");', app_js) + self.assertIn('return "Ready for download";', app_js) + self.assertIn('return "Multi-item ZIP";', app_js) + self.assertIn('details.push(`Current: ${task.current_item}`);', app_js) + self.assertIn('details.push(`${task.done_items}/${task.total_items} items`);', app_js) self.assertIn('function applyContextMenuSelection()', app_js) self.assertIn('function startContextMenuOpen()', app_js) self.assertIn('function startContextMenuEdit()', app_js) diff --git a/webui/html/app.js b/webui/html/app.js index 554a774..6d2d0e6 100644 --- a/webui/html/app.js +++ b/webui/html/app.js @@ -101,6 +101,8 @@ let folderUploadPickerInput = null; let settingsState = { activeTab: "general", logsLoaded: false, + tasksLoaded: false, + logsPollTimer: null, showThumbnails: false, preferredStartupPathLeft: null, preferredStartupPathRight: null, @@ -903,6 +905,7 @@ function settingsElements() { downloadScanTimeout: document.getElementById("settings-download-scan-timeout"), downloadSymlinkPolicy: document.getElementById("settings-download-symlink-policy"), logsPanel: document.getElementById("settings-logs-panel"), + tasksList: document.getElementById("settings-tasks-list"), logsList: document.getElementById("settings-logs-list"), logsError: document.getElementById("settings-logs-error"), }; @@ -3726,6 +3729,68 @@ function formatHistoryLine(item) { }; } +function formatTaskStatusLabel(task) { + if (task.operation === "download") { + switch (task.status) { + case "requested": + return "Requested"; + case "preparing": + return "Preparing"; + case "ready": + return "Ready for download"; + case "failed": + return "Failed"; + case "cancelled": + return "Cancelled"; + default: + return task.status; + } + } + switch (task.status) { + case "queued": + return "Queued"; + case "running": + return "Running"; + case "completed": + return "Completed"; + case "failed": + return "Failed"; + default: + return task.status; + } +} + +function inferDownloadTaskContext(task) { + if (task.operation !== "download") { + return null; + } + if (typeof task.destination === "string" && /^kodidownload-\d{8}-\d{6}\.zip$/.test(task.destination)) { + return "Multi-item ZIP"; + } + return "Directory ZIP"; +} + +function formatTaskLine(task) { + const when = formatModified(task.finished_at || task.created_at || ""); + const details = []; + const downloadContext = inferDownloadTaskContext(task); + if (downloadContext) { + details.push(downloadContext); + } + if (typeof task.done_items === "number" && typeof task.total_items === "number") { + details.push(`${task.done_items}/${task.total_items} items`); + } + if (task.current_item) { + details.push(`Current: ${task.current_item}`); + } + return { + title: `${task.operation} · ${formatTaskStatusLabel(task)}`, + path: task.destination ? `${task.destination} · ${task.source}` : task.source || "-", + meta: [when, ...details].filter(Boolean).join(" · "), + error: task.status === "failed" ? (task.error_message || task.error_code || "") : "", + }; +} + function renderHistoryItems(items) { const elements = settingsElements(); elements.logsList.innerHTML = ""; @@ -3760,20 +3825,84 @@ function renderHistoryItems(items) { } } +function renderTaskItems(items) { + const elements = settingsElements(); + elements.tasksList.innerHTML = ""; + if (!Array.isArray(items) || items.length === 0) { + const empty = document.createElement("div"); + empty.className = "popup-meta"; + empty.textContent = "No tasks yet."; + elements.tasksList.append(empty); + return; + } + for (const task of items) { + const line = formatTaskLine(task); + const row = document.createElement("div"); + row.className = `settings-log-item status-${task.status}`; + const title = document.createElement("div"); + title.className = "settings-log-title"; + title.textContent = line.title; + const path = document.createElement("div"); + path.className = "settings-log-path"; + path.textContent = line.path; + const meta = document.createElement("div"); + meta.className = "settings-log-meta"; + meta.textContent = line.meta; + row.append(title, path, meta); + if (line.error) { + const error = document.createElement("div"); + error.className = "settings-log-error"; + error.textContent = line.error; + row.append(error); + } + elements.tasksList.append(row); + } +} + async function loadHistoryForSettings() { + const data = await apiRequest("GET", "/api/history"); + renderHistoryItems(data.items || []); + settingsState.logsLoaded = true; +} + +async function loadTasksForSettings() { + const data = await apiRequest("GET", "/api/tasks"); + renderTaskItems(data.items || []); + settingsState.tasksLoaded = true; +} + +async function loadLogsAndTasksForSettings() { const elements = settingsElements(); elements.logsError.textContent = ""; + elements.tasksList.innerHTML = '
'; elements.logsList.innerHTML = ''; try { - const data = await apiRequest("GET", "/api/history"); - renderHistoryItems(data.items || []); - settingsState.logsLoaded = true; + await Promise.all([loadTasksForSettings(), loadHistoryForSettings()]); } catch (err) { + elements.tasksList.innerHTML = ""; elements.logsList.innerHTML = ""; elements.logsError.textContent = err.message; } } +function stopSettingsLogsPolling() { + if (settingsState.logsPollTimer) { + window.clearTimeout(settingsState.logsPollTimer); + settingsState.logsPollTimer = null; + } +} + +function scheduleSettingsLogsPolling() { + stopSettingsLogsPolling(); + if (settingsState.activeTab !== "logs" || settingsElements().overlay.classList.contains("hidden")) { + return; + } + settingsState.logsPollTimer = window.setTimeout(async () => { + await loadLogsAndTasksForSettings(); + scheduleSettingsLogsPolling(); + }, 1500); +} + async function handleShowThumbnailsChange(event) { const input = event.target; try { @@ -3814,6 +3943,7 @@ async function handleInterfaceSave() { } function closeSettings() { + stopSettingsLogsPolling(); settingsElements().overlay.classList.add("hidden"); } @@ -3823,7 +3953,10 @@ async function openSettings(tab = "general") { elements.generalError.textContent = ""; setSettingsTab(tab); if (settingsState.activeTab === "logs") { - await loadHistoryForSettings(); + await loadLogsAndTasksForSettings(); + scheduleSettingsLogsPolling(); + } else { + stopSettingsLogsPolling(); } (settingsState.activeTab === "logs" ? elements.logsTab @@ -4520,12 +4653,20 @@ function setupEvents() { const settings = settingsElements(); settings.closeButton.onclick = closeSettings; - settings.generalTab.onclick = () => setSettingsTab("general"); - settings.interfaceTab.onclick = () => setSettingsTab("interface"); - settings.downloadsTab.onclick = () => setSettingsTab("downloads"); + settings.generalTab.onclick = () => { + stopSettingsLogsPolling(); + setSettingsTab("general"); + }; + settings.interfaceTab.onclick = () => { + stopSettingsLogsPolling(); + setSettingsTab("interface"); + }; + settings.downloadsTab.onclick = () => { + stopSettingsLogsPolling(); + setSettingsTab("downloads"); + }; settings.logsTab.onclick = async () => { - setSettingsTab("logs"); - await loadHistoryForSettings(); + await openSettings("logs"); }; settings.showThumbnailsInput.onchange = handleShowThumbnailsChange; settings.generalSaveButton.onclick = handlePreferredStartupPathSave; diff --git a/webui/html/index.html b/webui/html/index.html index d4e4627..504dd5e 100644 --- a/webui/html/index.html +++ b/webui/html/index.html @@ -227,6 +227,9 @@