librelad 875a60f90f LibrePortal v0.1.0 — initial release
A free, open, self-hosted app platform (GNU AGPLv3): one-click app deploys,
Traefik reverse proxy with automatic SSL, rootless Docker support, gluetun
VPN routing, and a web dashboard to manage it all.

Free & open forever to self-host; optional paid hosted services fund it.
See PROMISE.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-21 20:37:54 +01:00

873 lines
36 KiB
JavaScript

// Tools tab on the app detail page.
//
// Each app may declare per-app actions ("tools") in
// containers/<app>/<app>.tools.json
// served by GET /api/apps/<app>/tools. Each tool is rendered as a card
// with a button. Clicking the button either runs the tool directly (no
// fields, no confirm) or opens a generic modal that collects the tool's
// inputs.
//
// On submit we dispatch through the existing TaskRouter:
// TaskRouter.routeAction('tool', { appName, toolName, toolArgs })
// which builds: libreportal app tool <app> <tool> '<pipe-encoded-args>'
// — same task pipeline as install/restart/etc., so the Tasks tab and
// log streaming come for free.
//
// Manifest schema (containers/<app>/<app>.tools.json):
// {
// "tools": [
// {
// "id": "<unique-id>", // matches the case in <app>Tool()
// "label": "Refresh providers",
// "description": "...", // optional, shown on card + modal
// "icon": "🔄", // optional emoji
// "destructive": false, // optional, renders red button
// "confirm": "Are you sure?", // optional, forces a modal
// "fields": [
// { "name": "duration",
// "label": "Duration (s)",
// "type": "number", // text|password|number|select|checkbox|textarea
// "default": 5,
// "placeholder": "5",
// "required": true,
// "min": 1, "max": 60 },
// { "name": "mode",
// "label": "Mode",
// "type": "select",
// "options": [
// { "value": "fast", "label": "Fast" },
// { "value": "slow", "label": "Slow" }
// ] }
// ]
// }
// ]
// }
class ToolsManager {
constructor() {
this.currentApp = null;
this.tools = [];
// Cache of manifest results per app so prepare() doesn't re-fetch on
// every tab switch. Cleared by reset().
this._manifestCache = new Map();
// Re-check Tools tab visibility on focus + task completion.
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', () => {
if (!document.hidden && this.currentApp) this._revalidateCurrent();
});
}
if (typeof window !== 'undefined') {
window.addEventListener('taskCompleted', (ev) => {
if (this.currentApp) this._revalidateCurrent();
this._maybeOpenUserListModal(ev?.detail);
});
}
}
// When a list_users task completes, fetch its log, parse EZ_USER\t lines,
// and open an interactive modal where each row has action buttons.
async _maybeOpenUserListModal(detail) {
if (!detail || detail.status !== 'completed') return;
const cmd = String(detail.command || detail.task?.command || '');
const m = cmd.match(/libreportal app tool (\S+) list_users\b/);
if (!m) return;
const appName = m[1];
const taskId = detail.taskId || detail.id || detail.task?.id;
if (!taskId) return;
let logText = '';
try {
const r = await fetch(`/read-file?path=tasks/${encodeURIComponent(taskId)}.log`, { cache: 'no-store' });
if (r.ok) logText = await r.text();
} catch (_) {}
const users = [];
logText.split(/\r?\n/).forEach(line => {
const i = line.indexOf('EZ_USER\t');
if (i < 0) return;
const parts = line.slice(i + 8).split('\t');
const [email = '', username = '', roles = ''] = parts;
if (!email && !username) return;
users.push({ email: email.trim(), username: username.trim(), roles: roles.trim() });
});
this._openUserListModal(appName, users);
}
_openUserListModal(appName, users) {
const tools = (window.toolsCatalog?.apps?.[appName]?.tools) || [];
const resetTool = tools.find(t => t.id === 'reset_password');
const deleteTool = tools.find(t => t.id === 'delete_user');
const adminTool = tools.find(t => t.id === 'set_admin');
const appLabel = (window.getAppDisplayName ? window.getAppDisplayName(appName) : appName);
const iconUrl = `/icons/apps/${encodeURIComponent(appName)}.svg`;
// Reopen this exact modal (used as returnTo when a row action's
// sub-modal is cancelled — keeps the user in flow instead of
// dumping them out to the Tools tab).
const reopen = () => this._openUserListModal(appName, users);
const rowsHtml = users.length
? users.map((u, idx) => {
const isAdmin = /admin/i.test(u.roles || '');
return `
<div class="user-row" data-idx="${idx}">
<div class="user-row-info">
<div class="user-row-primary">${escapeHtml(u.email || u.username || '—')}</div>
${u.username && u.username !== u.email ? `<div class="user-row-secondary">${escapeHtml(u.username)}</div>` : ''}
${u.roles && u.roles !== '—' ? `<div class="user-row-roles${isAdmin ? ' is-admin' : ''}">${escapeHtml(u.roles)}</div>` : ''}
</div>
<div class="user-row-actions">
${resetTool ? `<button type="button" class="user-row-btn" data-act="reset" data-idx="${idx}" title="Reset password">🔑</button>` : ''}
${adminTool ? `<button type="button" class="user-row-btn" data-act="admin" data-idx="${idx}" title="${isAdmin ? 'Demote from admin' : 'Promote to admin'}">${isAdmin ? '👤' : '👑'}</button>` : ''}
${deleteTool ? `<button type="button" class="user-row-btn danger" data-act="delete" data-idx="${idx}" title="Delete user">🗑</button>` : ''}
</div>
</div>`;
}).join('')
: window.eoEmpty('No users found.');
const mod = window.openEoModal({
id: 'user-list-modal',
size: 'sm',
icon: iconUrl,
iconAlt: appLabel,
eyebrow: 'Users',
title: `${appLabel} Accounts`,
desc: users.length ? `${users.length} account${users.length === 1 ? '' : 's'}` : '',
body: `<div class="user-list">${rowsHtml}</div>`,
actions: [{ label: 'Close', variant: 'secondary' }]
});
const fillIdentifier = (tool, idValue) => {
const prefill = {};
if (tool.fields?.some(f => f.name === 'email')) prefill.email = idValue;
if (tool.fields?.some(f => f.name === 'username')) prefill.username = idValue;
return prefill;
};
mod.contentEl.querySelectorAll('.user-row-btn').forEach(btn => {
btn.addEventListener('click', () => {
const u = users[parseInt(btn.dataset.idx, 10)];
const idValue = u.email || u.username;
const act = btn.dataset.act;
const tool = act === 'reset' ? resetTool : act === 'delete' ? deleteTool : adminTool;
if (!tool) return;
const prefill = fillIdentifier(tool, idValue);
// For toggle-admin we also pre-set the boolean.
if (act === 'admin' && tool.fields?.some(f => f.name === 'admin')) {
prefill.admin = !(/admin/i.test(u.roles || ''));
}
mod.close();
this._activate(tool, { prefill, returnTo: reopen });
});
});
}
async _revalidateCurrent() {
const app = this.currentApp;
if (!app) return;
this._manifestCache.delete(app);
this._aggregatePromise = null;
try { await this.prepare(app); } catch (_) { /* swallow */ }
}
// Called when the app changes. Fetches the manifest once and toggles the
// Tools tab buttons' visibility. Apps with no tools never expose the tab.
async prepare(appName) {
if (!appName) {
this._toggleTabVisibility(false);
return { tools: [] };
}
let result = this._manifestCache.get(appName);
if (!result) {
result = await this._fetchManifest(appName);
// Only cache non-empty success results. An empty list usually means
// apps-tools.json was briefly stale (mid-regen during install) — if
// we cache that, the tab stays hidden until full page reload. By
// also dropping the aggregate promise we force the next prepare()
// call to refetch the JSON fresh.
if (!result.error && result.tools.length > 0) {
this._manifestCache.set(appName, result);
} else {
this._aggregatePromise = null;
}
}
// Tools act on a live container — only expose the tab when the
// app is installed. apps-manager.js already does this on render
// for backup/services/tools, but prepare() can re-fire after that
// (visibilitychange, taskCompleted, app switches), so re-check
// here too — otherwise we'd silently re-show the tab for a
// not-installed app whenever we revalidate.
const installed = this._isAppInstalled(appName);
this._toggleTabVisibility(installed && result.tools.length > 0);
return result;
}
_isAppInstalled(appName) {
if (!appName) return false;
const target = String(appName).toLowerCase();
const entry = (window.apps || []).find(a =>
((a.command || '').split(' ').pop() || '').toLowerCase() === target
);
return !!entry?.installed;
}
// Drop cached manifests so the next prepare() re-fetches. Called when
// an app's compose/install changes — currently a no-op caller side, but
// keeps the door open for cache invalidation.
reset() {
this._manifestCache.clear();
this._aggregatePromise = null;
}
// The whole apps-tools.json aggregate, fetched once per page load.
// Generated by scripts/webui/data/generators/apps/webui_tools.sh.
// Also populates window.toolsCatalog so other components (e.g.
// tasks-manager.js's formatCommandForUser) can look up tool labels
// without re-fetching.
async _loadAggregate() {
if (this._aggregatePromise) return this._aggregatePromise;
this._aggregatePromise = (async () => {
try {
const resp = await fetch('/data/apps/generated/apps-tools.json', { cache: 'no-store' });
if (!resp.ok) return { apps: {}, error: `HTTP ${resp.status}` };
const data = await resp.json();
const apps = data && typeof data.apps === 'object' ? data.apps : {};
window.toolsCatalog = { apps };
return { apps };
} catch (err) {
return { apps: {}, error: err.message || String(err) };
}
})();
return this._aggregatePromise;
}
async _fetchManifest(appName) {
const agg = await this._loadAggregate();
if (agg.error) return { tools: [], error: agg.error };
const entry = agg.apps[appName];
return { tools: Array.isArray(entry?.tools) ? entry.tools : [] };
}
_toggleTabVisibility(show) {
const buttons = document.querySelectorAll('[data-tab="tools"]');
buttons.forEach(btn => {
btn.style.display = show ? '' : 'none';
});
}
async load(appName) {
this.currentApp = appName;
const list = document.getElementById('tools-list');
if (!list) return;
list.innerHTML = `
${this._titleBlock(appName)}
<div class="tools-loading">
<div class="tools-spinner"></div>
<span>Loading tools…</span>
</div>`;
const result = await this.prepare(appName);
this.tools = this._sortTools(result.tools);
if (result.error) {
list.innerHTML = `
${this._titleBlock(appName)}
<div class="tools-empty">
<span class="tools-empty-icon">⚠️</span>
<p>Couldn't load tools right now.</p>
</div>`;
return;
}
if (this.tools.length === 0) {
// Tab should already be hidden by prepare(); render a soft message
// anyway in case the user got here via a deep link.
list.innerHTML = `
${this._titleBlock(appName)}
<div class="tools-empty">
<span class="tools-empty-icon">🧰</span>
<p>This app has no tools.</p>
</div>`;
return;
}
const grouped = this._groupByCategory(this.tools);
if (grouped.size > 1) {
const cats = [...grouped.keys()];
const tabsHtml = cats.map((cat, i) => `
<button type="button" class="tools-tab ${i === 0 ? 'active' : ''}" data-cat="${escapeHtml(cat)}">
${escapeHtml(this._categoryLabel(cat))}
<span class="tools-tab-count">${grouped.get(cat).length}</span>
</button>`).join('');
const panesHtml = cats.map((cat, i) => `
<div class="tools-rows tools-cat-pane ${i === 0 ? 'active' : ''}" data-cat="${escapeHtml(cat)}">
${grouped.get(cat).map(t => this._renderRow(t)).join('')}
</div>`).join('');
list.innerHTML = `
${this._titleBlock(appName)}
<div class="tools-tab-bar" role="tablist">${tabsHtml}</div>
${panesHtml}`;
this._wireTabs(list);
} else {
list.innerHTML = `
${this._titleBlock(appName)}
<div class="tools-rows">
${this.tools.map(t => this._renderRow(t)).join('')}
</div>`;
}
this._wireActions(list);
}
// Bucket tools by their `category` field into an insertion-ordered map.
// Tools without a category land in "general". Categories appear in the
// order they're first seen (matches authoring order in webui_tools.sh).
_groupByCategory(tools) {
const out = new Map();
for (const t of tools) {
const cat = (typeof t.category === 'string' && t.category.trim()) ? t.category.trim() : 'general';
if (!out.has(cat)) out.set(cat, []);
out.get(cat).push(t);
}
return out;
}
_categoryLabel(cat) {
return String(cat).replace(/[-_]+/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
}
_wireTabs(root) {
const bar = root.querySelector('.tools-tab-bar');
if (!bar) return;
bar.addEventListener('click', (ev) => {
const btn = ev.target.closest('.tools-tab');
if (!btn) return;
const cat = btn.dataset.cat;
bar.querySelectorAll('.tools-tab').forEach(b => b.classList.toggle('active', b === btn));
root.querySelectorAll('.tools-cat-pane').forEach(p => p.classList.toggle('active', p.dataset.cat === cat));
});
}
unload() { /* no timers/streams */ }
_titleBlock(appName) {
const display = (appName || '').replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
return `
<div class="tools-title">
<h3>🧰 Tools</h3>
<p>Run app-specific actions for ${escapeHtml(display)} — each tool creates a task.</p>
</div>`;
}
_renderRow(tool) {
const icon = tool.icon || '⚙️';
const desc = tool.description ? `<p class="tool-desc">${escapeHtml(tool.description)}</p>` : '';
const btnClass = tool.destructive ? 'tool-run-btn destructive' : 'tool-run-btn';
return `
<div class="tool-item" data-tool-id="${escapeHtml(tool.id)}">
<div class="tool-text">
<div class="tool-head">
<span class="tool-icon">${escapeHtml(icon)}</span>
<span class="tool-title">${escapeHtml(tool.label || tool.id)}</span>
</div>
${desc}
</div>
<div class="tool-action">
<button class="${btnClass}" data-action="run" title="${escapeHtml(tool.label || tool.id)}">
Run
</button>
</div>
</div>`;
}
_wireActions(root) {
if (root.dataset.wired === '1') return;
root.dataset.wired = '1';
root.addEventListener('click', (ev) => {
const btn = ev.target.closest('[data-action="run"]');
if (!btn) return;
const item = btn.closest('.tool-item');
if (!item) return;
const toolId = item.dataset.toolId;
const tool = this.tools.find(t => t.id === toolId);
if (!tool) return;
this._activate(tool);
});
}
// Tools render in this order: explicit `order` field if present,
// otherwise the default precedence map (least → most destructive),
// otherwise their authoring order from webui_tools.sh.
_sortTools(tools) {
if (!Array.isArray(tools) || tools.length < 2) return tools || [];
const defaults = {
reset_password: 10,
apply_dns_updater: 15,
manage_shortcuts: 20,
refresh_providers: 20,
create_account: 30,
list_users: 40,
set_admin: 50,
delete_user: 90,
};
const weight = (t, idx) => {
if (typeof t.order === 'number') return t.order;
if (defaults[t.id] !== undefined) return defaults[t.id];
return 1000 + idx;
};
return tools.map((t, i) => [weight(t, i), i, t])
.sort((a, b) => a[0] - b[0] || a[1] - b[1])
.map(x => x[2]);
}
_activate(tool, opts) {
const hasFields = Array.isArray(tool.fields) && tool.fields.length > 0;
if (!hasFields && !tool.confirm) {
this._dispatch(tool, '');
return;
}
this._openModal(tool, opts);
}
_openModal(tool, opts) {
const fields = Array.isArray(tool.fields) ? tool.fields : [];
const prefill = (opts && opts.prefill) || {};
const returnTo = opts && opts.returnTo;
let submitted = false;
const appIconUrl = this.currentApp ? `/icons/apps/${encodeURIComponent(this.currentApp)}.svg` : '';
const bodyHtml = `
${tool.confirm ? `<div class="tool-modal-confirm">${escapeHtml(tool.confirm)}</div>` : ''}
<form class="tool-form">
${fields.map(f => this._renderField(prefill[f.name] !== undefined ? { ...f, default: prefill[f.name] } : f)).join('')}
</form>`;
const widePicker = fields.some(f => f.type === 'app_urls_multi' || f.type === 'installed_apps_multi');
const m = window.openEoModal({
id: 'tool-run-modal',
className: 'tool-modal',
size: widePicker ? 'md' : 'sm',
icon: appIconUrl || undefined,
iconAlt: this.currentApp || '',
title: tool.label || tool.id,
endIcon: tool.icon || undefined,
body: bodyHtml,
actions: [
{ label: 'Run', variant: 'primary', onClick: (modal) => {
const args = this._collectFormArgs(modal.contentEl, fields);
if (args === null) return;
submitted = true;
modal.close();
this._dispatch(tool, args);
}},
{ label: 'Cancel', variant: 'secondary', onClick: (modal) => modal.close() }
],
onClose: () => {
if (!submitted && typeof returnTo === 'function') {
try { returnTo(); } catch (e) { console.error(e); }
}
}
});
const modal = m.contentEl;
// Wire the installed_apps_multi / app_urls_multi search + bulk
// select buttons. For app_urls_multi we also hide whole group
// headers when none of their rows match the search.
modal.querySelectorAll('.installed-apps-multi').forEach((root) => {
const isUrlMode = root.classList.contains('app-urls-multi');
const search = root.querySelector('.installed-apps-search');
const all = root.querySelector('.installed-apps-all');
const none = root.querySelector('.installed-apps-none');
const filterRows = (q) => {
if (!isUrlMode) {
root.querySelectorAll('.installed-apps-item').forEach((item) => {
const text = item.querySelector('.installed-apps-name')?.textContent.toLowerCase() || '';
item.style.display = text.includes(q) ? '' : 'none';
});
return;
}
root.querySelectorAll('.app-url-row').forEach((item) => {
const label = (item.querySelector('.app-url-label')?.textContent || '').toLowerCase();
item.style.display = (!q || label.includes(q)) ? '' : 'none';
});
};
if (search) search.addEventListener('input', (e) => filterRows(e.target.value.toLowerCase()));
if (all) all.addEventListener('click', () => {
root.querySelectorAll('.installed-apps-item').forEach((item) => {
if (item.offsetParent !== null) item.querySelector('input').checked = true;
});
});
if (none) none.addEventListener('click', () => {
root.querySelectorAll('.installed-apps-item input').forEach((cb) => cb.checked = false);
});
});
// Async-fill app_urls_multi rows from apps-services.json after
// mount so the modal opens instantly and rows fade in.
this._hydrateAppUrlsMulti(modal);
return m;
}
_renderField(field) {
const name = String(field.name || '');
const label = escapeHtml(field.label || name);
const id = `tool-field-${name}`;
const required = field.required ? 'required' : '';
const placeholder = field.placeholder ? `placeholder="${escapeHtml(field.placeholder)}"` : '';
const def = field.default !== undefined ? String(field.default) : '';
const wrap = (inner) => `
<div class="form-group">
<label class="form-label" for="${id}">${label}${field.required ? ' <span class="required-mark">*</span>' : ''}</label>
${inner}
</div>`;
switch (field.type) {
case 'password':
return wrap(`<input type="password" id="${id}" name="${escapeHtml(name)}" class="form-input" value="${escapeHtml(def)}" ${placeholder} ${required}>`);
case 'number': {
const min = field.min !== undefined ? `min="${Number(field.min)}"` : '';
const max = field.max !== undefined ? `max="${Number(field.max)}"` : '';
return wrap(`<input type="number" id="${id}" name="${escapeHtml(name)}" class="form-input" value="${escapeHtml(def)}" ${min} ${max} ${placeholder} ${required}>`);
}
case 'checkbox': {
const checked = (def === 'true' || def === '1' || field.default === true) ? 'checked' : '';
return `
<label class="eo-toggle eo-toggle-card tool-form-toggle" for="${id}">
<input type="checkbox" id="${id}" name="${escapeHtml(name)}" ${checked}>
<span class="eo-toggle-track"></span>
<span class="eo-toggle-text">
<span class="eo-toggle-text-title">${label}</span>
${field.tooltip ? `<span class="eo-toggle-text-help">${escapeHtml(field.tooltip)}</span>` : ''}
</span>
</label>`;
}
case 'select': {
const opts = Array.isArray(field.options) ? field.options : [];
const optsHtml = opts.map(o => {
const v = escapeHtml(String(o.value));
const l = escapeHtml(String(o.label || o.value));
const sel = String(o.value) === def ? 'selected' : '';
return `<option value="${v}" ${sel}>${l}</option>`;
}).join('');
return wrap(`<select id="${id}" name="${escapeHtml(name)}" class="form-input" ${required}>${optsHtml}</select>`);
}
case 'textarea':
return wrap(`<textarea id="${id}" name="${escapeHtml(name)}" class="form-input" rows="3" ${placeholder} ${required}>${escapeHtml(def)}</textarea>`);
case 'installed_apps_multi':
return wrap(this._renderInstalledAppsMulti(field, name));
case 'app_urls_multi':
// Label rendered inside the picker's container as a title bar
// so the field reads as a single visual unit (no floating
// disconnected label above the box). Skip wrap().
return `<div class="form-group form-group-app-urls">${this._renderAppUrlsMulti(field, name, label, !!field.required)}</div>`;
case 'text':
default:
return wrap(`<input type="text" id="${id}" name="${escapeHtml(name)}" class="form-input" value="${escapeHtml(def)}" ${placeholder} ${required}>`);
}
}
// Multi-select list of installed apps, styled to mirror the gluetun
// country picker: search box + select-all/clear + compact rows.
// Collected as CSV by _collectFormArgs.
_renderInstalledAppsMulti(field, name) {
const apps = (window.apps || []).filter(a => a && a.installed);
const currentAppEntry = (window.apps || []).find(a =>
((a.command || '').split(' ').pop()) === this.currentApp
);
const cfgKey = field.prefillFromCfgKey
|| `CFG_${(this.currentApp || '').toUpperCase()}_${(name || '').toUpperCase()}`;
const currentCsv = (currentAppEntry && currentAppEntry.config && currentAppEntry.config[cfgKey]) || '';
const selected = new Set(currentCsv.split(',').map(s => s.trim()).filter(Boolean));
const exclude = new Set(Array.isArray(field.excludeApps) ? field.excludeApps : [this.currentApp]);
const items = apps
.map(a => ({ app: a, slug: (a.command || '').split(' ').pop() }))
.filter(({ slug }) => slug && !exclude.has(slug))
.sort((x, y) => (x.app.name || x.slug).localeCompare(y.app.name || y.slug))
.map(({ app, slug }) => {
const checked = selected.has(slug) ? 'checked' : '';
const iconUrl = `/icons/apps/${encodeURIComponent(slug)}.svg`;
const displayName = escapeHtml(app.name || slug);
return `
<label class="installed-apps-item">
<input type="checkbox" name="${escapeHtml(name)}__opt" value="${escapeHtml(slug)}" ${checked}>
<img src="${iconUrl}" alt="" class="installed-apps-icon" onerror="this.style.display='none'">
<span class="installed-apps-name">${displayName}</span>
</label>`;
}).join('');
if (!items) {
return `<div class="installed-apps-multi-empty">No other installed apps to choose from.</div>`;
}
return `
<div class="installed-apps-multi" data-field="${escapeHtml(name)}">
<div class="installed-apps-search-card">
<div class="installed-apps-search-row">
<svg class="installed-apps-search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
<input type="text" class="installed-apps-search" placeholder="Filter apps...">
</div>
<div class="installed-apps-actions">
<button type="button" class="btn btn-secondary installed-apps-all">Select all</button>
<button type="button" class="btn btn-secondary installed-apps-none">Clear</button>
</div>
</div>
<div class="installed-apps-list">${items}</div>
</div>`;
}
// URL-level multi-select. Same data source as the WebUI URL buttons
// (apps-services.json + window.expandServiceLinks), so what the user
// picks here matches what they'd see hovering an app on the
// dashboard. Each row is one URL, grouped under its parent app.
// Stable id per URL: "<service.name>:<link_index>". Pre-checked from
// a CSV in CFG_<APP>_<NAME> (override via prefillFromCfgKey).
_renderAppUrlsMulti(field, name, labelHtml, required) {
const cfgKey = field.prefillFromCfgKey
|| `CFG_${(this.currentApp || '').toUpperCase()}_${(name || '').toUpperCase()}`;
const currentAppEntry = (window.apps || []).find(a =>
((a.command || '').split(' ').pop()) === this.currentApp
);
const currentCsv = (currentAppEntry && currentAppEntry.config && currentAppEntry.config[cfgKey]) || '';
const selected = new Set(currentCsv.split(',').map(s => s.trim()).filter(Boolean));
const exclude = new Set(Array.isArray(field.excludeApps) ? field.excludeApps : [this.currentApp]);
const titleHtml = labelHtml
? `<div class="app-urls-title">${labelHtml}${required ? ' <span class="required-mark">*</span>' : ''}</div>`
: '';
return `
<div class="installed-apps-multi app-urls-multi" data-field="${escapeHtml(name)}"
data-cfg-key="${escapeHtml(cfgKey)}"
data-app-slug="${escapeHtml(this.currentApp || '')}"
data-prefill='${escapeHtml(JSON.stringify([...selected]))}'
data-exclude='${escapeHtml(JSON.stringify([...exclude]))}'>
<div class="app-urls-container">
${titleHtml}
<div class="app-urls-header">
<div class="installed-apps-search-row">
<svg class="installed-apps-search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
<input type="text" class="installed-apps-search" placeholder="Filter URLs or apps...">
</div>
<div class="installed-apps-actions">
<button type="button" class="btn btn-secondary installed-apps-all">Select all</button>
<button type="button" class="btn btn-secondary installed-apps-none">Clear</button>
</div>
</div>
<div class="installed-apps-list app-urls-list">
<div class="app-urls-loading">Loading services…</div>
</div>
</div>
</div>`;
}
// Async hydration for the app_urls_multi field — fetch
// apps-services.json and render rows. Called after the modal has
// been mounted (see open() below).
async _hydrateAppUrlsMulti(modal) {
const roots = modal.querySelectorAll('.app-urls-multi');
if (roots.length === 0) return;
// Re-fetch apps.json so the pre-checked state reflects the
// latest CFG_* values (a previous tool save may have updated
// them after window.apps was first loaded). Cheap and avoids
// a "ticked nothing even though I just saved" UX bug.
try {
const appsResp = await fetch('/data/apps/generated/apps.json', { cache: 'no-store' });
if (appsResp.ok) {
const appsData = await appsResp.json();
if (Array.isArray(appsData?.apps)) window.apps = appsData.apps;
}
} catch (_) { /* keep stale window.apps if fetch fails */ }
// Recompute data-prefill from the fresh window.apps. The render
// pass encoded it from whatever data was current at modal-open
// time; if the user saved shortcuts in a different tab/session
// we'd otherwise show stale ticks here.
roots.forEach((root) => {
const cfgKey = root.dataset.cfgKey;
const appSlug = root.dataset.appSlug;
if (!cfgKey || !appSlug) return;
const entry = (window.apps || []).find(a =>
((a.command || '').split(' ').pop()) === appSlug
);
const csv = (entry && entry.config && entry.config[cfgKey]) || '';
const fresh = csv.split(',').map(s => s.trim()).filter(Boolean);
root.dataset.prefill = JSON.stringify(fresh);
});
let services = [];
try {
const resp = await fetch('/data/apps/generated/apps-services.json', { cache: 'no-store' });
if (resp.ok) {
const data = await resp.json();
services = Array.isArray(data?.services) ? data.services : [];
}
} catch (e) { /* fall through, render empty state */ }
const expand = typeof window.expandServiceLinks === 'function'
? window.expandServiceLinks
: ((s) => [{ url: s.externalURL || `http://localhost:${s.externalPort}`, label: s.buttonText || s.name }]);
const displayName = (slug) => (window.getAppDisplayName ? window.getAppDisplayName(slug) : slug);
roots.forEach((root) => {
const name = root.dataset.field;
const selected = new Set(JSON.parse(root.dataset.prefill || '[]'));
const exclude = new Set(JSON.parse(root.dataset.exclude || '[]'));
const list = root.querySelector('.app-urls-list');
const byApp = new Map();
for (const svc of services) {
if (!svc || !svc.app || exclude.has(svc.app)) continue;
if (svc.buttonEnabled === false) continue;
const links = expand(svc);
if (!links || links.length === 0) continue;
if (!byApp.has(svc.app)) byApp.set(svc.app, []);
links.forEach((lnk, idx) => {
if (!lnk?.url) return;
byApp.get(svc.app).push({
id: `${svc.name}:${idx}`,
label: lnk.label || svc.buttonText || svc.name,
url: lnk.url,
traefik: !!svc.traefikManaged,
locked: !!svc.loginRequired
});
});
}
if (byApp.size === 0) {
list.innerHTML = `<div class="installed-apps-multi-empty">No service URLs available yet — install some apps first.</div>`;
return;
}
// Flat task-style rows: one URL per row, app icon + name + URL
// inline. Sorted by app name then URL label so related entries
// sit together without needing per-app group cards.
const flat = [];
[...byApp.keys()].sort((a, b) => displayName(a).localeCompare(displayName(b))).forEach(slug => {
byApp.get(slug).forEach(u => flat.push({ slug, ...u }));
});
list.innerHTML = flat.map(u => {
const checked = selected.has(u.id) ? 'checked' : '';
const iconUrl = `/icons/apps/${encodeURIComponent(u.slug)}.svg`;
const appLabel = displayName(u.slug);
const showButton = u.label && u.label !== appLabel;
const fullLabel = showButton
? `${escapeHtml(appLabel)} <span class="app-url-sep">—</span> ${escapeHtml(u.label)}`
: escapeHtml(appLabel);
return `
<label class="installed-apps-item app-url-item app-url-row" data-url-id="${escapeHtml(u.id)}">
<input type="checkbox" name="${escapeHtml(name)}__opt" value="${escapeHtml(u.id)}" ${checked}>
<img src="${iconUrl}" alt="" class="app-url-icon" onerror="this.style.display='none'">
<span class="app-url-label">${fullLabel}</span>
</label>`;
}).join('');
});
}
// Collect form values into a pipe-encoded args string for the bash side.
// Returns null if a required field is missing.
_collectFormArgs(modal, fields) {
const pairs = [];
for (const field of fields) {
const name = String(field.name || '');
if (!name) continue;
// installed_apps_multi has no [name=name] element directly; it
// collects from [name=name+'__opt'] checkboxes below. Other field
// types have a single matching element to validate against.
const isMultiPickerType = field.type === 'installed_apps_multi' || field.type === 'app_urls_multi';
const el = modal.querySelector(`[name="${cssEscape(name)}"]`)
|| (isMultiPickerType ? modal.querySelector(`.installed-apps-multi[data-field="${cssEscape(name)}"]`) : null);
if (!el) continue;
let value;
if (field.type === 'checkbox') {
value = el.checked ? 'true' : 'false';
} else if (isMultiPickerType) {
const checked = modal.querySelectorAll(`[name="${cssEscape(name + '__opt')}"]:checked`);
value = Array.from(checked).map(c => c.value).join(',');
} else {
value = el.value;
}
if (field.required && (value === '' || value === undefined || value === null)) {
if (window.notificationSystem) {
window.notificationSystem.show(`<strong>Missing field</strong><br>${escapeHtml(field.label || name)} is required.`, 'warning');
} else {
alert(`${field.label || name} is required.`);
}
return null;
}
// Strip pipes/newlines from values — they're our delimiters.
const safe = String(value).replace(/\|/g, '%7C').replace(/[\r\n]/g, ' ');
pairs.push(`${name}=${safe}`);
}
return pairs.join('|');
}
async _dispatch(tool, toolArgs) {
if (!this.currentApp) return;
if (!window.tasksManager || !window.tasksManager.router) {
console.error('TasksManager router not available');
if (window.notificationSystem) {
window.notificationSystem.error('Task system is not ready yet — try again in a moment.');
}
return;
}
try {
const task = await window.tasksManager.router.routeAction('tool', {
appName: this.currentApp,
toolName: tool.id,
toolArgs,
toolLabel: tool.label || tool.id
});
// Mirror the install flow: jump to the Tasks tab and auto-expand
// the new task so the user sees its log streaming in.
setTimeout(() => {
if (window.appTabbedManager) {
window.appTabbedManager.switchTab('tasks');
setTimeout(() => {
if (task && window.appTabbedManager.tasksManager) {
window.appTabbedManager.tasksManager.highlightedTaskId = task.id;
window.appTabbedManager.tasksManager.renderTasks();
}
}, 300);
}
}, 200);
} catch (err) {
console.error('Tool dispatch failed', err);
if (window.notificationSystem) {
window.notificationSystem.error(`Tool failed: ${err.message}`);
}
}
}
}
// Tiny helpers ----------------------------------------------------------
function escapeHtml(s) {
return String(s ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function cssEscape(s) {
if (window.CSS && CSS.escape) return CSS.escape(s);
return String(s).replace(/[^a-zA-Z0-9_-]/g, c => '\\' + c);
}
window.toolsManager = new ToolsManager();