librelad adf79db9e2 ux(tools): play icon on the Run button
Each tool row's Run button gains a small play-triangle SVG to the left
of the label, matching the iconography pattern the Services tab uses
for its Restart and Open buttons. Same green colour (currentColor), so
the icon inherits the success/destructive variants without extra CSS.

Button becomes a flex container with a 6px gap so icon + label stay
nicely centred regardless of label width.

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-28 01:30:46 +01:00

876 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)}">
<svg class="tool-run-btn-icon" width="12" height="12" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<polygon points="6,4 20,12 6,20"></polygon>
</svg>
<span class="tool-run-btn-label">Run</span>
</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();