Files
media-player-server/media_server/static/js/scripts.js
T
alexei.dolgolyov d131ba461c
Lint & Test / test (push) Successful in 20s
fix: production-readiness hardening — security, perf, a11y, observability
Security
- Default scripts_management, callbacks_management, links_management, and
  media_folders_management to False so a leaked token cannot escalate to RCE
  through admin CRUD endpoints.
- TokenSpec + scope hierarchy (read | control | admin); legacy bare-string
  api_tokens entries promote to admin for back-compat. Management endpoints
  now require admin scope.
- WebSocket subprotocol auth (Sec-WebSocket-Protocol: media-server.token.<T>)
  preferred over ?token= query so the token no longer lands in URL/history/
  Referer; query fallback retained for HA integration back-compat.
- Origin allow-list check on the WS endpoint (CSWSH defence).
- In-process token-bucket rate limiter: 5/min for failed auths,
  10/min for /api/scripts/execute and /api/callbacks/execute.
- shell=False subprocess path (shlex.split) + per-parameter regex `pattern`
  in ScriptParameterConfig to harden shell=true scripts against parameter
  injection (Windows cmd.exe env-var expansion).
- CSP gains form-action, worker-src, manifest-src directives.
- Refuse cors_origins=["*"] at startup; strip token=... from uvicorn access
  logs; validate Gitea release tag against strict SemVer regex.
- noopener noreferrer + no-referrer referrerpolicy on every outbound link.
- icacls hardening of config.yaml on Windows (current user + SYSTEM +
  Administrators only); 0600 still enforced on POSIX.
- WS volume handler clamps input and never drops the socket on bad messages.

Performance
- Album-art read in windows_media gated by track key — was decoding the
  WinRT thumbnail twice per second regardless of track changes.
- /api/media/artwork returns content-derived ETag + Cache-Control so the
  browser sends If-None-Match and gets 304s on track repeats.
- Foreground-service ctypes argtypes hoisted to one-time module init
  (was re-declaring ~14 prototypes per probe).
- display_service _static_cache keyed by (edid_hash, ...) tuple with
  eviction of disappeared monitors — fixes stale capabilities on hot-plug
  swaps where the new topology has the same monitor count.
- Visualizer rAF loop paused on document.hidden, resumed on visible.

Reliability / bug fixes
- Lifespan rewritten as try/yield/finally so a partial-startup failure
  cannot orphan background tasks or executors.
- _run_callback in routes/media.py keeps a strong task ref (GC-safe) and
  uses the dedicated callback executor instead of the default pool.
- macos_media.set_volume() no longer always returns True.
- TrayManager._restart_requested initialised in __init__; set before
  signalling exit so the main thread observes it correctly.
- Missing static_dir now logs a WARNING instead of silent UI disable.

UX / accessibility / PWA
- manifest.json theme_color and background_color match the Studio Reference
  base (#0E0D0B); added id and scope for PWA installability.
- ARIA on mini-player icon buttons; inner SVGs marked aria-hidden.
- OS mediaSession API wired so headset / lockscreen / Bluetooth buttons
  drive play/pause/next/prev/seek and show track metadata + artwork.

Observability
- X-Request-ID middleware (accept upstream id if it matches a safe regex,
  otherwise UUID4); request_id_var added to ContextVars and included in
  every log line alongside the token label.
- Audit log (append-only JSONL) for every script + callback execution,
  including the on_play/on_pause/etc. event callbacks. Background-thread
  writer; queue capped; flushed in lifespan teardown.

Deployment
- proxy_headers + forwarded_allow_ips plumbed through Settings →
  uvicorn.Config for reverse-proxy installs.
- HTTPS support via ssl_certfile + ssl_keyfile (+ optional password);
  startup refuses to launch with only one of the pair set.
- Thumbnail cache moved from project-root .cache to
  %LOCALAPPDATA%/media-server/cache (Windows) and
  $XDG_CACHE_HOME/media-server/thumbnails (POSIX).

Tests
- 35 new tests across auth scopes, rate limiter, browser path traversal
  (../ NUL UNC absolute), script-param validation incl. regex, Gitea tag
  whitelist, config atomic write + POSIX perms. 47 passed / 4 skipped.
2026-05-22 22:25:54 +03:00

860 lines
34 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';
// Prevent the WebUI's URL (which may carry ?token=...) from
// leaking to third-party sites via Referer.
card.referrerPolicy = 'no-referrer';
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')}">&times;</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');
}
}