mirror of
https://github.com/infinition/Bjorn.git
synced 2026-03-19 10:10:24 +00:00
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:
@@ -489,9 +489,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•
|
||||
/* ==========================================================================
|
||||
BACKUP & UPDATE (.page-backup)
|
||||
â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â• */
|
||||
========================================================================== */
|
||||
.page-backup .main-container {
|
||||
display: flex;
|
||||
height: calc(100vh - 60px);
|
||||
@@ -735,9 +735,9 @@
|
||||
border: #007acc;
|
||||
}
|
||||
|
||||
/* â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•
|
||||
/* ==========================================================================
|
||||
WEB ENUM (.webenum-container)
|
||||
â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â• */
|
||||
========================================================================== */
|
||||
.webenum-container .container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
@@ -1137,9 +1137,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•
|
||||
/* ==========================================================================
|
||||
ZOMBIELAND C2C (.zombieland-container)
|
||||
â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â• */
|
||||
========================================================================== */
|
||||
.zombieland-container .panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--c-border);
|
||||
@@ -1622,9 +1622,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•
|
||||
/* ==========================================================================
|
||||
ACTIONS LAUNCHER (.actions-container)
|
||||
â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â• */
|
||||
========================================================================== */
|
||||
.actions-container #actionsLauncher {
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
@@ -2131,9 +2131,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•
|
||||
/* ==========================================================================
|
||||
ACTIONS STUDIO (.studio-container)
|
||||
â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â• */
|
||||
========================================================================== */
|
||||
.studio-container {
|
||||
--st-bg: #060c12;
|
||||
--st-panel: #0a1520;
|
||||
|
||||
@@ -1,6 +1,155 @@
|
||||
/* ==========================================================================
|
||||
SCHEDULER
|
||||
========================================================================== */
|
||||
|
||||
/* ===== Tab bar ===== */
|
||||
.sched-tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: .5rem .6rem 0;
|
||||
border-bottom: 1px solid var(--c-border);
|
||||
background: var(--panel);
|
||||
}
|
||||
.sched-tab {
|
||||
padding: 6px 16px;
|
||||
border: 1px solid transparent;
|
||||
border-bottom: none;
|
||||
border-radius: 8px 8px 0 0;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
.sched-tab:hover { color: var(--ink); background: color-mix(in oklab, var(--c-panel-2) 60%, transparent); }
|
||||
.sched-tab.sched-tab-active {
|
||||
color: var(--acid);
|
||||
background: var(--c-panel-2);
|
||||
border-color: var(--c-border);
|
||||
}
|
||||
.sched-tab-content { display: none; padding: .6rem; }
|
||||
.sched-tab-content.active { display: block; }
|
||||
|
||||
/* ===== Schedule / Trigger cards ===== */
|
||||
.schedule-list, .trigger-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.schedule-card, .trigger-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: 8px;
|
||||
background: var(--c-panel);
|
||||
font-size: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.schedule-card:hover, .trigger-card:hover {
|
||||
border-color: var(--c-border-strong);
|
||||
}
|
||||
.sched-script-name { font-weight: 700; color: var(--ink); }
|
||||
.sched-badge {
|
||||
display: inline-block;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.sched-badge-recurring { background: color-mix(in oklab, var(--acid) 18%, transparent); color: var(--acid); }
|
||||
.sched-badge-oneshot { background: color-mix(in oklab, #f59e0b 18%, transparent); color: #f59e0b; }
|
||||
.sched-badge-success { background: color-mix(in oklab, #22c55e 18%, transparent); color: #22c55e; }
|
||||
.sched-badge-error { background: color-mix(in oklab, #ef4444 18%, transparent); color: #ef4444; }
|
||||
.sched-badge-running { background: color-mix(in oklab, #3b82f6 18%, transparent); color: #3b82f6; }
|
||||
.sched-meta { color: var(--muted); font-size: 11px; }
|
||||
.sched-actions { display: flex; gap: 4px; align-items: center; }
|
||||
.sched-actions button {
|
||||
padding: 3px 8px;
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.sched-actions button:hover { color: var(--ink); border-color: var(--c-border-strong); }
|
||||
.sched-actions button.sched-delete:hover { color: #ef4444; border-color: #ef4444; }
|
||||
|
||||
/* Toggle switch */
|
||||
.sched-toggle {
|
||||
position: relative;
|
||||
width: 32px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.sched-toggle input { opacity: 0; width: 0; height: 0; }
|
||||
.sched-toggle-slider {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 9px;
|
||||
background: var(--c-border);
|
||||
transition: background .2s;
|
||||
}
|
||||
.sched-toggle-slider::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
left: 2px;
|
||||
bottom: 2px;
|
||||
border-radius: 50%;
|
||||
background: var(--ink);
|
||||
transition: transform .2s;
|
||||
}
|
||||
.sched-toggle input:checked + .sched-toggle-slider { background: var(--acid); }
|
||||
.sched-toggle input:checked + .sched-toggle-slider::before { transform: translateX(14px); }
|
||||
|
||||
/* ===== Create form (schedules & triggers) ===== */
|
||||
.sched-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: flex-end;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: 8px;
|
||||
background: var(--c-panel);
|
||||
}
|
||||
.sched-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.sched-form input, .sched-form select {
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-size: 12px;
|
||||
}
|
||||
.sched-form button {
|
||||
padding: 5px 14px;
|
||||
border: 1px solid var(--acid);
|
||||
border-radius: 4px;
|
||||
background: color-mix(in oklab, var(--acid) 15%, transparent);
|
||||
color: var(--acid);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.sched-form button:hover { background: color-mix(in oklab, var(--acid) 25%, transparent); }
|
||||
.sched-form-section { width: 100%; margin-top: 6px; }
|
||||
.sched-empty-msg { color: var(--muted); font-size: 12px; padding: 12px 0; text-align: center; }
|
||||
|
||||
.scheduler-container .toolbar-top {
|
||||
position: sticky;
|
||||
top: calc(var(--h-topbar, 0px) + 5px);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* ==========================================================================
|
||||
pages.css — Page-specific styles for all SPA page modules.
|
||||
Page-specific styles for all SPA page modules.
|
||||
Each section is scoped under the page's wrapper class to avoid conflicts.
|
||||
========================================================================== */
|
||||
|
||||
@@ -154,6 +154,201 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ===== Condition Builder ===== */
|
||||
|
||||
.cond-editor { padding: 4px 0; }
|
||||
|
||||
.cond-group {
|
||||
border-left: 3px solid var(--acid);
|
||||
padding: 8px 8px 8px 12px;
|
||||
margin: 4px 0;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in oklab, var(--c-panel-2) 50%, transparent);
|
||||
}
|
||||
.cond-group-or { border-left-color: #f59e0b; }
|
||||
.cond-group-and { border-left-color: var(--acid); }
|
||||
|
||||
.cond-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.cond-op-toggle {
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--c-border);
|
||||
background: var(--c-panel);
|
||||
color: var(--ink);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cond-children { display: flex; flex-direction: column; gap: 4px; }
|
||||
|
||||
.cond-item-wrapper {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
.cond-item-wrapper > .cond-group,
|
||||
.cond-item-wrapper > .cond-block { flex: 1; min-width: 0; }
|
||||
|
||||
.cond-block {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: 4px;
|
||||
background: var(--c-panel);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cond-source-select {
|
||||
padding: 2px 6px;
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-size: 11px;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.cond-params {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cond-param-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.cond-param-name { white-space: nowrap; }
|
||||
|
||||
.cond-param-input {
|
||||
padding: 2px 6px;
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: 3px;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-size: 11px;
|
||||
width: 80px;
|
||||
}
|
||||
.cond-param-input[type="number"] { width: 60px; }
|
||||
select.cond-param-input { width: auto; min-width: 60px; }
|
||||
|
||||
.cond-delete-btn {
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.cond-delete-btn:hover { color: #ef4444; background: rgba(239,68,68,.12); }
|
||||
|
||||
.cond-group-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.cond-add-btn {
|
||||
padding: 2px 10px;
|
||||
border: 1px dashed var(--c-border);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.cond-add-btn:hover { color: var(--acid); border-color: var(--acid); }
|
||||
|
||||
/* ===== Package Manager (actions page sidebar) ===== */
|
||||
|
||||
.pkg-list { list-style: none; padding: 0; margin: 0; }
|
||||
.pkg-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid color-mix(in oklab, var(--c-border) 40%, transparent);
|
||||
font-size: 12px;
|
||||
}
|
||||
.pkg-item:last-child { border-bottom: none; }
|
||||
.pkg-name { font-weight: 600; color: var(--ink); }
|
||||
.pkg-version { color: var(--muted); font-size: 11px; margin-left: 6px; }
|
||||
.pkg-uninstall-btn {
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pkg-uninstall-btn:hover { color: #ef4444; border-color: #ef4444; }
|
||||
|
||||
.pkg-install-form {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.pkg-install-input {
|
||||
flex: 1;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-size: 12px;
|
||||
}
|
||||
.pkg-install-btn {
|
||||
padding: 4px 12px;
|
||||
border: 1px solid var(--acid);
|
||||
border-radius: 4px;
|
||||
background: color-mix(in oklab, var(--acid) 15%, transparent);
|
||||
color: var(--acid);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pkg-install-btn:hover { background: color-mix(in oklab, var(--acid) 25%, transparent); }
|
||||
|
||||
.pkg-console {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
margin-top: 8px;
|
||||
display: none;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
.pkg-console.active { display: block; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.page-with-sidebar {
|
||||
min-height: calc(100vh - var(--h-topbar, 56px) - var(--h-bottombar, 56px) - 12px);
|
||||
|
||||
@@ -257,18 +257,18 @@ body.console-docked .app-container {
|
||||
|
||||
#bjornSay {
|
||||
white-space: normal;
|
||||
/* autorise le retour à la ligne */
|
||||
/* allow word wrapping */
|
||||
word-break: break-word;
|
||||
line-height: 1.25;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
/* centre verticalement dans la bottombar */
|
||||
/* vertically center in the bottombar */
|
||||
height: 100%;
|
||||
|
||||
text-align: right;
|
||||
max-width: 240px;
|
||||
/* évite qu’il déborde vers le centre */
|
||||
/* prevent overflow toward center */
|
||||
}
|
||||
|
||||
/* ---- Console panel (matches old global.css console) ---- */
|
||||
|
||||
@@ -65,6 +65,7 @@ function bootUI() {
|
||||
router.route('/bjorn', () => import('./pages/bjorn.js'));
|
||||
router.route('/llm-chat', () => import('./pages/llm-chat.js'));
|
||||
router.route('/llm-config', () => import('./pages/llm-config.js'));
|
||||
router.route('/plugins', () => import('./pages/plugins.js'));
|
||||
|
||||
// 404 fallback
|
||||
router.setNotFound((container, path) => {
|
||||
@@ -150,7 +151,7 @@ function ensureBjornProgress() {
|
||||
const host = document.querySelector('.status-left .status-text');
|
||||
if (!host) return;
|
||||
|
||||
if (document.getElementById('bjornProgress')) return; // déjà là
|
||||
if (document.getElementById('bjornProgress')) return;
|
||||
|
||||
const progress = el('div', {
|
||||
id: 'bjornProgress',
|
||||
@@ -165,7 +166,7 @@ function ensureBjornProgress() {
|
||||
}
|
||||
|
||||
function startGlobalPollers() {
|
||||
// Status (Toutes les 6s)
|
||||
// Status poll (every 6s)
|
||||
const statusPoller = new Poller(async () => {
|
||||
try {
|
||||
const data = await api.get('/bjorn_status', { timeout: 5000, retries: 0 });
|
||||
@@ -208,7 +209,7 @@ function startGlobalPollers() {
|
||||
} catch (e) { }
|
||||
}, 6000);
|
||||
|
||||
// Character (Toutes les 10s - C'est suffisant pour une icône)
|
||||
// Character icon poll (every 10s)
|
||||
const charPoller = new Poller(async () => {
|
||||
try {
|
||||
const imgEl = $('#bjorncharacter');
|
||||
@@ -221,7 +222,7 @@ function startGlobalPollers() {
|
||||
} catch (e) { }
|
||||
}, 10000);
|
||||
|
||||
// Say (Toutes les 8s)
|
||||
// Say text poll (every 8s)
|
||||
const sayPoller = new Poller(async () => {
|
||||
try {
|
||||
const data = await api.get('/bjorn_say', { timeout: 5000, retries: 0 });
|
||||
@@ -263,7 +264,7 @@ function wireTopbar() {
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
* Liveview dropdown (BÉTON EDITION)
|
||||
* Liveview dropdown
|
||||
* Uses recursive setTimeout to prevent thread stacking
|
||||
* ========================================= */
|
||||
|
||||
@@ -279,19 +280,19 @@ function wireLiveview() {
|
||||
|
||||
const liveImg = $('#screenImage_Home', dropdown);
|
||||
let timer = null;
|
||||
const LIVE_DELAY = 4000; // On passe à 4s pour matcher display.py
|
||||
const LIVE_DELAY = 4000; // 4s to match display.py refresh rate
|
||||
|
||||
function updateLive() {
|
||||
if (dropdown.style.display !== 'block') return; // Stop si caché
|
||||
if (dropdown.style.display !== 'block') return; // stop if hidden
|
||||
|
||||
const n = new Image();
|
||||
n.onload = () => {
|
||||
liveImg.src = n.src;
|
||||
// On ne planifie la suivante QUE quand celle-ci est affichée
|
||||
// Schedule next frame only after current one is rendered
|
||||
timer = setTimeout(updateLive, LIVE_DELAY);
|
||||
};
|
||||
n.onerror = () => {
|
||||
// En cas d'erreur, on attend un peu avant de réessayer
|
||||
// On error, wait longer before retrying
|
||||
timer = setTimeout(updateLive, LIVE_DELAY * 2);
|
||||
};
|
||||
n.src = '/web/screen.png?t=' + Date.now();
|
||||
@@ -420,6 +421,7 @@ const PAGES = [
|
||||
{ path: '/bjorn-debug', icon: 'database.png', label: 'Bjorn Debug' },
|
||||
{ path: '/llm-chat', icon: 'ai.png', label: 'nav.llm_chat' },
|
||||
{ path: '/llm-config', icon: 'ai_dashboard.png', label: 'nav.llm_config' },
|
||||
{ path: '/plugins', icon: 'actions_launcher.png', label: 'nav.plugins' },
|
||||
];
|
||||
|
||||
function wireLauncher() {
|
||||
|
||||
280
web/js/core/condition-builder.js
Normal file
280
web/js/core/condition-builder.js
Normal file
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* condition-builder.js - Visual block-based condition editor for triggers.
|
||||
* Produces/consumes JSON condition trees with AND/OR groups + leaf conditions.
|
||||
*/
|
||||
import { el, empty } from './dom.js';
|
||||
|
||||
// Condition source definitions (drives the parameter UI)
|
||||
const SOURCES = {
|
||||
action_result: {
|
||||
label: 'Action Result',
|
||||
params: [
|
||||
{ key: 'action', type: 'text', placeholder: 'e.g. scanning', label: 'Action' },
|
||||
{ key: 'check', type: 'select', choices: ['eq', 'neq'], label: 'Check' },
|
||||
{ key: 'value', type: 'select', choices: ['success', 'failed'], label: 'Value' },
|
||||
],
|
||||
},
|
||||
hosts_with_port: {
|
||||
label: 'Hosts with Port',
|
||||
params: [
|
||||
{ key: 'port', type: 'number', placeholder: '22', label: 'Port' },
|
||||
{ key: 'check', type: 'select', choices: ['gt', 'lt', 'eq', 'gte', 'lte'], label: 'Op' },
|
||||
{ key: 'value', type: 'number', placeholder: '0', label: 'Count' },
|
||||
],
|
||||
},
|
||||
hosts_alive: {
|
||||
label: 'Hosts Alive',
|
||||
params: [
|
||||
{ key: 'check', type: 'select', choices: ['gt', 'lt', 'eq', 'gte', 'lte'], label: 'Op' },
|
||||
{ key: 'value', type: 'number', placeholder: '0', label: 'Count' },
|
||||
],
|
||||
},
|
||||
cred_found: {
|
||||
label: 'Credentials Found',
|
||||
params: [
|
||||
{ key: 'service', type: 'text', placeholder: 'e.g. ssh, ftp', label: 'Service' },
|
||||
],
|
||||
},
|
||||
has_vuln: {
|
||||
label: 'Has Vulnerabilities',
|
||||
params: [],
|
||||
},
|
||||
db_count: {
|
||||
label: 'DB Row Count',
|
||||
params: [
|
||||
{ key: 'table', type: 'select', choices: ['hosts', 'creds', 'vulnerabilities', 'services'], label: 'Table' },
|
||||
{ key: 'check', type: 'select', choices: ['gt', 'lt', 'eq', 'gte', 'lte'], label: 'Op' },
|
||||
{ key: 'value', type: 'number', placeholder: '0', label: 'Count' },
|
||||
],
|
||||
},
|
||||
time_after: {
|
||||
label: 'Time After',
|
||||
params: [
|
||||
{ key: 'hour', type: 'number', placeholder: '9', label: 'Hour (0-23)', min: 0, max: 23 },
|
||||
{ key: 'minute', type: 'number', placeholder: '0', label: 'Minute (0-59)', min: 0, max: 59 },
|
||||
],
|
||||
},
|
||||
time_before: {
|
||||
label: 'Time Before',
|
||||
params: [
|
||||
{ key: 'hour', type: 'number', placeholder: '18', label: 'Hour (0-23)', min: 0, max: 23 },
|
||||
{ key: 'minute', type: 'number', placeholder: '0', label: 'Minute (0-59)', min: 0, max: 59 },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a condition editor inside a container element.
|
||||
* @param {HTMLElement} container - DOM element to render into
|
||||
* @param {object|null} initial - Initial condition JSON tree (null = empty AND group)
|
||||
*/
|
||||
export function buildConditionEditor(container, initial = null) {
|
||||
empty(container);
|
||||
container.classList.add('cond-editor');
|
||||
const root = initial || { type: 'group', op: 'AND', children: [] };
|
||||
container.appendChild(_renderNode(root));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the current condition tree from the DOM.
|
||||
* @param {HTMLElement} container - The editor container
|
||||
* @returns {object} JSON condition tree
|
||||
*/
|
||||
export function getConditions(container) {
|
||||
const rootEl = container.querySelector('.cond-group, .cond-block');
|
||||
if (!rootEl) return null;
|
||||
return _readNode(rootEl);
|
||||
}
|
||||
|
||||
// --- Internal rendering ---
|
||||
|
||||
function _renderNode(node) {
|
||||
if (node.type === 'group') return _renderGroup(node);
|
||||
return _renderCondition(node);
|
||||
}
|
||||
|
||||
function _renderGroup(node) {
|
||||
const op = (node.op || 'AND').toUpperCase();
|
||||
const childContainer = el('div', { class: 'cond-children' });
|
||||
|
||||
// Render existing children
|
||||
(node.children || []).forEach(child => {
|
||||
childContainer.appendChild(_wrapDeletable(_renderNode(child)));
|
||||
});
|
||||
|
||||
const opToggle = el('select', { class: 'cond-op-toggle', 'data-op': op }, [
|
||||
el('option', { value: 'AND', selected: op === 'AND' ? '' : null }, ['AND']),
|
||||
el('option', { value: 'OR', selected: op === 'OR' ? '' : null }, ['OR']),
|
||||
]);
|
||||
opToggle.value = op;
|
||||
opToggle.addEventListener('change', () => {
|
||||
group.dataset.op = opToggle.value;
|
||||
group.classList.toggle('cond-group-or', opToggle.value === 'OR');
|
||||
group.classList.toggle('cond-group-and', opToggle.value === 'AND');
|
||||
});
|
||||
|
||||
const addCondBtn = el('button', {
|
||||
class: 'cond-add-btn',
|
||||
type: 'button',
|
||||
onClick: () => {
|
||||
const newCond = { type: 'condition', source: 'action_result', action: '', check: 'eq', value: 'success' };
|
||||
childContainer.appendChild(_wrapDeletable(_renderCondition(newCond)));
|
||||
},
|
||||
}, ['+ Condition']);
|
||||
|
||||
const addGroupBtn = el('button', {
|
||||
class: 'cond-add-btn cond-add-group-btn',
|
||||
type: 'button',
|
||||
onClick: () => {
|
||||
const newGroup = { type: 'group', op: 'AND', children: [] };
|
||||
childContainer.appendChild(_wrapDeletable(_renderGroup(newGroup)));
|
||||
},
|
||||
}, ['+ Group']);
|
||||
|
||||
const group = el('div', {
|
||||
class: `cond-group cond-group-${op.toLowerCase()}`,
|
||||
'data-type': 'group',
|
||||
'data-op': op,
|
||||
}, [
|
||||
el('div', { class: 'cond-group-header' }, [opToggle]),
|
||||
childContainer,
|
||||
el('div', { class: 'cond-group-actions' }, [addCondBtn, addGroupBtn]),
|
||||
]);
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
function _renderCondition(node) {
|
||||
const source = node.source || 'action_result';
|
||||
const paramsContainer = el('div', { class: 'cond-params' });
|
||||
|
||||
const sourceSelect = el('select', { class: 'cond-source-select' });
|
||||
Object.entries(SOURCES).forEach(([key, def]) => {
|
||||
const opt = el('option', { value: key, selected: key === source ? '' : null }, [def.label]);
|
||||
sourceSelect.appendChild(opt);
|
||||
});
|
||||
sourceSelect.value = source;
|
||||
|
||||
// Build params for current source
|
||||
_buildParams(paramsContainer, source, node);
|
||||
|
||||
sourceSelect.addEventListener('change', () => {
|
||||
const newSource = sourceSelect.value;
|
||||
block.dataset.source = newSource;
|
||||
_buildParams(paramsContainer, newSource, {});
|
||||
});
|
||||
|
||||
const block = el('div', {
|
||||
class: 'cond-block',
|
||||
'data-type': 'condition',
|
||||
'data-source': source,
|
||||
}, [sourceSelect, paramsContainer]);
|
||||
|
||||
return block;
|
||||
}
|
||||
|
||||
function _buildParams(container, source, data) {
|
||||
empty(container);
|
||||
const def = SOURCES[source];
|
||||
if (!def) return;
|
||||
|
||||
def.params.forEach(p => {
|
||||
const val = data[p.key] !== undefined ? data[p.key] : (p.placeholder || '');
|
||||
let input;
|
||||
|
||||
if (p.type === 'select') {
|
||||
input = el('select', { class: 'cond-param-input', 'data-key': p.key });
|
||||
(p.choices || []).forEach(c => {
|
||||
const opt = el('option', { value: c, selected: String(c) === String(data[p.key] || '') ? '' : null }, [c]);
|
||||
input.appendChild(opt);
|
||||
});
|
||||
if (data[p.key] !== undefined) input.value = String(data[p.key]);
|
||||
} else if (p.type === 'number') {
|
||||
input = el('input', {
|
||||
type: 'number',
|
||||
class: 'cond-param-input',
|
||||
'data-key': p.key,
|
||||
value: data[p.key] !== undefined ? String(data[p.key]) : '',
|
||||
placeholder: p.placeholder || '',
|
||||
min: p.min !== undefined ? String(p.min) : undefined,
|
||||
max: p.max !== undefined ? String(p.max) : undefined,
|
||||
});
|
||||
} else {
|
||||
input = el('input', {
|
||||
type: 'text',
|
||||
class: 'cond-param-input',
|
||||
'data-key': p.key,
|
||||
value: data[p.key] !== undefined ? String(data[p.key]) : '',
|
||||
placeholder: p.placeholder || '',
|
||||
});
|
||||
}
|
||||
|
||||
container.appendChild(
|
||||
el('label', { class: 'cond-param-label' }, [
|
||||
el('span', { class: 'cond-param-name' }, [p.label]),
|
||||
input,
|
||||
])
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function _wrapDeletable(nodeEl) {
|
||||
const wrapper = el('div', { class: 'cond-item-wrapper' }, [
|
||||
nodeEl,
|
||||
el('button', {
|
||||
class: 'cond-delete-btn',
|
||||
type: 'button',
|
||||
title: 'Remove',
|
||||
onClick: () => wrapper.remove(),
|
||||
}, ['\u00d7']),
|
||||
]);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
// --- Read DOM -> JSON ---
|
||||
|
||||
function _readNode(nodeEl) {
|
||||
const type = nodeEl.dataset.type;
|
||||
if (type === 'group') return _readGroup(nodeEl);
|
||||
if (type === 'condition') return _readCondition(nodeEl);
|
||||
|
||||
// Check if it's a wrapper
|
||||
const inner = nodeEl.querySelector('.cond-group, .cond-block');
|
||||
if (inner) return _readNode(inner);
|
||||
return null;
|
||||
}
|
||||
|
||||
function _readGroup(groupEl) {
|
||||
const op = groupEl.dataset.op || 'AND';
|
||||
const children = [];
|
||||
const childrenContainer = groupEl.querySelector('.cond-children');
|
||||
if (childrenContainer) {
|
||||
for (const wrapper of childrenContainer.children) {
|
||||
const inner = wrapper.querySelector('.cond-group, .cond-block');
|
||||
if (inner) {
|
||||
const child = _readNode(inner);
|
||||
if (child) children.push(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { type: 'group', op: op.toUpperCase(), children };
|
||||
}
|
||||
|
||||
function _readCondition(blockEl) {
|
||||
const source = blockEl.dataset.source || 'action_result';
|
||||
const result = { type: 'condition', source };
|
||||
|
||||
const inputs = blockEl.querySelectorAll('.cond-param-input');
|
||||
inputs.forEach(input => {
|
||||
const key = input.dataset.key;
|
||||
if (!key) return;
|
||||
let val = input.value;
|
||||
// Auto-cast numbers
|
||||
if (input.type === 'number' && val !== '') {
|
||||
val = Number(val);
|
||||
}
|
||||
result[key] = val;
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
* @module core/console-sse
|
||||
*/
|
||||
|
||||
import { $, el, toast } from './dom.js';
|
||||
import { $, el, toast, escapeHtml } from './dom.js';
|
||||
import { api } from './api.js';
|
||||
import { t } from './i18n.js';
|
||||
|
||||
@@ -47,6 +47,7 @@ const HEALTHY_THRESHOLD = 5; // messages needed before resetting reconnect coun
|
||||
let isUserScrolling = false;
|
||||
let autoScroll = true;
|
||||
let lineBuffer = []; // lines held while user is scrolled up
|
||||
const MAX_BUFFER_LINES = 500; // cap buffer to prevent unbounded memory growth
|
||||
let isDocked = false;
|
||||
let consoleMode = 'log'; // 'log' | 'bubble'
|
||||
const CONSOLE_SESSION_ID = 'console';
|
||||
@@ -435,6 +436,10 @@ function connectSSE() {
|
||||
const html = processLogLine(raw);
|
||||
if (isUserScrolling && !autoScroll) {
|
||||
lineBuffer.push(html);
|
||||
// Evict oldest lines if buffer exceeds max
|
||||
if (lineBuffer.length > MAX_BUFFER_LINES) {
|
||||
lineBuffer = lineBuffer.slice(-MAX_BUFFER_LINES);
|
||||
}
|
||||
updateBufferBadge();
|
||||
} else {
|
||||
appendLogHtml(html);
|
||||
@@ -660,8 +665,10 @@ async function loadManualTargets() {
|
||||
if (currentIp && ips.includes(currentIp)) elSelIp.value = currentIp;
|
||||
}
|
||||
|
||||
const customActions = Array.isArray(data?.custom_actions) ? data.custom_actions : [];
|
||||
|
||||
elSelAction.innerHTML = '';
|
||||
if (!actions.length) {
|
||||
if (!actions.length && !customActions.length) {
|
||||
const op = document.createElement('option');
|
||||
op.value = '';
|
||||
op.textContent = t('console.noAction');
|
||||
@@ -673,7 +680,20 @@ async function loadManualTargets() {
|
||||
op.textContent = String(action);
|
||||
elSelAction.appendChild(op);
|
||||
}
|
||||
if (currentAction && actions.includes(currentAction)) elSelAction.value = currentAction;
|
||||
if (customActions.length) {
|
||||
const grp = document.createElement('optgroup');
|
||||
grp.label = 'Custom Scripts';
|
||||
for (const action of customActions) {
|
||||
const op = document.createElement('option');
|
||||
op.value = String(action);
|
||||
op.textContent = String(action);
|
||||
grp.appendChild(op);
|
||||
}
|
||||
elSelAction.appendChild(grp);
|
||||
}
|
||||
if (currentAction && (actions.includes(currentAction) || customActions.includes(currentAction))) {
|
||||
elSelAction.value = currentAction;
|
||||
}
|
||||
}
|
||||
|
||||
updatePortsForSelectedIp(portsByIp);
|
||||
@@ -1090,26 +1110,29 @@ async function sendConsoleChat(inputEl) {
|
||||
if (!msg) return;
|
||||
inputEl.value = '';
|
||||
|
||||
// Show user message in console
|
||||
// Show user message in console (escape to prevent XSS)
|
||||
const safeMsg = escapeHtml(msg);
|
||||
if (consoleMode === 'bubble') {
|
||||
appendLogHtml(`<div class="console-bubble-user">${msg}</div>`);
|
||||
appendLogHtml(`<div class="console-bubble-user">${safeMsg}</div>`);
|
||||
} else {
|
||||
appendLogHtml(`<span class="comment-line"><span class="comment-status">YOU</span> ${msg}</span>`);
|
||||
appendLogHtml(`<span class="comment-line"><span class="comment-status">YOU</span> ${safeMsg}</span>`);
|
||||
}
|
||||
|
||||
// Call LLM
|
||||
try {
|
||||
const data = await api.post('/api/llm/chat', { message: msg, session_id: CONSOLE_SESSION_ID });
|
||||
if (data?.status === 'ok' && data.response) {
|
||||
// Escape LLM response to prevent stored XSS via prompt injection
|
||||
const safeResp = escapeHtml(data.response);
|
||||
if (consoleMode === 'bubble') {
|
||||
appendLogHtml(`<div class="console-bubble-bjorn llm"><img src="/web/images/icon-60x60.png" class="comment-icon" alt="" style="width:14px;height:14px;vertical-align:middle;margin-right:4px">${data.response}</div>`);
|
||||
appendLogHtml(`<div class="console-bubble-bjorn llm"><img src="/web/images/icon-60x60.png" class="comment-icon" alt="" style="width:14px;height:14px;vertical-align:middle;margin-right:4px">${safeResp}</div>`);
|
||||
} else {
|
||||
appendLogHtml(`<span class="comment-line"><span class="comment-llm-badge">LLM</span><span class="comment-status">BJORN</span> <img src="/web/images/icon-60x60.png" class="comment-icon" alt=""> ${data.response}</span>`);
|
||||
appendLogHtml(`<span class="comment-line"><span class="comment-llm-badge">LLM</span><span class="comment-status">BJORN</span> <img src="/web/images/icon-60x60.png" class="comment-icon" alt=""> ${safeResp}</span>`);
|
||||
}
|
||||
} else {
|
||||
appendLogHtml(`<span class="loglvl error">Chat error: ${data?.message || 'unknown'}</span>`);
|
||||
appendLogHtml(`<span class="loglvl error">Chat error: ${escapeHtml(data?.message || 'unknown')}</span>`);
|
||||
}
|
||||
} catch (e) {
|
||||
appendLogHtml(`<span class="loglvl error">Chat error: ${e.message}</span>`);
|
||||
appendLogHtml(`<span class="loglvl error">Chat error: ${escapeHtml(e.message)}</span>`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 l’autolayout)
|
||||
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; }
|
||||
|
||||
// ré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 l’axe 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 l’axe X
|
||||
} else { // pousser surtout en Y (hosts OK en Y)
|
||||
// both hosts — don’t 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
|
||||
|
||||
@@ -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
401
web/js/pages/plugins.js
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
324
web/login.html
Normal file
324
web/login.html
Normal file
@@ -0,0 +1,324 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>Login - Bjorn</title>
|
||||
<link rel="icon" href="web/images/favicon.ico" type="image/x-icon">
|
||||
<link rel="stylesheet" href="web/css/global.css">
|
||||
<link rel="manifest" href="manifest.json">
|
||||
<link rel="apple-touch-icon" sizes="192x192" href="web/images/icon-192x192.png">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#ff0000">
|
||||
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#ff0000">
|
||||
<style>
|
||||
/* Importation de la police personnalisée */
|
||||
@font-face {
|
||||
font-family: 'Viking';
|
||||
src: url('/web/css/fonts/Viking.TTF') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
body, html {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
font-family: 'Arial', sans-serif;
|
||||
background: #0a0a0a;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rgb-border {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(45deg, #ff0000, #00ff00, #0000ff, #ff0000);
|
||||
background-size: 400% 400%;
|
||||
animation: rgb 10s ease infinite;
|
||||
filter: blur(20px);
|
||||
opacity: 0.5;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
@keyframes rgb {
|
||||
0% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
100% { background-position: 0% 50%; }
|
||||
}
|
||||
|
||||
.login-container {
|
||||
background: rgba(10, 10, 10, 0.8);
|
||||
padding: 40px;
|
||||
border-radius: 15px;
|
||||
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.37);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
text-align: center;
|
||||
width: 90%;
|
||||
max-width: 400px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.login-container::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: conic-gradient(
|
||||
transparent,
|
||||
transparent,
|
||||
transparent,
|
||||
var(--rgb-color)
|
||||
);
|
||||
animation: rotate 4s linear infinite;
|
||||
opacity: 0.1;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
h2 {
|
||||
color: #fff;
|
||||
margin-bottom: 20px;
|
||||
font-size: 2em;
|
||||
text-shadow: 0 0 10px rgba(var(--rgb-values), 0.5);
|
||||
font-family: 'Viking', sans-serif; /* Application de la police Viking */
|
||||
}
|
||||
|
||||
.input-group {
|
||||
position: relative;
|
||||
margin: 20px 0;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
input[type="text"], input[type="password"] {
|
||||
width: 100%;
|
||||
padding: 15px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
transition: all 0.3s;
|
||||
box-sizing: border-box;
|
||||
min-height: 44px;
|
||||
-webkit-tap-highlight-color: rgba(255,255,255,0.1);
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 15px rgba(var(--rgb-values), 0.3);
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.password-toggle {
|
||||
position: absolute;
|
||||
right: 15px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
cursor: pointer;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
user-select: none;
|
||||
z-index: 3;
|
||||
padding: 15px;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
width: 100%;
|
||||
padding: 15px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(45deg, #ff0000, #00ff00, #0000ff);
|
||||
background-size: 200% 200%;
|
||||
animation: rgb 10s ease infinite;
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
margin-top: 20px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
min-height: 44px;
|
||||
font-family: 'Viking', sans-serif; /* Application de la police Viking */
|
||||
}
|
||||
|
||||
.login-button:hover {
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 0 20px rgba(var(--rgb-values), 0.4);
|
||||
}
|
||||
|
||||
.login-button:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.auth-options {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
margin: 20px 0;
|
||||
gap: 10px;
|
||||
z-index: 2;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Styles pour le toggle switch */
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 50px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
background: #ccc;
|
||||
border-radius: 24px;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
transition: background 0.4s;
|
||||
}
|
||||
|
||||
.slider::before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
left: 2px;
|
||||
bottom: 2px;
|
||||
background-color: white;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.4s;
|
||||
}
|
||||
|
||||
input:checked + .slider {
|
||||
background: var(--rgb-color);
|
||||
}
|
||||
|
||||
input:checked + .slider::before {
|
||||
transform: translateX(26px);
|
||||
}
|
||||
|
||||
/* Optional: Adding a hue animation to the slider */
|
||||
@keyframes hue {
|
||||
from { background: red; }
|
||||
to { background: red; }
|
||||
}
|
||||
|
||||
:root {
|
||||
--rgb-color: #ff0000;
|
||||
--rgb-values: 255, 0, 0;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.login-container {
|
||||
padding: 20px;
|
||||
width: 85%;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="rgb-border"></div>
|
||||
<div class="login-container">
|
||||
<h2>Bjorn Login</h2>
|
||||
<form method="POST" action="/login">
|
||||
<div class="input-group">
|
||||
<input type="text" name="username" placeholder="Username" required>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<input type="password" name="password" id="password" placeholder="Password" required>
|
||||
<span class="password-toggle" onclick="togglePassword()">👁️</span>
|
||||
</div>
|
||||
<div class="auth-options">
|
||||
<label class="switch">
|
||||
<input type="checkbox" id="alwaysAuth" name="alwaysAuth">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span>Always require authentication</span>
|
||||
</div>
|
||||
<button type="submit" class="login-button">Login</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Password visibility toggle
|
||||
function togglePassword() {
|
||||
const password = document.getElementById('password');
|
||||
password.type = password.type === 'password' ? 'text' : 'password';
|
||||
}
|
||||
|
||||
// Dynamic RGB color and theme-color update
|
||||
function updateRGBColor() {
|
||||
const r = Math.sin(Date.now() * 0.001) * 127 + 128;
|
||||
const g = Math.sin(Date.now() * 0.001 + 2) * 127 + 128;
|
||||
const b = Math.sin(Date.now() * 0.001 + 4) * 127 + 128;
|
||||
|
||||
const rgbColor = `rgb(${Math.round(r)},${Math.round(g)},${Math.round(b)})`;
|
||||
document.documentElement.style.setProperty('--rgb-color', rgbColor);
|
||||
document.documentElement.style.setProperty('--rgb-values', `${Math.round(r)},${Math.round(g)},${Math.round(b)}`);
|
||||
|
||||
// Update theme-color meta tags
|
||||
const themeColorLight = document.querySelector('meta[name="theme-color"][media="(prefers-color-scheme: light)"]');
|
||||
const themeColorDark = document.querySelector('meta[name="theme-color"][media="(prefers-color-scheme: dark)"]');
|
||||
if (themeColorLight) themeColorLight.setAttribute('content', rgbColor);
|
||||
if (themeColorDark) themeColorDark.setAttribute('content', rgbColor);
|
||||
|
||||
requestAnimationFrame(updateRGBColor);
|
||||
}
|
||||
|
||||
// Enhanced mobile touch handling
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const passwordToggle = document.querySelector('.password-toggle');
|
||||
passwordToggle.addEventListener('touchend', function(e) {
|
||||
e.preventDefault();
|
||||
togglePassword();
|
||||
});
|
||||
|
||||
// **Suppression du gestionnaire touchend pour le slider**
|
||||
/*
|
||||
const checkbox = document.getElementById('alwaysAuth');
|
||||
checkbox.parentElement.addEventListener('touchend', function(e) {
|
||||
e.stopPropagation();
|
||||
checkbox.checked = !checkbox.checked;
|
||||
});
|
||||
*/
|
||||
});
|
||||
|
||||
updateRGBColor();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user