bcc6d40ed7
Lint & Test / test (push) Successful in 20s
Security
- Default bind 127.0.0.1; first-run bootstrap generates random api_token
and refuses to bind non-loopback without auth unless explicitly opted in
- Path-traversal hardened: BrowserService.validate_path rejects absolute
paths, drive letters, UNC, NUL bytes. /api/browser/{play,metadata,
thumbnail} now require folder_id and a folder-relative path
- Pydantic validators on links: http(s) URLs only, mdi:<slug> icons only
- Scripts/callbacks/links create/update/delete gated by *_management flags
- Strict CSP, X-Frame-Options DENY, Referrer-Policy no-referrer,
X-Content-Type-Options nosniff
- CORS locked to localhost:<port> + 127.0.0.1:<port> by default; configurable
- config.yaml writes atomic (tmp + os.replace) and 0o600 on POSIX
- Subprocesses spawned in their own process group / new session so timeout
kills the whole tree (Windows CREATE_NEW_PROCESS_GROUP, POSIX
start_new_session=True)
- Frontend XSS: monitor name + details escapeHtml'd; power button moved to
delegated data-action handler; remote MDI SVGs parsed and sanitized
(strip script/foreignObject/on*/javascript: hrefs) before innerHTML
- All dynamic URL segments now wrapped in encodeURIComponent
Bugs
- WebSocket reconnect: close previous socket before opening new, clear
ping interval per-socket, clear reconnectTimeout up-front, retry on
online/visibilitychange, try/catch JSON.parse
- Artwork fetch race: AbortController + generation guard
- _broadcast_after_open: initialize status, swallow per-poll errors,
background tasks tracked in a strong-ref set with done-callback cleanup
- Audio analyzer: sticky _unavailable flag prevents infinite start/stop
spin when no loopback device exists; cleared by set_device()
- Volume short-circuit cache invalidated when server reports remote volume
- Browser thumbnail race: per-folder generation counter + isConnected
checks; aborts in-flight fetches on navigation
- Track-skip uses cached title instead of full WinRT status round-trip
Performance
- Linux MPRIS/pactl and /api/display DDC-CI handlers wrapped in
asyncio.to_thread so blocking IO never stalls the event loop
- browse_directory moved off the event loop (SMB shares could freeze it)
- Windows status poll caches one asyncio loop per worker thread via
threading.local instead of new_event_loop/close on every 0.5s tick
- broadcast() serializes JSON once and uses send_text to all clients
- Hourly thumbnail cache cleanup scheduled in lifespan (was never invoked
— cache grew unbounded)
- Progress drag listeners attached only while dragging
Quality
- All asyncio.get_event_loop() in coroutines → get_running_loop()
- ThreadPoolExecutors shut down cleanly during lifespan teardown
- config_manager dedup: 12 near-identical methods collapsed onto generic
_upsert/_delete helpers (~290 lines removed)
- Service worker no longer pass-throughs every fetch
- M3U playlist written via NamedTemporaryFile (no fixed-path symlink
clobber race)
- __version__ now prefers live pyproject.toml in dev checkouts so
pip install -e . users see the source-of-truth version, not the stale
package-metadata version baked in at install time
UI/UX (Studio Reference)
- Green leftover focus rings (rgba(29,185,84,...)) all replaced with
copper accent (rgba(var(--copper-rgb),...))
- Dialogs: square corners, copper top hairline, unified with editorial
chrome
- .browser-item: transparent with copper hover border (was filled card)
- Audio device select uses var(--sans) instead of generic system font
- Mobile container padding tuned for ≤480px screens
- Breadcrumb home is a real <button> with aria-label; aria-current on root
- i18n: filled display.msg.power_*, execution.*, scripts.params.execute,
callbacks.empty in both en + ru
857 lines
33 KiB
JavaScript
857 lines
33 KiB
JavaScript
// ============================================================
|
|
// Scripts: CRUD, quick access, execution dialog
|
|
// ============================================================
|
|
|
|
import {
|
|
t, showToast, escapeHtml, closeDialog, showConfirm,
|
|
resolveMdiIcons, fetchMdiIcon,
|
|
scripts, setScripts,
|
|
getAuthHeaders, hasCredentials,
|
|
} from './core.js';
|
|
import { IconSelect } from './icon-select.js';
|
|
import { paramTypeIcons } from './icons.js';
|
|
|
|
export let scriptFormDirty = false;
|
|
export function setScriptFormDirty(value) { scriptFormDirty = value; }
|
|
|
|
export async function loadScripts() {
|
|
try {
|
|
const response = await fetch('/api/scripts/list', {
|
|
headers: getAuthHeaders()
|
|
});
|
|
|
|
if (response.ok) {
|
|
setScripts(await response.json());
|
|
displayQuickAccess();
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading scripts:', error);
|
|
}
|
|
}
|
|
|
|
let _quickAccessGen = 0;
|
|
export async function displayQuickAccess() {
|
|
const gen = ++_quickAccessGen;
|
|
const grid = document.getElementById('scripts-grid');
|
|
|
|
const fragment = document.createDocumentFragment();
|
|
const hasScripts = scripts.length > 0;
|
|
let hasLinks = false;
|
|
|
|
scripts.forEach(script => {
|
|
const button = document.createElement('button');
|
|
button.className = 'script-btn';
|
|
button.onclick = () => executeScript(script.name, button);
|
|
|
|
if (script.icon) {
|
|
const iconEl = document.createElement('div');
|
|
iconEl.className = 'script-icon';
|
|
iconEl.setAttribute('data-mdi-icon', script.icon);
|
|
button.appendChild(iconEl);
|
|
}
|
|
|
|
const label = document.createElement('div');
|
|
label.className = 'script-label';
|
|
label.textContent = script.label || script.name;
|
|
button.appendChild(label);
|
|
|
|
if (script.description) {
|
|
const description = document.createElement('div');
|
|
description.className = 'script-description';
|
|
description.textContent = script.description;
|
|
button.appendChild(description);
|
|
}
|
|
|
|
fragment.appendChild(button);
|
|
});
|
|
|
|
try {
|
|
if (hasCredentials()) {
|
|
const response = await fetch('/api/links/list', {
|
|
headers: getAuthHeaders()
|
|
});
|
|
if (gen !== _quickAccessGen) return;
|
|
if (response.ok) {
|
|
const links = await response.json();
|
|
hasLinks = links.length > 0;
|
|
links.forEach(link => {
|
|
const card = document.createElement('a');
|
|
card.className = 'script-btn link-card';
|
|
card.href = link.url;
|
|
card.target = '_blank';
|
|
card.rel = 'noopener noreferrer';
|
|
|
|
if (link.icon) {
|
|
const iconEl = document.createElement('div');
|
|
iconEl.className = 'script-icon';
|
|
iconEl.setAttribute('data-mdi-icon', link.icon);
|
|
card.appendChild(iconEl);
|
|
}
|
|
|
|
const label = document.createElement('div');
|
|
label.className = 'script-label';
|
|
label.textContent = link.label || link.name;
|
|
card.appendChild(label);
|
|
|
|
if (link.description) {
|
|
const desc = document.createElement('div');
|
|
desc.className = 'script-description';
|
|
desc.textContent = link.description;
|
|
card.appendChild(desc);
|
|
}
|
|
|
|
fragment.appendChild(card);
|
|
});
|
|
}
|
|
}
|
|
} catch (e) {
|
|
if (gen !== _quickAccessGen) return;
|
|
console.warn('Failed to load links for quick access:', e);
|
|
}
|
|
|
|
if (!hasScripts && !hasLinks) {
|
|
const empty = document.createElement('div');
|
|
empty.className = 'scripts-empty empty-state-illustration';
|
|
empty.innerHTML = `<svg viewBox="0 0 64 64"><path d="M20 8l-8 48"/><path d="M44 8l8 48"/><path d="M10 24h44"/><path d="M8 40h44"/></svg><p>${t('quick_access.no_items')}</p>`;
|
|
fragment.prepend(empty);
|
|
}
|
|
|
|
grid.innerHTML = '';
|
|
grid.appendChild(fragment);
|
|
resolveMdiIcons(grid);
|
|
}
|
|
|
|
function _getScriptParams(scriptName) {
|
|
const script = scripts.find(s => s.name === scriptName);
|
|
return (script && script.parameters) ? script.parameters : {};
|
|
}
|
|
|
|
async function executeScript(scriptName, buttonElement) {
|
|
const paramDefs = _getScriptParams(scriptName);
|
|
if (Object.keys(paramDefs).length > 0) {
|
|
_showParamsInputDialog(scriptName, paramDefs, async (params) => {
|
|
buttonElement.classList.add('executing');
|
|
try {
|
|
await _doExecuteScript(scriptName, params);
|
|
} finally {
|
|
buttonElement.classList.remove('executing');
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
buttonElement.classList.add('executing');
|
|
try {
|
|
await _doExecuteScript(scriptName, {});
|
|
} finally {
|
|
buttonElement.classList.remove('executing');
|
|
}
|
|
}
|
|
|
|
async function _doExecuteScript(scriptName, params) {
|
|
try {
|
|
const response = await fetch(`/api/scripts/execute/${encodeURIComponent(scriptName)}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
|
|
body: JSON.stringify({ params })
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (response.ok && result.success) {
|
|
showToast(t('scripts.msg.executed', { name: scriptName }), 'success');
|
|
} else {
|
|
showToast(result.detail || t('scripts.msg.execute_failed', { name: scriptName }), 'error');
|
|
}
|
|
} catch (error) {
|
|
console.error(`Error executing script ${scriptName}:`, error);
|
|
showToast(t('scripts.msg.execute_error', { name: scriptName }), 'error');
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// Script Parameters Input Dialog (execution-time)
|
|
// ============================================================
|
|
|
|
let _paramsCallback = null;
|
|
let _paramsScriptName = null;
|
|
let _paramsIconSelects = null;
|
|
|
|
function _showParamsInputDialog(scriptName, paramDefs, callback) {
|
|
_paramsCallback = callback;
|
|
_paramsScriptName = scriptName;
|
|
|
|
const dialog = document.getElementById('scriptParamsDialog');
|
|
const title = document.getElementById('scriptParamsDialogTitle');
|
|
const container = document.getElementById('scriptParamsInputs');
|
|
|
|
const script = scripts.find(s => s.name === scriptName);
|
|
title.textContent = script ? (script.label || scriptName) : scriptName;
|
|
container.innerHTML = '';
|
|
|
|
// Track IconSelect instances for cleanup
|
|
if (!_paramsIconSelects) _paramsIconSelects = [];
|
|
|
|
for (const [pname, pdef] of Object.entries(paramDefs)) {
|
|
const wrapper = document.createElement('label');
|
|
|
|
const labelText = document.createElement('span');
|
|
labelText.textContent = pname + (pdef.required ? ' *' : '');
|
|
wrapper.appendChild(labelText);
|
|
|
|
if (pdef.description) {
|
|
const hint = document.createElement('small');
|
|
hint.className = 'param-hint';
|
|
hint.textContent = pdef.description;
|
|
wrapper.appendChild(hint);
|
|
}
|
|
|
|
let input;
|
|
if (pdef.type === 'select' && pdef.options) {
|
|
input = document.createElement('select');
|
|
if (!pdef.required) {
|
|
const opt = document.createElement('option');
|
|
opt.value = '';
|
|
opt.textContent = '—';
|
|
input.appendChild(opt);
|
|
}
|
|
for (const optVal of pdef.options) {
|
|
const opt = document.createElement('option');
|
|
opt.value = optVal;
|
|
opt.textContent = optVal;
|
|
if (pdef.default !== undefined && pdef.default !== null && String(pdef.default) === optVal) {
|
|
opt.selected = true;
|
|
}
|
|
input.appendChild(opt);
|
|
}
|
|
input.dataset.paramName = pname;
|
|
input.dataset.paramType = pdef.type;
|
|
if (pdef.required) input.required = true;
|
|
wrapper.appendChild(input);
|
|
|
|
// Enhance with icon grid if few options
|
|
if (pdef.options.length <= 10) {
|
|
const selItems = pdef.options.map(o => ({ value: o, icon: '', label: o }));
|
|
const cols = Math.min(pdef.options.length, 4);
|
|
const isel = new IconSelect({ target: input, items: selItems, columns: cols });
|
|
_paramsIconSelects.push(isel);
|
|
}
|
|
} else if (pdef.type === 'boolean') {
|
|
const boolSvgTrue = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>';
|
|
const boolSvgFalse = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>';
|
|
|
|
input = document.createElement('select');
|
|
const optTrue = document.createElement('option');
|
|
optTrue.value = 'true';
|
|
optTrue.textContent = 'true';
|
|
const optFalse = document.createElement('option');
|
|
optFalse.value = 'false';
|
|
optFalse.textContent = 'false';
|
|
input.appendChild(optTrue);
|
|
input.appendChild(optFalse);
|
|
if (pdef.default !== undefined && pdef.default !== null) {
|
|
input.value = String(pdef.default);
|
|
}
|
|
input.dataset.paramName = pname;
|
|
input.dataset.paramType = pdef.type;
|
|
if (pdef.required) input.required = true;
|
|
wrapper.appendChild(input);
|
|
|
|
// Enhance with icon grid
|
|
const isel = new IconSelect({
|
|
target: input,
|
|
items: [
|
|
{ value: 'true', icon: boolSvgTrue, label: 'True' },
|
|
{ value: 'false', icon: boolSvgFalse, label: 'False' },
|
|
],
|
|
columns: 2,
|
|
});
|
|
_paramsIconSelects.push(isel);
|
|
} else if (pdef.type === 'integer' || pdef.type === 'float') {
|
|
input = document.createElement('input');
|
|
input.type = 'number';
|
|
if (pdef.type === 'float') input.step = 'any';
|
|
if (pdef.min !== undefined && pdef.min !== null) input.min = pdef.min;
|
|
if (pdef.max !== undefined && pdef.max !== null) input.max = pdef.max;
|
|
if (pdef.default !== undefined && pdef.default !== null) input.value = pdef.default;
|
|
input.dataset.paramName = pname;
|
|
input.dataset.paramType = pdef.type;
|
|
if (pdef.required) input.required = true;
|
|
wrapper.appendChild(input);
|
|
} else {
|
|
input = document.createElement('input');
|
|
input.type = 'text';
|
|
if (pdef.default !== undefined && pdef.default !== null) input.value = pdef.default;
|
|
input.dataset.paramName = pname;
|
|
input.dataset.paramType = pdef.type;
|
|
if (pdef.required) input.required = true;
|
|
wrapper.appendChild(input);
|
|
}
|
|
|
|
container.appendChild(wrapper);
|
|
}
|
|
|
|
document.body.classList.add('dialog-open');
|
|
dialog.showModal();
|
|
}
|
|
|
|
export function closeScriptParamsDialog() {
|
|
const dialog = document.getElementById('scriptParamsDialog');
|
|
_paramsCallback = null;
|
|
_paramsScriptName = null;
|
|
// Destroy icon selects from execution dialog
|
|
if (_paramsIconSelects) {
|
|
_paramsIconSelects.forEach(isel => isel.destroy());
|
|
_paramsIconSelects = null;
|
|
}
|
|
closeDialog(dialog);
|
|
document.body.classList.remove('dialog-open');
|
|
}
|
|
|
|
export async function submitScriptWithParams(event) {
|
|
event.preventDefault();
|
|
const container = document.getElementById('scriptParamsInputs');
|
|
const inputs = container.querySelectorAll('[data-param-name]');
|
|
const params = {};
|
|
|
|
for (const input of inputs) {
|
|
const name = input.dataset.paramName;
|
|
const type = input.dataset.paramType;
|
|
let val = input.value;
|
|
|
|
if (val === '' && !input.required) continue;
|
|
if (val === '') continue;
|
|
|
|
if (type === 'integer') val = parseInt(val, 10);
|
|
else if (type === 'float') val = parseFloat(val);
|
|
else if (type === 'boolean') val = val === 'true';
|
|
|
|
params[name] = val;
|
|
}
|
|
|
|
const callback = _paramsCallback;
|
|
closeScriptParamsDialog();
|
|
|
|
if (callback) {
|
|
await callback(params);
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// Script Management CRUD
|
|
// ============================================================
|
|
|
|
let _loadScriptsPromise = null;
|
|
export async function loadScriptsTable() {
|
|
if (_loadScriptsPromise) return _loadScriptsPromise;
|
|
_loadScriptsPromise = _loadScriptsTableImpl();
|
|
_loadScriptsPromise.finally(() => { _loadScriptsPromise = null; });
|
|
return _loadScriptsPromise;
|
|
}
|
|
|
|
async function _loadScriptsTableImpl() {
|
|
const tbody = document.getElementById('scriptsTableBody');
|
|
|
|
try {
|
|
const response = await fetch('/api/scripts/list', {
|
|
headers: getAuthHeaders()
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to fetch scripts');
|
|
}
|
|
|
|
const scriptsList = await response.json();
|
|
|
|
if (scriptsList.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="5" class="empty-state"><div class="empty-state-illustration"><svg viewBox="0 0 64 64"><rect x="8" y="4" width="48" height="56" rx="4"/><path d="M20 20h24M20 32h16M20 44h20"/></svg><p>' + t('scripts.empty') + '</p></div></td></tr>';
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = scriptsList.map(script => `
|
|
<tr>
|
|
<td><span class="name-with-icon">${script.icon ? `<span class="table-icon" data-mdi-icon="${escapeHtml(script.icon)}"></span>` : ''}<code>${escapeHtml(script.name)}</code></span></td>
|
|
<td>${escapeHtml(script.label || script.name)}</td>
|
|
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"
|
|
title="${escapeHtml(script.command || 'N/A')}">${escapeHtml(script.command || 'N/A')}</td>
|
|
<td>${script.timeout}s</td>
|
|
<td>
|
|
<div class="action-buttons">
|
|
<button class="action-btn execute" data-action="execute" data-script-name="${escapeHtml(script.name)}" title="Execute script">
|
|
<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
|
</button>
|
|
<button class="action-btn" data-action="edit" data-script-name="${escapeHtml(script.name)}" title="Edit script">
|
|
<svg viewBox="0 0 24 24"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
|
|
</button>
|
|
<button class="action-btn delete" data-action="delete" data-script-name="${escapeHtml(script.name)}" title="Delete script">
|
|
<svg viewBox="0 0 24 24"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
`).join('');
|
|
resolveMdiIcons(tbody);
|
|
} catch (error) {
|
|
console.error('Error loading scripts:', error);
|
|
tbody.innerHTML = `<tr><td colspan="5" class="empty-state" style="color: var(--error);">${escapeHtml(t('scripts.msg.load_failed'))}</td></tr>`;
|
|
}
|
|
}
|
|
|
|
export function showAddScriptDialog() {
|
|
const dialog = document.getElementById('scriptDialog');
|
|
const form = document.getElementById('scriptForm');
|
|
const title = document.getElementById('dialogTitle');
|
|
|
|
form.reset();
|
|
document.getElementById('scriptOriginalName').value = '';
|
|
document.getElementById('scriptIsEdit').value = 'false';
|
|
document.getElementById('scriptName').disabled = false;
|
|
document.getElementById('scriptIconPreview').innerHTML = '';
|
|
document.getElementById('scriptParamsContainer').innerHTML = '';
|
|
title.textContent = t('scripts.dialog.add');
|
|
|
|
scriptFormDirty = false;
|
|
|
|
document.body.classList.add('dialog-open');
|
|
dialog.showModal();
|
|
}
|
|
|
|
export async function showEditScriptDialog(scriptName) {
|
|
const dialog = document.getElementById('scriptDialog');
|
|
const title = document.getElementById('dialogTitle');
|
|
|
|
try {
|
|
const response = await fetch('/api/scripts/list', {
|
|
headers: getAuthHeaders()
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to fetch script details');
|
|
}
|
|
|
|
const scriptsList = await response.json();
|
|
const script = scriptsList.find(s => s.name === scriptName);
|
|
|
|
if (!script) {
|
|
showToast(t('scripts.msg.not_found'), 'error');
|
|
return;
|
|
}
|
|
|
|
document.getElementById('scriptOriginalName').value = scriptName;
|
|
document.getElementById('scriptIsEdit').value = 'true';
|
|
document.getElementById('scriptName').value = scriptName;
|
|
document.getElementById('scriptName').disabled = true;
|
|
document.getElementById('scriptLabel').value = script.label || '';
|
|
document.getElementById('scriptCommand').value = script.command || '';
|
|
document.getElementById('scriptDescription').value = script.description || '';
|
|
document.getElementById('scriptIcon').value = script.icon || '';
|
|
document.getElementById('scriptTimeout').value = script.timeout || 30;
|
|
|
|
const preview = document.getElementById('scriptIconPreview');
|
|
if (script.icon) {
|
|
fetchMdiIcon(script.icon).then(svg => { preview.innerHTML = svg; });
|
|
} else {
|
|
preview.innerHTML = '';
|
|
}
|
|
|
|
// Populate parameters
|
|
const paramsContainer = document.getElementById('scriptParamsContainer');
|
|
paramsContainer.innerHTML = '';
|
|
if (script.parameters) {
|
|
for (const [pname, pdef] of Object.entries(script.parameters)) {
|
|
_addParameterRowWithData(pname, pdef);
|
|
}
|
|
}
|
|
|
|
title.textContent = t('scripts.dialog.edit');
|
|
scriptFormDirty = false;
|
|
|
|
document.body.classList.add('dialog-open');
|
|
dialog.showModal();
|
|
} catch (error) {
|
|
console.error('Error loading script for edit:', error);
|
|
showToast(t('scripts.msg.load_failed'), 'error');
|
|
}
|
|
}
|
|
|
|
export async function closeScriptDialog() {
|
|
if (scriptFormDirty) {
|
|
if (!await showConfirm(t('scripts.confirm.unsaved'))) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
const dialog = document.getElementById('scriptDialog');
|
|
scriptFormDirty = false;
|
|
closeDialog(dialog);
|
|
document.body.classList.remove('dialog-open');
|
|
}
|
|
|
|
export async function saveScript(event) {
|
|
event.preventDefault();
|
|
|
|
const submitBtn = event.target.querySelector('button[type="submit"]');
|
|
if (submitBtn) submitBtn.disabled = true;
|
|
|
|
const isEdit = document.getElementById('scriptIsEdit').value === 'true';
|
|
const scriptName = isEdit ?
|
|
document.getElementById('scriptOriginalName').value :
|
|
document.getElementById('scriptName').value;
|
|
|
|
const data = {
|
|
command: document.getElementById('scriptCommand').value,
|
|
label: document.getElementById('scriptLabel').value || null,
|
|
description: document.getElementById('scriptDescription').value || '',
|
|
icon: document.getElementById('scriptIcon').value || null,
|
|
timeout: parseInt(document.getElementById('scriptTimeout').value) || 30,
|
|
shell: true,
|
|
parameters: _collectParameterDefinitions(),
|
|
};
|
|
|
|
const encodedName = encodeURIComponent(scriptName);
|
|
const endpoint = isEdit ?
|
|
`/api/scripts/update/${encodedName}` :
|
|
`/api/scripts/create/${encodedName}`;
|
|
|
|
const method = isEdit ? 'PUT' : 'POST';
|
|
|
|
try {
|
|
const response = await fetch(endpoint, {
|
|
method,
|
|
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
|
|
body: JSON.stringify(data)
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (response.ok && result.success) {
|
|
showToast(t(isEdit ? 'scripts.msg.updated' : 'scripts.msg.created'), 'success');
|
|
scriptFormDirty = false;
|
|
closeScriptDialog();
|
|
} else {
|
|
showToast(result.detail || t(isEdit ? 'scripts.msg.update_failed' : 'scripts.msg.create_failed'), 'error');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error saving script:', error);
|
|
showToast(t(isEdit ? 'scripts.msg.update_failed' : 'scripts.msg.create_failed'), 'error');
|
|
} finally {
|
|
if (submitBtn) submitBtn.disabled = false;
|
|
}
|
|
}
|
|
|
|
export async function deleteScriptConfirm(scriptName) {
|
|
if (!await showConfirm(t('scripts.confirm.delete').replace('{name}', scriptName))) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/api/scripts/delete/${encodeURIComponent(scriptName)}`, {
|
|
method: 'DELETE',
|
|
headers: getAuthHeaders()
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (response.ok && result.success) {
|
|
showToast(t('scripts.msg.deleted'), 'success');
|
|
} else {
|
|
showToast(result.detail || t('scripts.msg.delete_failed'), 'error');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error deleting script:', error);
|
|
showToast(t('scripts.msg.delete_failed'), 'error');
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// Execution Result Dialog (shared by scripts and callbacks)
|
|
// ============================================================
|
|
|
|
export function closeExecutionDialog() {
|
|
const dialog = document.getElementById('executionDialog');
|
|
closeDialog(dialog);
|
|
document.body.classList.remove('dialog-open');
|
|
}
|
|
|
|
function showExecutionResult(name, result, type = 'script') {
|
|
const dialog = document.getElementById('executionDialog');
|
|
const title = document.getElementById('executionDialogTitle');
|
|
const statusDiv = document.getElementById('executionStatus');
|
|
const outputSection = document.getElementById('outputSection');
|
|
const errorSection = document.getElementById('errorSection');
|
|
const outputPre = document.getElementById('executionOutput');
|
|
const errorPre = document.getElementById('executionError');
|
|
|
|
title.textContent = `${t('execution.result')}: ${name}`;
|
|
|
|
const success = result.success && result.exit_code === 0;
|
|
const statusClass = success ? 'success' : 'error';
|
|
const statusText = t(success ? 'execution.success' : 'execution.failed');
|
|
|
|
statusDiv.innerHTML = `
|
|
<div class="status-item ${statusClass}">
|
|
<label>${escapeHtml(t('execution.status'))}</label>
|
|
<value>${escapeHtml(statusText)}</value>
|
|
</div>
|
|
<div class="status-item">
|
|
<label>${escapeHtml(t('execution.exit_code'))}</label>
|
|
<value>${result.exit_code !== undefined ? result.exit_code : 'N/A'}</value>
|
|
</div>
|
|
<div class="status-item">
|
|
<label>${escapeHtml(t('execution.duration'))}</label>
|
|
<value>${result.execution_time !== undefined && result.execution_time !== null ? result.execution_time.toFixed(3) + 's' : 'N/A'}</value>
|
|
</div>
|
|
`;
|
|
|
|
outputSection.style.display = 'block';
|
|
if (result.stdout && result.stdout.trim()) {
|
|
outputPre.textContent = result.stdout;
|
|
} else {
|
|
outputPre.textContent = t('execution.no_output');
|
|
outputPre.style.fontStyle = 'italic';
|
|
outputPre.style.color = 'var(--text-secondary)';
|
|
}
|
|
|
|
if (result.stderr && result.stderr.trim()) {
|
|
errorSection.style.display = 'block';
|
|
errorPre.textContent = result.stderr;
|
|
errorPre.style.fontStyle = 'normal';
|
|
errorPre.style.color = 'var(--error)';
|
|
} else if (!success && result.error) {
|
|
errorSection.style.display = 'block';
|
|
errorPre.textContent = result.error;
|
|
errorPre.style.fontStyle = 'normal';
|
|
errorPre.style.color = 'var(--error)';
|
|
} else {
|
|
errorSection.style.display = 'none';
|
|
}
|
|
|
|
dialog.showModal();
|
|
}
|
|
|
|
export async function executeScriptDebug(scriptName) {
|
|
const paramDefs = _getScriptParams(scriptName);
|
|
if (Object.keys(paramDefs).length > 0) {
|
|
_showParamsInputDialog(scriptName, paramDefs, (params) => _executeScriptDebugWithParams(scriptName, params));
|
|
return;
|
|
}
|
|
await _executeScriptDebugWithParams(scriptName, {});
|
|
}
|
|
|
|
async function _executeScriptDebugWithParams(scriptName, params) {
|
|
const dialog = document.getElementById('executionDialog');
|
|
const title = document.getElementById('executionDialogTitle');
|
|
const statusDiv = document.getElementById('executionStatus');
|
|
|
|
title.textContent = `${t('execution.executing')}: ${scriptName}`;
|
|
statusDiv.innerHTML = `
|
|
<div class="status-item">
|
|
<label>${escapeHtml(t('execution.status'))}</label>
|
|
<value><span class="loading-spinner"></span> ${escapeHtml(t('execution.running'))}</value>
|
|
</div>
|
|
`;
|
|
document.getElementById('outputSection').style.display = 'none';
|
|
document.getElementById('errorSection').style.display = 'none';
|
|
document.body.classList.add('dialog-open');
|
|
dialog.showModal();
|
|
|
|
try {
|
|
const response = await fetch(`/api/scripts/execute/${scriptName}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
|
|
body: JSON.stringify({ params })
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (response.ok) {
|
|
showExecutionResult(scriptName, result, 'script');
|
|
} else {
|
|
showExecutionResult(scriptName, {
|
|
success: false,
|
|
exit_code: -1,
|
|
error: result.detail || 'Execution failed',
|
|
stderr: result.detail || 'Unknown error'
|
|
}, 'script');
|
|
}
|
|
} catch (error) {
|
|
console.error(`Error executing script ${scriptName}:`, error);
|
|
showExecutionResult(scriptName, {
|
|
success: false,
|
|
exit_code: -1,
|
|
error: error.message,
|
|
stderr: `Network error: ${error.message}`
|
|
}, 'script');
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// Parameter Definition Editor (CRUD dialog)
|
|
// ============================================================
|
|
|
|
const PARAM_TYPES = ['string', 'integer', 'float', 'boolean', 'select'];
|
|
|
|
export function addParameterRow() {
|
|
_addParameterRowWithData('', {});
|
|
scriptFormDirty = true;
|
|
}
|
|
|
|
const _paramTypeItems = PARAM_TYPES.map(pt => ({
|
|
value: pt,
|
|
icon: paramTypeIcons[pt] || '',
|
|
label: pt.charAt(0).toUpperCase() + pt.slice(1),
|
|
}));
|
|
|
|
function _addParameterRowWithData(name, def) {
|
|
const container = document.getElementById('scriptParamsContainer');
|
|
const row = document.createElement('div');
|
|
row.className = 'param-row';
|
|
|
|
row.innerHTML = `
|
|
<div class="param-row-header">
|
|
<input type="text" class="param-name" value="${escapeHtml(name)}"
|
|
placeholder="${t('scripts.params.name_placeholder')}" pattern="[a-zA-Z][a-zA-Z0-9_]*">
|
|
<select class="param-type">
|
|
${PARAM_TYPES.map(pt => `<option value="${pt}" ${def.type === pt ? 'selected' : ''}>${pt}</option>`).join('')}
|
|
</select>
|
|
<label class="param-required-label" title="${t('scripts.params.required')}">
|
|
<input type="checkbox" class="param-required" ${def.required ? 'checked' : ''}>
|
|
<span>*</span>
|
|
</label>
|
|
<button type="button" class="param-remove-btn" title="${t('scripts.params.remove')}">×</button>
|
|
</div>
|
|
<div class="param-row-details">
|
|
<input type="text" class="param-description" value="${escapeHtml(def.description || '')}"
|
|
placeholder="${t('scripts.params.description_placeholder')}">
|
|
<div class="param-row-extra">
|
|
<input type="text" class="param-default" value="${def.default !== undefined && def.default !== null ? escapeHtml(String(def.default)) : ''}"
|
|
placeholder="${t('scripts.params.default_placeholder')}">
|
|
<input type="text" class="param-min" value="${def.min !== undefined && def.min !== null ? def.min : ''}"
|
|
placeholder="Min" style="display:${def.type === 'integer' || def.type === 'float' ? '' : 'none'}">
|
|
<input type="text" class="param-max" value="${def.max !== undefined && def.max !== null ? def.max : ''}"
|
|
placeholder="Max" style="display:${def.type === 'integer' || def.type === 'float' ? '' : 'none'}">
|
|
<input type="text" class="param-options" value="${def.options ? def.options.join(', ') : ''}"
|
|
placeholder="${t('scripts.params.options_placeholder')}" style="display:${def.type === 'select' ? '' : 'none'}">
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// Enhance the type <select> with icon grid
|
|
const typeSelect = row.querySelector('.param-type');
|
|
const iconSelect = new IconSelect({
|
|
target: typeSelect,
|
|
items: _paramTypeItems,
|
|
columns: 5,
|
|
onChange: () => {
|
|
const isNumeric = typeSelect.value === 'integer' || typeSelect.value === 'float';
|
|
const isSelect = typeSelect.value === 'select';
|
|
row.querySelector('.param-min').style.display = isNumeric ? '' : 'none';
|
|
row.querySelector('.param-max').style.display = isNumeric ? '' : 'none';
|
|
row.querySelector('.param-options').style.display = isSelect ? '' : 'none';
|
|
scriptFormDirty = true;
|
|
},
|
|
});
|
|
|
|
row.querySelector('.param-remove-btn').addEventListener('click', () => {
|
|
iconSelect.destroy();
|
|
row.remove();
|
|
scriptFormDirty = true;
|
|
});
|
|
|
|
// Mark dirty on any input change
|
|
row.querySelectorAll('input').forEach(el => {
|
|
el.addEventListener('input', () => { scriptFormDirty = true; });
|
|
});
|
|
|
|
container.appendChild(row);
|
|
}
|
|
|
|
function _collectParameterDefinitions() {
|
|
const container = document.getElementById('scriptParamsContainer');
|
|
const rows = container.querySelectorAll('.param-row');
|
|
const params = {};
|
|
|
|
for (const row of rows) {
|
|
const name = row.querySelector('.param-name').value.trim();
|
|
if (!name) continue;
|
|
|
|
const type = row.querySelector('.param-type').value;
|
|
const def = { type };
|
|
|
|
const description = row.querySelector('.param-description').value.trim();
|
|
if (description) def.description = description;
|
|
|
|
if (row.querySelector('.param-required').checked) def.required = true;
|
|
|
|
const defaultVal = row.querySelector('.param-default').value.trim();
|
|
if (defaultVal !== '') {
|
|
if (type === 'integer') def.default = parseInt(defaultVal, 10);
|
|
else if (type === 'float') def.default = parseFloat(defaultVal);
|
|
else if (type === 'boolean') def.default = defaultVal.toLowerCase() === 'true';
|
|
else def.default = defaultVal;
|
|
}
|
|
|
|
if (type === 'integer' || type === 'float') {
|
|
const minVal = row.querySelector('.param-min').value.trim();
|
|
const maxVal = row.querySelector('.param-max').value.trim();
|
|
if (minVal !== '') def.min = parseFloat(minVal);
|
|
if (maxVal !== '') def.max = parseFloat(maxVal);
|
|
}
|
|
|
|
if (type === 'select') {
|
|
const optStr = row.querySelector('.param-options').value.trim();
|
|
if (optStr) def.options = optStr.split(',').map(s => s.trim()).filter(Boolean);
|
|
}
|
|
|
|
params[name] = def;
|
|
}
|
|
|
|
return params;
|
|
}
|
|
|
|
export async function executeCallbackDebug(callbackName) {
|
|
const dialog = document.getElementById('executionDialog');
|
|
const title = document.getElementById('executionDialogTitle');
|
|
const statusDiv = document.getElementById('executionStatus');
|
|
|
|
title.textContent = `${t('execution.executing')}: ${callbackName}`;
|
|
statusDiv.innerHTML = `
|
|
<div class="status-item">
|
|
<label>${escapeHtml(t('execution.status'))}</label>
|
|
<value><span class="loading-spinner"></span> ${escapeHtml(t('execution.running'))}</value>
|
|
</div>
|
|
`;
|
|
document.getElementById('outputSection').style.display = 'none';
|
|
document.getElementById('errorSection').style.display = 'none';
|
|
document.body.classList.add('dialog-open');
|
|
dialog.showModal();
|
|
|
|
try {
|
|
const response = await fetch(`/api/callbacks/execute/${encodeURIComponent(callbackName)}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (response.ok) {
|
|
showExecutionResult(callbackName, result, 'callback');
|
|
} else {
|
|
showExecutionResult(callbackName, {
|
|
success: false,
|
|
exit_code: -1,
|
|
error: result.detail || 'Execution failed',
|
|
stderr: result.detail || 'Unknown error'
|
|
}, 'callback');
|
|
}
|
|
} catch (error) {
|
|
console.error(`Error executing callback ${callbackName}:`, error);
|
|
showExecutionResult(callbackName, {
|
|
success: false,
|
|
exit_code: -1,
|
|
error: error.message,
|
|
stderr: `Network error: ${error.message}`
|
|
}, 'callback');
|
|
}
|
|
}
|