proxy-monitor/app/templates/index.html

618 lines
37 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

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

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DockVisor — Firewall Monitor</title>
<style>
:root {
--bg: #0a0e14; --bg2: #11161f; --bg3: #1a2130; --bg4: #232c3d;
--border: #1f2937; --border2: #2b3648;
--text: #e6edf3; --text2: #8b98a9; --text3: #5b6675;
--accent: #3b82f6; --accent2: #2563eb;
--green: #22c55e; --yellow: #f59e0b; --red: #ef4444; --cyan: #06b6d4; --purple: #8b5cf6;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; }
body {
background: var(--bg); color: var(--text);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 14px; line-height: 1.5;
display: flex; overflow: hidden;
}
/* ===== SIDEBAR ===== */
.sidebar {
width: 248px; min-width: 248px; height: 100vh;
background: var(--bg2); border-right: 1px solid var(--border);
display: flex; flex-direction: column; padding: 20px 14px;
}
.brand { display: flex; align-items: center; gap: 10px; padding: 4px 8px 20px; }
.brand .logo {
width: 34px; height: 34px; border-radius: 9px;
background: linear-gradient(135deg, var(--accent), var(--purple));
display: flex; align-items: center; justify-content: center; font-weight: 700; color: #fff;
}
.brand .name { font-size: 16px; font-weight: 700; letter-spacing: -0.3px; }
.brand .sub { font-size: 10px; color: var(--text3); text-transform: uppercase; letter-spacing: 1px; }
.nav-group-label { font-size: 10px; text-transform: uppercase; letter-spacing: 1.2px; color: var(--text3); font-weight: 600; margin: 16px 8px 6px; }
.nav-item {
display: flex; align-items: center; gap: 10px; padding: 9px 12px;
border-radius: 8px; cursor: pointer; font-size: 13px; font-weight: 500;
color: var(--text2); transition: all .15s; margin-bottom: 2px; border: 1px solid transparent;
}
.nav-item:hover { background: var(--bg3); color: var(--text); }
.nav-item.active { background: var(--bg4); color: var(--text); border-color: var(--border2); }
.nav-item svg { color: var(--text3); flex-shrink: 0; }
.nav-item.active svg { color: var(--accent); }
.nav-item .badge { margin-left: auto; background: var(--bg4); color: var(--text2); font-size: 10px; padding: 1px 7px; border-radius: 9px; font-weight: 600; }
.nav-item.active .badge { background: rgba(59,130,246,.15); color: var(--accent); }
.servers { margin-top: 4px; }
.server-row { display: flex; align-items: center; gap: 8px; padding: 6px 12px; font-size: 12px; color: var(--text2); }
.dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
.dot.online { background: var(--green); box-shadow: 0 0 6px rgba(34,197,94,.6); }
.dot.offline { background: var(--text3); }
.sidebar-footer { margin-top: auto; padding-top: 14px; border-top: 1px solid var(--border); }
.refresh-row { display: flex; align-items: center; justify-content: space-between; font-size: 11px; color: var(--text3); padding: 0 8px; }
select {
background: var(--bg3); color: var(--text); border: 1px solid var(--border2);
border-radius: 6px; padding: 4px 8px; font-family: inherit; font-size: 12px;
}
/* ===== MAIN ===== */
.main { flex: 1; height: 100vh; overflow-y: auto; display: flex; flex-direction: column; }
.topbar {
position: sticky; top: 0; z-index: 10;
display: flex; align-items: center; justify-content: space-between;
padding: 18px 28px; background: rgba(10,14,20,.9); backdrop-filter: blur(8px);
border-bottom: 1px solid var(--border);
}
.topbar h1 { font-size: 18px; font-weight: 700; letter-spacing: -0.3px; }
.topbar .subtitle { font-size: 12px; color: var(--text3); margin-top: 2px; }
.topbar .actions { display: flex; gap: 8px; align-items: center; }
.clock { font-size: 12px; color: var(--text3); margin-right: 8px; }
.content { padding: 24px 28px; }
/* ===== COMPONENTS ===== */
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 14px; margin-bottom: 22px; }
.card {
background: var(--bg2); border: 1px solid var(--border); border-radius: 12px;
padding: 18px; position: relative; overflow: hidden;
}
.card .label { font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: var(--text3); font-weight: 600; margin-bottom: 6px; }
.card .value { font-size: 26px; font-weight: 700; letter-spacing: -0.5px; }
.card .sub { font-size: 12px; color: var(--text2); margin-top: 4px; }
.card .icon {
position: absolute; top: 14px; right: 14px; width: 34px; height: 34px; border-radius: 9px;
display: flex; align-items: center; justify-content: center; opacity: .9;
}
.icon.blue { background: rgba(59,130,246,.14); color: var(--accent); }
.icon.green { background: rgba(34,197,94,.14); color: var(--green); }
.icon.red { background: rgba(239,68,68,.14); color: var(--red); }
.icon.yellow { background: rgba(245,158,11,.14); color: var(--yellow); }
.icon.cyan { background: rgba(6,182,212,.14); color: var(--cyan); }
.icon.purple { background: rgba(139,92,246,.14); color: var(--purple); }
.section {
background: var(--bg2); border: 1px solid var(--border); border-radius: 12px;
margin-bottom: 20px; overflow: hidden;
}
.section-header { display: flex; justify-content: space-between; align-items: center; padding: 15px 20px; border-bottom: 1px solid var(--border); }
.section-header h3 { font-size: 14px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
.section-header .count { font-size: 11px; color: var(--text3); font-weight: 500; }
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th { text-align: left; padding: 10px 18px; color: var(--text3); font-size: 10px; text-transform: uppercase; letter-spacing: 1px; border-bottom: 1px solid var(--border); font-weight: 600; white-space: nowrap; }
td { padding: 11px 18px; border-bottom: 1px solid var(--bg3); white-space: nowrap; }
tr:last-child td { border-bottom: none; }
tbody tr { transition: background .12s; }
tbody tr:hover { background: var(--bg3); }
tbody tr.flagged { background: rgba(239,68,68,.06); }
.tag { background: var(--bg4); color: var(--text2); padding: 2px 9px; border-radius: 10px; font-size: 11px; font-family: 'SF Mono', Menlo, monospace; }
.badge { padding: 2px 9px; border-radius: 10px; font-size: 11px; font-weight: 600; }
.badge.green { background: rgba(34,197,94,.15); color: var(--green); }
.badge.blue { background: rgba(59,130,246,.15); color: var(--accent); }
.badge.yellow { background: rgba(245,158,11,.15); color: var(--yellow); }
.badge.red { background: rgba(239,68,68,.15); color: var(--red); }
.badge.gray { background: var(--bg4); color: var(--text2); }
.method { font-weight: 600; font-size: 11px; padding: 2px 7px; border-radius: 5px; }
.method.GET { background: rgba(34,197,94,.15); color: var(--green); }
.method.POST { background: rgba(59,130,246,.15); color: var(--accent); }
.method.PUT, .method.PATCH { background: rgba(245,158,11,.15); color: var(--yellow); }
.method.DELETE { background: rgba(239,68,68,.15); color: var(--red); }
.verdict-banner {
display: flex; align-items: center; gap: 14px; padding: 20px;
border-radius: 12px; margin-bottom: 22px; border: 1px solid;
}
.verdict-banner.clean { background: rgba(34,197,94,.07); border-color: rgba(34,197,94,.3); }
.verdict-banner.warn { background: rgba(239,68,68,.07); border-color: rgba(239,68,68,.3); }
.verdict-banner .big { font-size: 20px; font-weight: 700; }
.verdict-banner .msg { font-size: 13px; color: var(--text2); }
.btn {
background: var(--bg3); border: 1px solid var(--border2); color: var(--text);
border-radius: 7px; padding: 6px 13px; cursor: pointer; font-family: inherit;
font-size: 12px; font-weight: 500; transition: all .15s; white-space: nowrap;
}
.btn:hover { background: var(--bg4); }
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.btn-primary:hover { background: var(--accent2); }
.btn-danger { background: var(--red); border-color: var(--red); color: #fff; }
.btn-danger:hover { background: #dc2626; }
.btn-ghost { background: transparent; }
.btn-xs { padding: 4px 10px; font-size: 11px; }
.fw-input {
background: var(--bg3); border: 1px solid var(--border2); border-radius: 7px;
padding: 6px 11px; color: var(--text); font-family: inherit; font-size: 12px; min-width: 180px;
}
.fw-input:focus { outline: none; border-color: var(--accent); }
.empty { padding: 34px; text-align: center; color: var(--text3); font-size: 13px; }
.error-line { font-family: 'SF Mono', Menlo, monospace; font-size: 12px; color: var(--text2); padding: 7px 18px; border-bottom: 1px solid var(--bg3); white-space: pre-wrap; }
.shimmer { color: var(--text3); text-align: center; padding: 36px; animation: pulse 1.2s infinite; font-size: 13px; }
@keyframes pulse { 0%,100% { opacity: .35; } 50% { opacity: 1; } }
.chips { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 20px 16px; }
.footer { text-align: center; padding: 18px; color: var(--text3); font-size: 11px; }
a { color: var(--accent); text-decoration: none; }
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-thumb { background: var(--bg4); border-radius: 4px; }
::-webkit-scrollbar-track { background: transparent; }
</style>
</head>
<body>
<!-- SIDEBAR -->
<aside class="sidebar">
<div class="brand">
<div class="logo">D</div>
<div>
<div class="name">DockVisor</div>
<div class="sub">Firewall Monitor</div>
</div>
</div>
<div class="nav-group-label">Monitor</div>
<div class="nav-item active" id="nav-overview" onclick="switchView('overview')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/></svg>
Overview
</div>
<div class="nav-item" id="nav-firewall" onclick="switchView('firewall')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
Firewall Control
</div>
<div class="nav-item" id="nav-intrusion" onclick="switchView('intrusion')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 8v4l3 3"/></svg>
Intrusion Watch
</div>
<div class="nav-item" id="nav-web" onclick="switchView('web')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
Web Traffic
</div>
<div class="nav-group-label">Servers</div>
<div class="servers" id="sidebar-servers"><div class="server-row" style="color:var(--text3)">Loading…</div></div>
<div class="sidebar-footer">
<div class="refresh-row" style="margin-bottom:8px">
<span>Auto-refresh</span>
<select id="interval" onchange="setInterval(this.value)">
<option value="15">15s</option>
<option value="30" selected>30s</option>
<option value="60">1m</option>
<option value="300">5m</option>
</select>
</div>
<button class="btn btn-ghost btn-xs" onclick="refreshAll()" style="width:100%">↻ Refresh now</button>
</div>
</aside>
<!-- MAIN -->
<main class="main">
<div class="topbar">
<div>
<h1 id="page-title">Overview</h1>
<div class="subtitle" id="page-subtitle">Security & firewall status across all servers</div>
</div>
<div class="actions">
<span class="clock" id="clock"></span>
<button class="btn btn-primary" onclick="refreshAll()">Refresh</button>
</div>
</div>
<div class="content" id="view-container"><div class="shimmer">Loading…</div></div>
</main>
<!-- IP logs modal -->
<div id="ip-modal" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.65);z-index:100;align-items:center;justify-content:center">
<div style="background:var(--bg2);border:1px solid var(--border);border-radius:12px;max-width:720px;width:92%;max-height:80vh;display:flex;flex-direction:column">
<div style="padding:16px 20px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center">
<h3 id="ip-modal-title" style="font-size:15px;font-weight:600">Connection attempts</h3>
<button class="btn btn-ghost btn-xs" onclick="closeIpModal()">✕ Close</button>
</div>
<div id="ip-modal-body" style="padding:16px 20px;overflow-y:auto;font-family:'SF Mono',Menlo,monospace;font-size:12px"></div>
</div>
</div>
<script>
let currentView = 'overview';
let timer = null;
let cachedFirewall = [];
const TITLES = {
overview: ['Overview', 'Security & firewall status across all servers'],
firewall: ['Firewall Control', 'Manage blocks, countries, and IPs per server'],
intrusion: ['Intrusion Watch', 'Live VPN connection attempts — geo-located'],
web: ['Web Traffic', 'Apache proxy requests and client IPs'],
};
function esc(s) { return String(s ?? '').replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
function switchView(v) {
currentView = v;
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
document.getElementById(`nav-${v}`).classList.add('active');
document.getElementById('page-title').textContent = TITLES[v][0];
document.getElementById('page-subtitle').textContent = TITLES[v][1];
renderView();
}
function renderView() {
const c = document.getElementById('view-container');
c.innerHTML = '<div class="shimmer">Loading…</div>';
if (currentView === 'overview') renderOverview();
else if (currentView === 'firewall') loadFirewall();
else if (currentView === 'intrusion') loadIntrusion();
else if (currentView === 'web') loadWeb();
}
function refreshAll() {
document.getElementById('clock').textContent = new Date().toLocaleTimeString();
loadServers();
renderView();
}
async function loadServers() {
try {
const s = await fetch('/api/firewall').then(r => r.json());
cachedFirewall = s;
document.getElementById('sidebar-servers').innerHTML = s.map(x =>
`<div class="server-row"><span class="dot ${x.reachable ? 'online' : 'offline'}"></span>${esc(x.name.split(' ')[0])}</div>`
).join('');
} catch(e) {}
}
function setInterval(sec) {
if (timer) clearInterval(timer);
timer = setInterval(refreshAll, sec * 1000);
}
/* ===== OVERVIEW ===== */
async function renderOverview() {
const c = document.getElementById('view-container');
c.innerHTML = '<div class="shimmer">Loading overview…</div>';
try {
const [status, fw, sec, vpsStats, webStats] = await Promise.all([
fetch('/api/security/status').then(r => r.json()),
fetch('/api/firewall').then(r => r.json()),
fetch('/api/security').then(r => r.json()),
fetch('/api/vps/stats').then(r => r.json()),
fetch('/api/stats').then(r => r.json()),
]);
cachedFirewall = fw;
const clean = status.verdict === 'clean';
const ssh = status.ssh || {};
const nodes = status.headscale_nodes || [];
const nc = status.nextcloud || {};
const fl = sec.flagged || {};
const onlineNodes = nodes.filter(n => n.online).length;
const topCountry = sec.countries && sec.countries[0];
const serverCards = fw.map(s => `
<div class="card">
<div class="icon ${s.reachable ? 'green' : 'red'}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg></div>
<div class="label">${s.reachable ? '🟢' : '🔴'} ${esc(s.name)}</div>
<div class="value">${(s.country_cidrs + s.scanner_ips).toLocaleString()}</div>
<div class="sub">${s.country_block ? '🌍' : ''}${s.country_cidrs.toLocaleString()} CIDRs · ${s.scanner_block ? '🛡️' : ''}${s.scanner_ips} IPs</div>
</div>`).join('');
c.innerHTML = `
<div class="verdict-banner ${clean ? 'clean' : 'warn'}">
<div style="font-size:32px">${clean ? '✅' : '⚠️'}</div>
<div>
<div class="big">${clean ? 'No signs of compromise' : 'Review needed'}</div>
<div class="msg">${(status.warnings || []).join(' · ') || 'All systems verified clean'}</div>
</div>
</div>
<div class="section">
<div class="section-header"><h3>🖥️ Servers</h3><span class="count">${fw.length} monitored</span></div>
<div class="grid" style="padding:18px;margin-bottom:0">${serverCards}</div>
</div>
<div class="grid">
<div class="card"><div class="icon green"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></div><div class="label">VPN Nodes</div><div class="value">${onlineNodes}/${nodes.length}</div><div class="sub">online / authorized</div></div>
<div class="card"><div class="icon blue"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></div><div class="label">SSH Logins</div><div class="value">${ssh.total_accepted ?? 0}</div><div class="sub">${ssh.total_failed ?? 0} failed</div></div>
<div class="card"><div class="icon purple"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg></div><div class="label">Nextcloud Users</div><div class="value">${(nc.users || []).length}</div><div class="sub">${(nc.users || []).join(', ')}</div></div>
<div class="card"><div class="icon red"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg></div><div class="label">Intrusion Attempts</div><div class="value">${(sec.total_attempts || 0).toLocaleString()}</div><div class="sub">${sec.unique_ips || 0} IPs · top: ${topCountry ? esc(topCountry.country) : '—'}</div></div>
<div class="card"><div class="icon yellow"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></div><div class="label">Flagged Attacks</div><div class="value">${fl.attempts ?? 0}</div><div class="sub">${(fl.countries || []).join(', ')}</div></div>
<div class="card"><div class="icon cyan"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg></div><div class="label">VPS Web Requests</div><div class="value">${(vpsStats.total || 0).toLocaleString()}</div><div class="sub">${vpsStats.errors_4xx || 0} 4xx · ${vpsStats.errors_5xx || 0} 5xx</div></div>
</div>
<div class="section">
<div class="section-header"><h3>🛡️ Security Details</h3><span class="count">live</span></div>
<div class="table-wrap"><table>
<thead><tr><th>Check</th><th>Detail</th></tr></thead>
<tbody>
<tr><td style="font-weight:500">SSH source IPs</td><td style="color:var(--text2)">${(ssh.accepted || []).slice(0,6).map(a => `${esc(a.ip)} ×${a.count}`).join(', ') || 'none'}</td></tr>
<tr><td style="font-weight:500">VPN nodes</td><td style="color:var(--text2)">${nodes.map(n => `${esc(n.given_name || n.name)}${(n.ip_addresses || []).map(esc).join(', ')}`).join(' | ') || 'none'}</td></tr>
<tr><td style="font-weight:500">Top attacker countries</td><td style="color:var(--text2)">${(sec.countries || []).slice(0,6).map(x => `${esc(x.country)} (${x.count})`).join(', ') || 'none'}</td></tr>
<tr><td style="font-weight:500">Nextcloud failed logins</td><td style="color:var(--text2)">${(nc.failed_logins || []).slice(0,6).map(f => `${esc(f.ip || '?')} (${esc(f.user || '?')})`).join(', ') || 'none'}</td></tr>
</tbody>
</table></div>
</div>
`;
} catch(e) {
c.innerHTML = `<div class="empty">❌ ${esc(e.message)}</div>`;
}
}
/* ===== FIREWALL ===== */
async function loadFirewall() {
const c = document.getElementById('view-container');
c.innerHTML = '<div class="shimmer">Loading firewall…</div>';
try {
const servers = await fetch('/api/firewall').then(r => r.json());
cachedFirewall = servers;
renderFirewall(servers, c);
} catch(e) { c.innerHTML = `<div class="empty">❌ ${esc(e.message)}</div>`; }
}
function renderFirewall(servers, c) {
c.innerHTML = servers.map((s, i) => `
<div class="section">
<div class="section-header">
<h3>${s.reachable ? '🟢' : '🔴'} ${esc(s.name)} <span style="font-weight:400;color:var(--text3);font-size:12px">(${esc(s.host)})</span></h3>
<span class="count">${s.reachable ? '' : 'unreachable'}</span>
</div>
<div style="padding:18px 20px">
<div class="grid" style="margin-bottom:16px">
<div class="card"><div class="label">Country Block</div><div class="value" style="color:${s.country_block ? 'var(--green)' : 'var(--text3)'}">${s.country_block ? 'ACTIVE' : 'OFF'}</div><div class="sub">${s.country_cidrs} CIDRs</div></div>
<div class="card"><div class="label">Scanner Block</div><div class="value" style="color:${s.scanner_block ? 'var(--green)' : 'var(--text3)'}">${s.scanner_block ? 'ACTIVE' : 'OFF'}</div><div class="sub">${s.scanner_ips} IPs</div></div>
<div class="card"><div class="label">Total Blocked</div><div class="value">${s.country_cidrs + s.scanner_ips}</div><div class="sub">combined rules</div></div>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px">
<button class="btn ${s.country_block ? 'btn-danger' : 'btn-primary'}" onclick="toggleFw(${i},'country',${!s.country_block})">${s.country_block ? '⏸ Disable Country' : '▶ Enable Country'}</button>
<button class="btn ${s.scanner_block ? 'btn-danger' : 'btn-primary'}" onclick="toggleFw(${i},'scanner',${!s.scanner_block})">${s.scanner_block ? '⏸ Disable Scanner' : '▶ Enable Scanner'}</button>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<input id="fw-ip-${i}" class="fw-input" placeholder="IP to block/unblock">
<button class="btn" onclick="blockIp(${i})">Block IP</button>
<button class="btn" onclick="unblockIp(${i})">Unblock IP</button>
</div>
</div>
</div>
`).join('') || '<div class="empty">No servers configured</div>';
}
async function toggleFw(i, type, enabled) {
await fetch(`/api/firewall/${i}/toggle`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ type, enabled }) });
loadFirewall();
}
async function blockIp(i) {
const ip = document.getElementById(`fw-ip-${i}`).value.trim();
if (!ip) return;
await fetch(`/api/firewall/${i}/block`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ ip }) });
loadFirewall();
}
async function unblockIp(i) {
const ip = document.getElementById(`fw-ip-${i}`).value.trim();
if (!ip) return;
await fetch(`/api/firewall/${i}/unblock`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ ip }) });
loadFirewall();
}
/* ===== INTRUSION ===== */
async function loadIntrusion() {
const c = document.getElementById('view-container');
c.innerHTML = '<div class="shimmer">Scanning intrusion attempts…</div>';
try {
const sec = await fetch('/api/security').then(r => r.json());
renderIntrusion(sec, c);
} catch(e) { c.innerHTML = `<div class="empty">❌ ${esc(e.message)}</div>`; }
}
function renderIntrusion(sec, c) {
if (sec.error) { c.innerHTML = `<div class="empty">⚠️ ${esc(sec.error)}</div>`; return; }
const top = sec.countries[0];
const fl = sec.flagged || {};
c.innerHTML = `
<div class="grid">
<div class="card"><div class="icon red"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg></div><div class="label">Failed Attempts</div><div class="value">${sec.total_attempts}</div><div class="sub">last ${sec.window_hours}h</div></div>
<div class="card"><div class="icon blue"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg></div><div class="label">Unique Source IPs</div><div class="value">${sec.unique_ips}</div><div class="sub">all failed handshakes</div></div>
<div class="card"><div class="icon yellow"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></div><div class="label">Top Country</div><div class="value">${top ? esc(top.country) : '—'}</div><div class="sub">${top ? top.count + ' hits' : ''}</div></div>
<div class="card"><div class="icon red"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></div><div class="label">Flagged Attempts</div><div class="value">${fl.attempts ?? 0}</div><div class="sub">${fl.ips ?? 0} IPs · ${(fl.countries || []).join(', ')}</div></div>
</div>
<div class="section">
<div class="section-header"><h3>🌍 Source Countries</h3><span class="count">${sec.countries.length} distinct</span></div>
<div class="chips">${sec.countries.slice(0,24).map(x => `<span class="tag">${esc(x.country)} · ${x.count}</span>`).join('')}</div>
</div>
<div class="section">
<div class="section-header"><h3>🚨 Intrusion Sources</h3><span class="count">${sec.unique_ips} IPs · <a href="/api/threats/export" download style="color:var(--accent)">⬇ Export CSV</a></span></div>
<div class="table-wrap"><table>
<thead><tr><th>Source IP</th><th>Country</th><th>Organization</th><th>Attempts</th><th>First</th><th>Last</th></tr></thead>
<tbody>${sec.attempts.map(a => `
<tr class="${a.flagged ? 'flagged' : ''}">
<td><span class="tag" style="cursor:pointer;text-decoration:underline;color:var(--accent)" onclick="showIpLogs('${a.ip}')">${esc(a.ip)}</span></td>
<td>${a.flagged ? '🔴 ' : ''}${a.cc ? `<span class="badge yellow">${esc(a.cc)}</span> ` : ''}${esc(a.country)}</td>
<td style="color:var(--text2);max-width:260px;overflow:hidden;text-overflow:ellipsis">${esc(a.org)}</td>
<td><b>${a.count}</b></td>
<td style="color:var(--text3);font-size:12px">${esc(a.first)}</td>
<td style="color:var(--text3);font-size:12px">${esc(a.last)}</td>
</tr>`).join('') || '<tr><td class="empty" colspan="6">No intrusion attempts</td></tr>'}
</tbody>
</table></div>
</div>
`;
}
/* ===== WEB TRAFFIC ===== */
async function loadWeb() {
const c = document.getElementById('view-container');
c.innerHTML = '<div class="shimmer">Loading web traffic…</div>';
try {
const [vpsStats, vpsLogs, stats, logs, errors] = await Promise.all([
fetch('/api/vps/stats').then(r => r.json()),
fetch('/api/vps/logs?lines=300').then(r => r.json()),
fetch('/api/stats').then(r => r.json()),
fetch('/api/logs?lines=200').then(r => r.json()),
fetch('/api/errors?lines=20').then(r => r.json()),
]);
renderWeb(vpsStats, vpsLogs, stats, logs, errors, c);
} catch(e) { c.innerHTML = `<div class="empty">❌ ${esc(e.message)}</div>`; }
}
function statusBadge(s) {
const cls = s.startsWith('5') ? 'red' : s.startsWith('4') ? 'yellow' : s.startsWith('3') ? 'blue' : 'green';
return `<span class="badge ${cls}">${s}</span>`;
}
function renderWeb(vpsStats, vpsLogs, stats, logs, errors, c) {
const vs = vpsStats.error ? null : vpsStats;
const vr = Array.isArray(vpsLogs) ? vpsLogs : [];
const vhosts = stats.vhosts || [];
const hits = Object.fromEntries((stats.per_vhost || []).map(v => [v.vhost, v.count]));
c.innerHTML = `
<div class="section">
<div class="section-header"><h3>🌐 Public Web Server — ubuntu VPS (100.64.0.2)</h3><span class="count">Apache</span></div>
${vs ? `
<div class="grid" style="padding:18px 20px 0">
<div class="card"><div class="icon blue"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg></div><div class="label">Recent Requests</div><div class="value">${vs.total}</div><div class="sub">sampled</div></div>
<div class="card"><div class="icon green"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></div><div class="label">Unique Clients</div><div class="value">${vs.unique_ips}</div><div class="sub">distinct IPs</div></div>
<div class="card"><div class="icon yellow"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg></div><div class="label">4xx</div><div class="value">${vs.errors_4xx}</div><div class="sub">client errors</div></div>
<div class="card"><div class="icon red"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg></div><div class="label">5xx</div><div class="value">${vs.errors_5xx}</div><div class="sub">server errors</div></div>
</div>
` : `<div class="empty">❌ ${esc(vpsStats.error)}</div>`}
<div class="table-wrap"><table>
<thead><tr><th>Time</th><th>Client IP</th><th>Method</th><th>Path</th><th>Status</th><th>Bytes</th></tr></thead>
<tbody>${vr.map(r => `<tr>
<td style="color:var(--text3);font-size:12px">${esc(r.ts.split(' ')[0])}</td>
<td><span class="tag" style="cursor:pointer;text-decoration:underline;color:var(--accent)" onclick="showApacheLogs('${r.ip}')">${esc(r.ip)}</span></td>
<td><span class="method ${r.method}">${r.method}</span></td>
<td style="max-width:320px;overflow:hidden;text-overflow:ellipsis">${esc(r.path)}</td>
<td>${statusBadge(r.status)}</td>
<td style="color:var(--text2)">${esc(r.bytes)}</td>
</tr>`).join('') || '<tr><td class="empty" colspan="6">No requests</td></tr>'}</tbody>
</table></div>
</div>
<div class="section">
<div class="section-header"><h3>🔀 Internal Proxy — ubuntu-web (.65)</h3><span class="count">apache-proxy-manager</span></div>
<div class="table-wrap"><table>
<thead><tr><th>Domain</th><th>Target</th><th>SSL</th><th>Hits</th></tr></thead>
<tbody>${vhosts.map(v => `<tr><td style="font-weight:500">${esc(v.domain)}</td><td><span class="tag">${esc(v.target)}</span></td><td>${v.ssl ? '<span class="badge green">HTTPS</span>' : '<span class="badge blue">HTTP</span>'}</td><td>${hits[v.domain] || 0}</td></tr>`).join('') || '<tr><td class="empty" colspan="4">No proxies.json</td></tr>'}</tbody>
</table></div>
<div class="table-wrap"><table>
<thead><tr><th>Time</th><th>Client IP</th><th>Method</th><th>Path</th><th>Status</th></tr></thead>
<tbody>${(logs || []).map(r => `<tr>
<td style="color:var(--text3);font-size:12px">${esc(r.ts.split(' ')[0])}</td>
<td><span class="tag" style="cursor:pointer;text-decoration:underline;color:var(--accent)" onclick="showApacheLogs('${r.ip}')">${esc(r.ip)}</span></td>
<td><span class="method ${r.method}">${r.method}</span></td>
<td style="max-width:320px;overflow:hidden;text-overflow:ellipsis">${esc(r.path)}</td>
<td>${statusBadge(r.status)}</td>
</tr>`).join('') || '<tr><td class="empty" colspan="5">No requests</td></tr>'}</tbody>
</table></div>
</div>
<div class="section">
<div class="section-header"><h3>⚠️ Internal Error Log</h3><span class="count">${errors.length} lines</span></div>
${errors.length ? errors.map(e => `<div class="error-line">${esc(e)}</div>`).join('') : '<div class="empty">No errors</div>'}
</div>
`;
}
function geoHeader(ip, g) {
const geo = (g && g.country) ? `<span class="tag">${esc(g.country)}</span> <span class="tag">${esc(g.org || g.as || '')}</span>` : '';
return `<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;gap:10px;flex-wrap:wrap">
<div>${geo}</div>
<button class="btn btn-danger btn-xs" onclick="blockIpModal('${ip}')">🛡️ Block IP</button>
</div>`;
}
async function blockIpModal(ip) {
try {
const r = await fetch(`/api/block/${ip}`, { method: 'POST' }).then(r => r.json());
const msg = r.map(x => `${x.server}: ${x.result.status || x.result.error || '?'}`).join(' · ');
document.getElementById('ip-modal-body').insertAdjacentHTML('afterbegin',
`<div style="padding:10px 14px;border-radius:8px;background:rgba(34,197,94,.12);color:var(--green);margin-bottom:12px;font-size:12px">🛡️ ${esc(msg)}</div>`);
} catch(e) {
document.getElementById('ip-modal-body').insertAdjacentHTML('afterbegin', `<div class="empty">❌ ${esc(e.message)}</div>`);
}
}
/* ===== IP LOGS MODAL ===== */
async function showIpLogs(ip) {
document.getElementById('ip-modal-title').textContent = `Connection attempts from ${ip}`;
document.getElementById('ip-modal-body').innerHTML = '<div class="shimmer">Loading…</div>';
document.getElementById('ip-modal').style.display = 'flex';
try {
const [d, g] = await Promise.all([
fetch(`/api/security/ip/${ip}`).then(r => r.json()),
fetch(`/api/geo/${ip}`).then(r => r.json()).catch(() => ({})),
]);
if (d.error) {
document.getElementById('ip-modal-body').innerHTML = `<div class="empty">❌ ${esc(d.error)}</div>`;
return;
}
const rows = (d.attempts || []).map(a =>
`<div style="padding:5px 0;border-bottom:1px solid var(--bg3)"><span style="color:var(--text3)">${esc(a.time)}</span> — <span style="color:var(--red)">${esc(a.event)}</span></div>`
).join('');
document.getElementById('ip-modal-body').innerHTML =
`${geoHeader(ip, g)}<div style="margin-bottom:12px;color:var(--text2)"><b>${d.count}</b> attempts (last 7 days, max 500 shown)</div>${rows || '<div class="empty">No attempts found</div>'}`;
} catch(e) {
document.getElementById('ip-modal-body').innerHTML = `<div class="empty">❌ ${esc(e.message)}</div>`;
}
}
function closeIpModal() {
document.getElementById('ip-modal').style.display = 'none';
}
async function showApacheLogs(ip) {
document.getElementById('ip-modal-title').textContent = `Apache requests from ${ip}`;
document.getElementById('ip-modal-body').innerHTML = '<div class="shimmer">Loading…</div>';
document.getElementById('ip-modal').style.display = 'flex';
try {
const [local, vps, g] = await Promise.all([
fetch(`/api/logs/ip/${ip}`).then(r => r.json()),
fetch(`/api/vps/logs/ip/${ip}`).then(r => r.json()),
fetch(`/api/geo/${ip}`).then(r => r.json()).catch(() => ({})),
]);
const all = [...(Array.isArray(local) ? local : []), ...(Array.isArray(vps) ? vps : [])];
const rows = all.map(r =>
`<div style="padding:5px 0;border-bottom:1px solid var(--bg3)"><span style="color:var(--text3)">${esc(r.ts)}</span> — <span class="method ${r.method}" style="font-size:11px">${r.method}</span> <span style="color:var(--accent)">${esc(r.path)}</span> <span class="badge ${r.status.startsWith('5') ? 'red' : r.status.startsWith('4') ? 'yellow' : 'green'}">${r.status}</span> <span style="color:var(--text3)">${esc(r.bytes || '')}</span></div>`
).join('');
document.getElementById('ip-modal-body').innerHTML =
`${geoHeader(ip, g)}<div style="margin-bottom:12px;color:var(--text2)"><b>${all.length}</b> requests</div>${rows || '<div class="empty">No Apache requests for this IP</div>'}`;
} catch(e) {
document.getElementById('ip-modal-body').innerHTML = `<div class="empty">❌ ${esc(e.message)}</div>`;
}
}
/* ===== INIT ===== */
document.getElementById('clock').textContent = new Date().toLocaleTimeString();
setInterval(() => { document.getElementById('clock').textContent = new Date().toLocaleTimeString(); }, 1000);
loadServers();
renderView();
setInterval(30);
</script>
</body>
</html>