Files
Bjorn/web/js/pages/actions.js
Fabien POLLY eb20b168a6 Add RLUtils class for managing RL/AI dashboard endpoints
- Implemented methods for fetching AI stats, training history, and recent experiences.
- Added functionality to set operation mode (MANUAL, AUTO, AI) with appropriate handling.
- Included helper methods for querying the database and sending JSON responses.
- Integrated model metadata extraction for visualization purposes.
2026-02-18 22:36:10 +01:00

818 lines
26 KiB
JavaScript

/**
* Actions page (SPA) — old actions_launcher parity.
* Sidebar (actions/arguments) + multi-console panes.
*/
import { ResourceTracker } from '../core/resource-tracker.js';
import { api } from '../core/api.js';
import { el, $, $$, empty, toast } from '../core/dom.js';
import { t } from '../core/i18n.js';
import { initSharedSidebarLayout } from '../core/sidebar-layout.js';
const PAGE = 'actions';
let tracker = null;
let root = null;
let sidebarLayoutCleanup = null;
let actions = [];
let activeActionId = null;
let panes = [null, null, null, null];
let split = 1;
let assignTargetPaneIndex = null;
let searchQuery = '';
let currentTab = 'actions';
const logsByAction = new Map(); // actionId -> string[]
const pollingTimers = new Map(); // actionId -> timeoutId
const autoClearPane = [false, false, false, false];
function tx(key, fallback) {
const v = t(key);
return v === key ? fallback : v;
}
function isMobile() {
return window.matchMedia('(max-width: 860px)').matches;
}
function q(sel, base = root) { return base?.querySelector(sel) || null; }
export async function mount(container) {
tracker = new ResourceTracker(PAGE);
root = buildShell();
container.appendChild(root);
sidebarLayoutCleanup = initSharedSidebarLayout(root, {
sidebarSelector: '.al-sidebar',
mainSelector: '#actionsLauncher',
storageKey: 'sidebar:actions',
toggleLabel: tx('common.menu', 'Menu'),
});
bindStaticEvents();
enforceMobileOnePane();
await loadActions();
renderActionsList();
renderConsoles();
}
export function unmount() {
if (typeof sidebarLayoutCleanup === 'function') {
sidebarLayoutCleanup();
sidebarLayoutCleanup = null;
}
for (const tmr of pollingTimers.values()) clearTimeout(tmr);
pollingTimers.clear();
if (tracker) {
tracker.cleanupAll();
tracker = null;
}
root = null;
actions = [];
activeActionId = null;
panes = [null, null, null, null];
split = 1;
assignTargetPaneIndex = null;
searchQuery = '';
currentTab = 'actions';
logsByAction.clear();
}
function buildShell() {
const sideTabs = el('div', { class: 'tabs-container' }, [
el('button', { class: 'tab-btn active', id: 'tabBtnActions', type: 'button' }, [tx('actions.tabs.actions', 'Actions')]),
el('button', { class: 'tab-btn', id: 'tabBtnArgs', type: 'button' }, [tx('actions.tabs.arguments', 'Arguments')]),
]);
const sideHeader = el('div', { class: 'sideheader' }, [
el('div', { class: 'al-side-meta' }, [
el('div', { class: 'sidetitle' }, [tx('nav.actions', 'Actions')]),
el('button', { class: 'al-btn', id: 'hideSidebar', 'data-hide-sidebar': '1', type: 'button' }, [tx('common.hide', 'Hide')]),
]),
sideTabs,
el('div', { class: 'al-search' }, [
el('input', {
id: 'searchInput',
class: 'al-input',
type: 'text',
placeholder: tx('actions.searchPlaceholder', 'Search actions...'),
}),
]),
]);
const actionsSidebar = el('div', { id: 'tab-actions', class: 'sidebar-page' }, [
el('div', { id: 'actionsList', class: 'al-list' }),
]);
const argsSidebar = el('div', { id: 'tab-arguments', class: 'sidebar-page', style: 'display:none' }, [
el('div', { class: 'section' }, [
el('div', { class: 'h' }, [tx('actions.args.title', 'Arguments')]),
el('div', { class: 'sub' }, [tx('actions.args.subtitle', 'Auto-generated from action definitions')]),
]),
el('div', { id: 'argBuilder', class: 'builder' }),
el('div', { class: 'section' }, [
el('input', {
id: 'freeArgs',
class: 'ctl',
type: 'text',
placeholder: tx('actions.args.free', 'Additional arguments (e.g., --verbose --debug)'),
}),
]),
el('div', { id: 'presetChips', class: 'chips' }),
]);
const sideContent = el('div', { class: 'sidecontent' }, [actionsSidebar, argsSidebar]);
const sidebarPanel = el('aside', { class: 'panel al-sidebar' }, [sideHeader, sideContent]);
const splitSeg = el('div', { class: 'seg', id: 'splitSeg' }, [
el('button', { type: 'button', 'data-split': '1', class: 'active' }, ['1']),
el('button', { type: 'button', 'data-split': '2' }, ['2']),
el('button', { type: 'button', 'data-split': '3' }, ['3']),
el('button', { type: 'button', 'data-split': '4' }, ['4']),
]);
const toolbar = el('div', { class: 'toolbar2' }, [
el('div', { class: 'spacer' }),
splitSeg,
]);
const multiConsole = el('div', { class: 'multiConsole split-1', id: 'multiConsole' });
const centerPanel = el('section', { class: 'center panel' }, [toolbar, multiConsole]);
return el('div', { class: 'actions-container page-with-sidebar' }, [
sidebarPanel,
el('main', { id: 'actionsLauncher' }, [centerPanel]),
]);
}
function bindStaticEvents() {
const tabActions = q('#tabBtnActions');
const tabArgs = q('#tabBtnArgs');
if (tabActions) tracker.trackEventListener(tabActions, 'click', () => switchTab('actions'));
if (tabArgs) tracker.trackEventListener(tabArgs, 'click', () => switchTab('arguments'));
const searchInput = q('#searchInput');
if (searchInput) {
tracker.trackEventListener(searchInput, 'input', () => {
searchQuery = String(searchInput.value || '').trim().toLowerCase();
renderActionsList();
});
}
$$('#splitSeg button', root).forEach((btn) => {
tracker.trackEventListener(btn, 'click', () => {
if (isMobile()) {
enforceMobileOnePane();
return;
}
split = Number(btn.dataset.split || '1');
$$('#splitSeg button', root).forEach((b) => b.classList.toggle('active', b === btn));
renderConsoles();
});
});
tracker.trackEventListener(window, 'resize', onResizeDebounced);
}
function onResizeDebounced() {
clearTimeout(onResizeDebounced._t);
onResizeDebounced._t = setTimeout(() => {
enforceMobileOnePane();
renderConsoles();
}, 120);
}
function switchTab(tab) {
currentTab = tab;
const tabActions = q('#tabBtnActions');
const tabArgs = q('#tabBtnArgs');
const actionsPane = q('#tab-actions');
const argsPane = q('#tab-arguments');
if (tabActions) tabActions.classList.toggle('active', tab === 'actions');
if (tabArgs) tabArgs.classList.toggle('active', tab === 'arguments');
if (actionsPane) actionsPane.style.display = tab === 'actions' ? '' : 'none';
if (argsPane) argsPane.style.display = tab === 'arguments' ? '' : 'none';
}
function enforceMobileOnePane() {
if (!isMobile()) {
$$('#splitSeg button', root).forEach((btn) => {
btn.disabled = false;
btn.style.opacity = '';
btn.style.pointerEvents = '';
});
return;
}
split = 1;
if (!panes[0] && activeActionId) panes[0] = activeActionId;
for (let i = 1; i < panes.length; i++) panes[i] = null;
$$('#splitSeg button', root).forEach((btn) => {
btn.classList.toggle('active', btn.dataset.split === '1');
btn.disabled = true;
btn.style.opacity = '0.6';
btn.style.pointerEvents = 'none';
});
}
async function loadActions() {
try {
const response = await api.get('/list_scripts', { timeout: 12000, retries: 1 });
const list = Array.isArray(response?.data) ? response.data : [];
const prev = new Map(actions.map((a) => [a.id, a.status]));
actions = list.map((raw) => normalizeAction(raw));
actions.forEach((a) => {
a.status = prev.get(a.id) || (a.is_running ? 'running' : 'ready');
if (!logsByAction.has(a.id)) logsByAction.set(a.id, []);
});
if (activeActionId && !actions.some((a) => a.id === activeActionId)) {
activeActionId = null;
empty(q('#argBuilder'));
empty(q('#presetChips'));
}
} catch (err) {
toast(`${tx('common.error', 'Error')}: ${err.message}`, 2600, 'error');
actions = [];
}
}
function normalizeAction(raw) {
const id = raw.b_module || (raw.name ? raw.name.replace(/\.py$/, '') : 'unknown');
let args = raw.b_args ?? {};
if (typeof args === 'string') {
try { args = JSON.parse(args); } catch { args = {}; }
}
let examples = raw.b_examples;
if (typeof examples === 'string') {
try { examples = JSON.parse(examples); } catch { examples = []; }
}
if (!Array.isArray(examples)) examples = [];
return {
id,
name: raw.name || raw.b_class || raw.b_module || 'Unnamed',
module: raw.b_module || raw.module || id,
bClass: raw.b_class || id,
category: (raw.b_action || raw.category || 'normal').toLowerCase(),
description: raw.description || tx('actions.description', 'Description'),
args,
icon: raw.b_icon || `/actions_icons/${encodeURIComponent(raw.b_class || id)}.png`,
version: raw.b_version || '',
author: raw.b_author || '',
docsUrl: raw.b_docs_url || '',
examples,
path: raw.path || raw.module_path || raw.b_module || id,
is_running: !!raw.is_running,
status: raw.is_running ? 'running' : 'ready',
};
}
function renderActionsList() {
const container = q('#actionsList');
if (!container) return;
empty(container);
const filtered = actions.filter((a) => {
if (!searchQuery) return true;
const hay = `${a.name} ${a.description} ${a.module} ${a.id} ${a.author} ${a.category}`.toLowerCase();
return searchQuery.split(/\s+/).every((term) => hay.includes(term));
});
if (!filtered.length) {
container.appendChild(el('div', { class: 'sub' }, [tx('actions.noActions', 'No actions found')]));
return;
}
for (const a of filtered) {
const row = el('div', { class: `al-row${a.id === activeActionId ? ' selected' : ''}`, draggable: 'true', 'data-action-id': a.id }, [
el('div', { class: 'ic' }, [
el('img', {
class: 'ic-img',
src: a.icon,
alt: '',
onerror: (e) => {
e.target.onerror = null;
e.target.src = '/actions/actions_icons/default.png';
},
}),
]),
el('div', {}, [
el('div', { class: 'name' }, [a.name]),
el('div', { class: 'desc' }, [a.description]),
]),
el('div', { class: `chip ${statusChipClass(a.status)}` }, [statusChipText(a.status)]),
]);
tracker.trackEventListener(row, 'click', () => onActionSelected(a.id));
tracker.trackEventListener(row, 'dragstart', (ev) => {
ev.dataTransfer?.setData('text/plain', a.id);
});
container.appendChild(row);
}
}
function statusChipClass(status) {
if (status === 'running') return 'run';
if (status === 'success') return 'ok';
if (status === 'error') return 'err';
return '';
}
function statusChipText(status) {
if (status === 'running') return tx('actions.running', 'Running');
if (status === 'success') return tx('common.success', 'Success');
if (status === 'error') return tx('common.error', 'Error');
return tx('common.ready', 'Ready');
}
function onActionSelected(actionId) {
activeActionId = actionId;
const action = actions.find((a) => a.id === actionId);
if (!action) return;
renderActionsList();
renderArguments(action);
if (assignTargetPaneIndex != null) {
panes[assignTargetPaneIndex] = actionId;
clearAssignTarget();
renderConsoles();
return;
}
const existing = panes.findIndex((id) => id === actionId);
if (existing >= 0) {
highlightPane(existing);
return;
}
const effectiveSplit = isMobile() ? 1 : split;
let target = panes.slice(0, effectiveSplit).findIndex((id) => !id);
if (target < 0) target = 0;
panes[target] = actionId;
renderConsoles();
}
function renderArguments(action) {
switchTab('arguments');
const builder = q('#argBuilder');
const chips = q('#presetChips');
if (!builder || !chips) return;
empty(builder);
empty(chips);
const metaBits = [];
if (action.version) metaBits.push(`v${action.version}`);
if (action.author) metaBits.push(`by ${action.author}`);
if (metaBits.length || action.docsUrl) {
const top = el('div', { style: 'display:flex;justify-content:space-between;gap:8px;align-items:center' }, [
el('div', { class: 'sub' }, [metaBits.join(' • ')]),
action.docsUrl
? el('a', { class: 'al-btn', href: action.docsUrl, target: '_blank', rel: 'noopener noreferrer' }, ['Docs'])
: null,
]);
builder.appendChild(top);
}
const entries = Object.entries(action.args || {});
if (!entries.length) {
builder.appendChild(el('div', { class: 'sub' }, [tx('actions.args.none', 'No configurable arguments')]));
}
for (const [key, cfgRaw] of entries) {
const cfg = cfgRaw && typeof cfgRaw === 'object' ? cfgRaw : { type: 'text', default: cfgRaw };
const field = el('div', { class: 'field' }, [
el('div', { class: 'label' }, [cfg.label || key]),
createArgControl(key, cfg),
cfg.help ? el('div', { class: 'sub' }, [cfg.help]) : null,
]);
builder.appendChild(field);
}
const presets = Array.isArray(action.examples) ? action.examples : [];
for (let i = 0; i < presets.length; i++) {
const p = presets[i];
const label = p.name || p.title || `Preset ${i + 1}`;
const btn = el('button', { class: 'chip2', type: 'button' }, [label]);
tracker.trackEventListener(btn, 'click', () => applyPreset(p));
chips.appendChild(btn);
}
}
function createArgControl(key, cfg) {
const tpe = cfg.type || 'text';
if (tpe === 'select') {
const sel = el('select', { class: 'select', 'data-arg': key });
const choices = Array.isArray(cfg.choices) ? cfg.choices : [];
for (const c of choices) {
const opt = el('option', { value: String(c) }, [String(c)]);
if (cfg.default != null && String(cfg.default) === String(c)) opt.selected = true;
sel.appendChild(opt);
}
return sel;
}
if (tpe === 'checkbox') {
const ctl = el('input', { type: 'checkbox', class: 'ctl', 'data-arg': key });
ctl.checked = !!cfg.default;
return ctl;
}
if (tpe === 'number') {
const attrs = {
type: 'number',
class: 'ctl',
'data-arg': key,
value: cfg.default != null ? String(cfg.default) : '',
};
if (cfg.min != null) attrs.min = String(cfg.min);
if (cfg.max != null) attrs.max = String(cfg.max);
if (cfg.step != null) attrs.step = String(cfg.step);
return el('input', attrs);
}
if (tpe === 'range' || tpe === 'slider') {
const min = cfg.min != null ? Number(cfg.min) : 0;
const max = cfg.max != null ? Number(cfg.max) : 100;
const val = cfg.default != null ? Number(cfg.default) : min;
const wrap = el('div', { style: 'display:grid;grid-template-columns:1fr auto;gap:8px;align-items:center' });
const range = el('input', {
type: 'range',
class: 'range',
'data-arg': key,
min: String(min),
max: String(max),
step: String(cfg.step != null ? cfg.step : 1),
value: String(val),
});
const out = el('span', { class: 'sub' }, [String(val)]);
tracker.trackEventListener(range, 'input', () => { out.textContent = range.value; });
wrap.appendChild(range);
wrap.appendChild(out);
return wrap;
}
return el('input', {
type: 'text',
class: 'ctl',
'data-arg': key,
value: cfg.default != null ? String(cfg.default) : '',
placeholder: cfg.placeholder || '',
});
}
function applyPreset(preset) {
const builder = q('#argBuilder');
if (!builder) return;
for (const [k, v] of Object.entries(preset || {})) {
if (k === 'name' || k === 'title') continue;
const input = builder.querySelector(`[data-arg="${k}"]`);
if (!input) continue;
if (input.type === 'checkbox') input.checked = !!v;
else input.value = String(v ?? '');
}
toast(tx('actions.toast.presetApplied', 'Preset applied'), 1400, 'success');
}
function collectArguments() {
const args = [];
const builder = q('#argBuilder');
if (builder) {
const controls = $$('[data-arg]', builder);
controls.forEach((ctl) => {
const key = ctl.getAttribute('data-arg');
const flag = '--' + String(key).replace(/_/g, '-');
if (ctl.type === 'checkbox') {
if (ctl.checked) args.push(flag);
return;
}
const value = String(ctl.value ?? '').trim();
if (!value) return;
args.push(flag, value);
});
}
const free = String(q('#freeArgs')?.value || '').trim();
if (free) args.push(...free.split(/\s+/));
return args.join(' ');
}
function renderConsoles() {
const container = q('#multiConsole');
if (!container) return;
const effectiveSplit = isMobile() ? 1 : split;
container.className = `multiConsole split-${effectiveSplit}`;
container.style.setProperty('--rows', effectiveSplit === 4 ? '2' : '1');
empty(container);
for (let i = effectiveSplit; i < panes.length; i++) panes[i] = null;
for (let i = 0; i < effectiveSplit; i++) {
const actionId = panes[i];
const action = actionId ? actions.find((a) => a.id === actionId) : null;
const pane = el('div', { class: 'pane', 'data-index': String(i) });
const title = el('div', { class: 'paneTitle' }, [
el('span', { class: 'dot', style: `background:${statusDotColor(action?.status || 'ready')}` }),
action ? el('img', {
class: 'paneIcon',
src: action.icon,
alt: '',
onerror: (e) => {
e.target.onerror = null;
e.target.src = '/actions/actions_icons/default.png';
},
}) : null,
el('div', { class: 'titleBlock' }, [
el('div', { class: 'titleLine' }, [el('strong', {}, [action ? action.name : tx('actions.emptyPane', '— Empty Pane —')])]),
action ? el('div', { class: 'metaLine' }, [
action.version ? el('span', { class: 'chip' }, ['v' + action.version]) : null,
action.author ? el('span', { class: 'chip' }, ['by ' + action.author]) : null,
]) : null,
]),
]);
const paneBtns = el('div', { class: 'paneBtns' });
if (!action) {
const assignBtn = el('button', { class: 'al-btn', type: 'button' }, [tx('actions.assign', 'Assign')]);
tracker.trackEventListener(assignBtn, 'click', () => setAssignTarget(i));
paneBtns.appendChild(assignBtn);
} else {
const runBtn = el('button', { class: 'al-btn', type: 'button' }, [tx('common.run', 'Run')]);
tracker.trackEventListener(runBtn, 'click', () => runActionInPane(i));
const stopBtn = el('button', { class: 'al-btn warn', type: 'button' }, [tx('common.stop', 'Stop')]);
tracker.trackEventListener(stopBtn, 'click', () => stopActionInPane(i));
const clearBtn = el('button', { class: 'al-btn', type: 'button' }, [tx('console.clear', 'Clear')]);
tracker.trackEventListener(clearBtn, 'click', () => clearActionLogs(action.id));
const exportBtn = el('button', { class: 'al-btn', type: 'button' }, ['⬇ Export']);
tracker.trackEventListener(exportBtn, 'click', () => exportActionLogs(action.id, action.name));
const autoBtn = el('button', { class: 'al-btn', type: 'button' }, [autoClearPane[i] ? 'Auto-clear ON' : 'Auto-clear OFF']);
if (autoClearPane[i]) autoBtn.classList.add('warn');
tracker.trackEventListener(autoBtn, 'click', () => {
autoClearPane[i] = !autoClearPane[i];
renderConsoles();
});
paneBtns.appendChild(runBtn);
paneBtns.appendChild(stopBtn);
paneBtns.appendChild(clearBtn);
paneBtns.appendChild(exportBtn);
paneBtns.appendChild(autoBtn);
}
const header = el('div', { class: 'paneHeader' }, [title, paneBtns]);
const log = el('div', { class: 'paneLog', id: `paneLog-${i}` });
pane.appendChild(header);
pane.appendChild(log);
container.appendChild(pane);
tracker.trackEventListener(pane, 'dragover', (e) => {
e.preventDefault();
pane.classList.add('paneHighlight');
});
tracker.trackEventListener(pane, 'dragleave', () => pane.classList.remove('paneHighlight'));
tracker.trackEventListener(pane, 'drop', (e) => {
e.preventDefault();
pane.classList.remove('paneHighlight');
const dropped = e.dataTransfer?.getData('text/plain');
if (!dropped) return;
panes[i] = dropped;
renderConsoles();
});
renderPaneLog(i, actionId);
}
}
function renderPaneLog(index, actionId) {
const logEl = q(`#paneLog-${index}`);
if (!logEl) return;
empty(logEl);
if (!actionId) {
logEl.appendChild(el('div', { class: 'logline dim' }, [tx('actions.logs.empty', 'Select an action to see logs')]));
return;
}
const lines = logsByAction.get(actionId) || [];
if (!lines.length) {
logEl.appendChild(el('div', { class: 'logline dim' }, [tx('actions.logs.waiting', 'Waiting for logs...')]));
return;
}
for (const line of lines) {
logEl.appendChild(el('div', { class: `logline ${logLineClass(line)}` }, [String(line)]));
}
logEl.scrollTop = logEl.scrollHeight;
}
function logLineClass(line) {
const l = String(line || '').toLowerCase();
if (l.includes('error') || l.includes('failed') || l.includes('traceback')) return 'err';
if (l.includes('warn')) return 'warn';
if (l.includes('success') || l.includes('done') || l.includes('complete')) return 'ok';
if (l.includes('info') || l.includes('start')) return 'info';
return 'dim';
}
function statusDotColor(status) {
if (status === 'running') return 'var(--acid)';
if (status === 'success') return 'var(--ok)';
if (status === 'error') return 'var(--danger)';
return 'var(--accent-2, #18f0ff)';
}
function setAssignTarget(index) {
assignTargetPaneIndex = index;
$$('.pane', root).forEach((p) => p.classList.remove('paneHighlight'));
q(`.pane[data-index="${index}"]`)?.classList.add('paneHighlight');
switchTab('actions');
}
function clearAssignTarget() {
assignTargetPaneIndex = null;
$$('.pane', root).forEach((p) => p.classList.remove('paneHighlight'));
}
function highlightPane(index) {
const pane = q(`.pane[data-index="${index}"]`);
if (!pane) return;
pane.classList.add('paneHighlight');
setTimeout(() => pane.classList.remove('paneHighlight'), 900);
}
async function runActionInPane(index) {
const actionId = panes[index] || activeActionId;
const action = actions.find((a) => a.id === actionId);
if (!action) {
toast(tx('actions.toast.selectActionFirst', 'Select an action first'), 1600, 'warning');
return;
}
if (!panes[index]) panes[index] = action.id;
if (autoClearPane[index]) clearActionLogs(action.id);
action.status = 'running';
renderActionsList();
renderConsoles();
const args = collectArguments();
appendActionLog(action.id, tx('actions.toast.startingAction', 'Starting {{name}}...').replace('{{name}}', action.name));
try {
const res = await api.post('/run_script', { script_name: action.module || action.id, args });
if (res.status !== 'success') throw new Error(res.message || 'Run failed');
startOutputPolling(action.id);
} catch (err) {
action.status = 'error';
appendActionLog(action.id, `Error: ${err.message}`);
renderActionsList();
renderConsoles();
toast(`${tx('common.error', 'Error')}: ${err.message}`, 2600, 'error');
}
}
async function stopActionInPane(index) {
const actionId = panes[index] || activeActionId;
const action = actions.find((a) => a.id === actionId);
if (!action) return;
try {
const res = await api.post('/stop_script', { script_name: action.path || action.module || action.id });
if (res.status !== 'success') throw new Error(res.message || 'Stop failed');
action.status = 'ready';
stopOutputPolling(action.id);
appendActionLog(action.id, tx('actions.toast.stoppedByUser', 'Stopped by user'));
renderActionsList();
renderConsoles();
} catch (err) {
toast(`${tx('actions.toast.failedToStop', 'Failed to stop')}: ${err.message}`, 2600, 'error');
}
}
function clearActionLogs(actionId) {
logsByAction.set(actionId, []);
for (let i = 0; i < panes.length; i++) if (panes[i] === actionId) renderPaneLog(i, actionId);
const action = actions.find((a) => a.id === actionId);
if (action) {
api.post('/clear_script_output', { script_name: action.path || action.module || action.id }).catch(() => {});
}
}
function exportActionLogs(actionId, actionName = 'action') {
const logs = logsByAction.get(actionId) || [];
if (!logs.length) {
toast(tx('actions.toast.noLogsToExport', 'No logs to export'), 1600, 'warning');
return;
}
const blob = new Blob([logs.join('\n')], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${actionName}_logs_${Date.now()}.txt`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
function appendActionLog(actionId, line) {
const list = logsByAction.get(actionId) || [];
list.push(line);
logsByAction.set(actionId, list);
for (let i = 0; i < panes.length; i++) if (panes[i] === actionId) renderPaneLog(i, actionId);
}
function startOutputPolling(actionId) {
stopOutputPolling(actionId);
const action = actions.find((a) => a.id === actionId);
if (!action) return;
const scriptPath = action.path || action.module || action.id;
const tick = async () => {
try {
const res = await api.get(`/get_script_output/${encodeURIComponent(scriptPath)}`, { timeout: 8000, retries: 0 });
if (res?.status !== 'success') throw new Error('Invalid output payload');
const data = res.data || {};
const output = Array.isArray(data.output) ? data.output : [];
logsByAction.set(actionId, output);
if (data.is_running) {
action.status = 'running';
renderActionsList();
for (let i = 0; i < panes.length; i++) if (panes[i] === actionId) renderPaneLog(i, actionId);
const id = setTimeout(tick, 1000);
pollingTimers.set(actionId, id);
return;
}
if (data.last_error) {
action.status = 'error';
appendActionLog(actionId, `Error: ${data.last_error}`);
} else {
action.status = 'success';
appendActionLog(actionId, tx('actions.logs.completed', 'Script completed'));
}
stopOutputPolling(actionId);
renderActionsList();
renderConsoles();
} catch {
// Keep trying while action is expected running.
if (action.status === 'running') {
const id = setTimeout(tick, 1200);
pollingTimers.set(actionId, id);
}
}
};
tick();
}
function stopOutputPolling(actionId) {
const timer = pollingTimers.get(actionId);
if (timer) {
clearTimeout(timer);
pollingTimers.delete(actionId);
}
}