librelad b4649cd713 refactor(webui): relocate tasks page + shared task kernel
- features/tasks/: tasks-manager.js (the /tasks page controller) + tasks.css.
- shared/task/: the 6 cross-cutting task-kernel files (event-bus, commands,
  actions, router, global-functions, manager) + task-refresh-coordinator.js —
  used by tasks AND apps/app-detail/backup, so they go to shared/, not a
  feature. task-parameter-preserve.js stays at js/ (shared root).
- Updated all path strings: system-loader.js task-system + apps-manager
  components, apps-manager loadTaskSystem(), index.html (refresh-coordinator +
  tasks.css). Globals (taskEventBus/tasksManager/TaskManager/...) unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-30 02:00:59 +01:00

179 lines
6.1 KiB
JavaScript
Executable File

/**
* TaskManager — thin client over /api/tasks.
*
* Replaces the old direct-file-operation approach. We never touch task files
* or the queue from the browser anymore — the server owns them. State updates
* arrive via SSE through TaskEventBus; this class just performs CRUD.
*/
class TaskManager {
constructor() {}
/**
* Create a new task. Returns the server's authoritative task object.
* Side effect: a `taskCreated` window event will fire from the SSE bus
* within milliseconds — but the POST response also contains the task,
* so callers that need the id immediately can use the return value.
*/
async createTask(command, type = 'custom', app = null, config = '') {
const res = await fetch('/api/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command, type, app, config })
});
if (!res.ok) throw new Error(`Failed to create task: HTTP ${res.status}`);
const task = await res.json();
if (window.tasksManager) window.tasksManager.highlightedTaskId = task.id;
return task;
}
async getTask(taskId) {
if (window.taskEventBus) {
const cached = window.taskEventBus.getTask(taskId);
if (cached) return cached;
}
const res = await fetch(`/api/tasks/${taskId}`);
if (res.status === 404) return null;
if (!res.ok) return null;
return res.json();
}
async getTaskSummary(taskId) {
const task = await this.getTask(taskId);
if (!task) return null;
return {
id: task.id,
command: task.command,
type: task.type,
app: task.app,
config: task.config,
status: task.status,
createdAt: task.created_at || task.createdAt,
startedAt: task.started_at || task.startedAt,
completedAt: task.completed_at || task.completedAt,
hasOutput: false,
hasError: !!task.error_message,
outputLength: 0,
errorLength: task.error_message ? task.error_message.length : 0
};
}
async listTasks() {
const res = await fetch('/api/tasks');
if (!res.ok) return [];
return res.json();
}
async cancelTask(taskId) {
const res = await fetch(`/api/tasks/${taskId}/cancel`, { method: 'POST' });
if (!res.ok) throw new Error(`Cancel failed: HTTP ${res.status}`);
return res.json();
}
async deleteTask(taskId, { force = false } = {}) {
const url = force
? `/api/tasks/${taskId}?force=1`
: `/api/tasks/${taskId}`;
const res = await fetch(url, { method: 'DELETE' });
if (!res.ok) throw new Error(`Delete failed: HTTP ${res.status}`);
return true;
}
/**
* Read the task log. Returns the last `maxLines` lines as an array.
* The SSE bus delivers incremental log chunks via `taskLog` events; this
* is for "open the modal and dump the full output" use-cases.
*/
async readTaskLog(taskId, maxLines = 1000) {
try {
const res = await fetch(`/api/tasks/${taskId}/log`);
if (!res.ok) return [];
const text = await res.text();
const lines = text.split('\n');
return lines.length > maxLines ? lines.slice(-maxLines) : lines;
} catch { return []; }
}
async readFullTaskLog(taskId) {
try {
const res = await fetch(`/api/tasks/${taskId}/log`);
if (!res.ok) return '';
return res.text();
} catch { return ''; }
}
/**
* Lightweight log streamer for callers that want a callback per chunk.
* Backed by SSE: registers a window listener for `taskLog` events that
* match the requested taskId.
*/
streamTaskLog(taskId, onNewLines, onError) {
let stopped = false;
const handler = (event) => {
if (stopped) return;
const detail = event.detail || {};
if (detail.id !== taskId || typeof detail.chunk !== 'string') return;
try {
const lines = detail.chunk.split('\n').filter(l => l.length > 0);
if (lines.length > 0) onNewLines(lines);
} catch (err) { onError && onError(err); }
};
window.addEventListener('taskLog', handler);
return {
stop: () => { stopped = true; window.removeEventListener('taskLog', handler); },
isStreaming: () => !stopped
};
}
/**
* ANSI -> HTML colour parser (kept from the previous TaskManager so log
* rendering doesn't lose formatting).
*/
parseAnsiColors(text) {
const ansiRegex = /\x1b\[[0-9;]*m/g;
const colorMap = {
'30':'black','31':'red','32':'green','33':'yellow','34':'blue','35':'magenta','36':'cyan','37':'white',
'90':'darkgray','91':'lightred','92':'lightgreen','93':'lightyellow','94':'lightblue','95':'lightmagenta','96':'lightcyan','97':'lightwhite'
};
const bgColorMap = {
'40':'black','41':'red','42':'green','43':'yellow','44':'blue','45':'magenta','46':'cyan','47':'white',
'100':'darkgray','101':'lightred','102':'lightgreen','103':'lightyellow','104':'lightblue','105':'lightmagenta','106':'lightcyan','107':'lightwhite'
};
let result = '';
let cursor = 0;
let m;
while ((m = ansiRegex.exec(text)) !== null) {
result += text.substring(cursor, m.index);
const codes = m[0].slice(2, -1).split(';');
const styles = [];
let reset = false;
for (const code of codes) {
if (code === '0' || code === '') reset = true;
else if (code === '1') styles.push('font-weight: bold');
else if (code === '4') styles.push('text-decoration: underline');
else if (colorMap[code]) styles.push(`color: ${colorMap[code]}`);
else if (bgColorMap[code]) styles.push(`background-color: ${bgColorMap[code]}`);
}
if (styles.length) result += `<span style="${styles.join('; ')}">`;
else if (reset) result += '</span>';
cursor = m.index + m[0].length;
}
result += text.substring(cursor);
const open = (result.match(/<span/g) || []).length;
const close = (result.match(/<\/span>/g) || []).length;
result += '</span>'.repeat(Math.max(0, open - close));
return result;
}
generateTaskId() {
return `task_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
}
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = TaskManager;
} else {
window.TaskManager = TaskManager;
}