mirror of
https://github.com/infinition/Bjorn.git
synced 2025-12-13 16:14:57 +00:00
981 lines
31 KiB
HTML
981 lines
31 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<title>Bjorn Cyberviking - Network Visualization</title>
|
|
<link rel="icon" href="/web/images/favicon.ico" type="image/x-icon" />
|
|
<link rel="stylesheet" href="/web/css/global.css" />
|
|
<script src="web/js/d3.v7.min.js"></script>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
|
|
<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="#333" />
|
|
<script src="web/js/global.js" defer></script>
|
|
|
|
<style>
|
|
/* Page base on global tokens */
|
|
body {
|
|
background: var(--grad-bg-1), var(--grad-bg-2), var(--bg);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.main {
|
|
padding: 16px;
|
|
position: relative;
|
|
z-index: 2;
|
|
height: 100vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
/* ======================= Toolbar (sticky, uniformisée) ======================= */
|
|
.nv-toolbar-wrap {
|
|
position: sticky;
|
|
top: 1px;
|
|
margin: 10px;
|
|
z-index: 500;
|
|
backdrop-filter: saturate(1.1) blur(6px);
|
|
}
|
|
|
|
.nv-toolbar {
|
|
display: flex;
|
|
gap: 12px;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
background: color-mix(in oklab, var(--panel) 88%, transparent);
|
|
border: 1px solid var(--c-border-strong);
|
|
border-radius: 16px;
|
|
padding: 8px 10px;
|
|
box-shadow: var(--shadow);
|
|
}
|
|
|
|
/* Search */
|
|
.nv-search {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
background: var(--panel);
|
|
border: 1px solid var(--c-border-strong);
|
|
border-radius: 12px;
|
|
padding: 6px 10px;
|
|
min-width: 240px;
|
|
box-shadow: var(--shadow);
|
|
}
|
|
|
|
.nv-search svg {
|
|
width: 18px;
|
|
height: 18px;
|
|
fill: var(--ink);
|
|
opacity: .9;
|
|
}
|
|
|
|
.nv-search input {
|
|
border: none;
|
|
outline: none;
|
|
background: transparent;
|
|
color: var(--ink);
|
|
font-weight: 700;
|
|
width: 100%;
|
|
}
|
|
|
|
/* Segmented control (Table / Map) */
|
|
.segmented {
|
|
display: inline-flex;
|
|
background: var(--panel);
|
|
border: 1px solid var(--c-border-strong);
|
|
border-radius: 999px;
|
|
padding: 4px;
|
|
box-shadow: var(--shadow);
|
|
}
|
|
|
|
.segmented button {
|
|
appearance: none;
|
|
border: 0;
|
|
background: transparent;
|
|
color: var(--muted);
|
|
font-weight: 700;
|
|
padding: 8px 14px;
|
|
border-radius: 999px;
|
|
cursor: pointer;
|
|
transition: background .15s ease, color .15s ease, transform .1s ease;
|
|
}
|
|
|
|
.segmented button[aria-pressed="true"] {
|
|
background: var(--grad-card);
|
|
color: var(--ink);
|
|
box-shadow: inset 0 0 0 1px var(--c-border-hi), 0 6px 24px var(--glow-weak);
|
|
transform: translateY(-1px);
|
|
}
|
|
|
|
/* Switch Show hostname */
|
|
.nv-switch {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
font-weight: 700;
|
|
color: var(--muted);
|
|
background: var(--panel);
|
|
border: 1px solid var(--c-border-strong);
|
|
border-radius: 999px;
|
|
padding: 6px 10px;
|
|
box-shadow: var(--shadow);
|
|
}
|
|
|
|
.nv-switch input {
|
|
display: none;
|
|
}
|
|
|
|
.nv-switch .track {
|
|
width: 44px;
|
|
height: 24px;
|
|
border-radius: 999px;
|
|
background: var(--c-panel-2);
|
|
position: relative;
|
|
border: 1px solid var(--c-border);
|
|
}
|
|
|
|
.nv-switch .thumb {
|
|
position: absolute;
|
|
top: 2px;
|
|
left: 2px;
|
|
width: 20px;
|
|
height: 20px;
|
|
border-radius: 50%;
|
|
background: var(--ink);
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, .4);
|
|
transition: left .18s ease, background .18s ease;
|
|
}
|
|
|
|
.nv-switch input:checked~.track .thumb {
|
|
left: 22px;
|
|
background: var(--acid);
|
|
}
|
|
|
|
.nv-switch[data-on="true"] {
|
|
color: var(--ink);
|
|
}
|
|
|
|
/* ======================= Table ======================= */
|
|
.table-wrap {
|
|
border: 1px solid var(--c-border-strong);
|
|
border-radius: 14px;
|
|
overflow: auto;
|
|
background: var(--panel);
|
|
box-shadow: var(--shadow);
|
|
flex: 1;
|
|
}
|
|
|
|
table.network-table {
|
|
width: 100%;
|
|
border-collapse: separate;
|
|
border-spacing: 0 .5rem;
|
|
table-layout: fixed;
|
|
}
|
|
|
|
thead th {
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 3;
|
|
background: var(--c-panel);
|
|
color: var(--ink);
|
|
border-bottom: 1px solid var(--c-border-strong);
|
|
padding: 10px;
|
|
text-align: left;
|
|
white-space: nowrap;
|
|
cursor: pointer;
|
|
border-radius: 8px;
|
|
}
|
|
|
|
tbody tr {
|
|
background: var(--grad-card, linear-gradient(145deg, color-mix(in oklab, var(--panel) 92%, transparent) 0%, color-mix(in oklab, var(--c-panel) 88%, transparent) 100%));
|
|
border: 1px solid var(--c-border-strong);
|
|
border-radius: 8px;
|
|
transition: .25s ease;
|
|
}
|
|
|
|
tbody tr:hover {
|
|
background: var(--grad-card, linear-gradient(145deg, color-mix(in oklab, var(--panel) 88%, transparent) 0%, color-mix(in oklab, var(--c-panel) 82%, transparent) 100%));
|
|
box-shadow: var(--shadow);
|
|
transform: translateY(-2px);
|
|
}
|
|
|
|
td {
|
|
padding: 10px;
|
|
color: #fff;
|
|
background: transparent;
|
|
overflow: hidden;
|
|
white-space: nowrap;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
th.hosts-header {
|
|
left: 0;
|
|
position: sticky;
|
|
z-index: 4;
|
|
}
|
|
|
|
td.hosts-cell {
|
|
position: sticky;
|
|
left: 0;
|
|
z-index: 2;
|
|
background: transparent;
|
|
backdrop-filter: none;
|
|
}
|
|
|
|
thead th.sort-asc::after {
|
|
content: '↑';
|
|
margin-left: 8px;
|
|
color: #00b894;
|
|
}
|
|
|
|
thead th.sort-desc::after {
|
|
content: '↓';
|
|
margin-left: 8px;
|
|
color: #00b894;
|
|
}
|
|
|
|
.hosts-content {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: .6rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
/* Bulles */
|
|
.bubble {
|
|
padding: .5rem 1rem;
|
|
border-radius: 6px;
|
|
font-size: .9rem;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: .5rem;
|
|
transition: .2s;
|
|
box-shadow: 0 2px 4px rgba(0, 0, 0, .1);
|
|
}
|
|
|
|
.bubble.essid {
|
|
background: linear-gradient(135deg, #272727, #2560a1);
|
|
color: #fff;
|
|
padding: 5px 10px;
|
|
border-radius: 5px;
|
|
font-size: .9em;
|
|
font-weight: bold;
|
|
white-space: nowrap;
|
|
display: inline-block;
|
|
}
|
|
|
|
.bubble.ip-address {
|
|
background: linear-gradient(135deg, #272727, #00cec9);
|
|
color: #fff;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.bubble.hostname {
|
|
background: linear-gradient(135deg, #5b5c5a, #e7951a);
|
|
color: #fff;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.bubble.mac-address {
|
|
background: linear-gradient(135deg, #404041, #636e72);
|
|
color: #b2bec3;
|
|
font-family: monospace;
|
|
cursor: pointer;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.bubble.vendor {
|
|
background: linear-gradient(135deg, #5b5c5a, #0a4952);
|
|
color: #fff;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.ports-container {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: .5rem;
|
|
align-items: center;
|
|
}
|
|
|
|
.port-bubble {
|
|
background: linear-gradient(135deg, #2e2e2e, #00b894);
|
|
color: white;
|
|
padding: .4rem .8rem;
|
|
border-radius: 20px;
|
|
font-size: .85rem;
|
|
max-width: fit-content;
|
|
transition: .2s;
|
|
}
|
|
|
|
.port-bubble:hover {
|
|
transform: scale(1.08);
|
|
box-shadow: 0 2px 8px rgba(9, 132, 227, .3);
|
|
}
|
|
|
|
/* Focus */
|
|
.segmented button:focus-visible,
|
|
.nv-search input:focus-visible,
|
|
.nv-switch:has(input:focus-visible) {
|
|
outline: 2px solid var(--acid);
|
|
outline-offset: 2px;
|
|
box-shadow: 0 0 0 3px color-mix(in oklab, var(--acid) 25%, transparent);
|
|
}
|
|
|
|
/* ======================= MAP STYLES (NEW) ======================= */
|
|
|
|
/* Ocean Background */
|
|
.ocean-container {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
overflow: hidden;
|
|
z-index: 0;
|
|
pointer-events: none;
|
|
background: radial-gradient(ellipse at center, #0a4b7a 0%, #01162e 60%, #00050a 100%);
|
|
}
|
|
|
|
.ocean-surface {
|
|
position: absolute;
|
|
top: -50%;
|
|
left: -50%;
|
|
width: 200%;
|
|
height: 200%;
|
|
opacity: 0.3;
|
|
background-image: repeating-radial-gradient(circle at 50% 50%, transparent 0, transparent 20px, rgba(255, 255, 255, 0.02) 25px, transparent 40px);
|
|
animation: oceanDrift 60s linear infinite alternate;
|
|
}
|
|
|
|
.ocean-caustics {
|
|
position: absolute;
|
|
top: -100%;
|
|
left: -100%;
|
|
width: 300%;
|
|
height: 300%;
|
|
opacity: 0.3;
|
|
background: url('data:image/svg+xml;utf8,<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg"><filter id="n"><feTurbulence type="fractalNoise" baseFrequency="0.005" numOctaves="5"/></filter><rect width="100%" height="100%" filter="url(%23n)" opacity="0.3"/></svg>');
|
|
mix-blend-mode: overlay;
|
|
animation: causticFlow 30s linear infinite;
|
|
}
|
|
|
|
@keyframes oceanDrift {
|
|
0% {
|
|
transform: translate(0, 0) rotate(0deg);
|
|
}
|
|
|
|
100% {
|
|
transform: translate(-40px, 20px) rotate(1deg);
|
|
}
|
|
}
|
|
|
|
@keyframes causticFlow {
|
|
0% {
|
|
transform: translate(0, 0);
|
|
}
|
|
|
|
100% {
|
|
transform: translate(-100px, -50px);
|
|
}
|
|
}
|
|
|
|
/* Map Container */
|
|
#visualization-container {
|
|
display: none;
|
|
position: relative;
|
|
height: 100%;
|
|
border-radius: 14px;
|
|
overflow: hidden;
|
|
border: 1px solid var(--c-border-strong);
|
|
box-shadow: var(--shadow);
|
|
background: transparent;
|
|
/* Transparent pour voir l'océan */
|
|
}
|
|
|
|
/* D3 Elements */
|
|
.link {
|
|
stroke: rgba(255, 255, 255, 0.15);
|
|
stroke-width: 1px;
|
|
}
|
|
|
|
.node {
|
|
cursor: pointer;
|
|
transition: opacity 0.5s;
|
|
}
|
|
|
|
.foam-ring {
|
|
fill: rgba(240, 248, 255, 0.3);
|
|
filter: url(#foam-filter);
|
|
mix-blend-mode: screen;
|
|
animation: foamPulse 4s ease-in-out infinite alternate;
|
|
}
|
|
|
|
.foam-ring:nth-child(2) {
|
|
animation-delay: -1s;
|
|
opacity: 0.3;
|
|
}
|
|
|
|
@keyframes foamPulse {
|
|
0% {
|
|
transform: scale(0.9) rotate(0deg);
|
|
opacity: 0.4;
|
|
}
|
|
|
|
100% {
|
|
transform: scale(1.1) rotate(10deg);
|
|
opacity: 0.1;
|
|
}
|
|
}
|
|
|
|
.sonar-wave {
|
|
fill: none;
|
|
stroke: #ffb703;
|
|
stroke-width: 2px;
|
|
animation: sonar 4s infinite ease-out;
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
}
|
|
|
|
@keyframes sonar {
|
|
0% {
|
|
r: 40px;
|
|
opacity: 0.6;
|
|
stroke-width: 3px;
|
|
}
|
|
|
|
100% {
|
|
r: 300px;
|
|
opacity: 0;
|
|
stroke-width: 1px;
|
|
}
|
|
}
|
|
|
|
.label-group {
|
|
transition: transform 0.1s;
|
|
}
|
|
|
|
.label-bg {
|
|
fill: rgba(0, 20, 40, 0.8);
|
|
rx: 4;
|
|
stroke: rgba(255, 255, 255, 0.1);
|
|
stroke-width: 0.5px;
|
|
}
|
|
|
|
.label-text {
|
|
font-size: 10px;
|
|
fill: #fff;
|
|
font-family: monospace;
|
|
text-shadow: 0 1px 2px #000;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.d3-tooltip {
|
|
position: absolute;
|
|
pointer-events: none;
|
|
opacity: 0;
|
|
background: rgba(2, 16, 31, 0.95);
|
|
border: 1px solid #219ebc;
|
|
padding: 12px;
|
|
border-radius: 8px;
|
|
font-size: 0.85rem;
|
|
color: #fff;
|
|
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.5);
|
|
transform: translate(-50%, -110%);
|
|
transition: opacity 0.2s;
|
|
white-space: nowrap;
|
|
z-index: 1000;
|
|
}
|
|
|
|
/* Responsive */
|
|
@media (max-width: 720px) {
|
|
.nv-search {
|
|
min-width: 0;
|
|
flex: 1;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
|
|
<body>
|
|
|
|
<svg style="position: absolute; width: 0; height: 0; overflow: hidden;">
|
|
<defs>
|
|
<filter id="foam-filter" x="-50%" y="-50%" width="200%" height="200%">
|
|
<feTurbulence type="fractalNoise" baseFrequency="0.02" numOctaves="3" result="noise" />
|
|
<feDisplacementMap in="SourceGraphic" in2="noise" scale="10" xChannelSelector="R" yChannelSelector="G" />
|
|
</filter>
|
|
</defs>
|
|
</svg>
|
|
|
|
<div class="ocean-container">
|
|
<div class="ocean-surface"></div>
|
|
<div class="ocean-caustics"></div>
|
|
</div>
|
|
|
|
<main class="main">
|
|
<div class="nv-toolbar-wrap">
|
|
<div class="nv-toolbar">
|
|
<div class="nv-search">
|
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
<path
|
|
d="M15.5 14h-.8l-.3-.3a6.5 6.5 0 1 0-.9.9l.3.3v.8L20 21.5 21.5 20l-6-6zm-6 0A4.5 4.5 0 1 1 14 9.5 4.5 4.5 0 0 1 9.5 14z" />
|
|
</svg>
|
|
<input id="searchInput" type="text" placeholder="Search IP / Hostname / Ports…" />
|
|
</div>
|
|
|
|
<div class="segmented" id="viewSeg">
|
|
<button type="button" data-view="table" aria-pressed="true">Table</button>
|
|
<button type="button" data-view="map" aria-pressed="false">Map</button>
|
|
</div>
|
|
|
|
<label class="nv-switch" id="hostSwitch" data-on="true" title="Show Labels on map">
|
|
<input type="checkbox" id="toggleHostname" checked>
|
|
<span>Labels</span>
|
|
<span class="track"><span class="thumb"></span></span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="table-wrap" class="table-wrap">
|
|
<div id="network-table"></div>
|
|
</div>
|
|
<div id="visualization-container"></div>
|
|
</main>
|
|
|
|
<div id="d3-tooltip" class="d3-tooltip"></div>
|
|
|
|
<script>
|
|
// --- GLOBAL STATE ---
|
|
let currentSort = { column: null, direction: 'asc' };
|
|
let viewMode = 'table';
|
|
let networkData = []; // Raw data from table parsing
|
|
|
|
// D3 State (Persistent)
|
|
let globalNodes = [], globalLinks = [];
|
|
let simulation, svg, g, linkGroup, nodeGroup, labelsGroup;
|
|
let currentZoomScale = 1;
|
|
let showLabels = true;
|
|
let mapInitialized = false;
|
|
|
|
// --- CONFIGURATION ---
|
|
const ICONS = {
|
|
bjorn: '/web/images/boat.png',
|
|
host_active: '/web/images/target.png',
|
|
host_empty: '/web/images/target2.png',
|
|
loot: '/web/images/treasure.png',
|
|
gateway: '/web/images/lighthouse.png'
|
|
};
|
|
|
|
/* ========== Helpers ========== */
|
|
const getPref = (k, d) => localStorage.getItem(k) ?? d;
|
|
const setPref = (k, v) => localStorage.setItem(k, v);
|
|
const norm = v => (v ?? '').toString().toLowerCase();
|
|
|
|
/* ========== Initialization ========== */
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
// Restore prefs
|
|
const savedView = getPref('nv:view', 'table');
|
|
const savedSearch = getPref('nv:search', '');
|
|
const savedShowLabels = getPref('nv:showHostname', 'true') === 'true';
|
|
|
|
document.getElementById('searchInput').value = savedSearch;
|
|
document.getElementById('toggleHostname').checked = savedShowLabels;
|
|
document.getElementById('hostSwitch').dataset.on = String(savedShowLabels);
|
|
showLabels = savedShowLabels;
|
|
|
|
// Event Listeners
|
|
document.querySelectorAll('#viewSeg button').forEach(btn => {
|
|
btn.addEventListener('click', () => setView(btn.dataset.view));
|
|
});
|
|
|
|
document.getElementById('toggleHostname').addEventListener('change', e => {
|
|
showLabels = e.target.checked;
|
|
document.getElementById('hostSwitch').dataset.on = String(showLabels);
|
|
setPref('nv:showHostname', String(showLabels));
|
|
updateLabelsVisibility();
|
|
});
|
|
|
|
let t;
|
|
document.getElementById('searchInput').addEventListener('input', (e) => {
|
|
clearTimeout(t); t = setTimeout(applySearch, 120);
|
|
});
|
|
|
|
// Init View
|
|
setView(savedView === 'map' ? 'map' : 'table');
|
|
|
|
// Start Loop
|
|
fetchNetworkData();
|
|
setInterval(fetchNetworkData, 5000); // 5s refresh
|
|
});
|
|
|
|
/* ========== View Switching ========== */
|
|
function setView(mode) {
|
|
viewMode = mode;
|
|
setPref('nv:view', mode);
|
|
document.querySelectorAll('#viewSeg button').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.view === mode)));
|
|
|
|
const tableWrap = document.getElementById('table-wrap');
|
|
const mapWrap = document.getElementById('visualization-container');
|
|
const hostSwitch = document.getElementById('hostSwitch');
|
|
|
|
if (mode === 'table') {
|
|
tableWrap.style.display = '';
|
|
mapWrap.style.display = 'none';
|
|
hostSwitch.style.display = 'none';
|
|
} else {
|
|
tableWrap.style.display = 'none';
|
|
mapWrap.style.display = 'block';
|
|
hostSwitch.style.display = 'flex';
|
|
|
|
if (!mapInitialized) {
|
|
initMap();
|
|
updateMapFromData(networkData); // Initial draw
|
|
}
|
|
}
|
|
}
|
|
|
|
/* ========== Data Logic ========== */
|
|
function fetchNetworkData() {
|
|
fetch('/network_data')
|
|
.then(r => r.text())
|
|
.then(html => {
|
|
const processedHTML = processTableData(html);
|
|
document.getElementById('network-table').innerHTML = processedHTML;
|
|
|
|
const table = document.querySelector('#network-table table');
|
|
if (table) {
|
|
initTableSorting(table);
|
|
applySearchToTable();
|
|
networkData = extractDataForMap(table);
|
|
|
|
if (mapInitialized) {
|
|
updateMapFromData(networkData);
|
|
}
|
|
}
|
|
})
|
|
.catch(e => console.error('Network data error:', e));
|
|
}
|
|
|
|
function processTableData(htmlString) {
|
|
const div = document.createElement('div'); div.innerHTML = htmlString;
|
|
const oldTable = div.querySelector('table'); if (!oldTable) return htmlString;
|
|
|
|
const newTable = document.createElement('table'); newTable.className = 'network-table';
|
|
newTable.innerHTML = `<thead><tr><th class="hosts-header">Hosts</th><th>Ports</th></tr></thead><tbody></tbody>`;
|
|
const tbody = newTable.querySelector('tbody');
|
|
|
|
Array.from(oldTable.querySelectorAll('tr')).slice(1).forEach(oldRow => {
|
|
const cells = oldRow.querySelectorAll('td');
|
|
if (cells.length >= 4) {
|
|
const tr = document.createElement('tr');
|
|
const essid = (cells[0]?.textContent || '').trim();
|
|
const ip = (cells[1]?.textContent || '').trim();
|
|
const hostname = (cells[2]?.textContent || '').trim();
|
|
const mac = (cells[3]?.textContent || '').trim();
|
|
const vendor = (cells[4]?.textContent || '').trim();
|
|
const portsStr = (cells[5]?.textContent || '').trim();
|
|
|
|
const hosts = `<div class="hosts-content">
|
|
<span class="bubble ip-address">${ip}</span>
|
|
${hostname ? `<span class="bubble hostname">${hostname}</span>` : ''}
|
|
${mac ? `<span class="bubble mac-address">${mac}</span>` : ''}
|
|
${vendor ? `<span class="bubble vendor">${vendor}</span>` : ''}
|
|
${essid ? `<span class="bubble essid">${essid}</span>` : ''}
|
|
</div>`;
|
|
|
|
let portsHtml = '';
|
|
if (portsStr) {
|
|
const portsList = portsStr.split(';').map(p => p.trim()).filter(p => p !== '' && p.toLowerCase() !== 'none');
|
|
portsHtml = `<div class="ports-container">${portsList.map(p => `<span class="port-bubble">${p}</span>`).join(' ')}</div>`;
|
|
}
|
|
|
|
tr.innerHTML = `<td class="hosts-cell">${hosts}</td><td>${portsHtml}</td>`;
|
|
tbody.appendChild(tr);
|
|
}
|
|
});
|
|
return `<div class="table-inner">${newTable.outerHTML}</div>`;
|
|
}
|
|
|
|
function extractDataForMap(table) {
|
|
const out = [];
|
|
table.querySelectorAll('tbody tr').forEach((tr, i) => {
|
|
const ip = tr.querySelector('.ip-address')?.textContent.trim() || '';
|
|
const hostname = tr.querySelector('.hostname')?.textContent.trim() || '';
|
|
const mac = tr.querySelector('.mac-address')?.textContent.trim() || '';
|
|
const vendor = tr.querySelector('.vendor')?.textContent.trim() || '';
|
|
const ports = Array.from(tr.querySelectorAll('.port-bubble')).map(el => el.textContent.trim());
|
|
out.push({ ip, hostname, mac, vendor, ports });
|
|
});
|
|
return out;
|
|
}
|
|
|
|
/* ========== Search Logic ========== */
|
|
function applySearch() {
|
|
const term = document.getElementById('searchInput').value.trim().toLowerCase();
|
|
localStorage.setItem('nv:search', term);
|
|
applySearchToTable();
|
|
if (mapInitialized && g) {
|
|
g.selectAll('.node').style('opacity', d => {
|
|
if (!term) return 1;
|
|
const content = (d.label + d.ip + d.vendor).toLowerCase();
|
|
return content.includes(term) ? 1 : 0.1;
|
|
});
|
|
}
|
|
}
|
|
|
|
function applySearchToTable() {
|
|
const term = document.getElementById('searchInput').value.trim().toLowerCase();
|
|
const table = document.querySelector('#network-table table');
|
|
if (table) {
|
|
table.querySelectorAll('tbody tr').forEach(tr => {
|
|
tr.style.display = tr.textContent.toLowerCase().includes(term) ? '' : 'none';
|
|
});
|
|
}
|
|
}
|
|
|
|
/* ========== D3 MAP ENGINE ========== */
|
|
function initMap() {
|
|
mapInitialized = true;
|
|
const container = document.getElementById('visualization-container');
|
|
const width = container.clientWidth;
|
|
const height = container.clientHeight;
|
|
|
|
svg = d3.select("#visualization-container").append("svg")
|
|
.attr("width", width).attr("height", height)
|
|
.on("click", () => document.getElementById('d3-tooltip').style.opacity = 0);
|
|
|
|
g = svg.append("g");
|
|
g.append("g").attr("class", "sonar-layer");
|
|
linkGroup = g.append("g").attr("class", "links-layer");
|
|
nodeGroup = g.append("g").attr("class", "nodes-layer");
|
|
labelsGroup = g.append("g").attr("class", "labels-layer node-labels");
|
|
|
|
const zoom = d3.zoom().scaleExtent([0.2, 6]).on("zoom", (e) => {
|
|
g.attr("transform", e.transform);
|
|
currentZoomScale = e.transform.k;
|
|
requestAnimationFrame(() => labelsGroup.selectAll(".label-group").attr("transform", d => `translate(${d.x},${d.y + d.r + 15}) scale(${1 / currentZoomScale})`));
|
|
});
|
|
svg.call(zoom);
|
|
|
|
// --- PHYSIQUE STABILISÉE ---
|
|
simulation = d3.forceSimulation()
|
|
// Liens plus courts pour compacter
|
|
.force("link", d3.forceLink().id(d => d.id).distance(d => d.target.type === 'loot' ? 30 : 80))
|
|
|
|
// Répulsion différencée : Les 'vides' se repoussent plus pour s'écarter
|
|
.force("charge", d3.forceManyBody().strength(d => d.type === 'host_empty' ? -300 : -100))
|
|
|
|
// Anti-collision
|
|
.force("collide", d3.forceCollide().radius(d => d.r * 1.5).iterations(2))
|
|
|
|
// Gravité centrale pour tout ramener
|
|
.force("x", d3.forceX(width / 2).strength(0.08))
|
|
.force("y", d3.forceY(height / 2).strength(0.08))
|
|
|
|
// Friction importante pour stopper le mouvement
|
|
.alphaMin(0.05)
|
|
.velocityDecay(0.6)
|
|
.on("tick", ticked);
|
|
|
|
window.addEventListener('resize', () => {
|
|
svg.attr("width", container.clientWidth).attr("height", container.clientHeight);
|
|
simulation.force("center", d3.forceCenter(container.clientWidth / 2, container.clientHeight / 2)).alpha(0.3).restart();
|
|
});
|
|
}
|
|
|
|
function updateMapFromData(data) {
|
|
const incomingNodes = new Map();
|
|
const incomingLinks = [];
|
|
|
|
// 1. BJORN
|
|
incomingNodes.set('bjorn', { id: 'bjorn', type: 'bjorn', r: 50, label: 'BJORN' });
|
|
|
|
// 2. Process Nodes
|
|
data.forEach(h => {
|
|
const hasPorts = h.ports && h.ports.length > 0;
|
|
const isGateway = h.ip.endsWith('.1') || h.ip.endsWith('.254');
|
|
const type = isGateway ? 'gateway' : (hasPorts ? 'host_active' : 'host_empty');
|
|
const radius = isGateway ? 40 : (hasPorts ? 30 : 20);
|
|
|
|
incomingNodes.set(h.ip, {
|
|
id: h.ip, type: type, ip: h.ip, label: h.hostname || h.ip,
|
|
vendor: h.vendor, r: radius, ports: h.ports
|
|
});
|
|
|
|
if (hasPorts) {
|
|
h.ports.forEach(p => {
|
|
const portId = `${h.ip}_${p}`;
|
|
incomingNodes.set(portId, { id: portId, type: 'loot', label: p, r: 15, parent: h.ip });
|
|
incomingLinks.push({ source: h.ip, target: portId });
|
|
});
|
|
}
|
|
});
|
|
|
|
// 3. RECONCILIATION
|
|
const nextNodes = [];
|
|
let hasStructuralChanges = false;
|
|
|
|
// Check counts to decide if we need to restart sim
|
|
if (globalNodes.length !== incomingNodes.size) hasStructuralChanges = true;
|
|
|
|
incomingNodes.forEach((data, id) => {
|
|
const existing = globalNodes.find(n => n.id === id);
|
|
if (existing) {
|
|
if (existing.type !== data.type) hasStructuralChanges = true; // Status changed
|
|
Object.assign(existing, data);
|
|
nextNodes.push(existing);
|
|
} else {
|
|
hasStructuralChanges = true;
|
|
const w = svg.attr("width"), h = svg.attr("height");
|
|
data.x = w / 2 + (Math.random() - 0.5) * 50;
|
|
data.y = h / 2 + (Math.random() - 0.5) * 50;
|
|
nextNodes.push(data);
|
|
}
|
|
});
|
|
|
|
globalNodes = nextNodes;
|
|
globalLinks = incomingLinks.map(l => ({ source: l.source, target: l.target }));
|
|
|
|
updateViz(hasStructuralChanges);
|
|
}
|
|
|
|
function updateViz(restartSimulation) {
|
|
const node = nodeGroup.selectAll(".node").data(globalNodes, d => d.id);
|
|
|
|
const nodeEnter = node.enter().append("g").attr("class", "node")
|
|
.call(d3.drag().on("start", dragstarted).on("drag", dragged).on("end", dragended));
|
|
|
|
nodeEnter.append("g").attr("class", "foam-container");
|
|
nodeEnter.append("image").attr("class", "node-icon")
|
|
.on("error", function () { d3.select(this).style("display", "none"); });
|
|
|
|
const nodeUpdate = nodeEnter.merge(node);
|
|
nodeUpdate.attr("class", d => `node ${d.type === 'host_empty' ? 'empty' : ''}`);
|
|
|
|
nodeUpdate.select(".node-icon")
|
|
.attr("xlink:href", d => ICONS[d.type] || ICONS.host_empty)
|
|
.attr("x", d => -d.r).attr("y", d => -d.r)
|
|
.attr("width", d => d.r * 2).attr("height", d => d.r * 2)
|
|
.style("display", "block");
|
|
|
|
// Only update foam if needed (perf)
|
|
nodeUpdate.select(".foam-container").each(function (d) {
|
|
if (!['bjorn', 'gateway', 'host_active'].includes(d.type)) {
|
|
d3.select(this).selectAll("*").remove();
|
|
return;
|
|
}
|
|
if (d3.select(this).empty()) {
|
|
const c = d3.select(this);
|
|
[1, 2].forEach(i => c.append("circle").attr("class", "foam-ring").attr("r", d.r * (1 + i * 0.15)));
|
|
}
|
|
});
|
|
|
|
nodeUpdate.on("click", (e, d) => showTooltip(e, d));
|
|
node.exit().transition().duration(500).style("opacity", 0).remove();
|
|
|
|
const link = linkGroup.selectAll(".link").data(globalLinks, d => d.source.id ? (d.source.id + "-" + d.target.id) : (d.source + "-" + d.target));
|
|
link.enter().append("line").attr("class", "link");
|
|
link.exit().remove();
|
|
|
|
const label = labelsGroup.selectAll(".label-group").data(globalNodes.filter(d => ['bjorn', 'gateway', 'host_active', 'loot'].includes(d.type)), d => d.id);
|
|
const labelEnter = label.enter().append("g").attr("class", "label-group");
|
|
labelEnter.append("rect").attr("class", "label-bg").attr("height", 16);
|
|
labelEnter.append("text").attr("class", "label-text").attr("text-anchor", "middle").attr("y", 11);
|
|
|
|
const labelUpdate = labelEnter.merge(label);
|
|
labelUpdate.select("text").text(d => d.label).each(function () {
|
|
const w = this.getBBox().width;
|
|
d3.select(this.parentNode).select("rect").attr("x", -w / 2 - 4).attr("width", w + 8);
|
|
});
|
|
label.exit().remove();
|
|
|
|
updateLabelsVisibility();
|
|
|
|
simulation.nodes(globalNodes);
|
|
simulation.force("link").links(globalLinks);
|
|
|
|
// Crucial: Only restart physics if something actually changed structure
|
|
if (restartSimulation) {
|
|
simulation.alpha(0.3).restart();
|
|
}
|
|
}
|
|
|
|
function ticked() {
|
|
linkGroup.selectAll(".link")
|
|
.attr("x1", d => d.source.x).attr("y1", d => d.source.y)
|
|
.attr("x2", d => d.target.x).attr("y2", d => d.target.y);
|
|
|
|
nodeGroup.selectAll(".node").attr("transform", d => `translate(${d.x},${d.y})`);
|
|
labelsGroup.selectAll(".label-group").attr("transform", d => `translate(${d.x},${d.y + d.r + 15}) scale(${1 / currentZoomScale})`);
|
|
|
|
const bjorn = globalNodes.find(n => n.type === 'bjorn');
|
|
if (bjorn) {
|
|
let sonar = g.select(".sonar-layer").selectAll(".sonar-wave").data([bjorn]);
|
|
sonar.enter().append("circle").attr("class", "sonar-wave")
|
|
.merge(sonar).attr("cx", d => d.x).attr("cy", d => d.y);
|
|
}
|
|
}
|
|
|
|
/* ========== D3 Utils ========== */
|
|
function dragstarted(e, d) { if (!e.active) simulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; }
|
|
function dragged(e, d) { d.fx = e.x; d.fy = e.y; }
|
|
function dragended(e, d) { if (!e.active) simulation.alphaTarget(0); d.fx = null; d.fy = null; }
|
|
|
|
function showTooltip(e, d) {
|
|
e.stopPropagation();
|
|
const tt = document.getElementById('d3-tooltip');
|
|
let html = `<div style="color:var(--gold);font-weight:bold;margin-bottom:5px;">${d.label}</div>`;
|
|
if (d.ip && d.ip !== d.label) html += `<div><i class="fas fa-network-wired"></i> ${d.ip}</div>`;
|
|
if (d.vendor) html += `<div style="opacity:0.8;font-size:0.8em"><i class="fas fa-microchip"></i> ${d.vendor}</div>`;
|
|
if (d.type === 'loot') html = `<div>💰 <strong>Port ${d.label}</strong></div>`;
|
|
tt.innerHTML = html;
|
|
tt.style.left = (e.pageX + 10) + "px"; tt.style.top = (e.pageY - 50) + "px";
|
|
tt.style.opacity = 1;
|
|
}
|
|
|
|
function updateLabelsVisibility() {
|
|
if (labelsGroup) labelsGroup.style("opacity", showLabels ? 1 : 0);
|
|
}
|
|
|
|
function initTableSorting(table) {
|
|
const headers = table.querySelectorAll('th');
|
|
headers.forEach((h, idx) => {
|
|
h.addEventListener('click', () => {
|
|
headers.forEach(x => x.classList.remove('sort-asc', 'sort-desc'));
|
|
if (currentSort.column === idx) currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
|
|
else { currentSort.column = idx; currentSort.direction = 'asc'; }
|
|
h.classList.add(`sort-${currentSort.direction}`);
|
|
|
|
const tbody = table.querySelector('tbody');
|
|
const rows = Array.from(tbody.querySelectorAll('tr'));
|
|
const sorted = rows.sort((a, b) => {
|
|
const A = a.querySelectorAll('td')[idx].textContent.trim().toLowerCase();
|
|
const B = b.querySelectorAll('td')[idx].textContent.trim().toLowerCase();
|
|
return currentSort.direction === 'asc' ? A.localeCompare(B) : B.localeCompare(A);
|
|
});
|
|
tbody.innerHTML = ''; sorted.forEach(r => tbody.appendChild(r));
|
|
});
|
|
});
|
|
}
|
|
|
|
function applySearchToTable() {
|
|
const term = document.getElementById('searchInput').value.trim().toLowerCase();
|
|
const table = document.querySelector('#network-table table');
|
|
if (table) {
|
|
table.querySelectorAll('tbody tr').forEach(tr => {
|
|
tr.style.display = tr.textContent.toLowerCase().includes(term) ? '' : 'none';
|
|
});
|
|
}
|
|
}
|
|
</script>
|
|
</body>
|
|
|
|
</html> |