Compare commits

...

2 Commits

Author SHA1 Message Date
librelad
3d058b3469 Merge claude/1 2026-05-30 14:53:19 +01:00
librelad
1b0040dbf1 refactor(tasks): decompose tasks-manager god-file into 8 responsibility files
Faithful brace-aware split of tasks-manager.js (2664->491 line base) into
list-render, data-load, log-stream, row-expand, actions, modals, logs-modal,
format — augmenting TasksManager.prototype. First removed 4 provably-dead
duplicate defs (the earlier init/setupAutoRefresh/startLogStreaming/loadTaskLogs
that the later defs already overrode — behavior-preserving). Methods relocated
verbatim via a brace-aware Node extractor (handles strings/templates/comments/
regex, fixing the line-heuristic over-capture). Verified: all 60 (deduped)
methods present exactly once, no dups, all 9 files node --check clean. Wired
after the base in the task-system loader (async=false-ordered).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-30 14:53:19 +01:00
10 changed files with 2040 additions and 2174 deletions

View File

@ -0,0 +1,294 @@
// Auto-extracted from tasks-manager.js (verbatim) — augments TasksManager.prototype. Loaded after the base.
Object.assign(TasksManager.prototype, {
async retryTask(taskId) {
if (!confirm('Are you sure you want to retry this task?')) return;
try {
const task = this.tasks.find(t => t.id === taskId);
if (!task) {
throw new Error('Task not found');
}
// Create a new task with the same command
const newTask = await this.taskManager.createTask(
task.command,
task.type,
task.app,
task.config
);
//// // console.log(`✅ Task retried: ${newTask.id}`);
// Refresh tasks to show the new one
await this.loadTasks();
if (window.notificationSystem) {
// Use the source task's type icon — retrying a backup shows 💾 etc.
const typeIcon = this.getTaskTypeIcon ? this.getTaskTypeIcon(task)?.icon : '';
const customIcon = typeIcon ? `<span style="font-size:18px;line-height:1;">${typeIcon}</span>` : null;
window.notificationSystem.show('Task retried successfully', 'success', null, null, null, customIcon);
}
} catch (error) {
console.error('Error retrying task:', error);
if (window.notificationSystem) {
window.notificationSystem.error(`Failed to retry task: ${error.message}`);
}
}
},
async deleteTask(taskId) {
// Get the latest known status before deciding what to do. The server
// refuses to delete tasks that are still running or queued (HTTP 409),
// and we used to just propagate that as a red banner — but the user is
// almost always trying to clean up a row that looked finished, or a
// genuinely active task they want gone. Either way the right answer is
// "cancel first, then delete", which is what we do here.
const cached = (window.taskEventBus && window.taskEventBus.getTask)
? window.taskEventBus.getTask(taskId)
: null;
let task = cached || (this.tasks && this.tasks.find(t => t.id === taskId));
if (!task && this.taskManager && this.taskManager.getTask) {
try { task = await this.taskManager.getTask(taskId); } catch {}
}
const isActive = task && (task.status === 'running' || task.status === 'queued' || task.status === 'pending');
const confirmed = await this._showDeleteTaskModal(task, isActive);
if (!confirmed) return;
try {
if (isActive) {
// Ask the server to cancel. POST /cancel either flips status straight
// to `cancelled` (queued -> cancelled is synchronous) or drops a
// `<id>.cancel` marker the bash processor picks up within ~1s
// (running -> cancelled). We then wait for the terminal status via
// SSE before issuing the delete.
try { await this.taskManager.cancelTask(taskId); } catch {
// If cancel itself failed (e.g. already terminal), fall through
// and try the delete anyway.
}
await this._waitForTaskTerminal(taskId, 15_000);
}
// Try the polite delete first. If the task is still flagged as
// running/queued server-side (e.g. the bash processor is dead and
// never picked up the cancel marker), fall back to the force-delete
// override so the user isn't stuck with a permanently-undeletable row.
try {
await this.taskManager.deleteTask(taskId);
} catch (err) {
const looks409 = /\bHTTP 409\b/.test(err && err.message ? err.message : '');
if (!looks409) throw err;
await this.taskManager.deleteTask(taskId, { force: true });
}
// Remove from local array and re-render
this.tasks = this.tasks.filter(t => t.id !== taskId);
this.renderTasks();
this.updateStats();
this.updateSidebarCounts();
this.generateAppCategories();
if (window.notificationSystem) {
// Match the "<App> task <verb>" two-line shape used by started/
// completed/failed/cancelled so deletion reads as part of the same
// family. Type emoji falls back to 🗑️ since deletion is a 🗑️ action.
const { appName, actionTitle, displayName, icon, typeIcon } = this._taskNotificationDescriptor(task);
const emoji = typeIcon || '🗑️';
const customIcon = `<span style="font-size:18px;line-height:1;">${emoji}</span>`;
const body = displayName
? `<strong>${displayName}</strong><br>${actionTitle} task deleted.`
: `${actionTitle || 'Task'} deleted.`;
window.notificationSystem.show(body, 'info', appName, null, icon, customIcon);
}
} catch (error) {
console.error('Error deleting task:', error);
if (window.notificationSystem) {
window.notificationSystem.error(`Failed to delete task: ${error.message}`);
}
}
},
async clearAllTasks() {
// Routes to one of two modes depending on whether any rows are ticked.
// Both paths share _showClearAllModal — same UX, same modal, different
// input list + title.
const hasSelection = this.selectedTaskIds.size > 0;
const targetTasks = hasSelection
? this.tasks.filter(t => this.selectedTaskIds.has(t.id))
: this.tasks;
const result = await this._showClearAllModal(targetTasks, hasSelection ? 'selected' : 'all');
if (!result || !result.confirmed) return false;
await this.performClearAll({ cancelRunning: result.cancelRunning, targets: targetTasks });
if (hasSelection) {
// Drop any ids we just deleted from the selection set, then refresh
// the button label + master-checkbox state.
this.selectedTaskIds = new Set([...this.selectedTaskIds].filter(id => this.tasks.find(t => t.id === id)));
this._updateSelectionUI();
}
return true;
},
async performClearAll(opts) {
opts = opts || {};
const cancelRunning = !!opts.cancelRunning;
// Caller (clearAllTasks) passes the subset to operate on. Falls back
// to this.tasks for backward compatibility — old direct callers
// continue to mean "everything".
const universe = Array.isArray(opts.targets) ? opts.targets : (this.tasks || []);
// Show progress notification with the trash type icon in the left slot
// (this is conceptually a delete-all action, so 🗑️ matches the row icon).
const customIcon = '<span style="font-size:18px;line-height:1;">🗑️</span>';
const progressNotification = window.notificationSystem.show(
'Clearing tasks...',
'info',
null,
null,
null,
customIcon
);
const isActive = (t) => t && (t.status === 'running' || t.status === 'queued' || t.status === 'pending');
// Partition: which tasks we attempt now, which we skip silently.
const targets = universe.filter(t => cancelRunning || !isActive(t));
const skipped = universe.filter(t => !targets.includes(t));
try {
// For active tasks (only reached when the toggle is on): cancel first,
// wait for terminal status, then delete. Mirrors the single-row
// deleteTask flow so the bash processor has a chance to honour the
// cancel marker before the DELETE arrives (otherwise we'd 409).
await Promise.all(targets.map(async (task) => {
if (cancelRunning && isActive(task)) {
try { await this.taskManager.cancelTask(task.id); } catch {}
await this._waitForTaskTerminal(task.id, 15_000);
}
try {
await this.taskManager.deleteTask(task.id);
} catch (err) {
const looks409 = /\bHTTP 409\b/.test(err && err.message ? err.message : '');
if (!looks409) throw err;
await this.taskManager.deleteTask(task.id, { force: true });
}
}));
// Re-sync local state: keep anything we intentionally skipped.
const deletedIds = new Set(targets.map(t => t.id));
this.tasks = this.tasks.filter(t => !deletedIds.has(t.id));
this.renderTasks();
this.updateStats();
this.updateSidebarCounts();
this.generateAppCategories();
if (progressNotification && progressNotification.remove) {
progressNotification.remove();
}
if (window.notificationSystem) {
const msg = skipped.length > 0
? `Deleted ${targets.length} tasks (skipped ${skipped.length} still running)`
: (targets.length === 1 ? 'Task deleted' : `Deleted ${targets.length} tasks`);
window.notificationSystem.show(msg, 'success', null, null, null, customIcon);
}
} catch (error) {
console.error('Error clearing tasks:', error);
if (progressNotification && progressNotification.remove) {
progressNotification.remove();
}
if (window.notificationSystem) {
window.notificationSystem.show(
`Failed to clear tasks: ${error.message}`,
'error',
null,
null,
null,
customIcon
);
}
}
},
// Selection helpers wired by the row + master checkboxes.
toggleTaskSelection(taskId, checked) {
if (checked) this.selectedTaskIds.add(taskId);
else this.selectedTaskIds.delete(taskId);
this._updateSelectionUI();
},
toggleSelectAll(checked) {
if (checked) {
// Tick every CURRENTLY VISIBLE row. We use the rendered checkboxes
// rather than this.tasks so category-filtered views only select
// what the user can see.
const boxes = document.querySelectorAll('#tasks-list [data-task-select]');
boxes.forEach((cb) => {
this.selectedTaskIds.add(cb.dataset.taskSelect);
cb.checked = true;
});
} else {
this.selectedTaskIds.clear();
document.querySelectorAll('#tasks-list [data-task-select]').forEach((cb) => { cb.checked = false; });
}
this._updateSelectionUI();
},
// Rewrites the Clear All button label + the master-checkbox indeterminate
// state to reflect the current selection. Cheap — only touches a few
// DOM nodes, safe to call from any selection-change path.
_updateSelectionUI() {
const n = this.selectedTaskIds.size;
const btn = document.getElementById('tasks-clear-btn');
const btnLabel = btn && btn.querySelector('.clear-btn-label');
if (btnLabel) btnLabel.textContent = n > 0 ? `Delete Selected (${n})` : 'Clear All';
if (btn) btn.title = n > 0 ? `Delete ${n} selected task${n === 1 ? '' : 's'}` : 'Clear All Tasks';
// Master checkbox: checked when ALL visible are picked, indeterminate
// when SOME are, unchecked when none.
const master = document.getElementById('tasks-select-all');
const visible = document.querySelectorAll('#tasks-list [data-task-select]');
if (master) {
if (n === 0 || visible.length === 0) {
master.checked = false;
master.indeterminate = false;
} else if (n >= visible.length) {
master.checked = true;
master.indeterminate = false;
} else {
master.checked = false;
master.indeterminate = true;
}
}
},
// Resolves once the task reaches completed/failed/cancelled, or after the
// timeout. Used by deleteTask so a cancel request has time to take effect
// before we issue the DELETE that would otherwise 409.
_waitForTaskTerminal(taskId, timeoutMs = 15_000) {
return new Promise((resolve) => {
// Already terminal in the bus cache? No need to wait.
const cached = (window.taskEventBus && window.taskEventBus.getTask) ? window.taskEventBus.getTask(taskId) : null;
if (cached && (cached.status === 'completed' || cached.status === 'failed' || cached.status === 'cancelled')) {
return resolve(cached);
}
let timer;
const cleanup = () => {
window.removeEventListener('taskCompleted', onComplete);
window.removeEventListener('taskUpdated', onUpdate);
if (timer) clearTimeout(timer);
};
const isTerminal = (s) => s === 'completed' || s === 'failed' || s === 'cancelled';
const onComplete = (e) => {
const t = e.detail && (e.detail.task || (e.detail.taskId === taskId ? { id: taskId, status: e.detail.status } : null));
const id = (t && t.id) || (e.detail && e.detail.taskId);
if (id !== taskId) return;
cleanup();
resolve(t);
};
const onUpdate = (e) => {
const t = e.detail && e.detail.task;
if (!t || t.id !== taskId) return;
if (isTerminal(t.status)) { cleanup(); resolve(t); }
};
window.addEventListener('taskCompleted', onComplete);
window.addEventListener('taskUpdated', onUpdate);
timer = setTimeout(() => { cleanup(); resolve(null); }, timeoutMs);
});
},
});

View File

@ -0,0 +1,217 @@
// Auto-extracted from tasks-manager.js (verbatim) — augments TasksManager.prototype. Loaded after the base.
Object.assign(TasksManager.prototype, {
async refreshTasks() {
// Show refresh notification
const refreshNotification = window.notificationSystem.info(
'🔄 Refreshing tasks...',
'Tasks',
null,
null
);
try {
await this.loadTasks();
// Remove refresh notification and show success
if (refreshNotification && refreshNotification.remove) {
refreshNotification.remove();
}
if (window.notificationSystem) {
window.notificationSystem.success(
'🔄 Tasks refreshed successfully',
'Tasks',
null,
null
);
}
} catch (error) {
console.error('Error refreshing tasks:', error);
// Remove refresh notification and show error
if (refreshNotification && refreshNotification.remove) {
refreshNotification.remove();
}
if (window.notificationSystem) {
window.notificationSystem.error(
`⚠️ Failed to refresh tasks: ${error.message}`,
'Tasks',
null,
null
);
}
}
},
async loadTasks() {
try {
//// // console.log('🔄 Loading tasks from file system...');
// Check if task system is available
if (!this.taskManager) {
console.warn('⚠️ Task system not yet initialized, skipping task loading');
this.tasks = [];
return;
}
// Get tasks using new system
// console.log('📥 Getting tasks using new queue system...');
// Get queue and current status
let queue = [];
let current = {};
try {
const queueResponse = await fetch('/read-file?path=tasks/queue.json');
if (queueResponse.ok) {
const queueText = await queueResponse.text();
if (queueText.trim()) { // Only parse if not empty
try {
queue = JSON.parse(queueText);
} catch (parseError) {
console.warn('⚠️ Invalid queue.json format, starting with empty queue');
queue = [];
}
}
}
} catch (error) {
// console.log('📝 Queue file not found, starting with empty queue');
}
try {
const currentResponse = await fetch('/read-file?path=tasks/current.json');
if (currentResponse.ok) {
const currentText = await currentResponse.text();
if (currentText.trim()) { // Only parse if not empty
try {
current = JSON.parse(currentText);
} catch (parseError) {
console.warn('⚠️ Invalid current.json format, treating as empty');
current = {};
}
}
}
} catch (error) {
// console.log('📝 Current file not found, no current task');
}
// Load individual task files
const allTasks = [];
// Add queued tasks
for (const taskId of queue) {
try {
const task = await this.taskManager.getTask(taskId);
if (task) allTasks.push(task);
} catch (error) {
console.warn(`⚠️ Failed to load queued task ${taskId}:`, error);
}
}
// Add current task if different from queue
if (current.id && !queue.includes(current.id)) {
try {
const task = await this.taskManager.getTask(current.id);
if (task) allTasks.push(task);
} catch (error) {
console.warn(`⚠️ Failed to load current task ${current.id}:`, error);
}
}
// Scan tasks folder for all task files (including completed ones) - OPTIMIZED
try {
// console.log('🔍 Scanning tasks folder for all task files...');
const tasksResponse = await fetch('/read-directory?path=tasks');
if (tasksResponse.ok) {
const files = await tasksResponse.json();
const taskFiles = files.filter(file =>
file.endsWith('.json') &&
file !== 'queue.json' &&
file !== 'current.json'
);
// console.log(`📁 Found ${taskFiles.length} task files in folder`);
// OPTIMIZATION: Batch load tasks instead of individual calls
const missingTaskIds = taskFiles
.map(file => file.replace('.json', ''))
.filter(taskId => !allTasks.find(task => task.id === taskId));
if (missingTaskIds.length > 0) {
// console.log(`📦 Batch loading ${missingTaskIds.length} missing tasks...`);
try {
const batchResponse = await fetch('/read-tasks-batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ taskIds: missingTaskIds })
});
if (batchResponse.ok) {
const batchTasks = await batchResponse.json();
batchTasks.forEach(task => {
if (task) {
allTasks.push(task);
// console.log(`✅ Added completed task ${task.id} from batch load`);
}
});
} else {
// Fallback to individual loading if batch endpoint not available
// console.log('⚠️ Batch endpoint not available, falling back to individual loading');
await this.loadTasksIndividually(missingTaskIds, allTasks);
}
} catch (error) {
console.warn('⚠️ Batch loading failed, falling back to individual loading:', error);
await this.loadTasksIndividually(missingTaskIds, allTasks);
}
}
}
} catch (error) {
console.warn('⚠️ Failed to scan tasks folder:', error);
}
//// // console.log('📊 Task counts:', {
//queued: queuedTasks.length,
//processing: processingTasks.length,
//completed: completedTasks.length
//});
// Combine all tasks
this.tasks = allTasks;
// Sort by creation time (newest first)
this.tasks.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
// console.log(`✅ Loaded ${this.tasks.length} tasks`);
//// // console.log('📋 All tasks:', this.tasks);
this.renderTasks();
this.updateStats();
this.updateSidebarCounts();
this.generateAppCategories();
} catch (error) {
console.error('❌ Failed to load tasks:', error);
if (window.notificationSystem) {
window.notificationSystem.error(`Failed to load tasks: ${error.message}`);
}
this.tasks = [];
this.renderTasks();
this.updateStats();
this.updateSidebarCounts();
this.generateAppCategories();
}
},
async loadTasksIndividually(taskIds, allTasks) {
// Fallback method for individual task loading
for (const taskId of taskIds) {
try {
const task = await this.taskManager.getTask(taskId);
if (task) {
allTasks.push(task);
// console.log(`✅ Added completed task ${taskId} from individual load`);
}
} catch (error) {
console.warn(`⚠️ Failed to load task ${taskId}:`, error);
}
}
},
});

View File

@ -0,0 +1,188 @@
// Auto-extracted from tasks-manager.js (verbatim) — augments TasksManager.prototype. Loaded after the base.
Object.assign(TasksManager.prototype, {
formatCommandForUser(task) {
if (!task.command) return 'Unknown Task';
// Declarative pattern table — order matters (first match wins, so put
// specific patterns ahead of catch-alls). `title` is either a literal
// string or a function taking the regex match and returning the title;
// the latter is used for per-app patterns that extract the app slug.
//
// Adding a new task: just append one row here. Add the matching command
// shape in the WebUI submission site and you're done — no new branch
// needed in this function.
const displayName = (slug) => window.getAppDisplayName ? window.getAppDisplayName(slug) : (slug.charAt(0).toUpperCase() + slug.slice(1));
const PATTERNS = [
// -- System / setup ----------------------------------------------------
{ match: /^libreportal setup config\b/, title: 'LibrePortal - Apply Configuration' },
{ match: /^libreportal setup finalize\b/, title: 'LibrePortal - Finalize Setup' },
{ match: /^libreportal setup apply\b/, title: 'LibrePortal - Setup Wizard' },
{ match: /^libreportal config update\b/, title: 'LibrePortal - Apply Configuration' },
// -- Self-update -------------------------------------------------------
{ match: /^libreportal update (apply|now)\b/, title: 'LibrePortal - Update' },
{ match: /^libreportal update check\b/, title: 'LibrePortal - Check for Updates' },
// -- Peers -------------------------------------------------------------
{ match: /^libreportal peer add\b/, title: 'LibrePortal - Add Peer' },
{ match: /^libreportal peer remove\b/, title: 'LibrePortal - Remove Peer' },
{ match: /^libreportal peer pair\b/, title: 'LibrePortal - Pair with Peer' },
// -- Regen -------------------------------------------------------------
{ match: /^libreportal regen\b/, title: 'LibrePortal - Regenerate WebUI Data' },
// -- System maintenance ------------------------------------------------
{ match: /^libreportal system reclaim\b/, title: 'LibrePortal - Reclaim Space' },
{ match: /^libreportal system image rm\b/, title: 'LibrePortal - Remove Images' },
{ match: /^libreportal verify\b/, title: 'LibrePortal - Verify System' },
// -- Backup: per-app (these capture the app slug) ----------------------
{ match: /^libreportal backup app create (\w+)/, title: (m) => `${displayName(m[1])} - Create Backup` },
{ match: /^libreportal backup app schedule (\w+)/, title: (m) => `${displayName(m[1])} - Scheduled Backup` },
{ match: /^libreportal backup app list (\w+)/, title: (m) => `${displayName(m[1])} - List Backups` },
{ match: /^libreportal backup app delete_all (\w+)/, title: (m) => `${displayName(m[1])} - Delete All Backups` },
{ match: /^libreportal backup app delete (\w+)/, title: (m) => `${displayName(m[1])} - Delete Backup` },
// -- Backup: system / locations ----------------------------------------
{ match: /^libreportal backup all\b/, title: 'LibrePortal - Backup All Apps' },
{ match: /^libreportal backup verify\b/, title: 'LibrePortal - Verify Backups' },
{ match: /^libreportal backup system\b/, title: 'LibrePortal - Backup Configs' },
{ match: /^libreportal backup location add\b/, title: 'LibrePortal - Add Backup Location' },
{ match: /^libreportal backup location remove\b/, title: 'LibrePortal - Remove Backup Location' },
{ match: /^libreportal backup location init\b/, title: 'LibrePortal - Initialise Backup Locations' },
{ match: /^libreportal backup location check\b/, title: 'LibrePortal - Check Backup Locations' },
{ match: /^libreportal backup location list\b/, title: 'LibrePortal - List Backup Locations' },
{ match: /^libreportal backup location stats\b/, title: 'LibrePortal - Backup Location Stats' },
// -- Restore / migrate -------------------------------------------------
{ match: /^libreportal restore app start (\w+)/, title: (m) => `${displayName(m[1])} - Restore Backup` },
{ match: /^libreportal restore app list (\w+)/, title: (m) => `${displayName(m[1])} - List Backups` },
{ match: /^libreportal restore migrate app (\w+)/, title: (m) => `${displayName(m[1])} - Migrate from Host` },
{ match: /^libreportal restore migrate system\b/, title: 'LibrePortal - Migrate System' },
{ match: /^libreportal restore migrate discover\b/, title: 'LibrePortal - Discover Backups' },
{ match: /^libreportal restore first-run\b/, title: 'LibrePortal - First-Run Restore' },
];
for (const p of PATTERNS) {
const m = task.command.match(p.match);
if (m) return typeof p.title === 'function' ? p.title(m) : p.title;
}
// `libreportal app tool <app> <tool_id> ['<args>']` — needs the tools
// catalog to resolve the friendly label, so it lives outside the table.
const toolMatch = task.command.match(/libreportal app tool (\S+) (\S+)/);
if (toolMatch) {
const appName = toolMatch[1];
const toolId = toolMatch[2];
let label = null;
const cat = window.toolsCatalog;
if (cat && cat.apps && cat.apps[appName] && Array.isArray(cat.apps[appName].tools)) {
const t = cat.apps[appName].tools.find(x => x.id === toolId);
if (t && t.label) label = t.label;
}
if (!label) {
label = toolId.split('_').map(w => w ? w.charAt(0).toUpperCase() + w.slice(1) : '').join(' ');
}
return `${displayName(appName)} - ${label}`;
}
// Generic `libreportal app <action> <app>` — capture the app token only;
// anything after (e.g. config overrides `CFG_FOO=bar|…`) is for the CLI.
const libreportalMatch = task.command.match(/libreportal app (\w+) (\S+)/);
if (libreportalMatch) {
const action = libreportalMatch[1];
const appName = libreportalMatch[2];
const actionMap = {
'install': 'Install Application',
'uninstall': 'Uninstall Application',
'restart': 'Restart Application',
'start': 'Start Application',
'stop': 'Stop Application',
'update': 'Update Application',
'rebuild': 'Rebuild Application',
'delete': 'Delete Backup',
'backup': 'Backup Application',
};
const formattedAction = actionMap[action] || `${action.charAt(0).toUpperCase() + action.slice(1)} Application`;
return `${displayName(appName)} - ${formattedAction}`;
}
if (task.command.includes('docker-compose')) return 'Docker Compose Operation';
if (task.command.includes('docker')) return 'Docker Operation';
return task.command.length > 50 ? task.command.substring(0, 47) + '...' : task.command;
},
// User-facing label for a task action. Explicit map first (so
// 'config_update' reads as 'Update Config', not 'Config Update');
// anything not listed falls back to snake/kebab → Title Case so a
// future task type still renders cleanly instead of leaking the
// literal id like "Config_update".
formatActionTitle(action) {
if (!action) return 'Task';
const map = {
'install': 'Install', 'app-install': 'Install',
'uninstall': 'Uninstall', 'restart': 'Restart',
'start': 'Start', 'stop': 'Stop',
'update': 'Update', 'rebuild': 'Rebuild',
'backup': 'Backup', 'restore': 'Restore',
'delete': 'Delete Backup', 'delete_all': 'Delete All Backups',
'config_update': 'Update Config', 'update_config': 'Update Config',
'system_update': 'Update System', 'system_reclaim': 'Reclaim Space',
'system_image_rm': 'Remove Images', 'verify': 'Verify System',
'setup-config': 'Apply Configuration',
'setup-finalize': 'Finalize Setup'
};
if (map[action]) return map[action];
return action.split(/[_-]/).map(w => w ? w.charAt(0).toUpperCase() + w.slice(1) : '').join(' ');
},
formatDuration(seconds) {
if (seconds < 60) {
return `${seconds}s`;
} else if (seconds < 3600) {
return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
} else {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return `${hours}h ${minutes}m`;
}
},
calculateExecutionTime(startedAt, completedAt) {
if (!startedAt || !completedAt) return null;
const start = new Date(startedAt);
const end = new Date(completedAt);
const durationMs = end - start;
const durationSeconds = Math.floor(durationMs / 1000);
return this.formatDuration(durationSeconds);
},
getTimeAgo(dateString) {
const date = new Date(dateString);
const now = new Date();
const diffMs = now - date;
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return `${diffHours}h ago`;
const diffDays = Math.floor(diffHours / 24);
return `${diffDays}d ago`;
},
extractTimestamp(logEntry) {
const match = logEntry.match(/\[([^\]]+)\]/);
return match ? match[1] : '';
},
extractLogMessage(logEntry) {
const match = logEntry.match(/\] (.+)$/);
return match ? match[1] : logEntry;
},
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
},
});

View File

@ -0,0 +1,512 @@
// Auto-extracted from tasks-manager.js (verbatim) — augments TasksManager.prototype. Loaded after the base.
Object.assign(TasksManager.prototype, {
renderTasks() {
const container = document.getElementById('tasks-list');
if (!container) return;
// Capture which task panels are currently open and where the user
// is scrolled to *before* the innerHTML rebuild. Without this,
// every refresh slams every expanded log shut and snaps to the top
// — which makes watching a live task feel hostile.
const expandedIds = new Set();
container.querySelectorAll('.task-details.task-details-open').forEach(el => {
const taskId = (el.id || '').replace(/^details-/, '');
if (taskId) expandedIds.add(taskId);
});
const scrollParent = container.closest('.main') || document.scrollingElement || document.documentElement;
const savedScrollTop = scrollParent ? scrollParent.scrollTop : window.scrollY;
// Filter tasks based on current category and specific task
let filteredTasks = this.filterTasksByCategory(this.tasks, this.currentCategory);
// Note: Don't filter out other tasks when one is highlighted
// highlightedTaskId is only for auto-expansion, not for filtering
if (filteredTasks.length === 0) {
let message;
if (this.highlightedTaskId) {
message = `Task ${this.highlightedTaskId} not found`;
} else {
// Category display names sometimes already end in "Tasks" (e.g.
// "All Tasks", "Running Tasks") — naive interpolation produced
// "No all tasks tasks found". Strip the trailing word when
// present, and special-case the "all" bucket to read naturally.
const catLow = this.getCategoryDisplayName(this.currentCategory).toLowerCase();
if (catLow === 'all tasks' || catLow === 'all') {
message = 'No tasks found';
} else if (catLow.endsWith(' tasks')) {
message = `No ${catLow} found`;
} else {
message = `No ${catLow} tasks found`;
}
}
container.innerHTML = `
<div class="task-item">
<div class="task-header">
<div class="task-info">
<span class="task-status status-completed">info</span>
<span class="task-command">$ ${message}</span>
<span class="task-time">just now</span>
</div>
<div class="task-actions">
</div>
</div>
<div class="task-details task-details-open">
<div class="task-output">Run a task or install an application to see your task list here</div>
</div>
</div>
`;
return;
}
// Sort tasks by creation time (newest first)
const sortedTasks = filteredTasks.sort((a, b) =>
new Date(b.createdAt) - new Date(a.createdAt)
);
const html = sortedTasks.map(task => this.renderTask(task)).join('');
container.innerHTML = html;
// Re-open everything that was open before the rebuild and re-attach
// log streaming so live tasks keep updating without an extra click.
expandedIds.forEach(taskId => {
const details = document.getElementById(`details-${taskId}`);
if (!details) return;
details.style.display = 'block';
details.classList.add('task-details-open');
const btn = document.querySelector(`.task-btn.toggle-details[onclick*="toggleTaskDetails('${taskId}')"]`);
if (btn) btn.classList.add('expanded');
const t = this.tasks.find(x => x.id === taskId);
if (t && (t.status === 'running' || t.status === 'queued' || t.status === 'pending')) {
if (typeof this.startLogStreaming === 'function') this.startLogStreaming(taskId, t);
} else {
if (typeof this.loadTaskLogs === 'function') this.loadTaskLogs(taskId);
}
});
// Restore scroll. Defer one frame so the browser has laid out the
// new content height before we scroll back into it.
if (scrollParent) {
requestAnimationFrame(() => { scrollParent.scrollTop = savedScrollTop; });
}
// Resync the multi-select UI after the render. The Clear All label
// + master-checkbox tri-state need to reflect the just-rendered row
// set (selections can become stale across category switches).
if (typeof this._updateSelectionUI === 'function') this._updateSelectionUI();
},
filterTasksByCategory(tasks, category) {
switch (category) {
case 'all':
return tasks;
case 'queued':
case 'running':
case 'completed':
case 'failed':
return tasks.filter(task => task.status === category);
case 'install':
case 'uninstall':
return tasks.filter(task => task.type === category);
case 'management':
return tasks.filter(task =>
['restart', 'start', 'stop'].includes(task.type)
);
case 'backup':
return tasks.filter(task =>
['backup', 'restore', 'delete'].includes(task.type)
);
case 'config':
return tasks.filter(task => task.type === 'update_config');
default:
// Assume it's an app name
return tasks.filter(task => task.app === category);
}
},
getCategoryDisplayName(category) {
const displayNames = {
'all': 'All Tasks',
'queued': 'Queued',
'running': 'Running',
'completed': 'Completed',
'failed': 'Failed',
'install': 'Install',
'uninstall': 'Uninstall',
'management': 'Management',
'backup': 'Backups',
'config': 'Configuration',
'libreportal': 'LibrePortal'
};
return displayNames[category] || category.charAt(0).toUpperCase() + category.slice(1);
},
renderTask(task) {
// console.log(`🔍 renderTask called with task:`, task);
// Debug undefined status
if (!task.status) {
console.warn(`⚠️ Task ${task.id} has undefined status:`, task);
}
const statusClass = `status-${task.status || 'unknown'}`;
const timeAgo = this.getTimeAgo(task.createdAt);
const isRunning = task.status === 'running';
const isFailed = task.status === 'failed';
const hasOutput = task.output && task.output.length > 0;
const hasError = task.error && task.error.length > 0;
const hasLogs = task.log && Array.isArray(task.log) && task.log.length > 0;
// console.log(`🔍 Task fields check:`, {
//hasOutput: hasOutput,
//hasError: hasError,
//hasLogs: hasLogs,
//isRunning: isRunning,
//outputLength: task.output ? task.output.length : 0,
//error: task.error,
//logCount: task.log ? task.log.length : 0
//});
const executionTime = task.startedAt && task.completedAt ?
this.calculateExecutionTime(task.startedAt, task.completedAt) : null;
// console.log('🔍 renderTask debug:', {
//taskStatus: task.status,
//statusClass: statusClass,
//statusDisplay: task.status ? task.status.toUpperCase() : 'UNKNOWN'
//});
return `
<div class="task-item" data-task-id="${task.id}">
<div class="task-header" onclick="toggleTaskDetails('${task.id}')">
<div class="task-info">
${this.renderTaskIcons(task)}
<span class="task-title">${this.formatCommandForUser(task)}</span>
<span class="task-status ${statusClass}">${this.getStatusIcon(task.status)} ${task.status ? task.status.toUpperCase() : 'UNKNOWN'}</span>
<span class="task-time">${timeAgo}</span>
${executionTime ? `<span class="task-duration">⏱️ ${executionTime}</span>` : ''}
</div>
<div class="task-actions">
${isFailed ? `
<button class="task-btn retry" onclick="event.stopPropagation(); retryTask('${task.id}')" title="Retry Task">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/>
</svg>
Retry
</button>
` : ''}
<button class="task-btn toggle-details" onclick="event.stopPropagation(); toggleTaskDetails('${task.id}')" title="Toggle Task Details">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6,9 12,15 18,9"></polyline>
</svg>
<span class="task-btn-label">Logs</span>
</button>
<button class="task-btn delete" onclick="event.stopPropagation(); deleteTask('${task.id}')" title="Delete Task">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
<span class="task-btn-label">Delete</span>
</button>
<label class="task-select" onclick="event.stopPropagation();" title="Select for bulk delete">
<input type="checkbox" data-task-select="${task.id}" ${this.selectedTaskIds.has(task.id) ? 'checked' : ''}
onchange="event.stopPropagation(); window.tasksManager && tasksManager.toggleTaskSelection('${task.id}', this.checked)">
<span class="task-select-box" aria-hidden="true"></span>
</label>
</div>
</div>
<!-- Simplified details section (metadata and logs only) -->
<div class="task-details" id="details-${task.id}">
<!-- Task metadata -->
<div class="task-meta">
<div class="meta-item">
<strong>Task ID:</strong> <a href="/tasks/all?task=${task.id}" class="task-id-link" data-task-id="${task.id}">${task.id}</a>
</div>
<div class="meta-item">
<strong>Type:</strong> ${task.type || 'unknown'}
</div>
<div class="meta-item">
<strong>App:</strong> ${task.app ? `<a href="/app/${task.app}" class="task-app-link" data-app-name="${task.app}">${task.app}</a>` : 'system'}
</div>
<div class="meta-item">
<strong>Created:</strong> ${new Date(task.createdAt).toLocaleString()}
</div>
${task.startedAt ? `
<div class="meta-item">
<strong>Started:</strong> ${new Date(task.startedAt).toLocaleString()}
</div>
` : ''}
${task.completedAt ? `
<div class="meta-item">
<strong>Completed:</strong> ${new Date(task.completedAt).toLocaleString()}
</div>
` : ''}
${executionTime ? `
<div class="meta-item">
<strong>Execution Time:</strong> ${executionTime}
</div>
` : ''}
</div>
<!-- Logs section only -->
<div class="task-logs">
<div class="log-container terminal-style" id="logs-${task.id}" style="height: 200px; overflow-y: auto; position: relative;">
${hasLogs ?
task.log.map(log => `<div class="log-entry">${this.taskManager.parseAnsiColors(log)}</div>`).join('') :
'<div style="position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(10, 18, 36, 0.85); display: flex; align-items: center; justify-content: center; z-index: 10;"><div class="loading-spinner" style="width: 16px; height: 16px; border: 2px solid rgba(255,255,255,0.18); border-top: 2px solid #fff; border-radius: 50%; animation: spin 1s linear infinite; margin-right: 8px;"></div>Loading logs...</div></div>'
}
</div>
</div>
</div>
</div>
`;
},
renderTaskIcons(task) {
const typeIcon = `<span class="task-type-icon">${this.getTaskTypeIcon(task).icon}</span>`;
// `app: 'system'` is a category sentinel (config_update, system_update, …),
// not a real app slug, so it has no /core/icons/apps/system.svg — fall through
// to the LibrePortal-system branch so those tasks still get a logo.
const isSystemSentinel = task.app === 'system';
if (task.app && !isSystemSentinel) {
const appIconPath = this.getAppIconPath(task);
return `${typeIcon}<img src="${appIconPath}" alt="${task.app}" class="task-app-icon" onerror="this.style.display='none'">`;
}
if (isSystemSentinel || this.isLibrePortalSystemTask(task)) {
return `${typeIcon}<img src="/core/icons/libreportal.svg" alt="LibrePortal" class="task-app-icon">`;
}
return typeIcon;
},
getAppIconPath(task) {
if (!task.app) return null;
// Try to get icon from commands if available
if (this.commands && this.commands.getAppData) {
const appData = this.commands.getAppData(task.app);
if (appData && appData.icon) {
return appData.icon;
}
}
// Default icon path
return `/core/icons/apps/${task.app}.svg`;
},
isLibrePortalSystemTask(task) {
if (!task || !task.command || task.app) return false;
return /^libreportal (setup|backup\s+all|backup\s+system|backup\s+verify|backup\s+location|backup\s+repo|restore\s+migrate\s+system|restore\s+migrate\s+discover|restore\s+first-run|webui|config|update|verify|system\s+(reclaim|image))\b/.test(task.command);
},
getTaskTypeIcon(task) {
if (!task.type) return { icon: '⚙️', class: 'custom' };
const iconMap = {
'install': { icon: '✅', class: 'install' },
'app-install': { icon: '✅', class: 'install' },
'uninstall': { icon: '❌', class: 'uninstall' },
'restart': { icon: '🔄', class: 'restart' },
'start': { icon: '▶️', class: 'start' },
'stop': { icon: '⏹️', class: 'stop' },
'update': { icon: '⬆️', class: 'update' },
'rebuild': { icon: '🔨', class: 'rebuild' },
'backup': { icon: '💾', class: 'backup' },
'restore': { icon: '📦', class: 'restore' },
'delete': { icon: '🗑️', class: 'delete' },
'delete_all': { icon: '🗑️', class: 'delete' },
'system_image_rm': { icon: '🗑️', class: 'delete' },
'verify': { icon: '🛡️', class: 'verify' },
'setup-config': { icon: '🛠️', class: 'setup' },
'setup-finalize': { icon: '🎉', class: 'setup' },
'custom': { icon: '⚙️', class: 'custom' }
};
return iconMap[task.type] || iconMap['custom'];
},
getStatusIcon(status) {
const icons = {
queued: '⏳',
running: '🔄',
completed: '✅',
failed: '❌'
};
return icons[status] || '📋';
},
updateStats() {
const stats = {
queued: this.tasks.filter(t => t.status === 'queued').length,
running: this.tasks.filter(t => t.status === 'running').length,
completed: this.tasks.filter(t => t.status === 'completed').length,
failed: this.tasks.filter(t => t.status === 'failed').length
};
Object.keys(stats).forEach(status => {
const element = document.getElementById(`${status}-count`);
if (element) element.textContent = stats[status];
});
},
updateSidebarCounts() {
// Update all sidebar category counts
const categories = ['all', 'queued', 'running', 'completed', 'failed', 'install', 'uninstall', 'management', 'backup', 'config'];
categories.forEach(category => {
const count = this.filterTasksByCategory(this.tasks, category).length;
const element = document.getElementById(`count-${category}`);
if (element) element.textContent = count;
});
// Update app-specific counts
const apps = [...new Set(this.tasks.map(task => task.app).filter(Boolean))];
apps.forEach(app => {
const count = this.tasks.filter(task => task.app === app).length;
const element = document.getElementById(`count-app-${app}`);
if (element) element.textContent = count;
});
},
async generateAppCategories() {
const container = document.getElementById('app-categories');
if (!container) return;
try {
// Show loading state
container.innerHTML = '<div class="loading-categories"><div class="loading-spinner"></div><p style="color: #ffffff;">Loading apps...</p></div>';
// Try to load apps data if not already available
let appsData = window.apps || [];
// If window.apps is not available, try to load it
if (!appsData || appsData.length === 0) {
try {
const appsResponse = await fetch('/read-file?path=apps.json');
if (appsResponse.ok) {
const appsText = await appsResponse.text();
if (appsText.trim()) {
appsData = JSON.parse(appsText);
window.apps = appsData; // Cache for future use
}
}
} catch (error) {
// console.log('Could not load apps.json, will use fallback');
}
}
// Filter for installed apps only and extract slugs
const installedApps = appsData
.filter(app => app.installed === true || app.status === 'installed')
.map(app => {
// Extract slug from command like "libreportal app install adguard"
const command = app.command || '';
const slugMatch = command.match(/libreportal app install\s+(.+)$/);
const slug = slugMatch ? slugMatch[1].trim() : '';
// Extract title from "Title - Description" format
const fullName = app.name || '';
const title = fullName.split(' - ')[0].trim();
return { slug, title };
})
.filter(app => app.slug && app.title);
// If no installed apps found from apps data, fall back to task-based apps
if (installedApps.length === 0) {
// console.log('No installed apps found, using task-based app list');
const taskApps = [...new Set(this.tasks.map(task => task.app).filter(Boolean))];
if (taskApps.length === 0) {
container.innerHTML = '<div class="no-apps" style="color: var(--text-secondary); font-size: 12px; padding: 8px;">No apps found</div>';
return;
}
const appCategories = taskApps.map(app => {
const taskCount = this.tasks.filter(task => task.app === app).length;
const displayName = window.getAppDisplayName ? window.getAppDisplayName(app) : (app.charAt(0).toUpperCase() + app.slice(1));
return `
<a href="#" class="sidebar-item" data-category="${app}" onclick="filterTasksByCategory('${app}')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<line x1="9" y1="9" x2="15" y2="9"></line>
<line x1="9" y1="15" x2="15" y2="15"></line>
</svg>
${displayName}
<span class="task-count" id="count-app-${app}">${taskCount}</span>
</a>
`;
}).join('');
container.innerHTML = appCategories;
return;
}
const appCategories = installedApps.map(app => {
const taskCount = this.tasks.filter(task => task.app === app.slug).length;
return `
<a href="#" class="sidebar-item" data-category="${app.slug}" onclick="filterTasksByCategory('${app.slug}')">
<div class="status-indicator status-installed"></div>
${app.title}
<span class="task-count" id="count-app-${app.slug}">${taskCount}</span>
</a>
`;
}).join('');
container.innerHTML = appCategories;
} catch (error) {
console.error('Error generating app categories:', error);
// Final fallback to task-based app list
const apps = [...new Set(this.tasks.map(task => task.app).filter(Boolean))];
if (apps.length === 0) {
container.innerHTML = '<div class="no-apps" style="color: var(--text-secondary); font-size: 12px; padding: 8px;">No apps found</div>';
return;
}
const appCategories = apps.map(app => {
const taskCount = this.tasks.filter(task => task.app === app).length;
const displayName = window.getAppDisplayName ? window.getAppDisplayName(app) : (app.charAt(0).toUpperCase() + app.slice(1));
return `
<a href="#" class="sidebar-item" data-category="${app}" onclick="filterTasksByCategory('${app}')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<line x1="9" y1="9" x2="15" y2="9"></line>
<line x1="9" y1="15" x2="15" y2="15"></line>
</svg>
${displayName}
<span class="task-count" id="count-app-${app}">${taskCount}</span>
</a>
`;
}).join('');
container.innerHTML = appCategories;
}
},
filterTasksByCategoryHandler(category) {
this.currentCategory = category;
// Clear specific task filter when switching categories
this.highlightedTaskId = null;
// Update URL
this.updateURL(category);
// Update sidebar active state
document.querySelectorAll('.sidebar-item').forEach(item => {
item.classList.toggle('active', item.dataset.category === category);
});
this.renderTasks();
},
showError(message) {
const container = document.getElementById('tasks-list');
if (container) {
container.innerHTML = `
<div class="terminal-error">
<div class="error-text">$ ${message}</div>
<div class="error-cursor">_</div>
</div>
`;
}
},
});

View File

@ -0,0 +1,372 @@
// Auto-extracted from tasks-manager.js (verbatim) — augments TasksManager.prototype. Loaded after the base.
Object.assign(TasksManager.prototype, {
// SSE-driven log streaming. Subscribes to `taskLog` events from the bus and
// appends incoming chunks. Initial backlog is fetched once via the API.
//
// Resilient to DOM replacement: the logs container can be wiped out by
// `renderTasks()` (the 30s auto-refresh) or by `loadTaskLogs()` (toggle
// re-open). Instead of capturing a `preElement` reference once, `render()`
// re-locates / re-creates it on every call from the cumulative `buffered`
// string. Idempotent: if already streaming, a second call just re-renders.
async startLogStreaming(taskId, task) {
if (!this.activeLogStreams) this.activeLogStreams = new Map();
if (this.activeLogStreams.has(taskId)) {
const existing = this.activeLogStreams.get(taskId);
if (existing && typeof existing.render === 'function') existing.render();
return;
}
const state = { buffered: '' };
const render = () => {
const logsContainer = document.getElementById(`logs-${taskId}`);
if (!logsContainer) return;
const overlay = logsContainer.querySelector('div[style*="position: absolute"]');
if (overlay) overlay.remove();
let preElement = logsContainer.querySelector('pre.output-content');
if (!preElement) {
logsContainer.innerHTML = '';
preElement = document.createElement('pre');
preElement.className = 'output-content terminal-style';
logsContainer.appendChild(preElement);
}
const atBottom = logsContainer.scrollHeight - logsContainer.scrollTop <= logsContainer.clientHeight + 10;
if (state.buffered) {
preElement.innerHTML = this.taskManager.parseAnsiColors(state.buffered);
} else {
preElement.innerHTML = '<span style="color: #888;">Waiting for logs...</span>';
}
if (atBottom) logsContainer.scrollTop = logsContainer.scrollHeight;
};
// Initial backlog via the API.
try {
const initial = await this.taskManager.readFullTaskLog(taskId);
if (initial && initial.length) state.buffered = initial;
} catch { /* fall through to placeholder */ }
render();
const onLog = (event) => {
const detail = event.detail || {};
if (detail.id !== taskId || typeof detail.chunk !== 'string') return;
state.buffered += detail.chunk;
render();
};
window.addEventListener('taskLog', onLog);
// SSE catch-up: when the backend restarts mid-task (e.g., libreportal
// recreates itself during a CrowdSec install), the SSE event source
// drops. EventSource auto-reconnects, but task.log events emitted
// during the gap are lost. taskBusReady fires after every reconnect —
// pull the missed bytes via the existing /:id/log?position=N endpoint
// and splice them in. Skips on the initial connect (no gap).
let initialReadyFired = false;
const onBusReady = async () => {
if (!initialReadyFired) { initialReadyFired = true; return; }
try {
const res = await fetch(`/api/tasks/${encodeURIComponent(taskId)}/log?position=${state.buffered.length}`);
if (!res.ok) return;
const missed = await res.text();
if (missed) {
state.buffered += missed;
render();
}
} catch { /* network blip, next ready will retry */ }
};
window.addEventListener('taskBusReady', onBusReady);
this.activeLogStreams.set(taskId, {
stream: { stop: () => {
window.removeEventListener('taskLog', onLog);
window.removeEventListener('taskBusReady', onBusReady);
this.activeLogStreams.delete(taskId);
} },
render,
isPaused: false
});
},
// Stop log streaming for a task
stopLogStreaming(taskId) {
if (this.activeLogStreams && this.activeLogStreams.has(taskId)) {
const streamData = this.activeLogStreams.get(taskId);
streamData.stream.stop();
this.activeLogStreams.delete(taskId);
// console.log(`⏹️ Stopped log streaming for task ${taskId}`);
}
},
// Load task logs automatically
async loadTaskLogs(taskId) {
try {
const logsContainer = document.getElementById(`logs-${taskId}`);
if (!logsContainer) {
console.warn(`⚠️ Logs container not found for task ${taskId}`);
return;
}
const task = this.tasks.find(t => t.id === taskId);
const inMemoryLog = (task && Array.isArray(task.log) && task.log.length > 0) ? task.log : null;
const renderInMemory = () => {
if (!inMemoryLog) return false;
logsContainer.innerHTML = inMemoryLog
.map(line => `<div class="log-entry">${this.taskManager.parseAnsiColors(line)}</div>`)
.join('');
return true;
};
logsContainer.innerHTML = '<div class="log-entry">🔄 Loading logs...</div>';
const isScrolledToBottom = logsContainer.scrollHeight - logsContainer.scrollTop <= logsContainer.clientHeight + 10;
const logResponse = await fetch(`/read-file?path=tasks/${taskId}.log`);
if (logResponse.ok) {
const logContent = await logResponse.text();
if (logContent.trim()) {
logsContainer.innerHTML = `<pre class="output-content terminal-style">${this.taskManager.parseAnsiColors(logContent)}</pre>`;
if (isScrolledToBottom) logsContainer.scrollTop = logsContainer.scrollHeight;
return;
}
}
if (renderInMemory()) return;
logsContainer.innerHTML = '<div class="log-entry"> No logs available for this task.</div>';
} catch (error) {
console.error(`❌ Error loading logs for task ${taskId}:`, error);
const logsContainer = document.getElementById(`logs-${taskId}`);
if (logsContainer) {
logsContainer.innerHTML = '<div class="log-entry">❌ Failed to load logs.</div>';
}
}
},
// Load task output on demand
async loadTaskOutput(taskId) {
try {
// Read the task file to get output
const task = await this.taskManager.getTask(taskId);
if (!task) return;
const outputElement = document.querySelector(`[data-task-id="${taskId}"] .task-output`);
if (!outputElement) return;
// Show loading state
outputElement.innerHTML = `
<div class="loading-output">Loading output...</div>
`;
// Check if task has output
if (task.output && task.output.trim()) {
outputElement.innerHTML = `
<h4>📤 Output</h4>
<pre class="output-content terminal-style">${this.taskManager.parseAnsiColors(task.output)}</pre>
`;
} else if (task.error && task.error.trim()) {
outputElement.innerHTML = `
<h4> Error</h4>
<pre class="error-content">${this.escapeHtml(task.error)}</pre>
`;
} else {
// Try to read from log file
const logResponse = await fetch(`/read-file?path=tasks/${taskId}.log`);
if (logResponse.ok) {
const logContent = await logResponse.text();
if (logContent.trim()) {
outputElement.innerHTML = `
<pre class="output-content terminal-style">${this.taskManager.parseAnsiColors(logContent)}</pre>
`;
} else {
outputElement.innerHTML = `
<h4> Information</h4>
<div class="info-content">No output available for this task.</div>
`;
}
} else {
outputElement.innerHTML = `
<h4> Information</h4>
<div class="info-content">No output available for this task.</div>
`;
}
}
} catch (error) {
console.error(`❌ Error loading task output for ${taskId}:`, error);
const outputElement = document.querySelector(`[data-task-id="${taskId}"] .task-output`);
if (outputElement) {
outputElement.innerHTML = `
<h4> Error</h4>
<div class="error-content">Failed to load task output: ${error.message}</div>
`;
}
}
},
// Start global live log updater - simple 2-second updates for all running tasks
startGlobalLiveLogUpdater() {
// console.log(`🔄 Starting global live log updater`);
// Update every 2 seconds
setInterval(async () => {
// console.log(`🔄 Global updater running - checking tasks...`);
// Find all running tasks
const runningTasks = this.tasks.filter(task => task.status === 'running');
// console.log(`🔄 Found ${runningTasks.length} running tasks:`, runningTasks.map(t => t.id));
if (runningTasks.length > 0) {
// console.log(`🔄 Updating live logs for ${runningTasks.length} running tasks`);
// Update each running task's live logs
for (const task of runningTasks) {
// console.log(`🔄 About to update live logs for task ${task.id}`);
await this.updateLiveLogsSimple(task.id);
}
} else {
// console.log(`🔄 No running tasks found, skipping live log updates`);
}
}, 2000); // Every 2 seconds
},
// Simple live log update - no complex polling logic
async updateLiveLogsSimple(taskId) {
const liveLogsElement = document.getElementById(`live-logs-${taskId}`);
if (!liveLogsElement) {
// console.log(`⚠️ Live logs element not found for task ${taskId}`);
return; // Silently skip if element not found
}
try {
// console.log(`🔄 Reading log file for task ${taskId}`);
// Read the log file content
const response = await fetch(`/read-file?path=tasks/${taskId}.log`);
// console.log(`🔄 Log file response status: ${response.status} for task ${taskId}`);
if (response.ok) {
const logContent = await response.text();
// console.log(`🔄 Log file content length: ${logContent.length} chars for task ${taskId}`);
// console.log(`🔄 First 100 chars of log content: "${logContent.substring(0, 100)}..."`);
if (logContent.trim()) {
// Split into lines and display
const lines = logContent.split('\n').filter(line => line.trim());
// console.log(`🔄 Displaying ${lines.length} log lines for task ${taskId}`);
liveLogsElement.innerHTML = lines.map(line =>
`<div class="log-entry">${this.parseAnsiColors(line)}</div>`
).join('');
// Auto-scroll to bottom
liveLogsElement.scrollTop = liveLogsElement.scrollHeight;
} else {
// console.log(`🔄 Log file is empty for task ${taskId}`);
liveLogsElement.innerHTML = '<div class="log-entry">🔄 Waiting for logs...</div>';
}
} else {
console.warn(`⚠️ Failed to read log file for task ${taskId}: ${response.status}`);
liveLogsElement.innerHTML = '<div class="log-entry">⚠️ Unable to read logs</div>';
}
} catch (error) {
// Silently handle errors
console.warn(`⚠️ Error reading live logs for task ${taskId}:`, error);
liveLogsElement.innerHTML = '<div class="log-entry">❌ Error loading logs</div>';
}
},
// Update task structure for live logs
updateTaskStructure(taskId, task) {
const taskElement = document.querySelector(`[data-task-id="${taskId}"]`);
if (!taskElement) return;
const detailsElement = taskElement.querySelector('.task-details');
if (!detailsElement) return;
// console.log(`🔄 Updating task structure for ${taskId} to show simplified logs`);
// Check if logs container already exists
const existingLogs = detailsElement.querySelector('.task-logs .log-container');
if (existingLogs) {
// console.log(`🔄 Logs container already exists for ${taskId}`);
return; // Already exists, no need to update
}
// Add simplified logs section for running tasks
if (task.status === 'running') {
const logsHtml = `
<div class="task-logs">
<div class="log-container terminal-style" id="logs-${taskId}" style="height: 200px; overflow-y: auto; position: relative;">
<div style="position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(10, 18, 36, 0.85); display: flex; align-items: center; justify-content: center; z-index: 10;"><div class="loading-spinner" style="width: 16px; height: 16px; border: 2px solid rgba(255,255,255,0.18); border-top: 2px solid #fff; border-radius: 50%; animation: spin 1s linear infinite; margin-right: 8px;"></div>Loading logs...</div></div>
</div>
</div>
`;
// Insert logs section at the bottom of details
detailsElement.insertAdjacentHTML('beforeend', logsHtml);
// console.log(`✅ Added simplified logs section for task ${taskId}`);
// Auto-load logs
this.loadTaskLogs(taskId);
}
},
// Update task display in real-time
updateTaskDisplay(task) {
const taskElement = document.querySelector(`[data-task-id="${task.id}"]`);
if (!taskElement) {
// The monitored task isn't always rendered (different tab, list filtered out,
// task already removed). Silently skip — this is the normal case.
return;
}
// console.log(`🔄 Found task element:`, taskElement);
// Update status and content
const statusElement = taskElement.querySelector('.task-status');
const contentElement = taskElement.querySelector('.task-content');
if (statusElement) {
const statusClass = `status-${task.status || 'unknown'}`;
statusElement.className = `task-status ${statusClass}`;
statusElement.innerHTML = `${this.getStatusIcon(task.status)} ${task.status ? task.status.toUpperCase() : 'UNKNOWN'}`;
} else {
console.warn(`⚠️ Status element not found for task ${task.id}`);
}
// Mirror the status into the details panel's metadata block too — that
// copy of the status was previously left stale until the page reloaded.
const detailsStatus = taskElement.querySelector(`#details-${task.id} .task-meta .status-running, #details-${task.id} .task-meta .status-queued, #details-${task.id} .task-meta .status-pending, #details-${task.id} .task-meta .status-completed, #details-${task.id} .task-meta .status-failed, #details-${task.id} .task-meta .status-cancelled, #details-${task.id} .task-meta [class^="status-"]`);
if (detailsStatus) {
detailsStatus.className = `status-${task.status || 'unknown'}`;
detailsStatus.innerHTML = `${this.getStatusIcon(task.status)} ${task.status ? task.status.toUpperCase() : 'UNKNOWN'}`;
}
if (contentElement) {
contentElement.textContent = task.command;
}
// console.log(`🔄 Updated task ${task.id} display: ${task.status}`);
},
// Update highlighted task status and UI
async updateHighlightedTaskStatus(taskId) {
try {
// Use lightweight summary for status updates
const task = await this.taskManager.getTaskSummary(taskId);
if (!task) return;
// console.log(`🔄 Updating highlighted task ${taskId} status: ${task.status}`);
// Update task display
this.updateTaskDisplay(task);
// If task completed or failed, always load output
if ((task.status === 'completed' || task.status === 'failed')) {
// console.log(`🔄 Task ${taskId} is ${task.status}, loading output...`);
const details = document.getElementById(`details-${taskId}`);
if (details && details.style.display === 'block') {
// Load output regardless of current content
this.loadTaskOutput(taskId);
} else if (details) {
// If details aren't open, mark for loading when opened
details.setAttribute('data-load-output-on-open', 'true');
}
}
} catch (error) {
console.error(`❌ Error updating highlighted task status for ${taskId}:`, error);
}
} ,
});

View File

@ -0,0 +1,104 @@
// Auto-extracted from tasks-manager.js (verbatim) — augments TasksManager.prototype. Loaded after the base.
Object.assign(TasksManager.prototype, {
async viewTaskLogs(taskId) {
// Open logs in a modal or new view
const task = this.tasks.find(t => t.id === taskId);
if (!task) return;
// Load full logs for modal
const fullLogs = await this.taskManager.readFullTaskLog(taskId);
// Create modal with streaming logs
const modal = document.createElement('div');
modal.className = 'task-logs-modal';
modal.innerHTML = `
<div class="modal-overlay" onclick="closeTaskLogsModal()"></div>
<div class="modal-content">
<div class="modal-header">
<h3>📋 Task Logs - ${task.id}</h3>
<div class="log-status">
<span class="status-indicator" id="log-status-${taskId}">Loading...</span>
<button class="log-toggle" id="log-toggle-${taskId}" onclick="toggleLogStreaming('${taskId}')"> Pause</button>
</div>
<button class="modal-close" onclick="closeTaskLogsModal()">×</button>
</div>
<div class="modal-body">
<div class="task-info-summary">
<div class="info-row">
<strong>Command:</strong> <code>${task.command}</code>
</div>
<div class="info-row">
<strong>Status:</strong> <span class="status-${task.status || 'unknown'}">${this.getStatusIcon(task.status)} ${task.status ? task.status.toUpperCase() : 'UNKNOWN'}</span>
</div>
<div class="info-row">
<strong>Created:</strong> ${new Date(task.createdAt).toLocaleString()}
</div>
</div>
<div class="logs-section">
<h4>Execution Logs <span class="log-line-count">(0 lines)</span></h4>
<div class="log-viewer terminal-style" id="log-viewer-${taskId}">
${fullLogs.split('\n').map(log => `<div class="log-line">${this.taskManager.parseAnsiColors(log)}</div>`).join('')}
</div>
</div>
${task.output ? `
<div class="output-section">
<h4>Command Output</h4>
<pre class="output-viewer">${this.taskManager.parseAnsiColors(task.output)}</pre>
</div>
` : ''}
${task.error ? `
<div class="error-section">
<h4>Error Details</h4>
<pre class="error-viewer">${this.escapeHtml(task.error)}</pre>
</div>
` : ''}
</div>
</div>
`;
document.body.appendChild(modal);
// Update line count
const lineCount = fullLogs.split('\n').length;
const lineCountElement = modal.querySelector('.log-line-count');
if (lineCountElement) {
lineCountElement.textContent = `(${lineCount} lines)`;
}
// Start streaming if task is running
if (task.status === 'running' || task.status === 'queued') {
this.startLogStreaming(taskId, modal);
}
// Store streaming controller
this.activeLogStreams = this.activeLogStreams || new Map();
},
toggleLogStreaming(taskId) {
const streamData = this.activeLogStreams?.get(taskId);
if (!streamData) return;
const toggleButton = document.getElementById(`log-toggle-${taskId}`);
const statusIndicator = document.getElementById(`log-status-${taskId}`);
if (streamData.isPaused) {
// Resume streaming
streamData.isPaused = false;
if (toggleButton) toggleButton.textContent = '⏸️ Pause';
if (statusIndicator) {
statusIndicator.textContent = '🔴 Live';
statusIndicator.className = 'status-indicator live';
}
} else {
// Pause streaming
streamData.isPaused = true;
if (toggleButton) toggleButton.textContent = '▶️ Resume';
if (statusIndicator) {
statusIndicator.textContent = '⏸️ Paused';
statusIndicator.className = 'status-indicator paused';
}
}
},
});

View File

@ -0,0 +1,177 @@
// Auto-extracted from tasks-manager.js (verbatim) — augments TasksManager.prototype. Loaded after the base.
Object.assign(TasksManager.prototype, {
// Confirmation modal for deleteTask. Uses the shared openEoModal so it
// visually matches every other destructive confirmation on the app
// (Uninstall, Apply Configuration, etc). Resolves true if the user
// confirms Delete, false on Cancel / backdrop click / close.
_showDeleteTaskModal(task, isActive) {
return new Promise((resolve) => {
const escHtml = (s) => String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
// Friendly title + app icon — mirrors the completion-toast format so
// the modal's identity matches every other surface.
const { isSystemTask, actionTitle, displayName, icon: taskIcon } = this._taskNotificationDescriptor(task);
// For the modal we omit the "LibrePortal" subject (the eyebrow already
// says "Delete Task") and just lead with the action: "Update Config".
const modalSubject = isSystemTask ? '' : displayName;
const taskLabel = modalSubject
? `${actionTitle} ${modalSubject}`
: (actionTitle || (task && task.id) || 'Unknown task');
const taskStatus = (task && task.status) || 'unknown';
const warningTitle = isActive ? 'Active task' : 'This cannot be undone';
const warningText = isActive
? 'This task is still running or queued. It will be cancelled first, then deleted.'
: 'The task and its logs will be permanently removed.';
const bodyHtml = `
<div class="eo-empty-state danger" role="status">
<div class="eo-empty-state-icon">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
<line x1="12" y1="9" x2="12" y2="13"></line>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</svg>
</div>
<div class="eo-empty-state-body">
<p class="eo-empty-state-title">${escHtml(warningTitle)}</p>
<p class="eo-empty-state-text">${escHtml(warningText)}</p>
</div>
</div>
${window.eoBadgeRow ? window.eoBadgeRow([
{ label: `Status: ${taskStatus}`, variant: isActive ? 'warning' : 'info' }
]) : ''}
`;
let decided = false;
const finish = (val, modal) => {
if (decided) return;
decided = true;
if (modal) modal.close();
resolve(val);
};
window.openEoModal({
id: 'delete-task-modal',
size: 'sm',
icon: taskIcon || undefined,
iconAlt: displayName || 'Task',
eyebrow: 'Delete Task',
title: taskLabel,
desc: 'Confirm to delete this task.',
body: bodyHtml,
actions: [
{ label: 'Delete Task', variant: 'danger', onClick: (m) => finish(true, m) },
{ label: 'Cancel', variant: 'secondary', onClick: (m) => finish(false, m) }
],
onClose: () => finish(false, null)
});
});
},
// Confirmation modal for clearAllTasks. Same openEoModal shape as
// _showDeleteTaskModal so visual identity matches every other destructive
// confirmation (Uninstall, Delete Task, …). Adds a "Cancel running tasks
// too" toggle (off by default) — when off, running/queued tasks are
// skipped; when on, they're cancelled first then deleted.
// Resolves {confirmed, cancelRunning}. Cancel/backdrop/close → confirmed=false.
// mode: 'all' (everything) or 'selected' (user-picked subset) — just
// shapes the title/copy.
_showClearAllModal(tasks, mode) {
return new Promise((resolve) => {
const escHtml = (s) => String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const isActive = (t) => t && (t.status === 'running' || t.status === 'queued' || t.status === 'pending');
const total = (tasks || []).length;
const runningCount = (tasks || []).filter(isActive).length;
const terminalCount = total - runningCount;
const bodyHtml = `
<div class="eo-empty-state danger" role="status">
<div class="eo-empty-state-icon">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
<line x1="12" y1="9" x2="12" y2="13"></line>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</svg>
</div>
<div class="eo-empty-state-body">
<p class="eo-empty-state-title">This cannot be undone</p>
<p class="eo-empty-state-text">All selected tasks and their logs will be permanently removed.</p>
</div>
</div>
${window.eoBadgeRow ? window.eoBadgeRow([
{ label: `Total: ${total}`, variant: 'info' },
...(runningCount > 0 ? [{ label: `Running: ${runningCount}`, variant: 'warning' }] : []),
...(terminalCount > 0 ? [{ label: `Terminal: ${terminalCount}`, variant: 'success' }] : []),
]) : ''}
${runningCount > 0 ? `
<label class="eo-toggle eo-toggle-card" data-eo-toggle-row>
<input type="checkbox" id="clear-all-cancel-running">
<span class="eo-toggle-track"></span>
<span class="eo-toggle-text">
<span class="eo-toggle-text-title">Cancel running tasks too</span>
<span class="eo-toggle-text-help">Off: skip the ${runningCount} active task${runningCount === 1 ? '' : 's'}. On: cancel them first, then delete.</span>
</span>
</label>
` : ''}
`;
let decided = false;
const finish = (val, modal) => {
if (decided) return;
decided = true;
if (modal) modal.close();
resolve(val);
};
// Title shape depends on which path got us here:
// selected → "Delete N selected task(s)?" (multi-select chip)
// all → "Delete all N tasks?" (legacy Clear All)
const isSelectedMode = mode === 'selected';
const titleText = isSelectedMode
? (total === 1 ? 'Delete 1 selected task?' : `Delete ${total} selected tasks?`)
: (total === 1 ? 'Delete 1 task?' : `Delete all ${total} tasks?`);
const m = window.openEoModal({
id: 'clear-all-tasks-modal',
size: 'sm',
eyebrow: isSelectedMode ? 'Delete Selected' : 'Delete Tasks',
title: titleText,
desc: isSelectedMode
? 'Confirm to delete the ticked tasks.'
: 'Confirm to delete every task on the list.',
body: bodyHtml,
actions: [
{
label: 'Delete',
variant: 'danger',
onClick: (modal) => {
const cb = modal.bodyEl.querySelector('#clear-all-cancel-running');
finish({ confirmed: true, cancelRunning: !!(cb && cb.checked) }, modal);
}
},
{ label: 'Cancel', variant: 'secondary', onClick: (modal) => finish({ confirmed: false }, modal) }
],
onClose: () => finish({ confirmed: false }, null)
});
// Live-update the danger button's label so the user knows exactly
// what will happen as they flip the toggle. No-op when no running
// tasks (no toggle in the body in that case).
const cb = m.bodyEl.querySelector('#clear-all-cancel-running');
const deleteBtn = m.contentEl.querySelector('.btn-danger');
const updateLabel = () => {
if (!deleteBtn) return;
const cancelRunning = !!(cb && cb.checked);
if (runningCount > 0 && !cancelRunning) {
deleteBtn.textContent = `Delete ${terminalCount} (skip ${runningCount} running)`;
} else {
deleteBtn.textContent = total === 1 ? 'Delete Task' : `Delete ${total} Tasks`;
}
};
if (cb) cb.addEventListener('change', updateLabel);
updateLabel();
});
},
});

View File

@ -0,0 +1,166 @@
// Auto-extracted from tasks-manager.js (verbatim) — augments TasksManager.prototype. Loaded after the base.
Object.assign(TasksManager.prototype, {
toggleTaskDetails(taskId) {
const details = document.getElementById(`details-${taskId}`);
const toggleBtn = document.querySelector(`.task-btn.toggle-details[onclick*="toggleTaskDetails('${taskId}')"]`);
if (details) {
const isOpen = details.style.display === 'block';
// Close all other task details and reset their buttons
if (isOpen) {
document.querySelectorAll('.task-details').forEach(otherDetails => {
if (otherDetails.id !== `details-${taskId}`) {
otherDetails.style.display = 'none';
otherDetails.classList.remove('task-details-open');
}
});
document.querySelectorAll('.task-btn.toggle-details').forEach(otherBtn => {
if (!otherBtn.getAttribute('onclick').includes(taskId)) {
otherBtn.classList.remove('expanded');
}
});
// Close current
details.style.display = 'none';
details.classList.remove('task-details-open');
if (toggleBtn) toggleBtn.classList.remove('expanded');
} else {
// Open current
details.style.display = 'block';
details.classList.add('task-details-open');
if (toggleBtn) toggleBtn.classList.add('expanded');
// Auto-load logs when opened. For active tasks, hand off to the live
// streamer so SSE chunks keep updating the panel; for terminal tasks
// a one-shot snapshot is enough.
const t = this.tasks.find(x => x.id === taskId);
if (t && (t.status === 'running' || t.status === 'queued' || t.status === 'pending')) {
this.startLogStreaming(taskId, t);
} else {
this.loadTaskLogs(taskId);
}
// Scroll to task
const taskElement = document.querySelector(`[data-task-id="${taskId}"]`);
if (taskElement) {
taskElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
// Update URL to include task parameter
this.updateURL(this.currentCategory, isOpen ? null : taskId);
this.highlightedTaskId = isOpen ? null : taskId;
} else {
// Remove task parameter from URL
this.updateURL(this.currentCategory);
this.highlightedTaskId = null;
}
},
// Monitor a specific task. State changes now arrive via SSE through
// TaskEventBus, so this is mostly a hook for the UI to:
// - auto-expand the task if it's the one we just started
// - start log streaming when the task transitions to running
// - clean up local intervals when it terminates
// No more polling; the only fetches happen on-demand via TaskManager.
monitorTask(taskId, appName, action) {
if (!this.highlightedTaskId || this.highlightedTaskId === taskId) {
setTimeout(() => this.autoExpandTask(taskId), 1500);
}
let statusUpdateInterval = null;
const onUpdate = (event) => {
const t = event.detail && event.detail.task;
if (!t || t.id !== taskId) return;
if (t.status === 'running') {
this.updateTaskStructure(taskId, t);
this.startLogStreaming(taskId, t);
if (this.highlightedTaskId === taskId && !statusUpdateInterval) {
statusUpdateInterval = setInterval(() => this.updateHighlightedTaskStatus(taskId), 2000);
}
} else {
this.updateTaskDisplay(t);
}
};
const onComplete = (event) => {
if (!event.detail || event.detail.taskId !== taskId) return;
if (statusUpdateInterval) { clearInterval(statusUpdateInterval); statusUpdateInterval = null; }
this.stopLogStreaming(taskId);
window.removeEventListener('taskUpdated', onUpdate);
window.removeEventListener('taskCompleted', onComplete);
};
window.addEventListener('taskUpdated', onUpdate);
window.addEventListener('taskCompleted', onComplete);
},
// Auto-expand a task when it's created
async autoExpandTask(taskId) {
// console.log(`🔄 Auto-expanding task ${taskId}`);
// Wait for task to be rendered
let attempts = 0;
const maxAttempts = 10;
const tryExpand = async () => {
attempts++;
// console.log(`🔄 Auto-expand attempt ${attempts}/${maxAttempts} for task ${taskId}`);
// Check if task element exists
const taskElement = document.querySelector(`[data-task-id="${taskId}"]`);
if (!taskElement) {
// console.log(`⚠️ Task element not found for ${taskId}, attempt ${attempts}`);
if (attempts < maxAttempts) {
setTimeout(tryExpand, 500); // Try again in 500ms
} else {
console.warn(`⚠️ Could not find task element for ${taskId} after ${maxAttempts} attempts`);
}
return;
}
// console.log(`✅ Found task element for ${taskId}`);
// Get the details element
const details = document.getElementById(`details-${taskId}`);
if (!details) {
// console.log(`⚠️ Details element not found for ${taskId}, attempt ${attempts}`);
if (attempts < maxAttempts) {
setTimeout(tryExpand, 500);
} else {
console.warn(`⚠️ Could not find details element for ${taskId}`);
}
return;
}
// console.log(`✅ Found details element for ${taskId}`);
// Expand the task details
details.style.display = 'block';
details.classList.add('task-details-open');
// Update toggle button
const toggleBtn = document.querySelector(`.task-btn.toggle-details[onclick*="toggleTaskDetails('${taskId}')"]`);
if (toggleBtn) {
toggleBtn.classList.add('expanded');
}
// Scroll to the task
taskElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
// Load the output if task is completed
const task = await this.taskManager.getTask(taskId);
if (task && (task.status === 'completed' || task.status === 'failed')) {
setTimeout(() => {
this.loadTaskOutput(taskId);
}, 1000);
}
// console.log(`✅ Auto-expanded task ${taskId}`);
};
tryExpand();
},
});

View File

@ -174,7 +174,16 @@ class SystemLoader {
'/core/lib/task-global-functions.js',
'/core/lib/task-manager.js',
'/core/lib/task-parameter-preserve.js',
'/components/tasks/js/tasks-manager.js'
'/components/tasks/js/tasks-manager.js', // base: class + constructor + init + bus wiring
// prototype-augment clusters (load after base; ordered via async=false):
'/components/tasks/js/tasks-format.js',
'/components/tasks/js/tasks-list-render.js',
'/components/tasks/js/tasks-data-load.js',
'/components/tasks/js/tasks-log-stream.js',
'/components/tasks/js/tasks-row-expand.js',
'/components/tasks/js/tasks-actions.js',
'/components/tasks/js/tasks-modals.js',
'/components/tasks/js/tasks-logs-modal.js'
]
});