mirror of
https://github.com/infinition/Bjorn.git
synced 2025-12-12 23:54:59 +00:00
940 lines
35 KiB
HTML
940 lines
35 KiB
HTML
<!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, viewport-fit=cover"
|
|
/>
|
|
<title>Bjorn - Actions Launcher</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" content="#0b0d10" />
|
|
<script src="web/js/global.js" defer></script>
|
|
|
|
<style>
|
|
/* =========================================================
|
|
Actions Launcher — scoped CSS (plays nice with global.css)
|
|
---------------------------------------------------------
|
|
• No layout override of .sidebar/.main positions.
|
|
• Uses global tokens (colors, radii, borders, shadows).
|
|
• Mobile = single console, toolbar hidden.
|
|
========================================================= */
|
|
|
|
/* Page container (center column only) */
|
|
#actionsLauncher{
|
|
min-height:100%;
|
|
display:grid;
|
|
grid-template-columns:1fr;
|
|
gap:var(--gap-3, 10px);
|
|
}
|
|
|
|
/* Panel look consistent with cards/console surfaces */
|
|
.panel{
|
|
background: var(--grad-card, var(--c-panel));
|
|
border: 1px solid var(--c-border);
|
|
border-radius: var(--radius, 14px);
|
|
box-shadow: var(--elev, 0 10px 30px var(--acid-1a, #00ff9a1a), inset 0 0 0 1px var(--acid-22, #00ff9a22));
|
|
overflow: clip;
|
|
}
|
|
|
|
/* ---------- Sidebar (only inner content styles) ---------- */
|
|
.sidebar .sideheader{ padding:10px 10px 6px; border-bottom:1px dashed var(--c-border); }
|
|
.tabs-container{ display:flex; gap:8px; flex-wrap:wrap; }
|
|
.tab-btn{
|
|
all:unset; cursor:pointer; padding:6px 10px; border-radius:10px;
|
|
background:var(--c-pill-bg); border:1px solid var(--c-border); color:var(--muted);
|
|
}
|
|
.tab-btn.active{
|
|
background:var(--grad-chip-selected);
|
|
outline:2px solid color-mix(in oklab, var(--acid) 55%, transparent);
|
|
outline-offset:0;
|
|
}
|
|
.sidebar .search{ display:flex; gap:10px; padding:10px; }
|
|
.sidebar .input{
|
|
flex:1; background:var(--c-panel); border:1px solid var(--c-border-strong);
|
|
color:var(--ink); padding:10px 12px; border-radius:var(--control-r,10px); font:inherit;
|
|
}
|
|
.sidebar .input:focus{ outline:none; box-shadow:0 0 0 2px color-mix(in oklab, var(--acid) 55%, transparent) inset; }
|
|
.sidecontent{ padding:8px; overflow:auto; }
|
|
|
|
/* Action list */
|
|
.sidebar .list{ display:flex; flex-direction:column; gap:10px; padding-right:4px; }
|
|
.sidebar .row{
|
|
position:relative; display:grid; grid-template-columns:84px 1fr;
|
|
gap:12px; padding:10px; background:var(--c-panel-2); border-radius:12px; cursor:pointer;
|
|
transition:transform .15s ease, border-color .15s ease, box-shadow .15s ease;
|
|
}
|
|
.sidebar .row:hover{
|
|
transform:translateY(-1px);
|
|
border-color:color-mix(in oklab, var(--accent) 25%, var(--c-border));
|
|
box-shadow:0 10px 26px var(--glow-weak);
|
|
}
|
|
.sidebar .row .ic{
|
|
width:84px; height:84px; display:grid; place-items:center;
|
|
border-radius:12px; background:var(--c-panel); overflow:hidden;
|
|
}
|
|
.ic-img{ width:70px; height:70px; object-fit:cover; display:block; }
|
|
.sidebar .row > div:nth-child(2){ min-width:0; display:flex; flex-direction:column; gap:4px; }
|
|
.name{ font-weight:800; color:var(--acid-2); font-size:14px; line-height:1.2; }
|
|
.desc{ color:var(--muted); font-size:13px; line-height:1.25; }
|
|
.sidebar .row .chip{
|
|
position:absolute; top:6px; left:calc(84px/2 + 10px); transform:translateX(-50%);
|
|
padding:2px 8px; border-radius:999px; border:1px solid var(--c-border);
|
|
background:var(--c-chip-bg); color:var(--muted); font-size:11px; line-height:1; pointer-events:none;
|
|
}
|
|
.chip.ok{ color:var(--ok); border-color:color-mix(in oklab, var(--ok) 60%, transparent); }
|
|
.chip.err{ color:var(--danger); border-color:color-mix(in oklab, var(--danger) 60%, transparent); }
|
|
.chip.run{ color:var(--acid); border-color:color-mix(in oklab, var(--acid) 60%, transparent); }
|
|
|
|
/* ---------- Center area ---------- */
|
|
.center{ display:flex; flex-direction:column; min-height:50vh; }
|
|
|
|
/* Secondary toolbar (split controls). Hidden on mobile. */
|
|
.toolbar2{
|
|
display:flex; align-items:center; gap:10px; padding:10px;
|
|
border-bottom:1px solid var(--c-border);
|
|
background:linear-gradient(180deg, color-mix(in oklab, var(--acid-2) 12%, transparent), transparent);
|
|
flex-wrap:wrap;
|
|
}
|
|
.seg{ display:flex; border-radius:10px; overflow:hidden; border:1px solid var(--c-border); }
|
|
.seg button{
|
|
background:var(--c-panel); color:var(--muted);
|
|
padding:8px 10px; border:none; border-right:1px solid var(--c-border);
|
|
cursor:pointer; font:inherit;
|
|
}
|
|
.seg button:last-child{ border-right:none; }
|
|
.seg button.active{
|
|
color:var(--ink-invert);
|
|
background:linear-gradient(90deg, var(--acid-2), color-mix(in oklab, var(--acid-2) 60%, white));
|
|
}
|
|
|
|
.btn{
|
|
background:var(--c-btn); color:var(--ink); border:1px solid var(--c-border-strong);
|
|
border-radius:var(--control-r,10px); padding:8px 12px;
|
|
display:inline-flex; align-items:center; gap:8px; cursor:pointer; transition:.18s;
|
|
box-shadow:var(--elev); font:inherit;
|
|
}
|
|
.btn:hover{ transform:translateY(-1px); box-shadow:var(--shadow-hover); }
|
|
.btn.warn{
|
|
background:linear-gradient(180deg, color-mix(in oklab, var(--warning) 28%, var(--c-btn)), var(--c-btn));
|
|
color:var(--warning); border-color:color-mix(in oklab, var(--warning) 55%, var(--c-border));
|
|
}
|
|
|
|
/* Multi-console grid */
|
|
.multiConsole{
|
|
flex:1; padding:10px; display:grid; gap:10px; height:100%;
|
|
grid-auto-flow:row; grid-auto-rows:1fr;
|
|
grid-template-rows:repeat(var(--rows,1), 1fr);
|
|
}
|
|
.split-1{ grid-template-columns:1fr; }
|
|
.split-2{ grid-template-columns:1fr 1fr; }
|
|
.split-3{ grid-template-columns:1fr 1fr 1fr; }
|
|
.split-4{ grid-template-columns:1fr 1fr; } /* 2x2 */
|
|
|
|
/* Console pane */
|
|
.pane{
|
|
position:relative; border:1px solid var(--c-border);
|
|
border-radius:12px; background:var(--grad-console);
|
|
display:flex; flex-direction:column;
|
|
box-shadow:inset 0 0 0 1px var(--c-border-muted);
|
|
}
|
|
|
|
/* Clean two-column header: title/meta | actions */
|
|
.paneHeader{
|
|
display:grid; grid-template-columns:1fr auto; align-items:center;
|
|
gap:10px; padding:8px 10px; border-bottom:1px solid var(--c-border);
|
|
background:linear-gradient(180deg, color-mix(in oklab, var(--acid-2) 8%, transparent), transparent);
|
|
}
|
|
|
|
/* Left side: dot + icon + stacked title/meta */
|
|
.paneTitle{
|
|
display:grid; grid-template-columns:auto auto 1fr; align-items:center; gap:10px; min-width:0;
|
|
}
|
|
.paneTitle .dot{ width:8px; height:8px; border-radius:50%; flex:0 0 auto; }
|
|
.paneIcon{ width:70px; height:70px; border-radius:6px; object-fit:cover; opacity:.95; }
|
|
.titleBlock{ display:flex; flex-direction:column; gap:4px; min-width:0; }
|
|
.titleLine strong{ white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
.metaLine{ display:flex; flex-wrap:wrap; gap:6px; }
|
|
.metaLine .chip{ border:1px solid var(--c-border-strong); background:var(--c-chip-bg); color:var(--muted); padding:3px 8px; border-radius:999px; }
|
|
|
|
/* Right side: actions wrap neatly */
|
|
.paneBtns{ display:flex; flex-wrap:wrap; gap:8px; justify-content:flex-end; }
|
|
.paneBtns .btn{ padding:6px 8px; font-size:.9rem; }
|
|
|
|
.paneLog{
|
|
flex:1; overflow:auto; padding:6px 8px;
|
|
font-family:ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
|
font-size:.92rem;
|
|
}
|
|
|
|
/* Log colors */
|
|
.logline{ white-space:pre-wrap; word-break:break-word; padding:4px 6px; line-height:1.32; color:var(--ink); }
|
|
.logline.info{ color:#bfefff; }
|
|
.logline.ok{ color:#9ff7c5; }
|
|
.logline.warn{ color:#ffd27a; }
|
|
.logline.err{ color:#ff99b3; }
|
|
.logline.dim{ color:#6a8596; }
|
|
|
|
.paneHighlight{
|
|
box-shadow:0 0 0 2px var(--acid-2), 0 0 24px color-mix(in oklab, var(--acid-2) 55%, transparent) inset, 0 0 40px color-mix(in oklab, var(--acid-2) 35%, transparent);
|
|
animation:hi 900ms ease-out 1;
|
|
}
|
|
@keyframes hi{ 0%{transform:scale(1)} 50%{transform:scale(1.01)} 100%{transform:scale(1)} }
|
|
|
|
/* Arguments section */
|
|
.section{ padding:12px; border-bottom:1px dashed var(--c-border); }
|
|
.h{ font-weight:800; letter-spacing:.5px; color:var(--acid-2); }
|
|
.sub{ color:var(--muted); font-size:.9rem; }
|
|
.builder{ padding:12px; display:grid; gap:12px; }
|
|
.field{ display:grid; gap:6px; }
|
|
.label{ font-size:.85rem; color:var(--muted); }
|
|
.ctl, .select, .range{
|
|
background:var(--c-panel); color:var(--ink); border:1px solid var(--c-border-strong);
|
|
border-radius:var(--control-r,10px); padding:10px 12px; font:inherit;
|
|
}
|
|
.ctl:focus, .select:focus{ outline:none; box-shadow:0 0 0 2px color-mix(in oklab, var(--acid) 55%, transparent) inset; }
|
|
.chips{ display:flex; gap:8px; flex-wrap:wrap; padding:10px; }
|
|
.chip2{ padding:6px 10px; border-radius:999px; background:var(--c-chip-bg); border:1px solid var(--c-border-hi); cursor:pointer; transition:.18s; }
|
|
.chip2:hover{ box-shadow:0 0 0 1px var(--c-border-hi) inset, 0 8px 22px var(--glow-weak); }
|
|
|
|
/* Mobile tweaks */
|
|
@media (max-width: 860px){
|
|
.toolbar2{ display:none !important; } /* hide toolbar in mobile */
|
|
.paneHeader{ grid-template-columns:1fr; row-gap:8px; }
|
|
.paneBtns{ justify-content:flex-start; }
|
|
.paneBtns .btn{ padding:5px 6px; font-size:.85rem; }
|
|
.main {
|
|
left: 350px;
|
|
}
|
|
.sidebar {
|
|
width: 350px;
|
|
}
|
|
}
|
|
|
|
</style>
|
|
</head>
|
|
|
|
<body>
|
|
|
|
<!-- Sidebar content (sidebar frame is from global.css) -->
|
|
<aside class="sidebar" id="sidebar">
|
|
<div class="sideheader" id="sideheader">
|
|
<div class="tabs-container">
|
|
<button class="tab-btn active" data-page="attacks">Actions</button>
|
|
<button class="tab-btn" data-page="arguments">Arguments</button>
|
|
</div>
|
|
<div class="search">
|
|
<input class="input" id="searchInput" placeholder="Search actions..." />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="sidecontent" id="sidecontent">
|
|
<!-- Actions tab -->
|
|
<div id="attacks-sidebar" class="sidebar-page" style="display:block">
|
|
<div class="list" id="actionsList"></div>
|
|
</div>
|
|
|
|
<!-- Arguments tab -->
|
|
<div id="arguments-sidebar" class="sidebar-page" style="display:none">
|
|
<div class="section">
|
|
<div class="h">Arguments</div>
|
|
<div class="sub">Auto-generated from action definitions</div>
|
|
</div>
|
|
<div class="builder" id="argBuilder"></div>
|
|
<div class="section">
|
|
<input id="freeArgs" class="ctl" placeholder="Additional arguments (e.g., --verbose --debug)" />
|
|
</div>
|
|
<div class="chips" id="presetChips"></div>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
<!-- Main area (positioned by global.css) -->
|
|
<div class="main">
|
|
<main id="actionsLauncher">
|
|
<section class="center panel">
|
|
<div class="toolbar2">
|
|
<div class="spacer"></div>
|
|
<div class="seg" id="splitSeg">
|
|
<button data-split="1" class="active">1</button>
|
|
<button data-split="2">2</button>
|
|
<button data-split="3">3</button>
|
|
<button data-split="4">4</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="multiConsole split-1" id="multiConsole"></div>
|
|
</section>
|
|
</main>
|
|
</div>
|
|
|
|
<script>
|
|
/* =========================================================
|
|
Actions Launcher — JS (queue removed)
|
|
---------------------------------------------------------
|
|
• Forces single console on mobile and hides toolbar2.
|
|
• Clean pane header markup + better responsiveness.
|
|
• API endpoints kept: /list_scripts, /run_script, /stop_script, /get_script_output/:path
|
|
========================================================= */
|
|
|
|
const isMobile = () => window.matchMedia('(max-width: 860px)').matches;
|
|
|
|
class ActionsLauncher{
|
|
constructor(){
|
|
this.actions = [];
|
|
this.activeAction = null;
|
|
this.runningActions = new Map();
|
|
this.logs = new Map();
|
|
this.split = 1; // forced to 1 on mobile
|
|
this.panes = [null, null, null, null];
|
|
this.assignTargetPaneIndex = null;
|
|
this.autoClearPane = {0:false,1:false,2:false,3:false};
|
|
this.filter = 'all';
|
|
this.pollingIntervals = new Map();
|
|
this.init();
|
|
}
|
|
|
|
async init(){
|
|
await this.loadActions();
|
|
this.setupEventListeners();
|
|
this.enforceMobileOnePane(); // always one pane on mobile
|
|
this.renderActions();
|
|
this.renderConsoles();
|
|
window.addEventListener('resize', this.onResizeDebounced.bind(this));
|
|
}
|
|
|
|
onResizeDebounced(){
|
|
clearTimeout(this._rz_t);
|
|
this._rz_t = setTimeout(()=>{
|
|
this.enforceMobileOnePane();
|
|
this.renderConsoles();
|
|
}, 120);
|
|
}
|
|
|
|
enforceMobileOnePane(){
|
|
if(isMobile()){
|
|
this.split = 1;
|
|
if(!this.panes[0] && this.activeAction){ this.panes[0] = this.activeAction.id; }
|
|
for(let i=1;i<this.panes.length;i++) this.panes[i] = null;
|
|
// Disable split buttons visually
|
|
document.querySelectorAll('#splitSeg button').forEach(b=>{
|
|
b.classList.toggle('active', b.dataset.split === '1');
|
|
b.disabled = true; b.style.opacity = .6; b.style.pointerEvents = 'none';
|
|
});
|
|
}else{
|
|
document.querySelectorAll('#splitSeg button').forEach(b=>{
|
|
b.disabled = false; b.style.opacity = ''; b.style.pointerEvents = '';
|
|
});
|
|
}
|
|
}
|
|
|
|
async loadActions(){
|
|
try{
|
|
const response = await fetch('/list_scripts');
|
|
const { status, data } = await response.json();
|
|
|
|
if(status === 'success' && Array.isArray(data)){
|
|
this.actions = data.map(action=>{
|
|
const raw = action.b_args ?? {};
|
|
let args = raw;
|
|
if(typeof raw === 'string'){ try{ args = JSON.parse(raw);}catch{ args = {}; } }
|
|
|
|
const id = action.b_module || (action.name ? action.name.replace(/\.py$/,'') : 'unknown');
|
|
const className = action.b_class || id;
|
|
const icon = action.b_icon || `/actions_icons/${className}.png`;
|
|
|
|
return {
|
|
id,
|
|
name: action.name || action.b_class || action.b_module || 'Unnamed',
|
|
module: action.b_module || action.module,
|
|
category: action.b_action || action.category || 'normal',
|
|
description: action.description || 'No description',
|
|
args,
|
|
status: 'ready',
|
|
path: action.path || action.module_path,
|
|
icon,
|
|
version: action.b_version,
|
|
author: action.b_author,
|
|
docsUrl: action.b_docs_url,
|
|
examples: action.b_examples
|
|
};
|
|
});
|
|
}else{
|
|
this.actions = [];
|
|
}
|
|
}catch(err){
|
|
console.error('Failed to load actions:', err);
|
|
this.actions = [];
|
|
}
|
|
}
|
|
|
|
setupEventListeners(){
|
|
// Search
|
|
const search = document.getElementById('searchInput');
|
|
if(search) search.addEventListener('input', ()=>this.renderActions());
|
|
|
|
// Sidebar tabs
|
|
document.querySelectorAll('.tab-btn').forEach(btn=>{
|
|
btn.addEventListener('click', ()=>{
|
|
const page = btn.dataset.page;
|
|
document.querySelectorAll('.tab-btn').forEach(b=>b.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
document.querySelectorAll('.sidebar-page').forEach(sp=>sp.style.display='none');
|
|
document.getElementById(page+'-sidebar').style.display='block';
|
|
});
|
|
});
|
|
|
|
// Split buttons (ignored on mobile)
|
|
document.querySelectorAll('#splitSeg button').forEach(btn=>{
|
|
btn.addEventListener('click', ()=>{
|
|
if(isMobile()){ this.enforceMobileOnePane(); return; }
|
|
document.querySelectorAll('#splitSeg button').forEach(b=>b.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
this.split = parseInt(btn.dataset.split, 10);
|
|
this.renderConsoles();
|
|
});
|
|
});
|
|
}
|
|
|
|
/* ---------- Sidebar: actions ---------- */
|
|
renderActions(){
|
|
const q = (document.getElementById('searchInput')?.value || '').trim().toLowerCase();
|
|
const terms = q ? q.split(/\s+/).filter(Boolean) : [];
|
|
|
|
const filtered = this.actions.filter(action=>{
|
|
const matchesFilter = this.filter==='all' || (action.category||'').toLowerCase()===this.filter;
|
|
if(!matchesFilter) return false;
|
|
if(terms.length===0) return true;
|
|
const hay = [
|
|
action.name, action.description, action.module, action.id, action.author
|
|
].concat(Array.isArray(action.tags)?action.tags:[])
|
|
.join(' ')
|
|
.toLowerCase();
|
|
return terms.every(t=>hay.includes(t));
|
|
});
|
|
|
|
const list = document.getElementById('actionsList');
|
|
if(!list) return;
|
|
list.innerHTML = filtered.map(a=>this.createActionRow(a)).join('');
|
|
this.attachActionHandlers('actionsList');
|
|
}
|
|
|
|
createActionRow(action){
|
|
const statusChip = this.getStatusChip(action.status);
|
|
const iconHtml = `
|
|
<img src="${action.icon}" class="ic-img" alt=""
|
|
onerror="this.onerror=null; this.src='/actions/actions_icons/default.png';" />
|
|
`;
|
|
return `
|
|
<div class="row" data-action="${action.id}">
|
|
<div class="ic">${iconHtml}</div>
|
|
<div>
|
|
<div class="name">${action.name}</div>
|
|
<div class="desc">${action.description}</div>
|
|
</div>
|
|
<div class="chip ${statusChip.class}">${statusChip.text}</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
attachActionHandlers(containerId){
|
|
const container = document.getElementById(containerId);
|
|
if(!container) return;
|
|
container.querySelectorAll('.row').forEach(row=>{
|
|
const actionId = row.dataset.action;
|
|
row.draggable = true;
|
|
|
|
row.addEventListener('dragstart', e=>{
|
|
e.dataTransfer.setData('text/plain', actionId);
|
|
});
|
|
|
|
row.addEventListener('click', ()=>{
|
|
if(this.assignTargetPaneIndex!==null){
|
|
this.panes[this.assignTargetPaneIndex] = actionId;
|
|
this.clearAssignTarget();
|
|
this.renderConsoles();
|
|
return;
|
|
}
|
|
this.selectAction(actionId);
|
|
});
|
|
});
|
|
}
|
|
|
|
getStatusChip(status){
|
|
switch(status){
|
|
case 'running': return { class:'run', text:'Running' };
|
|
case 'success': return { class:'ok', text:'Success' };
|
|
case 'error': return { class:'err', text:'Error' };
|
|
default: return { class:'', text:'Ready' };
|
|
}
|
|
}
|
|
|
|
/* ---------- Arguments ---------- */
|
|
selectAction(actionId){
|
|
const action = this.actions.find(a=>a.id===actionId);
|
|
if(!action) return;
|
|
|
|
this.activeAction = action;
|
|
this.renderArguments(action);
|
|
this.renderPresets(action);
|
|
|
|
if(this.assignTargetPaneIndex!==null){
|
|
this.panes[this.assignTargetPaneIndex] = actionId;
|
|
this.clearAssignTarget();
|
|
this.renderConsoles();
|
|
return;
|
|
}
|
|
const paneIndex = this.panes.findIndex(p=>p===actionId);
|
|
if(paneIndex===-1){
|
|
const emptyIndex = this.panes.slice(0, this.split).findIndex(p=>!p);
|
|
this.panes[emptyIndex !== -1 ? emptyIndex : 0] = actionId;
|
|
this.renderConsoles();
|
|
}else{
|
|
this.highlightPane(paneIndex);
|
|
}
|
|
}
|
|
|
|
renderArguments(action){
|
|
const builder = document.getElementById('argBuilder');
|
|
if(!builder) return;
|
|
|
|
builder.innerHTML = '';
|
|
|
|
// Meta + Docs
|
|
const header = document.createElement('div');
|
|
header.style.display = 'flex';
|
|
header.style.alignItems = 'center';
|
|
header.style.justifyContent = 'space-between';
|
|
header.style.gap = '10px';
|
|
header.style.marginBottom = '8px';
|
|
|
|
const meta = document.createElement('div');
|
|
meta.className = 'sub';
|
|
const bits = [];
|
|
if(action?.version) bits.push(`v${action.version}`);
|
|
if(action?.author) bits.push(`by ${action.author}`);
|
|
meta.textContent = bits.join(' · ') || '';
|
|
header.appendChild(meta);
|
|
|
|
const right = document.createElement('div');
|
|
if(action?.docsUrl){
|
|
const a = document.createElement('a');
|
|
a.href = action.docsUrl;
|
|
a.target = '_blank'; a.rel='noopener noreferrer';
|
|
a.className = 'btn';
|
|
a.textContent = '📖 Docs';
|
|
right.appendChild(a);
|
|
}
|
|
header.appendChild(right);
|
|
builder.appendChild(header);
|
|
|
|
// Presets
|
|
const hasPresets = Array.isArray(action?.examples) && action.examples.length>0;
|
|
if(hasPresets){
|
|
const chipbar = document.createElement('div');
|
|
chipbar.style.display = 'flex';
|
|
chipbar.style.flexWrap = 'wrap';
|
|
chipbar.style.gap = '8px';
|
|
chipbar.style.margin = '2px 0 10px 0';
|
|
|
|
action.examples.forEach((preset, idx)=>{
|
|
const b = document.createElement('button');
|
|
b.className = 'chip2';
|
|
b.textContent = preset.name || preset.title || `Preset ${idx+1}`;
|
|
b.title = 'Apply this preset';
|
|
b.addEventListener('click', ()=>this.applyPreset(preset));
|
|
chipbar.appendChild(b);
|
|
});
|
|
builder.appendChild(chipbar);
|
|
}
|
|
|
|
// Fields
|
|
if(!action.args || Object.keys(action.args).length===0){
|
|
const empty = document.createElement('div');
|
|
empty.className = 'sub';
|
|
empty.textContent = 'No configurable arguments';
|
|
builder.appendChild(empty);
|
|
return;
|
|
}
|
|
|
|
Object.entries(action.args).forEach(([key, config])=>{
|
|
const field = document.createElement('div');
|
|
field.className = 'field';
|
|
|
|
const label = document.createElement('div');
|
|
label.className = 'label';
|
|
label.textContent = config.label || key;
|
|
field.appendChild(label);
|
|
|
|
const input = this.createInput(key, config);
|
|
field.appendChild(input);
|
|
|
|
if(config.help){
|
|
const help = document.createElement('div');
|
|
help.className = 'sub';
|
|
help.textContent = config.help;
|
|
field.appendChild(help);
|
|
}
|
|
|
|
builder.appendChild(field);
|
|
});
|
|
}
|
|
|
|
createInput(key, config){
|
|
const type = config.type || 'text';
|
|
let input;
|
|
switch(type){
|
|
case 'select':
|
|
input = document.createElement('select');
|
|
input.className = 'select';
|
|
(config.choices || []).forEach(choice=>{
|
|
const option = document.createElement('option');
|
|
option.value = choice; option.textContent = choice;
|
|
if(choice === config.default) option.selected = true;
|
|
input.appendChild(option);
|
|
});
|
|
break;
|
|
case 'checkbox':
|
|
input = document.createElement('input');
|
|
input.type='checkbox'; input.className='ctl';
|
|
input.checked = config.default || false;
|
|
break;
|
|
case 'number':
|
|
input = document.createElement('input');
|
|
input.type='number'; input.className='ctl';
|
|
if(config.min !== undefined) input.min = config.min;
|
|
if(config.max !== undefined) input.max = config.max;
|
|
if(config.step !== undefined) input.step = config.step;
|
|
input.value = config.default || '';
|
|
break;
|
|
case 'slider':
|
|
case 'range':
|
|
input = document.createElement('input');
|
|
input.type='range'; input.className='range';
|
|
input.min = config.min || 0;
|
|
input.max = config.max || 100;
|
|
input.step = config.step || 1;
|
|
input.value = config.default || input.min;
|
|
break;
|
|
default:
|
|
input = document.createElement('input');
|
|
input.type='text'; input.className='ctl';
|
|
input.value = config.default || '';
|
|
input.placeholder = config.placeholder || '';
|
|
}
|
|
input.dataset.arg = key;
|
|
return input;
|
|
}
|
|
|
|
applyPreset(preset){
|
|
Object.entries(preset).forEach(([key,val])=>{
|
|
if(key==='name'||key==='title') return;
|
|
const container = document.getElementById('argBuilder');
|
|
if(!container) return;
|
|
const input = container.querySelector(`[data-arg="${key}"]`);
|
|
if(!input) return;
|
|
if(input.type==='checkbox') input.checked = !!val;
|
|
else input.value = val;
|
|
});
|
|
}
|
|
|
|
collectArguments(){
|
|
if(!this.activeAction) return '';
|
|
const args = [];
|
|
const builder = document.getElementById('argBuilder');
|
|
if(builder){
|
|
builder.querySelectorAll('[data-arg]').forEach(input=>{
|
|
const key = input.dataset.arg;
|
|
const flag = `--${key.replace(/_/g,'-')}`;
|
|
if(input.type==='checkbox'){
|
|
if(input.checked) args.push(flag);
|
|
}else{
|
|
const value = input.value.trim();
|
|
if(value){ args.push(flag); args.push(value); }
|
|
}
|
|
});
|
|
}
|
|
const freeArgs = document.getElementById('freeArgs')?.value || '';
|
|
if(freeArgs.trim()) args.push(...freeArgs.trim().split(' '));
|
|
return args.join(' ');
|
|
}
|
|
|
|
/* ---------- Consoles ---------- */
|
|
renderConsoles(){
|
|
const container = document.getElementById('multiConsole');
|
|
const effectiveSplit = isMobile() ? 1 : this.split;
|
|
container.className = `multiConsole split-${effectiveSplit}`;
|
|
container.innerHTML = '';
|
|
|
|
const rows = (effectiveSplit===4) ? 2 : 1;
|
|
container.style.setProperty('--rows', rows);
|
|
|
|
for(let i=0;i<effectiveSplit;i++){
|
|
const actionId = this.panes[i];
|
|
const action = actionId ? this.actions.find(a=>a.id===actionId) : null;
|
|
|
|
const pane = document.createElement('div');
|
|
pane.className='pane'; pane.dataset.index=i;
|
|
|
|
const statusColor = this.getStatusColor(action?.status);
|
|
const iconUrl = action?.icon || '/web/images/attack.png';
|
|
const hasMeta = !!(action && (action.docsUrl || action.author || action.version));
|
|
|
|
pane.innerHTML = `
|
|
<div class="paneHeader">
|
|
<!-- Left: status dot + icon + title/meta -->
|
|
<div class="paneTitle" title="${action ? (action.description||'') : ''}">
|
|
<span class="dot" style="background:${statusColor}"></span>
|
|
${action ? `<img class="paneIcon" src="${iconUrl}" alt="" onerror="this.style.display='none'">` : ''}
|
|
<div class="titleBlock">
|
|
<div class="titleLine">
|
|
<strong>${action ? action.name : '— Empty Pane —'}</strong>
|
|
</div>
|
|
${
|
|
hasMeta
|
|
? `<div class="metaLine">
|
|
${action?.version ? `<span class="chip">v${action.version}</span>` : ''}
|
|
${action?.author ? `<span class="chip">by ${action.author}</span>` : ''}
|
|
</div>`
|
|
: ''
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Right: actions -->
|
|
<div class="paneBtns">
|
|
${
|
|
action
|
|
? `
|
|
${action?.docsUrl ? `<a class="btn" href="${action.docsUrl}" target="_blank" rel="noopener">Docs</a>` : ''}
|
|
<button class="btn" data-action="run" data-idx="${i}">Run</button>
|
|
<button class="btn warn" data-action="stop" data-idx="${i}">Stop</button>
|
|
<button class="btn" data-action="clear" data-idx="${i}">Clear</button>
|
|
<button class="btn" data-action="export" data-idx="${i}">⬇ Export</button>
|
|
<button class="btn ${this.autoClearPane[i] ? 'on':''}" data-action="toggleAuto" data-idx="${i}">
|
|
${this.autoClearPane[i] ? 'Auto-clear ON' : 'Auto-clear OFF'}
|
|
</button>
|
|
`
|
|
: `<button class="btn" data-action="assign" data-idx="${i}">Assign</button>`
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="paneLog" id="paneLog-${i}">
|
|
${this.renderLogs(actionId)}
|
|
</div>
|
|
`;
|
|
|
|
pane.querySelectorAll('[data-action]').forEach(btn=>{
|
|
btn.addEventListener('click', ()=>{
|
|
const idx = parseInt(btn.dataset.idx ?? i, 10);
|
|
switch(btn.dataset.action){
|
|
case 'run': this.activeAction = action; this.runAction(idx); break;
|
|
case 'stop': this.stopAction(actionId); break;
|
|
case 'clear': this.clearActionLogs(actionId); break;
|
|
case 'export':this.exportPaneLogs(idx); break;
|
|
case 'toggleAuto':
|
|
this.autoClearPane[idx] = !this.autoClearPane[idx];
|
|
btn.classList.toggle('on', this.autoClearPane[idx]);
|
|
btn.textContent = this.autoClearPane[idx] ? 'Auto-clear ON' : 'Auto-clear OFF';
|
|
break;
|
|
case 'assign': this.assignActionToPane(idx); break;
|
|
}
|
|
});
|
|
});
|
|
|
|
// Drag-to-assign
|
|
pane.addEventListener('dragover', e=>{ e.preventDefault(); pane.classList.add('paneHighlight'); });
|
|
pane.addEventListener('dragleave', ()=>pane.classList.remove('paneHighlight'));
|
|
pane.addEventListener('drop', e=>{
|
|
e.preventDefault(); pane.classList.remove('paneHighlight');
|
|
const droppedId = e.dataTransfer.getData('text/plain'); if(!droppedId) return;
|
|
this.panes[i] = droppedId; this.renderConsoles();
|
|
});
|
|
|
|
container.appendChild(pane);
|
|
pane.querySelector(`#paneLog-${i}`)?.addEventListener('click', ()=>this.setAssignTarget(i));
|
|
}
|
|
}
|
|
|
|
renderLogs(actionId){
|
|
if(!actionId) return '<div class="logline dim">Select an action to see logs</div>';
|
|
const logs = this.logs.get(actionId) || [];
|
|
if(logs.length===0) return '<div class="logline dim">Waiting for logs...</div>';
|
|
return logs.map(log=>this.formatLogLine(log)).join('');
|
|
}
|
|
formatLogLine(log){
|
|
const timestamp = new Date().toLocaleTimeString();
|
|
const cssClass = this.getLogClass(log);
|
|
return `<div class="logline ${cssClass}">[${timestamp}] ${this.escapeHtml(log)}</div>`;
|
|
}
|
|
getLogClass(log){
|
|
const lower = String(log).toLowerCase();
|
|
if(lower.includes('error') || lower.includes('failed')) return 'err';
|
|
if(lower.includes('warning') || lower.includes('warn')) return 'warn';
|
|
if(lower.includes('success') || lower.includes('complete')) return 'ok';
|
|
if(lower.includes('info')) return 'info';
|
|
return 'dim';
|
|
}
|
|
escapeHtml(text){
|
|
const map = { '&':'&','<':'<','>':'>','"':'"',"'":''' };
|
|
return String(text).replace(/[&<>"']/g, m=>map[m]);
|
|
}
|
|
getStatusColor(status){
|
|
switch(status){
|
|
case 'running': return 'var(--acid)';
|
|
case 'success': return 'var(--ok)';
|
|
case 'error': return 'var(--danger)';
|
|
default: return 'var(--acid-2)';
|
|
}
|
|
}
|
|
|
|
clearActionLogs(actionId){
|
|
this.logs.set(actionId, []); this.renderConsoles();
|
|
}
|
|
|
|
exportPaneLogs(paneIndex){
|
|
const actionId = this.panes[paneIndex]; if(!actionId) return;
|
|
const action = this.actions.find(a=>a.id===actionId);
|
|
const logs = this.logs.get(actionId) || [];
|
|
const blob = new Blob([logs.join('\n')], {type:'text/plain'});
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `${(action?.name || 'pane')}_logs_${Date.now()}.txt`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
highlightPane(index){
|
|
const pane = document.querySelector(`.pane[data-index="${index}"]`);
|
|
if(pane){ pane.classList.add('paneHighlight'); setTimeout(()=>pane.classList.remove('paneHighlight'), 900); }
|
|
}
|
|
|
|
setAssignTarget(paneIndex){
|
|
this.assignTargetPaneIndex = paneIndex;
|
|
document.querySelectorAll('.pane').forEach(el=>el.classList.remove('paneHighlight'));
|
|
const pane = document.querySelector(`.pane[data-index="${paneIndex}"]`);
|
|
if(pane) pane.classList.add('paneHighlight');
|
|
}
|
|
clearAssignTarget(){
|
|
this.assignTargetPaneIndex = null;
|
|
document.querySelectorAll('.pane').forEach(el=>el.classList.remove('paneHighlight'));
|
|
}
|
|
|
|
/* ---------- Run / Stop / Poll ---------- */
|
|
async runAction(paneIndex=null){
|
|
if(!this.activeAction) return;
|
|
const args = this.collectArguments();
|
|
const action = this.activeAction;
|
|
|
|
if(paneIndex!==null && this.autoClearPane[paneIndex]){
|
|
this.logs.set(action.id, []);
|
|
const logEl = document.getElementById(`paneLog-${paneIndex}`);
|
|
if(logEl) logEl.innerHTML = '';
|
|
}
|
|
|
|
action.status = 'running';
|
|
this.runningActions.set(action.id, {status:'running', process:null});
|
|
this.renderActions(); this.renderConsoles();
|
|
|
|
if(!this.logs.has(action.id)) this.logs.set(action.id, []);
|
|
this.logs.get(action.id).push(`Starting ${action.name}...`);
|
|
|
|
try{
|
|
const response = await fetch('/run_script', {
|
|
method:'POST',
|
|
headers:{'Content-Type':'application/json'},
|
|
body: JSON.stringify({ script_name: action.module || action.id, args })
|
|
});
|
|
const data = await response.json();
|
|
if(data.status==='success'){ this.startOutputPolling(action.id); }
|
|
else{ throw new Error(data.message || 'Run failed'); }
|
|
}catch(err){
|
|
action.status = 'error';
|
|
this.logs.get(action.id).push(`Error: ${err.message}`);
|
|
this.runningActions.delete(action.id);
|
|
this.renderActions(); this.renderConsoles();
|
|
}
|
|
}
|
|
|
|
async stopAction(actionId){
|
|
const id = actionId || this.activeAction?.id; if(!id) return;
|
|
try{
|
|
const action = this.actions.find(a=>a.id===id); if(!action) return;
|
|
const response = await fetch('/stop_script', {
|
|
method:'POST', headers:{'Content-Type':'application/json'},
|
|
body: JSON.stringify({ script_name: action.path })
|
|
});
|
|
const data = await response.json();
|
|
if(data.status==='success'){
|
|
action.status = 'ready';
|
|
this.runningActions.delete(id);
|
|
this.stopOutputPolling(id);
|
|
this.logs.get(id)?.push('Script stopped by user');
|
|
this.renderActions(); this.renderConsoles();
|
|
}
|
|
}catch(err){ console.error('Failed to stop action:', err); }
|
|
}
|
|
|
|
startOutputPolling(actionId){
|
|
const poll = async ()=>{
|
|
try{
|
|
const action = this.actions.find(a=>a.id===actionId); if(!action) return;
|
|
const response = await fetch(`/get_script_output/${encodeURIComponent(action.path)}`);
|
|
const data = await response.json();
|
|
|
|
if(data.status==='success'){
|
|
const output = data.data.output || [];
|
|
if(output.length>0){
|
|
if(!this.logs.has(actionId)) this.logs.set(actionId, []);
|
|
this.logs.set(actionId, output);
|
|
this.updatePaneLog(actionId);
|
|
}
|
|
if(data.data.is_running){
|
|
this.pollingIntervals.set(actionId, setTimeout(poll, 1000));
|
|
}else{
|
|
action.status = 'success';
|
|
this.runningActions.delete(actionId);
|
|
this.stopOutputPolling(actionId);
|
|
this.logs.get(actionId)?.push('Script completed successfully');
|
|
this.renderActions(); this.renderConsoles();
|
|
}
|
|
}
|
|
}catch(err){
|
|
console.error('Polling error:', err);
|
|
this.stopOutputPolling(actionId);
|
|
}
|
|
};
|
|
poll();
|
|
}
|
|
|
|
stopOutputPolling(actionId){
|
|
const t = this.pollingIntervals.get(actionId);
|
|
if(t){ clearTimeout(t); this.pollingIntervals.delete(actionId); }
|
|
}
|
|
|
|
updatePaneLog(actionId){
|
|
const paneIndex = this.panes.findIndex(p=>p===actionId);
|
|
if(paneIndex===-1) return;
|
|
const logEl = document.getElementById(`paneLog-${paneIndex}`);
|
|
if(logEl){
|
|
logEl.innerHTML = this.renderLogs(actionId);
|
|
logEl.scrollTop = logEl.scrollHeight;
|
|
}
|
|
}
|
|
}
|
|
|
|
/* Boot */
|
|
document.addEventListener('DOMContentLoaded', ()=>{
|
|
window.actionsLauncher = new ActionsLauncher();
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|