librelad 9dace1ed95 feat(tasks): auto-select the running task on the tasks page
Landing on /tasks (directly, via a deep link, or from the setup-wizard
handoff) now opens the row the visit is actually about:

- init() re-reads the URL on every SPA (re)mount, so ?task= deep links
  work after the first visit instead of using constructor-stale state.
- applyInitialSelection() opens the deep-linked task, or — for setup
  handoffs whose first task the queue has already moved past, and for
  plain visits with no deep link — the currently running task (else the
  next queued one).
- The selection then follows the queue: when a new task starts running
  the open panel moves with it, until the user manually toggles a row
  or switches category (their choice then wins for the visit).
- selectTask() is the shared programmatic open: exclusive expand, live
  log stream for active tasks, smooth scroll into view.

Signed-off-by: librelad <librelad@digitalangels.vip>

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 18:27:06 +01:00

503 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;
// 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;
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...');
// Re-read the URL on every (re)mount. The SPA reuses this singleton, so a
// navigation to /tasks/<cat>?task=X 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);
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;