Convert the remaining sections off the legacy ?= query form to clean paths, matching the Admin area: /apps/<category> (was /apps?=<category>) /app/<name>?tab=&task= (was /app?=<name>&tab=&task=) /tasks/<category>?task= (was /tasks?=<category>&task=) /backup/<tab> (was /backup?=<tab>) Builders updated everywhere (sidebar, dashboard, notifications, tasks, apps, app tabs, task-actions, setup watcher); parsers now read the resource from the path with the legacy ?= kept as a fallback so old links/bookmarks still work (server already serves index.html at any depth). Route table gains /apps* and orders it before /app* (since '/apps' startsWith '/app'); active-nav and config/apps data-loading recognise the new paths. Tab/task remain ordinary query params (modifiers, not the primary resource). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Signed-off-by: librelad <librelad@digitalangels.vip>
2463 lines
95 KiB
JavaScript
Executable File
2463 lines
95 KiB
JavaScript
Executable File
/**
|
||
* 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';
|
||
|
||
const displayName = (slug) => window.getAppDisplayName ? window.getAppDisplayName(slug) : (slug.charAt(0).toUpperCase() + slug.slice(1));
|
||
|
||
if (/^libreportal setup config\b/.test(task.command)) return 'LibrePortal - Apply Configuration';
|
||
if (/^libreportal setup finalize\b/.test(task.command)) return 'LibrePortal - Finalize Setup';
|
||
if (/^libreportal setup apply\b/.test(task.command)) return 'LibrePortal - Setup Wizard';
|
||
|
||
// Self-update actions (WebUI "Update now" / "Check for updates").
|
||
if (/^libreportal update (apply|now)\b/.test(task.command)) return 'LibrePortal - Update';
|
||
if (/^libreportal update check\b/.test(task.command)) return 'LibrePortal - Check for Updates';
|
||
|
||
// Backup engine — per-app actions.
|
||
const backupDeleteAllMatch = task.command.match(/libreportal backup app delete_all (\w+)/);
|
||
if (backupDeleteAllMatch) return `${displayName(backupDeleteAllMatch[1])} - Delete All Backups`;
|
||
|
||
const backupDeleteMatch = task.command.match(/libreportal backup app delete (\w+) (.+)/);
|
||
if (backupDeleteMatch) return `${displayName(backupDeleteMatch[1])} - Delete Backup`;
|
||
|
||
const backupCreateMatch = task.command.match(/^libreportal backup app create (\w+)/);
|
||
if (backupCreateMatch) return `${displayName(backupCreateMatch[1])} - Create Backup`;
|
||
|
||
const backupScheduleMatch = task.command.match(/^libreportal backup app schedule (\w+)/);
|
||
if (backupScheduleMatch) return `${displayName(backupScheduleMatch[1])} - Scheduled Backup`;
|
||
|
||
const backupListMatch = task.command.match(/^libreportal backup app list (\w+)/);
|
||
if (backupListMatch) return `${displayName(backupListMatch[1])} - List Backups`;
|
||
|
||
// Backup engine — restore actions.
|
||
const restoreStartMatch = task.command.match(/^libreportal restore app start (\w+)/);
|
||
if (restoreStartMatch) return `${displayName(restoreStartMatch[1])} - Restore Backup`;
|
||
|
||
const restoreListMatch = task.command.match(/^libreportal restore app list (\w+)/);
|
||
if (restoreListMatch) return `${displayName(restoreListMatch[1])} - List Backups`;
|
||
|
||
const migrateAppMatch = task.command.match(/^libreportal restore migrate app (\w+)/);
|
||
if (migrateAppMatch) return `${displayName(migrateAppMatch[1])} - Migrate from Host`;
|
||
|
||
// Backup engine — system-level actions (no per-app target).
|
||
if (/^libreportal backup all\b/.test(task.command)) return 'LibrePortal - Backup All Apps';
|
||
if (/^libreportal backup verify\b/.test(task.command)) return 'LibrePortal - Verify Backups';
|
||
if (/^libreportal backup location add\b/.test(task.command)) return 'LibrePortal - Add Backup Location';
|
||
if (/^libreportal backup location remove\b/.test(task.command)) return 'LibrePortal - Remove Backup Location';
|
||
if (/^libreportal backup location init\b/.test(task.command)) return 'LibrePortal - Initialise Backup Locations';
|
||
if (/^libreportal backup location check\b/.test(task.command)) return 'LibrePortal - Check Backup Locations';
|
||
if (/^libreportal backup location list\b/.test(task.command)) return 'LibrePortal - List Backup Locations';
|
||
if (/^libreportal backup location stats\b/.test(task.command)) return 'LibrePortal - Backup Location Stats';
|
||
if (/^libreportal restore migrate system\b/.test(task.command)) return 'LibrePortal - Migrate System';
|
||
if (/^libreportal restore migrate discover\b/.test(task.command)) return 'LibrePortal - Discover Backups';
|
||
if (/^libreportal restore first-run\b/.test(task.command)) return 'LibrePortal - First-Run Restore';
|
||
|
||
// `libreportal app tool <app> <tool_id> ['<args>']` — show the
|
||
// tool's friendly label instead of "Tool Application". Pull the
|
||
// label from window.toolsCatalog if loaded; otherwise titlecase
|
||
// the snake_case tool id.
|
||
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}`;
|
||
}
|
||
|
||
// Parse libreportal commands. Capture only the app name token — anything after
|
||
// (e.g. config overrides like `CFG_FOO=bar|...`) is for the CLI, not the title.
|
||
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}`;
|
||
}
|
||
|
||
// Handle other common patterns
|
||
if (task.command.includes('docker-compose')) {
|
||
return 'Docker Compose Operation';
|
||
}
|
||
|
||
if (task.command.includes('docker')) {
|
||
return 'Docker Operation';
|
||
}
|
||
|
||
// Return first 50 chars of command as fallback
|
||
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>`;
|
||
if (task.app) {
|
||
const appIconPath = this.getAppIconPath(task);
|
||
return `${typeIcon}<img src="${appIconPath}" alt="${task.app}" class="task-app-icon" onerror="this.style.display='none'">`;
|
||
}
|
||
if (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();
|
||
}
|
||
};
|
||
|
||
window.createAndExecuteTask = async (action, appName, config = '') => {
|
||
try {
|
||
// Create task using NEW signature
|
||
const task = await this.taskManager.createTask(`libreportal app ${action} ${appName}`, action, appName, config);
|
||
|
||
// Emit task creation event for AppTabbedManager
|
||
window.dispatchEvent(new CustomEvent('taskCreated', {
|
||
detail: {
|
||
taskId: task.id,
|
||
appName: appName,
|
||
action: action,
|
||
timestamp: Date.now()
|
||
}
|
||
}));
|
||
|
||
// Set up monitoring for this specific task
|
||
this.monitorTask(task.id, appName, action);
|
||
|
||
// Show success notification with app icon and direct link
|
||
if (window.notificationSystem) {
|
||
const taskUrl = `/tasks/all?task=${task.id}`;
|
||
const typeIcon = this.getTaskTypeIcon ? this.getTaskTypeIcon({ type: action })?.icon : '';
|
||
const customIcon = typeIcon ? `<span style="font-size:18px;line-height:1;">${typeIcon}</span>` : null;
|
||
window.notificationSystem.show(
|
||
`Task created: ${action} ${appName}`,
|
||
'info',
|
||
appName,
|
||
taskUrl,
|
||
`icons/apps/${appName}.svg`,
|
||
customIcon
|
||
);
|
||
}
|
||
|
||
return task;
|
||
} catch (error) {
|
||
console.error(`❌ Failed to create ${action} task for ${appName}:`, error);
|
||
if (window.notificationSystem) {
|
||
window.notificationSystem.error(`Failed to start ${action} task for ${appName}: ${error.message}`);
|
||
}
|
||
throw error;
|
||
}
|
||
};
|
||
}
|
||
|
||
// 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;
|
||
}
|
||
const isSystemTask = action.startsWith('setup-');
|
||
const displayName = isSystemTask
|
||
? 'LibrePortal'
|
||
: ((appName && window.getAppDisplayName)
|
||
? window.getAppDisplayName(appName)
|
||
: (appName || (task.command || `Task ${taskId}`)));
|
||
const subjectLabel = isSystemTask ? 'System' : 'App';
|
||
const onAppPage = window.location.pathname.startsWith('/app') && !window.location.pathname.startsWith('/apps');
|
||
const url = (onAppPage && appName)
|
||
? `/app/${appName}?tab=tasks&task=${taskId}`
|
||
: `/tasks/all?task=${taskId}`;
|
||
const icon = 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>${subjectLabel}: ${displayName}</strong><br>${actionTitle} task completed!`;
|
||
level = 'success';
|
||
} else if (task.status === 'failed') {
|
||
body = `<strong>${subjectLabel}: ${displayName}</strong><br>${actionTitle} task failed.`;
|
||
level = 'error';
|
||
} else if (task.status === 'cancelled') {
|
||
body = `<strong>${subjectLabel}: ${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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
|
||
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;
|