librelad e57d42ddf6 refactor(webui): path-based URLs for app tabs + config sub-tabs
The app-detail page was the last corner of the SPA still using query
parameters for navigation state. Two related complaints surfaced it:

  - `/app/adguard?tab=tasks` should mirror admin (`/admin/tools/peers`,
    `/admin/config/network`) and be `/app/adguard/tasks`.
  - The config sub-tab (general / advanced / features / network / …)
    had no URL representation at all — `showTab` was a pure visual
    swap with no history push, so refreshing a deep config sub-tab
    sent the user back to the default first category.

New URL shape:

  /app/<name>                          → config tab, default sub-tab
  /app/<name>/<tab>                    → non-config main tab (tasks, backups, …)
  /app/<name>/config/<category>        → config tab + specific sub-tab
  …?task=<id>                          → optional deep-link to a single task

Mirrors `adminPath` / `adminCategoryFromPath`. Two new helpers in
spa.js carry the convention:

  window.appPath(name, tab, sub, taskId) → URL
  window.appPartsFromPath(pathname)      → { app, tab, sub }

Every URL constructor in the WebUI was replaced with `window.appPath`:

  spa.js                               — handleAppDetail back-compat redirect
  app-tabbed-manager.js                — getTabFromURL + new getConfigSubFromURL
                                          (path first, ?tab= fallback for legacy)
                                          updateURL + updateApp use appPath
                                          the inline task-deep-link constructor
  apps-manager.js                      — showAppDetail + showAppDetailWithConfig
                                          showTab now pushes /app/<n>/config/<sub>
                                          renderAppDetail picks the sub-tab out of
                                          the URL on first load
                                          4 fallback task-URL constructors
  tasks-manager.js                     — completion-notification URL
  task-actions.js                      — start-notification URL
  notifications.js                     — 2 task deep-link URLs

Back-compat: handleAppDetail detects legacy `?tab=` / `?config=` /
`?task=` queries and replaceState()s the URL to the canonical path
shape BEFORE anything else reads URL state — old bookmarks land on
the right page and end up with a clean URL.

Verified by running every appPath / appPartsFromPath case (including
the `logs` → `tasks` legacy alias) and confirming the round-trip is
identity. JS syntax checks clean on all six files. No remaining
hardcoded `/app/<x>?tab=` strings outside the back-compat comment.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-27 01:40:05 +01:00

359 lines
12 KiB
JavaScript
Executable File

/**
* Task Actions - Action implementations for individual task operations
* Handles app installations, management operations, etc.
*/
class TaskActions {
constructor(tasksManager, commands) {
this.tasksManager = tasksManager;
this.commands = commands;
}
/**
* Install an application
*/
async installApp(appName, config = '', resetNetwork = false) {
try {
this.commands.validateCommand('install', { appName });
if (resetNetwork) {
const parts = ['libreportal', 'app', 'install', appName];
if (config) parts.push(`'${config.replace(/'/g, "'\\''")}'`);
parts.push('--reset-network');
return await this.executeTask('install', appName, parts.join(' '));
}
return await this.executeTask('install', appName, config);
} catch (error) {
throw new Error(`Failed to install ${appName}: ${error.message}`);
}
}
/**
* Uninstall an application
*/
async uninstallApp(appName, deleteImage = false, deleteTasks = false) {
try {
this.commands.validateCommand('uninstall', { appName });
// Build a verbatim command when the user opted into any flag so the
// --delete-images / --delete-tasks switches survive executeTask's
// standard command-builder (which would otherwise quote them as config args).
const flags = [];
if (deleteImage) flags.push('--delete-images');
if (deleteTasks) flags.push('--delete-tasks');
if (flags.length) {
const command = `libreportal app uninstall ${appName} ${flags.join(' ')}`;
return await this.executeTask('uninstall', appName, command);
}
return await this.executeTask('uninstall', appName);
} catch (error) {
throw new Error(`Failed to uninstall ${appName}: ${error.message}`);
}
}
/**
* Restart an application
*/
async restartApp(appName) {
try {
this.commands.validateCommand('restart', { appName });
return await this.executeTask('restart', appName);
} catch (error) {
throw new Error(`Failed to restart ${appName}: ${error.message}`);
}
}
/**
* Start an application
*/
async startApp(appName) {
try {
this.commands.validateCommand('start', { appName });
return await this.executeTask('start', appName);
} catch (error) {
throw new Error(`Failed to start ${appName}: ${error.message}`);
}
}
/**
* Stop an application
*/
async stopApp(appName) {
try {
this.commands.validateCommand('stop', { appName });
return await this.executeTask('stop', appName);
} catch (error) {
throw new Error(`Failed to stop ${appName}: ${error.message}`);
}
}
/**
* Create backup for an application
*/
async backupApp(appName, customPassword = '') {
try {
this.commands.validateCommand('backup', { appName });
return await this.executeTask('backup', appName, customPassword);
} catch (error) {
throw new Error(`Failed to backup ${appName}: ${error.message}`);
}
}
async restoreApp(appName, location, backupFile, password) {
try {
if (!appName) throw new Error('appName is required');
if (!backupFile) throw new Error('backupFile is required');
const parts = ['libreportal', 'app', 'restore', appName];
if (location) parts.push(location);
parts.push(backupFile);
if (password) parts.push(password);
return await this.executeTask('restore', appName, parts.join(' '));
} catch (error) {
throw new Error(`Failed to restore ${appName}: ${error.message}`);
}
}
/**
* Delete backup file for an application
*/
async deleteBackup(appName, backupFile, deleteRemote1 = 'false', deleteRemote2 = 'false') {
try {
// Build the command with all parameters
const command = `libreportal backup app delete ${appName} ${backupFile} ${deleteRemote1} ${deleteRemote2}`;
// Create task directly with the full command
// createTask will handle monitoring
const task = await this.tasksManager.taskManager.createTask(command, 'delete', appName, backupFile);
// Emit taskCreated event for AppTabbedManager to track the task
// This is needed for tab re-enabling on task completion
window.dispatchEvent(new CustomEvent('taskCreated', {
detail: {
taskId: task.id,
appName: appName,
action: 'delete',
timestamp: Date.now()
}
}));
return task;
} catch (error) {
throw new Error(`Failed to delete backup ${backupFile}: ${error.message}`);
}
}
/**
* Delete all backup files for an application
*/
async deleteAllBackups(appName, deleteRemote1 = 'false', deleteRemote2 = 'false') {
try {
// Build the command with all parameters
const command = `libreportal backup app delete_all ${appName} ${deleteRemote1} ${deleteRemote2}`;
// Create task directly with the full command
// createTask will handle monitoring
const task = await this.tasksManager.taskManager.createTask(command, 'delete_all', appName, 'all');
// Emit taskCreated event for AppTabbedManager to track the task
// This is needed for tab re-enabling on task completion
window.dispatchEvent(new CustomEvent('taskCreated', {
detail: {
taskId: task.id,
appName: appName,
action: 'delete_all',
timestamp: Date.now()
}
}));
return task;
} catch (error) {
throw new Error(`Failed to delete all backups for ${appName}: ${error.message}`);
}
}
/**
* Update application configuration
*/
async updateConfig(appName) {
try {
this.commands.validateCommand('update_config', { appName });
await this.executeTask('update_config', appName);
return;
} catch (error) {
throw new Error(`Failed to update config for ${appName}: ${error.message}`);
}
}
async configUpdate(changes) {
try {
if (!changes) throw new Error('No changes to save');
const command = `libreportal config update ${changes}`;
return await this.executeTask('config_update', 'system', command);
} catch (error) {
throw new Error(`Failed to update configuration: ${error.message}`);
}
}
/**
* Run a per-app tool from the Tools tab. Builds:
* libreportal app tool <appName> <toolName> '<argsString>'
* argsString is pipe-encoded: key=value|key=value (matches the install
* config pattern so the bash side can use the same parser).
*/
async runTool(appName, toolName, toolArgs = '', toolLabel = '') {
try {
if (!appName) throw new Error('appName is required');
if (!toolName) throw new Error('toolName is required');
const safeTool = String(toolName).replace(/[^A-Za-z0-9_.-]/g, '');
if (!safeTool) throw new Error('Invalid tool name');
const parts = ['libreportal', 'app', 'tool', appName, safeTool];
if (toolArgs) {
parts.push(`'${String(toolArgs).replace(/'/g, "'\\''")}'`);
}
return await this.executeTask('tool', appName, parts.join(' '), toolLabel);
} catch (error) {
throw new Error(`Failed to run tool ${toolName} on ${appName}: ${error.message}`);
}
}
/**
* System operations
*/
async systemUpdate() {
try {
await this.executeTask('system_update', 'system');
return;
} catch (error) {
throw new Error(`Failed to update system: ${error.message}`);
}
}
/**
* Create a task object
*/
createTask(type, command, metadata = {}) {
const task = {
id: Date.now().toString(),
command: command,
type: type,
status: 'running',
createdAt: new Date().toISOString(),
output: `Starting ${type} operation...`,
error: null,
...metadata
};
return task;
}
/**
* Execute a task with enhanced event emission
*/
async executeTask(action, appName, config = '', displayLabel = '') {
try {
// If config is a full `libreportal …` command, use it verbatim. Otherwise
// build the standard form and single-quote the config arg.
let command;
if (config && config.startsWith('libreportal')) {
command = config;
} else {
command = `libreportal app ${action} ${appName}`;
if (config) command += ` '${config.replace(/'/g, "'\\''")}'`;
}
// Create task — POSTs to /api/tasks and returns the authoritative task.
const task = await this.tasksManager.taskManager.createTask(command, action, appName, config);
// Hook the UI side-effects (auto-expand, log streaming, status interval).
// The bus also delivers `taskCreated` from the SSE feed; AppTabbedManager
// dedupes by appName|action so the double-fire is harmless.
if (this.tasksManager && typeof this.tasksManager.monitorTask === 'function') {
this.tasksManager.monitorTask(task.id, appName, action);
}
window.dispatchEvent(new CustomEvent('taskCreated', {
detail: {
taskId: task.id,
appName: appName,
action: action,
timestamp: Date.now()
}
}));
// Show success notification
let appData = null;
try {
appData = this.commands.getAppData ? this.commands.getAppData(appName) : null;
} catch (error) {
console.warn('Could not get app data:', error);
}
const appIcon = appData?.icon || `/icons/apps/${task.app}.svg`;
let taskUrl;
const currentUrl = window.location.href;
if (currentUrl.includes('/app/') && appName) {
taskUrl = window.appPath(appName, 'tasks', null, task.id);
} else {
taskUrl = `/tasks/all?task=${task.id}`;
}
if (window.notificationSystem) {
// `appName === 'system'` is a category sentinel (config_update,
// system_update — no real app). Resolve it to the LibrePortal
// logo + name so the toast doesn't read "App: System" with a
// broken /icons/apps/system.svg.
const isSystem = appName === 'system';
const displayName = isSystem
? 'LibrePortal'
: (window.getAppDisplayName ? window.getAppDisplayName(appName) : appName);
const notifIcon = isSystem ? '/icons/libreportal.svg' : appIcon;
const typeIcon = (window.tasksManager && window.tasksManager.getTaskTypeIcon
? window.tasksManager.getTaskTypeIcon({ type: action })
: null)?.icon || '';
const customIcon = typeIcon ? `<span style="font-size:18px;line-height:1;">${typeIcon}</span>` : null;
const headline = displayLabel
? `${displayLabel} task started!`
: `${action.charAt(0).toUpperCase() + action.slice(1)} task started!`;
window.notificationSystem.show(
`<strong>${displayName}</strong><br>${headline}`,
'success',
appName,
taskUrl,
notifIcon,
customIcon
);
}
return task;
} catch (error) {
console.error(`❌ Failed to execute ${action} task for ${appName}:`, error);
if (window.notificationSystem) {
window.notificationSystem.error(`Failed to start ${action} task for ${appName}: ${error.message}`);
}
throw error;
}
}
/**
* Update task in the manager
*/
updateTaskInManager(updatedTask) {
const taskIndex = this.tasksManager.tasks.findIndex(t => t.id === updatedTask.id);
if (taskIndex !== -1) {
this.tasksManager.tasks[taskIndex] = updatedTask;
this.tasksManager.renderTasks();
this.tasksManager.updateStats();
this.tasksManager.updateSidebarCounts();
this.tasksManager.generateAppCategories();
}
}
}
// Export for use
window.TaskActions = TaskActions;