The shipped frontend carried ~600 muted '// console.…' debug statements (and their multi-line commented continuation lines) left over from development — clutter across 30 files. Removed them with a guarded pass that ONLY ever deletes lines starting with // (so it can never alter behaviour), consuming each commented console opener plus its continuation comment lines until the string-stripped parens balance. 665 lines removed, 30 files; 0 insertions. Verified every deleted line is a // comment (no code touched), real prose comments preserved, full node --check sweep clean. Signed-off-by: librelad <librelad@digitalangels.vip>
488 lines
18 KiB
JavaScript
Executable File
488 lines
18 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;
|
|
// Multi-select: ids of tasks the user has ticked. When non-empty the
|
|
// "Clear All" button morphs into "Delete Selected (N)". Both paths
|
|
// share _showClearAllModal — same UX, different filter.
|
|
this.selectedTaskIds = new Set();
|
|
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');
|
|
}
|
|
|
|
|
|
// Shared "task → notification payload" resolver. Every task surface
|
|
// (started/completed toast, delete modal, delete confirmation) builds
|
|
// the same identity (display name, app icon, friendly action title,
|
|
// emoji type-icon) — keep it in one place so they read consistently.
|
|
_taskNotificationDescriptor(task) {
|
|
const appName = (task && task.app) || null;
|
|
const action = (task && task.type) || '';
|
|
const command = (task && task.command) || '';
|
|
// System-level backups carry no app slug, so they'd otherwise resolve to a
|
|
// blank subject + no icon. Give them the LibrePortal identity and a
|
|
// distinct subject ("Configs" vs the whole-fleet "All apps").
|
|
const sysBackup = command.match(/^libreportal backup (system|all)\b/);
|
|
const sysBackupSubject = sysBackup
|
|
? (sysBackup[1] === 'system' ? 'Configs' : 'All apps')
|
|
: null;
|
|
const isSystemTask = action.startsWith('setup-') || appName === 'system' || !!sysBackup;
|
|
let actionTitle = this.formatActionTitle(action);
|
|
// Tool tasks: prefer the catalog-defined 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 = sysBackupSubject
|
|
? sysBackupSubject
|
|
: (isSystemTask
|
|
? 'LibrePortal'
|
|
: ((appName && window.getAppDisplayName) ? window.getAppDisplayName(appName) : (appName || '')));
|
|
const icon = isSystemTask
|
|
? '/core/icons/libreportal.svg'
|
|
: (appName ? `/core/icons/apps/${encodeURIComponent(appName)}.svg` : null);
|
|
const typeIcon = (this.getTaskTypeIcon ? this.getTaskTypeIcon(task)?.icon : '') || '';
|
|
return { appName, isSystemTask, actionTitle, displayName, icon, typeIcon };
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
|
|
|
|
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) {
|
|
this.highlightedTaskId = taskParam;
|
|
} else {
|
|
// Clear any existing highlighted task when on main tasks page without task param
|
|
this.highlightedTaskId = null;
|
|
}
|
|
} 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
|
|
}
|
|
|
|
}
|
|
|
|
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
|
|
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');
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/* 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. */
|
|
|
|
/* 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. */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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');
|
|
});
|
|
}
|
|
}
|
|
|
|
|
|
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, actionTitle, displayName, icon, typeIcon } = this._taskNotificationDescriptor(task);
|
|
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}`;
|
|
// Per-action emoji (install ✅, backup 💾, restore 📦, …) in the
|
|
// notification's leftmost icon slot, mirroring task-list rows.
|
|
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>`;
|
|
}
|
|
});
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
destroy() {
|
|
if (this.refreshInterval) {
|
|
clearInterval(this.refreshInterval);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Export for use in other modules
|
|
window.TasksManager = TasksManager;
|