The Delete Task confirmation modal was rendering the raw command
("libreportal app install ipinfo") as its title with no app icon, while
the rest of the WebUI (task notifications, task rows) shows
"Install Ipinfo" and the ipinfo logo. Inconsistent and slightly
intimidating for a confirmation step.
Now mirrors the completion-notification flow:
- Title: `${formatActionTitle(task.type)} ${getAppDisplayName(task.app)}`
→ "Install Ipinfo", "Backup Nextcloud", etc.
- Icon: /icons/apps/<slug>.svg (or libreportal.svg for system tasks)
- Tool tasks borrow the same tool-catalog-lookup the completion toast
uses so a tool deletion reads as "Manage Shortcuts" rather than the
raw tool id.
Reuses the existing TasksManager.formatActionTitle() helper so any
future task type added to that map flows through here automatically.
Signed-off-by: librelad <librelad@digitalangels.vip>
2474 lines
97 KiB
JavaScript
Executable File
2474 lines
97 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');
|
||
}
|
||
|
||
// User-facing label for a task action. Explicit map first (so
|
||
// 'config_update' reads as 'Update Config', not 'Config Update');
|
||
// anything not listed falls back to snake/kebab → Title Case so a
|
||
// future task type still renders cleanly instead of leaking the
|
||
// literal id like "Config_update".
|
||
formatActionTitle(action) {
|
||
if (!action) return 'Task';
|
||
const map = {
|
||
'install': 'Install', 'app-install': 'Install',
|
||
'uninstall': 'Uninstall', 'restart': 'Restart',
|
||
'start': 'Start', 'stop': 'Stop',
|
||
'update': 'Update', 'rebuild': 'Rebuild',
|
||
'backup': 'Backup', 'restore': 'Restore',
|
||
'delete': 'Delete Backup', 'delete_all': 'Delete All Backups',
|
||
'config_update': 'Update Config', 'update_config': 'Update Config',
|
||
'system_update': 'Update System',
|
||
'setup-config': 'Apply Configuration',
|
||
'setup-finalize': 'Finalize Setup'
|
||
};
|
||
if (map[action]) return map[action];
|
||
return action.split(/[_-]/).map(w => w ? w.charAt(0).toUpperCase() + w.slice(1) : '').join(' ');
|
||
}
|
||
|
||
// Initialize task system after scripts are loaded
|
||
initializeTaskSystem() {
|
||
try {
|
||
//// // console.log('🔧 Initializing task system components...');
|
||
|
||
// Check if TaskManager is available
|
||
if (typeof TaskManager === 'undefined') {
|
||
console.warn('⚠️ TaskManager not available yet, deferring initialization');
|
||
return false;
|
||
}
|
||
|
||
this.commands = new TaskCommands();
|
||
this.actions = new TaskActions(this, this.commands);
|
||
this.router = new TaskRouter(this, this.actions);
|
||
this.taskManager = new TaskManager(); // Add TaskManager for task operations
|
||
|
||
// Now load tasks since system is ready
|
||
this.loadTasks();
|
||
|
||
//// // console.log('✅ Task system initialized successfully');
|
||
return true;
|
||
} catch (error) {
|
||
console.error('❌ Failed to initialize task system:', error);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// Main initialization method for the tasks page
|
||
async init() {
|
||
//// // console.log('🔧 Initializing TasksManager...');
|
||
|
||
// Initialize task system if not already done
|
||
if (!this.taskManager) {
|
||
let initialized = false;
|
||
let attempts = 0;
|
||
const maxAttempts = 5;
|
||
|
||
while (!initialized && attempts < maxAttempts) {
|
||
//// // console.log(`🔄 Attempting to initialize task system (${attempts + 1}/${maxAttempts})...`);
|
||
initialized = this.initializeTaskSystem();
|
||
|
||
if (!initialized) {
|
||
attempts++;
|
||
await new Promise(resolve => setTimeout(resolve, 200)); // Wait 200ms
|
||
}
|
||
}
|
||
|
||
if (!initialized) {
|
||
console.warn('⚠️ Task system initialization failed after retries');
|
||
}
|
||
}
|
||
|
||
// Setup refresh interval
|
||
this.setupRefreshInterval();
|
||
|
||
// Reconstructor() {
|
||
this.tasks = [];
|
||
this.taskManager = new TaskManager();
|
||
this.activeLogStreams = new Map(); // Track active log streams
|
||
// console.log('🔍 TasksManager initialized');
|
||
}
|
||
|
||
initializeFromURL() {
|
||
const currentUrl = new URL(window.location.href);
|
||
const searchParams = currentUrl.searchParams;
|
||
|
||
// Check if we're on the main tasks page (not app page)
|
||
const isMainTasksPage = currentUrl.pathname === '/tasks' || currentUrl.pathname.startsWith('/tasks/') || currentUrl.pathname === '/tasks.html';
|
||
|
||
if (isMainTasksPage) {
|
||
// Category from the path (/tasks/<category>), else legacy ?=<category>.
|
||
const seg = currentUrl.pathname.replace(/^\/tasks\/?/, '').split('/')[0];
|
||
this.currentCategory = seg || searchParams.get('') || 'all';
|
||
|
||
// Only check for specific task parameter if we're not coming from an app page
|
||
const taskParam = searchParams.get('task');
|
||
if (taskParam) {
|
||
// console.log(`🎯 Found task parameter in URL: ${taskParam} on main tasks page`);
|
||
this.highlightedTaskId = taskParam;
|
||
} else {
|
||
// Clear any existing highlighted task when on main tasks page without task param
|
||
this.highlightedTaskId = null;
|
||
// console.log(`🎯 Clearing highlighted task on main tasks page`);
|
||
}
|
||
} else {
|
||
// Not on main tasks page, get default filter from localStorage
|
||
this.currentCategory = localStorage.getItem('tasksDefaultFilter') || 'all';
|
||
this.highlightedTaskId = null; // Always clear when not on tasks page
|
||
}
|
||
|
||
// console.log(`🎯 Tasks category from URL: ${this.currentCategory}`);
|
||
}
|
||
|
||
updateURL(category, taskId = null) {
|
||
// Update URL without page reload and without hash
|
||
let newURL = `/tasks/${category || 'all'}`;
|
||
if (taskId) {
|
||
newURL += `?task=${taskId}`;
|
||
}
|
||
|
||
// Prevent the SPA from interfering
|
||
if (window.librePortalSPA) {
|
||
window.librePortalSPA.currentRoute = newURL;
|
||
}
|
||
|
||
// Use a timeout to avoid conflicts with SPA routing
|
||
setTimeout(() => {
|
||
window.history.pushState({ category, taskId }, '', newURL);
|
||
}, 0);
|
||
}
|
||
|
||
async init() {
|
||
//// // console.log('🔧 Initializing TasksManager...');
|
||
|
||
// Load initial tasks and refresh sidebar counts
|
||
await this.loadTasks();
|
||
|
||
// Force a refresh to ensure latest data
|
||
// console.log('🔄 Refreshing tasks data on initialization...');
|
||
await this.loadTasks();
|
||
|
||
// Setup auto-refresh
|
||
this.setupAutoRefresh();
|
||
|
||
// Setup global functions
|
||
this.setupGlobalFunctions();
|
||
|
||
// Subscribe to the SSE bus once for the page so every visible task row
|
||
// reacts to status changes, not just ones spawned in this session.
|
||
this.setupTaskBusListeners();
|
||
|
||
// Setup mobile menu
|
||
this.setupMobileMenu();
|
||
|
||
//// // console.log('✅ TasksManager initialized');
|
||
}
|
||
|
||
async refreshTasks() {
|
||
// Show refresh notification
|
||
const refreshNotification = window.notificationSystem.info(
|
||
'🔄 Refreshing tasks...',
|
||
'Tasks',
|
||
null,
|
||
null
|
||
);
|
||
|
||
try {
|
||
await this.loadTasks();
|
||
|
||
// Remove refresh notification and show success
|
||
if (refreshNotification && refreshNotification.remove) {
|
||
refreshNotification.remove();
|
||
}
|
||
|
||
if (window.notificationSystem) {
|
||
window.notificationSystem.success(
|
||
'🔄 Tasks refreshed successfully',
|
||
'Tasks',
|
||
null,
|
||
null
|
||
);
|
||
}
|
||
} catch (error) {
|
||
console.error('Error refreshing tasks:', error);
|
||
|
||
// Remove refresh notification and show error
|
||
if (refreshNotification && refreshNotification.remove) {
|
||
refreshNotification.remove();
|
||
}
|
||
|
||
if (window.notificationSystem) {
|
||
window.notificationSystem.error(
|
||
`⚠️ Failed to refresh tasks: ${error.message}`,
|
||
'Tasks',
|
||
null,
|
||
null
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
async loadTasks() {
|
||
try {
|
||
//// // console.log('🔄 Loading tasks from file system...');
|
||
|
||
// Check if task system is available
|
||
if (!this.taskManager) {
|
||
console.warn('⚠️ Task system not yet initialized, skipping task loading');
|
||
this.tasks = [];
|
||
return;
|
||
}
|
||
|
||
// Get tasks using new system
|
||
// console.log('📥 Getting tasks using new queue system...');
|
||
|
||
// Get queue and current status
|
||
let queue = [];
|
||
let current = {};
|
||
|
||
try {
|
||
const queueResponse = await fetch('/read-file?path=tasks/queue.json');
|
||
if (queueResponse.ok) {
|
||
const queueText = await queueResponse.text();
|
||
if (queueText.trim()) { // Only parse if not empty
|
||
try {
|
||
queue = JSON.parse(queueText);
|
||
} catch (parseError) {
|
||
console.warn('⚠️ Invalid queue.json format, starting with empty queue');
|
||
queue = [];
|
||
}
|
||
}
|
||
}
|
||
} catch (error) {
|
||
// console.log('📝 Queue file not found, starting with empty queue');
|
||
}
|
||
|
||
try {
|
||
const currentResponse = await fetch('/read-file?path=tasks/current.json');
|
||
if (currentResponse.ok) {
|
||
const currentText = await currentResponse.text();
|
||
if (currentText.trim()) { // Only parse if not empty
|
||
try {
|
||
current = JSON.parse(currentText);
|
||
} catch (parseError) {
|
||
console.warn('⚠️ Invalid current.json format, treating as empty');
|
||
current = {};
|
||
}
|
||
}
|
||
}
|
||
} catch (error) {
|
||
// console.log('📝 Current file not found, no current task');
|
||
}
|
||
|
||
// Load individual task files
|
||
const allTasks = [];
|
||
|
||
// Add queued tasks
|
||
for (const taskId of queue) {
|
||
try {
|
||
const task = await this.taskManager.getTask(taskId);
|
||
if (task) allTasks.push(task);
|
||
} catch (error) {
|
||
console.warn(`⚠️ Failed to load queued task ${taskId}:`, error);
|
||
}
|
||
}
|
||
|
||
// Add current task if different from queue
|
||
if (current.id && !queue.includes(current.id)) {
|
||
try {
|
||
const task = await this.taskManager.getTask(current.id);
|
||
if (task) allTasks.push(task);
|
||
} catch (error) {
|
||
console.warn(`⚠️ Failed to load current task ${current.id}:`, error);
|
||
}
|
||
}
|
||
|
||
// Scan tasks folder for all task files (including completed ones) - OPTIMIZED
|
||
try {
|
||
// console.log('🔍 Scanning tasks folder for all task files...');
|
||
const tasksResponse = await fetch('/read-directory?path=tasks');
|
||
if (tasksResponse.ok) {
|
||
const files = await tasksResponse.json();
|
||
const taskFiles = files.filter(file =>
|
||
file.endsWith('.json') &&
|
||
file !== 'queue.json' &&
|
||
file !== 'current.json'
|
||
);
|
||
|
||
// console.log(`📁 Found ${taskFiles.length} task files in folder`);
|
||
|
||
// OPTIMIZATION: Batch load tasks instead of individual calls
|
||
const missingTaskIds = taskFiles
|
||
.map(file => file.replace('.json', ''))
|
||
.filter(taskId => !allTasks.find(task => task.id === taskId));
|
||
|
||
if (missingTaskIds.length > 0) {
|
||
// console.log(`📦 Batch loading ${missingTaskIds.length} missing tasks...`);
|
||
try {
|
||
const batchResponse = await fetch('/read-tasks-batch', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ taskIds: missingTaskIds })
|
||
});
|
||
|
||
if (batchResponse.ok) {
|
||
const batchTasks = await batchResponse.json();
|
||
batchTasks.forEach(task => {
|
||
if (task) {
|
||
allTasks.push(task);
|
||
// console.log(`✅ Added completed task ${task.id} from batch load`);
|
||
}
|
||
});
|
||
} else {
|
||
// Fallback to individual loading if batch endpoint not available
|
||
// console.log('⚠️ Batch endpoint not available, falling back to individual loading');
|
||
await this.loadTasksIndividually(missingTaskIds, allTasks);
|
||
}
|
||
} catch (error) {
|
||
console.warn('⚠️ Batch loading failed, falling back to individual loading:', error);
|
||
await this.loadTasksIndividually(missingTaskIds, allTasks);
|
||
}
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.warn('⚠️ Failed to scan tasks folder:', error);
|
||
}
|
||
|
||
//// // console.log('📊 Task counts:', {
|
||
//queued: queuedTasks.length,
|
||
//processing: processingTasks.length,
|
||
//completed: completedTasks.length
|
||
//});
|
||
|
||
// Combine all tasks
|
||
this.tasks = allTasks;
|
||
|
||
// Sort by creation time (newest first)
|
||
this.tasks.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
||
|
||
// console.log(`✅ Loaded ${this.tasks.length} tasks`);
|
||
//// // console.log('📋 All tasks:', this.tasks);
|
||
|
||
this.renderTasks();
|
||
this.updateStats();
|
||
this.updateSidebarCounts();
|
||
this.generateAppCategories();
|
||
} catch (error) {
|
||
console.error('❌ Failed to load tasks:', error);
|
||
if (window.notificationSystem) {
|
||
window.notificationSystem.error(`Failed to load tasks: ${error.message}`);
|
||
}
|
||
this.tasks = [];
|
||
this.renderTasks();
|
||
this.updateStats();
|
||
this.updateSidebarCounts();
|
||
this.generateAppCategories();
|
||
}
|
||
}
|
||
|
||
async loadTasksIndividually(taskIds, allTasks) {
|
||
// Fallback method for individual task loading
|
||
for (const taskId of taskIds) {
|
||
try {
|
||
const task = await this.taskManager.getTask(taskId);
|
||
if (task) {
|
||
allTasks.push(task);
|
||
// console.log(`✅ Added completed task ${taskId} from individual load`);
|
||
}
|
||
} catch (error) {
|
||
console.warn(`⚠️ Failed to load task ${taskId}:`, error);
|
||
}
|
||
}
|
||
}
|
||
|
||
renderTasks() {
|
||
const container = document.getElementById('tasks-list');
|
||
if (!container) return;
|
||
|
||
// Capture which task panels are currently open and where the user
|
||
// is scrolled to *before* the innerHTML rebuild. Without this,
|
||
// every refresh slams every expanded log shut and snaps to the top
|
||
// — which makes watching a live task feel hostile.
|
||
const expandedIds = new Set();
|
||
container.querySelectorAll('.task-details.task-details-open').forEach(el => {
|
||
const taskId = (el.id || '').replace(/^details-/, '');
|
||
if (taskId) expandedIds.add(taskId);
|
||
});
|
||
const scrollParent = container.closest('.main') || document.scrollingElement || document.documentElement;
|
||
const savedScrollTop = scrollParent ? scrollParent.scrollTop : window.scrollY;
|
||
|
||
// Filter tasks based on current category and specific task
|
||
let filteredTasks = this.filterTasksByCategory(this.tasks, this.currentCategory);
|
||
|
||
// Note: Don't filter out other tasks when one is highlighted
|
||
// highlightedTaskId is only for auto-expansion, not for filtering
|
||
|
||
if (filteredTasks.length === 0) {
|
||
let message;
|
||
if (this.highlightedTaskId) {
|
||
message = `Task ${this.highlightedTaskId} not found`;
|
||
} else {
|
||
const categoryName = this.getCategoryDisplayName(this.currentCategory);
|
||
message = `No ${categoryName.toLowerCase()} tasks found`;
|
||
}
|
||
|
||
container.innerHTML = `
|
||
<div class="task-item">
|
||
<div class="task-header">
|
||
<div class="task-info">
|
||
<span class="task-status status-completed">info</span>
|
||
<span class="task-command">$ ${message}</span>
|
||
<span class="task-time">just now</span>
|
||
</div>
|
||
<div class="task-actions">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="task-details task-details-open">
|
||
<div class="task-output">Run a task or install an application to see your task list here</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
// Sort tasks by creation time (newest first)
|
||
const sortedTasks = filteredTasks.sort((a, b) =>
|
||
new Date(b.createdAt) - new Date(a.createdAt)
|
||
);
|
||
|
||
const html = sortedTasks.map(task => this.renderTask(task)).join('');
|
||
container.innerHTML = html;
|
||
|
||
// Re-open everything that was open before the rebuild and re-attach
|
||
// log streaming so live tasks keep updating without an extra click.
|
||
expandedIds.forEach(taskId => {
|
||
const details = document.getElementById(`details-${taskId}`);
|
||
if (!details) return;
|
||
details.style.display = 'block';
|
||
details.classList.add('task-details-open');
|
||
const btn = document.querySelector(`.task-btn.toggle-details[onclick*="toggleTaskDetails('${taskId}')"]`);
|
||
if (btn) btn.classList.add('expanded');
|
||
|
||
const t = this.tasks.find(x => x.id === taskId);
|
||
if (t && (t.status === 'running' || t.status === 'queued' || t.status === 'pending')) {
|
||
if (typeof this.startLogStreaming === 'function') this.startLogStreaming(taskId, t);
|
||
} else {
|
||
if (typeof this.loadTaskLogs === 'function') this.loadTaskLogs(taskId);
|
||
}
|
||
});
|
||
|
||
// Restore scroll. Defer one frame so the browser has laid out the
|
||
// new content height before we scroll back into it.
|
||
if (scrollParent) {
|
||
requestAnimationFrame(() => { scrollParent.scrollTop = savedScrollTop; });
|
||
}
|
||
}
|
||
|
||
filterTasksByCategory(tasks, category) {
|
||
switch (category) {
|
||
case 'all':
|
||
return tasks;
|
||
|
||
case 'queued':
|
||
case 'running':
|
||
case 'completed':
|
||
case 'failed':
|
||
return tasks.filter(task => task.status === category);
|
||
|
||
case 'install':
|
||
case 'uninstall':
|
||
return tasks.filter(task => task.type === category);
|
||
|
||
case 'management':
|
||
return tasks.filter(task =>
|
||
['restart', 'start', 'stop'].includes(task.type)
|
||
);
|
||
|
||
case 'backup':
|
||
return tasks.filter(task =>
|
||
['backup', 'restore', 'delete'].includes(task.type)
|
||
);
|
||
|
||
case 'config':
|
||
return tasks.filter(task => task.type === 'update_config');
|
||
|
||
default:
|
||
// Assume it's an app name
|
||
return tasks.filter(task => task.app === category);
|
||
}
|
||
}
|
||
|
||
getCategoryDisplayName(category) {
|
||
const displayNames = {
|
||
'all': 'All Tasks',
|
||
'queued': 'Queued',
|
||
'running': 'Running',
|
||
'completed': 'Completed',
|
||
'failed': 'Failed',
|
||
'install': 'Install',
|
||
'uninstall': 'Uninstall',
|
||
'management': 'Management',
|
||
'backup': 'Backups',
|
||
'config': 'Configuration',
|
||
'libreportal': 'LibrePortal'
|
||
};
|
||
|
||
return displayNames[category] || category.charAt(0).toUpperCase() + category.slice(1);
|
||
}
|
||
|
||
renderTask(task) {
|
||
// console.log(`🔍 renderTask called with task:`, task);
|
||
|
||
// Debug undefined status
|
||
if (!task.status) {
|
||
console.warn(`⚠️ Task ${task.id} has undefined status:`, task);
|
||
}
|
||
|
||
const statusClass = `status-${task.status || 'unknown'}`;
|
||
const timeAgo = this.getTimeAgo(task.createdAt);
|
||
const isRunning = task.status === 'running';
|
||
const isFailed = task.status === 'failed';
|
||
const hasOutput = task.output && task.output.length > 0;
|
||
const hasError = task.error && task.error.length > 0;
|
||
const hasLogs = task.log && Array.isArray(task.log) && task.log.length > 0;
|
||
|
||
// console.log(`🔍 Task fields check:`, {
|
||
//hasOutput: hasOutput,
|
||
//hasError: hasError,
|
||
//hasLogs: hasLogs,
|
||
//isRunning: isRunning,
|
||
//outputLength: task.output ? task.output.length : 0,
|
||
//error: task.error,
|
||
//logCount: task.log ? task.log.length : 0
|
||
//});
|
||
|
||
const executionTime = task.startedAt && task.completedAt ?
|
||
this.calculateExecutionTime(task.startedAt, task.completedAt) : null;
|
||
|
||
// console.log('🔍 renderTask debug:', {
|
||
//taskStatus: task.status,
|
||
//statusClass: statusClass,
|
||
//statusDisplay: task.status ? task.status.toUpperCase() : 'UNKNOWN'
|
||
//});
|
||
|
||
return `
|
||
<div class="task-item" data-task-id="${task.id}">
|
||
<div class="task-header" onclick="toggleTaskDetails('${task.id}')">
|
||
<div class="task-info">
|
||
${this.renderTaskIcons(task)}
|
||
<span class="task-title">${this.formatCommandForUser(task)}</span>
|
||
<span class="task-status ${statusClass}">${this.getStatusIcon(task.status)} ${task.status ? task.status.toUpperCase() : 'UNKNOWN'}</span>
|
||
<span class="task-time">${timeAgo}</span>
|
||
${executionTime ? `<span class="task-duration">⏱️ ${executionTime}</span>` : ''}
|
||
</div>
|
||
<div class="task-actions">
|
||
${isFailed ? `
|
||
<button class="task-btn retry" onclick="event.stopPropagation(); retryTask('${task.id}')" title="Retry Task">
|
||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/>
|
||
</svg>
|
||
Retry
|
||
</button>
|
||
` : ''}
|
||
<button class="task-btn toggle-details" onclick="event.stopPropagation(); toggleTaskDetails('${task.id}')" title="Toggle Task Details">
|
||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<polyline points="6,9 12,15 18,9"></polyline>
|
||
</svg>
|
||
<span class="task-btn-label">Logs</span>
|
||
</button>
|
||
<button class="task-btn delete" onclick="event.stopPropagation(); deleteTask('${task.id}')" title="Delete Task">
|
||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||
</svg>
|
||
<span class="task-btn-label">Delete</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Simplified details section (metadata and logs only) -->
|
||
<div class="task-details" id="details-${task.id}">
|
||
<!-- Task metadata -->
|
||
<div class="task-meta">
|
||
<div class="meta-item">
|
||
<strong>Task ID:</strong> <a href="/tasks/all?task=${task.id}" class="task-id-link" data-task-id="${task.id}">${task.id}</a>
|
||
</div>
|
||
<div class="meta-item">
|
||
<strong>Type:</strong> ${task.type || 'unknown'}
|
||
</div>
|
||
<div class="meta-item">
|
||
<strong>App:</strong> ${task.app ? `<a href="/app/${task.app}" class="task-app-link" data-app-name="${task.app}">${task.app}</a>` : 'system'}
|
||
</div>
|
||
<div class="meta-item">
|
||
<strong>Created:</strong> ${new Date(task.createdAt).toLocaleString()}
|
||
</div>
|
||
${task.startedAt ? `
|
||
<div class="meta-item">
|
||
<strong>Started:</strong> ${new Date(task.startedAt).toLocaleString()}
|
||
</div>
|
||
` : ''}
|
||
${task.completedAt ? `
|
||
<div class="meta-item">
|
||
<strong>Completed:</strong> ${new Date(task.completedAt).toLocaleString()}
|
||
</div>
|
||
` : ''}
|
||
${executionTime ? `
|
||
<div class="meta-item">
|
||
<strong>Execution Time:</strong> ${executionTime}
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
|
||
<!-- Logs section only -->
|
||
<div class="task-logs">
|
||
<div class="log-container terminal-style" id="logs-${task.id}" style="height: 200px; overflow-y: auto; position: relative;">
|
||
${hasLogs ?
|
||
task.log.map(log => `<div class="log-entry">${this.taskManager.parseAnsiColors(log)}</div>`).join('') :
|
||
'<div style="position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(10, 18, 36, 0.85); display: flex; align-items: center; justify-content: center; z-index: 10;"><div class="loading-spinner" style="width: 16px; height: 16px; border: 2px solid rgba(255,255,255,0.18); border-top: 2px solid #fff; border-radius: 50%; animation: spin 1s linear infinite; margin-right: 8px;"></div>Loading logs...</div></div>'
|
||
}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
getStatusIcon(status) {
|
||
const icons = {
|
||
queued: '⏳',
|
||
running: '🔄',
|
||
completed: '✅',
|
||
failed: '❌'
|
||
};
|
||
return icons[status] || '📋';
|
||
}
|
||
|
||
formatDuration(seconds) {
|
||
if (seconds < 60) {
|
||
return `${seconds}s`;
|
||
} else if (seconds < 3600) {
|
||
return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
||
} else {
|
||
const hours = Math.floor(seconds / 3600);
|
||
const minutes = Math.floor((seconds % 3600) / 60);
|
||
return `${hours}h ${minutes}m`;
|
||
}
|
||
}
|
||
|
||
calculateExecutionTime(startedAt, completedAt) {
|
||
if (!startedAt || !completedAt) return null;
|
||
|
||
const start = new Date(startedAt);
|
||
const end = new Date(completedAt);
|
||
const durationMs = end - start;
|
||
const durationSeconds = Math.floor(durationMs / 1000);
|
||
|
||
return this.formatDuration(durationSeconds);
|
||
}
|
||
|
||
extractTimestamp(logEntry) {
|
||
const match = logEntry.match(/\[([^\]]+)\]/);
|
||
return match ? match[1] : '';
|
||
}
|
||
|
||
extractLogMessage(logEntry) {
|
||
const match = logEntry.match(/\] (.+)$/);
|
||
return match ? match[1] : logEntry;
|
||
}
|
||
|
||
async viewTaskLogs(taskId) {
|
||
// Open logs in a modal or new view
|
||
const task = this.tasks.find(t => t.id === taskId);
|
||
if (!task) return;
|
||
|
||
// Load full logs for modal
|
||
const fullLogs = await this.taskManager.readFullTaskLog(taskId);
|
||
|
||
// Create modal with streaming logs
|
||
const modal = document.createElement('div');
|
||
modal.className = 'task-logs-modal';
|
||
modal.innerHTML = `
|
||
<div class="modal-overlay" onclick="closeTaskLogsModal()"></div>
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h3>📋 Task Logs - ${task.id}</h3>
|
||
<div class="log-status">
|
||
<span class="status-indicator" id="log-status-${taskId}">Loading...</span>
|
||
<button class="log-toggle" id="log-toggle-${taskId}" onclick="toggleLogStreaming('${taskId}')">⏸️ Pause</button>
|
||
</div>
|
||
<button class="modal-close" onclick="closeTaskLogsModal()">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="task-info-summary">
|
||
<div class="info-row">
|
||
<strong>Command:</strong> <code>${task.command}</code>
|
||
</div>
|
||
<div class="info-row">
|
||
<strong>Status:</strong> <span class="status-${task.status || 'unknown'}">${this.getStatusIcon(task.status)} ${task.status ? task.status.toUpperCase() : 'UNKNOWN'}</span>
|
||
</div>
|
||
<div class="info-row">
|
||
<strong>Created:</strong> ${new Date(task.createdAt).toLocaleString()}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="logs-section">
|
||
<h4>Execution Logs <span class="log-line-count">(0 lines)</span></h4>
|
||
<div class="log-viewer terminal-style" id="log-viewer-${taskId}">
|
||
${fullLogs.split('\n').map(log => `<div class="log-line">${this.taskManager.parseAnsiColors(log)}</div>`).join('')}
|
||
</div>
|
||
</div>
|
||
|
||
${task.output ? `
|
||
<div class="output-section">
|
||
<h4>Command Output</h4>
|
||
<pre class="output-viewer">${this.taskManager.parseAnsiColors(task.output)}</pre>
|
||
</div>
|
||
` : ''}
|
||
|
||
${task.error ? `
|
||
<div class="error-section">
|
||
<h4>Error Details</h4>
|
||
<pre class="error-viewer">${this.escapeHtml(task.error)}</pre>
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
document.body.appendChild(modal);
|
||
|
||
// Update line count
|
||
const lineCount = fullLogs.split('\n').length;
|
||
const lineCountElement = modal.querySelector('.log-line-count');
|
||
if (lineCountElement) {
|
||
lineCountElement.textContent = `(${lineCount} lines)`;
|
||
}
|
||
|
||
// Start streaming if task is running
|
||
if (task.status === 'running' || task.status === 'queued') {
|
||
this.startLogStreaming(taskId, modal);
|
||
}
|
||
|
||
// Store streaming controller
|
||
this.activeLogStreams = this.activeLogStreams || new Map();
|
||
}
|
||
|
||
startLogStreaming(taskId, modal) {
|
||
const statusIndicator = document.getElementById(`log-status-${taskId}`);
|
||
const toggleButton = document.getElementById(`log-toggle-${taskId}`);
|
||
const logViewer = document.getElementById(`log-viewer-${taskId}`);
|
||
const lineCountElement = modal.querySelector('.log-line-count');
|
||
|
||
if (!logViewer) return;
|
||
|
||
let lineCount = logViewer.children.length;
|
||
|
||
const stream = this.taskManager.streamTaskLog(
|
||
taskId,
|
||
(newLines) => {
|
||
// Add new lines to existing content
|
||
const preElement = logViewer.querySelector('pre');
|
||
let currentContent = preElement ? preElement.textContent : '';
|
||
const separator = currentContent.trim() && !currentContent.includes('Waiting for logs...') ? '\n' : '';
|
||
let cleanedContent = currentContent.replace('Waiting for logs...', '').trim();
|
||
const newContent = cleanedContent + separator + newLines.join('\n');
|
||
preElement.innerHTML = this.taskManager.parseAnsiColors(newContent);
|
||
logViewer.appendChild(lineElement);
|
||
lineCount++;
|
||
if (lineCountElement) {
|
||
lineCountElement.textContent = `(${lineCount} lines)`;
|
||
}
|
||
|
||
// Auto-scroll to bottom
|
||
logViewer.scrollTop = logViewer.scrollHeight;
|
||
|
||
// Update status
|
||
if (statusIndicator) {
|
||
statusIndicator.textContent = '🔴 Live';
|
||
statusIndicator.className = 'status-indicator live';
|
||
}
|
||
|
||
// Remove new-line highlighting after a moment
|
||
setTimeout(() => {
|
||
const newLines = logViewer.querySelectorAll('.new-line');
|
||
newLines.forEach(line => line.classList.remove('new-line'));
|
||
}, 2000);
|
||
},
|
||
(error) => {
|
||
console.error('Log streaming error:', error);
|
||
if (statusIndicator) {
|
||
statusIndicator.textContent = '❌ Error';
|
||
statusIndicator.className = 'status-indicator error';
|
||
}
|
||
}
|
||
);
|
||
|
||
// Store stream controller
|
||
this.activeLogStreams.set(taskId, { stream, modal, isPaused: false });
|
||
|
||
// Update toggle button
|
||
if (toggleButton) {
|
||
toggleButton.textContent = '⏸️ Pause';
|
||
}
|
||
}
|
||
|
||
toggleLogStreaming(taskId) {
|
||
const streamData = this.activeLogStreams?.get(taskId);
|
||
if (!streamData) return;
|
||
|
||
const toggleButton = document.getElementById(`log-toggle-${taskId}`);
|
||
const statusIndicator = document.getElementById(`log-status-${taskId}`);
|
||
|
||
if (streamData.isPaused) {
|
||
// Resume streaming
|
||
streamData.isPaused = false;
|
||
if (toggleButton) toggleButton.textContent = '⏸️ Pause';
|
||
if (statusIndicator) {
|
||
statusIndicator.textContent = '🔴 Live';
|
||
statusIndicator.className = 'status-indicator live';
|
||
}
|
||
} else {
|
||
// Pause streaming
|
||
streamData.isPaused = true;
|
||
if (toggleButton) toggleButton.textContent = '▶️ Resume';
|
||
if (statusIndicator) {
|
||
statusIndicator.textContent = '⏸️ Paused';
|
||
statusIndicator.className = 'status-indicator paused';
|
||
}
|
||
}
|
||
}
|
||
|
||
formatCommandForUser(task) {
|
||
if (!task.command) return 'Unknown Task';
|
||
|
||
// Declarative pattern table — order matters (first match wins, so put
|
||
// specific patterns ahead of catch-alls). `title` is either a literal
|
||
// string or a function taking the regex match and returning the title;
|
||
// the latter is used for per-app patterns that extract the app slug.
|
||
//
|
||
// Adding a new task: just append one row here. Add the matching command
|
||
// shape in the WebUI submission site and you're done — no new branch
|
||
// needed in this function.
|
||
const displayName = (slug) => window.getAppDisplayName ? window.getAppDisplayName(slug) : (slug.charAt(0).toUpperCase() + slug.slice(1));
|
||
|
||
const PATTERNS = [
|
||
// -- System / setup ----------------------------------------------------
|
||
{ match: /^libreportal setup config\b/, title: 'LibrePortal - Apply Configuration' },
|
||
{ match: /^libreportal setup finalize\b/, title: 'LibrePortal - Finalize Setup' },
|
||
{ match: /^libreportal setup apply\b/, title: 'LibrePortal - Setup Wizard' },
|
||
{ match: /^libreportal config update\b/, title: 'LibrePortal - Apply Configuration' },
|
||
|
||
// -- Self-update -------------------------------------------------------
|
||
{ match: /^libreportal update (apply|now)\b/, title: 'LibrePortal - Update' },
|
||
{ match: /^libreportal update check\b/, title: 'LibrePortal - Check for Updates' },
|
||
|
||
// -- Peers -------------------------------------------------------------
|
||
{ match: /^libreportal peer add\b/, title: 'LibrePortal - Add Peer' },
|
||
{ match: /^libreportal peer remove\b/, title: 'LibrePortal - Remove Peer' },
|
||
{ match: /^libreportal peer pair\b/, title: 'LibrePortal - Pair with Peer' },
|
||
|
||
// -- Regen -------------------------------------------------------------
|
||
{ match: /^libreportal regen\b/, title: 'LibrePortal - Regenerate WebUI Data' },
|
||
|
||
// -- Backup: per-app (these capture the app slug) ----------------------
|
||
{ match: /^libreportal backup app create (\w+)/, title: (m) => `${displayName(m[1])} - Create Backup` },
|
||
{ match: /^libreportal backup app schedule (\w+)/, title: (m) => `${displayName(m[1])} - Scheduled Backup` },
|
||
{ match: /^libreportal backup app list (\w+)/, title: (m) => `${displayName(m[1])} - List Backups` },
|
||
{ match: /^libreportal backup app delete_all (\w+)/, title: (m) => `${displayName(m[1])} - Delete All Backups` },
|
||
{ match: /^libreportal backup app delete (\w+)/, title: (m) => `${displayName(m[1])} - Delete Backup` },
|
||
|
||
// -- Backup: system / locations ----------------------------------------
|
||
{ match: /^libreportal backup all\b/, title: 'LibrePortal - Backup All Apps' },
|
||
{ match: /^libreportal backup verify\b/, title: 'LibrePortal - Verify Backups' },
|
||
{ match: /^libreportal backup system\b/, title: 'LibrePortal - Backup System Config' },
|
||
{ match: /^libreportal backup location add\b/, title: 'LibrePortal - Add Backup Location' },
|
||
{ match: /^libreportal backup location remove\b/, title: 'LibrePortal - Remove Backup Location' },
|
||
{ match: /^libreportal backup location init\b/, title: 'LibrePortal - Initialise Backup Locations' },
|
||
{ match: /^libreportal backup location check\b/, title: 'LibrePortal - Check Backup Locations' },
|
||
{ match: /^libreportal backup location list\b/, title: 'LibrePortal - List Backup Locations' },
|
||
{ match: /^libreportal backup location stats\b/, title: 'LibrePortal - Backup Location Stats' },
|
||
|
||
// -- Restore / migrate -------------------------------------------------
|
||
{ match: /^libreportal restore app start (\w+)/, title: (m) => `${displayName(m[1])} - Restore Backup` },
|
||
{ match: /^libreportal restore app list (\w+)/, title: (m) => `${displayName(m[1])} - List Backups` },
|
||
{ match: /^libreportal restore migrate app (\w+)/, title: (m) => `${displayName(m[1])} - Migrate from Host` },
|
||
{ match: /^libreportal restore migrate system\b/, title: 'LibrePortal - Migrate System' },
|
||
{ match: /^libreportal restore migrate discover\b/, title: 'LibrePortal - Discover Backups' },
|
||
{ match: /^libreportal restore first-run\b/, title: 'LibrePortal - First-Run Restore' },
|
||
];
|
||
|
||
for (const p of PATTERNS) {
|
||
const m = task.command.match(p.match);
|
||
if (m) return typeof p.title === 'function' ? p.title(m) : p.title;
|
||
}
|
||
|
||
// `libreportal app tool <app> <tool_id> ['<args>']` — needs the tools
|
||
// catalog to resolve the friendly label, so it lives outside the table.
|
||
const toolMatch = task.command.match(/libreportal app tool (\S+) (\S+)/);
|
||
if (toolMatch) {
|
||
const appName = toolMatch[1];
|
||
const toolId = toolMatch[2];
|
||
let label = null;
|
||
const cat = window.toolsCatalog;
|
||
if (cat && cat.apps && cat.apps[appName] && Array.isArray(cat.apps[appName].tools)) {
|
||
const t = cat.apps[appName].tools.find(x => x.id === toolId);
|
||
if (t && t.label) label = t.label;
|
||
}
|
||
if (!label) {
|
||
label = toolId.split('_').map(w => w ? w.charAt(0).toUpperCase() + w.slice(1) : '').join(' ');
|
||
}
|
||
return `${displayName(appName)} - ${label}`;
|
||
}
|
||
|
||
// Generic `libreportal app <action> <app>` — capture the app token only;
|
||
// anything after (e.g. config overrides `CFG_FOO=bar|…`) is for the CLI.
|
||
const libreportalMatch = task.command.match(/libreportal app (\w+) (\S+)/);
|
||
if (libreportalMatch) {
|
||
const action = libreportalMatch[1];
|
||
const appName = libreportalMatch[2];
|
||
const actionMap = {
|
||
'install': 'Install Application',
|
||
'uninstall': 'Uninstall Application',
|
||
'restart': 'Restart Application',
|
||
'start': 'Start Application',
|
||
'stop': 'Stop Application',
|
||
'update': 'Update Application',
|
||
'rebuild': 'Rebuild Application',
|
||
'delete': 'Delete Backup',
|
||
'backup': 'Backup Application',
|
||
};
|
||
const formattedAction = actionMap[action] || `${action.charAt(0).toUpperCase() + action.slice(1)} Application`;
|
||
return `${displayName(appName)} - ${formattedAction}`;
|
||
}
|
||
|
||
if (task.command.includes('docker-compose')) return 'Docker Compose Operation';
|
||
if (task.command.includes('docker')) return 'Docker Operation';
|
||
|
||
return task.command.length > 50 ? task.command.substring(0, 47) + '...' : task.command;
|
||
}
|
||
|
||
getTaskTypeIcon(task) {
|
||
if (!task.type) return { icon: '⚙️', class: 'custom' };
|
||
|
||
const iconMap = {
|
||
'install': { icon: '✅', class: 'install' },
|
||
'app-install': { icon: '✅', class: 'install' },
|
||
'uninstall': { icon: '❌', class: 'uninstall' },
|
||
'restart': { icon: '🔄', class: 'restart' },
|
||
'start': { icon: '▶️', class: 'start' },
|
||
'stop': { icon: '⏹️', class: 'stop' },
|
||
'update': { icon: '⬆️', class: 'update' },
|
||
'rebuild': { icon: '🔨', class: 'rebuild' },
|
||
'backup': { icon: '💾', class: 'backup' },
|
||
'restore': { icon: '📦', class: 'restore' },
|
||
'delete': { icon: '🗑️', class: 'delete' },
|
||
'delete_all': { icon: '🗑️', class: 'delete' },
|
||
'setup-config': { icon: '🛠️', class: 'setup' },
|
||
'setup-finalize': { icon: '🎉', class: 'setup' },
|
||
'custom': { icon: '⚙️', class: 'custom' }
|
||
};
|
||
|
||
return iconMap[task.type] || iconMap['custom'];
|
||
}
|
||
|
||
/* Detect a task that's an LibrePortal system action (no specific app) so
|
||
the row can show the LibrePortal logo instead of a blank icon slot. */
|
||
isLibrePortalSystemTask(task) {
|
||
if (!task || !task.command || task.app) return false;
|
||
return /^libreportal (setup|backup\s+all|backup\s+verify|backup\s+location|backup\s+repo|restore\s+migrate\s+system|restore\s+migrate\s+discover|restore\s+first-run|webui|config|update)\b/.test(task.command);
|
||
}
|
||
|
||
/* Render the leading icon(s) on a task row:
|
||
- Per-app task → emoji type icon + app icon
|
||
- System task → emoji type icon + LibrePortal logo
|
||
- Anything else → emoji type icon only
|
||
Keeps the layout consistent across every row regardless of source. */
|
||
renderTaskIcons(task) {
|
||
const typeIcon = `<span class="task-type-icon">${this.getTaskTypeIcon(task).icon}</span>`;
|
||
// `app: 'system'` is a category sentinel (config_update, system_update, …),
|
||
// not a real app slug, so it has no /icons/apps/system.svg — fall through
|
||
// to the LibrePortal-system branch so those tasks still get a logo.
|
||
const isSystemSentinel = task.app === 'system';
|
||
if (task.app && !isSystemSentinel) {
|
||
const appIconPath = this.getAppIconPath(task);
|
||
return `${typeIcon}<img src="${appIconPath}" alt="${task.app}" class="task-app-icon" onerror="this.style.display='none'">`;
|
||
}
|
||
if (isSystemSentinel || this.isLibrePortalSystemTask(task)) {
|
||
return `${typeIcon}<img src="/icons/libreportal.svg" alt="LibrePortal" class="task-app-icon">`;
|
||
}
|
||
return typeIcon;
|
||
}
|
||
|
||
getAppIconPath(task) {
|
||
if (!task.app) return null;
|
||
|
||
// Try to get icon from commands if available
|
||
if (this.commands && this.commands.getAppData) {
|
||
const appData = this.commands.getAppData(task.app);
|
||
if (appData && appData.icon) {
|
||
return appData.icon;
|
||
}
|
||
}
|
||
|
||
// Default icon path
|
||
return `/icons/apps/${task.app}.svg`;
|
||
}
|
||
|
||
updateStats() {
|
||
const stats = {
|
||
queued: this.tasks.filter(t => t.status === 'queued').length,
|
||
running: this.tasks.filter(t => t.status === 'running').length,
|
||
completed: this.tasks.filter(t => t.status === 'completed').length,
|
||
failed: this.tasks.filter(t => t.status === 'failed').length
|
||
};
|
||
|
||
Object.keys(stats).forEach(status => {
|
||
const element = document.getElementById(`${status}-count`);
|
||
if (element) element.textContent = stats[status];
|
||
});
|
||
}
|
||
|
||
updateSidebarCounts() {
|
||
// Update all sidebar category counts
|
||
const categories = ['all', 'queued', 'running', 'completed', 'failed', 'install', 'uninstall', 'management', 'backup', 'config'];
|
||
|
||
categories.forEach(category => {
|
||
const count = this.filterTasksByCategory(this.tasks, category).length;
|
||
const element = document.getElementById(`count-${category}`);
|
||
if (element) element.textContent = count;
|
||
});
|
||
|
||
// Update app-specific counts
|
||
const apps = [...new Set(this.tasks.map(task => task.app).filter(Boolean))];
|
||
apps.forEach(app => {
|
||
const count = this.tasks.filter(task => task.app === app).length;
|
||
const element = document.getElementById(`count-app-${app}`);
|
||
if (element) element.textContent = count;
|
||
});
|
||
}
|
||
|
||
async generateAppCategories() {
|
||
const container = document.getElementById('app-categories');
|
||
if (!container) return;
|
||
|
||
try {
|
||
// Show loading state
|
||
container.innerHTML = '<div class="loading-categories"><div class="loading-spinner"></div><p style="color: #ffffff;">Loading apps...</p></div>';
|
||
|
||
// Try to load apps data if not already available
|
||
let appsData = window.apps || [];
|
||
|
||
// If window.apps is not available, try to load it
|
||
if (!appsData || appsData.length === 0) {
|
||
try {
|
||
const appsResponse = await fetch('/read-file?path=apps.json');
|
||
if (appsResponse.ok) {
|
||
const appsText = await appsResponse.text();
|
||
if (appsText.trim()) {
|
||
appsData = JSON.parse(appsText);
|
||
window.apps = appsData; // Cache for future use
|
||
}
|
||
}
|
||
} catch (error) {
|
||
// console.log('Could not load apps.json, will use fallback');
|
||
}
|
||
}
|
||
|
||
// Filter for installed apps only and extract slugs
|
||
const installedApps = appsData
|
||
.filter(app => app.installed === true || app.status === 'installed')
|
||
.map(app => {
|
||
// Extract slug from command like "libreportal app install adguard"
|
||
const command = app.command || '';
|
||
const slugMatch = command.match(/libreportal app install\s+(.+)$/);
|
||
const slug = slugMatch ? slugMatch[1].trim() : '';
|
||
|
||
// Extract title from "Title - Description" format
|
||
const fullName = app.name || '';
|
||
const title = fullName.split(' - ')[0].trim();
|
||
|
||
return { slug, title };
|
||
})
|
||
.filter(app => app.slug && app.title);
|
||
|
||
// If no installed apps found from apps data, fall back to task-based apps
|
||
if (installedApps.length === 0) {
|
||
// console.log('No installed apps found, using task-based app list');
|
||
const taskApps = [...new Set(this.tasks.map(task => task.app).filter(Boolean))];
|
||
|
||
if (taskApps.length === 0) {
|
||
container.innerHTML = '<div class="no-apps" style="color: var(--text-secondary); font-size: 12px; padding: 8px;">No apps found</div>';
|
||
return;
|
||
}
|
||
|
||
const appCategories = taskApps.map(app => {
|
||
const taskCount = this.tasks.filter(task => task.app === app).length;
|
||
const displayName = window.getAppDisplayName ? window.getAppDisplayName(app) : (app.charAt(0).toUpperCase() + app.slice(1));
|
||
return `
|
||
<a href="#" class="sidebar-item" data-category="${app}" onclick="filterTasksByCategory('${app}')">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
||
<line x1="9" y1="9" x2="15" y2="9"></line>
|
||
<line x1="9" y1="15" x2="15" y2="15"></line>
|
||
</svg>
|
||
${displayName}
|
||
<span class="task-count" id="count-app-${app}">${taskCount}</span>
|
||
</a>
|
||
`;
|
||
}).join('');
|
||
|
||
container.innerHTML = appCategories;
|
||
return;
|
||
}
|
||
|
||
const appCategories = installedApps.map(app => {
|
||
const taskCount = this.tasks.filter(task => task.app === app.slug).length;
|
||
return `
|
||
<a href="#" class="sidebar-item" data-category="${app.slug}" onclick="filterTasksByCategory('${app.slug}')">
|
||
<div class="status-indicator status-installed"></div>
|
||
${app.title}
|
||
<span class="task-count" id="count-app-${app.slug}">${taskCount}</span>
|
||
</a>
|
||
`;
|
||
}).join('');
|
||
|
||
container.innerHTML = appCategories;
|
||
} catch (error) {
|
||
console.error('Error generating app categories:', error);
|
||
// Final fallback to task-based app list
|
||
const apps = [...new Set(this.tasks.map(task => task.app).filter(Boolean))];
|
||
|
||
if (apps.length === 0) {
|
||
container.innerHTML = '<div class="no-apps" style="color: var(--text-secondary); font-size: 12px; padding: 8px;">No apps found</div>';
|
||
return;
|
||
}
|
||
|
||
const appCategories = apps.map(app => {
|
||
const taskCount = this.tasks.filter(task => task.app === app).length;
|
||
const displayName = window.getAppDisplayName ? window.getAppDisplayName(app) : (app.charAt(0).toUpperCase() + app.slice(1));
|
||
return `
|
||
<a href="#" class="sidebar-item" data-category="${app}" onclick="filterTasksByCategory('${app}')">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
||
<line x1="9" y1="9" x2="15" y2="9"></line>
|
||
<line x1="9" y1="15" x2="15" y2="15"></line>
|
||
</svg>
|
||
${displayName}
|
||
<span class="task-count" id="count-app-${app}">${taskCount}</span>
|
||
</a>
|
||
`;
|
||
}).join('');
|
||
|
||
container.innerHTML = appCategories;
|
||
}
|
||
}
|
||
|
||
setupAutoRefresh() {
|
||
// Refresh every 5 seconds
|
||
this.refreshInterval = setInterval(() => {
|
||
this.loadTasks();
|
||
}, 5000);
|
||
}
|
||
|
||
setupMobileMenu() {
|
||
// Setup mobile menu toggle (if needed)
|
||
const mobileOverlay = document.getElementById('mobile-overlay');
|
||
const sidebar = document.getElementById('sidebar');
|
||
|
||
if (mobileOverlay && sidebar) {
|
||
mobileOverlay.addEventListener('click', () => {
|
||
sidebar.classList.remove('mobile-open');
|
||
mobileOverlay.classList.remove('active');
|
||
});
|
||
}
|
||
}
|
||
|
||
filterTasksByCategoryHandler(category) {
|
||
this.currentCategory = category;
|
||
|
||
// Clear specific task filter when switching categories
|
||
this.highlightedTaskId = null;
|
||
|
||
// Update URL
|
||
this.updateURL(category);
|
||
|
||
// Update sidebar active state
|
||
document.querySelectorAll('.sidebar-item').forEach(item => {
|
||
item.classList.toggle('active', item.dataset.category === category);
|
||
});
|
||
|
||
this.renderTasks();
|
||
}
|
||
|
||
setupAutoRefresh() {
|
||
// Only refresh when tasks page is visible and every 30 seconds
|
||
this.refreshInterval = setInterval(() => {
|
||
// Only refresh if we're on the tasks page
|
||
if (window.location.pathname === '/tasks' || window.location.hash.includes('tasks')) {
|
||
this.loadTasks();
|
||
}
|
||
}, 30000); // 30 seconds instead of 5
|
||
}
|
||
|
||
setupGlobalFunctions() {
|
||
// Make functions available globally for onclick handlers
|
||
window.filterTasksByCategory = (category) => {
|
||
event.preventDefault();
|
||
this.filterTasksByCategoryHandler(category);
|
||
};
|
||
|
||
// Delegated click handler for the Task ID and App links rendered inside
|
||
// each task row. Behaviour:
|
||
// * Task ID always lands on the global /tasks page with that task open.
|
||
// If we're already there, just push the URL and toggle the row open
|
||
// (no content reload); otherwise SPA-navigate to /tasks.
|
||
// * App link goes to that app's /app page on whatever its default tab
|
||
// is (we don't pin tab=… so the app's own logic picks one).
|
||
if (!window.__taskMetaLinksBound) {
|
||
window.__taskMetaLinksBound = true;
|
||
document.addEventListener('click', (e) => {
|
||
const link = e.target.closest('a.task-id-link, a.task-app-link');
|
||
if (!link) return;
|
||
const href = link.getAttribute('href');
|
||
if (!href) return;
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
|
||
const navigate = (url) => {
|
||
if (window.spaClean && typeof window.spaClean.navigate === 'function') {
|
||
window.spaClean.navigate(url);
|
||
} else {
|
||
window.location.href = url;
|
||
}
|
||
};
|
||
|
||
if (link.classList.contains('task-id-link')) {
|
||
const taskId = link.dataset.taskId;
|
||
const onTasksPage = window.location.pathname.startsWith('/tasks');
|
||
if (onTasksPage && taskId && typeof window.toggleTaskDetails === 'function') {
|
||
// Already on /tasks — soft-update the URL and open the row.
|
||
window.history.pushState({}, '', href);
|
||
if (window.tasksManager) window.tasksManager.highlightedTaskId = taskId;
|
||
window.toggleTaskDetails(taskId);
|
||
} else {
|
||
// Coming from /app or anywhere else — go to the tasks page; its
|
||
// initializeFromURL picks up `task=` and auto-expands.
|
||
navigate(href);
|
||
}
|
||
} else {
|
||
// App link — go to the app page on its default tab.
|
||
//
|
||
// `task-parameter-preserve.js` stashes any `task=…` from the
|
||
// initial page URL into sessionStorage.pendingTaskId, and
|
||
// app-tabbed-manager's init reads that as a fallback when the
|
||
// current URL has no task param. Without clearing it here, an old
|
||
// task id from /tasks would hijack the app page, force-switching
|
||
// to the Tasks tab and opening that task. Clear it so the app
|
||
// page lands on its actual default tab.
|
||
try { sessionStorage.removeItem('pendingTaskId'); } catch {}
|
||
navigate(href);
|
||
}
|
||
});
|
||
}
|
||
window.refreshTasks = () => this.refreshTasks();
|
||
window.toggleTaskDetails = (taskId) => this.toggleTaskDetails(taskId);
|
||
window.retryTask = (taskId) => this.retryTask(taskId);
|
||
window.deleteTask = (taskId) => this.deleteTask(taskId);
|
||
window.clearAllTasks = () => this.clearAllTasks();
|
||
window.viewTaskLogs = (taskId) => this.viewTaskLogs(taskId);
|
||
window.toggleLogStreaming = (taskId) => this.toggleLogStreaming(taskId);
|
||
window.closeTaskLogsModal = () => {
|
||
const modal = document.querySelector('.task-logs-modal');
|
||
if (modal) {
|
||
// Stop all active streaming for this modal
|
||
this.activeLogStreams?.forEach((streamData, streamTaskId) => {
|
||
if (streamData.modal === modal) {
|
||
streamData.stream.stop();
|
||
this.activeLogStreams.delete(streamTaskId);
|
||
}
|
||
});
|
||
modal.remove();
|
||
}
|
||
};
|
||
|
||
}
|
||
|
||
// Single page-wide subscription to the TaskEventBus. This complements
|
||
// monitorTask (which only fires for tasks created in this session) and
|
||
// ensures any visible row updates when its status changes — including the
|
||
// running -> completed/failed/cancelled transition that previously only
|
||
// refreshed when the user switched tabs and came back.
|
||
//
|
||
// The guard is on `window`, not `this`, because TasksManager is constructed
|
||
// in several places (system-loader, app-tabbed-manager, …). Without a
|
||
// global guard each instance would attach its own listener and the user
|
||
// would see N notifications for one task completion.
|
||
setupTaskBusListeners() {
|
||
if (window.__tasksManagerBusBound) return;
|
||
window.__tasksManagerBusBound = true;
|
||
|
||
const upsertLocal = (task) => {
|
||
if (!task || !task.id) return;
|
||
const idx = this.tasks.findIndex(t => t.id === task.id);
|
||
if (idx >= 0) this.tasks[idx] = task; else this.tasks.unshift(task);
|
||
};
|
||
|
||
window.addEventListener('taskCreated', (e) => {
|
||
const task = e.detail && e.detail.task;
|
||
if (!task) return;
|
||
upsertLocal(task);
|
||
// Don't re-render the entire list here — `renderTasks` would blow away
|
||
// any open dropdown's DOM. A targeted update is enough; the next
|
||
// category switch / refresh will pull the new row in.
|
||
this.updateTaskDisplay(task);
|
||
});
|
||
|
||
window.addEventListener('taskUpdated', (e) => {
|
||
const task = e.detail && e.detail.task;
|
||
if (!task) return;
|
||
upsertLocal(task);
|
||
if (task.status === 'running') {
|
||
this.updateTaskStructure(task.id, task);
|
||
this.startLogStreaming(task.id, task);
|
||
}
|
||
this.updateTaskDisplay(task);
|
||
});
|
||
|
||
window.addEventListener('taskCompleted', (e) => {
|
||
const task = e.detail && e.detail.task;
|
||
const taskId = (task && task.id) || (e.detail && e.detail.taskId);
|
||
if (!taskId) return;
|
||
if (task) upsertLocal(task);
|
||
if (task) this.updateTaskDisplay(task);
|
||
|
||
// Final render of any buffered log content, then stop the SSE listener.
|
||
const stream = this.activeLogStreams && this.activeLogStreams.get(taskId);
|
||
if (stream && typeof stream.render === 'function') stream.render();
|
||
this.stopLogStreaming(taskId);
|
||
|
||
// User-visible notification so they know without staring at the page.
|
||
// Layout matches the "<App> task started!" format used by task-actions
|
||
// and backup-manager so started/completed look like a matched pair.
|
||
if (window.notificationSystem && task) {
|
||
const appName = task.app || null;
|
||
const action = task.type || 'task';
|
||
let actionTitle = this.formatActionTitle(action);
|
||
// Tool tasks: override the generic "Tool" label with the tool's
|
||
// friendly name (e.g. "Manage Shortcuts") so completion toasts
|
||
// match what the user clicked.
|
||
const toolCmdMatch = (task.command || '').match(/libreportal app tool (\S+) (\S+)/);
|
||
if (toolCmdMatch) {
|
||
const toolApp = toolCmdMatch[1];
|
||
const toolId = toolCmdMatch[2];
|
||
let toolLabel = null;
|
||
const cat = window.toolsCatalog;
|
||
if (cat && cat.apps && cat.apps[toolApp] && Array.isArray(cat.apps[toolApp].tools)) {
|
||
const t = cat.apps[toolApp].tools.find(x => x.id === toolId);
|
||
if (t && t.label) toolLabel = t.label;
|
||
}
|
||
if (!toolLabel) {
|
||
toolLabel = toolId.split('_').map(w => w ? w.charAt(0).toUpperCase() + w.slice(1) : '').join(' ');
|
||
}
|
||
actionTitle = toolLabel;
|
||
}
|
||
// System-level tasks resolve to LibrePortal as the subject + use the
|
||
// LibrePortal logo as the app-icon. Covers `app: 'system'` (config
|
||
// update, system update) and `setup-*` types (setup wizard phases).
|
||
const isSystemTask = action.startsWith('setup-') || appName === 'system';
|
||
const displayName = isSystemTask
|
||
? 'LibrePortal'
|
||
: ((appName && window.getAppDisplayName)
|
||
? window.getAppDisplayName(appName)
|
||
: (appName || (task.command || `Task ${taskId}`)));
|
||
const onAppPage = window.location.pathname.startsWith('/app') && !window.location.pathname.startsWith('/apps');
|
||
const url = (onAppPage && appName)
|
||
? window.appPath(appName, 'tasks', null, taskId)
|
||
: `/tasks/all?task=${taskId}`;
|
||
const icon = isSystemTask
|
||
? '/icons/libreportal.svg'
|
||
: (appName ? `/icons/apps/${appName}.svg` : null);
|
||
|
||
// Match the per-action emoji used in the task list rows (see
|
||
// `getTaskTypeIcon`). Passed as the 6th `customIcon` arg so the
|
||
// notification's leftmost icon slot shows the task *type* (install
|
||
// ✅, backup 💾, restore 📦, …) instead of the generic level tick.
|
||
const typeIcon = (this.getTaskTypeIcon ? this.getTaskTypeIcon(task) : null)?.icon || '';
|
||
const customIcon = typeIcon ? `<span style="font-size:18px;line-height:1;">${typeIcon}</span>` : null;
|
||
|
||
let body;
|
||
let level;
|
||
if (task.status === 'completed') {
|
||
body = `<strong>${displayName}</strong><br>${actionTitle} task completed!`;
|
||
level = 'success';
|
||
} else if (task.status === 'failed') {
|
||
body = `<strong>${displayName}</strong><br>${actionTitle} task failed.`;
|
||
level = 'error';
|
||
} else if (task.status === 'cancelled') {
|
||
body = `<strong>${displayName}</strong><br>${actionTitle} task cancelled.`;
|
||
level = 'warning';
|
||
}
|
||
if (body) window.notificationSystem.show(body, level, appName, url, icon, customIcon);
|
||
|
||
// Belt-and-braces: when the completion notification fires we also
|
||
// tell the app-tabbed manager to re-enable that app's tabs and
|
||
// buttons. The taskCompleted listener inside app-tabbed-manager
|
||
// does this too, plus the 5s reconcile sweep — but routing it
|
||
// through here as well means any one path being broken or
|
||
// de-bound still leaves the user with a usable UI rather than
|
||
// permanently-disabled tabs.
|
||
if (appName && window.appTabbedManager && typeof window.appTabbedManager.enableAppButtons === 'function') {
|
||
try { window.appTabbedManager.enableAppButtons(appName); } catch {}
|
||
}
|
||
}
|
||
});
|
||
|
||
window.addEventListener('taskDeleted', (e) => {
|
||
const id = e.detail && e.detail.id;
|
||
if (!id) return;
|
||
this.tasks = this.tasks.filter(t => t.id !== id);
|
||
const el = document.querySelector(`.task-item[data-task-id="${id}"]`);
|
||
if (!el) return;
|
||
const parent = el.parentElement;
|
||
el.remove();
|
||
if (!parent || parent.querySelector('.task-item')) return;
|
||
if (parent.id === 'tasks-list') {
|
||
this.renderTasks();
|
||
} else if (parent.id === 'app-tasks') {
|
||
const appName = (window.appTabbedManager && window.appTabbedManager.currentApp) || '';
|
||
parent.innerHTML = `<p style="color: #888;">No tasks found for ${appName}.</p>`;
|
||
}
|
||
});
|
||
}
|
||
|
||
// Load task logs on demand
|
||
async loadTaskLogs(taskId) {
|
||
try {
|
||
// console.log(`📋 Loading logs for task ${taskId}...`);
|
||
|
||
// Show loading state
|
||
const detailsElement = document.getElementById(`details-${taskId}`);
|
||
if (detailsElement) {
|
||
const logsContainer = detailsElement.querySelector('.task-logs');
|
||
if (logsContainer) {
|
||
logsContainer.innerHTML = '<div class="loading-logs">📋 Loading logs...</div>';
|
||
}
|
||
}
|
||
|
||
// Load task data directly from task file
|
||
const task = this.tasks.find(t => t.id === taskId);
|
||
let output = '';
|
||
|
||
if (task) {
|
||
output = task.output || '';
|
||
} else {
|
||
// Try to fetch task data directly
|
||
try {
|
||
const response = await fetch(`/api/tasks/${taskId}`);
|
||
if (response.ok) {
|
||
const taskData = await response.json();
|
||
output = taskData.output || '';
|
||
}
|
||
} catch (error) {
|
||
console.warn('Failed to fetch task data:', error);
|
||
}
|
||
}
|
||
|
||
if (output && output.trim().length > 0) {
|
||
// Display the output
|
||
const logsHtml = output.split('\n').map(log => `<div class="log-entry">${this.parseAnsiColors(log)}</div>`).join('');
|
||
|
||
if (detailsElement) {
|
||
const logsContainer = detailsElement.querySelector('.task-logs');
|
||
if (logsContainer) {
|
||
logsContainer.innerHTML = `
|
||
<h4 style="margin-left: 20px;">📋 Execution Logs</h4>
|
||
<div class="log-container terminal-style">${logsHtml}</div>
|
||
`;
|
||
}
|
||
}
|
||
|
||
// console.log(`✅ Loaded logs for task ${taskId}`);
|
||
} else {
|
||
// No logs available
|
||
if (detailsElement) {
|
||
const logsContainer = detailsElement.querySelector('.task-logs');
|
||
if (logsContainer) {
|
||
logsContainer.innerHTML = '<div class="no-logs">📋 No output available for this task.</div>';
|
||
}
|
||
}
|
||
|
||
// console.log(`ℹ️ No logs available for task ${taskId}`);
|
||
}
|
||
|
||
return output;
|
||
} catch (error) {
|
||
console.error(`❌ Failed to load logs for task ${taskId}:`, error);
|
||
if (window.notificationSystem) {
|
||
window.notificationSystem.error(`Failed to load logs: ${error.message}`);
|
||
}
|
||
return '';
|
||
}
|
||
}
|
||
|
||
// Monitor a specific task. State changes now arrive via SSE through
|
||
// TaskEventBus, so this is mostly a hook for the UI to:
|
||
// - auto-expand the task if it's the one we just started
|
||
// - start log streaming when the task transitions to running
|
||
// - clean up local intervals when it terminates
|
||
// No more polling; the only fetches happen on-demand via TaskManager.
|
||
monitorTask(taskId, appName, action) {
|
||
if (!this.highlightedTaskId || this.highlightedTaskId === taskId) {
|
||
setTimeout(() => this.autoExpandTask(taskId), 1500);
|
||
}
|
||
|
||
let statusUpdateInterval = null;
|
||
|
||
const onUpdate = (event) => {
|
||
const t = event.detail && event.detail.task;
|
||
if (!t || t.id !== taskId) return;
|
||
|
||
if (t.status === 'running') {
|
||
this.updateTaskStructure(taskId, t);
|
||
this.startLogStreaming(taskId, t);
|
||
if (this.highlightedTaskId === taskId && !statusUpdateInterval) {
|
||
statusUpdateInterval = setInterval(() => this.updateHighlightedTaskStatus(taskId), 2000);
|
||
}
|
||
} else {
|
||
this.updateTaskDisplay(t);
|
||
}
|
||
};
|
||
|
||
const onComplete = (event) => {
|
||
if (!event.detail || event.detail.taskId !== taskId) return;
|
||
if (statusUpdateInterval) { clearInterval(statusUpdateInterval); statusUpdateInterval = null; }
|
||
this.stopLogStreaming(taskId);
|
||
window.removeEventListener('taskUpdated', onUpdate);
|
||
window.removeEventListener('taskCompleted', onComplete);
|
||
};
|
||
|
||
window.addEventListener('taskUpdated', onUpdate);
|
||
window.addEventListener('taskCompleted', onComplete);
|
||
}
|
||
|
||
// SSE-driven log streaming. Subscribes to `taskLog` events from the bus and
|
||
// appends incoming chunks. Initial backlog is fetched once via the API.
|
||
//
|
||
// Resilient to DOM replacement: the logs container can be wiped out by
|
||
// `renderTasks()` (the 30s auto-refresh) or by `loadTaskLogs()` (toggle
|
||
// re-open). Instead of capturing a `preElement` reference once, `render()`
|
||
// re-locates / re-creates it on every call from the cumulative `buffered`
|
||
// string. Idempotent: if already streaming, a second call just re-renders.
|
||
async startLogStreaming(taskId, task) {
|
||
if (!this.activeLogStreams) this.activeLogStreams = new Map();
|
||
|
||
if (this.activeLogStreams.has(taskId)) {
|
||
const existing = this.activeLogStreams.get(taskId);
|
||
if (existing && typeof existing.render === 'function') existing.render();
|
||
return;
|
||
}
|
||
|
||
const state = { buffered: '' };
|
||
|
||
const render = () => {
|
||
const logsContainer = document.getElementById(`logs-${taskId}`);
|
||
if (!logsContainer) return;
|
||
const overlay = logsContainer.querySelector('div[style*="position: absolute"]');
|
||
if (overlay) overlay.remove();
|
||
let preElement = logsContainer.querySelector('pre.output-content');
|
||
if (!preElement) {
|
||
logsContainer.innerHTML = '';
|
||
preElement = document.createElement('pre');
|
||
preElement.className = 'output-content terminal-style';
|
||
logsContainer.appendChild(preElement);
|
||
}
|
||
const atBottom = logsContainer.scrollHeight - logsContainer.scrollTop <= logsContainer.clientHeight + 10;
|
||
if (state.buffered) {
|
||
preElement.innerHTML = this.taskManager.parseAnsiColors(state.buffered);
|
||
} else {
|
||
preElement.innerHTML = '<span style="color: #888;">Waiting for logs...</span>';
|
||
}
|
||
if (atBottom) logsContainer.scrollTop = logsContainer.scrollHeight;
|
||
};
|
||
|
||
// Initial backlog via the API.
|
||
try {
|
||
const initial = await this.taskManager.readFullTaskLog(taskId);
|
||
if (initial && initial.length) state.buffered = initial;
|
||
} catch { /* fall through to placeholder */ }
|
||
render();
|
||
|
||
const onLog = (event) => {
|
||
const detail = event.detail || {};
|
||
if (detail.id !== taskId || typeof detail.chunk !== 'string') return;
|
||
state.buffered += detail.chunk;
|
||
render();
|
||
};
|
||
window.addEventListener('taskLog', onLog);
|
||
|
||
// SSE catch-up: when the backend restarts mid-task (e.g., libreportal
|
||
// recreates itself during a CrowdSec install), the SSE event source
|
||
// drops. EventSource auto-reconnects, but task.log events emitted
|
||
// during the gap are lost. taskBusReady fires after every reconnect —
|
||
// pull the missed bytes via the existing /:id/log?position=N endpoint
|
||
// and splice them in. Skips on the initial connect (no gap).
|
||
let initialReadyFired = false;
|
||
const onBusReady = async () => {
|
||
if (!initialReadyFired) { initialReadyFired = true; return; }
|
||
try {
|
||
const res = await fetch(`/api/tasks/${encodeURIComponent(taskId)}/log?position=${state.buffered.length}`);
|
||
if (!res.ok) return;
|
||
const missed = await res.text();
|
||
if (missed) {
|
||
state.buffered += missed;
|
||
render();
|
||
}
|
||
} catch { /* network blip, next ready will retry */ }
|
||
};
|
||
window.addEventListener('taskBusReady', onBusReady);
|
||
|
||
this.activeLogStreams.set(taskId, {
|
||
stream: { stop: () => {
|
||
window.removeEventListener('taskLog', onLog);
|
||
window.removeEventListener('taskBusReady', onBusReady);
|
||
this.activeLogStreams.delete(taskId);
|
||
} },
|
||
render,
|
||
isPaused: false
|
||
});
|
||
}
|
||
|
||
// Stop log streaming for a task
|
||
stopLogStreaming(taskId) {
|
||
if (this.activeLogStreams && this.activeLogStreams.has(taskId)) {
|
||
const streamData = this.activeLogStreams.get(taskId);
|
||
streamData.stream.stop();
|
||
this.activeLogStreams.delete(taskId);
|
||
// console.log(`⏹️ Stopped log streaming for task ${taskId}`);
|
||
}
|
||
}
|
||
|
||
// Update task structure for live logs
|
||
updateTaskStructure(taskId, task) {
|
||
const taskElement = document.querySelector(`[data-task-id="${taskId}"]`);
|
||
if (!taskElement) return;
|
||
|
||
const detailsElement = taskElement.querySelector('.task-details');
|
||
if (!detailsElement) return;
|
||
|
||
// console.log(`🔄 Updating task structure for ${taskId} to show simplified logs`);
|
||
|
||
// Check if logs container already exists
|
||
const existingLogs = detailsElement.querySelector('.task-logs .log-container');
|
||
if (existingLogs) {
|
||
// console.log(`🔄 Logs container already exists for ${taskId}`);
|
||
return; // Already exists, no need to update
|
||
}
|
||
|
||
// Add simplified logs section for running tasks
|
||
if (task.status === 'running') {
|
||
const logsHtml = `
|
||
<div class="task-logs">
|
||
<div class="log-container terminal-style" id="logs-${taskId}" style="height: 200px; overflow-y: auto; position: relative;">
|
||
<div style="position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(10, 18, 36, 0.85); display: flex; align-items: center; justify-content: center; z-index: 10;"><div class="loading-spinner" style="width: 16px; height: 16px; border: 2px solid rgba(255,255,255,0.18); border-top: 2px solid #fff; border-radius: 50%; animation: spin 1s linear infinite; margin-right: 8px;"></div>Loading logs...</div></div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// Insert logs section at the bottom of details
|
||
detailsElement.insertAdjacentHTML('beforeend', logsHtml);
|
||
// console.log(`✅ Added simplified logs section for task ${taskId}`);
|
||
|
||
// Auto-load logs
|
||
this.loadTaskLogs(taskId);
|
||
}
|
||
}
|
||
|
||
// Update task display in real-time
|
||
updateTaskDisplay(task) {
|
||
const taskElement = document.querySelector(`[data-task-id="${task.id}"]`);
|
||
if (!taskElement) {
|
||
// The monitored task isn't always rendered (different tab, list filtered out,
|
||
// task already removed). Silently skip — this is the normal case.
|
||
return;
|
||
}
|
||
|
||
// console.log(`🔄 Found task element:`, taskElement);
|
||
|
||
// Update status and content
|
||
const statusElement = taskElement.querySelector('.task-status');
|
||
const contentElement = taskElement.querySelector('.task-content');
|
||
|
||
if (statusElement) {
|
||
const statusClass = `status-${task.status || 'unknown'}`;
|
||
statusElement.className = `task-status ${statusClass}`;
|
||
statusElement.innerHTML = `${this.getStatusIcon(task.status)} ${task.status ? task.status.toUpperCase() : 'UNKNOWN'}`;
|
||
} else {
|
||
console.warn(`⚠️ Status element not found for task ${task.id}`);
|
||
}
|
||
|
||
// Mirror the status into the details panel's metadata block too — that
|
||
// copy of the status was previously left stale until the page reloaded.
|
||
const detailsStatus = taskElement.querySelector(`#details-${task.id} .task-meta .status-running, #details-${task.id} .task-meta .status-queued, #details-${task.id} .task-meta .status-pending, #details-${task.id} .task-meta .status-completed, #details-${task.id} .task-meta .status-failed, #details-${task.id} .task-meta .status-cancelled, #details-${task.id} .task-meta [class^="status-"]`);
|
||
if (detailsStatus) {
|
||
detailsStatus.className = `status-${task.status || 'unknown'}`;
|
||
detailsStatus.innerHTML = `${this.getStatusIcon(task.status)} ${task.status ? task.status.toUpperCase() : 'UNKNOWN'}`;
|
||
}
|
||
|
||
if (contentElement) {
|
||
contentElement.textContent = task.command;
|
||
}
|
||
|
||
// console.log(`🔄 Updated task ${task.id} display: ${task.status}`);
|
||
}
|
||
|
||
// Start global live log updater - simple 2-second updates for all running tasks
|
||
startGlobalLiveLogUpdater() {
|
||
// console.log(`🔄 Starting global live log updater`);
|
||
|
||
// Update every 2 seconds
|
||
setInterval(async () => {
|
||
// console.log(`🔄 Global updater running - checking tasks...`);
|
||
|
||
// Find all running tasks
|
||
const runningTasks = this.tasks.filter(task => task.status === 'running');
|
||
// console.log(`🔄 Found ${runningTasks.length} running tasks:`, runningTasks.map(t => t.id));
|
||
|
||
if (runningTasks.length > 0) {
|
||
// console.log(`🔄 Updating live logs for ${runningTasks.length} running tasks`);
|
||
|
||
// Update each running task's live logs
|
||
for (const task of runningTasks) {
|
||
// console.log(`🔄 About to update live logs for task ${task.id}`);
|
||
await this.updateLiveLogsSimple(task.id);
|
||
}
|
||
} else {
|
||
// console.log(`🔄 No running tasks found, skipping live log updates`);
|
||
}
|
||
}, 2000); // Every 2 seconds
|
||
}
|
||
|
||
// Simple live log update - no complex polling logic
|
||
async updateLiveLogsSimple(taskId) {
|
||
const liveLogsElement = document.getElementById(`live-logs-${taskId}`);
|
||
if (!liveLogsElement) {
|
||
// console.log(`⚠️ Live logs element not found for task ${taskId}`);
|
||
return; // Silently skip if element not found
|
||
}
|
||
|
||
try {
|
||
// console.log(`🔄 Reading log file for task ${taskId}`);
|
||
|
||
// Read the log file content
|
||
const response = await fetch(`/read-file?path=tasks/${taskId}.log`);
|
||
// console.log(`🔄 Log file response status: ${response.status} for task ${taskId}`);
|
||
|
||
if (response.ok) {
|
||
const logContent = await response.text();
|
||
// console.log(`🔄 Log file content length: ${logContent.length} chars for task ${taskId}`);
|
||
// console.log(`🔄 First 100 chars of log content: "${logContent.substring(0, 100)}..."`);
|
||
|
||
if (logContent.trim()) {
|
||
// Split into lines and display
|
||
const lines = logContent.split('\n').filter(line => line.trim());
|
||
// console.log(`🔄 Displaying ${lines.length} log lines for task ${taskId}`);
|
||
|
||
liveLogsElement.innerHTML = lines.map(line =>
|
||
`<div class="log-entry">${this.parseAnsiColors(line)}</div>`
|
||
).join('');
|
||
// Auto-scroll to bottom
|
||
liveLogsElement.scrollTop = liveLogsElement.scrollHeight;
|
||
} else {
|
||
// console.log(`🔄 Log file is empty for task ${taskId}`);
|
||
liveLogsElement.innerHTML = '<div class="log-entry">🔄 Waiting for logs...</div>';
|
||
}
|
||
} else {
|
||
console.warn(`⚠️ Failed to read log file for task ${taskId}: ${response.status}`);
|
||
liveLogsElement.innerHTML = '<div class="log-entry">⚠️ Unable to read logs</div>';
|
||
}
|
||
} catch (error) {
|
||
// Silently handle errors
|
||
console.warn(`⚠️ Error reading live logs for task ${taskId}:`, error);
|
||
liveLogsElement.innerHTML = '<div class="log-entry">❌ Error loading logs</div>';
|
||
}
|
||
}
|
||
|
||
// Load task logs automatically
|
||
async loadTaskLogs(taskId) {
|
||
try {
|
||
const logsContainer = document.getElementById(`logs-${taskId}`);
|
||
if (!logsContainer) {
|
||
console.warn(`⚠️ Logs container not found for task ${taskId}`);
|
||
return;
|
||
}
|
||
|
||
const task = this.tasks.find(t => t.id === taskId);
|
||
const inMemoryLog = (task && Array.isArray(task.log) && task.log.length > 0) ? task.log : null;
|
||
|
||
const renderInMemory = () => {
|
||
if (!inMemoryLog) return false;
|
||
logsContainer.innerHTML = inMemoryLog
|
||
.map(line => `<div class="log-entry">${this.taskManager.parseAnsiColors(line)}</div>`)
|
||
.join('');
|
||
return true;
|
||
};
|
||
|
||
logsContainer.innerHTML = '<div class="log-entry">🔄 Loading logs...</div>';
|
||
const isScrolledToBottom = logsContainer.scrollHeight - logsContainer.scrollTop <= logsContainer.clientHeight + 10;
|
||
|
||
const logResponse = await fetch(`/read-file?path=tasks/${taskId}.log`);
|
||
if (logResponse.ok) {
|
||
const logContent = await logResponse.text();
|
||
if (logContent.trim()) {
|
||
logsContainer.innerHTML = `<pre class="output-content terminal-style">${this.taskManager.parseAnsiColors(logContent)}</pre>`;
|
||
if (isScrolledToBottom) logsContainer.scrollTop = logsContainer.scrollHeight;
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (renderInMemory()) return;
|
||
|
||
logsContainer.innerHTML = '<div class="log-entry">ℹ️ No logs available for this task.</div>';
|
||
|
||
} catch (error) {
|
||
console.error(`❌ Error loading logs for task ${taskId}:`, error);
|
||
const logsContainer = document.getElementById(`logs-${taskId}`);
|
||
if (logsContainer) {
|
||
logsContainer.innerHTML = '<div class="log-entry">❌ Failed to load logs.</div>';
|
||
}
|
||
}
|
||
}
|
||
|
||
// Load task output on demand
|
||
async loadTaskOutput(taskId) {
|
||
try {
|
||
// Read the task file to get output
|
||
const task = await this.taskManager.getTask(taskId);
|
||
if (!task) return;
|
||
|
||
const outputElement = document.querySelector(`[data-task-id="${taskId}"] .task-output`);
|
||
if (!outputElement) return;
|
||
|
||
// Show loading state
|
||
outputElement.innerHTML = `
|
||
<div class="loading-output">Loading output...</div>
|
||
`;
|
||
|
||
// Check if task has output
|
||
if (task.output && task.output.trim()) {
|
||
outputElement.innerHTML = `
|
||
<h4>📤 Output</h4>
|
||
<pre class="output-content terminal-style">${this.taskManager.parseAnsiColors(task.output)}</pre>
|
||
`;
|
||
} else if (task.error && task.error.trim()) {
|
||
outputElement.innerHTML = `
|
||
<h4>❌ Error</h4>
|
||
<pre class="error-content">${this.escapeHtml(task.error)}</pre>
|
||
`;
|
||
} else {
|
||
// Try to read from log file
|
||
const logResponse = await fetch(`/read-file?path=tasks/${taskId}.log`);
|
||
if (logResponse.ok) {
|
||
const logContent = await logResponse.text();
|
||
if (logContent.trim()) {
|
||
outputElement.innerHTML = `
|
||
<pre class="output-content terminal-style">${this.taskManager.parseAnsiColors(logContent)}</pre>
|
||
`;
|
||
} else {
|
||
outputElement.innerHTML = `
|
||
<h4>ℹ️ Information</h4>
|
||
<div class="info-content">No output available for this task.</div>
|
||
`;
|
||
}
|
||
} else {
|
||
outputElement.innerHTML = `
|
||
<h4>ℹ️ Information</h4>
|
||
<div class="info-content">No output available for this task.</div>
|
||
`;
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error(`❌ Error loading task output for ${taskId}:`, error);
|
||
const outputElement = document.querySelector(`[data-task-id="${taskId}"] .task-output`);
|
||
if (outputElement) {
|
||
outputElement.innerHTML = `
|
||
<h4>❌ Error</h4>
|
||
<div class="error-content">Failed to load task output: ${error.message}</div>
|
||
`;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Auto-expand a task when it's created
|
||
async autoExpandTask(taskId) {
|
||
// console.log(`🔄 Auto-expanding task ${taskId}`);
|
||
|
||
// Wait for task to be rendered
|
||
let attempts = 0;
|
||
const maxAttempts = 10;
|
||
|
||
const tryExpand = async () => {
|
||
attempts++;
|
||
// console.log(`🔄 Auto-expand attempt ${attempts}/${maxAttempts} for task ${taskId}`);
|
||
|
||
// Check if task element exists
|
||
const taskElement = document.querySelector(`[data-task-id="${taskId}"]`);
|
||
if (!taskElement) {
|
||
// console.log(`⚠️ Task element not found for ${taskId}, attempt ${attempts}`);
|
||
if (attempts < maxAttempts) {
|
||
setTimeout(tryExpand, 500); // Try again in 500ms
|
||
} else {
|
||
console.warn(`⚠️ Could not find task element for ${taskId} after ${maxAttempts} attempts`);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// console.log(`✅ Found task element for ${taskId}`);
|
||
|
||
// Get the details element
|
||
const details = document.getElementById(`details-${taskId}`);
|
||
if (!details) {
|
||
// console.log(`⚠️ Details element not found for ${taskId}, attempt ${attempts}`);
|
||
if (attempts < maxAttempts) {
|
||
setTimeout(tryExpand, 500);
|
||
} else {
|
||
console.warn(`⚠️ Could not find details element for ${taskId}`);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// console.log(`✅ Found details element for ${taskId}`);
|
||
|
||
// Expand the task details
|
||
details.style.display = 'block';
|
||
details.classList.add('task-details-open');
|
||
|
||
// Update toggle button
|
||
const toggleBtn = document.querySelector(`.task-btn.toggle-details[onclick*="toggleTaskDetails('${taskId}')"]`);
|
||
if (toggleBtn) {
|
||
toggleBtn.classList.add('expanded');
|
||
}
|
||
|
||
// Scroll to the task
|
||
taskElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||
|
||
// Load the output if task is completed
|
||
const task = await this.taskManager.getTask(taskId);
|
||
if (task && (task.status === 'completed' || task.status === 'failed')) {
|
||
setTimeout(() => {
|
||
this.loadTaskOutput(taskId);
|
||
}, 1000);
|
||
}
|
||
|
||
// console.log(`✅ Auto-expanded task ${taskId}`);
|
||
};
|
||
|
||
tryExpand();
|
||
}
|
||
|
||
// Update highlighted task status and UI
|
||
async updateHighlightedTaskStatus(taskId) {
|
||
try {
|
||
// Use lightweight summary for status updates
|
||
const task = await this.taskManager.getTaskSummary(taskId);
|
||
if (!task) return;
|
||
|
||
// console.log(`🔄 Updating highlighted task ${taskId} status: ${task.status}`);
|
||
|
||
// Update task display
|
||
this.updateTaskDisplay(task);
|
||
|
||
// If task completed or failed, always load output
|
||
if ((task.status === 'completed' || task.status === 'failed')) {
|
||
// console.log(`🔄 Task ${taskId} is ${task.status}, loading output...`);
|
||
|
||
const details = document.getElementById(`details-${taskId}`);
|
||
if (details && details.style.display === 'block') {
|
||
// Load output regardless of current content
|
||
this.loadTaskOutput(taskId);
|
||
} else if (details) {
|
||
// If details aren't open, mark for loading when opened
|
||
details.setAttribute('data-load-output-on-open', 'true');
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error(`❌ Error updating highlighted task status for ${taskId}:`, error);
|
||
}
|
||
}
|
||
|
||
toggleTaskDetails(taskId) {
|
||
const details = document.getElementById(`details-${taskId}`);
|
||
const toggleBtn = document.querySelector(`.task-btn.toggle-details[onclick*="toggleTaskDetails('${taskId}')"]`);
|
||
|
||
if (details) {
|
||
const isOpen = details.style.display === 'block';
|
||
|
||
// Close all other task details and reset their buttons
|
||
if (isOpen) {
|
||
document.querySelectorAll('.task-details').forEach(otherDetails => {
|
||
if (otherDetails.id !== `details-${taskId}`) {
|
||
otherDetails.style.display = 'none';
|
||
otherDetails.classList.remove('task-details-open');
|
||
}
|
||
});
|
||
|
||
document.querySelectorAll('.task-btn.toggle-details').forEach(otherBtn => {
|
||
if (!otherBtn.getAttribute('onclick').includes(taskId)) {
|
||
otherBtn.classList.remove('expanded');
|
||
}
|
||
});
|
||
|
||
// Close current
|
||
details.style.display = 'none';
|
||
details.classList.remove('task-details-open');
|
||
if (toggleBtn) toggleBtn.classList.remove('expanded');
|
||
} else {
|
||
// Open current
|
||
details.style.display = 'block';
|
||
details.classList.add('task-details-open');
|
||
if (toggleBtn) toggleBtn.classList.add('expanded');
|
||
|
||
// Auto-load logs when opened. For active tasks, hand off to the live
|
||
// streamer so SSE chunks keep updating the panel; for terminal tasks
|
||
// a one-shot snapshot is enough.
|
||
const t = this.tasks.find(x => x.id === taskId);
|
||
if (t && (t.status === 'running' || t.status === 'queued' || t.status === 'pending')) {
|
||
this.startLogStreaming(taskId, t);
|
||
} else {
|
||
this.loadTaskLogs(taskId);
|
||
}
|
||
|
||
// Scroll to task
|
||
const taskElement = document.querySelector(`[data-task-id="${taskId}"]`);
|
||
if (taskElement) {
|
||
taskElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||
}
|
||
}
|
||
|
||
// Update URL to include task parameter
|
||
this.updateURL(this.currentCategory, isOpen ? null : taskId);
|
||
this.highlightedTaskId = isOpen ? null : taskId;
|
||
} else {
|
||
// Remove task parameter from URL
|
||
this.updateURL(this.currentCategory);
|
||
this.highlightedTaskId = null;
|
||
}
|
||
}
|
||
|
||
async retryTask(taskId) {
|
||
if (!confirm('Are you sure you want to retry this task?')) return;
|
||
|
||
try {
|
||
const task = this.tasks.find(t => t.id === taskId);
|
||
if (!task) {
|
||
throw new Error('Task not found');
|
||
}
|
||
|
||
// Create a new task with the same command
|
||
const newTask = await this.taskManager.createTask(
|
||
task.command,
|
||
task.type,
|
||
task.app,
|
||
task.config
|
||
);
|
||
|
||
//// // console.log(`✅ Task retried: ${newTask.id}`);
|
||
|
||
// Refresh tasks to show the new one
|
||
await this.loadTasks();
|
||
|
||
if (window.notificationSystem) {
|
||
// Use the source task's type icon — retrying a backup shows 💾 etc.
|
||
const typeIcon = this.getTaskTypeIcon ? this.getTaskTypeIcon(task)?.icon : '';
|
||
const customIcon = typeIcon ? `<span style="font-size:18px;line-height:1;">${typeIcon}</span>` : null;
|
||
window.notificationSystem.show('Task retried successfully', 'success', null, null, null, customIcon);
|
||
}
|
||
} catch (error) {
|
||
console.error('Error retrying task:', error);
|
||
if (window.notificationSystem) {
|
||
window.notificationSystem.error(`Failed to retry task: ${error.message}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
async deleteTask(taskId) {
|
||
// Get the latest known status before deciding what to do. The server
|
||
// refuses to delete tasks that are still running or queued (HTTP 409),
|
||
// and we used to just propagate that as a red banner — but the user is
|
||
// almost always trying to clean up a row that looked finished, or a
|
||
// genuinely active task they want gone. Either way the right answer is
|
||
// "cancel first, then delete", which is what we do here.
|
||
const cached = (window.taskEventBus && window.taskEventBus.getTask)
|
||
? window.taskEventBus.getTask(taskId)
|
||
: null;
|
||
let task = cached || (this.tasks && this.tasks.find(t => t.id === taskId));
|
||
if (!task && this.taskManager && this.taskManager.getTask) {
|
||
try { task = await this.taskManager.getTask(taskId); } catch {}
|
||
}
|
||
|
||
const isActive = task && (task.status === 'running' || task.status === 'queued' || task.status === 'pending');
|
||
|
||
const confirmed = await this._showDeleteTaskModal(task, isActive);
|
||
if (!confirmed) return;
|
||
|
||
try {
|
||
if (isActive) {
|
||
// Ask the server to cancel. POST /cancel either flips status straight
|
||
// to `cancelled` (queued -> cancelled is synchronous) or drops a
|
||
// `<id>.cancel` marker the bash processor picks up within ~1s
|
||
// (running -> cancelled). We then wait for the terminal status via
|
||
// SSE before issuing the delete.
|
||
try { await this.taskManager.cancelTask(taskId); } catch {
|
||
// If cancel itself failed (e.g. already terminal), fall through
|
||
// and try the delete anyway.
|
||
}
|
||
await this._waitForTaskTerminal(taskId, 15_000);
|
||
}
|
||
|
||
// Try the polite delete first. If the task is still flagged as
|
||
// running/queued server-side (e.g. the bash processor is dead and
|
||
// never picked up the cancel marker), fall back to the force-delete
|
||
// override so the user isn't stuck with a permanently-undeletable row.
|
||
try {
|
||
await this.taskManager.deleteTask(taskId);
|
||
} catch (err) {
|
||
const looks409 = /\bHTTP 409\b/.test(err && err.message ? err.message : '');
|
||
if (!looks409) throw err;
|
||
await this.taskManager.deleteTask(taskId, { force: true });
|
||
}
|
||
|
||
// Remove from local array and re-render
|
||
this.tasks = this.tasks.filter(t => t.id !== taskId);
|
||
this.renderTasks();
|
||
this.updateStats();
|
||
this.updateSidebarCounts();
|
||
this.generateAppCategories();
|
||
|
||
if (window.notificationSystem) {
|
||
// Type icon from whatever the task was, with a trash fallback
|
||
// because deletion is conceptually a 🗑️ action.
|
||
const typeIcon = (task && this.getTaskTypeIcon ? this.getTaskTypeIcon(task)?.icon : '') || '🗑️';
|
||
const customIcon = `<span style="font-size:18px;line-height:1;">${typeIcon}</span>`;
|
||
window.notificationSystem.show('Task deleted successfully', 'info', null, null, null, customIcon);
|
||
}
|
||
} catch (error) {
|
||
console.error('Error deleting task:', error);
|
||
if (window.notificationSystem) {
|
||
window.notificationSystem.error(`Failed to delete task: ${error.message}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Confirmation modal for deleteTask. Uses the shared openEoModal so it
|
||
// visually matches every other destructive confirmation on the app
|
||
// (Uninstall, Apply Configuration, etc). Resolves true if the user
|
||
// confirms Delete, false on Cancel / backdrop click / close.
|
||
_showDeleteTaskModal(task, isActive) {
|
||
return new Promise((resolve) => {
|
||
const escHtml = (s) => String(s == null ? '' : s)
|
||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
|
||
// Build a friendly title + app icon so the modal reads "Install Ipinfo"
|
||
// with the app's logo, not the raw "libreportal app install ipinfo"
|
||
// command. Mirrors the completion-notification flow above so the
|
||
// visual identity of a task is consistent across surfaces.
|
||
const appName = (task && task.app) || null;
|
||
const action = (task && task.type) || '';
|
||
const isSystemTask = action.startsWith('setup-') || appName === 'system';
|
||
let actionTitle = this.formatActionTitle(action);
|
||
// Tool tasks: prefer the tool's curated label.
|
||
const toolCmdMatch = (task && task.command || '').match(/libreportal app tool (\S+) (\S+)/);
|
||
if (toolCmdMatch) {
|
||
const toolId = toolCmdMatch[2];
|
||
let toolLabel = null;
|
||
const cat = window.toolsCatalog;
|
||
if (cat && cat.apps && cat.apps[toolCmdMatch[1]] && Array.isArray(cat.apps[toolCmdMatch[1]].tools)) {
|
||
const t = cat.apps[toolCmdMatch[1]].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 displayName = isSystemTask
|
||
? ''
|
||
: ((appName && window.getAppDisplayName) ? window.getAppDisplayName(appName) : (appName || ''));
|
||
const taskLabel = displayName
|
||
? `${actionTitle} ${displayName}`
|
||
: (actionTitle || (task && task.id) || 'Unknown task');
|
||
const taskIcon = isSystemTask
|
||
? '/icons/libreportal.svg'
|
||
: (appName ? `/icons/apps/${encodeURIComponent(appName)}.svg` : null);
|
||
const taskStatus = (task && task.status) || 'unknown';
|
||
|
||
const warningTitle = isActive ? 'Active task' : 'This cannot be undone';
|
||
const warningText = isActive
|
||
? 'This task is still running or queued. It will be cancelled first, then deleted.'
|
||
: 'The task and its logs will be permanently removed.';
|
||
|
||
const bodyHtml = `
|
||
<div class="eo-empty-state danger" role="status">
|
||
<div class="eo-empty-state-icon">
|
||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
|
||
<line x1="12" y1="9" x2="12" y2="13"></line>
|
||
<line x1="12" y1="17" x2="12.01" y2="17"></line>
|
||
</svg>
|
||
</div>
|
||
<div class="eo-empty-state-body">
|
||
<p class="eo-empty-state-title">${escHtml(warningTitle)}</p>
|
||
<p class="eo-empty-state-text">${escHtml(warningText)}</p>
|
||
</div>
|
||
</div>
|
||
${window.eoBadgeRow ? window.eoBadgeRow([
|
||
{ label: `Status: ${taskStatus}`, variant: isActive ? 'warning' : 'info' }
|
||
]) : ''}
|
||
`;
|
||
|
||
let decided = false;
|
||
const finish = (val, modal) => {
|
||
if (decided) return;
|
||
decided = true;
|
||
if (modal) modal.close();
|
||
resolve(val);
|
||
};
|
||
|
||
window.openEoModal({
|
||
id: 'delete-task-modal',
|
||
size: 'sm',
|
||
icon: taskIcon || undefined,
|
||
iconAlt: displayName || 'Task',
|
||
eyebrow: 'Delete Task',
|
||
title: taskLabel,
|
||
desc: 'Confirm to delete this task.',
|
||
body: bodyHtml,
|
||
actions: [
|
||
{ label: 'Delete Task', variant: 'danger', onClick: (m) => finish(true, m) },
|
||
{ label: 'Cancel', variant: 'secondary', onClick: (m) => finish(false, m) }
|
||
],
|
||
onClose: () => finish(false, null)
|
||
});
|
||
});
|
||
}
|
||
|
||
// 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;
|