Files
bot-api/public/index.html

775 lines
26 KiB
HTML
Raw Normal View History

<!DOCTYPE html>
<html lang="en" id="html-root">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BOT // NETWORK INTELLIGENCE</title>
<style>
:root {
--bg: #00060d;
--bg2: #010c18;
--cyan: #00d4ff;
--cyan2: #00aacc;
--dim: #3399bb;
--dim2: #1a6688;
--muted: #001a28;
--border: #003a55;
--red: #ff4040;
--amber: #ffaa00;
--white: #d4f4ff;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { scrollbar-color: var(--dim2) var(--bg); scrollbar-width: thin; }
body {
background: var(--bg);
color: var(--cyan);
font-family: 'Courier New', 'Lucida Console', monospace;
font-size: 13px;
line-height: 1.5;
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
body::after {
content: '';
position: fixed;
inset: 0;
background: repeating-linear-gradient(
0deg, transparent, transparent 2px,
rgba(0,0,0,.06) 2px, rgba(0,0,0,.06) 4px
);
pointer-events: none;
z-index: 9999;
}
.glow { text-shadow: 0 0 12px var(--cyan), 0 0 24px var(--cyan); }
.glow-sm { text-shadow: 0 0 6px var(--cyan); }
header {
border-bottom: 1px solid var(--border);
padding: 8px 20px;
display: flex;
align-items: center;
justify-content: space-between;
background: var(--bg2);
flex-shrink: 0;
flex-wrap: wrap;
gap: 6px;
}
.logo {
font-size: 16px;
font-weight: bold;
letter-spacing: 4px;
color: var(--cyan);
}
.logo em { color: var(--amber); font-style: normal; }
.header-right {
display: flex;
align-items: center;
gap: 14px;
font-size: 11px;
color: var(--cyan2);
letter-spacing: 1px;
}
.live-dot {
display: inline-block;
width: 8px; height: 8px;
background: var(--red);
border-radius: 50%;
animation: blink 1s step-end infinite;
box-shadow: 0 0 6px var(--red);
vertical-align: middle;
margin-right: 4px;
}
@keyframes blink { 50% { opacity: 0; } }
.lang-switcher { display: flex; gap: 3px; align-items: center; }
.lang-btn {
background: none; border: none; cursor: pointer;
padding: 1px 2px; line-height: 1;
transition: opacity .15s, font-size .15s;
}
.lang-btn.active { font-size: 20px; opacity: 1; }
.lang-btn.inactive { font-size: 14px; opacity: 0.35; }
main {
flex: 1;
min-height: 0;
padding: 10px 14px;
display: flex;
flex-direction: column;
gap: 8px;
overflow: hidden;
}
.stats-row {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 8px;
flex-shrink: 0;
}
.stat-card {
background: var(--bg2);
border: 1px solid var(--border);
padding: 10px 10px 8px;
text-align: center;
position: relative;
transition: border-color .2s;
}
.stat-card::before {
content: ''; position: absolute;
top: 0; left: 0; right: 0; height: 2px;
background: var(--muted);
transition: background .2s, box-shadow .2s;
}
.stat-card:hover { border-color: var(--dim2); }
.stat-card:hover::before { background: var(--cyan); box-shadow: 0 0 8px var(--cyan); }
.stat-num {
font-size: 26px; font-weight: bold;
letter-spacing: 2px; line-height: 1.1;
color: var(--cyan);
}
.stat-lbl {
font-size: 9px; letter-spacing: 2px;
text-transform: uppercase;
color: var(--dim);
margin-top: 3px;
}
#top-target {
background: var(--bg2);
border: 1px solid #001a33;
border-left: 3px solid var(--amber);
padding: 7px 14px;
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
flex-shrink: 0;
}
#top-target .tt-label { font-size: 10px; letter-spacing: 2px; color: var(--dim2); }
#top-target .tt-form { font-size: 14px; font-weight: bold; color: var(--amber); text-shadow: 0 0 10px var(--amber); }
#top-target .tt-hits { font-size: 11px; color: var(--cyan2); }
#top-target .tt-pct { font-size: 11px; color: var(--dim); }
.content-grid {
display: grid;
grid-template-columns: 1fr 420px;
gap: 8px;
flex: 1;
min-height: 0;
overflow: hidden;
}
.left-col {
display: flex;
flex-direction: column;
gap: 8px;
overflow-y: auto;
min-height: 0;
}
.left-col::-webkit-scrollbar { width: 3px; }
.left-col::-webkit-scrollbar-track { background: var(--bg); }
.left-col::-webkit-scrollbar-thumb { background: var(--dim2); }
.right-col {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
overflow: hidden;
}
.panel { background: var(--bg2); border: 1px solid var(--border); }
.panel-hdr {
padding: 6px 12px;
border-bottom: 1px solid var(--border);
font-size: 10px; letter-spacing: 2px;
color: var(--amber);
display: flex; align-items: center; justify-content: space-between;
}
.panel-body { padding: 10px 12px; }
#chart { width: 100%; height: 72px; display: block; }
.bars-2col { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.bars-3col { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 14px; }
.ua-list { list-style: none; display: flex; flex-direction: column; gap: 5px; }
.ua-row { display: flex; align-items: center; gap: 8px; font-size: 11px; }
.ua-str { flex: 1; color: var(--dim); font-family: monospace; white-space: nowrap;
overflow: hidden; text-overflow: ellipsis; }
.ua-hits { color: var(--cyan2); font-weight: 700; width: 36px; text-align: right; flex-shrink: 0; }
.bar-section-title { font-size: 10px; letter-spacing: 2px; color: var(--amber); margin-bottom: 6px; }
.bar-list { list-style: none; }
.bar-item {
display: grid;
grid-template-columns: 140px 1fr 50px;
align-items: center;
gap: 6px; padding: 2px 0; font-size: 11px;
}
.bar-lbl { color: var(--white); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bar-track { background: var(--muted); height: 6px; }
.bar-fill { background: var(--cyan); height: 100%; transition: width .5s ease; }
.bar-fill-amber { background: var(--amber); }
.bar-cnt { color: var(--dim); font-size: 11px; text-align: right; }
.feed-panel {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
#feed {
flex: 1;
overflow-y: auto;
padding: 6px 12px;
font-size: 11px;
line-height: 1.6;
min-height: 0;
}
#feed::-webkit-scrollbar { width: 3px; }
#feed::-webkit-scrollbar-track { background: var(--bg); }
#feed::-webkit-scrollbar-thumb { background: var(--dim2); }
.feed-row {
display: grid;
grid-template-columns: 58px 110px auto;
gap: 5px;
border-bottom: 1px solid var(--muted);
padding: 1px 0;
align-items: start;
}
.feed-ts { color: var(--dim2); }
.feed-ip { color: var(--amber); }
.feed-bot { color: var(--cyan2); }
.feed-action { font-weight: bold; }
.feed-action.blocked { color: var(--red); }
.feed-action.rate_limited { color: var(--amber); }
.feed-action.observed { color: var(--cyan2); }
.feed-local { font-size: 9px; font-weight: 700; letter-spacing: .5px;
color: var(--bg); background: var(--cyan2); border-radius: 3px;
padding: 1px 5px; margin-left: 4px; vertical-align: middle; }
.feed-reason { color: var(--dim); font-size: 10px; }
.feed-geo { color: var(--dim); font-size: 10px; }
.feed-footer {
padding: 5px 12px;
border-top: 1px solid var(--border);
font-size: 10px; color: var(--dim);
display: flex; justify-content: space-between;
flex-shrink: 0;
}
.cursor::after { content: '█'; animation: blink 1s step-end infinite; }
.atk-panel {
flex-shrink: 0;
max-height: 260px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.atk-scroll { overflow-y: auto; flex: 1; }
.atk-scroll::-webkit-scrollbar { width: 3px; }
.atk-scroll::-webkit-scrollbar-track { background: var(--bg); }
.atk-scroll::-webkit-scrollbar-thumb { background: var(--dim2); }
.atk-table { width: 100%; border-collapse: collapse; font-size: 11px; }
.atk-table th {
padding: 5px 10px;
text-align: left; color: var(--amber);
font-size: 9px; letter-spacing: 2px;
border-bottom: 1px solid var(--border);
white-space: nowrap;
position: sticky; top: 0; background: var(--bg2);
}
.atk-table td { padding: 4px 10px; border-bottom: 1px solid var(--muted); }
.atk-table tr:hover td { background: var(--muted); }
.atk-rank { color: var(--dim2); font-size: 10px; }
.atk-ip { color: var(--amber); font-weight: bold; }
.atk-hits { color: var(--cyan); font-weight: bold; }
.atk-asn { color: var(--dim); font-size: 10px; max-width: 90px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mini-bar { height: 6px; background: var(--muted); min-width: 40px; width: 100%; }
.mini-fill { background: var(--cyan); height: 100%; box-shadow: 0 0 4px var(--cyan); transition: width .5s; }
footer {
border-top: 1px solid var(--border);
padding: 6px 20px;
font-size: 10px; color: var(--dim2);
display: flex; justify-content: space-between; align-items: center;
letter-spacing: 1px; flex-shrink: 0; flex-wrap: wrap; gap: 4px;
}
footer a { color: var(--dim); text-decoration: none; }
footer a:hover { color: var(--cyan2); }
.footer-eu { display: flex; align-items: center; gap: 5px; }
@media (max-width: 1100px) {
body { overflow: auto; height: auto; }
.stats-row { grid-template-columns: repeat(3, 1fr); }
.content-grid { grid-template-columns: 1fr; height: auto; }
.left-col { overflow: visible; }
.right-col { height: 700px; }
.feed-panel { flex: 1; }
}
@media (max-width: 640px) {
.stats-row { grid-template-columns: 1fr 1fr; }
.bars-2col, .bars-3col { grid-template-columns: 1fr; }
.bar-item { grid-template-columns: 100px 1fr 42px; }
}
</style>
</head>
<body>
<header>
<div class="logo glow">[BOT<em>]</em> // NETWORK INTELLIGENCE</div>
<div class="header-right">
<span id="clock">--:--:--</span>
<span><span class="live-dot"></span><span data-i18n="live_feed">LIVE FEED</span></span>
<div class="lang-switcher">
<button class="lang-btn" data-lang="en" onclick="setLang('en')" title="English">🇬🇧</button>
<button class="lang-btn" data-lang="es" onclick="setLang('es')" title="Español">🇪🇸</button>
<button class="lang-btn" data-lang="ro" onclick="setLang('ro')" title="Română">🇷🇴</button>
</div>
</div>
</header>
<main>
<div class="stats-row">
<div class="stat-card">
<div class="stat-num glow" id="s-total"></div>
<div class="stat-lbl" data-i18n="stat_total">TOTAL BLOCKED</div>
</div>
<div class="stat-card">
<div class="stat-num glow" id="s-today"></div>
<div class="stat-lbl" data-i18n="stat_today">TODAY</div>
</div>
<div class="stat-card">
<div class="stat-num" id="s-7d"></div>
<div class="stat-lbl" data-i18n="stat_7d">LAST 7 DAYS</div>
</div>
<div class="stat-card">
<div class="stat-num" id="s-30d"></div>
<div class="stat-lbl" data-i18n="stat_30d">LAST 30 DAYS</div>
</div>
<div class="stat-card">
<div class="stat-num" id="s-rl"></div>
<div class="stat-lbl" data-i18n="stat_rl">RATE LIMITED</div>
</div>
<div class="stat-card">
<div class="stat-num glow" id="s-sites"></div>
<div class="stat-lbl" data-i18n="stat_sites">SITES REPORTING</div>
</div>
</div>
<div id="top-target">
<span class="tt-label" data-i18n="top_target_label">▶ MOST ACTIVE BOT TYPE (30D):</span>
<span class="tt-form" id="tt-form"></span>
<span class="tt-hits" id="tt-hits"></span>
<span class="tt-pct" id="tt-pct"></span>
</div>
<div class="content-grid">
<div class="left-col">
<div class="panel">
<div class="panel-hdr">
<span data-i18n="chart_title">▶ 24H ACTIVITY TREND</span>
<span id="chart-peak" style="color:var(--dim);font-size:11px"></span>
</div>
<div class="panel-body" style="padding:8px 10px">
<canvas id="chart"></canvas>
</div>
</div>
<div class="panel">
<div class="panel-hdr" data-i18n="breakdown_title">▶ BOT BREAKDOWN // LAST 30 DAYS</div>
<div class="panel-body">
<div class="bars-2col">
<div>
<div class="bar-section-title" data-i18n="bot_types">BOT TYPES</div>
<ul class="bar-list" id="bars-bots"></ul>
</div>
<div>
<div class="bar-section-title" data-i18n="ua_family">UA FAMILIES</div>
<ul class="bar-list" id="bars-ua"></ul>
</div>
</div>
</div>
</div>
<div class="panel">
<div class="panel-hdr" data-i18n="actions_title">▶ ACTIONS + REASONS // LAST 30 DAYS</div>
<div class="panel-body">
<div class="bars-2col">
<div>
<div class="bar-section-title" data-i18n="actions">ACTIONS</div>
<ul class="bar-list" id="bars-actions"></ul>
</div>
<div>
<div class="bar-section-title" data-i18n="reasons">REASONS</div>
<ul class="bar-list" id="bars-reasons"></ul>
</div>
</div>
</div>
</div>
<div class="panel">
<div class="panel-hdr" data-i18n="ua_title">▶ TOP USER AGENTS // LAST 30 DAYS</div>
<div class="panel-body">
<ul class="ua-list" id="ua-list"></ul>
</div>
</div>
</div>
<div class="right-col">
<div class="panel feed-panel">
<div class="panel-hdr">
<span data-i18n="feed_title">▶ LIVE BOT FEED</span>
<span id="feed-count" style="color:var(--dim);font-size:11px">0 <span data-i18n="events">events</span></span>
</div>
<div id="feed"></div>
<div class="feed-footer">
<span class="cursor"></span>
<span id="feed-status" data-i18n="connecting">connecting…</span>
</div>
</div>
<div class="panel atk-panel">
<div class="panel-hdr" data-i18n="attackers_title">▶ TOP OFFENDERS // LAST 30 DAYS</div>
<div class="atk-scroll">
<table class="atk-table">
<thead>
<tr>
<th>#</th>
<th data-i18n="col_ip">IP ADDRESS</th>
<th data-i18n="col_hits">HITS</th>
<th data-i18n="col_asn">AS</th>
</tr>
</thead>
<tbody id="atk-body">
<tr><td colspan="4" style="text-align:center;padding:14px;color:var(--dim)" data-i18n="loading">Loading…</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</main>
<footer>
<span data-i18n="footer_copy">BOT NETWORK MONITOR // CENTRALIZED THREAT INTELLIGENCE</span>
<div class="footer-eu">
<span data-i18n="made_in_eu">🇪🇺 Made &amp; hosted in the EU by</span>
<a href="https://cloudhost.es" target="_blank" rel="noopener">Cloud Host</a>
&nbsp;|&nbsp;
<span data-i18n="refreshed">REFRESHED:</span> <span id="last-update">--</span>
</div>
</footer>
<script>
const I18N = {
en: {
live_feed:'LIVE FEED', stat_total:'TOTAL BLOCKED', stat_today:'TODAY',
stat_7d:'LAST 7 DAYS', stat_30d:'LAST 30 DAYS', stat_rl:'RATE LIMITED',
stat_sites:'SITES REPORTING', top_target_label:'▶ MOST ACTIVE BOT TYPE (30D):',
chart_title:'▶ 24H ACTIVITY TREND', breakdown_title:'▶ BOT BREAKDOWN // LAST 30 DAYS',
bot_types:'BOT TYPES', ua_family:'UA FAMILIES',
actions_title:'▶ ACTIONS + REASONS // LAST 30 DAYS', actions:'ACTIONS', reasons:'REASONS',
ua_title:'▶ TOP USER AGENTS // LAST 30 DAYS',
feed_title:'▶ LIVE BOT FEED', events:'events',
connecting:'connecting…', connected:'connected', reconnecting:'reconnecting…',
attackers_title:'▶ TOP OFFENDERS // LAST 30 DAYS',
col_ip:'IP ADDRESS', col_hits:'HITS', col_asn:'AS',
loading:'Loading…', no_data:'No data yet',
footer_copy:'BOT NETWORK MONITOR // CENTRALIZED THREAT INTELLIGENCE',
refreshed:'REFRESHED:', made_in_eu:'🇪🇺 Made & hosted in the EU by',
},
es: {
live_feed:'EN VIVO', stat_total:'TOTAL BLOQUEADOS', stat_today:'HOY',
stat_7d:'ÚLTIMOS 7 DÍAS', stat_30d:'ÚLTIMOS 30 DÍAS', stat_rl:'LIMITADOS',
stat_sites:'SITIOS REPORTANDO', top_target_label:'▶ BOT MÁS ACTIVO (30D):',
chart_title:'▶ TENDENCIA 24H', breakdown_title:'▶ DESGLOSE // ÚLTIMOS 30 DÍAS',
bot_types:'TIPOS DE BOT', ua_family:'FAMILIAS UA',
actions_title:'▶ ACCIONES + MOTIVOS // ÚLTIMOS 30 DÍAS', actions:'ACCIONES', reasons:'MOTIVOS',
ua_title:'▶ TOP AGENTES DE USUARIO // ÚLTIMOS 30 DÍAS',
feed_title:'▶ FEED EN VIVO', events:'eventos',
connecting:'conectando…', connected:'conectado', reconnecting:'reconectando…',
attackers_title:'▶ TOP OFENSORES // ÚLTIMOS 30 DÍAS',
col_ip:'DIRECCIÓN IP', col_hits:'IMPACTOS', col_asn:'AS',
loading:'Cargando…', no_data:'Sin datos aún',
footer_copy:'MONITOR BOT // INTELIGENCIA CENTRALIZADA DE AMENAZAS',
refreshed:'ACTUALIZADO:', made_in_eu:'🇪🇺 Hecho y alojado en la UE por',
},
ro: {
live_feed:'LIVE', stat_total:'TOTAL BLOCATE', stat_today:'AZI',
stat_7d:'ULTIMELE 7 ZILE', stat_30d:'ULTIMELE 30 ZILE', stat_rl:'LIMITATE',
stat_sites:'SITE-URI RAPORTÂND', top_target_label:'▶ BOT CEL MAI ACTIV (30Z):',
chart_title:'▶ TENDINȚĂ 24H', breakdown_title:'▶ ANALIZĂ // ULTIMELE 30 ZILE',
bot_types:'TIPURI BOT', ua_family:'FAMILII UA',
actions_title:'▶ ACȚIUNI + MOTIVE // ULTIMELE 30 ZILE', actions:'ACȚIUNI', reasons:'MOTIVE',
ua_title:'▶ TOP USER AGENTS // ULTIMELE 30 ZILE',
feed_title:'▶ FLUX LIVE BOȚI', events:'evenimente',
connecting:'conectare…', connected:'conectat', reconnecting:'reconectare…',
attackers_title:'▶ TOP OFENSATORI // ULTIMELE 30 ZILE',
col_ip:'ADRESĂ IP', col_hits:'ACCESĂRI', col_asn:'AS',
loading:'Se încarcă…', no_data:'Fără date încă',
footer_copy:'MONITOR BOT // INFORMAȚII CENTRALIZATE DESPRE AMENINȚĂRI',
refreshed:'ACTUALIZAT:', made_in_eu:'🇪🇺 Realizat și găzduit în UE de',
},
};
function detectLang() {
const s = localStorage.getItem('bot_lang');
if (s && I18N[s]) return s;
const nav = (navigator.language || 'en').slice(0,2).toLowerCase();
return I18N[nav] ? nav : 'en';
}
let currentLang = detectLang();
function t(k) { return (I18N[currentLang]||I18N.en)[k]||(I18N.en[k]||k); }
function setLang(lang) {
if (!I18N[lang]) return;
currentLang = lang;
localStorage.setItem('bot_lang', lang);
document.getElementById('html-root').lang = lang;
applyTranslations(); updateLangButtons();
}
function applyTranslations() {
document.querySelectorAll('[data-i18n]').forEach(el => {
if (!el.children.length) el.textContent = t(el.getAttribute('data-i18n'));
});
}
function updateLangButtons() {
document.querySelectorAll('.lang-btn').forEach(btn => {
btn.className = 'lang-btn ' + (btn.dataset.lang === currentLang ? 'active' : 'inactive');
});
}
function flag(cc) {
if (!cc || cc.length !== 2) return '';
return String.fromCodePoint(...[...cc.toUpperCase()].map(c => c.charCodeAt(0) + 127397));
}
const clockEl = document.getElementById('clock');
function tick() { clockEl.textContent = new Date().toISOString().slice(11,19) + ' UTC'; }
tick(); setInterval(tick, 1000);
const ctMap = new Map();
function countUp(el, to) {
const from = parseInt(el.textContent.replace(/,/g,''))||0;
if (from === to) return;
const steps = 25, diff = to - from; let s = 0;
clearInterval(ctMap.get(el));
const id = setInterval(() => {
s++;
el.textContent = Math.round(from + diff*(s/steps)).toLocaleString();
if (s >= steps) { el.textContent = to.toLocaleString(); clearInterval(id); }
}, 16);
ctMap.set(el, id);
}
function renderBars(listEl, items, labelKey, fillClass = '') {
if (!items || !items.length) {
listEl.innerHTML = `<li style="color:var(--dim);font-size:11px;padding:3px 0">${t('no_data')}</li>`;
return;
}
const max = items[0].hits;
listEl.innerHTML = items.map(item => {
const label = item[labelKey] || item.bot_type || item.ua_family || item.action || item.reason || item.ip || '?';
const pct = max > 0 ? Math.round(item.hits/max*100) : 0;
return `<li class="bar-item">
<span class="bar-lbl" title="${esc(label)}">${esc(label)}</span>
<div class="bar-track"><div class="bar-fill ${fillClass}" style="width:${pct}%"></div></div>
<span class="bar-cnt">${item.hits.toLocaleString()}</span>
</li>`;
}).join('');
}
function renderUAList(listEl, items) {
if (!items || !items.length) {
listEl.innerHTML = `<li class="ua-row" style="color:var(--dim);font-size:11px">${t('no_data')}</li>`;
return;
}
listEl.innerHTML = items.map(item =>
`<li class="ua-row">
<span class="ua-hits">${item.hits.toLocaleString()}</span>
<span class="ua-str" title="${esc(item.ua)}">${esc(item.ua || '(empty)')}</span>
</li>`
).join('');
}
const canvas = document.getElementById('chart');
const ctx = canvas.getContext('2d');
function drawChart(hourly) {
const W = canvas.offsetWidth||600, H = 72;
canvas.width = W*(window.devicePixelRatio||1);
canvas.height = H*(window.devicePixelRatio||1);
ctx.scale(window.devicePixelRatio||1, window.devicePixelRatio||1);
ctx.clearRect(0,0,W,H);
if (!hourly||!hourly.length) {
ctx.fillStyle='#226644'; ctx.font='12px Courier New';
ctx.fillText(t('no_data'),10,38); return;
}
const base = Math.floor(Date.now()/1000/3600)*3600;
const map = new Map(hourly.map(r=>[r.h,r.n]));
const hrs = Array.from({length:24},(_,i)=>({h:base-(23-i)*3600, n:map.get(base-(23-i)*3600)||0}));
const max = Math.max(...hrs.map(h=>h.n),1);
const pad = {l:2,r:2,t:5,b:3};
const cW = W-pad.l-pad.r, cH=H-pad.t-pad.b;
const bW = cW/hrs.length-1;
ctx.strokeStyle='#001a28'; ctx.lineWidth=1;
[0.33,0.66].forEach(f=>{
const y=pad.t+cH*(1-f);
ctx.beginPath(); ctx.moveTo(pad.l,y); ctx.lineTo(W-pad.r,y); ctx.stroke();
});
hrs.forEach((h,i)=>{
const x=pad.l+i*(cW/hrs.length);
const bH=Math.max(1,h.n/max*cH), y=pad.t+cH-bH;
const g=ctx.createLinearGradient(0,y,0,y+bH);
g.addColorStop(0,'#00d4ff'); g.addColorStop(1,'#003344');
ctx.fillStyle=g; ctx.fillRect(x+0.5,y,Math.max(bW,1),bH);
if (h.n===max) {
ctx.shadowColor='#00d4ff'; ctx.shadowBlur=10;
ctx.fillRect(x+0.5,y,Math.max(bW,1),bH);
ctx.shadowBlur=0;
}
});
document.getElementById('chart-peak').textContent=
`PEAK ${max.toLocaleString()}/hr TOTAL ${hrs.reduce((a,h)=>a+h.n,0).toLocaleString()}`;
}
window.addEventListener('resize', ()=>{ if (window._hourly) drawChart(window._hourly); });
function renderAttackers(ips) {
const tbody = document.getElementById('atk-body');
if (!ips||!ips.length) {
tbody.innerHTML=`<tr><td colspan="4" style="text-align:center;padding:14px;color:var(--dim)">${t('no_data')}</td></tr>`;
return;
}
const max = ips[0].hits;
tbody.innerHTML = ips.map((row,i)=>{
const f = flag(row.country);
const asnNo = (row.asn||'').split(' ')[0];
return `<tr>
<td class="atk-rank">#${i+1}</td>
<td class="atk-ip">${f?f+' ':''}${esc(row.ip)}</td>
<td class="atk-hits">${row.hits.toLocaleString()}</td>
<td class="atk-asn" title="${esc(row.asn||'')}">${esc(asnNo)}</td>
</tr>`;
}).join('');
}
function esc(s) {
return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function fmtTime(ts) { return new Date(ts*1000).toISOString().slice(11,19); }
let feedCount = 0;
const feedEl = document.getElementById('feed');
const feedCount$ = document.getElementById('feed-count');
const feedStatus = document.getElementById('feed-status');
function addRow(row) {
feedCount++;
feedCount$.textContent = `${feedCount.toLocaleString()} ${t('events')}`;
const el = document.createElement('div');
el.className = 'feed-row';
const f = flag(row.country||'');
const action = row.action||'blocked';
const isLocal = row.site_id === 'self';
el.innerHTML = `
<span class="feed-ts">${fmtTime(row.received_at)}</span>
<span class="feed-ip">${esc(row.ip_masked||row.ip||'?')}</span>
<span>
${f?`<span class="feed-geo">${f} ${esc(row.country||'')}</span><br>`:''}
<span class="feed-bot">${esc(row.bot_type||'?')}</span>
<span class="feed-action ${action}"> [${esc(action)}]</span>
${isLocal?'<span class="feed-local">LOCAL</span>':''}
<br><span class="feed-reason">${esc(row.reason||row.ua_family||'')}</span>
</span>`;
feedEl.prepend(el);
while (feedEl.children.length > 120) feedEl.removeChild(feedEl.lastChild);
}
async function seedFeed() {
try {
const r = await fetch('/api/v1/stats');
const s = await r.json();
if (s.recent) [...s.recent].reverse().forEach(addRow);
} catch {}
}
function connectSSE() {
const es = new EventSource('/api/v1/stream');
es.onopen = () => { feedStatus.textContent = t('connected'); };
es.onmessage = e => { try { JSON.parse(e.data).reverse().forEach(addRow); } catch {} };
es.onerror = () => {
es.close();
feedStatus.textContent = t('reconnecting');
setTimeout(connectSSE, 5000);
};
}
async function fetchStats() {
try {
const r = await fetch('/api/v1/stats');
if (!r.ok) return;
const s = await r.json();
countUp(document.getElementById('s-total'), s.total);
countUp(document.getElementById('s-today'), s.today);
countUp(document.getElementById('s-7d'), s.last_7d);
countUp(document.getElementById('s-30d'), s.last_30d);
countUp(document.getElementById('s-rl'), s.rate_limited||0);
countUp(document.getElementById('s-sites'), s.total_sites);
renderBars(document.getElementById('bars-bots'), s.top_bot_types, 'bot_type');
renderBars(document.getElementById('bars-ua'), s.top_ua, 'ua_family');
renderBars(document.getElementById('bars-actions'), s.top_actions, 'action', 'bar-fill-amber');
renderBars(document.getElementById('bars-reasons'), s.top_reasons, 'reason');
renderUAList(document.getElementById('ua-list'), s.top_user_agents || []);
renderAttackers(s.top_ips);
if (s.top_bot_types && s.top_bot_types.length) {
const top = s.top_bot_types[0];
document.getElementById('tt-form').textContent = top.bot_type;
document.getElementById('tt-hits').textContent = `${top.hits.toLocaleString()} hits`;
const pct = s.last_30d > 0 ? Math.round(top.hits/s.last_30d*100) : 0;
document.getElementById('tt-pct').textContent = `(${pct}% of all blocks)`;
}
window._hourly = s.hourly;
drawChart(s.hourly);
document.getElementById('last-update').textContent = new Date().toISOString().slice(11,19)+' UTC';
} catch {}
}
feedStatus.textContent = t('connecting');
applyTranslations();
updateLangButtons();
seedFeed();
connectSSE();
fetchStats();
setInterval(fetchStats, 6000);
</script>
</body>
</html>