feat: Add login page with dynamic RGB effects and password toggle functionality

feat: Implement package management utilities with JSON endpoints for listing and uninstalling packages

feat: Create plugin management utilities with endpoints for listing, configuring, and installing plugins

feat: Develop schedule and trigger management utilities with CRUD operations for schedules and triggers
This commit is contained in:
infinition
2026-03-19 00:40:04 +01:00
parent 3fa4d5742a
commit b0584a1a8e
176 changed files with 7795 additions and 1781 deletions

View File

@@ -107,7 +107,7 @@ async function fetchActions(){
const r = await fetch(`${API_BASE}/studio/actions_studio`);
if(!r.ok) throw 0; const j = await r.json(); return Array.isArray(j)?j:(j.data||[]);
}catch{
// Fallback de démo
// Demo fallback data
return [
{ b_class:'NetworkScanner', b_module:'network_scanner', b_action:'global', b_trigger:'on_interval:600', b_priority:10, b_enabled:1, b_icon:'NetworkScanner.png' },
{ b_class:'SSHbruteforce', b_module:'ssh_bruteforce', b_trigger:'on_new_port:22', b_priority:70, b_enabled:1, b_port:22, b_service:'["ssh"]', b_icon:'SSHbruteforce.png' },
@@ -736,11 +736,11 @@ function evaluateHostToAction(link){
return {ok,label:link.label|| (link.mode==='trigger'?'trigger':'requires')};
}
// remplace complètement la fonction existante
// Repel overlapping nodes while keeping hosts pinned to their column
function repelLayout(iter = 16, str = 0.6) {
const HOST_X = 80; // X fixe pour la colonne des hosts (même valeur que lautolayout)
const TOP_Y = 60; // Y de départ de la colonne
const V_GAP = 160; // espacement vertical entre hosts
const HOST_X = 80; // Fixed X for host column (matches autoLayout)
const TOP_Y = 60; // Column start Y
const V_GAP = 160; // Vertical gap between hosts
const ids = [...state.nodes.keys()];
const boxes = ids.map(id => {
@@ -757,7 +757,7 @@ function repelLayout(iter = 16, str = 0.6) {
if (boxes.length < 2) { LinkEngine.render(); return; }
// pulsion douce en évitant de bouger les hosts en X
// Soft repulsion — hosts are locked on the X axis
for (let it = 0; it < iter; it++) {
for (let i = 0; i < boxes.length; i++) {
for (let j = i + 1; j < boxes.length; j++) {
@@ -769,16 +769,16 @@ function repelLayout(iter = 16, str = 0.6) {
const pushX = (ox/2) * str * Math.sign(dx || (Math.random() - .5));
const pushY = (oy/2) * str * Math.sign(dy || (Math.random() - .5));
// Sur laxe X, on NE BOUGE PAS les hosts
// Hosts stay pinned on X axis
const aCanX = a.type !== 'host';
const bCanX = b.type !== 'host';
if (ox > oy) { // pousser surtout en X
if (ox > oy) { // push mainly on X
if (aCanX && bCanX) { a.x -= pushX; a.cx -= pushX; b.x += pushX; b.cx += pushX; }
else if (aCanX) { a.x -= 2*pushX; a.cx -= 2*pushX; }
else if (bCanX) { b.x += 2*pushX; b.cx += 2*pushX; }
// sinon (deux hosts) : on ne touche pas laxe X
} else { // pousser surtout en Y (hosts OK en Y)
// both hosts — dont move X
} else { // push mainly on Y (hosts can move vertically)
a.y -= pushY; a.cy -= pushY;
b.y += pushY; b.cy += pushY;
}
@@ -787,11 +787,11 @@ function repelLayout(iter = 16, str = 0.6) {
}
}
// Snap final : hosts parfaitement en colonne et espacés régulièrement
// Final snap: align hosts into a uniform vertical column
const hosts = boxes.filter(b => b.type === 'host').sort((u, v) => u.y - v.y);
hosts.forEach((b, i) => { b.x = HOST_X; b.cx = b.x + b.w/2; b.y = TOP_Y + i * V_GAP; b.cy = b.y + b.h/2; });
// appliquer positions au DOM + state
// Apply positions to DOM + state
boxes.forEach(b => {
const n = state.nodes.get(b.id);
const el = document.querySelector(`[data-id="${b.id}"]`);
@@ -802,15 +802,15 @@ function repelLayout(iter = 16, str = 0.6) {
LinkEngine.render();
}
/* ===== Auto-layout: hosts en colonne verticale (X constant), actions à droite ===== */
/* ===== Auto-layout: hosts in vertical column (fixed X), actions to the right ===== */
function autoLayout(){
const col = new Map(); // id -> column
const set=(id,c)=>col.set(id, Math.max(c, col.get(id)??-Infinity));
// Colonne 0 = HOSTS
// Column 0 = HOSTS
state.nodes.forEach((n,id)=>{ if(n.type==='host') set(id,0); });
// Colonnes suivantes = actions (en fonction des dépendances action->action)
// Subsequent columns = actions (based on action->action dependencies)
const edges=[];
state.links.forEach(l=>{
const A=state.nodes.get(l.from), B=state.nodes.get(l.to);
@@ -831,7 +831,7 @@ function autoLayout(){
if(up.length===0) return 0;
return up.reduce((s,p)=> s + (state.nodes.get(p).y||0),0)/up.length;
};
// tri : hosts triés par hostname/IP/MAC pour une colonne bien lisible
// Sort hosts by hostname/IP/MAC for readable column ordering
ids.sort((a,b)=>{
if(c===0){
const na=state.nodes.get(a), nb=state.nodes.get(b);
@@ -847,8 +847,8 @@ function autoLayout(){
el.style.left=n.x+'px'; el.style.top=n.y+'px';
});
});
// à la fin d'autoLayout():
repelLayout(6, 0.4); // applique aussi le snap vertical des hosts
// Post-layout: repel overlaps + snap hosts vertically
repelLayout(6, 0.4);
toast(t('studio.autoLayoutApplied'),'success');
}
@@ -1381,7 +1381,7 @@ function isHostRuleInRequires(req){
function importActionsForHostsAndDeps(){
const aliveHosts=[...state.hosts.values()].filter(h=>parseInt(h.alive)==1);
// 1) actions liées aux hôtes (triggers/requires) => placer + lier
// 1) Place actions linked to hosts via triggers/requires and create edges
for(const a of state.actions.values()){
const matches = aliveHosts.filter(h=> hostMatchesActionByTriggers(a,h) || (isHostRuleInRequires(a.b_requires) && checkHostRequires(a.b_requires,h)) );
if(matches.length===0) continue;
@@ -1393,7 +1393,7 @@ function importActionsForHostsAndDeps(){
}
}
// 2) dépendances entre actions (on_success/on_failure + requires action)
// 2) Inter-action dependencies (on_success/on_failure + requires action)
state.nodes.forEach((nA,idA)=>{
if(nA.type!=='action') return;
const a=nA.data;
@@ -1413,14 +1413,12 @@ async function init(){
const actions=await fetchActions(); const hosts=await fetchHosts();
actions.forEach(a=>state.actions.set(a.b_class,a)); hosts.forEach(h=>state.hosts.set(h.mac_address,h));
// >>> plus de BJORN ni NetworkScanner auto-placés
// 1) Tous les hosts ALIVE sont importés (vertical)
// 1) Import all ALIVE hosts (vertical column)
placeAllAliveHosts();
buildPalette(); buildHostPalette();
// 2) Auto-import des actions dont trigger/require matchent les hôtes + liens
// 2) Auto-import actions whose triggers/requires match placed hosts + create links
importActionsForHostsAndDeps();
// 3) Layout + rendu

View File

@@ -83,6 +83,7 @@ function buildShell() {
const sideTabs = el('div', { class: 'tabs-container' }, [
el('button', { class: 'tab-btn active', id: 'tabBtnActions', type: 'button' }, [t('actions.tabs.actions')]),
el('button', { class: 'tab-btn', id: 'tabBtnArgs', type: 'button' }, [t('actions.tabs.arguments')]),
el('button', { class: 'tab-btn', id: 'tabBtnPkgs', type: 'button' }, ['Packages']),
]);
const sideHeader = el('div', { class: 'sideheader' }, [
@@ -122,7 +123,16 @@ function buildShell() {
el('div', { id: 'presetChips', class: 'chips' }),
]);
const sideContent = el('div', { class: 'sidecontent' }, [actionsSidebar, argsSidebar]);
const pkgsSidebar = el('div', { id: 'tab-packages', class: 'sidebar-page', style: 'display:none' }, [
el('div', { class: 'pkg-install-form' }, [
el('input', { type: 'text', class: 'pkg-install-input', placeholder: 'Package name (e.g. requests)', id: 'pkgNameInput' }),
el('button', { class: 'pkg-install-btn', type: 'button' }, ['Install']),
]),
el('div', { class: 'pkg-console', id: 'pkgConsole' }),
el('ul', { class: 'pkg-list', id: 'pkgList' }),
]);
const sideContent = el('div', { class: 'sidecontent' }, [actionsSidebar, argsSidebar, pkgsSidebar]);
const sidebarPanel = el('aside', { class: 'panel al-sidebar' }, [sideHeader, sideContent]);
@@ -149,11 +159,27 @@ function buildShell() {
}
function bindStaticEvents() {
// Hidden file input for custom script uploads
const fileInput = el('input', { type: 'file', accept: '.py', id: 'customScriptFileInput', style: 'display:none' });
root.appendChild(fileInput);
tracker.trackEventListener(fileInput, 'change', () => {
const file = fileInput.files?.[0];
if (file) {
uploadCustomScript(file);
fileInput.value = '';
}
});
const tabActions = q('#tabBtnActions');
const tabArgs = q('#tabBtnArgs');
const tabPkgs = q('#tabBtnPkgs');
if (tabActions) tracker.trackEventListener(tabActions, 'click', () => switchTab('actions'));
if (tabArgs) tracker.trackEventListener(tabArgs, 'click', () => switchTab('arguments'));
if (tabPkgs) tracker.trackEventListener(tabPkgs, 'click', () => switchTab('packages'));
const pkgInstallBtn = q('.pkg-install-btn');
if (pkgInstallBtn) tracker.trackEventListener(pkgInstallBtn, 'click', () => installPackage());
const searchInput = q('#searchInput');
if (searchInput) {
@@ -190,13 +216,19 @@ function switchTab(tab) {
currentTab = tab;
const tabActions = q('#tabBtnActions');
const tabArgs = q('#tabBtnArgs');
const tabPkgs = q('#tabBtnPkgs');
const actionsPane = q('#tab-actions');
const argsPane = q('#tab-arguments');
const pkgsPane = q('#tab-packages');
if (tabActions) tabActions.classList.toggle('active', tab === 'actions');
if (tabArgs) tabArgs.classList.toggle('active', tab === 'arguments');
if (tabPkgs) tabPkgs.classList.toggle('active', tab === 'packages');
if (actionsPane) actionsPane.style.display = tab === 'actions' ? '' : 'none';
if (argsPane) argsPane.style.display = tab === 'arguments' ? '' : 'none';
if (pkgsPane) pkgsPane.style.display = tab === 'packages' ? '' : 'none';
if (tab === 'packages') loadPackages();
}
function enforceMobileOnePane() {
@@ -275,6 +307,8 @@ function normalizeAction(raw) {
path: raw.path || raw.module_path || raw.b_module || id,
is_running: !!raw.is_running,
status: raw.is_running ? 'running' : 'ready',
isCustom: !!raw.is_custom,
scriptFormat: raw.script_format || 'bjorn',
};
}
@@ -294,32 +328,116 @@ function renderActionsList() {
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)]),
]);
const builtIn = filtered.filter((a) => a.category !== 'custom');
const custom = filtered.filter((a) => a.category === 'custom');
tracker.trackEventListener(row, 'click', () => onActionSelected(a.id));
tracker.trackEventListener(row, 'dragstart', (ev) => {
ev.dataTransfer?.setData('text/plain', a.id);
for (const a of builtIn) {
container.appendChild(buildActionRow(a));
}
// 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(row);
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));
}
}
function buildActionRow(a, isCustom = false) {
const badges = [];
if (isCustom) {
badges.push(el('span', { class: 'chip format-badge' }, [a.scriptFormat]));
}
const infoBlock = el('div', {}, [
el('div', { class: 'name' }, [a.name]),
el('div', { class: 'desc' }, [a.description]),
]);
const rowChildren = [
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';
},
}),
]),
infoBlock,
...badges,
el('div', { class: `chip ${statusChipClass(a.status)}` }, [statusChipText(a.status)]),
];
if (isCustom) {
const deleteBtn = el('button', { class: 'al-btn al-delete-btn', type: 'button', title: 'Delete script' }, ['\uD83D\uDDD1']);
tracker.trackEventListener(deleteBtn, 'click', (ev) => {
ev.stopPropagation();
deleteCustomScript(a.bClass);
});
rowChildren.push(deleteBtn);
}
const row = el('div', { class: `al-row${a.id === activeActionId ? ' selected' : ''}`, draggable: 'true', 'data-action-id': a.id }, rowChildren);
tracker.trackEventListener(row, 'click', () => onActionSelected(a.id));
tracker.trackEventListener(row, 'dragstart', (ev) => {
ev.dataTransfer?.setData('text/plain', a.id);
});
return row;
}
async function uploadCustomScript(file) {
const formData = new FormData();
formData.append('script_file', file);
try {
const resp = await fetch('/upload_custom_script', { method: 'POST', body: formData });
const data = await resp.json();
if (data.status === 'success') {
toast('Custom script uploaded', 1800, 'success');
await loadActions();
renderActionsList();
} else {
toast(`Upload failed: ${data.message || 'Unknown error'}`, 2600, 'error');
}
} catch (err) {
toast(`Upload error: ${err.message}`, 2600, 'error');
}
}
async function deleteCustomScript(bClass) {
if (!confirm(`Delete custom script "${bClass}"?`)) return;
try {
const resp = await api.post('/delete_custom_script', { script_name: bClass });
if (resp.status === 'success') {
toast('Custom script deleted', 1800, 'success');
await loadActions();
renderActionsList();
} else {
toast(`Delete failed: ${resp.message || 'Unknown error'}`, 2600, 'error');
}
} catch (err) {
toast(`Delete error: ${err.message}`, 2600, 'error');
}
}
@@ -814,3 +932,81 @@ function stopOutputPolling(actionId) {
pollingTimers.delete(actionId);
}
}
/* ── Package Management ────────────────────────────── */
async function installPackage() {
const input = document.getElementById('pkgNameInput');
const name = (input?.value || '').trim();
if (!name) return;
if (!/^[a-zA-Z0-9._-]+$/.test(name)) {
toast('Invalid package name', 3000, 'error');
return;
}
const consoleEl = document.getElementById('pkgConsole');
if (consoleEl) {
consoleEl.classList.add('active');
consoleEl.textContent = '';
}
const evtSource = new EventSource(`/api/packages/install?name=${encodeURIComponent(name)}`);
evtSource.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.line && consoleEl) {
consoleEl.textContent += data.line + '\n';
consoleEl.scrollTop = consoleEl.scrollHeight;
}
if (data.done) {
evtSource.close();
if (data.success) {
toast(`${name} installed successfully`, 3000, 'success');
loadPackages();
} else {
toast(`Failed to install ${name}`, 3000, 'error');
}
}
};
evtSource.onerror = () => {
evtSource.close();
toast('Install connection lost', 3000, 'error');
};
}
async function loadPackages() {
try {
const resp = await api.post('/api/packages/list', {});
if (resp.status === 'success') {
const list = document.getElementById('pkgList');
if (!list) return;
empty(list);
for (const pkg of resp.data) {
list.appendChild(el('li', { class: 'pkg-item' }, [
el('span', {}, [
el('span', { class: 'pkg-name' }, [pkg.name]),
el('span', { class: 'pkg-version' }, [pkg.version || '']),
]),
el('button', { class: 'pkg-uninstall-btn', type: 'button', onClick: () => uninstallPackage(pkg.name) }, ['Uninstall']),
]));
}
}
} catch (err) {
toast(`Failed to load packages: ${err.message}`, 2600, 'error');
}
}
async function uninstallPackage(name) {
if (!confirm(`Uninstall ${name}?`)) return;
try {
const resp = await api.post('/api/packages/uninstall', { name });
if (resp.status === 'success') {
toast(`${name} uninstalled`, 3000, 'success');
loadPackages();
} else {
toast(resp.message || 'Failed', 3000, 'error');
}
} catch (err) {
toast(`Uninstall error: ${err.message}`, 2600, 'error');
}
}

401
web/js/pages/plugins.js Normal file
View File

@@ -0,0 +1,401 @@
/**
* Plugins page - Install, configure, enable/disable, and uninstall plugins.
* @module pages/plugins
*/
import { api } from '../core/api.js';
import { $, el, escapeHtml, toast } from '../core/dom.js';
import { t } from '../core/i18n.js';
/* ------------------------------------------------------------------ */
/* State */
/* ------------------------------------------------------------------ */
let root = null;
let plugins = [];
let activeConfigId = null; // plugin ID whose config modal is open
const TYPE_BADGES = {
action: { label: 'Action', cls: 'badge-action' },
notifier: { label: 'Notifier', cls: 'badge-notifier' },
enricher: { label: 'Enricher', cls: 'badge-enricher' },
exporter: { label: 'Exporter', cls: 'badge-exporter' },
ui_widget:{ label: 'Widget', cls: 'badge-widget' },
};
const STATUS_LABELS = {
loaded: 'Loaded',
disabled: 'Disabled',
error: 'Error',
missing: 'Missing',
not_installed: 'Not installed',
};
/* ------------------------------------------------------------------ */
/* Lifecycle */
/* ------------------------------------------------------------------ */
export async function mount(container) {
root = el('div', { class: 'plugins-page' });
container.appendChild(root);
await loadPlugins();
render();
}
export function unmount() {
// Close config modal if open
const modal = document.getElementById('pluginConfigModal');
if (modal) modal.remove();
// Clear DOM reference (listeners on removed DOM are GC'd by browser)
if (root && root.parentNode) {
root.parentNode.removeChild(root);
}
root = null;
plugins = [];
activeConfigId = null;
}
/* ------------------------------------------------------------------ */
/* Data */
/* ------------------------------------------------------------------ */
async function loadPlugins() {
try {
const res = await api.get('/api/plugins/list', { timeout: 10000, retries: 0 });
plugins = Array.isArray(res?.data) ? res.data : [];
} catch {
plugins = [];
}
}
/* ------------------------------------------------------------------ */
/* Rendering */
/* ------------------------------------------------------------------ */
function render() {
if (!root) return;
root.innerHTML = '';
// Header
const header = el('div', { class: 'plugins-header' }, [
el('h1', {}, ['Plugins']),
el('div', { class: 'plugins-actions' }, [
buildInstallButton(),
el('button', {
class: 'btn btn-sm',
onclick: async () => { await loadPlugins(); render(); },
}, ['Reload']),
]),
]);
root.appendChild(header);
// Plugin count
const loaded = plugins.filter(p => p.status === 'loaded').length;
root.appendChild(el('p', { class: 'plugins-count' }, [
`${plugins.length} plugin(s) installed, ${loaded} active`
]));
// Cards
if (plugins.length === 0) {
root.appendChild(el('div', { class: 'plugins-empty' }, [
el('p', {}, ['No plugins installed.']),
el('p', {}, ['Drop a .zip plugin archive or use the Install button above.']),
]));
} else {
const grid = el('div', { class: 'plugins-grid' });
for (const p of plugins) {
grid.appendChild(buildPluginCard(p));
}
root.appendChild(grid);
}
// Config modal (if open)
if (activeConfigId) {
renderConfigModal(activeConfigId);
}
}
function buildPluginCard(p) {
const typeBadge = TYPE_BADGES[p.type] || { label: p.type, cls: '' };
const statusLabel = STATUS_LABELS[p.status] || p.status;
const statusCls = `status-${p.status}`;
const card = el('div', { class: `plugin-card ${p.enabled ? '' : 'plugin-disabled'}` }, [
// Top row: name + toggle
el('div', { class: 'plugin-card-head' }, [
el('div', { class: 'plugin-card-title' }, [
el('strong', {}, [escapeHtml(p.name || p.id)]),
el('span', { class: `plugin-type-badge ${typeBadge.cls}` }, [typeBadge.label]),
el('span', { class: `plugin-status ${statusCls}` }, [statusLabel]),
]),
buildToggle(p),
]),
// Info
el('div', { class: 'plugin-card-info' }, [
el('p', { class: 'plugin-desc' }, [escapeHtml(p.description || '')]),
el('div', { class: 'plugin-meta' }, [
el('span', {}, [`v${escapeHtml(p.version || '?')}`]),
p.author ? el('span', {}, [`by ${escapeHtml(p.author)}`]) : null,
]),
]),
// Hooks
p.hooks && p.hooks.length ? el('div', { class: 'plugin-hooks' },
p.hooks.map(h => el('span', { class: 'hook-badge' }, [h]))
) : null,
// Error message
p.error ? el('div', { class: 'plugin-error' }, [escapeHtml(p.error)]) : null,
// Dependencies warning
p.dependencies && !p.dependencies.ok
? el('div', { class: 'plugin-deps-warn' }, [
'Missing: ' + p.dependencies.missing.join(', ')
])
: null,
// Actions
el('div', { class: 'plugin-card-actions' }, [
p.has_config ? el('button', {
class: 'btn btn-sm',
onclick: () => openConfig(p.id),
}, ['Configure']) : null,
el('button', {
class: 'btn btn-sm btn-danger',
onclick: () => confirmUninstall(p.id, p.name),
}, ['Uninstall']),
]),
]);
return card;
}
function buildToggle(p) {
const toggle = el('label', { class: 'plugin-toggle' }, [
el('input', {
type: 'checkbox',
...(p.enabled ? { checked: 'checked' } : {}),
onchange: async (e) => {
const enabled = e.target.checked;
try {
await api.post('/api/plugins/toggle', { id: p.id, enabled: enabled ? 1 : 0 });
toast(`${p.name} ${enabled ? 'enabled' : 'disabled'}`, 2000, 'success');
await loadPlugins();
render();
} catch {
toast('Failed to toggle plugin', 2500, 'error');
e.target.checked = !enabled;
}
},
}),
el('span', { class: 'toggle-slider' }),
]);
return toggle;
}
function buildInstallButton() {
const fileInput = el('input', {
type: 'file',
accept: '.zip',
style: 'display:none',
onchange: async (e) => {
const file = e.target.files?.[0];
if (!file) return;
await installPlugin(file);
e.target.value = '';
},
});
const btn = el('button', {
class: 'btn btn-sm btn-primary',
onclick: () => fileInput.click(),
}, ['+ Install Plugin']);
return el('div', { style: 'display:inline-block' }, [fileInput, btn]);
}
/* ------------------------------------------------------------------ */
/* Config Modal */
/* ------------------------------------------------------------------ */
async function openConfig(pluginId) {
activeConfigId = pluginId;
renderConfigModal(pluginId);
}
async function renderConfigModal(pluginId) {
// Remove existing modal
const existing = $('#pluginConfigModal');
if (existing) existing.remove();
let schema = {};
let values = {};
try {
const res = await api.get(`/api/plugins/config?id=${encodeURIComponent(pluginId)}`, { timeout: 5000 });
if (res?.status === 'ok') {
schema = res.schema || {};
values = res.values || {};
}
} catch { /* keep defaults */ }
const fields = Object.entries(schema);
if (fields.length === 0) {
toast('No configurable settings', 2000, 'info');
activeConfigId = null;
return;
}
const form = el('div', { class: 'config-form' });
for (const [key, spec] of fields) {
const current = values[key] ?? spec.default ?? '';
const label = spec.label || key;
const inputType = spec.secret ? 'password' : 'text';
let input;
if (spec.type === 'bool' || spec.type === 'boolean') {
input = el('input', {
type: 'checkbox',
id: `cfg_${key}`,
'data-key': key,
...(current ? { checked: 'checked' } : {}),
});
} else if (spec.type === 'select' && Array.isArray(spec.choices)) {
input = el('select', { id: `cfg_${key}`, 'data-key': key },
spec.choices.map(c => el('option', {
value: c,
...(c === current ? { selected: 'selected' } : {}),
}, [String(c)]))
);
} else if (spec.type === 'number' || spec.type === 'int' || spec.type === 'float') {
input = el('input', {
type: 'number',
id: `cfg_${key}`,
'data-key': key,
value: String(current),
...(spec.min != null ? { min: String(spec.min) } : {}),
...(spec.max != null ? { max: String(spec.max) } : {}),
});
} else {
input = el('input', {
type: inputType,
id: `cfg_${key}`,
'data-key': key,
value: String(current),
placeholder: spec.placeholder || '',
});
}
form.appendChild(el('div', { class: 'config-field' }, [
el('label', { for: `cfg_${key}` }, [label]),
input,
spec.help ? el('small', { class: 'config-help' }, [spec.help]) : null,
]));
}
const modal = el('div', { class: 'modal-overlay', id: 'pluginConfigModal' }, [
el('div', { class: 'modal-content plugin-config-modal' }, [
el('div', { class: 'modal-header' }, [
el('h3', {}, [`Configure: ${escapeHtml(pluginId)}`]),
el('button', { class: 'modal-close', onclick: closeConfig }, ['X']),
]),
form,
el('div', { class: 'modal-footer' }, [
el('button', { class: 'btn', onclick: closeConfig }, ['Cancel']),
el('button', {
class: 'btn btn-primary',
onclick: () => saveConfig(pluginId),
}, ['Save']),
]),
]),
]);
(root || document.body).appendChild(modal);
}
function closeConfig() {
activeConfigId = null;
const modal = $('#pluginConfigModal');
if (modal) modal.remove();
}
async function saveConfig(pluginId) {
const modal = $('#pluginConfigModal');
if (!modal) return;
const config = {};
const inputs = modal.querySelectorAll('[data-key]');
for (const input of inputs) {
const key = input.getAttribute('data-key');
if (input.type === 'checkbox') {
config[key] = input.checked;
} else {
config[key] = input.value;
}
}
try {
const res = await api.post('/api/plugins/config', { id: pluginId, config });
if (res?.status === 'ok') {
toast('Configuration saved', 2000, 'success');
closeConfig();
} else {
toast(res?.message || 'Save failed', 2500, 'error');
}
} catch {
toast('Failed to save configuration', 2500, 'error');
}
}
/* ------------------------------------------------------------------ */
/* Install / Uninstall */
/* ------------------------------------------------------------------ */
async function installPlugin(file) {
try {
toast('Installing plugin...', 3000, 'info');
const formData = new FormData();
formData.append('plugin', file);
const res = await fetch('/api/plugins/install', {
method: 'POST',
body: formData,
});
const data = await res.json();
if (data?.status === 'ok') {
toast(`Plugin "${data.name || data.plugin_id}" installed`, 3000, 'success');
await loadPlugins();
render();
} else {
toast(data?.message || 'Install failed', 4000, 'error');
}
} catch (e) {
toast(`Install error: ${e.message}`, 4000, 'error');
}
}
function confirmUninstall(pluginId, name) {
if (!confirm(`Uninstall plugin "${name || pluginId}"? This will remove all plugin files.`)) {
return;
}
uninstallPlugin(pluginId);
}
async function uninstallPlugin(pluginId) {
try {
const res = await api.post('/api/plugins/uninstall', { id: pluginId });
if (res?.status === 'ok') {
toast('Plugin uninstalled', 2000, 'success');
await loadPlugins();
render();
} else {
toast(res?.message || 'Uninstall failed', 3000, 'error');
}
} catch {
toast('Failed to uninstall plugin', 3000, 'error');
}
}

View File

@@ -5,8 +5,9 @@
*/
import { ResourceTracker } from '../core/resource-tracker.js';
import { api, Poller } from '../core/api.js';
import { el, $, $$, empty } from '../core/dom.js';
import { el, $, $$, empty, toast } from '../core/dom.js';
import { t } from '../core/i18n.js';
import { buildConditionEditor, getConditions } from '../core/condition-builder.js';
const PAGE = 'scheduler';
const PAGE_SIZE = 100;
@@ -36,6 +37,12 @@ let iconCache = new Map();
/** Map<lane, Map<cardKey, DOM element>> for incremental updates */
let laneCardMaps = new Map();
/* ── tab state ── */
let activeTab = 'queue';
let schedulePoller = null;
let triggerPoller = null;
let scriptsList = [];
/* ── lifecycle ── */
export async function mount(container) {
tracker = new ResourceTracker(PAGE);
@@ -43,14 +50,16 @@ export async function mount(container) {
tracker.trackEventListener(window, 'keydown', (e) => {
if (e.key === 'Escape') closeModal();
});
await tick();
setLive(true);
fetchScriptsList();
switchTab('queue');
}
export function unmount() {
clearTimeout(searchDeb);
searchDeb = null;
if (poller) { poller.stop(); poller = null; }
if (schedulePoller) { schedulePoller.stop(); schedulePoller = null; }
if (triggerPoller) { triggerPoller.stop(); triggerPoller = null; }
if (clockTimer) { clearInterval(clockTimer); clockTimer = null; }
if (tracker) { tracker.cleanupAll(); tracker = null; }
lastBuckets = null;
@@ -58,6 +67,8 @@ export function unmount() {
lastFilterKey = '';
iconCache.clear();
laneCardMaps.clear();
scriptsList = [];
activeTab = 'queue';
LIVE = true; FOCUS = false; COMPACT = false;
COLLAPSED = false; INCLUDE_SUPERSEDED = false;
}
@@ -66,21 +77,38 @@ export function unmount() {
function buildShell() {
return el('div', { class: 'scheduler-container' }, [
el('div', { id: 'sched-errorBar', class: 'notice', style: 'display:none' }),
el('div', { class: 'controls' }, [
el('input', {
type: 'text', id: 'sched-search', placeholder: t('sched.filterPlaceholder'),
oninput: onSearch
}),
pill('sched-liveBtn', t('common.on'), true, () => setLive(!LIVE)),
pill('sched-refBtn', t('common.refresh'), false, () => tick()),
pill('sched-focBtn', t('sched.focusActive'), false, () => { FOCUS = !FOCUS; $('#sched-focBtn')?.classList.toggle('active', FOCUS); lastFilterKey = ''; tick(); }),
pill('sched-cmpBtn', t('sched.compact'), false, () => { COMPACT = !COMPACT; $('#sched-cmpBtn')?.classList.toggle('active', COMPACT); lastFilterKey = ''; tick(); }),
pill('sched-colBtn', t('sched.collapse'), false, toggleCollapse),
pill('sched-supBtn', INCLUDE_SUPERSEDED ? t('sched.hideSuperseded') : t('sched.showSuperseded'), false, toggleSuperseded),
el('span', { id: 'sched-stats', class: 'stats' }),
/* ── tab bar ── */
el('div', { class: 'sched-tabs' }, [
el('button', { class: 'sched-tab sched-tab-active', 'data-tab': 'queue', onclick: () => switchTab('queue') }, ['Queue']),
el('button', { class: 'sched-tab', 'data-tab': 'schedules', onclick: () => switchTab('schedules') }, ['Schedules']),
el('button', { class: 'sched-tab', 'data-tab': 'triggers', onclick: () => switchTab('triggers') }, ['Triggers']),
]),
el('div', { id: 'sched-boardWrap', class: 'boardWrap' }, [
el('div', { id: 'sched-board', class: 'board' }),
/* ── Queue tab content (existing kanban) ── */
el('div', { id: 'sched-tab-queue', class: 'sched-tab-content' }, [
el('div', { class: 'controls' }, [
el('input', {
type: 'text', id: 'sched-search', placeholder: t('sched.filterPlaceholder'),
oninput: onSearch
}),
pill('sched-liveBtn', t('common.on'), true, () => setLive(!LIVE)),
pill('sched-refBtn', t('common.refresh'), false, () => tick()),
pill('sched-focBtn', t('sched.focusActive'), false, () => { FOCUS = !FOCUS; $('#sched-focBtn')?.classList.toggle('active', FOCUS); lastFilterKey = ''; tick(); }),
pill('sched-cmpBtn', t('sched.compact'), false, () => { COMPACT = !COMPACT; $('#sched-cmpBtn')?.classList.toggle('active', COMPACT); lastFilterKey = ''; tick(); }),
pill('sched-colBtn', t('sched.collapse'), false, toggleCollapse),
pill('sched-supBtn', INCLUDE_SUPERSEDED ? t('sched.hideSuperseded') : t('sched.showSuperseded'), false, toggleSuperseded),
el('span', { id: 'sched-stats', class: 'stats' }),
]),
el('div', { id: 'sched-boardWrap', class: 'boardWrap' }, [
el('div', { id: 'sched-board', class: 'board' }),
]),
]),
/* ── Schedules tab content ── */
el('div', { id: 'sched-tab-schedules', class: 'sched-tab-content', style: 'display:none' }, [
buildSchedulesPanel(),
]),
/* ── Triggers tab content ── */
el('div', { id: 'sched-tab-triggers', class: 'sched-tab-content', style: 'display:none' }, [
buildTriggersPanel(),
]),
/* history modal */
el('div', {
@@ -103,6 +131,485 @@ function buildShell() {
]);
}
/* ── tab switching ── */
function switchTab(tab) {
activeTab = tab;
/* update tab buttons */
$$('.sched-tab').forEach(btn => {
btn.classList.toggle('sched-tab-active', btn.dataset.tab === tab);
});
/* show/hide tab content */
['queue', 'schedules', 'triggers'].forEach(id => {
const panel = $(`#sched-tab-${id}`);
if (panel) panel.style.display = id === tab ? '' : 'none';
});
/* stop all pollers first */
if (poller) { poller.stop(); poller = null; }
if (schedulePoller) { schedulePoller.stop(); schedulePoller = null; }
if (triggerPoller) { triggerPoller.stop(); triggerPoller = null; }
/* start relevant pollers */
if (tab === 'queue') {
tick();
setLive(true);
} else if (tab === 'schedules') {
refreshScheduleList();
schedulePoller = new Poller(refreshScheduleList, 10000, { immediate: false });
schedulePoller.start();
} else if (tab === 'triggers') {
refreshTriggerList();
triggerPoller = new Poller(refreshTriggerList, 10000, { immediate: false });
triggerPoller.start();
}
}
/* ── fetch scripts list ── */
async function fetchScriptsList() {
try {
const data = await api.get('/list_scripts', { timeout: 12000 });
scriptsList = Array.isArray(data) ? data : (data?.scripts || data?.actions || []);
} catch (e) {
scriptsList = [];
}
}
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]));
});
}
/* ══════════════════════════════════════════════════════════════════
SCHEDULES TAB
══════════════════════════════════════════════════════════════════ */
function buildSchedulesPanel() {
return el('div', { class: 'schedules-panel' }, [
buildScheduleForm(),
el('div', { id: 'sched-schedule-list' }),
]);
}
function buildScheduleForm() {
const typeToggle = el('select', { id: 'sched-sform-type', onchange: onScheduleTypeChange }, [
el('option', { value: 'recurring' }, ['Recurring']),
el('option', { value: 'oneshot' }, ['One-shot']),
]);
const presets = [
{ label: '60s', val: 60 }, { label: '5m', val: 300 }, { label: '15m', val: 900 },
{ label: '30m', val: 1800 }, { label: '1h', val: 3600 }, { label: '6h', val: 21600 },
{ label: '24h', val: 86400 },
];
const intervalRow = el('div', { id: 'sched-sform-interval-row' }, [
el('label', {}, ['Interval (seconds): ']),
el('input', { type: 'number', id: 'sched-sform-interval', min: '1', value: '300', style: 'width:100px' }),
el('span', { style: 'margin-left:8px' },
presets.map(p =>
el('button', {
class: 'pill', type: 'button', style: 'margin:0 2px',
onclick: () => { const inp = $('#sched-sform-interval'); if (inp) inp.value = p.val; }
}, [p.label])
)
),
]);
const runAtRow = el('div', { id: 'sched-sform-runat-row', style: 'display:none' }, [
el('label', {}, ['Run at: ']),
el('input', { type: 'datetime-local', id: 'sched-sform-runat' }),
]);
return el('div', { class: 'schedules-form' }, [
el('h3', {}, ['Create Schedule']),
el('div', { class: 'form-row' }, [
el('label', {}, ['Script: ']),
el('select', { id: 'sched-sform-script' }),
]),
el('div', { class: 'form-row' }, [
el('label', {}, ['Type: ']),
typeToggle,
]),
intervalRow,
runAtRow,
el('div', { class: 'form-row' }, [
el('label', {}, ['Args (optional): ']),
el('input', { type: 'text', id: 'sched-sform-args', placeholder: 'CLI arguments' }),
]),
el('div', { class: 'form-row' }, [
el('button', { class: 'btn', onclick: createSchedule }, ['Create']),
]),
]);
}
function onScheduleTypeChange() {
const type = $('#sched-sform-type')?.value;
const intervalRow = $('#sched-sform-interval-row');
const runAtRow = $('#sched-sform-runat-row');
if (intervalRow) intervalRow.style.display = type === 'recurring' ? '' : 'none';
if (runAtRow) runAtRow.style.display = type === 'oneshot' ? '' : 'none';
}
async function createSchedule() {
const script = $('#sched-sform-script')?.value;
if (!script) { toast('Please select a script', 2600, 'error'); return; }
const type = $('#sched-sform-type')?.value || 'recurring';
const args = $('#sched-sform-args')?.value || '';
const payload = { script, type, args };
if (type === 'recurring') {
payload.interval = 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; }
}
try {
await api.post('/api/schedules/create', payload);
toast('Schedule created');
refreshScheduleList();
} catch (e) {
toast('Failed to create schedule: ' + e.message, 3000, 'error');
}
}
async function refreshScheduleList() {
const container = $('#sched-schedule-list');
if (!container) return;
/* also refresh script selector */
const sel = $('#sched-sform-script');
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 || []);
renderScheduleList(container, schedules);
} catch (e) {
empty(container);
container.appendChild(el('div', { class: 'notice error' }, ['Failed to load schedules: ' + e.message]));
}
}
function renderScheduleList(container, schedules) {
empty(container);
if (!schedules.length) {
container.appendChild(el('div', { class: 'empty' }, ['No schedules configured']));
return;
}
schedules.forEach(s => {
const typeBadge = el('span', { class: `badge status-${s.type === 'recurring' ? 'running' : 'upcoming'}` }, [s.type || 'recurring']);
const timing = s.type === 'oneshot'
? `Run at: ${fmt(s.run_at)}`
: `Every ${ms2str((s.interval || 0) * 1000)}`;
const nextRun = s.next_run_at ? `Next: ${fmt(s.next_run_at)}` : '';
const statusBadge = s.last_status
? el('span', { class: `badge status-${s.last_status}` }, [s.last_status])
: el('span', { class: 'badge' }, ['never run']);
const toggleBtn = el('label', { class: 'toggle-switch' }, [
el('input', {
type: 'checkbox',
checked: s.enabled !== false,
onchange: () => toggleSchedule(s.id, !s.enabled)
}),
el('span', { class: 'toggle-slider' }),
]);
const deleteBtn = el('button', { class: 'btn danger', onclick: () => deleteSchedule(s.id) }, ['Delete']);
const editBtn = el('button', { class: 'btn', onclick: () => editScheduleInline(s) }, ['Edit']);
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 || '']),
]),
typeBadge,
toggleBtn,
]),
el('div', { class: 'meta' }, [
el('span', {}, [timing]),
nextRun ? el('span', {}, [nextRun]) : null,
el('span', {}, [`Runs: ${s.run_count || 0}`]),
statusBadge,
].filter(Boolean)),
s.args ? el('div', { class: 'kv' }, [el('span', {}, [`Args: ${s.args}`])]) : null,
el('div', { class: 'btns' }, [editBtn, deleteBtn]),
].filter(Boolean)));
});
}
async function toggleSchedule(id, enabled) {
try {
await api.post('/api/schedules/toggle', { id, enabled });
toast(enabled ? 'Schedule enabled' : 'Schedule disabled');
refreshScheduleList();
} catch (e) {
toast('Toggle failed: ' + e.message, 3000, 'error');
}
}
async function deleteSchedule(id) {
if (!confirm('Delete this schedule?')) return;
try {
await api.post('/api/schedules/delete', { id });
toast('Schedule deleted');
refreshScheduleList();
} catch (e) {
toast('Delete failed: ' + e.message, 3000, 'error');
}
}
function editScheduleInline(s) {
const card = $(`[data-schedule-id="${s.id}"]`);
if (!card) return;
empty(card);
const isRecurring = s.type === 'recurring';
card.appendChild(el('div', { class: 'schedules-form' }, [
el('h3', {}, ['Edit Schedule']),
el('div', { class: 'form-row' }, [
el('label', {}, ['Script: ']),
(() => {
const sel = el('select', { id: `sched-edit-script-${s.id}` });
populateScriptSelect(sel);
sel.value = s.script || '';
return sel;
})(),
]),
el('div', { class: 'form-row' }, [
el('label', {}, ['Type: ']),
(() => {
const sel = el('select', { id: `sched-edit-type-${s.id}` }, [
el('option', { value: 'recurring' }, ['Recurring']),
el('option', { value: 'oneshot' }, ['One-shot']),
]);
sel.value = s.type || 'recurring';
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('div', { class: 'form-row' }, [
el('label', {}, ['Run at: ']),
el('input', { type: 'datetime-local', id: `sched-edit-runat-${s.id}`, value: s.run_at || '' }),
]),
el('div', { class: 'form-row' }, [
el('label', {}, ['Args: ']),
el('input', { type: 'text', id: `sched-edit-args-${s.id}`, value: s.args || '' }),
]),
el('div', { class: 'form-row' }, [
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,
args: $(`#sched-edit-args-${s.id}`)?.value || '',
};
if (payload.type === 'recurring') {
payload.interval = parseInt($(`#sched-edit-interval-${s.id}`)?.value || '300', 10);
} else {
payload.run_at = $(`#sched-edit-runat-${s.id}`)?.value || '';
}
try {
await api.post('/api/schedules/update', payload);
toast('Schedule updated');
refreshScheduleList();
} catch (e) {
toast('Update failed: ' + e.message, 3000, 'error');
}
}}, ['Save']),
el('button', { class: 'btn warn', onclick: () => refreshScheduleList() }, ['Cancel']),
]),
]));
}
/* ══════════════════════════════════════════════════════════════════
TRIGGERS TAB
══════════════════════════════════════════════════════════════════ */
function buildTriggersPanel() {
return el('div', { class: 'triggers-panel' }, [
buildTriggerForm(),
el('div', { id: 'sched-trigger-list' }),
]);
}
function buildTriggerForm() {
const conditionContainer = el('div', { id: 'sched-tform-conditions' });
const form = el('div', { class: 'triggers-form' }, [
el('h3', {}, ['Create Trigger']),
el('div', { class: 'form-row' }, [
el('label', {}, ['Script: ']),
el('select', { id: 'sched-tform-script' }),
]),
el('div', { class: 'form-row' }, [
el('label', {}, ['Trigger name: ']),
el('input', { type: 'text', id: 'sched-tform-name', placeholder: 'Trigger name' }),
]),
el('div', { class: 'form-row' }, [
el('label', {}, ['Conditions:']),
conditionContainer,
]),
el('div', { class: 'form-row' }, [
el('label', {}, ['Cooldown (seconds): ']),
el('input', { type: 'number', id: 'sched-tform-cooldown', value: '60', min: '0', style: 'width:100px' }),
]),
el('div', { class: 'form-row' }, [
el('label', {}, ['Args (optional): ']),
el('input', { type: 'text', id: 'sched-tform-args', placeholder: 'CLI arguments' }),
]),
el('div', { class: 'form-row' }, [
el('button', { class: 'btn', onclick: testTriggerConditions }, ['Test Conditions']),
el('span', { id: 'sched-tform-test-result', style: 'margin-left:8px' }),
]),
el('div', { class: 'form-row' }, [
el('button', { class: 'btn', onclick: createTrigger }, ['Create Trigger']),
]),
]);
/* initialize condition builder after DOM is ready */
setTimeout(() => {
const cond = $('#sched-tform-conditions');
if (cond) buildConditionEditor(cond);
}, 0);
return form;
}
async function testTriggerConditions() {
const condContainer = $('#sched-tform-conditions');
const resultEl = $('#sched-tform-test-result');
if (!condContainer || !resultEl) return;
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)';
} catch (e) {
resultEl.textContent = 'Test failed: ' + e.message;
resultEl.style.color = 'var(--red, #f00)';
}
}
async function createTrigger() {
const script = $('#sched-tform-script')?.value;
const name = $('#sched-tform-name')?.value;
if (!script) { toast('Please select a script', 2600, 'error'); return; }
if (!name) { toast('Please enter a trigger name', 2600, 'error'); return; }
const condContainer = $('#sched-tform-conditions');
const conditions = condContainer ? getConditions(condContainer) : [];
const cooldown = parseInt($('#sched-tform-cooldown')?.value || '60', 10);
const args = $('#sched-tform-args')?.value || '';
try {
await api.post('/api/triggers/create', { script, name, conditions, cooldown, args });
toast('Trigger created');
$('#sched-tform-name').value = '';
refreshTriggerList();
} catch (e) {
toast('Failed to create trigger: ' + e.message, 3000, 'error');
}
}
async function refreshTriggerList() {
const container = $('#sched-trigger-list');
if (!container) return;
/* also refresh script selector */
const sel = $('#sched-tform-script');
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 || []);
renderTriggerList(container, triggers);
} catch (e) {
empty(container);
container.appendChild(el('div', { class: 'notice error' }, ['Failed to load triggers: ' + e.message]));
}
}
function renderTriggerList(container, triggers) {
empty(container);
if (!triggers.length) {
container.appendChild(el('div', { class: 'empty' }, ['No triggers configured']));
return;
}
triggers.forEach(trig => {
const condCount = Array.isArray(trig.conditions) ? trig.conditions.length : 0;
const toggleBtn = el('label', { class: 'toggle-switch' }, [
el('input', {
type: 'checkbox',
checked: trig.enabled !== false,
onchange: () => toggleTrigger(trig.id, !trig.enabled)
}),
el('span', { class: 'toggle-slider' }),
]);
const deleteBtn = el('button', { class: 'btn danger', onclick: () => deleteTrigger(trig.id) }, ['Delete']);
container.appendChild(el('div', { class: 'card' }, [
el('div', { class: 'cardHeader' }, [
el('div', { class: 'actionName' }, [
el('strong', {}, [trig.name || '']),
el('span', { style: 'margin-left:8px' }, [' \u2192 ']),
el('span', { class: 'chip', style: `--h:${hashHue(trig.script || '')}` }, [trig.script || '']),
]),
toggleBtn,
]),
el('div', { class: 'meta' }, [
el('span', {}, [`${condCount} condition${condCount !== 1 ? 's' : ''}`]),
el('span', {}, [`Cooldown: ${ms2str(( trig.cooldown || 0) * 1000)}`]),
el('span', {}, [`Fired: ${trig.fire_count || 0}`]),
trig.last_fired_at ? el('span', {}, [`Last: ${fmt(trig.last_fired_at)}`]) : null,
].filter(Boolean)),
trig.args ? el('div', { class: 'kv' }, [el('span', {}, [`Args: ${trig.args}`])]) : null,
el('div', { class: 'btns' }, [deleteBtn]),
].filter(Boolean)));
});
}
async function toggleTrigger(id, enabled) {
try {
await api.post('/api/triggers/toggle', { id, enabled });
toast(enabled ? 'Trigger enabled' : 'Trigger disabled');
refreshTriggerList();
} catch (e) {
toast('Toggle failed: ' + e.message, 3000, 'error');
}
}
async function deleteTrigger(id) {
if (!confirm('Delete this trigger?')) return;
try {
await api.post('/api/triggers/delete', { id });
toast('Trigger deleted');
refreshTriggerList();
} catch (e) {
toast('Delete failed: ' + e.message, 3000, 'error');
}
}
function pill(id, text, active, onclick) {
return el('span', { id, class: `pill ${active ? 'active' : ''}`, onclick }, [text]);
}