librelad e57d42ddf6 refactor(webui): path-based URLs for app tabs + config sub-tabs
The app-detail page was the last corner of the SPA still using query
parameters for navigation state. Two related complaints surfaced it:

  - `/app/adguard?tab=tasks` should mirror admin (`/admin/tools/peers`,
    `/admin/config/network`) and be `/app/adguard/tasks`.
  - The config sub-tab (general / advanced / features / network / …)
    had no URL representation at all — `showTab` was a pure visual
    swap with no history push, so refreshing a deep config sub-tab
    sent the user back to the default first category.

New URL shape:

  /app/<name>                          → config tab, default sub-tab
  /app/<name>/<tab>                    → non-config main tab (tasks, backups, …)
  /app/<name>/config/<category>        → config tab + specific sub-tab
  …?task=<id>                          → optional deep-link to a single task

Mirrors `adminPath` / `adminCategoryFromPath`. Two new helpers in
spa.js carry the convention:

  window.appPath(name, tab, sub, taskId) → URL
  window.appPartsFromPath(pathname)      → { app, tab, sub }

Every URL constructor in the WebUI was replaced with `window.appPath`:

  spa.js                               — handleAppDetail back-compat redirect
  app-tabbed-manager.js                — getTabFromURL + new getConfigSubFromURL
                                          (path first, ?tab= fallback for legacy)
                                          updateURL + updateApp use appPath
                                          the inline task-deep-link constructor
  apps-manager.js                      — showAppDetail + showAppDetailWithConfig
                                          showTab now pushes /app/<n>/config/<sub>
                                          renderAppDetail picks the sub-tab out of
                                          the URL on first load
                                          4 fallback task-URL constructors
  tasks-manager.js                     — completion-notification URL
  task-actions.js                      — start-notification URL
  notifications.js                     — 2 task deep-link URLs

Back-compat: handleAppDetail detects legacy `?tab=` / `?config=` /
`?task=` queries and replaceState()s the URL to the canonical path
shape BEFORE anything else reads URL state — old bookmarks land on
the right page and end up with a clean URL.

Verified by running every appPath / appPartsFromPath case (including
the `logs` → `tasks` legacy alias) and confirming the round-trip is
identity. JS syntax checks clean on all six files. No remaining
hardcoded `/app/<x>?tab=` strings outside the back-compat comment.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-27 01:40:05 +01:00

2430 lines
95 KiB
JavaScript
Executable File
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Tasks Manager - Tasks Page UI Management
* Handles the tasks page display, filtering, and UI interactions
* Uses individual task operations from the task/ folder
*/
class TasksManager {
constructor() {
this.tasks = [];
this.currentCategory = 'all';
this.highlightedTaskId = null;
this.taskManager = new TaskManager();
this.init();
// Start global live log updater
this.startGlobalLiveLogUpdater();
this.refreshInterval = null;
this.activeLogStreams = new Map(); // Track active log streams
this.autoRefreshIntervals = new Map(); // Track auto-refresh intervals for running tasks
// Initialize modular components for individual task operations (only if available)
try {
this.commands = new TaskCommands();
this.actions = new TaskActions(this, this.commands);
this.router = new TaskRouter(this, this.actions);
this.taskManager = new TaskManager();
} catch (error) {
console.warn('⚠️ Task system components not yet loaded, will initialize later:', error.message);
this.commands = null;
this.actions = null;
this.router = null;
this.taskManager = null;
}
// Initialize from URL parameters
this.initializeFromURL();
// Load tasks and setup (only if task system is available)
if (this.commands) {
this.loadTasks();
} else {
//// // console.log('⏳ Task system will be initialized later');
}
// Setup global functions
this.setupGlobalFunctions();
// Subscribe to the SSE bus once for the page so every visible task row
// reacts to status changes, not just ones spawned in this session.
this.setupTaskBusListeners();
// Setup mobile menu
this.setupMobileMenu();
//// // console.log('✅ TasksManager initialized with modular architecture');
}
// Initialize task system after scripts are loaded
initializeTaskSystem() {
try {
//// // console.log('🔧 Initializing task system components...');
// Check if TaskManager is available
if (typeof TaskManager === 'undefined') {
console.warn('⚠️ TaskManager not available yet, deferring initialization');
return false;
}
this.commands = new TaskCommands();
this.actions = new TaskActions(this, this.commands);
this.router = new TaskRouter(this, this.actions);
this.taskManager = new TaskManager(); // Add TaskManager for task operations
// Now load tasks since system is ready
this.loadTasks();
//// // console.log('✅ Task system initialized successfully');
return true;
} catch (error) {
console.error('❌ Failed to initialize task system:', error);
return false;
}
}
// Main initialization method for the tasks page
async init() {
//// // console.log('🔧 Initializing TasksManager...');
// Initialize task system if not already done
if (!this.taskManager) {
let initialized = false;
let attempts = 0;
const maxAttempts = 5;
while (!initialized && attempts < maxAttempts) {
//// // console.log(`🔄 Attempting to initialize task system (${attempts + 1}/${maxAttempts})...`);
initialized = this.initializeTaskSystem();
if (!initialized) {
attempts++;
await new Promise(resolve => setTimeout(resolve, 200)); // Wait 200ms
}
}
if (!initialized) {
console.warn('⚠️ Task system initialization failed after retries');
}
}
// Setup refresh interval
this.setupRefreshInterval();
// Reconstructor() {
this.tasks = [];
this.taskManager = new TaskManager();
this.activeLogStreams = new Map(); // Track active log streams
// console.log('🔍 TasksManager initialized');
}
initializeFromURL() {
const currentUrl = new URL(window.location.href);
const searchParams = currentUrl.searchParams;
// Check if we're on the main tasks page (not app page)
const isMainTasksPage = currentUrl.pathname === '/tasks' || currentUrl.pathname.startsWith('/tasks/') || currentUrl.pathname === '/tasks.html';
if (isMainTasksPage) {
// Category from the path (/tasks/<category>), else legacy ?=<category>.
const seg = currentUrl.pathname.replace(/^\/tasks\/?/, '').split('/')[0];
this.currentCategory = seg || searchParams.get('') || 'all';
// Only check for specific task parameter if we're not coming from an app page
const taskParam = searchParams.get('task');
if (taskParam) {
// console.log(`🎯 Found task parameter in URL: ${taskParam} on main tasks page`);
this.highlightedTaskId = taskParam;
} else {
// Clear any existing highlighted task when on main tasks page without task param
this.highlightedTaskId = null;
// console.log(`🎯 Clearing highlighted task on main tasks page`);
}
} else {
// Not on main tasks page, get default filter from localStorage
this.currentCategory = localStorage.getItem('tasksDefaultFilter') || 'all';
this.highlightedTaskId = null; // Always clear when not on tasks page
}
// console.log(`🎯 Tasks category from URL: ${this.currentCategory}`);
}
updateURL(category, taskId = null) {
// Update URL without page reload and without hash
let newURL = `/tasks/${category || 'all'}`;
if (taskId) {
newURL += `?task=${taskId}`;
}
// Prevent the SPA from interfering
if (window.librePortalSPA) {
window.librePortalSPA.currentRoute = newURL;
}
// Use a timeout to avoid conflicts with SPA routing
setTimeout(() => {
window.history.pushState({ category, taskId }, '', newURL);
}, 0);
}
async init() {
//// // console.log('🔧 Initializing TasksManager...');
// Load initial tasks and refresh sidebar counts
await this.loadTasks();
// Force a refresh to ensure latest data
// console.log('🔄 Refreshing tasks data on initialization...');
await this.loadTasks();
// Setup auto-refresh
this.setupAutoRefresh();
// Setup global functions
this.setupGlobalFunctions();
// Subscribe to the SSE bus once for the page so every visible task row
// reacts to status changes, not just ones spawned in this session.
this.setupTaskBusListeners();
// Setup mobile menu
this.setupMobileMenu();
//// // console.log('✅ TasksManager initialized');
}
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);
}
}
}
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 {
const categoryName = this.getCategoryDisplayName(this.currentCategory);
message = `No ${categoryName.toLowerCase()} 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; });
}
}
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>
</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>
`;
}
getStatusIcon(status) {
const icons = {
queued: '⏳',
running: '🔄',
completed: '✅',
failed: '❌'
};
return icons[status] || '📋';
}
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);
}
extractTimestamp(logEntry) {
const match = logEntry.match(/\[([^\]]+)\]/);
return match ? match[1] : '';
}
extractLogMessage(logEntry) {
const match = logEntry.match(/\] (.+)$/);
return match ? match[1] : logEntry;
}
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();
}
startLogStreaming(taskId, modal) {
const statusIndicator = document.getElementById(`log-status-${taskId}`);
const toggleButton = document.getElementById(`log-toggle-${taskId}`);
const logViewer = document.getElementById(`log-viewer-${taskId}`);
const lineCountElement = modal.querySelector('.log-line-count');
if (!logViewer) return;
let lineCount = logViewer.children.length;
const stream = this.taskManager.streamTaskLog(
taskId,
(newLines) => {
// Add new lines to existing content
const preElement = logViewer.querySelector('pre');
let currentContent = preElement ? preElement.textContent : '';
const separator = currentContent.trim() && !currentContent.includes('Waiting for logs...') ? '\n' : '';
let cleanedContent = currentContent.replace('Waiting for logs...', '').trim();
const newContent = cleanedContent + separator + newLines.join('\n');
preElement.innerHTML = this.taskManager.parseAnsiColors(newContent);
logViewer.appendChild(lineElement);
lineCount++;
if (lineCountElement) {
lineCountElement.textContent = `(${lineCount} lines)`;
}
// Auto-scroll to bottom
logViewer.scrollTop = logViewer.scrollHeight;
// Update status
if (statusIndicator) {
statusIndicator.textContent = '🔴 Live';
statusIndicator.className = 'status-indicator live';
}
// Remove new-line highlighting after a moment
setTimeout(() => {
const newLines = logViewer.querySelectorAll('.new-line');
newLines.forEach(line => line.classList.remove('new-line'));
}, 2000);
},
(error) => {
console.error('Log streaming error:', error);
if (statusIndicator) {
statusIndicator.textContent = '❌ Error';
statusIndicator.className = 'status-indicator error';
}
}
);
// Store stream controller
this.activeLogStreams.set(taskId, { stream, modal, isPaused: false });
// Update toggle button
if (toggleButton) {
toggleButton.textContent = '⏸️ Pause';
}
}
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';
}
}
}
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' },
// -- 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 System Config' },
{ 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;
}
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' },
'setup-config': { icon: '🛠️', class: 'setup' },
'setup-finalize': { icon: '🎉', class: 'setup' },
'custom': { icon: '⚙️', class: 'custom' }
};
return iconMap[task.type] || iconMap['custom'];
}
/* Detect a task that's an LibrePortal system action (no specific app) so
the row can show the LibrePortal logo instead of a blank icon slot. */
isLibrePortalSystemTask(task) {
if (!task || !task.command || task.app) return false;
return /^libreportal (setup|backup\s+all|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)\b/.test(task.command);
}
/* Render the leading icon(s) on a task row:
- Per-app task → emoji type icon + app icon
- System task → emoji type icon + LibrePortal logo
- Anything else → emoji type icon only
Keeps the layout consistent across every row regardless of source. */
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 /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="/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 `/icons/apps/${task.app}.svg`;
}
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;
}
}
setupAutoRefresh() {
// Refresh every 5 seconds
this.refreshInterval = setInterval(() => {
this.loadTasks();
}, 5000);
}
setupMobileMenu() {
// Setup mobile menu toggle (if needed)
const mobileOverlay = document.getElementById('mobile-overlay');
const sidebar = document.getElementById('sidebar');
if (mobileOverlay && sidebar) {
mobileOverlay.addEventListener('click', () => {
sidebar.classList.remove('mobile-open');
mobileOverlay.classList.remove('active');
});
}
}
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();
}
setupAutoRefresh() {
// Only refresh when tasks page is visible and every 30 seconds
this.refreshInterval = setInterval(() => {
// Only refresh if we're on the tasks page
if (window.location.pathname === '/tasks' || window.location.hash.includes('tasks')) {
this.loadTasks();
}
}, 30000); // 30 seconds instead of 5
}
setupGlobalFunctions() {
// Make functions available globally for onclick handlers
window.filterTasksByCategory = (category) => {
event.preventDefault();
this.filterTasksByCategoryHandler(category);
};
// Delegated click handler for the Task ID and App links rendered inside
// each task row. Behaviour:
// * Task ID always lands on the global /tasks page with that task open.
// If we're already there, just push the URL and toggle the row open
// (no content reload); otherwise SPA-navigate to /tasks.
// * App link goes to that app's /app page on whatever its default tab
// is (we don't pin tab=… so the app's own logic picks one).
if (!window.__taskMetaLinksBound) {
window.__taskMetaLinksBound = true;
document.addEventListener('click', (e) => {
const link = e.target.closest('a.task-id-link, a.task-app-link');
if (!link) return;
const href = link.getAttribute('href');
if (!href) return;
e.preventDefault();
e.stopPropagation();
const navigate = (url) => {
if (window.spaClean && typeof window.spaClean.navigate === 'function') {
window.spaClean.navigate(url);
} else {
window.location.href = url;
}
};
if (link.classList.contains('task-id-link')) {
const taskId = link.dataset.taskId;
const onTasksPage = window.location.pathname.startsWith('/tasks');
if (onTasksPage && taskId && typeof window.toggleTaskDetails === 'function') {
// Already on /tasks — soft-update the URL and open the row.
window.history.pushState({}, '', href);
if (window.tasksManager) window.tasksManager.highlightedTaskId = taskId;
window.toggleTaskDetails(taskId);
} else {
// Coming from /app or anywhere else — go to the tasks page; its
// initializeFromURL picks up `task=` and auto-expands.
navigate(href);
}
} else {
// App link — go to the app page on its default tab.
//
// `task-parameter-preserve.js` stashes any `task=…` from the
// initial page URL into sessionStorage.pendingTaskId, and
// app-tabbed-manager's init reads that as a fallback when the
// current URL has no task param. Without clearing it here, an old
// task id from /tasks would hijack the app page, force-switching
// to the Tasks tab and opening that task. Clear it so the app
// page lands on its actual default tab.
try { sessionStorage.removeItem('pendingTaskId'); } catch {}
navigate(href);
}
});
}
window.refreshTasks = () => this.refreshTasks();
window.toggleTaskDetails = (taskId) => this.toggleTaskDetails(taskId);
window.retryTask = (taskId) => this.retryTask(taskId);
window.deleteTask = (taskId) => this.deleteTask(taskId);
window.clearAllTasks = () => this.clearAllTasks();
window.viewTaskLogs = (taskId) => this.viewTaskLogs(taskId);
window.toggleLogStreaming = (taskId) => this.toggleLogStreaming(taskId);
window.closeTaskLogsModal = () => {
const modal = document.querySelector('.task-logs-modal');
if (modal) {
// Stop all active streaming for this modal
this.activeLogStreams?.forEach((streamData, streamTaskId) => {
if (streamData.modal === modal) {
streamData.stream.stop();
this.activeLogStreams.delete(streamTaskId);
}
});
modal.remove();
}
};
}
// Single page-wide subscription to the TaskEventBus. This complements
// monitorTask (which only fires for tasks created in this session) and
// ensures any visible row updates when its status changes — including the
// running -> completed/failed/cancelled transition that previously only
// refreshed when the user switched tabs and came back.
//
// The guard is on `window`, not `this`, because TasksManager is constructed
// in several places (system-loader, app-tabbed-manager, …). Without a
// global guard each instance would attach its own listener and the user
// would see N notifications for one task completion.
setupTaskBusListeners() {
if (window.__tasksManagerBusBound) return;
window.__tasksManagerBusBound = true;
const upsertLocal = (task) => {
if (!task || !task.id) return;
const idx = this.tasks.findIndex(t => t.id === task.id);
if (idx >= 0) this.tasks[idx] = task; else this.tasks.unshift(task);
};
window.addEventListener('taskCreated', (e) => {
const task = e.detail && e.detail.task;
if (!task) return;
upsertLocal(task);
// Don't re-render the entire list here — `renderTasks` would blow away
// any open dropdown's DOM. A targeted update is enough; the next
// category switch / refresh will pull the new row in.
this.updateTaskDisplay(task);
});
window.addEventListener('taskUpdated', (e) => {
const task = e.detail && e.detail.task;
if (!task) return;
upsertLocal(task);
if (task.status === 'running') {
this.updateTaskStructure(task.id, task);
this.startLogStreaming(task.id, task);
}
this.updateTaskDisplay(task);
});
window.addEventListener('taskCompleted', (e) => {
const task = e.detail && e.detail.task;
const taskId = (task && task.id) || (e.detail && e.detail.taskId);
if (!taskId) return;
if (task) upsertLocal(task);
if (task) this.updateTaskDisplay(task);
// Final render of any buffered log content, then stop the SSE listener.
const stream = this.activeLogStreams && this.activeLogStreams.get(taskId);
if (stream && typeof stream.render === 'function') stream.render();
this.stopLogStreaming(taskId);
// User-visible notification so they know without staring at the page.
// Layout matches the "<App> task started!" format used by task-actions
// and backup-manager so started/completed look like a matched pair.
if (window.notificationSystem && task) {
const appName = task.app || null;
const action = task.type || 'task';
const friendlyActionMap = {
'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',
'setup-config': 'Apply Configuration',
'setup-finalize': 'Finalize Setup'
};
let actionTitle = friendlyActionMap[action] || (action.charAt(0).toUpperCase() + action.slice(1));
// Tool tasks: override the generic "Tool" label with the tool's
// friendly name (e.g. "Manage Shortcuts") so completion toasts
// match what the user clicked.
const toolCmdMatch = (task.command || '').match(/libreportal app tool (\S+) (\S+)/);
if (toolCmdMatch) {
const toolApp = toolCmdMatch[1];
const toolId = toolCmdMatch[2];
let toolLabel = null;
const cat = window.toolsCatalog;
if (cat && cat.apps && cat.apps[toolApp] && Array.isArray(cat.apps[toolApp].tools)) {
const t = cat.apps[toolApp].tools.find(x => x.id === toolId);
if (t && t.label) toolLabel = t.label;
}
if (!toolLabel) {
toolLabel = toolId.split('_').map(w => w ? w.charAt(0).toUpperCase() + w.slice(1) : '').join(' ');
}
actionTitle = toolLabel;
}
// System-level tasks resolve to LibrePortal as the subject + use the
// LibrePortal logo as the app-icon. Covers `app: 'system'` (config
// update, system update) and `setup-*` types (setup wizard phases).
const isSystemTask = action.startsWith('setup-') || appName === 'system';
const displayName = isSystemTask
? 'LibrePortal'
: ((appName && window.getAppDisplayName)
? window.getAppDisplayName(appName)
: (appName || (task.command || `Task ${taskId}`)));
const onAppPage = window.location.pathname.startsWith('/app') && !window.location.pathname.startsWith('/apps');
const url = (onAppPage && appName)
? window.appPath(appName, 'tasks', null, taskId)
: `/tasks/all?task=${taskId}`;
const icon = isSystemTask
? '/icons/libreportal.svg'
: (appName ? `/icons/apps/${appName}.svg` : null);
// Match the per-action emoji used in the task list rows (see
// `getTaskTypeIcon`). Passed as the 6th `customIcon` arg so the
// notification's leftmost icon slot shows the task *type* (install
// ✅, backup 💾, restore 📦, …) instead of the generic level tick.
const typeIcon = (this.getTaskTypeIcon ? this.getTaskTypeIcon(task) : null)?.icon || '';
const customIcon = typeIcon ? `<span style="font-size:18px;line-height:1;">${typeIcon}</span>` : null;
let body;
let level;
if (task.status === 'completed') {
body = `<strong>${displayName}</strong><br>${actionTitle} task completed!`;
level = 'success';
} else if (task.status === 'failed') {
body = `<strong>${displayName}</strong><br>${actionTitle} task failed.`;
level = 'error';
} else if (task.status === 'cancelled') {
body = `<strong>${displayName}</strong><br>${actionTitle} task cancelled.`;
level = 'warning';
}
if (body) window.notificationSystem.show(body, level, appName, url, icon, customIcon);
// Belt-and-braces: when the completion notification fires we also
// tell the app-tabbed manager to re-enable that app's tabs and
// buttons. The taskCompleted listener inside app-tabbed-manager
// does this too, plus the 5s reconcile sweep — but routing it
// through here as well means any one path being broken or
// de-bound still leaves the user with a usable UI rather than
// permanently-disabled tabs.
if (appName && window.appTabbedManager && typeof window.appTabbedManager.enableAppButtons === 'function') {
try { window.appTabbedManager.enableAppButtons(appName); } catch {}
}
}
});
window.addEventListener('taskDeleted', (e) => {
const id = e.detail && e.detail.id;
if (!id) return;
this.tasks = this.tasks.filter(t => t.id !== id);
const el = document.querySelector(`.task-item[data-task-id="${id}"]`);
if (!el) return;
const parent = el.parentElement;
el.remove();
if (!parent || parent.querySelector('.task-item')) return;
if (parent.id === 'tasks-list') {
this.renderTasks();
} else if (parent.id === 'app-tasks') {
const appName = (window.appTabbedManager && window.appTabbedManager.currentApp) || '';
parent.innerHTML = `<p style="color: #888;">No tasks found for ${appName}.</p>`;
}
});
}
// Load task logs on demand
async loadTaskLogs(taskId) {
try {
// console.log(`📋 Loading logs for task ${taskId}...`);
// Show loading state
const detailsElement = document.getElementById(`details-${taskId}`);
if (detailsElement) {
const logsContainer = detailsElement.querySelector('.task-logs');
if (logsContainer) {
logsContainer.innerHTML = '<div class="loading-logs">📋 Loading logs...</div>';
}
}
// Load task data directly from task file
const task = this.tasks.find(t => t.id === taskId);
let output = '';
if (task) {
output = task.output || '';
} else {
// Try to fetch task data directly
try {
const response = await fetch(`/api/tasks/${taskId}`);
if (response.ok) {
const taskData = await response.json();
output = taskData.output || '';
}
} catch (error) {
console.warn('Failed to fetch task data:', error);
}
}
if (output && output.trim().length > 0) {
// Display the output
const logsHtml = output.split('\n').map(log => `<div class="log-entry">${this.parseAnsiColors(log)}</div>`).join('');
if (detailsElement) {
const logsContainer = detailsElement.querySelector('.task-logs');
if (logsContainer) {
logsContainer.innerHTML = `
<h4 style="margin-left: 20px;">📋 Execution Logs</h4>
<div class="log-container terminal-style">${logsHtml}</div>
`;
}
}
// console.log(`✅ Loaded logs for task ${taskId}`);
} else {
// No logs available
if (detailsElement) {
const logsContainer = detailsElement.querySelector('.task-logs');
if (logsContainer) {
logsContainer.innerHTML = '<div class="no-logs">📋 No output available for this task.</div>';
}
}
// console.log(` No logs available for task ${taskId}`);
}
return output;
} catch (error) {
console.error(`❌ Failed to load logs for task ${taskId}:`, error);
if (window.notificationSystem) {
window.notificationSystem.error(`Failed to load logs: ${error.message}`);
}
return '';
}
}
// 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);
}
// 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}`);
}
}
// 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}`);
}
// 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>';
}
}
// 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>
`;
}
}
}
// 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();
}
// 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);
}
}
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;
}
}
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) {
// Type icon from whatever the task was, with a trash fallback
// because deletion is conceptually a 🗑️ action.
const typeIcon = (task && this.getTaskTypeIcon ? this.getTaskTypeIcon(task)?.icon : '') || '🗑️';
const customIcon = `<span style="font-size:18px;line-height:1;">${typeIcon}</span>`;
window.notificationSystem.show('Task deleted successfully', 'info', null, null, null, customIcon);
}
} catch (error) {
console.error('Error deleting task:', error);
if (window.notificationSystem) {
window.notificationSystem.error(`Failed to delete task: ${error.message}`);
}
}
}
// 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;');
const taskLabel = (task && (task.command || 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',
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)
});
});
}
// 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);
});
}
async clearAllTasks() {
// Use the confirmation dialog system if available, otherwise fallback to confirm
return new Promise((resolve) => {
if (window.showConfirmation) {
window.showConfirmation(
'Clear All Tasks',
'Are you sure you want to clear all tasks? This will delete all task history and cannot be undone.',
() => {
this.performClearAll();
resolve(true);
},
'Yes, Clear All',
'Cancel',
'clear',
false
);
} else {
// Fallback to native confirm
const confirmed = confirm('Are you sure you want to clear all tasks? This will delete all task history.');
if (confirmed) {
this.performClearAll();
}
resolve(confirmed);
}
});
}
async performClearAll() {
// 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 all tasks...',
'info',
null,
null,
null,
customIcon
);
try {
// Delete all tasks using the task manager
const deletePromises = this.tasks.map(task =>
this.taskManager.deleteTask(task.id)
);
await Promise.all(deletePromises);
// Clear local array and re-render
this.tasks = [];
this.renderTasks();
this.updateStats();
this.updateSidebarCounts();
this.generateAppCategories();
// Remove progress notification and show success
if (progressNotification && progressNotification.remove) {
progressNotification.remove();
}
if (window.notificationSystem) {
window.notificationSystem.show(
'All tasks cleared successfully',
'success',
null,
null,
null,
customIcon
);
}
} catch (error) {
console.error('Error clearing tasks:', error);
// Remove progress notification and show error
if (progressNotification && progressNotification.remove) {
progressNotification.remove();
}
if (window.notificationSystem) {
window.notificationSystem.show(
`Failed to clear tasks: ${error.message}`,
'error',
null,
null,
null,
customIcon
);
}
}
}
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`;
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
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>
`;
}
}
destroy() {
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
}
}
}
// Export for use in other modules
window.TasksManager = TasksManager;