mirror of
https://github.com/infinition/Bjorn.git
synced 2025-12-12 15:44:58 +00:00
1372 lines
77 KiB
HTML
1372 lines
77 KiB
HTML
<!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 s’adaptent.</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 l’autolayout)
|
||
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 l’axe 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 l’axe 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>
|