feat: store raw UA strings, add separate Top User Agents panel
- Add user_agent column to bots table (migration-safe) - Store raw UA string (up to 300 chars) alongside ua_family on insert - selfObserve stores raw UA from incoming request headers - getStats() adds top_user_agents query (top 15 by count, last 30d) - Dashboard: revert actions+reasons to 2-col, remove embedded UA col - Dashboard: new separate panel below actions+reasons showing raw UA strings with hit counts in monospace, truncated with title tooltip Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -204,6 +204,11 @@ main {
|
||||
|
||||
.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 {
|
||||
@@ -410,7 +415,7 @@ footer a:hover { color: var(--cyan2); }
|
||||
<div class="panel">
|
||||
<div class="panel-hdr" data-i18n="actions_title">▶ ACTIONS + REASONS // LAST 30 DAYS</div>
|
||||
<div class="panel-body">
|
||||
<div class="bars-3col">
|
||||
<div class="bars-2col">
|
||||
<div>
|
||||
<div class="bar-section-title" data-i18n="actions">ACTIONS</div>
|
||||
<ul class="bar-list" id="bars-actions"></ul>
|
||||
@@ -419,12 +424,15 @@ footer a:hover { color: var(--cyan2); }
|
||||
<div class="bar-section-title" data-i18n="reasons">REASONS</div>
|
||||
<ul class="bar-list" id="bars-reasons"></ul>
|
||||
</div>
|
||||
<div>
|
||||
<div class="bar-section-title" data-i18n="top_ua">TOP UA</div>
|
||||
<ul class="bar-list" id="bars-ua-ar"></ul>
|
||||
</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>
|
||||
@@ -486,7 +494,8 @@ const I18N = {
|
||||
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', top_ua:'TOP UA',
|
||||
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',
|
||||
@@ -501,7 +510,8 @@ const I18N = {
|
||||
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', top_ua:'TOP 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',
|
||||
@@ -516,7 +526,8 @@ const I18N = {
|
||||
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', top_ua:'TOP 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',
|
||||
@@ -593,6 +604,19 @@ function renderBars(listEl, items, labelKey, fillClass = '') {
|
||||
}).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) {
|
||||
@@ -721,7 +745,7 @@ async function fetchStats() {
|
||||
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');
|
||||
renderBars(document.getElementById('bars-ua-ar'), s.top_ua, 'ua_family');
|
||||
renderUAList(document.getElementById('ua-list'), s.top_user_agents || []);
|
||||
renderAttackers(s.top_ips);
|
||||
|
||||
if (s.top_bot_types && s.top_bot_types.length) {
|
||||
|
||||
16
server.js
16
server.js
@@ -44,7 +44,7 @@ DB.exec(`
|
||||
`);
|
||||
|
||||
// Migrations – silently ignored if columns already exist
|
||||
['country', 'asn', 'request_uri'].forEach(col => {
|
||||
['country', 'asn', 'request_uri', 'user_agent'].forEach(col => {
|
||||
try { DB.exec(`ALTER TABLE bots ADD COLUMN ${col} TEXT NOT NULL DEFAULT ''`); } catch {}
|
||||
});
|
||||
|
||||
@@ -190,6 +190,11 @@ function getStats() {
|
||||
FROM bots WHERE received_at > ?
|
||||
GROUP BY ua_family ORDER BY hits DESC LIMIT 8
|
||||
`).all(now - 2592000),
|
||||
top_user_agents: DB.prepare(`
|
||||
SELECT user_agent ua, COUNT(*) hits
|
||||
FROM bots WHERE received_at > ? AND user_agent != ''
|
||||
GROUP BY user_agent ORDER BY hits DESC LIMIT 15
|
||||
`).all(now - 2592000),
|
||||
recent: DB.prepare(`
|
||||
SELECT received_at, ip_masked ip, country, bot_type, action, reason, ua_family, site_id
|
||||
FROM bots ORDER BY id DESC LIMIT 40
|
||||
@@ -221,8 +226,8 @@ setInterval(() => {
|
||||
// ── Prepared statements ───────────────────────────────────────────────────────
|
||||
|
||||
const stmtIns = DB.prepare(`
|
||||
INSERT INTO bots (received_at, site_id, ip_masked, bot_type, action, reason, ua_family, request_uri, country, asn)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?)
|
||||
INSERT INTO bots (received_at, site_id, ip_masked, bot_type, action, reason, ua_family, request_uri, country, asn, user_agent)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)
|
||||
`);
|
||||
const stmtSite = DB.prepare(`
|
||||
INSERT INTO sites (site_id, first_seen, last_seen, block_count) VALUES (?,?,?,?)
|
||||
@@ -244,7 +249,8 @@ const insertBatch = DB.transaction((siteId, bots) => {
|
||||
String(b.reason || '').slice(0, 255),
|
||||
parseUA(b.user_agent || ''),
|
||||
String(b.request_uri || '').slice(0, 500),
|
||||
'', '' // country/asn filled async
|
||||
'', '', // country/asn filled async
|
||||
String(b.user_agent || '').slice(0, 300)
|
||||
);
|
||||
ids.push({ id: Number(r.lastInsertRowid), ip });
|
||||
}
|
||||
@@ -274,7 +280,7 @@ function selfObserve(req, res, next) {
|
||||
|
||||
try {
|
||||
const r = stmtIns.run(
|
||||
now, 'self', ip, fam, 'observed', 'Direct API visitor', fam, req.path, '', ''
|
||||
now, 'self', ip, fam, 'observed', 'Direct API visitor', fam, req.path, '', '', ua.slice(0, 300)
|
||||
);
|
||||
_cache = null;
|
||||
setImmediate(() => enrichIP(Number(r.lastInsertRowid), ip));
|
||||
|
||||
Reference in New Issue
Block a user