Running a tool from the Tools tab raised two full-size toasts around a few seconds of work — "task started!" as the run began and "task completed!" as it ended — and then opened the result modal that actually carried the answer. The started one was stale by the time it was read and the finished one said what the modal was already showing. Tool tasks now go through LP_BACKGROUND_TASKS with a new `silent` flag (no started toast, no finish line), and tools-manager raises a compact "Running <tool>…" spinner toast for the duration instead, dismissed the moment the result modal or the user list opens. A failed list_users now falls through to the result modal too — it has no account list to open, and the completion toast that used to report the failure is gone.
528 lines
20 KiB
JavaScript
Executable File
528 lines
20 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;
|
|
// When true, the open row tracks whichever task is currently running
|
|
// (queue handoffs, setup installs). Cleared the moment the user manually
|
|
// toggles a row or switches category — their selection then wins.
|
|
this.followRunning = false;
|
|
// 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;
|
|
// Any task with no app slug (verify, regen, update, …) is a LibrePortal /
|
|
// system task. Without this it resolves to a blank subject + no icon, so
|
|
// its notification renders an empty bold line above the message (gap) and
|
|
// looks unlike the app/system notifications.
|
|
const isSystemTask = action.startsWith('setup-') || appName === 'system' || !!sysBackup || !appName;
|
|
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 + single-task deep link from the path (/tasks/<category>/<id>),
|
|
// with legacy ?=<category> and ?task=<id> queries still honoured.
|
|
const parts = (typeof window.taskPartsFromPath === 'function')
|
|
? window.taskPartsFromPath(currentUrl.pathname, currentUrl.search)
|
|
: { category: '', taskId: searchParams.get('task') || '' };
|
|
this.currentCategory = parts.category || searchParams.get('') || 'all';
|
|
this.highlightedTaskId = parts.taskId || 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. The task deep link is a
|
|
// path segment (/tasks/<category>/<id>) — see window.taskPath.
|
|
const newURL = (typeof window.taskPath === 'function')
|
|
? window.taskPath(category, taskId)
|
|
: `/tasks/${category || 'all'}${taskId ? '/' + String(taskId).replace(/^task_/, '') : ''}`;
|
|
|
|
// 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...');
|
|
|
|
// Re-read the URL on every (re)mount. The SPA reuses this singleton, so a
|
|
// navigation to /tasks/<cat>/<id> must refresh the category + deep-link
|
|
// state — a constructor-only read goes stale after the first visit.
|
|
this.initializeFromURL();
|
|
|
|
// Load initial tasks and refresh sidebar counts
|
|
await this.loadTasks();
|
|
|
|
// Force a refresh to ensure latest data
|
|
await this.loadTasks();
|
|
|
|
// Open the row this visit is about (deep link, or the live task) now that
|
|
// the first render is in the DOM, and arm running-task auto-follow.
|
|
if (typeof this.applyInitialSelection === 'function') this.applyInitialSelection();
|
|
|
|
// 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);
|
|
this.maybeFollowRunningTask(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.maybeFollowRunningTask(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);
|
|
|
|
// Background (self-surfacing) task — e.g. the updater check repaints its
|
|
// own page, so the standard "View Task" completion toast is just noise.
|
|
// Drop it; if the user launched this run by hand (executeTask marked its
|
|
// id pending) show one small plain result line instead. Backend/auto runs
|
|
// aren't pending, so they finish silently.
|
|
const bg = window.LP_BACKGROUND_TASKS;
|
|
if (bg && bg.match(task.type, task.command)) {
|
|
const handLaunched = bg.pending.delete(String(task.id));
|
|
const m = bg.messages(task.type);
|
|
// `silent` actions (app tools) report their own outcome in a modal —
|
|
// even the one-line finish toast would be saying it twice.
|
|
if (!m.silent && handLaunched && (task.status === 'completed' || task.status === 'failed')) {
|
|
window.notificationSystem.show(
|
|
task.status === 'completed' ? m.done : m.fail,
|
|
task.status === 'completed' ? 'success' : 'error'
|
|
);
|
|
}
|
|
// Still re-enable app buttons defensively (a no-op for the updater's
|
|
// pseudo-app), matching the standard path below.
|
|
if (appName && window.appTabbedManager && typeof window.appTabbedManager.enableAppButtons === 'function') {
|
|
try { window.appTabbedManager.enableAppButtons(appName); } catch {}
|
|
}
|
|
return;
|
|
}
|
|
|
|
const onAppPage = window.location.pathname.startsWith('/app') && !window.location.pathname.startsWith('/apps');
|
|
const url = (onAppPage && appName)
|
|
? window.appPath(appName, 'tasks', null, taskId)
|
|
: window.taskPath('all', 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;
|