mirror of
https://github.com/infinition/Bjorn.git
synced 2025-12-13 08:04:59 +00:00
591 lines
25 KiB
HTML
591 lines
25 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en" class="dark">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
|
||
<title>Bjorn Cyberviking – NetKB</title>
|
||
<link rel="icon" href="/web/images/favicon.ico" type="image/x-icon" />
|
||
<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" />
|
||
<link rel="stylesheet" href="/web/css/global.css" />
|
||
<script src="/web/js/global.js" defer></script>
|
||
|
||
<style>
|
||
:root{
|
||
--kb-chip: var(--c-chip-bg, rgba(255,255,255,.04));
|
||
--kb-pill: var(--c-pill-bg, rgba(0,0,0,.25));
|
||
--kb-hostname-bg: color-mix(in oklab, var(--acid) 16%, transparent);
|
||
--kb-ip-bg: color-mix(in oklab, #18f0ff 18%, transparent);
|
||
--kb-mac-bg: color-mix(in oklab, var(--muted) 10%, transparent);
|
||
--kb-vendor-bg: color-mix(in oklab, #b18cff 16%, transparent);
|
||
--kb-ports-bg: color-mix(in oklab, #5fd1ff 16%, transparent);
|
||
--kb-essid-bg: color-mix(in oklab, #00e6c3 16%, transparent);
|
||
|
||
--kb-offline-bg: color-mix(in oklab, var(--bg-2, #0b0f14) 88%, black 12%);
|
||
--kb-offline-brd: color-mix(in oklab, var(--c-border-strong) 60%, transparent);
|
||
--kb-offline-ring: color-mix(in oklab, #ff5b5b 30%, transparent);
|
||
|
||
--kb-badge-shimmer: linear-gradient(90deg, transparent, rgba(255,255,255,.22), transparent);
|
||
}
|
||
|
||
body{ background:var(--grad-bg-1),var(--grad-bg-2),var(--bg) }
|
||
.main{ padding:16px }
|
||
|
||
/* ============ Toolbar sticky (amélioré) ============ */
|
||
.netkb-toolbar-wrap{
|
||
position:sticky;
|
||
top: calc(var(--h-topbar, 0px) - 56px);
|
||
z-index:500;
|
||
backdrop-filter:saturate(1.1) blur(6px);
|
||
}
|
||
.netkb-toolbar{
|
||
position:relative; /* pour le popover */
|
||
display:flex; gap:12px; align-items:center; justify-content:flex-end;
|
||
margin-bottom:12px;
|
||
border:1px solid var(--c-border-strong);
|
||
padding:8px 10px;
|
||
box-shadow:var(--shadow);
|
||
background: color-mix(in oklab, var(--panel) 88%, transparent);
|
||
border-radius: 16px;
|
||
}
|
||
|
||
.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 offline) */
|
||
.kb-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;
|
||
}
|
||
.kb-switch input{ display:none }
|
||
.kb-switch .track{
|
||
width:44px; height:24px; border-radius:999px; background:var(--c-panel-2);
|
||
position:relative; border:1px solid var(--c-border);
|
||
}
|
||
.kb-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;
|
||
}
|
||
/* IMPORTANT: ~ et pas + (il y a un <span> avant .track) */
|
||
.kb-switch input:checked ~ .track .thumb{ left:22px; background:var(--acid) }
|
||
.kb-switch[data-on="true"]{ color:var(--ink) }
|
||
|
||
/* Loupe */
|
||
.icon-btn{
|
||
display:inline-flex; align-items:center; justify-content:center;
|
||
width:40px; height:40px; border-radius:12px;
|
||
background:var(--panel); border:1px solid var(--c-border-strong);
|
||
box-shadow:var(--shadow); cursor:pointer;
|
||
transition:transform .12s ease, box-shadow .12s ease;
|
||
}
|
||
.icon-btn:hover{ transform:translateY(-1px); box-shadow:var(--shadow-hover) }
|
||
.icon-btn svg{ width:20px; height:20px; fill:var(--ink) }
|
||
|
||
.search-pop{
|
||
position:absolute; right:8px; top:54px; /* sous la toolbar */
|
||
display:none; min-width:260px; background:var(--panel);
|
||
border:1px solid var(--c-border-strong); border-radius:12px; padding:10px;
|
||
box-shadow:var(--shadow-hover);
|
||
}
|
||
.search-pop.show{ display:block }
|
||
.search-pop input{
|
||
width:100%; padding:10px 12px; border-radius:10px; border:1px solid var(--c-border);
|
||
background:var(--c-panel-2); color:var(--ink); font-weight:700;
|
||
outline: none;
|
||
}
|
||
.search-hint{ margin-top:6px; font-size:.85rem; color:var(--muted) }
|
||
|
||
/* ============ Containers ============ */
|
||
/* .netkb-container{ display:grid; gap:16px; min-height:calc(100dvh - var(--h-topbar) - var(--h-bottombar)) } */
|
||
|
||
.netkb-container{ display:grid; gap:16px; }
|
||
.hidden{ display:none !important } /* empêche double affichage */
|
||
|
||
/* ============ Cards (Grid/List) ============ */
|
||
.card-container{
|
||
display:flex; flex-wrap:wrap; gap:12px; align-items:stretch; justify-content:center;
|
||
}
|
||
.card{
|
||
background:var(--grad-card); color:var(--ink);
|
||
border:1px solid var(--c-border-strong); border-radius:18px; box-shadow:var(--shadow);
|
||
width:min(380px, 100%); padding:12px; display:flex; flex-direction:column; gap:10px;
|
||
transition:transform .15s ease, box-shadow .15s ease, border-color .15s ease, background .15s ease;
|
||
}
|
||
.card:hover{ box-shadow:var(--shadow-hover); border-color:var(--c-border-hi); transform:translateY(-1px) }
|
||
.card.alive .card-title{ color:var(--ok) }
|
||
|
||
.card.not-alive{
|
||
background:var(--kb-offline-bg);
|
||
border-color:var(--kb-offline-brd);
|
||
color:color-mix(in oklab, var(--muted) 90%, var(--ink) 10%);
|
||
box-shadow:0 0 0 1px var(--kb-offline-brd),
|
||
0 0 0 2px color-mix(in oklab, var(--kb-offline-ring) 26%, transparent),
|
||
var(--shadow);
|
||
}
|
||
.card.not-alive .card-title{ color:color-mix(in oklab, var(--muted) 85%, var(--ink) 15%) }
|
||
|
||
.card-content{ display:flex; flex-direction:column; gap:6px; flex:1 }
|
||
.card-title{ font-size:1.1rem; font-weight:800; margin:0 }
|
||
.card-section{ display:flex; align-items:center; gap:8px; flex-wrap:wrap }
|
||
.card.list{ width:100%; max-width:none; flex-direction:row; align-items:center }
|
||
.card.list .card-title{ font-size:1rem }
|
||
|
||
/* Chips */
|
||
.chip{ display:inline-block; padding:.32rem .7rem; border-radius:999px; border:1px solid var(--c-border-strong);
|
||
background:var(--kb-chip); color:var(--ink); font-weight:700; font-size:.92rem; }
|
||
.chip.host{ background:var(--kb-hostname-bg) }
|
||
.chip.ip{ background:var(--kb-ip-bg) }
|
||
.chip.mac{ background:var(--kb-mac-bg); color:var(--muted) }
|
||
.chip.vendor{ background:var(--kb-vendor-bg) }
|
||
.chip.essid{ background:var(--kb-essid-bg) }
|
||
.chip.port{ background:var(--kb-ports-bg); border-color:var(--c-border-hi) }
|
||
.port-bubbles{ display:flex; flex-wrap:wrap; gap:6px }
|
||
|
||
/* Badges */
|
||
.status-container{ display:flex; flex-wrap:wrap; gap:8px; justify-content:center }
|
||
.badge{
|
||
background:var(--c-panel-2); color:var(--ink); border:1px solid var(--c-border);
|
||
border-radius:14px; padding:8px 10px; min-width:160px; text-align:center;
|
||
box-shadow:var(--shadow); transition:transform .12s ease, box-shadow .12s ease, opacity .12s ease;
|
||
position:relative;
|
||
}
|
||
.badge .badge-header{ font-weight:800; opacity:.95 }
|
||
.badge .badge-status{ font-weight:900 }
|
||
.badge .badge-timestamp{ font-size:.85em; opacity:.9 }
|
||
.badge.clickable{ cursor:pointer }
|
||
.badge:hover{ transform:translateY(-1px); box-shadow:var(--shadow-hover) }
|
||
|
||
.badge.success{ background:linear-gradient(180deg, color-mix(in oklab, var(--ok) 12%, transparent), transparent) }
|
||
.badge.failed { background:linear-gradient(180deg, color-mix(in oklab, var(--danger) 18%, transparent), transparent) }
|
||
.badge.pending{ background:linear-gradient(180deg, color-mix(in oklab, var(--muted) 12%, transparent), transparent) }
|
||
.badge.expired{ background:linear-gradient(180deg, color-mix(in oklab, var(--warning) 18%, transparent), transparent) }
|
||
.badge.cancelled{ background:linear-gradient(180deg, color-mix(in oklab, var(--c-panel) 18%, transparent), transparent) }
|
||
|
||
/* Running → shimmer horiz + sheen diagonal + pulse */
|
||
.badge.running{
|
||
background:linear-gradient(180deg, color-mix(in oklab, #18f0ff 14%, transparent), transparent);
|
||
overflow:hidden;
|
||
animation:badgePulse 1.6s ease-in-out infinite;
|
||
}
|
||
.badge.running::after{
|
||
content:""; position:absolute; inset:0; background:var(--kb-badge-shimmer);
|
||
animation:shimmer 1.8s linear infinite;
|
||
}
|
||
.badge.running::before{
|
||
content:""; position:absolute; inset:-20%;
|
||
background:linear-gradient(130deg, transparent 40%, rgba(255,255,255,.06) 50%, transparent 60%);
|
||
animation:sheen 2.2s ease-in-out infinite;
|
||
}
|
||
@keyframes shimmer{ 0%{transform:translateX(-100%)} 100%{transform:translateX(100%)} }
|
||
@keyframes sheen{ 0%{ transform:translateX(-30%) } 100%{ transform:translateX(30%) } }
|
||
@keyframes badgePulse{ 0%,100%{ box-shadow:0 0 0 0 rgba(24,240,255,.12) } 50%{ box-shadow:0 0 0 8px rgba(24,240,255,.04) } }
|
||
|
||
/* Table */
|
||
.table-wrap{
|
||
border:1px solid var(--c-border-strong); border-radius:14px; overflow:auto;
|
||
background:var(--panel); box-shadow:var(--shadow);
|
||
}
|
||
table{ width:100%; border-collapse:separate; border-spacing:0 }
|
||
thead th{
|
||
position:sticky; top:0; z-index:2; 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;
|
||
}
|
||
tbody td{
|
||
border-bottom:1px solid var(--c-border); padding:10px; white-space:nowrap; text-align:center;
|
||
}
|
||
th:first-child, td:first-child{ position:sticky; left:0; background:var(--panel); z-index:3 }
|
||
.filter-icon{ width:16px; height:16px; margin-left:6px; vertical-align:middle }
|
||
|
||
/* Highlight du terme recherché */
|
||
mark.hl{
|
||
background: color-mix(in oklab, var(--acid) 25%, transparent);
|
||
color: var(--ink);
|
||
padding: 0 .15em;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
/* Focus & accessibilité */
|
||
.segmented button:focus-visible,
|
||
.icon-btn:focus-visible,
|
||
.kb-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);
|
||
}
|
||
|
||
/* Mobile : masquer Grid, forcer list/table */
|
||
@media (max-width:720px){
|
||
.card{ width:100% }
|
||
thead th, tbody td{ min-width:120px; font-size:.94rem }
|
||
.segmented button[data-view="grid"]{ display:none }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<main class="main" id="main">
|
||
<!-- Toolbar (sticky) -->
|
||
<div class="netkb-toolbar-wrap">
|
||
<div class="netkb-toolbar" id="toolbar">
|
||
<!-- Loupe -->
|
||
<button class="icon-btn" id="btnSearch" title="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>
|
||
</button>
|
||
<div class="search-pop" id="searchPop">
|
||
<input id="searchInput" type="text" placeholder="Search IP / Hostname / MAC / Vendor / Ports / ESSID / Action…">
|
||
<div class="search-hint">Type to filter. <kbd>Esc</kbd> to close.</div>
|
||
</div>
|
||
|
||
<!-- View segmented -->
|
||
<div class="segmented" id="viewSeg">
|
||
<button type="button" data-view="grid" aria-pressed="true">Grid</button>
|
||
<button type="button" data-view="list" aria-pressed="false">List</button>
|
||
<button type="button" data-view="table" aria-pressed="false">Table</button>
|
||
</div>
|
||
|
||
<!-- Show offline -->
|
||
<label class="kb-switch" id="offlineSwitch" data-on="false">
|
||
<input type="checkbox" id="toggleOffline">
|
||
<span>Show offline</span>
|
||
<span class="track"><span class="thumb"></span></span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Content -->
|
||
<div class="netkb-container">
|
||
<div id="card-container" class="card-container"></div>
|
||
<div id="table-container" class="table-wrap hidden"></div>
|
||
</div>
|
||
</main>
|
||
|
||
<script>
|
||
let sortOrder = 1;
|
||
let showNotAlive = false;
|
||
let viewMode = 'grid';
|
||
let currentSort = 'ip';
|
||
let currentFilter = null;
|
||
let originalData = [];
|
||
let searchTerm = '';
|
||
|
||
/* -------- Helpers stockage préférences -------- */
|
||
const getPref = (k, d) => localStorage.getItem(k) ?? d;
|
||
const setPref = (k, v) => localStorage.setItem(k, v);
|
||
|
||
/* -------- Fetch -------- */
|
||
function fetchNetkbData(){
|
||
fetch('/netkb_data')
|
||
.then(r => r.json())
|
||
.then(data => { originalData = [...data]; refreshDisplay(); })
|
||
.catch(e => console.error('NetKB fetch error:', e));
|
||
}
|
||
|
||
/* -------- Render: Cards -------- */
|
||
function renderCards(data){
|
||
const host = document.getElementById('card-container');
|
||
host.innerHTML = '';
|
||
data.forEach(item => {
|
||
if (!showNotAlive && !item.alive) return;
|
||
|
||
const el = document.createElement('div');
|
||
el.className = 'card ' + (viewMode==='list' ? 'list ' : '') + (item.alive ? 'alive' : 'not-alive');
|
||
|
||
const titleText = (item.hostname && item.hostname !== 'N/A') ? hl(item.hostname) : hl(item.ip || 'N/A');
|
||
|
||
el.innerHTML = `
|
||
<div class="card-content">
|
||
<h3 class="card-title">${titleText}</h3>
|
||
${item.ip ? `<div class="card-section"><strong>IP:</strong> <span class="chip ip">${hl(item.ip)}</span></div>` : ''}
|
||
${item.mac ? `<div class="card-section"><strong>MAC:</strong> <span class="chip mac">${hl(item.mac)}</span></div>` : ''}
|
||
${item.vendor && item.vendor !== 'N/A' ? `<div class="card-section"><strong>Vendor:</strong> <span class="chip vendor">${hl(item.vendor)}</span></div>` : ''}
|
||
${item.essid && item.essid !== 'N/A' ? `<div class="card-section"><strong>ESSID:</strong> <span class="chip essid">${hl(item.essid)}</span></div>` : ''}
|
||
${item.ports && item.ports.length ? `<div class="card-section"><strong>Open Ports:</strong> <div class="port-bubbles">${renderPorts(item.ports)}</div></div>` : ''}
|
||
</div>
|
||
<div class="status-container">${renderActions(item.actions, item.ip)}</div>
|
||
`;
|
||
host.appendChild(el);
|
||
});
|
||
}
|
||
|
||
/* -------- Render: Table -------- */
|
||
function renderTable(data){
|
||
const host = document.getElementById('table-container');
|
||
let html = `
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th onclick="sortBy('hostname')">Hostname
|
||
<img src="/web/images/filter_icon.png" class="filter-icon" onclick="filterBy('toggleAlive', event)" title="Filter">
|
||
</th>
|
||
<th onclick="sortBy('ip')">IP</th>
|
||
<th onclick="sortBy('mac')">MAC</th>
|
||
<th onclick="sortBy('essid')">ESSID</th>
|
||
<th onclick="sortBy('vendor')">Vendor</th>
|
||
<th onclick="sortBy('ports')">Ports
|
||
<img src="/web/images/filter_icon.png" class="filter-icon" onclick="filterBy('hasPorts', event)" title="Filter">
|
||
</th>
|
||
<th>Actions
|
||
<img src="/web/images/filter_icon.png" class="filter-icon" onclick="filterBy('hasActions', event)" title="Filter">
|
||
</th>
|
||
</tr>
|
||
</thead><tbody>
|
||
`;
|
||
data.filter(i => showNotAlive || i.alive).forEach(item => {
|
||
const hostText = (item.hostname && item.hostname !== 'N/A') ? hl(item.hostname) : hl(item.ip || 'N/A');
|
||
html += `
|
||
<tr>
|
||
<td><span class="chip host">${hostText}</span></td>
|
||
<td>${item.ip ? `<span class="chip ip">${hl(item.ip)}</span>` : 'N/A'}</td>
|
||
<td>${item.mac ? `<span class="chip mac">${hl(item.mac)}</span>` : 'N/A'}</td>
|
||
<td>${item.essid && item.essid !== 'N/A' ? `<span class="chip essid">${hl(item.essid)}</span>` : 'N/A'}</td>
|
||
<td>${item.vendor && item.vendor !== 'N/A' ? `<span class="chip vendor">${hl(item.vendor)}</span>` : 'N/A'}</td>
|
||
<td><div class="port-bubbles">${renderPorts(item.ports)}</div></td>
|
||
<td><div class="status-container">${renderActions(item.actions, item.ip)}</div></td>
|
||
</tr>
|
||
`;
|
||
});
|
||
html += `</tbody></table>`;
|
||
host.innerHTML = html;
|
||
}
|
||
|
||
/* -------- Helpers -------- */
|
||
function hl(text){
|
||
if (!searchTerm) return text;
|
||
const esc = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||
return String(text).replace(new RegExp(esc(searchTerm), 'ig'), m => `<mark class="hl">${m}</mark>`);
|
||
}
|
||
|
||
function renderPorts(ports=[]){
|
||
if (!ports || ports.length === 0 || (ports.length === 1 && !ports[0])) return '';
|
||
return ports.filter(Boolean).map(p => `<span class="chip port">${hl(String(p))}</span>`).join(' ');
|
||
}
|
||
|
||
function renderActions(actions=[], ip){
|
||
if (!actions || actions.length === 0) return '';
|
||
function parseRaw(raw){
|
||
const m = /^([a-z_]+)_(\d{8})_(\d{6})$/i.exec(raw||""); if(!m) return null;
|
||
const s=m[1].toLowerCase(), y=m[2].slice(0,4), mo=m[2].slice(4,6), d=m[2].slice(6,8);
|
||
const hh=m[3].slice(0,2), mm=m[3].slice(2,4), ss=m[3].slice(4,6);
|
||
const ts = Date.parse(`${y}-${mo}-${d}T${hh}:${mm}:${ss}Z`) || 0;
|
||
return {status:s, ts, y, mo, d, hh, mm, ss};
|
||
}
|
||
const map = new Map();
|
||
for (const a of actions){
|
||
if (!a || !a.name || !a.status) continue;
|
||
const p = parseRaw(a.status); if (!p) continue;
|
||
const prev = map.get(a.name);
|
||
if (!prev || p.ts > prev.parsed.ts) map.set(a.name, {...a, parsed:p});
|
||
}
|
||
const list = Array.from(map.values()).sort((a,b)=> b.parsed.ts - a.parsed.ts);
|
||
const label = s => ({success:'Success', failed:'Failed', fail:'Failed', running:'Running', pending:'Pending', expired:'Expired', cancelled:'Cancelled'})[s]||s;
|
||
|
||
return list.map(a=>{
|
||
const s = a.parsed.status === 'fail' ? 'failed' : a.parsed.status;
|
||
const clickable = ['success','failed','expired','cancelled'].includes(s);
|
||
const date = `${a.parsed.d} ${getMonthName(a.parsed.mo)} ${a.parsed.y}`;
|
||
const time = `${a.parsed.hh}:${a.parsed.mm}:${a.parsed.ss}`;
|
||
const click = clickable ? `onclick="handleBadgeClick('${ip||''}','${a.name}')"` : '';
|
||
return `
|
||
<div class="badge ${s} ${clickable?'clickable':''}" ${click}>
|
||
<div class="badge-header">${hl(a.name)}</div>
|
||
<div class="badge-status">${label(s)}</div>
|
||
<div class="badge-timestamp"><div>${date}</div><div>at ${time}</div></div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
function handleBadgeClick(ip, actionName){
|
||
if (!confirm(`Are you sure you want to remove the action "${actionName}" for IP "${ip}"?`)) return;
|
||
removeAction(ip, actionName);
|
||
}
|
||
|
||
function removeAction(ip, action){
|
||
fetch('/delete_netkb_action', {
|
||
method:'POST', headers:{'Content-Type':'application/json'},
|
||
body: JSON.stringify({ ip, action })
|
||
})
|
||
.then(r => { if(!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); })
|
||
.then(j => {
|
||
if (j.status === 'success'){ window.toast?.(j.message || 'Action removed','success'); fetchNetkbData(); }
|
||
else throw new Error(j.message || 'Failed');
|
||
})
|
||
.catch(e => { console.error(e); alert(`Error: ${e.message}`); });
|
||
}
|
||
|
||
function getMonthName(m){ return ['January','February','March','April','May','June','July','August','September','October','November','December'][parseInt(m)-1] }
|
||
|
||
/* -------- Sorting / Filtering / Searching -------- */
|
||
function sortBy(key){
|
||
if (currentSort === key) sortOrder = -sortOrder; else { currentSort = key; sortOrder = 1; }
|
||
refreshDisplay();
|
||
}
|
||
function filterBy(criteria, ev){
|
||
ev?.stopPropagation?.();
|
||
currentFilter = (currentFilter === criteria) ? null : criteria;
|
||
refreshDisplay();
|
||
}
|
||
|
||
const norm = v => (v ?? '').toString().toLowerCase();
|
||
function matchesSearch(item){
|
||
if (!searchTerm) return true;
|
||
const q = searchTerm;
|
||
if (norm(item.hostname).includes(q)) return true;
|
||
if (norm(item.ip).includes(q)) return true;
|
||
if (norm(item.mac).includes(q)) return true;
|
||
if (norm(item.vendor).includes(q)) return true;
|
||
if (norm(item.essid).includes(q)) return true;
|
||
if (Array.isArray(item.ports) && item.ports.some(p => norm(p).includes(q))) return true;
|
||
if (Array.isArray(item.actions) && item.actions.some(a => norm(a?.name).includes(q))) return true;
|
||
return false;
|
||
}
|
||
|
||
/* -------- Modes + Toolbar sync -------- */
|
||
function isMobile(){ return window.matchMedia('(max-width: 720px)').matches }
|
||
|
||
function setView(mode){
|
||
// En mobile, on interdit 'grid' → convertit en 'list'
|
||
if (isMobile() && mode === 'grid') mode = 'list';
|
||
viewMode = mode;
|
||
|
||
const cards = document.getElementById('card-container');
|
||
const table = document.getElementById('table-container');
|
||
if (mode === 'table'){
|
||
cards.classList.add('hidden');
|
||
table.classList.remove('hidden');
|
||
} else {
|
||
table.classList.add('hidden');
|
||
cards.classList.remove('hidden');
|
||
}
|
||
document.querySelectorAll('#viewSeg button').forEach(b=>{
|
||
b.setAttribute('aria-pressed', String(b.dataset.view===mode));
|
||
});
|
||
|
||
setPref('netkb:view', mode);
|
||
refreshDisplay();
|
||
}
|
||
|
||
function setOffline(on){
|
||
showNotAlive = !!on;
|
||
const sw = document.getElementById('offlineSwitch');
|
||
sw.dataset.on = String(on);
|
||
document.getElementById('toggleOffline').checked = on;
|
||
setPref('netkb:offline', String(on));
|
||
refreshDisplay();
|
||
}
|
||
|
||
/* -------- Paint orchestrator -------- */
|
||
function refreshDisplay(){
|
||
let data = [...originalData];
|
||
|
||
// search
|
||
if (searchTerm){
|
||
data = data.filter(matchesSearch);
|
||
}
|
||
|
||
// filters
|
||
if (currentFilter){
|
||
data = data.filter(item => {
|
||
switch(currentFilter){
|
||
case 'hasActions': return item.actions && item.actions.some(a => a && a.status);
|
||
case 'hasPorts': return item.ports && item.ports.some(Boolean);
|
||
case 'toggleAlive':return !item.alive;
|
||
default: return true;
|
||
}
|
||
});
|
||
}
|
||
|
||
// sort
|
||
if (currentSort){
|
||
const ipToNumber = ip => !ip ? 0 : ip.split('.').reduce((a,p)=>(a<<8)+(+p||0),0);
|
||
data.sort((a,b)=>{
|
||
if (currentSort==='ports'){
|
||
const ap=(a.ports?.filter(Boolean).length)||0, bp=(b.ports?.filter(Boolean).length)||0;
|
||
return sortOrder*(ap-bp);
|
||
}
|
||
if (currentSort==='ip'){ return sortOrder*(ipToNumber(a.ip)-ipToNumber(b.ip)); }
|
||
const av=(a[currentSort]||'').toString(), bv=(b[currentSort]||'').toString();
|
||
return sortOrder*av.localeCompare(bv, undefined, {numeric:true});
|
||
});
|
||
}
|
||
|
||
// render
|
||
if (viewMode === 'table') renderTable(data);
|
||
else renderCards(data);
|
||
}
|
||
|
||
/* -------- Search UI logic -------- */
|
||
const btnSearch = document.getElementById('btnSearch');
|
||
const pop = document.getElementById('searchPop');
|
||
const input = document.getElementById('searchInput');
|
||
|
||
btnSearch.addEventListener('click', () => {
|
||
pop.classList.toggle('show');
|
||
if (pop.classList.contains('show')){
|
||
input.focus();
|
||
input.select();
|
||
}
|
||
});
|
||
document.addEventListener('click', (e)=>{
|
||
if (!pop.contains(e.target) && !btnSearch.contains(e.target)){
|
||
pop.classList.remove('show');
|
||
}
|
||
});
|
||
document.addEventListener('keydown', (e)=>{
|
||
if (e.key === 'Escape') pop.classList.remove('show');
|
||
});
|
||
|
||
let searchDebounce;
|
||
input.addEventListener('input', (e)=>{
|
||
clearTimeout(searchDebounce);
|
||
searchDebounce = setTimeout(()=>{
|
||
searchTerm = e.target.value.trim().toLowerCase();
|
||
setPref('netkb:search', e.target.value.trim());
|
||
refreshDisplay();
|
||
}, 120);
|
||
});
|
||
|
||
/* -------- Boot -------- */
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
// listeners
|
||
document.querySelectorAll('#viewSeg button').forEach(btn=>{
|
||
btn.addEventListener('click', ()=> setView(btn.dataset.view));
|
||
});
|
||
document.getElementById('toggleOffline').addEventListener('change', (e)=> setOffline(e.target.checked));
|
||
window.addEventListener('resize', ()=>{ if (isMobile() && viewMode === 'grid') setView('list'); });
|
||
|
||
// restaurer préférences
|
||
const savedView = getPref('netkb:view', isMobile() ? 'list' : 'grid');
|
||
const savedOffline = getPref('netkb:offline', 'false') === 'true';
|
||
const savedSearch = getPref('netkb:search', '');
|
||
if (savedSearch) { searchTerm = savedSearch; input.value = savedSearch; }
|
||
|
||
setView(isMobile() && savedView==='grid' ? 'list' : savedView);
|
||
setOffline(savedOffline);
|
||
|
||
// s'assurer que l'état visuel du switch correspond bien au checkbox initial
|
||
setOffline(document.getElementById('toggleOffline').checked || savedOffline);
|
||
|
||
fetchNetkbData();
|
||
setInterval(fetchNetkbData, 5000);
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|