Files
Bjorn/web/actions_studio.html

1372 lines
77 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<title>BJORN Action Studio — Orchestrateur visuel (v2.1 auto-link)</title>
<link rel="icon" href="web/images/favicon.ico">
<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="#060c12">
<style>
:root{
--bg:#060c12;--panel:#0a1520;--card:#0b1c2a;--card2:#0d2132;--text:#e9f3ff;--muted:#9fb4c9;--border:#203448;
--neon:#66ffd1;--neon2:#57c9ff;--ok:#30db98;--bad:#ff6b7c;--warn:#ffd166;--edge:#2a557a;
--global:#7040ff;--host:#25be7b;--missing:#3b2135;--tap:44px;
}
*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}
html,body{height:100%;background:var(--bg);color:var(--text);font:14px/1.35 Inter,system-ui,-apple-system,Segoe UI,Roboto,sans-serif;overflow:hidden}
#app{display:grid;grid-template-rows:auto 1fr auto;height:100vh}
/* Header */
header{display:flex;align-items:center;gap:.5rem;padding:.6rem .8rem;background:linear-gradient(90deg,rgba(6,12,18,.9),rgba(10,21,32,.7));border-bottom:1px solid var(--border);backdrop-filter:blur(8px);z-index:20}
.logo{width:22px;height:22px;border-radius:6px;background:conic-gradient(from 210deg,var(--neon),var(--neon2));box-shadow:0 0 32px rgba(90,255,200,.22)}
h1{font-size:15px;letter-spacing:.3px}
.sp{flex:1}
.btn{display:inline-flex;align-items:center;justify-content:center;gap:.45rem;padding:.48rem .7rem;border-radius:12px;background:#0c2132;border:1px solid var(--border);color:var(--text);cursor:pointer;font-size:13px;transition:all .2s;min-height:var(--tap)}
.btn:hover{transform:translateY(-1px);background:#0e2437}
.btn:active{transform:scale(.98)}
.btn.primary{background:linear-gradient(180deg,#0e2f25,#0b241d);border-color:#1d5a45;color:var(--neon)}
.btn.icon{width:var(--tap);padding:0}
/* Layout */
main{display:grid;grid-template-columns:320px 1fr 360px;gap:8px;padding:8px;min-height:0}
@media (max-width:1100px){
main{grid-template-columns:1fr;grid-template-rows:1fr auto;gap:8px}
#left,#right{position:fixed;z-index:19;top:56px;bottom:0;width:min(90vw,420px);max-width:420px;transition:transform .25s ease,opacity .25s;opacity:.98}
#left{left:0;transform:translateX(-120%)} #left.open{transform:translateX(0)}
#right{right:0;transform:translateX(120%)} #right.open{transform:translateX(0)}
}
/* Palette */
#left{background:var(--panel);border:1px solid var(--border);border-radius:12px;display:flex;flex-direction:column;min-height:0;overflow:hidden}
.tabs{display:flex;gap:4px;padding:8px;border-bottom:1px solid var(--border)}
.tab{padding:6px 12px;border-radius:10px;background:var(--card);border:1px solid transparent;cursor:pointer;font-size:13px}
.tab.active{background:var(--card2);border-color:var(--neon2);color:var(--neon2)}
.tab-content{flex:1;padding:10px;overflow:auto;display:none}
.tab-content.active{display:block}
h2{margin:.2rem 0 .6rem;font-size:12px;color:var(--muted);letter-spacing:.2px;text-transform:uppercase}
input.search{width:100%;background:#0a1f2e;color:var(--text);border:1px solid var(--border);border-radius:12px;padding:.6rem .7rem;margin-bottom:10px;font-size:14px}
/* Palette items */
.pitem{border:1px solid var(--border);background:#0a1b2a;border-radius:12px;padding:10px;display:flex;justify-content:space-between;gap:8px;align-items:center;user-select:none;margin-bottom:6px;cursor:grab;transition:all .2s}
.pitem:active{cursor:grabbing}
.pitem:hover{transform:translateX(2px);background:#0c1e2d}
.pitem.placed{opacity:.55}
.pmeta{font-size:12px;color:var(--muted)}
.padd{border:1px solid var(--border);background:#0b2437;border-radius:10px;padding:.35rem .6rem;font-size:12px;cursor:pointer}
.padd:hover{background:var(--neon2);color:var(--bg);transform:scale(1.05)}
.action-icon{width:24px;height:24px;border-radius:6px;margin-right:8px;object-fit:cover}
/* Hosts palette */
.host-card{border:1px solid var(--border);background:linear-gradient(135deg,#0b1e2c,#0a1b2a);border-radius:12px;padding:10px;margin-bottom:6px;cursor:grab}
.host-card:active{cursor:grabbing}
.host-card.simulated{border-color:var(--neon2);background:linear-gradient(135deg,#0b2233,#0a1f2e)}
.host-card .row{display:flex;gap:6px;flex-wrap:wrap;align-items:center;font-size:12px;margin-top:4px}
.host-card .row .btn{padding:.25rem .5rem;font-size:11px}
/* Canvas */
#center{position:relative;border:1px solid var(--border);border-radius:12px;background:radial-gradient(1200px 800px at 0% 0%,#0a1827 0%,#060c12 60%),#060c12;overflow:hidden;touch-action:none}
#bggrid{position:absolute;inset:0;background-image:linear-gradient(#0f2b3f 1px,transparent 1px),linear-gradient(90deg,#0f2b3f 1px,transparent 1px);background-size:40px 40px;opacity:.18;pointer-events:none}
#canvas{position:absolute;left:0;top:0;transform-origin:0 0}
#nodes{position:absolute;left:0;top:0;width:4000px;height:3000px}
#links{position:absolute;left:0;top:0;width:4000px;height:3000px;overflow:visible;pointer-events:auto}
/* Controls */
#controls{position:absolute;right:10px;bottom:10px;display:flex;flex-direction:column;gap:6px;z-index:5}
.ctrl{width:44px;height:44px;border-radius:12px;border:1px solid var(--border);background:#0a1f2e;color:var(--text);cursor:pointer;transition:all .2s}
.ctrl:hover{background:#0c2437;transform:scale(1.05)}
.ctrl:active{transform:scale(.97)}
/* Nodes */
.node{position:absolute;min-width:240px;max-width:320px;color:var(--text);background:linear-gradient(180deg,var(--card) 0%,var(--card2) 100%);border:2px solid var(--border);border-radius:12px;box-shadow:0 12px 32px rgba(0,0,0,.28);transition:transform .2s,box-shadow .2s,min-height .2s;cursor:grab}
.node:active{cursor:grabbing}
.node:hover{transform:translateY(-2px);box-shadow:0 16px 40px rgba(0,0,0,.4)}
.node.sel{outline:2px solid var(--neon);outline-offset:2px}
.nhdr{display:flex;align-items:center;justify-content:space-between;gap:6px;padding:8px 10px;border-bottom:1px solid var(--border);background:rgba(0,0,0,.2);border-radius:10px 10px 0 0}
.nname{font-weight:700;font-size:13px;letter-spacing:.2px;display:flex;align-items:center;gap:6px}
.node-icon{width:20px;height:20px;border-radius:4px;object-fit:cover}
.badge{font-size:11px;color:#97e8ff;background:#0b2b3f;border:1px solid #214b67;padding:.14rem .45rem;border-radius:999px}
.nbody{padding:8px 10px;display:grid;gap:6px;font-size:12px;color:var(--muted)}
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
.k{color:#7fa6c4}.v{color:var(--text)}
.nclose{border:none;background:transparent;color:#9fb4c9;font-size:16px;cursor:pointer;opacity:0;transition:opacity .2s}
.node:hover .nclose{opacity:1}
.host .badge{color:#9effc5;background:#0f2a22;border-color:#1f604b}
.host{background:linear-gradient(180deg,#0c241b,#0d2732);border-color:var(--host)}
.global .badge{color:#e6ddff;background:#1b1335;border-color:#4a3cb0}
.global{border-color:var(--global)}
.bjorn{min-width:120px;max-width:140px;border-radius:12px;overflow:hidden}
.bjorn .nhdr{border-bottom:none;background:linear-gradient(180deg,#1a1a2e,#16213e)}
/* Rails & ports dynamiques */
.rail{position:absolute;top:10px;bottom:10px;width:18px;border-radius:10px;border:1px solid var(--border);
background:#0a1f2e;display:flex;flex-direction:column;align-items:center;gap:6px;padding:6px;cursor:crosshair;z-index:3}
.rail.left{left:-10px}
.rail.right{right:-10px;background:#0f2a22;border-color:#1f604b}
.port{width:10px;height:10px;border:2px solid #0a1120;border-radius:50%;background:var(--neon2);box-shadow:0 0 10px rgba(88,201,255,.5)}
.rail.right .port{background:var(--neon)}
.port.add{opacity:.5;outline:1px dashed #31597b}
/* SVG edges */
svg{pointer-events:none}
.path{fill:none;stroke:var(--edge);stroke-width:2.5;opacity:.95;pointer-events:stroke;cursor:pointer;transition:all .2s}
.path:hover{stroke-width:3.5;opacity:1}
.path.ok{stroke:var(--ok)}.path.bad{stroke:var(--bad)}.path.req{stroke:var(--neon2)}
.path.flow{stroke-dasharray:6 9;animation:flow 1.5s linear infinite}
@keyframes flow{to{stroke-dashoffset:-60}}
.edgelabel{font-size:11px;fill:#d7ebff;paint-order:stroke;stroke:#0c1724;stroke-width:3px;cursor:pointer;pointer-events:all}
.edgelabel.bad{fill:#ffd4da}.edgelabel.ok{fill:#c8ffe7}.edgelabel.req{fill:#d7e2ff}
/* Inspector */
#right{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:10px;display:flex;flex-direction:column;gap:10px;min-height:0;overflow:auto}
.section{background:#0b1d2b;border:1px solid var(--border);border-radius:12px;padding:10px}
.section h3{margin:.2rem 0 .6rem;font-size:13px;color:var(--muted)}
label{display:flex;flex-direction:column;gap:.3rem;margin:.45rem 0}
label span{font-size:12px;color:var(--muted)}
input,select,textarea{background:#0a1f2e;color:var(--text);border:1px solid var(--border);border-radius:10px;padding:.6rem .65rem;font:inherit;outline:none;transition:all .2s;min-height:40px}
input:focus,select:focus,textarea:focus{border-color:var(--neon2);box-shadow:0 0 0 2px rgba(87,201,255,0.2)}
textarea{min-height:86px;resize:vertical}
.small{font-size:12px;color:var(--muted)}
.pill{display:inline-flex;gap:6px;align-items:center;padding:.14rem .5rem;border-radius:999px;border:1px solid var(--border);background:#0b2233;font-size:11px}
hr{border:none;border-top:1px solid var(--border);margin:.6rem 0}
.form-row{display:grid;grid-template-columns:1fr 1fr;gap:10px}
@media (max-width:600px){.form-row{grid-template-columns:1fr}}
/* Footer */
footer{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px;border-top:1px solid var(--border);background:linear-gradient(90deg,rgba(10,23,34,.6),rgba(6,16,24,.8));font-size:12px;color:var(--muted)}
/* Modals / Edge menu */
.modal{display:none;position:fixed;inset:0;background:rgba(0,0,0,.8);z-index:1000;align-items:center;justify-content:center}
.modal.show{display:flex}
.modal-content{background:var(--panel);border:1px solid var(--border);border-radius:16px;padding:20px;max-width:560px;width:92vw;max-height:90vh;overflow:auto}
.modal-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}
.modal-title{font-size:18px;font-weight:600}
.modal-close{background:none;border:none;color:#fff;font-size:24px;cursor:pointer}
.edge-menu{position:fixed;background:var(--card);border:1px solid var(--border);border-radius:12px;padding:6px;box-shadow:0 8px 24px rgba(0,0,0,.4);z-index:1100;display:none}
.edge-menu.show{display:block}
.edge-menu-item{padding:10px 12px;border-radius:8px;cursor:pointer;font-size:13px}
.edge-menu-item:hover{background:#0b2233}
.edge-menu-item.danger{color:var(--bad)}
/* Toast */
.toast{position:fixed;bottom:50px;right:20px;padding:12px 20px;border-radius:10px;color:#00131d;font-weight:600;box-shadow:0 4px 16px rgba(0,0,0,.35);z-index:1500;transition:opacity .25s;opacity:0}
</style>
</head>
<body>
<div id="app">
<header>
<div class="logo" aria-hidden="true"></div>
<h1>BJORN Studio</h1>
<div class="sp"></div>
<button class="btn icon" id="btnPal" title="Palette"></button>
<button class="btn icon" id="btnIns" title="Inspecteur"></button>
<button class="btn" id="btnAutoLayout" title="Autolayout">⚡ Auto-layout</button>
<button class="btn" id="btnRepel" title="Répulsion anti-overlap">🧲 Repel</button>
<button class="btn primary" id="btnApply" title="Save & Apply">🚀 Apply</button>
<div class="kebab" style="position:relative">
<button class="btn icon" id="btnMenu" aria-haspopup="true"></button>
<div class="menu" id="mainMenu" role="menu" aria-label="Actions" style="position:absolute;top:calc(100% + 6px);right:0;min-width:220px;background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:6px;box-shadow:0 10px 32px rgba(0,0,0,.45);display:none;z-index:30">
<div class="item" id="mAddHost" role="menuitem" style="padding:.55rem .7rem;border-radius:8px;font-size:13px;cursor:pointer"> Add Host</div>
<div class="item" id="mSave" role="menuitem" style="padding:.55rem .7rem;border-radius:8px;font-size:13px;cursor:pointer">💾 Save to DB</div>
<div class="item" id="mImportdbActions" role="menuitem" style="padding:.55rem .7rem;border-radius:8px;font-size:13px;cursor:pointer">⬇ Import Actions DB</div>
<div class="item" id="mImportdbActionsStudio" role="menuitem" style="padding:.55rem .7rem;border-radius:8px;font-size:13px;cursor:pointer">⬆ Import Studio DB</div>
</div>
</div>
</header>
<main>
<!-- Palette -->
<aside id="left" aria-label="Palette">
<div class="tabs">
<div class="tab active" data-tab="actions">Actions</div>
<div class="tab" data-tab="hosts">Hosts</div>
</div>
<div class="tab-content active" id="tab-actions">
<input class="search" id="filterActions" placeholder="🔍 Filtrer les actions…">
<h2>Available Actions</h2>
<div id="plist"></div>
</div>
<div class="tab-content" id="tab-hosts">
<input class="search" id="filterHosts" placeholder="🔍 Filtrer nom/IP/MAC…">
<button class="btn" id="btnCreateHost" style="width:100%;margin-bottom:10px"> Create Test Host</button>
<h2>Real Hosts</h2>
<div id="realHosts"></div>
<h2>Test Hosts</h2>
<div id="testHosts"></div>
</div>
</aside>
<!-- Canvas -->
<section id="center" aria-label="Canvas">
<div id="bggrid"></div>
<div id="canvas" style="transform:translate(0px,0px) scale(1)">
<svg id="links" width="4000" height="3000" aria-label="Liens"></svg>
<div id="nodes" aria-live="polite"></div>
</div>
<!-- Zoom controls -->
<div id="controls">
<button class="ctrl" id="zIn" title="Zoom in"></button>
<button class="ctrl" id="zOut" title="Zoom out"></button>
<button class="ctrl" id="zFit" title="Fit"></button>
</div>
</section>
<!-- Inspector -->
<aside id="right" aria-label="Inspector">
<div class="section" id="actionInspector">
<h3>Action sélectionnée</h3>
<div id="noSel" class="small">Sélectionne un nœud pour éditer</div>
<div id="edit" style="display:none">
<label><span>b_class</span><input id="e_class" disabled></label>
<div class="form-row">
<label><span>b_module</span><input id="e_module"></label>
<label><span>b_status</span><input id="e_status"></label>
</div>
<div class="form-row">
<label><span>Type</span>
<select id="e_type"><option value="normal">normal</option><option value="global">global</option></select>
</label>
<label><span>Enabled</span>
<select id="e_enabled"><option value="1">Oui</option><option value="0">Non</option></select>
</label>
</div>
<div class="form-row">
<label><span>Priority</span><input type="number" id="e_prio" min="1" max="100"></label>
<label><span>Timeout</span><input type="number" id="e_timeout"></label>
</div>
<div class="form-row">
<label><span>Max retries</span><input type="number" id="e_retry"></label>
<label><span>Cooldown (s)</span><input type="number" id="e_cool"></label>
</div>
<div class="form-row">
<label><span>Rate limit</span><input id="e_rate" placeholder="3/86400"></label>
<label><span>Port</span><input type="number" id="e_port" placeholder="22"></label>
</div>
<label><span>Services (CSV)</span><input id="e_services" placeholder="ssh, http, https"></label>
<label><span>Tags JSON</span><input id="e_tags" placeholder='["notif"]'></label>
<hr>
<h3>Trigger</h3>
<div class="form-row">
<label><span>Type</span>
<select id="t_type">
<option>on_start</option><option>on_new_host</option><option>on_host_alive</option><option>on_host_dead</option>
<option>on_join</option><option>on_leave</option><option>on_port_change</option><option>on_new_port</option>
<option>on_service</option><option>on_web_service</option><option>on_success</option><option>on_failure</option>
<option>on_cred_found</option><option>on_mac_is</option><option>on_essid_is</option><option>on_ip_is</option>
<option>on_has_cve</option><option>on_has_cpe</option><option>on_all</option><option>on_any</option><option>on_interval</option>
</select>
</label>
<label><span>Paramètre</span><input id="t_param" placeholder="port / service / ActionName / JSON list" style="font-family:ui-monospace"></label>
</div>
<hr>
<h3>Requirements</h3>
<div class="row">
<label style="flex:1"><span>Mode</span>
<select id="r_mode"><option value="all">ALL (AND)</option><option value="any">ANY (OR)</option></select>
</label>
<button class="btn" id="r_add">+ Condition</button>
</div>
<div id="r_list" class="small"></div>
<div class="row" style="margin-top:.6rem">
<button class="btn" id="btnUpdateAction">Appliquer</button>
<button class="btn" id="btnDeleteNode">🗑 Supprimer du canvas</button>
</div>
</div>
</div>
<div class="section" id="hostInspector" style="display:none">
<h3>Host sélectionné</h3>
<div class="form-row">
<label><span>MAC</span><input id="h_mac"></label>
<label><span>Hostname</span><input id="h_hostname"></label>
</div>
<div class="form-row">
<label><span>IP(s)</span><input id="h_ips" placeholder="192.168.1.10;192.168.1.11"></label>
<label><span>Ports</span><input id="h_ports" placeholder="22;80;443"></label>
</div>
<div class="form-row">
<label><span>Alive</span>
<select id="h_alive"><option value="1">Yes</option><option value="0">No</option></select>
</label>
<label><span>ESSID</span><input id="h_essid"></label>
</div>
<label><span>Services (JSON)</span><textarea id="h_services" placeholder='[{"port":22,"service":"ssh"},{"port":80,"service":"http"}]'></textarea></label>
<label><span>Vulns (CSV)</span><input id="h_vulns" placeholder="CVE-2023-..., CVE-2024-..."></label>
<label><span>Creds (JSON)</span><textarea id="h_creds" placeholder='[{"service":"ssh","user":"admin","password":"pass"}]'></textarea></label>
<div class="row" style="margin-top:.6rem">
<button class="btn" id="btnUpdateHost">Appliquer</button>
<button class="btn" id="btnDeleteHost">🗑 Supprimer du canvas</button>
</div>
</div>
</aside>
</main>
<footer>
<div class="pill"><span style="width:8px;height:8px;border-radius:50%;background:var(--ok)"></span> success</div>
<div class="pill"><span style="width:8px;height:8px;border-radius:50%;background:var(--bad)"></span> failure</div>
<div class="pill"><span style="width:8px;height:8px;border-radius:50%;background:#7aa7ff"></span> requires</div>
<div class="pill">Pinch/scroll = zoom · Drag = pan · Relie ports à ports pour créer des liens</div>
<div class="pill"><span id="nodeCount">0</span> nodes · <span id="linkCount">0</span> links</div>
</footer>
</div>
<!-- Edge pop menu -->
<div class="edge-menu" id="edgeMenu">
<div class="edge-menu-item" data-action="edit">✏️ Éditer…</div>
<div class="edge-menu-item" data-action="toggle-success">✅ Success</div>
<div class="edge-menu-item" data-action="toggle-failure">❌ Failure</div>
<div class="edge-menu-item" data-action="toggle-req">🔗 Requires</div>
<div class="edge-menu-item danger" data-action="delete">🗑 Supprimer</div>
</div>
<!-- Link Wizard -->
<div class="modal" id="linkWizard" aria-hidden="true" aria-labelledby="linkWizardTitle" role="dialog">
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title" id="linkWizardTitle">Lien</h2>
<button class="modal-close" id="lwClose" aria-label="Fermer">×</button>
</div>
<div class="modal-body">
<div class="row" style="margin-bottom:6px">
<div class="pill">From: <b id="lwFromName"></b></div>
<div class="pill">To: <b id="lwToName"></b></div>
</div>
<p class="small" id="lwContext">Choisis le comportement (trigger ou requirement). Les options sadaptent.</p>
<hr>
<div class="form-row">
<label><span>Mode</span>
<select id="lwMode"><option value="trigger">Trigger</option><option value="requires">Requirement</option></select>
</label>
<label><span>Preset</span><select id="lwPreset"></select></label>
</div>
<div class="form-row" id="lwParamsRow">
<label><span>Param 1</span><input id="lwParam1" placeholder="ssh / 22 / CVE-…"></label>
<label><span>Param 2</span><input id="lwParam2" placeholder="optionnel"></label>
</div>
<div class="section" style="margin-top:10px">
<div class="row"><div class="pill">Preview:</div><code id="lwPreview"></code></div>
</div>
<div class="row" style="margin-top:16px">
<button class="btn primary" id="lwCreate">Valider</button>
<button class="btn" id="lwCancel">Annuler</button>
</div>
</div>
</div>
</div>
<!-- Host Modal -->
<div class="modal" id="hostModal" aria-hidden="true" aria-labelledby="hostModalTitle" role="dialog">
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title" id="hostModalTitle">Add Test Host</h2>
<button class="modal-close" onclick="closeHostModal()" aria-label="Close">×</button>
</div>
<div class="modal-body">
<label><span>MAC Address</span><input id="new_mac" placeholder="AA:BB:CC:DD:EE:FF"></label>
<label><span>Hostname</span><input id="new_hostname" placeholder="test-server-01"></label>
<label><span>IP Address(es)</span><input id="new_ips" placeholder="192.168.1.100;192.168.1.101"></label>
<label><span>Open Ports</span><input id="new_ports" placeholder="22;80;443;3306"></label>
<label><span>Services (JSON)</span>
<textarea id="new_services" placeholder='[{"port":22,"service":"ssh"},{"port":80,"service":"http"}]'>[{"port":22,"service":"ssh"}]</textarea>
</label>
<label><span>Vulnerabilities (CSV)</span><input id="new_vulns" placeholder="CVE-2023-1234, CVE-2024-5678"></label>
<label><span>Credentials (JSON)</span>
<textarea id="new_creds" placeholder='[{"service":"ssh","user":"admin","password":"password"}]'>[]</textarea>
</label>
<label><span>Alive</span>
<select id="new_alive"><option value="1">Yes</option><option value="0">No</option></select>
</label>
<div style="display:flex;gap:10px;margin-top:20px">
<button class="btn primary" onclick="createTestHost()">Create Host</button>
<button class="btn" onclick="closeHostModal()">Cancel</button>
</div>
</div>
</div>
</div>
<script>
/* ===================== Config & State ===================== */
const API_BASE = window.BJORN_API_BASE || '/api';
const state = {
actions: new Map(), // b_class -> action
hosts: new Map(), // mac -> host
nodes: new Map(), // nodeId -> {type, data, x, y, slots:{in:[],out:[]}}
links: [], // [{id,from,to,type,mode,label?}]
selected: null,
pan: { x: 0, y: 0, scale: 1 },
placedActions: new Set(),
placedHosts: new Set(),
minGapH: 220,
minGapV: 150,
testMode:true
};
const $ = s => document.querySelector(s);
const $$ = s => Array.from(document.querySelectorAll(s));
const uid = (p='id') => p + '_' + Math.random().toString(36).slice(2) + Date.now().toString(36);
const clamp = (v,a,b)=>Math.max(a,Math.min(b,v));
const tryJSON = (s, fb) => { try { return typeof s === 'string' ? JSON.parse(s) : (s ?? fb); } catch { return fb; } };
const toCSV = a => (a || []).join(', ');
const fromCSV = s => (s || '').split(',').map(x => x.trim()).filter(Boolean);
/* ===================== API (mock fallback) ===================== */
async function fetchActions(){
try{
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
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' },
{ b_class:'StealFilesSSH', b_module:'steal_files_ssh', b_trigger:'on_success:SSHbruteforce', b_priority:70, b_enabled:1, b_requires:'{"all":[{"has_port":22},{"service_is_open":"ssh"}]}', b_icon:'StealFilesSSH.png' },
{ b_class:'ScanSSH', b_module:'ssh_scan', b_trigger:'on_service:ssh', b_priority:60, b_enabled:1, b_icon:'ScanSSH.png' },
{ b_class:'NmapVuln', b_module:'nmap_vuln', b_trigger:'on_new_port:445', b_priority:11, b_enabled:1, b_icon:'NmapVulnScanner.png' }
];
}
}
async function fetchHosts(){
try{
const r = await fetch(`${API_BASE}/studio/hosts`);
if(!r.ok) throw 0; const j = await r.json(); return Array.isArray(j)?j:(j.data||[]);
}catch{
return [
{ mac_address:'AA:BB:CC:DD:EE:FF', hostname:'server-01', ips:'192.168.1.100', ports:'22;80;443', services:'[{"port":22,"service":"ssh"},{"port":80,"service":"http"}]', vulns:'CVE-2023-0001', creds:'[]', alive:1, is_simulated:0 },
{ mac_address:'11:22:33:44:55:66', hostname:'db-01', ips:'192.168.1.101', ports:'3306;22', services:'[{"port":3306,"service":"mysql"},{"port":22,"service":"ssh"}]', vulns:'', creds:'[]', alive:1, is_simulated:0 },
{ mac_address:'22:33:44:55:66:77', hostname:'cam-01', ips:'192.168.1.120', ports:'554;80', services:'[{"port":80,"service":"http"}]', vulns:'', creds:'[]', alive:0, is_simulated:0 }
];
}
}
async function saveToStudio(){
const data = { edges: state.links, nodes: [], transform: state.pan };
state.nodes.forEach((n,id)=> data.nodes.push({id,...n}));
try{
const r = await fetch(`${API_BASE}/studio/save`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});
if(!r.ok) throw 0; toast('Sauvegardé','success');
}catch{
localStorage.setItem('bjorn_studio_backup', JSON.stringify(data));
toast('Sauvegarde locale (DB indisponible)','warn');
}
}
async function applyToRuntime(){
try{ const r = await fetch(`${API_BASE}/studio/apply`,{method:'POST'}); if(!r.ok) throw 0; toast('Appliqué au runtime','success'); }
catch{ toast('Apply runtime échoué','error'); }
}
/* ===================== Helpers UI ===================== */
function toast(msg,type='info'){
const t=document.createElement('div');
t.className='toast'; t.textContent=msg;
t.style.background = type==='success'? 'var(--ok)' : type==='error'? 'var(--bad)' : type==='warn'? 'var(--warn)' : 'var(--neon2)';
document.body.appendChild(t);
requestAnimationFrame(()=>{ t.style.opacity='1'; });
setTimeout(()=>{ t.style.opacity='0'; setTimeout(()=>t.remove(),260); }, 2100);
}
function updateStats(){ $('#nodeCount').textContent = state.nodes.size; $('#linkCount').textContent = state.links.length; }
const byHostnameIpMac = (a,b)=>{
const aHost=(a.hostname||'').toLowerCase(), bHost=(b.hostname||'').toLowerCase();
if(aHost||bHost){ const c=aHost.localeCompare(bHost); if(c) return c; }
const aIp=(a.ips||'').split(/[,; ]/)[0]||'', bIp=(b.ips||'').split(/[,; ]/)[0]||'';
if(aIp||bIp){ const c=aIp.localeCompare(bIp,undefined,{numeric:true,sensitivity:'base'}); if(c) return c; }
return (a.mac_address||'').localeCompare(b.mac_address||'');
};
function resolveIcon(action){
const raw=(action?.b_icon||'').toString().trim();
const name=raw || `${action.b_class}.png`;
if(/^https?:\/\//.test(name) || name.startsWith('/')) return name;
return `/actions/actions_icons/${encodeURIComponent(name)}`;
}
/* ===================== Palette ===================== */
function buildPalette(){
const list=$('#plist'); list.innerHTML='';
const q=($('#filterActions').value||'').toLowerCase();
const arr=[...state.actions.values()].sort((a,b)=>a.b_class.localeCompare(b.b_class));
for(const a of arr){
if(q && !a.b_class.toLowerCase().includes(q) && !(a.b_module||'').toLowerCase().includes(q)) continue;
const placed=state.placedActions.has(a.b_class);
const el=document.createElement('div'); el.className=`pitem ${placed?'placed':''}`; el.draggable=true;
const icon=resolveIcon(a);
el.innerHTML=`
<div style="display:flex;align-items:center;flex:1">
${icon?`<img src="${icon}" class="action-icon" onerror="this.style.display='none'">`:''}
<div><div class="nname">${a.b_class}${a.b_action==='global'?' <span style="color:#cbb8ff">[GLOBAL]</span>':''}</div>
<div class="pmeta">${a.b_module||''} · prio:${a.b_priority??'-'}</div></div>
</div>
<button class="padd"></button>`;
el.addEventListener('dragstart',e=>e.dataTransfer.setData('action',JSON.stringify(a)));
el.querySelector('.padd').addEventListener('click',()=>dropActionCenter(a));
list.appendChild(el);
}
}
function buildHostPalette(){
const real=$('#realHosts'), test=$('#testHosts'); real.innerHTML=''; test.innerHTML='';
const q=($('#filterHosts').value||'').toLowerCase();
const all=[...state.hosts.values()]
.filter(h=>!q || (h.mac_address||'').toLowerCase().includes(q) || (h.hostname||'').toLowerCase().includes(q) || (h.ips||'').toLowerCase().includes(q))
.sort(byHostnameIpMac);
for(const h of all){
const placed=state.placedHosts.has(h.mac_address);
const el=document.createElement('div'); el.className=`host-card ${h.is_simulated?'simulated':''}`;
el.innerHTML=`
<div><b style="color:#9effc5">${h.hostname || h.ips || h.mac_address}</b></div>
<div class="row"><div>IP: ${h.ips||'—'}</div><div>Ports: ${h.ports||'—'}</div><div>Alive: ${h.alive?'🟢':'🔴'}</div></div>
<div class="row">
<button class="btn" onclick="addHostToCanvas('${h.mac_address}')">${placed?'Focus':'Place'}</button>
${h.is_simulated?`<button class="btn" onclick="deleteTestHost('${h.mac_address}')">🗑</button>`:''}
</div>`;
(h.is_simulated?test:real).appendChild(el);
}
}
/* ===================== Nodes ===================== */
function addActionNode(action,x=100,y=100){
const id=uid('action'); const node={type:'action',data:action,x,y}; state.nodes.set(id,node); state.placedActions.add(action.b_class);
const isGlobal=action.b_action==='global';
const el=document.createElement('div'); el.className=`node ${isGlobal?'global':''}`; el.dataset.id=id; el.dataset.type='action';
el.style.left=x+'px'; el.style.top=y+'px';
const icon=resolveIcon(action);
el.innerHTML=`
<div class="nhdr"><div class="nname">${action.b_class}</div><span class="badge">${action.b_action||'normal'}</span><button class="nclose">×</button></div>
<div class="nbody">
${icon?`<img src="${icon}" class="node-icon" style="width:70px;height:70px;display:block;margin:0 auto 8px" onerror="this.style.display='none'">`:''}
<div class="row"><span class="k">module:</span><span class="v">${action.b_module||'—'}</span></div>
<div class="row"><span class="k">trigger:</span><span class="v trigger">${summTrig(action.b_trigger||'')}</span></div>
<div class="row"><span class="k">priority:</span><span class="v">${action.b_priority??50}</span></div>
<div class="row"><span class="k">requires:</span><span class="v requires">${requireSummary(action)}</span></div>
</div>
<div class="rail left" data-side="in"></div><div class="rail right" data-side="out"></div>`;
$('#nodes').appendChild(el);
el.querySelector('.nclose').addEventListener('click',e=>{e.stopPropagation(); deleteNode(id);});
setupNodeEvents(el,id);
buildPalette(); updateStats(); LinkEngine.rebuildRails(); LinkEngine.render();
return id;
}
function addHostNode(host,x=100,y=100){
const id=uid('host'); const node={type:'host',data:host,x,y}; state.nodes.set(id,node); state.placedHosts.add(host.mac_address);
const el=document.createElement('div'); el.className='node host'; el.dataset.id=id; el.dataset.type='host';
el.style.left=x+'px'; el.style.top=y+'px';
el.innerHTML=`
<div class="nhdr"><div class="nname">${host.hostname || host.ips || host.mac_address}</div><span class="badge">HOST</span><button class="nclose">×</button></div>
<div class="nbody">
<div class="row"><span class="k">IP:</span><span class="v">${host.ips||'—'}</span></div>
<div class="row"><span class="k">Ports:</span><span class="v">${host.ports||'—'}</span></div>
<div class="row"><span class="k">Alive:</span><span class="v">${host.alive?'🟢':'🔴'}</span></div>
${host.is_simulated?'<div class="row"><span class="badge" style="background:var(--neon2)">TEST HOST</span></div>':''}
</div>
<div class="rail left" data-side="in"></div><div class="rail right" data-side="out"></div>`;
$('#nodes').appendChild(el);
el.querySelector('.nclose').addEventListener('click',e=>{e.stopPropagation(); deleteNode(id);});
setupNodeEvents(el,id);
buildHostPalette(); updateStats(); LinkEngine.rebuildRails(); LinkEngine.render();
return id;
}
function deleteNode(id){
const n=state.nodes.get(id); if(!n) return;
if(n.type==='action'){ state.placedActions.delete(n.data.b_class); buildPalette(); }
if(n.type==='host'){ state.placedHosts.delete(n.data.mac_address); buildHostPalette(); }
state.links = state.links.filter(l=>l.from!==id && l.to!==id);
const el=$(`[data-id="${id}"]`); if(el) el.remove();
state.nodes.delete(id);
state.selected=null; $('#edit').style.display='none'; $('#noSel').style.display='block'; $('#hostInspector').style.display='none';
LinkEngine.rebuildRails(); LinkEngine.render(); updateStats();
}
function dropActionCenter(a){
const rect=$('#center').getBoundingClientRect();
const x=(rect.width/2 - state.pan.x)/state.pan.scale - 120;
const y=(rect.height/2 - state.pan.y)/state.pan.scale - 80;
addActionNode(a,x,y);
}
/* ===================== Node Events / Drag ===================== */
function setupNodeEvents(el,id){
el.addEventListener('click',()=>selectNode(id));
let drag=null;
el.addEventListener('mousedown',e=>{
if(e.target.closest('.rail') || e.target.classList.contains('nclose')) return;
e.preventDefault();
const n=state.nodes.get(id); drag={x:e.clientX,y:e.clientY,nx:n.x,ny:n.y};
const move=ev=>{
const dx=(ev.clientX-drag.x)/state.pan.scale, dy=(ev.clientY-drag.y)/state.pan.scale;
n.x=drag.nx+dx; n.y=drag.ny+dy; el.style.left=n.x+'px'; el.style.top=n.y+'px';
LinkEngine.render();
};
const up=()=>{ document.removeEventListener('mousemove',move); document.removeEventListener('mouseup',up); repelLayout(); };
document.addEventListener('mousemove',move); document.addEventListener('mouseup',up);
});
}
function selectNode(id){
$$('.node.sel').forEach(n=>n.classList.remove('sel'));
const el=$(`[data-id="${id}"]`); if(el) el.classList.add('sel');
state.selected=id;
const n=state.nodes.get(id); if(!n) return;
if(n.type==='action') showActionInspector(n.data);
else if(n.type==='host') showHostInspector(n.data);
}
/* ===================== Rails & Links Engine ===================== */
const LinkEngine = {
slotGap: 18, railPad: 12,
rebuildRails(){
state.nodes.forEach(n=>{ n.slots={in:[],out:[]}; });
for(const l of state.links){
const a=state.nodes.get(l.from), b=state.nodes.get(l.to);
if(!a||!b) continue; a.slots.out.push(l); b.slots.in.push(l);
}
const yOf=id=>{ const el=$(`[data-id="${id}"]`), n=state.nodes.get(id); return (n && el)? n.y + el.offsetHeight/2 : 0; };
state.nodes.forEach((n,id)=>{
n.slots.in.sort((l1,l2)=>yOf(l1.from)-yOf(l2.from));
n.slots.out.sort((l1,l2)=>yOf(l1.to)-yOf(l2.to));
const el=$(`[data-id="${id}"]`); if(!el) return;
const railL=el.querySelector('.rail.left'), railR=el.querySelector('.rail.right');
const needIn=Math.max(1,n.slots.in.length), needOut=Math.max(1,n.slots.out.length);
const needMax=Math.max(needIn,needOut);
const body=el.querySelector('.nbody'); const base=Math.max(140, body?body.scrollHeight+24:140);
const need=this.railPad*2 + needMax*this.slotGap;
el.style.minHeight=Math.max(base,need)+'px';
const build=(rail,count,side)=>{
rail.innerHTML=''; for(let i=0;i<count;i++){ const d=document.createElement('div'); d.className='port'; d.dataset.side=side; d.dataset.index=String(i); rail.appendChild(d);
d.addEventListener('mousedown',e=>startConnect(e,id,side,i)); }
const add=document.createElement('div'); add.className='port add'; add.dataset.side=side; add.dataset.index=String(count); rail.appendChild(add);
add.addEventListener('mousedown',e=>startConnect(e,id,side,count));
};
build(railL,needIn,'in'); build(railR,needOut,'out');
n.slots.in.forEach((l,i)=> l._inSlot=i);
n.slots.out.forEach((l,i)=> l._outSlot=i);
});
},
anchor(id,side,slot=0){
const n=state.nodes.get(id), el=$(`[data-id="${id}"]`); if(!n||!el) return {x:0,y:0};
const rail=el.querySelector(side==='in'?'.rail.left':'.rail.right');
const r=rail.getBoundingClientRect(), c=$('#center').getBoundingClientRect();
const x=(r.left-c.left-state.pan.x)/state.pan.scale + r.width/2 + (side==='in'?-9:9);
const y=(r.top-c.top-state.pan.y)/state.pan.scale + this.railPad + slot*this.slotGap + 6;
return {x,y};
},
evaluate(link){
const A=state.nodes.get(link.from), B=state.nodes.get(link.to);
if(!A||!B) return {cls:'req',lbl:link.label||'requires'};
if(A.type==='host' && B.type==='action'){
const ev = evaluateHostToAction(link); return {cls: ev.ok?'ok':'bad', lbl: ev.label};
}
if(link.type==='success') return {cls:'ok', lbl:'on_success'};
if(link.type==='failure') return {cls:'bad', lbl:'on_failure'};
return {cls:'req', lbl: link.label||'requires'};
},
route(link){
const a=this.anchor(link.from,'out',link._outSlot||0);
const b=this.anchor(link.to,'in', link._inSlot||0);
const lane=((hash(link.from+':'+link.to)%7)-3)*8;
const midX=((a.x+b.x)/2)+lane;
return `M ${a.x} ${a.y} L ${midX} ${a.y} L ${midX} ${b.y} L ${b.x} ${b.y}`;
},
render(){
const svg=$('#links'); svg.innerHTML='';
this.rebuildRails();
for(const l of state.links){
const style=this.evaluate(l);
const path=document.createElementNS('http://www.w3.org/2000/svg','path');
path.classList.add('path',style.cls); if(style.cls==='ok') path.classList.add('flow');
path.setAttribute('d',this.route(l));
path.dataset.linkId=l.id;
path.addEventListener('click',e=>openEdgeMenu(l,e.clientX,e.clientY));
svg.appendChild(path);
const a=this.anchor(l.from,'out',l._outSlot||0), b=this.anchor(l.to,'in',l._inSlot||0);
const midX=(a.x+b.x)/2, midY=(a.y+b.y)/2;
const txt=document.createElementNS('http://www.w3.org/2000/svg','text');
txt.classList.add('edgelabel',style.cls); txt.setAttribute('x',midX); txt.setAttribute('y',midY-8); txt.textContent=style.lbl;
txt.dataset.linkId=l.id; txt.addEventListener('click',e=>openEdgeMenu(l,e.clientX,e.clientY));
svg.appendChild(txt);
}
updateStats();
}
};
function hash(s){let h=0;for(let i=0;i<s.length;i++){h=((h<<5)-h)+s.charCodeAt(i);h|=0;}return Math.abs(h);}
/* ===================== Connect Drag ===================== */
let tempPath=null, connectFrom=null;
function startConnect(e,fromId,side,slotIndex){
e.preventDefault();
connectFrom={id:fromId,side,slot:slotIndex};
const svg=$('#links');
tempPath=document.createElementNS('http://www.w3.org/2000/svg','path');
tempPath.classList.add('path','req'); tempPath.style.strokeDasharray='6 8';
svg.appendChild(tempPath);
const move=ev=>{
const rect=$('#center').getBoundingClientRect();
const cx=(ev.clientX-rect.left-state.pan.x)/state.pan.scale;
const cy=(ev.clientY-rect.top -state.pan.y)/state.pan.scale;
const a=LinkEngine.anchor(fromId,side,slotIndex);
const midX=(a.x+cx)/2;
tempPath.setAttribute('d',`M ${a.x} ${a.y} L ${midX} ${a.y} L ${midX} ${cy} L ${cx} ${cy}`);
};
const up=ev=>{
document.removeEventListener('mousemove',move);
document.removeEventListener('mouseup',up);
if(tempPath){tempPath.remove(); tempPath=null;}
const t=document.elementFromPoint(ev.clientX,ev.clientY);
const rail=t?.closest?.('.rail'); if(!rail) return;
const toEl=rail.closest('.node'); if(!toEl) return;
const toId=toEl.dataset.id; const toSide=rail.classList.contains('left')?'in':'out';
if(toId===fromId || side===toSide) return;
const actualFrom = side==='out'? fromId : toId;
const actualTo = side==='out'? toId : fromId;
openLinkWizard(actualFrom,actualTo,null);
};
document.addEventListener('mousemove',move);
document.addEventListener('mouseup',up);
}
/* ===================== Edge Menu & Wizard ===================== */
let edgeMenuLink=null;
function openEdgeMenu(link, x, y){
edgeMenuLink=link;
const m=$('#edgeMenu'); m.style.left=x+'px'; m.style.top=y+'px'; m.classList.add('show');
}
window.addEventListener('click',e=>{
if(!e.target.closest('.edge-menu')) $('#edgeMenu').classList.remove('show');
});
$('#edgeMenu').addEventListener('click',e=>{
const act=e.target.closest('.edge-menu-item')?.dataset.action; if(!act) return;
if(act==='delete'){ state.links=state.links.filter(l=>l.id!==edgeMenuLink.id); LinkEngine.render(); }
if(act==='toggle-success'){ edgeMenuLink.type='success'; edgeMenuLink.mode='trigger'; syncActionForEdge(edgeMenuLink); LinkEngine.render(); }
if(act==='toggle-failure'){ edgeMenuLink.type='failure'; edgeMenuLink.mode='trigger'; syncActionForEdge(edgeMenuLink); LinkEngine.render(); }
if(act==='toggle-req'){ edgeMenuLink.type='requires'; edgeMenuLink.mode='requires'; LinkEngine.render(); }
if(act==='edit'){ openLinkWizard(edgeMenuLink.from, edgeMenuLink.to, edgeMenuLink); }
$('#edgeMenu').classList.remove('show');
});
const linkWizard = { from:null,to:null,editing:null, presets:[] };
function prettyNodeName(n){ if(n.type==='action') return n.data?.b_class||'Action'; if(n.type==='host') return n.data?.hostname||n.data?.ips||n.data?.mac_address||'Host'; return 'Node'; }
function splitTriggerSafe(s){ s=(s||'').trim(); const i=s.indexOf(':'); return i===-1?{name:s,param:''}:{name:s.slice(0,i),param:s.slice(i+1)}; }
function summTrig(t){ if(!t) return '—'; const {name,param}=splitTriggerSafe(t); if(name==='on_any'||name==='on_all'){ try{const a=JSON.parse(param); return `${name}(${Array.isArray(a)?a.length:0})`;}catch{} } return t; }
function requireSummary(action){ const r=tryJSON(action.b_requires,null); if(!r) return '—'; if(r.all) return 'ALL '+r.all.length; if(r.any) return 'ANY '+r.any.length; if(r.action) return `${r.action}:${r.status||'success'}`; return Object.keys(r).join(', '); }
function computePresets(fromNode,toNode,mode){
const list=[], add=(id,label)=>list.push({id,label});
const ctx=`${fromNode.type}->${toNode.type}`;
if(mode==='trigger'){
if(ctx==='action->action'){ add('on_success','on_success (from action)'); add('on_failure','on_failure (from action)'); }
else if(ctx==='host->action'){ add('on_service','on_service:<service>'); add('on_web_service','on_web_service'); add('on_new_port','on_new_port:<port>'); add('on_port_change','on_port_change:<port>'); add('on_has_cve','on_has_cve:<CVE>'); add('on_mac_is','on_mac_is:<MAC>'); add('on_ip_is','on_ip_is:<IP>'); add('on_essid_is','on_essid_is:<ESSID>'); add('on_host_alive','on_host_alive'); }
}else{
if(ctx==='action->action'){ add('req_action','requires action:status'); }
else if(ctx==='host->action'){ add('has_port','requires has_port:<port>'); add('service_is_open','requires service_is_open:<service>'); add('has_cve','requires has_cve:<CVE>'); add('has_cpe','requires has_cpe:<CPE>'); add('has_cred','requires has_cred:<service>'); add('mac_is','requires mac_is:<MAC>'); add('essid_is','requires essid_is:<ESSID>'); }
}
return list;
}
function guessParams(fromNode,toNode,preset){
const host = fromNode.type==='host' ? fromNode.data : (toNode.type==='host'?toNode.data:null);
let def1='',def2='',ph1='Param 1',ph2='Param 2'; const p=preset?.id;
if(host){
const sv=tryJSON(host.services,[]); const s1=(sv[0]?.service)||'ssh'; const po=(sv[0]?.port)||parseInt((host.ports||'').split(/[,; ]+/)[0]||'22',10);
const cve=(host.vulns||'').split(/[,; ]+/).filter(Boolean)[0]||'CVE-2023-0001';
if(p==='on_service'||p==='service_is_open'){def1=s1; ph1='service';}
if(p==='on_new_port'||p==='on_port_change'||p==='has_port'){def1=po||22; ph1='port';}
if(p==='on_has_cve'||p==='has_cve'){def1=cve; ph1='CVE-YYYY-NNNN';}
if(p==='mac_is'||p==='on_mac_is'){def1=host.mac_address||''; ph1='AA:BB:…';}
if(p==='on_ip_is'){def1=(host.ips||'').split(/[,; ]+/)[0]||''; ph1='192.168.x.x';}
if(p==='on_essid_is'||p==='essid_is'){def1=host.essid||''; ph1='ESSID';}
}
if(p==='req_action'){ def1= fromNode.type==='action' ? (fromNode.data?.b_class||'') : ''; def2='success'; ph1='ActionName'; ph2='status'; }
if(p==='on_all'||p==='on_any'){ def1='["ActionA","ActionB"]'; ph1='JSON array'; }
return {def1,def2,ph1,ph2};
}
function normalizeAnyAllParam(raw){
const t=(raw||'').trim(); if(!t) return '[]';
try{ const a=JSON.parse(t); if(Array.isArray(a)) return JSON.stringify(a.map(String)); }catch{}
return JSON.stringify(t.split(',').map(s=>s.trim()).filter(Boolean));
}
function updateLWPreview(){
const mode=$('#lwMode').value, pid=$('#lwPreset').value, p1=($('#lwParam1').value||'').trim(), p2=($('#lwParam2').value||'').trim();
let txt='—';
if(mode==='trigger'){
if(pid==='on_success'||pid==='on_failure'){ txt=`${pid}:${$('#lwFromName').textContent}`; }
else if(pid==='on_all'||pid==='on_any'){ txt=`${pid}:${normalizeAnyAllParam(p1)}`; }
else if(pid==='on_web_service'){ txt='on_web_service'; }
else{ const val=(pid==='on_new_port'||pid==='on_port_change') && p1 ? String(parseInt(p1,10)) : p1; txt=`${pid}${val?':'+val:''}`; }
}else{
if(pid==='req_action'){ txt=`requires:{action:"${p1||'Action'}",status:"${p2||'success'}"}`; }
else{ const v=(pid==='has_port'&&p1)?Number(p1):(p1||'?'); const value=typeof v==='number'&&!isNaN(v)?String(v):`"${v}"`; txt=`requires:{"${pid}":${value}}`; }
}
$('#lwPreview').textContent=txt;
}
function openLinkWizard(from,to,editing){
linkWizard.from=from; linkWizard.to=to; linkWizard.editing=editing||null;
const nf=state.nodes.get(from), nt=state.nodes.get(to);
$('#lwFromName').textContent=prettyNodeName(nf); $('#lwToName').textContent=prettyNodeName(nt);
const modeSel=$('#lwMode'); modeSel.value=(editing?.mode)||'trigger';
const presetSel=$('#lwPreset'); presetSel.innerHTML='';
linkWizard.presets=computePresets(nf,nt,modeSel.value);
linkWizard.presets.forEach((p,i)=>{ const o=document.createElement('option'); o.value=p.id; o.textContent=p.label; if(i===0) o.selected=true; presetSel.appendChild(o); });
const {def1,def2,ph1,ph2}=guessParams(nf,nt,linkWizard.presets[0]);
$('#lwParam1').value=editing?.label||def1||''; $('#lwParam2').value=def2||''; $('#lwParam1').placeholder=ph1||''; $('#lwParam2').placeholder=ph2||''; updateLWPreview();
$('#linkWizard').classList.add('show');
}
$('#lwClose').addEventListener('click',()=>$('#linkWizard').classList.remove('show'));
$('#lwCancel').addEventListener('click',()=>$('#linkWizard').classList.remove('show'));
$('#lwMode').addEventListener('change',()=>{
const nf=state.nodes.get(linkWizard.from), nt=state.nodes.get(linkWizard.to);
const presetSel=$('#lwPreset'); presetSel.innerHTML=''; linkWizard.presets=computePresets(nf,nt,$('#lwMode').value);
linkWizard.presets.forEach((p,i)=>{ const o=document.createElement('option'); o.value=p.id; o.textContent=p.label; if(i===0) o.selected=true; presetSel.appendChild(o); });
const g=guessParams(nf,nt,linkWizard.presets[0]); $('#lwParam1').value=g.def1||''; $('#lwParam2').value=g.def2||''; $('#lwParam1').placeholder=g.ph1||''; $('#lwParam2').placeholder=g.ph2||''; updateLWPreview();
});
$('#lwPreset').addEventListener('change',()=>{
const nf=state.nodes.get(linkWizard.from), nt=state.nodes.get(linkWizard.to);
const sel=linkWizard.presets.find(p=>p.id===$('#lwPreset').value); const g=guessParams(nf,nt,sel);
$('#lwParam1').value=g.def1||''; $('#lwParam2').value=g.def2||''; $('#lwParam1').placeholder=g.ph1||''; $('#lwParam2').placeholder=g.ph2||''; updateLWPreview();
});
$('#lwParam1').addEventListener('input',updateLWPreview);
$('#lwParam2').addEventListener('input',updateLWPreview);
$('#lwCreate').addEventListener('click',()=>{
const from=linkWizard.from, to=linkWizard.to; if(!from||!to) return;
const nf=state.nodes.get(from), nt=state.nodes.get(to);
const mode=$('#lwMode').value, pid=$('#lwPreset').value, p1=$('#lwParam1').value.trim(), p2=$('#lwParam2').value.trim();
if(linkWizard.editing){ state.links = state.links.filter(l=>l.id!==linkWizard.editing.id); }
createContextualLink(nf,nt,mode,pid,p1,p2,from,to);
$('#linkWizard').classList.remove('show');
LinkEngine.render();
repelLayout();
});
function ensureLink(obj){ const ex=state.links.find(l=>l.from===obj.from&&l.to===obj.to&&l.type===obj.type&&(l.mode||'')===(obj.mode||'')&&(l.label||'')===(obj.label||'')); if(!ex){ obj.id=uid('edge'); state.links.push(obj); } return obj; }
function createContextualLink(fromNode,toNode,mode,presetId,p1,p2,fromId,toId){
const push=(type,label,mode)=>ensureLink({from:fromId,to:toId,type,mode:mode||null,label:label||null});
const refreshCard=(id,a)=>{ const el=$(`[data-id="${id}"]`); if(!el) return; const t=el.querySelector('.v.trigger'); const r=el.querySelector('.v.requires'); if(t) t.textContent=summTrig(a.b_trigger||''); if(r) r.textContent=requireSummary(a); };
if(mode==='trigger' && toNode.type==='action'){
let trig='';
if(presetId==='on_success'||presetId==='on_failure'){
const fromName=(fromNode.type==='action')?(fromNode.data?.b_class||'Action'):''; trig=`${presetId}:${fromName}`; push(presetId==='on_failure'?'failure':'success',presetId,'trigger');
}else if(presetId==='on_web_service'){ trig='on_web_service'; push('requires','on_web_service','trigger'); }
else if(presetId==='on_all'||presetId==='on_any'){ trig=`${presetId}:${normalizeAnyAllParam(p1)}`; push('requires',trig,'trigger'); }
else{ const v=(presetId==='on_new_port'||presetId==='on_port_change')&&p1?String(parseInt(p1,10)):p1; trig=`${presetId}${v?':'+v:''}`; push('requires',trig,'trigger'); }
toNode.data.b_trigger=trig; refreshCard(toId,toNode.data);
}else if(toNode.type==='action'){ // requires
let obj=null;
if(presetId==='req_action'){ obj={action:p1|| (fromNode.type==='action'?fromNode.data?.b_class||'Action':'Action'), status:p2||'success'}; }
else if(presetId==='has_port'){ obj={has_port:Number(p1)}; }
else if(presetId==='service_is_open'){ obj={service_is_open:p1||'ssh'}; }
else if(presetId==='has_cve'){ obj={has_cve:p1||'CVE-2023-0001'}; }
else if(presetId==='has_cpe'){ obj={has_cpe:p1||'cpe:/a:vendor:product:version'}; }
else if(presetId==='has_cred'){ obj={has_cred:p1||'ssh'}; }
else if(presetId==='mac_is'){ obj={mac_is:p1||''}; }
else if(presetId==='essid_is'){ obj={essid_is:p1||''}; }
if(obj){
toNode.data.b_requires = addRequirementClause(toNode.data.b_requires, obj);
refreshCard(toId,toNode.data);
push('requires',presetId,'requires');
}
}
}
function syncActionForEdge(edge){
const A=state.nodes.get(edge.from), B=state.nodes.get(edge.to);
if(!A||!B) return;
if(edge.mode==='trigger' && edge.type!=='requires' && A.type==='action' && B.type==='action'){
B.data.b_trigger = `on_${edge.type}:${A.data.b_class}`;
const el=$(`[data-id="${edge.to}"] .v.trigger`); if(el) el.textContent=summTrig(B.data.b_trigger);
}
}
function addRequirementClause(current, clause){
const r=tryJSON(current,null);
if(!r) return JSON.stringify(clause);
if(r.all){ r.all.push(clause); return JSON.stringify(r); }
if(r.any){ r.any.push(clause); return JSON.stringify(r); }
return JSON.stringify({ all:[ r, clause ] });
}
/* ===================== Host evaluation ===================== */
function parseHostServices(host){
let arr=tryJSON(host.services,[]); if(!Array.isArray(arr)) arr=[];
const names=new Set(arr.map(s=>String(s.service||s.name||'').toLowerCase()).filter(Boolean));
const ports=new Set(arr.map(s=>Number(s.port)).filter(p=>Number.isFinite(p)));
(host.ports||'').split(/[,; ]+/).map(x=>parseInt(x,10)).filter(n=>!isNaN(n)).forEach(p=>ports.add(p));
return {names,ports};
}
function splitTriggerList(raw){
if(!raw) return {mode:'any',list:[]};
if(Array.isArray(raw)) return {mode:'any',list:raw.map(String)};
const t=String(raw).trim();
if(t.startsWith('[')){ try{const a=JSON.parse(t); if(Array.isArray(a)) return {mode:'any',list:a.map(String)};}catch{} }
const {name,param}=splitTriggerSafe(t);
if(name==='on_all'||name==='on_any'){ try{const a=JSON.parse(param); return {mode:name==='on_all'?'all':'any',list:Array.isArray(a)?a.map(String):[]};}catch{} }
return {mode:'any',list:[t]};
}
function hostMatchesSingleTriggerString(trigStr, host){
const {name,param}=splitTriggerSafe(trigStr);
const {names,ports}=parseHostServices(host);
const vulns=(host.vulns||'').split(/[,; ]+/).map(s=>s.trim()).filter(Boolean);
if(name==='on_service') return names.has(String(param).toLowerCase());
if(name==='on_web_service') return names.has('http')||names.has('https');
if(name==='on_new_port'||name==='on_port_change') return ports.has(parseInt(param,10));
if(name==='on_has_cve') return vulns.includes(String(param));
if(name==='on_mac_is') return (host.mac_address||'').toLowerCase()===String(param).toLowerCase();
if(name==='on_ip_is') return (host.ips||'').split(/[,; ]+/).includes(String(param));
if(name==='on_essid_is') return (host.essid||'')===String(param);
if(name==='on_host_alive') return parseInt(host.alive)==1;
if(name==='on_host_dead') return parseInt(host.alive)==0;
if(name==='on_new_host') return true;
return false;
}
function hostMatchesActionByTriggers(action, host){
const {mode,list}=splitTriggerList(action.b_trigger||'');
if(list.length===0) return false;
const res=list.map(t=>hostMatchesSingleTriggerString(t,host));
return mode==='all'? res.every(Boolean): res.some(Boolean);
}
function checkHostRequires(reqRaw,host){
const req=tryJSON(reqRaw,null); if(!req) return true;
const {names,ports}=parseHostServices(host);
const vulns=(host.vulns||'').split(/[,; ]+/).map(s=>s.trim()).filter(Boolean);
const check=r=>{
if(r.has_port!=null) return ports.has(parseInt(r.has_port,10));
if(r.service_is_open) return names.has(String(r.service_is_open).toLowerCase());
if(r.has_cve) return vulns.includes(String(r.has_cve));
if(r.has_cpe) return (host.cpe||'').includes(String(r.has_cpe));
if(r.has_cred) return (host.creds||'[]').includes(String(r.has_cred));
if(r.mac_is) return (host.mac_address||'').toLowerCase()===String(r.mac_is).toLowerCase();
if(r.essid_is) return (host.essid||'')===String(r.essid_is);
if(r.action) return false;
return false;
};
if(req.all) return req.all.every(check);
if(req.any) return req.any.some(check);
return check(req);
}
function evaluateHostToAction(link){
const A=state.nodes.get(link.from), B=state.nodes.get(link.to);
if(!A||!B||A.type!=='host'||B.type!=='action') return {ok:false,label:link.label||'requires'};
const h=A.data, a=B.data;
let ok=false;
if(link.mode==='trigger') ok = hostMatchesActionByTriggers(a,h);
else if(link.mode==='requires') ok = checkHostRequires(a.b_requires,h);
else ok = hostMatchesActionByTriggers(a,h) || checkHostRequires(a.b_requires,h);
return {ok,label:link.label|| (link.mode==='trigger'?'trigger':'requires')};
}
// remplace complètement la fonction existante
function repelLayout(iter = 16, str = 0.6) {
const HOST_X = 80; // X fixe pour la colonne des hosts (même valeur que lautolayout)
const TOP_Y = 60; // Y de départ de la colonne
const V_GAP = 160; // espacement vertical entre hosts
const ids = [...state.nodes.keys()];
const boxes = ids.map(id => {
const n = state.nodes.get(id);
const el = document.querySelector(`[data-id="${id}"]`);
if (!n || !el) return null;
const w = el.offsetWidth, h = el.offsetHeight;
return {
id, type: n.type,
x: n.x, y: n.y, w, h,
cx: n.x + w / 2, cy: n.y + h / 2
};
}).filter(Boolean);
if (boxes.length < 2) { LinkEngine.render(); return; }
// répulsion douce en évitant de bouger les hosts en X
for (let it = 0; it < iter; it++) {
for (let i = 0; i < boxes.length; i++) {
for (let j = i + 1; j < boxes.length; j++) {
const a = boxes[i], b = boxes[j];
const dx = b.cx - a.cx, dy = b.cy - a.cy;
const ox = (a.w/2 + b.w/2 + state.minGapH) - Math.abs(dx);
const oy = (a.h/2 + b.h/2 + state.minGapV) - Math.abs(dy);
if (ox > 0 && oy > 0) {
const pushX = (ox/2) * str * Math.sign(dx || (Math.random() - .5));
const pushY = (oy/2) * str * Math.sign(dy || (Math.random() - .5));
// Sur laxe X, on NE BOUGE PAS les hosts
const aCanX = a.type !== 'host';
const bCanX = b.type !== 'host';
if (ox > oy) { // pousser surtout en X
if (aCanX && bCanX) { a.x -= pushX; a.cx -= pushX; b.x += pushX; b.cx += pushX; }
else if (aCanX) { a.x -= 2*pushX; a.cx -= 2*pushX; }
else if (bCanX) { b.x += 2*pushX; b.cx += 2*pushX; }
// sinon (deux hosts) : on ne touche pas laxe X
} else { // pousser surtout en Y (hosts OK en Y)
a.y -= pushY; a.cy -= pushY;
b.y += pushY; b.cy += pushY;
}
}
}
}
}
// Snap final : hosts parfaitement en colonne et espacés régulièrement
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
boxes.forEach(b => {
const n = state.nodes.get(b.id);
const el = document.querySelector(`[data-id="${b.id}"]`);
n.x = b.x; n.y = b.y;
el.style.left = n.x + 'px';
el.style.top = n.y + 'px';
});
LinkEngine.render();
}
/* ===== Auto-layout: hosts en colonne verticale (X constant), actions à droite ===== */
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
state.nodes.forEach((n,id)=>{ if(n.type==='host') set(id,0); });
// Colonnes suivantes = actions (en fonction des dépendances action->action)
const edges=[];
state.links.forEach(l=>{
const A=state.nodes.get(l.from), B=state.nodes.get(l.to);
if(A&&B && A.type==='action' && B.type==='action') edges.push([l.from,l.to]);
});
const g=new Map(); edges.forEach(([u,v])=>{ if(!g.has(u)) g.set(u,[]); g.get(u).push(v); });
const memo=new Map();
const depth=(id)=>{ if(memo.has(id)) return memo.get(id); const nxt=(g.get(id)||[]); let d=0; for(const v of nxt) d=Math.max(d,1+depth(v)); memo.set(id,d); return d; };
state.nodes.forEach((n,id)=>{ if(n.type==='action'){ set(id, 1 + depth(id)); } });
const cols=new Map(); col.forEach((c,id)=>{ if(!cols.has(c)) cols.set(c,[]); cols.get(c).push(id); });
const xPad=80, colW=320, gapH=Math.max(200,state.minGapH);
cols.forEach((ids,c)=>{
const prev=cols.get(c-1)||[];
const avgY=id=>{
const up=prev.filter(p=> state.links.find(l=>l.from===p && l.to===id));
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
ids.sort((a,b)=>{
if(c===0){
const na=state.nodes.get(a), nb=state.nodes.get(b);
if(na?.type==='host' && nb?.type==='host') return byHostnameIpMac(na.data, nb.data);
}
return avgY(a)-avgY(b);
});
ids.forEach((id,i)=>{
const n=state.nodes.get(id), el=$(`[data-id="${id}"]`);
const vGap = c===0 ? 160 : Math.max(140,state.minGapV);
n.x = xPad + c*(colW+gapH*0.25);
n.y = 60 + i*vGap;
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
toast('Auto-layout appliqué','success');
}
/* ===================== Inspectors ===================== */
function showActionInspector(a){
$('#actionInspector').style.display='block'; $('#hostInspector').style.display='none';
$('#edit').style.display='block'; $('#noSel').style.display='none';
$('#e_class').value=a.b_class||''; $('#e_module').value=a.b_module||''; $('#e_status').value=a.b_status||a.b_class||'';
$('#e_type').value=a.b_action||'normal'; $('#e_enabled').value=String(a.b_enabled??1);
$('#e_prio').value=a.b_priority??50; $('#e_timeout').value=a.b_timeout??300; $('#e_retry').value=a.b_max_retries??3;
$('#e_cool').value=a.b_cooldown??0; $('#e_rate').value=a.b_rate_limit||''; $('#e_port').value=a.b_port??'';
$('#e_services').value=toCSV(tryJSON(a.b_service,[])); $('#e_tags').value=a.b_tags||'[]';
const {name,param}=splitTriggerSafe(a.b_trigger||''); $('#t_type').value=name||'on_host_alive'; $('#t_param').value=param||'';
buildReqUI(a);
}
function buildReqUI(a){
const root=$('#r_list'); root.innerHTML='';
const req=tryJSON(a.b_requires,null); let mode='all',items=[];
if(req){ if(req.all){mode='all'; items=req.all.slice();} else if(req.any){mode='any'; items=req.any.slice();} else {mode='all'; items=[req];} }
$('#r_mode').value=mode;
items.forEach(it=>root.appendChild(reqRow(it)));
$('#r_add').onclick=()=>{ root.appendChild(reqRow({action:'SomeAction',status:'success'})); sync(); };
$('#r_mode').onchange=sync;
function reqRow(it){
const row=document.createElement('div'); row.className='row'; row.style.cssText='align-items:flex-end;margin:4px 0';
row.innerHTML=`<label style="flex:1"><span>Type</span>
<select class="rt">
<option value="action">action:status</option>
<option value="has_port">has_port</option>
<option value="has_cred">has_cred</option>
<option value="has_cve">has_cve</option>
<option value="has_cpe">has_cpe</option>
<option value="mac_is">mac_is</option>
<option value="essid_is">essid_is</option>
<option value="service_is_open">service_is_open</option>
</select></label>
<label style="flex:1"><span>Param 1</span><input class="rp1"></label>
<label style="flex:1"><span>Param 2</span><input class="rp2" placeholder="status si action"></label>
<button class="btn" title="Delete">🗑</button>`;
const rt=row.querySelector('.rt'), rp1=row.querySelector('.rp1'), rp2=row.querySelector('.rp2'), del=row.querySelector('button');
if(it.action){ rt.value='action'; rp1.value=it.action; rp2.value=it.status||'success'; } else { const k=Object.keys(it)[0]; if(k){ rt.value=k; rp1.value=it[k]; } }
rt.onchange=()=>{ rp1.value=''; rp2.value=''; sync(); }; rp1.oninput=rp2.oninput=sync; del.onclick=()=>{ row.remove(); sync(); };
return row;
}
function sync(){
const md=$('#r_mode').value; const rows=[...root.children].map(r=>{
const t=r.querySelector('.rt').value, p1=r.querySelector('.rp1').value.trim(), p2=r.querySelector('.rp2').value.trim();
if(t==='action') return {action:p1, status:p2||'success'};
if(p1) return {[t]:t==='has_port'?Number(p1):p1};
return null;
}).filter(Boolean);
let obj=null; if(rows.length===1) obj=rows[0]; else if(rows.length>1) obj={[md]:rows};
a.b_requires = obj?JSON.stringify(obj):'';
const sel=state.selected&&state.nodes.get(state.selected); if(sel&&sel.type==='action'){ const el=$(`[data-id="${state.selected}"] .v.requires`); if(el) el.textContent=requireSummary(a); }
LinkEngine.render();
}
}
function showHostInspector(h){
$('#actionInspector').style.display='none'; $('#hostInspector').style.display='block';
$('#h_mac').value=h.mac_address||''; $('#h_hostname').value=h.hostname||''; $('#h_ips').value=h.ips||''; $('#h_ports').value=h.ports||''; $('#h_alive').value=h.alive?'1':'0';
$('#h_essid').value=h.essid||''; $('#h_services').value=h.services||'[]';
$('#h_vulns').value=h.vulns||''; $('#h_creds').value=h.creds||'[]';
}
/* ===================== Pan / Zoom ===================== */
function applyPanZoom(){ $('#canvas').style.transform=`translate(${state.pan.x}px,${state.pan.y}px) scale(${state.pan.scale})`; }
$('#center').addEventListener('wheel',e=>{
e.preventDefault();
const delta=e.deltaY>0?0.9:1.1; const rect=$('#center').getBoundingClientRect();
const x=e.clientX-rect.left, y=e.clientY-rect.top;
const before={x:(x-state.pan.x)/state.pan.scale, y:(y-state.pan.y)/state.pan.scale};
state.pan.scale=clamp(state.pan.scale*delta,0.25,2.5);
state.pan.x=x-before.x*state.pan.scale; state.pan.y=y-before.y*state.pan.scale; applyPanZoom();
},{passive:false});
$('#zIn').addEventListener('click',()=>{ state.pan.scale=clamp(state.pan.scale*1.2,0.25,2.5); applyPanZoom(); });
$('#zOut').addEventListener('click',()=>{ state.pan.scale=clamp(state.pan.scale/1.2,0.25,2.5); applyPanZoom(); });
$('#zFit').addEventListener('click',()=>fitToScreen());
function fitToScreen(){
if(state.nodes.size===0) return;
let minX=Infinity,minY=Infinity,maxX=-Infinity,maxY=-Infinity;
state.nodes.forEach((n,id)=>{ const el=$(`[data-id="${id}"]`); if(!el) return; minX=Math.min(minX,n.x); minY=Math.min(minY,n.y); maxX=Math.max(maxX,n.x+el.offsetWidth); maxY=Math.max(maxY,n.y+el.offsetHeight); });
const rect=$('#center').getBoundingClientRect(), pad=50;
const scaleX=(rect.width - pad*2)/Math.max(1,(maxX-minX));
const scaleY=(rect.height- pad*2)/Math.max(1,(maxY-minY));
const s=Math.min(scaleX,scaleY,1);
state.pan={x:rect.width/2 - ((minX+maxX)/2)*s, y:rect.height/2 - ((minY+maxY)/2)*s, scale:s};
applyPanZoom();
}
/* ===== background drag = pan (mouse & touch) ===== */
let panning=null;
$('#center').addEventListener('mousedown',e=>{
const isInteractive = e.target.closest('.node') || e.target.closest('.rail') || e.target.closest('.edge-menu') || e.target.closest('.modal');
if(e.button===1 || (e.button===0 && !isInteractive)){
panning={x:e.clientX,y:e.clientY,px:state.pan.x,py:state.pan.y};
$('#center').style.cursor='grabbing';
}
});
document.addEventListener('mousemove',e=>{
if(!panning) return;
state.pan.x = panning.px + (e.clientX - panning.x);
state.pan.y = panning.py + (e.clientY - panning.y);
applyPanZoom();
});
document.addEventListener('mouseup',()=>{ if(panning){ panning=null; $('#center').style.cursor='grab'; } });
let touchPan=null;
$('#center').addEventListener('touchstart',e=>{
if(e.touches.length===1){
const t=e.touches[0];
touchPan={x:t.clientX,y:t.clientY,px:state.pan.x,py:state.pan.y};
}
},{passive:false});
$('#center').addEventListener('touchmove',e=>{
if(!touchPan || e.touches.length!==1) return;
const t=e.touches[0];
state.pan.x = touchPan.px + (t.clientX - touchPan.x);
state.pan.y = touchPan.py + (t.clientY - touchPan.y);
applyPanZoom();
e.preventDefault();
},{passive:false});
$('#center').addEventListener('touchend',()=>{ touchPan=null; });
/* ===================== Toolbar & Misc ===================== */
$('#btnPal').addEventListener('click',()=>$('#left').classList.toggle('open'));
$('#btnIns').addEventListener('click',()=>$('#right').classList.toggle('open'));
$$('.tab').forEach(t=>t.addEventListener('click',()=>{ const target=t.dataset.tab; $$('.tab').forEach(x=>x.classList.remove('active')); $$('.tab-content').forEach(c=>c.classList.remove('active')); t.classList.add('active'); $(`#tab-${target}`).classList.add('active'); }));
$('#btnAutoLayout').addEventListener('click',autoLayout);
$('#btnRepel').addEventListener('click',()=>repelLayout());
$('#btnApply').addEventListener('click',async()=>{ await saveToStudio(); await applyToRuntime(); });
$('#btnMenu').addEventListener('click',()=>$('#mainMenu').style.display=$('#mainMenu').style.display==='block'?'none':'block');
$('#mSave').addEventListener('click',async()=>{ $('#mainMenu').style.display='none'; await saveToStudio(); });
$('#mImportdbActions').addEventListener('click',()=>{ $('#mainMenu').style.display='none'; toast('Import Actions DB — TODO','warn'); });
$('#mImportdbActionsStudio').addEventListener('click',()=>{ $('#mainMenu').style.display='none'; toast('Import Studio DB — TODO','warn'); });
/* close kebab on outside click */
window.addEventListener('click',e=>{
if(!e.target.closest('#btnMenu') && !e.target.closest('#mainMenu')){
$('#mainMenu').style.display='none';
}
});
$('#filterActions').addEventListener('input',buildPalette);
$('#filterHosts').addEventListener('input',buildHostPalette);
$('#canvas').addEventListener('dragover',e=>{ e.preventDefault(); e.dataTransfer.dropEffect='copy'; });
$('#canvas').addEventListener('drop',e=>{
e.preventDefault();
const data=e.dataTransfer.getData('action'); if(!data) return;
const a=JSON.parse(data); const rect=$('#center').getBoundingClientRect();
const x=(e.clientX-rect.left-state.pan.x)/state.pan.scale, y=(e.clientY-rect.top-state.pan.y)/state.pan.scale;
addActionNode(a,x,y);
});
$('#btnUpdateAction').addEventListener('click',()=>{
const n=state.nodes.get(state.selected); if(!n||n.type!=='action') return;
const a=n.data;
a.b_module=$('#e_module').value; a.b_status=$('#e_status').value; a.b_action=$('#e_type').value; a.b_enabled=parseInt($('#e_enabled').value);
a.b_priority=parseInt($('#e_prio').value); a.b_timeout=parseInt($('#e_timeout').value); a.b_max_retries=parseInt($('#e_retry').value);
a.b_cooldown=parseInt($('#e_cool').value); a.b_rate_limit=$('#e_rate').value; a.b_port=parseInt($('#e_port').value)||null;
a.b_service=JSON.stringify(fromCSV($('#e_services').value)); a.b_tags=$('#e_tags').value;
const tt=$('#t_type').value, tp=$('#t_param').value.trim(); a.b_trigger=tp?`${tt}:${tp}`:tt;
const el=$(`[data-id="${state.selected}"]`); if(el){ el.className=`node ${a.b_action==='global'?'global':''}`; el.querySelector('.badge').textContent=a.b_action||'normal'; el.querySelector('.v.trigger').textContent=summTrig(a.b_trigger||''); el.querySelector('.v.requires').textContent=requireSummary(a); }
LinkEngine.render(); toast('Action mise à jour','success');
});
$('#btnDeleteNode').addEventListener('click',()=>{ if(state.selected) deleteNode(state.selected); });
$('#btnUpdateHost').addEventListener('click',()=>{
const n=state.nodes.get(state.selected); if(!n||n.type!=='host') return; const h=n.data;
h.hostname=$('#h_hostname').value.trim(); h.ips=$('#h_ips').value.trim(); h.ports=$('#h_ports').value.trim(); h.alive=parseInt($('#h_alive').value);
h.essid=$('#h_essid').value.trim(); h.services=$('#h_services').value.trim(); h.vulns=$('#h_vulns').value.trim(); h.creds=$('#h_creds').value.trim();
const el=$(`[data-id="${state.selected}"]`); if(el){ el.querySelector('.nname').textContent=h.hostname||h.ips||h.mac_address; const rows=el.querySelectorAll('.nbody .row .v'); if(rows[0]) rows[0].textContent=h.ips||'—'; if(rows[1]) rows[1].textContent=h.ports||'—'; if(rows[2]) rows[2].textContent=h.alive?'🟢':'🔴'; }
LinkEngine.render(); toast('Host mis à jour','success');
});
$('#btnDeleteHost').addEventListener('click',()=>{ if(state.selected) deleteNode(state.selected); });
/* Palette hosts helpers (global) */
window.addHostToCanvas=function(mac){
const h=state.hosts.get(mac); if(!h) return;
let existing=null; state.nodes.forEach((n,id)=>{ if(n.type==='host'&&n.data.mac_address===mac) existing=id; });
if(existing){ const el=$(`[data-id="${existing}"]`); el.classList.add('sel'); selectNode(existing); }
else{ const rect=$('#center').getBoundingClientRect(); const x=80; const y=(rect.height/2 - state.pan.y)/state.pan.scale - 60; addHostNode(h,x,y); LinkEngine.render(); }
};
window.deleteTestHost=function(mac){
if(!confirm('Delete this test host?')) return;
state.hosts.delete(mac); const ids=[]; state.nodes.forEach((n,id)=>{ if(n.type==='host'&&n.data.mac_address===mac) ids.push(id); }); ids.forEach(id=>deleteNode(id)); buildHostPalette(); toast('Test host supprimé','success');
};
window.openHostModal=function(){ $('#hostModal').classList.add('show'); };
window.closeHostModal=function(){ $('#hostModal').classList.remove('show'); };
window.createTestHost=function(){
const mac=$('#new_mac').value.trim() || `AA:BB:CC:${Math.random().toString(16).slice(2,8).toUpperCase()}`;
if(state.hosts.has(mac)){ toast('MAC existe déjà','error'); return; }
const host={ mac_address:mac, hostname:$('#new_hostname').value.trim()||'test-host', ips:$('#new_ips').value.trim()||'', ports:$('#new_ports').value.trim()||'', services:$('#new_services').value.trim()||'[]', vulns:$('#new_vulns').value.trim()||'', creds:$('#new_creds').value.trim()||'[]', alive:parseInt($('#new_alive').value)||1, is_simulated:1 };
state.hosts.set(mac,host); buildHostPalette(); closeHostModal(); toast('Test host créé','success'); addHostToCanvas(mac);
};
$('#btnCreateHost').addEventListener('click',openHostModal);
$('#mAddHost').addEventListener('click',openHostModal);
/* ===== keyboard helpers ===== */
document.addEventListener('keydown',e=>{
if(e.key==='Escape'){
$('#edgeMenu').classList.remove('show');
$('#linkWizard').classList.remove('show');
$('#hostModal').classList.remove('show');
}
const tag=(document.activeElement?.tagName||'').toLowerCase();
if((e.key==='Delete' || e.key==='Backspace') && !['input','textarea','select'].includes(tag)){
if(state.selected){ e.preventDefault(); deleteNode(state.selected); }
}
});
/* ===================== Auto-import & Auto-link ===================== */
function ensureActionNodeByClass(bClass){
let existing=null; state.nodes.forEach((n,id)=>{ if(n.type==='action'&&n.data?.b_class===bClass) existing=id; });
if(existing) return existing; const a=state.actions.get(bClass); if(!a) return null;
const rect=$('#center').getBoundingClientRect();
const x=(rect.width/2 - state.pan.x)/state.pan.scale - 110, y=(rect.height/2 - state.pan.y)/state.pan.scale - 60;
return addActionNode(a,x,y);
}
function findHostNodeIdByMac(mac){
let id=null; state.nodes.forEach((n,nd)=>{ if(n.type==='host' && n.data.mac_address===mac) id=nd; }); return id;
}
/* hosts vertical placement (initial) */
function placeAllAliveHosts(){
const alive = [...state.hosts.values()].filter(h=>parseInt(h.alive)==1).sort(byHostnameIpMac);
let i=0; const x=80, startY=80, dy=170;
for(const h of alive){
if(state.placedHosts.has(h.mac_address)){ i++; continue; }
const y=startY + i*dy;
addHostNode(h,x,y); i++;
}
}
function isHostRuleInRequires(req){
const r=tryJSON(req,null); if(!r) return false;
const hasHostKey = obj => ['has_port','service_is_open','has_cve','has_cpe','has_cred','mac_is','essid_is'].some(k=>k in obj);
if(r.all) return r.all.some(hasHostKey);
if(r.any) return r.any.some(hasHostKey);
return hasHostKey(r);
}
function importActionsForHostsAndDeps(){
const aliveHosts=[...state.hosts.values()].filter(h=>parseInt(h.alive)==1);
// 1) actions liées aux hôtes (triggers/requires) => placer + lier
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;
const actionId = ensureActionNodeByClass(a.b_class); if(!actionId) continue;
for(const h of matches){
const hostId = findHostNodeIdByMac(h.mac_address); if(!hostId) continue;
if(hostMatchesActionByTriggers(a,h)) ensureLink({from:hostId,to:actionId,type:'requires',mode:'trigger',label:'trigger'});
if(isHostRuleInRequires(a.b_requires) && checkHostRequires(a.b_requires,h)) ensureLink({from:hostId,to:actionId,type:'requires',mode:'requires',label:'requires'});
}
}
// 2) dépendances entre actions (on_success/on_failure + requires action)
state.nodes.forEach((nA,idA)=>{
if(nA.type!=='action') return;
const a=nA.data;
const {name,param}=splitTriggerSafe(a.b_trigger||'');
if(name==='on_success' || name==='on_failure'){
const srcId = ensureActionNodeByClass(param);
if(srcId) ensureLink({from:srcId,to:idA,type:(name==='on_success'?'success':'failure'),mode:'trigger'});
}
const req=tryJSON(a.b_requires,null);
const addReq=r=>{ if(r&&r.action){ const srcId=ensureActionNodeByClass(r.action); if(srcId) ensureLink({from:srcId,to:idA,type:'requires',mode:'requires',label:'req_action'}); } };
if(req){ if(req.all) req.all.forEach(addReq); else if(req.any) req.any.forEach(addReq); else addReq(req); }
});
}
/* ===================== Boot ===================== */
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)
placeAllAliveHosts();
buildPalette(); buildHostPalette();
// 2) Auto-import des actions dont trigger/require matchent les hôtes + liens
importActionsForHostsAndDeps();
// 3) Layout + rendu
autoLayout();
LinkEngine.render();
updateStats();
toast('Studio chargé','success');
}
init();
</script>
</body>
</html>