Compare commits
2 Commits
e0737b65ef
...
3d058b3469
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d058b3469 | ||
|
|
1b0040dbf1 |
@ -0,0 +1,294 @@
|
||||
// Auto-extracted from tasks-manager.js (verbatim) — augments TasksManager.prototype. Loaded after the base.
|
||||
Object.assign(TasksManager.prototype, {
|
||||
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) {
|
||||
// Match the "<App> task <verb>" two-line shape used by started/
|
||||
// completed/failed/cancelled so deletion reads as part of the same
|
||||
// family. Type emoji falls back to 🗑️ since deletion is a 🗑️ action.
|
||||
const { appName, actionTitle, displayName, icon, typeIcon } = this._taskNotificationDescriptor(task);
|
||||
const emoji = typeIcon || '🗑️';
|
||||
const customIcon = `<span style="font-size:18px;line-height:1;">${emoji}</span>`;
|
||||
const body = displayName
|
||||
? `<strong>${displayName}</strong><br>${actionTitle} task deleted.`
|
||||
: `${actionTitle || 'Task'} deleted.`;
|
||||
window.notificationSystem.show(body, 'info', appName, null, icon, customIcon);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting task:', error);
|
||||
if (window.notificationSystem) {
|
||||
window.notificationSystem.error(`Failed to delete task: ${error.message}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
async clearAllTasks() {
|
||||
// Routes to one of two modes depending on whether any rows are ticked.
|
||||
// Both paths share _showClearAllModal — same UX, same modal, different
|
||||
// input list + title.
|
||||
const hasSelection = this.selectedTaskIds.size > 0;
|
||||
const targetTasks = hasSelection
|
||||
? this.tasks.filter(t => this.selectedTaskIds.has(t.id))
|
||||
: this.tasks;
|
||||
const result = await this._showClearAllModal(targetTasks, hasSelection ? 'selected' : 'all');
|
||||
if (!result || !result.confirmed) return false;
|
||||
await this.performClearAll({ cancelRunning: result.cancelRunning, targets: targetTasks });
|
||||
if (hasSelection) {
|
||||
// Drop any ids we just deleted from the selection set, then refresh
|
||||
// the button label + master-checkbox state.
|
||||
this.selectedTaskIds = new Set([...this.selectedTaskIds].filter(id => this.tasks.find(t => t.id === id)));
|
||||
this._updateSelectionUI();
|
||||
}
|
||||
return true;
|
||||
},
|
||||
async performClearAll(opts) {
|
||||
opts = opts || {};
|
||||
const cancelRunning = !!opts.cancelRunning;
|
||||
// Caller (clearAllTasks) passes the subset to operate on. Falls back
|
||||
// to this.tasks for backward compatibility — old direct callers
|
||||
// continue to mean "everything".
|
||||
const universe = Array.isArray(opts.targets) ? opts.targets : (this.tasks || []);
|
||||
|
||||
// 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 tasks...',
|
||||
'info',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
customIcon
|
||||
);
|
||||
|
||||
const isActive = (t) => t && (t.status === 'running' || t.status === 'queued' || t.status === 'pending');
|
||||
// Partition: which tasks we attempt now, which we skip silently.
|
||||
const targets = universe.filter(t => cancelRunning || !isActive(t));
|
||||
const skipped = universe.filter(t => !targets.includes(t));
|
||||
|
||||
try {
|
||||
// For active tasks (only reached when the toggle is on): cancel first,
|
||||
// wait for terminal status, then delete. Mirrors the single-row
|
||||
// deleteTask flow so the bash processor has a chance to honour the
|
||||
// cancel marker before the DELETE arrives (otherwise we'd 409).
|
||||
await Promise.all(targets.map(async (task) => {
|
||||
if (cancelRunning && isActive(task)) {
|
||||
try { await this.taskManager.cancelTask(task.id); } catch {}
|
||||
await this._waitForTaskTerminal(task.id, 15_000);
|
||||
}
|
||||
try {
|
||||
await this.taskManager.deleteTask(task.id);
|
||||
} catch (err) {
|
||||
const looks409 = /\bHTTP 409\b/.test(err && err.message ? err.message : '');
|
||||
if (!looks409) throw err;
|
||||
await this.taskManager.deleteTask(task.id, { force: true });
|
||||
}
|
||||
}));
|
||||
|
||||
// Re-sync local state: keep anything we intentionally skipped.
|
||||
const deletedIds = new Set(targets.map(t => t.id));
|
||||
this.tasks = this.tasks.filter(t => !deletedIds.has(t.id));
|
||||
this.renderTasks();
|
||||
this.updateStats();
|
||||
this.updateSidebarCounts();
|
||||
this.generateAppCategories();
|
||||
|
||||
if (progressNotification && progressNotification.remove) {
|
||||
progressNotification.remove();
|
||||
}
|
||||
|
||||
if (window.notificationSystem) {
|
||||
const msg = skipped.length > 0
|
||||
? `Deleted ${targets.length} tasks (skipped ${skipped.length} still running)`
|
||||
: (targets.length === 1 ? 'Task deleted' : `Deleted ${targets.length} tasks`);
|
||||
window.notificationSystem.show(msg, 'success', null, null, null, customIcon);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error clearing tasks:', error);
|
||||
|
||||
if (progressNotification && progressNotification.remove) {
|
||||
progressNotification.remove();
|
||||
}
|
||||
|
||||
if (window.notificationSystem) {
|
||||
window.notificationSystem.show(
|
||||
`Failed to clear tasks: ${error.message}`,
|
||||
'error',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
customIcon
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
// Selection helpers wired by the row + master checkboxes.
|
||||
toggleTaskSelection(taskId, checked) {
|
||||
if (checked) this.selectedTaskIds.add(taskId);
|
||||
else this.selectedTaskIds.delete(taskId);
|
||||
this._updateSelectionUI();
|
||||
},
|
||||
toggleSelectAll(checked) {
|
||||
if (checked) {
|
||||
// Tick every CURRENTLY VISIBLE row. We use the rendered checkboxes
|
||||
// rather than this.tasks so category-filtered views only select
|
||||
// what the user can see.
|
||||
const boxes = document.querySelectorAll('#tasks-list [data-task-select]');
|
||||
boxes.forEach((cb) => {
|
||||
this.selectedTaskIds.add(cb.dataset.taskSelect);
|
||||
cb.checked = true;
|
||||
});
|
||||
} else {
|
||||
this.selectedTaskIds.clear();
|
||||
document.querySelectorAll('#tasks-list [data-task-select]').forEach((cb) => { cb.checked = false; });
|
||||
}
|
||||
this._updateSelectionUI();
|
||||
},
|
||||
// Rewrites the Clear All button label + the master-checkbox indeterminate
|
||||
// state to reflect the current selection. Cheap — only touches a few
|
||||
// DOM nodes, safe to call from any selection-change path.
|
||||
_updateSelectionUI() {
|
||||
const n = this.selectedTaskIds.size;
|
||||
const btn = document.getElementById('tasks-clear-btn');
|
||||
const btnLabel = btn && btn.querySelector('.clear-btn-label');
|
||||
if (btnLabel) btnLabel.textContent = n > 0 ? `Delete Selected (${n})` : 'Clear All';
|
||||
if (btn) btn.title = n > 0 ? `Delete ${n} selected task${n === 1 ? '' : 's'}` : 'Clear All Tasks';
|
||||
|
||||
// Master checkbox: checked when ALL visible are picked, indeterminate
|
||||
// when SOME are, unchecked when none.
|
||||
const master = document.getElementById('tasks-select-all');
|
||||
const visible = document.querySelectorAll('#tasks-list [data-task-select]');
|
||||
if (master) {
|
||||
if (n === 0 || visible.length === 0) {
|
||||
master.checked = false;
|
||||
master.indeterminate = false;
|
||||
} else if (n >= visible.length) {
|
||||
master.checked = true;
|
||||
master.indeterminate = false;
|
||||
} else {
|
||||
master.checked = false;
|
||||
master.indeterminate = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
// 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);
|
||||
});
|
||||
},
|
||||
});
|
||||
@ -0,0 +1,217 @@
|
||||
// Auto-extracted from tasks-manager.js (verbatim) — augments TasksManager.prototype. Loaded after the base.
|
||||
Object.assign(TasksManager.prototype, {
|
||||
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);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
@ -0,0 +1,188 @@
|
||||
// Auto-extracted from tasks-manager.js (verbatim) — augments TasksManager.prototype. Loaded after the base.
|
||||
Object.assign(TasksManager.prototype, {
|
||||
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' },
|
||||
|
||||
// -- System maintenance ------------------------------------------------
|
||||
{ match: /^libreportal system reclaim\b/, title: 'LibrePortal - Reclaim Space' },
|
||||
{ match: /^libreportal system image rm\b/, title: 'LibrePortal - Remove Images' },
|
||||
{ match: /^libreportal verify\b/, title: 'LibrePortal - Verify System' },
|
||||
|
||||
// -- 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 Configs' },
|
||||
{ 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;
|
||||
},
|
||||
// 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', 'system_reclaim': 'Reclaim Space',
|
||||
'system_image_rm': 'Remove Images', 'verify': 'Verify 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(' ');
|
||||
},
|
||||
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);
|
||||
},
|
||||
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`;
|
||||
},
|
||||
extractTimestamp(logEntry) {
|
||||
const match = logEntry.match(/\[([^\]]+)\]/);
|
||||
return match ? match[1] : '';
|
||||
},
|
||||
extractLogMessage(logEntry) {
|
||||
const match = logEntry.match(/\] (.+)$/);
|
||||
return match ? match[1] : logEntry;
|
||||
},
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
},
|
||||
});
|
||||
@ -0,0 +1,512 @@
|
||||
// Auto-extracted from tasks-manager.js (verbatim) — augments TasksManager.prototype. Loaded after the base.
|
||||
Object.assign(TasksManager.prototype, {
|
||||
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 {
|
||||
// Category display names sometimes already end in "Tasks" (e.g.
|
||||
// "All Tasks", "Running Tasks") — naive interpolation produced
|
||||
// "No all tasks tasks found". Strip the trailing word when
|
||||
// present, and special-case the "all" bucket to read naturally.
|
||||
const catLow = this.getCategoryDisplayName(this.currentCategory).toLowerCase();
|
||||
if (catLow === 'all tasks' || catLow === 'all') {
|
||||
message = 'No tasks found';
|
||||
} else if (catLow.endsWith(' tasks')) {
|
||||
message = `No ${catLow} found`;
|
||||
} else {
|
||||
message = `No ${catLow} 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; });
|
||||
}
|
||||
|
||||
// Resync the multi-select UI after the render. The Clear All label
|
||||
// + master-checkbox tri-state need to reflect the just-rendered row
|
||||
// set (selections can become stale across category switches).
|
||||
if (typeof this._updateSelectionUI === 'function') this._updateSelectionUI();
|
||||
},
|
||||
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>
|
||||
<label class="task-select" onclick="event.stopPropagation();" title="Select for bulk delete">
|
||||
<input type="checkbox" data-task-select="${task.id}" ${this.selectedTaskIds.has(task.id) ? 'checked' : ''}
|
||||
onchange="event.stopPropagation(); window.tasksManager && tasksManager.toggleTaskSelection('${task.id}', this.checked)">
|
||||
<span class="task-select-box" aria-hidden="true"></span>
|
||||
</label>
|
||||
</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>
|
||||
`;
|
||||
},
|
||||
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 /core/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="/core/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 `/core/icons/apps/${task.app}.svg`;
|
||||
},
|
||||
isLibrePortalSystemTask(task) {
|
||||
if (!task || !task.command || task.app) return false;
|
||||
return /^libreportal (setup|backup\s+all|backup\s+system|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|verify|system\s+(reclaim|image))\b/.test(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' },
|
||||
'system_image_rm': { icon: '🗑️', class: 'delete' },
|
||||
'verify': { icon: '🛡️', class: 'verify' },
|
||||
'setup-config': { icon: '🛠️', class: 'setup' },
|
||||
'setup-finalize': { icon: '🎉', class: 'setup' },
|
||||
'custom': { icon: '⚙️', class: 'custom' }
|
||||
};
|
||||
|
||||
return iconMap[task.type] || iconMap['custom'];
|
||||
},
|
||||
getStatusIcon(status) {
|
||||
const icons = {
|
||||
queued: '⏳',
|
||||
running: '🔄',
|
||||
completed: '✅',
|
||||
failed: '❌'
|
||||
};
|
||||
return icons[status] || '📋';
|
||||
},
|
||||
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;
|
||||
}
|
||||
},
|
||||
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();
|
||||
},
|
||||
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>
|
||||
`;
|
||||
}
|
||||
},
|
||||
});
|
||||
@ -0,0 +1,372 @@
|
||||
// Auto-extracted from tasks-manager.js (verbatim) — augments TasksManager.prototype. Loaded after the base.
|
||||
Object.assign(TasksManager.prototype, {
|
||||
// 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}`);
|
||||
}
|
||||
},
|
||||
// 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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
},
|
||||
// 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>';
|
||||
}
|
||||
},
|
||||
// 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}`);
|
||||
},
|
||||
// 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);
|
||||
}
|
||||
} ,
|
||||
});
|
||||
@ -0,0 +1,104 @@
|
||||
// Auto-extracted from tasks-manager.js (verbatim) — augments TasksManager.prototype. Loaded after the base.
|
||||
Object.assign(TasksManager.prototype, {
|
||||
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();
|
||||
},
|
||||
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';
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,177 @@
|
||||
// Auto-extracted from tasks-manager.js (verbatim) — augments TasksManager.prototype. Loaded after the base.
|
||||
Object.assign(TasksManager.prototype, {
|
||||
// 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, '>');
|
||||
|
||||
// Friendly title + app icon — mirrors the completion-toast format so
|
||||
// the modal's identity matches every other surface.
|
||||
const { isSystemTask, actionTitle, displayName, icon: taskIcon } = this._taskNotificationDescriptor(task);
|
||||
// For the modal we omit the "LibrePortal" subject (the eyebrow already
|
||||
// says "Delete Task") and just lead with the action: "Update Config".
|
||||
const modalSubject = isSystemTask ? '' : displayName;
|
||||
const taskLabel = modalSubject
|
||||
? `${actionTitle} ${modalSubject}`
|
||||
: (actionTitle || (task && task.id) || 'Unknown task');
|
||||
const taskStatus = (task && task.status) || 'unknown';
|
||||
|
||||
const warningTitle = isActive ? 'Active task' : 'This cannot be undone';
|
||||
const warningText = isActive
|
||||
? 'This task is still running or queued. It will be cancelled first, then deleted.'
|
||||
: 'The task and its logs will be permanently removed.';
|
||||
|
||||
const bodyHtml = `
|
||||
<div class="eo-empty-state danger" role="status">
|
||||
<div class="eo-empty-state-icon">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
|
||||
<line x1="12" y1="9" x2="12" y2="13"></line>
|
||||
<line x1="12" y1="17" x2="12.01" y2="17"></line>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="eo-empty-state-body">
|
||||
<p class="eo-empty-state-title">${escHtml(warningTitle)}</p>
|
||||
<p class="eo-empty-state-text">${escHtml(warningText)}</p>
|
||||
</div>
|
||||
</div>
|
||||
${window.eoBadgeRow ? window.eoBadgeRow([
|
||||
{ label: `Status: ${taskStatus}`, variant: isActive ? 'warning' : 'info' }
|
||||
]) : ''}
|
||||
`;
|
||||
|
||||
let decided = false;
|
||||
const finish = (val, modal) => {
|
||||
if (decided) return;
|
||||
decided = true;
|
||||
if (modal) modal.close();
|
||||
resolve(val);
|
||||
};
|
||||
|
||||
window.openEoModal({
|
||||
id: 'delete-task-modal',
|
||||
size: 'sm',
|
||||
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)
|
||||
});
|
||||
});
|
||||
},
|
||||
// Confirmation modal for clearAllTasks. Same openEoModal shape as
|
||||
// _showDeleteTaskModal so visual identity matches every other destructive
|
||||
// confirmation (Uninstall, Delete Task, …). Adds a "Cancel running tasks
|
||||
// too" toggle (off by default) — when off, running/queued tasks are
|
||||
// skipped; when on, they're cancelled first then deleted.
|
||||
// Resolves {confirmed, cancelRunning}. Cancel/backdrop/close → confirmed=false.
|
||||
// mode: 'all' (everything) or 'selected' (user-picked subset) — just
|
||||
// shapes the title/copy.
|
||||
_showClearAllModal(tasks, mode) {
|
||||
return new Promise((resolve) => {
|
||||
const escHtml = (s) => String(s == null ? '' : s)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
const isActive = (t) => t && (t.status === 'running' || t.status === 'queued' || t.status === 'pending');
|
||||
const total = (tasks || []).length;
|
||||
const runningCount = (tasks || []).filter(isActive).length;
|
||||
const terminalCount = total - runningCount;
|
||||
|
||||
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">This cannot be undone</p>
|
||||
<p class="eo-empty-state-text">All selected tasks and their logs will be permanently removed.</p>
|
||||
</div>
|
||||
</div>
|
||||
${window.eoBadgeRow ? window.eoBadgeRow([
|
||||
{ label: `Total: ${total}`, variant: 'info' },
|
||||
...(runningCount > 0 ? [{ label: `Running: ${runningCount}`, variant: 'warning' }] : []),
|
||||
...(terminalCount > 0 ? [{ label: `Terminal: ${terminalCount}`, variant: 'success' }] : []),
|
||||
]) : ''}
|
||||
${runningCount > 0 ? `
|
||||
<label class="eo-toggle eo-toggle-card" data-eo-toggle-row>
|
||||
<input type="checkbox" id="clear-all-cancel-running">
|
||||
<span class="eo-toggle-track"></span>
|
||||
<span class="eo-toggle-text">
|
||||
<span class="eo-toggle-text-title">Cancel running tasks too</span>
|
||||
<span class="eo-toggle-text-help">Off: skip the ${runningCount} active task${runningCount === 1 ? '' : 's'}. On: cancel them first, then delete.</span>
|
||||
</span>
|
||||
</label>
|
||||
` : ''}
|
||||
`;
|
||||
|
||||
let decided = false;
|
||||
const finish = (val, modal) => {
|
||||
if (decided) return;
|
||||
decided = true;
|
||||
if (modal) modal.close();
|
||||
resolve(val);
|
||||
};
|
||||
|
||||
// Title shape depends on which path got us here:
|
||||
// selected → "Delete N selected task(s)?" (multi-select chip)
|
||||
// all → "Delete all N tasks?" (legacy Clear All)
|
||||
const isSelectedMode = mode === 'selected';
|
||||
const titleText = isSelectedMode
|
||||
? (total === 1 ? 'Delete 1 selected task?' : `Delete ${total} selected tasks?`)
|
||||
: (total === 1 ? 'Delete 1 task?' : `Delete all ${total} tasks?`);
|
||||
const m = window.openEoModal({
|
||||
id: 'clear-all-tasks-modal',
|
||||
size: 'sm',
|
||||
eyebrow: isSelectedMode ? 'Delete Selected' : 'Delete Tasks',
|
||||
title: titleText,
|
||||
desc: isSelectedMode
|
||||
? 'Confirm to delete the ticked tasks.'
|
||||
: 'Confirm to delete every task on the list.',
|
||||
body: bodyHtml,
|
||||
actions: [
|
||||
{
|
||||
label: 'Delete',
|
||||
variant: 'danger',
|
||||
onClick: (modal) => {
|
||||
const cb = modal.bodyEl.querySelector('#clear-all-cancel-running');
|
||||
finish({ confirmed: true, cancelRunning: !!(cb && cb.checked) }, modal);
|
||||
}
|
||||
},
|
||||
{ label: 'Cancel', variant: 'secondary', onClick: (modal) => finish({ confirmed: false }, modal) }
|
||||
],
|
||||
onClose: () => finish({ confirmed: false }, null)
|
||||
});
|
||||
|
||||
// Live-update the danger button's label so the user knows exactly
|
||||
// what will happen as they flip the toggle. No-op when no running
|
||||
// tasks (no toggle in the body in that case).
|
||||
const cb = m.bodyEl.querySelector('#clear-all-cancel-running');
|
||||
const deleteBtn = m.contentEl.querySelector('.btn-danger');
|
||||
const updateLabel = () => {
|
||||
if (!deleteBtn) return;
|
||||
const cancelRunning = !!(cb && cb.checked);
|
||||
if (runningCount > 0 && !cancelRunning) {
|
||||
deleteBtn.textContent = `Delete ${terminalCount} (skip ${runningCount} running)`;
|
||||
} else {
|
||||
deleteBtn.textContent = total === 1 ? 'Delete Task' : `Delete ${total} Tasks`;
|
||||
}
|
||||
};
|
||||
if (cb) cb.addEventListener('change', updateLabel);
|
||||
updateLabel();
|
||||
});
|
||||
},
|
||||
});
|
||||
@ -0,0 +1,166 @@
|
||||
// Auto-extracted from tasks-manager.js (verbatim) — augments TasksManager.prototype. Loaded after the base.
|
||||
Object.assign(TasksManager.prototype, {
|
||||
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;
|
||||
}
|
||||
},
|
||||
// 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);
|
||||
},
|
||||
// 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();
|
||||
},
|
||||
});
|
||||
@ -174,7 +174,16 @@ class SystemLoader {
|
||||
'/core/lib/task-global-functions.js',
|
||||
'/core/lib/task-manager.js',
|
||||
'/core/lib/task-parameter-preserve.js',
|
||||
'/components/tasks/js/tasks-manager.js'
|
||||
'/components/tasks/js/tasks-manager.js', // base: class + constructor + init + bus wiring
|
||||
// prototype-augment clusters (load after base; ordered via async=false):
|
||||
'/components/tasks/js/tasks-format.js',
|
||||
'/components/tasks/js/tasks-list-render.js',
|
||||
'/components/tasks/js/tasks-data-load.js',
|
||||
'/components/tasks/js/tasks-log-stream.js',
|
||||
'/components/tasks/js/tasks-row-expand.js',
|
||||
'/components/tasks/js/tasks-actions.js',
|
||||
'/components/tasks/js/tasks-modals.js',
|
||||
'/components/tasks/js/tasks-logs-modal.js'
|
||||
]
|
||||
});
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user