feat: enhance scheduler functionality and UI improvements

- Updated scheduler.js to improve tab switching and content display.
- Refactored script fetching logic to handle new data structure.
- Enhanced schedule creation and editing with updated payload structure.
- Improved trigger testing and creation with consistent naming conventions.
- Added new CSS styles for actions and plugins pages to enhance UI/UX.
- Introduced responsive design adjustments for better mobile experience.
This commit is contained in:
infinition
2026-03-19 16:59:55 +01:00
parent b0584a1a8e
commit b541ec1f61
14 changed files with 2178 additions and 1286 deletions

View File

@@ -19,6 +19,7 @@ let activeActionId = null;
let panes = [null, null, null, null];
let split = 1;
let assignTargetPaneIndex = null;
let focusedPaneIndex = 0;
let searchQuery = '';
let currentTab = 'actions';
@@ -30,6 +31,63 @@ function isMobile() {
return window.matchMedia('(max-width: 860px)').matches;
}
const STATE_KEY = 'bjorn.actions.state';
function saveState() {
try {
sessionStorage.setItem(STATE_KEY, JSON.stringify({
split,
panes,
activeActionId,
focusedPaneIndex,
autoClear: [...autoClearPane],
}));
} catch { /* noop */ }
}
function restoreState() {
try {
const raw = sessionStorage.getItem(STATE_KEY);
if (!raw) return false;
const s = JSON.parse(raw);
if (typeof s.split === 'number' && s.split >= 1 && s.split <= 4) split = s.split;
if (Array.isArray(s.panes)) {
panes = s.panes.slice(0, 4).map(v => v || null);
while (panes.length < 4) panes.push(null);
}
if (s.activeActionId) activeActionId = s.activeActionId;
if (typeof s.focusedPaneIndex === 'number') focusedPaneIndex = s.focusedPaneIndex;
if (Array.isArray(s.autoClear)) {
for (let i = 0; i < 4; i++) autoClearPane[i] = !!s.autoClear[i];
}
return true;
} catch { return false; }
}
async function recoverLogs() {
const seen = new Set();
for (const actionId of panes) {
if (!actionId || seen.has(actionId)) continue;
seen.add(actionId);
const action = actions.find(a => a.id === actionId);
if (!action) continue;
try {
const scriptPath = action.path || action.module || action.id;
const res = await api.get('/get_script_output/' + encodeURIComponent(scriptPath), { timeout: 8000, retries: 0 });
if (res?.status === 'success' && res.data) {
const output = Array.isArray(res.data.output) ? res.data.output : [];
if (output.length) logsByAction.set(actionId, output);
if (res.data.is_running) {
action.status = 'running';
startOutputPolling(actionId);
}
}
} catch { /* ignore */ }
}
renderActionsList();
renderConsoles();
}
function q(sel, base = root) { return base?.querySelector(sel) || null; }
export async function mount(container) {
@@ -47,11 +105,25 @@ export async function mount(container) {
enforceMobileOnePane();
await loadActions();
const restored = restoreState();
if (restored) {
// Validate pane assignments
for (let i = 0; i < panes.length; i++) {
if (panes[i] && !actions.some(a => a.id === panes[i])) panes[i] = null;
}
// Update split segment buttons
$$('#splitSeg button', root).forEach(btn =>
btn.classList.toggle('active', Number(btn.dataset.split) === split)
);
}
enforceMobileOnePane();
renderActionsList();
renderConsoles();
if (restored) recoverLogs();
}
export function unmount() {
saveState();
if (typeof sidebarLayoutCleanup === 'function') {
sidebarLayoutCleanup();
sidebarLayoutCleanup = null;
@@ -74,6 +146,7 @@ export function unmount() {
panes = [null, null, null, null];
split = 1;
assignTargetPaneIndex = null;
focusedPaneIndex = 0;
searchQuery = '';
currentTab = 'actions';
logsByAction.clear();
@@ -102,8 +175,15 @@ function buildShell() {
]),
]);
const actionsSidebar = el('div', { id: 'tab-actions', class: 'sidebar-page' }, [
el('div', { id: 'actionsList', class: 'al-list' }),
const actionsSidebar = el('div', { id: 'tab-actions', class: 'sidebar-page al-split-layout' }, [
el('div', { id: 'actionsList', class: 'al-list al-builtins-scroll' }),
el('div', { class: 'al-custom-section' }, [
el('div', { class: 'al-section-divider' }, [
el('span', { class: 'al-section-title' }, ['Custom Scripts']),
el('button', { class: 'al-btn al-upload-btn', type: 'button' }, ['\u2B06 Upload']),
]),
el('div', { id: 'customActionsList', class: 'al-list al-custom-scroll' }),
]),
]);
const argsSidebar = el('div', { id: 'tab-arguments', class: 'sidebar-page', style: 'display:none' }, [
@@ -170,6 +250,15 @@ function bindStaticEvents() {
}
});
// Wire upload button (now static in buildShell)
const uploadBtnStatic = q('.al-upload-btn');
if (uploadBtnStatic) {
tracker.trackEventListener(uploadBtnStatic, 'click', () => {
const fi = q('#customScriptFileInput');
if (fi) fi.click();
});
}
const tabActions = q('#tabBtnActions');
const tabArgs = q('#tabBtnArgs');
const tabPkgs = q('#tabBtnPkgs');
@@ -198,6 +287,7 @@ function bindStaticEvents() {
split = Number(btn.dataset.split || '1');
$$('#splitSeg button', root).forEach((b) => b.classList.toggle('active', b === btn));
renderConsoles();
saveState();
});
});
@@ -313,9 +403,11 @@ function normalizeAction(raw) {
}
function renderActionsList() {
const container = q('#actionsList');
if (!container) return;
empty(container);
const builtinContainer = q('#actionsList');
const customContainer = q('#customActionsList');
if (!builtinContainer) return;
empty(builtinContainer);
if (customContainer) empty(customContainer);
const filtered = actions.filter((a) => {
if (!searchQuery) return true;
@@ -323,40 +415,28 @@ function renderActionsList() {
return searchQuery.split(/\s+/).every((term) => hay.includes(term));
});
if (!filtered.length) {
container.appendChild(el('div', { class: 'sub' }, [t('actions.noActions')]));
return;
}
const builtIn = filtered.filter((a) => a.category !== 'custom');
const custom = filtered.filter((a) => a.category === 'custom');
if (!builtIn.length && !custom.length) {
builtinContainer.appendChild(el('div', { class: 'sub' }, [t('actions.noActions')]));
return;
}
for (const a of builtIn) {
container.appendChild(buildActionRow(a));
builtinContainer.appendChild(buildActionRow(a));
}
if (!builtIn.length) {
builtinContainer.appendChild(el('div', { class: 'sub' }, [t('actions.noActions')]));
}
// Custom Scripts section
const sectionHeader = el('div', { class: 'al-section-divider' }, [
el('span', { class: 'al-section-title' }, ['Custom Scripts']),
el('button', { class: 'al-btn al-upload-btn', type: 'button' }, ['\u2B06 Upload']),
]);
const uploadBtn = sectionHeader.querySelector('.al-upload-btn');
if (uploadBtn) {
tracker.trackEventListener(uploadBtn, 'click', () => {
const fileInput = q('#customScriptFileInput');
if (fileInput) fileInput.click();
});
}
container.appendChild(sectionHeader);
if (!custom.length) {
container.appendChild(el('div', { class: 'sub', style: 'padding:6px 12px' }, ['No custom scripts uploaded.']));
}
for (const a of custom) {
container.appendChild(buildActionRow(a, true));
if (customContainer) {
if (!custom.length) {
customContainer.appendChild(el('div', { class: 'sub', style: 'padding:6px 12px;font-size:11px' }, ['No custom scripts uploaded.']));
}
for (const a of custom) {
customContainer.appendChild(buildActionRow(a, true));
}
}
}
@@ -481,6 +561,7 @@ function onActionSelected(actionId) {
if (target < 0) target = 0;
panes[target] = actionId;
renderConsoles();
saveState();
}
function renderArguments(action) {
@@ -492,6 +573,10 @@ function renderArguments(action) {
empty(builder);
empty(chips);
builder.appendChild(el('div', { class: 'args-pane-label' }, [
`Pane ${focusedPaneIndex + 1}: ${action.name}`
]));
const metaBits = [];
if (action.version) metaBits.push(`v${action.version}`);
if (action.author) metaBits.push(t('actions.byAuthor', { author: action.author }));
@@ -726,6 +811,19 @@ function renderConsoles() {
if (!dropped) return;
panes[i] = dropped;
renderConsoles();
saveState();
});
tracker.trackEventListener(pane, 'click', () => {
focusedPaneIndex = i;
$$('.pane', root).forEach((p, idx) => p.classList.toggle('paneFocused', idx === i));
const pAction = actionId ? actions.find(a => a.id === actionId) : null;
if (pAction) {
activeActionId = pAction.id;
renderArguments(pAction);
renderActionsList();
}
saveState();
});
renderPaneLog(i, actionId);
@@ -799,6 +897,13 @@ async function runActionInPane(index) {
return;
}
// Auto-focus pane and render its args before collecting
if (focusedPaneIndex !== index) {
focusedPaneIndex = index;
$$('.pane', root).forEach((p, idx) => p.classList.toggle('paneFocused', idx === index));
if (action) renderArguments(action);
}
if (!panes[index]) panes[index] = action.id;
if (autoClearPane[index]) clearActionLogs(action.id);
@@ -813,6 +918,7 @@ async function runActionInPane(index) {
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);
saveState();
} catch (err) {
action.status = 'error';
appendActionLog(action.id, `Error: ${err.message}`);
@@ -836,6 +942,7 @@ async function stopActionInPane(index) {
appendActionLog(action.id, t('actions.toast.stoppedByUser'));
renderActionsList();
renderConsoles();
saveState();
} catch (err) {
toast(`${t('actions.toast.failedToStop')}: ${err.message}`, 2600, 'error');
}

View File

@@ -84,7 +84,7 @@ function buildShell() {
el('button', { class: 'sched-tab', 'data-tab': 'triggers', onclick: () => switchTab('triggers') }, ['Triggers']),
]),
/* ── Queue tab content (existing kanban) ── */
el('div', { id: 'sched-tab-queue', class: 'sched-tab-content' }, [
el('div', { id: 'sched-tab-queue', class: 'sched-tab-content active' }, [
el('div', { class: 'controls' }, [
el('input', {
type: 'text', id: 'sched-search', placeholder: t('sched.filterPlaceholder'),
@@ -103,11 +103,11 @@ function buildShell() {
]),
]),
/* ── Schedules tab content ── */
el('div', { id: 'sched-tab-schedules', class: 'sched-tab-content', style: 'display:none' }, [
el('div', { id: 'sched-tab-schedules', class: 'sched-tab-content' }, [
buildSchedulesPanel(),
]),
/* ── Triggers tab content ── */
el('div', { id: 'sched-tab-triggers', class: 'sched-tab-content', style: 'display:none' }, [
el('div', { id: 'sched-tab-triggers', class: 'sched-tab-content' }, [
buildTriggersPanel(),
]),
/* history modal */
@@ -143,7 +143,7 @@ function switchTab(tab) {
/* show/hide tab content */
['queue', 'schedules', 'triggers'].forEach(id => {
const panel = $(`#sched-tab-${id}`);
if (panel) panel.style.display = id === tab ? '' : 'none';
if (panel) panel.classList.toggle('active', id === tab);
});
/* stop all pollers first */
@@ -170,7 +170,7 @@ function switchTab(tab) {
async function fetchScriptsList() {
try {
const data = await api.get('/list_scripts', { timeout: 12000 });
scriptsList = Array.isArray(data) ? data : (data?.scripts || data?.actions || []);
scriptsList = Array.isArray(data) ? data : (data?.data || data?.scripts || data?.actions || []);
} catch (e) {
scriptsList = [];
}
@@ -180,8 +180,13 @@ function populateScriptSelect(selectEl) {
empty(selectEl);
selectEl.appendChild(el('option', { value: '' }, ['-- Select script --']));
scriptsList.forEach(s => {
const name = typeof s === 'string' ? s : (s.name || s.action_name || '');
if (name) selectEl.appendChild(el('option', { value: name }, [name]));
if (typeof s === 'string') {
if (s) selectEl.appendChild(el('option', { value: s }, [s]));
} else {
const value = s.b_module || s.b_class || s.name || '';
const label = s.name || value;
if (value) selectEl.appendChild(el('option', { value }, [label]));
}
});
}
@@ -263,9 +268,9 @@ async function createSchedule() {
const type = $('#sched-sform-type')?.value || 'recurring';
const args = $('#sched-sform-args')?.value || '';
const payload = { script, type, args };
const payload = { script_name: script, schedule_type: type, args };
if (type === 'recurring') {
payload.interval = parseInt($('#sched-sform-interval')?.value || '300', 10);
payload.interval_seconds = parseInt($('#sched-sform-interval')?.value || '300', 10);
} else {
payload.run_at = $('#sched-sform-runat')?.value || '';
if (!payload.run_at) { toast('Please set a run time', 2600, 'error'); return; }
@@ -289,8 +294,8 @@ async function refreshScheduleList() {
if (sel && sel.children.length <= 1) populateScriptSelect(sel);
try {
const data = await api.post('/api/schedules/list', {});
const schedules = Array.isArray(data) ? data : (data?.schedules || []);
const resp = await api.post('/api/schedules/list', {});
const schedules = Array.isArray(resp) ? resp : (resp?.data || []);
renderScheduleList(container, schedules);
} catch (e) {
empty(container);
@@ -306,10 +311,14 @@ function renderScheduleList(container, schedules) {
}
schedules.forEach(s => {
const typeBadge = el('span', { class: `badge status-${s.type === 'recurring' ? 'running' : 'upcoming'}` }, [s.type || 'recurring']);
const timing = s.type === 'oneshot'
const sType = s.schedule_type || s.type || 'recurring';
const sScript = s.script_name || s.script || '';
const sInterval = s.interval_seconds || s.interval || 0;
const typeBadge = el('span', { class: `badge status-${sType === 'recurring' ? 'running' : 'upcoming'}` }, [sType]);
const timing = sType === 'oneshot'
? `Run at: ${fmt(s.run_at)}`
: `Every ${ms2str((s.interval || 0) * 1000)}`;
: `Every ${ms2str(sInterval * 1000)}`;
const nextRun = s.next_run_at ? `Next: ${fmt(s.next_run_at)}` : '';
const statusBadge = s.last_status
@@ -319,8 +328,8 @@ function renderScheduleList(container, schedules) {
const toggleBtn = el('label', { class: 'toggle-switch' }, [
el('input', {
type: 'checkbox',
checked: s.enabled !== false,
onchange: () => toggleSchedule(s.id, !s.enabled)
checked: s.enabled !== false && s.enabled !== 0,
onchange: () => toggleSchedule(s.id, !(s.enabled !== false && s.enabled !== 0))
}),
el('span', { class: 'toggle-slider' }),
]);
@@ -331,7 +340,7 @@ function renderScheduleList(container, schedules) {
container.appendChild(el('div', { class: 'card', 'data-schedule-id': s.id }, [
el('div', { class: 'cardHeader' }, [
el('div', { class: 'actionName' }, [
el('span', { class: 'chip', style: `--h:${hashHue(s.script || '')}` }, [s.script || '']),
el('span', { class: 'chip', style: `--h:${hashHue(sScript)}` }, [sScript]),
]),
typeBadge,
toggleBtn,
@@ -375,7 +384,10 @@ function editScheduleInline(s) {
empty(card);
const isRecurring = s.type === 'recurring';
const sType = s.schedule_type || s.type || 'recurring';
const sScript = s.script_name || s.script || '';
const sInterval = s.interval_seconds || s.interval || 300;
const isRecurring = sType === 'recurring';
card.appendChild(el('div', { class: 'schedules-form' }, [
el('h3', {}, ['Edit Schedule']),
@@ -384,7 +396,7 @@ function editScheduleInline(s) {
(() => {
const sel = el('select', { id: `sched-edit-script-${s.id}` });
populateScriptSelect(sel);
sel.value = s.script || '';
sel.value = sScript;
return sel;
})(),
]),
@@ -395,14 +407,14 @@ function editScheduleInline(s) {
el('option', { value: 'recurring' }, ['Recurring']),
el('option', { value: 'oneshot' }, ['One-shot']),
]);
sel.value = s.type || 'recurring';
sel.value = sType;
return sel;
})(),
]),
isRecurring
? el('div', { class: 'form-row' }, [
el('label', {}, ['Interval (seconds): ']),
el('input', { type: 'number', id: `sched-edit-interval-${s.id}`, value: String(s.interval || 300), min: '1', style: 'width:100px' }),
el('input', { type: 'number', id: `sched-edit-interval-${s.id}`, value: String(sInterval), min: '1', style: 'width:100px' }),
])
: el('div', { class: 'form-row' }, [
el('label', {}, ['Run at: ']),
@@ -416,12 +428,12 @@ function editScheduleInline(s) {
el('button', { class: 'btn', onclick: async () => {
const payload = {
id: s.id,
script: $(`#sched-edit-script-${s.id}`)?.value,
type: $(`#sched-edit-type-${s.id}`)?.value,
script_name: $(`#sched-edit-script-${s.id}`)?.value,
schedule_type: $(`#sched-edit-type-${s.id}`)?.value,
args: $(`#sched-edit-args-${s.id}`)?.value || '',
};
if (payload.type === 'recurring') {
payload.interval = parseInt($(`#sched-edit-interval-${s.id}`)?.value || '300', 10);
if (payload.schedule_type === 'recurring') {
payload.interval_seconds = parseInt($(`#sched-edit-interval-${s.id}`)?.value || '300', 10);
} else {
payload.run_at = $(`#sched-edit-runat-${s.id}`)?.value || '';
}
@@ -499,9 +511,10 @@ async function testTriggerConditions() {
const conditions = getConditions(condContainer);
try {
const data = await api.post('/api/triggers/test', { conditions });
resultEl.textContent = data?.result ? 'Result: TRUE' : 'Result: FALSE';
resultEl.style.color = data?.result ? 'var(--green, #0f0)' : 'var(--red, #f00)';
const resp = await api.post('/api/triggers/test', { conditions });
const result = resp?.data?.result;
resultEl.textContent = result ? 'Result: TRUE' : 'Result: FALSE';
resultEl.style.color = result ? 'var(--green, #0f0)' : 'var(--red, #f00)';
} catch (e) {
resultEl.textContent = 'Test failed: ' + e.message;
resultEl.style.color = 'var(--red, #f00)';
@@ -520,7 +533,7 @@ async function createTrigger() {
const args = $('#sched-tform-args')?.value || '';
try {
await api.post('/api/triggers/create', { script, name, conditions, cooldown, args });
await api.post('/api/triggers/create', { script_name: script, trigger_name: name, conditions, cooldown_seconds: cooldown, args });
toast('Trigger created');
$('#sched-tform-name').value = '';
refreshTriggerList();
@@ -538,8 +551,8 @@ async function refreshTriggerList() {
if (sel && sel.children.length <= 1) populateScriptSelect(sel);
try {
const data = await api.post('/api/triggers/list', {});
const triggers = Array.isArray(data) ? data : (data?.triggers || []);
const resp = await api.post('/api/triggers/list', {});
const triggers = Array.isArray(resp) ? resp : (resp?.data || []);
renderTriggerList(container, triggers);
} catch (e) {
empty(container);
@@ -555,13 +568,21 @@ function renderTriggerList(container, triggers) {
}
triggers.forEach(trig => {
const condCount = Array.isArray(trig.conditions) ? trig.conditions.length : 0;
const tScript = trig.script_name || trig.script || '';
const tName = trig.trigger_name || trig.name || '';
const tCooldown = trig.cooldown_seconds || trig.cooldown || 0;
const tEnabled = trig.enabled !== false && trig.enabled !== 0;
// conditions may be a JSON string from DB
let conds = trig.conditions;
if (typeof conds === 'string') { try { conds = JSON.parse(conds); } catch { conds = null; } }
const condCount = conds && typeof conds === 'object' ? (Array.isArray(conds.conditions) ? conds.conditions.length : 1) : 0;
const toggleBtn = el('label', { class: 'toggle-switch' }, [
el('input', {
type: 'checkbox',
checked: trig.enabled !== false,
onchange: () => toggleTrigger(trig.id, !trig.enabled)
checked: tEnabled,
onchange: () => toggleTrigger(trig.id, !tEnabled)
}),
el('span', { class: 'toggle-slider' }),
]);
@@ -571,15 +592,15 @@ function renderTriggerList(container, triggers) {
container.appendChild(el('div', { class: 'card' }, [
el('div', { class: 'cardHeader' }, [
el('div', { class: 'actionName' }, [
el('strong', {}, [trig.name || '']),
el('strong', {}, [tName]),
el('span', { style: 'margin-left:8px' }, [' \u2192 ']),
el('span', { class: 'chip', style: `--h:${hashHue(trig.script || '')}` }, [trig.script || '']),
el('span', { class: 'chip', style: `--h:${hashHue(tScript)}` }, [tScript]),
]),
toggleBtn,
]),
el('div', { class: 'meta' }, [
el('span', {}, [`${condCount} condition${condCount !== 1 ? 's' : ''}`]),
el('span', {}, [`Cooldown: ${ms2str(( trig.cooldown || 0) * 1000)}`]),
el('span', {}, [`Cooldown: ${ms2str(tCooldown * 1000)}`]),
el('span', {}, [`Fired: ${trig.fire_count || 0}`]),
trig.last_fired_at ? el('span', {}, [`Last: ${fmt(trig.last_fired_at)}`]) : null,
].filter(Boolean)),
@@ -1149,14 +1170,19 @@ function showError(msg) {
}
/* ── icon resolution ── */
const ICON_DEFAULT = '/actions/actions_icons/default.png';
const ICON_PENDING = '__pending__';
function resolveIconSync(name) {
if (iconCache.has(name)) return iconCache.get(name);
const cached = iconCache.get(name);
if (cached === ICON_PENDING) return ICON_DEFAULT;
if (cached) return cached;
iconCache.set(name, ICON_PENDING);
resolveIconAsync(name);
return '/actions/actions_icons/default.png';
return ICON_DEFAULT;
}
async function resolveIconAsync(name) {
if (iconCache.has(name)) return;
const candidates = [
`/actions/actions_icons/${name}.png`,
`/resources/images/status/${name}/${name}.bmp`,
@@ -1167,7 +1193,7 @@ async function resolveIconAsync(name) {
if (r.ok) { iconCache.set(name, url); updateIconsInDOM(name, url); return; }
} catch { /* next */ }
}
iconCache.set(name, '/actions/actions_icons/default.png');
iconCache.set(name, ICON_DEFAULT);
}
function updateIconsInDOM(name, url) {