mirror of
https://github.com/infinition/Bjorn.git
synced 2026-03-11 23:21:59 +00:00
- 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.
818 lines
26 KiB
JavaScript
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);
|
|
}
|
|
}
|