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>
873 lines
36 KiB
JavaScript
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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
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();
|