You've already forked caddy-opnsense-blocker
Simplify the dashboard recent IP view
This commit is contained in:
@@ -20,6 +20,7 @@ type App interface {
|
||||
GetOverview(ctx context.Context, limit int) (model.Overview, error)
|
||||
ListEvents(ctx context.Context, limit int) ([]model.Event, error)
|
||||
ListIPs(ctx context.Context, limit int, state string) ([]model.IPState, error)
|
||||
ListRecentIPs(ctx context.Context, since time.Time, limit int) ([]model.RecentIPRow, error)
|
||||
GetIPDetails(ctx context.Context, ip string) (model.IPDetails, error)
|
||||
InvestigateIP(ctx context.Context, ip string) (model.IPDetails, error)
|
||||
ForceBlock(ctx context.Context, ip string, actor string, reason string) error
|
||||
@@ -57,6 +58,7 @@ func NewHandler(app App) http.Handler {
|
||||
mux.HandleFunc("/api/overview", h.handleAPIOverview)
|
||||
mux.HandleFunc("/api/events", h.handleAPIEvents)
|
||||
mux.HandleFunc("/api/ips", h.handleAPIIPs)
|
||||
mux.HandleFunc("/api/recent-ips", h.handleAPIRecentIPs)
|
||||
mux.HandleFunc("/api/ips/", h.handleAPIIP)
|
||||
return mux
|
||||
}
|
||||
@@ -141,6 +143,29 @@ func (h *handler) handleAPIIPs(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, items)
|
||||
}
|
||||
|
||||
func (h *handler) handleAPIRecentIPs(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/recent-ips" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodGet {
|
||||
methodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
limit := queryLimit(r, 200)
|
||||
hours := queryInt(r, "hours", 24)
|
||||
if hours <= 0 {
|
||||
hours = 24
|
||||
}
|
||||
since := time.Now().UTC().Add(-time.Duration(hours) * time.Hour)
|
||||
items, err := h.app.ListRecentIPs(r.Context(), since, limit)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, items)
|
||||
}
|
||||
|
||||
func (h *handler) handleAPIIP(w http.ResponseWriter, r *http.Request) {
|
||||
ip, action, ok := extractAPIPath(r.URL.Path)
|
||||
if !ok {
|
||||
@@ -235,6 +260,18 @@ func queryLimit(r *http.Request, fallback int) int {
|
||||
return parsed
|
||||
}
|
||||
|
||||
func queryInt(r *http.Request, name string, fallback int) int {
|
||||
value := strings.TrimSpace(r.URL.Query().Get(name))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, payload any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
@@ -300,14 +337,11 @@ const overviewHTML = `<!doctype html>
|
||||
:root { color-scheme: dark; }
|
||||
body { font-family: system-ui, sans-serif; margin: 0; background: #0f172a; color: #e2e8f0; }
|
||||
header { padding: 1rem 1.5rem; border-bottom: 1px solid #334155; position: sticky; top: 0; background: rgba(15,23,42,.97); }
|
||||
main { padding: 1.5rem; display: grid; gap: 1.5rem; }
|
||||
main { padding: 1.5rem; }
|
||||
h1, h2 { margin: 0 0 .75rem 0; }
|
||||
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: .75rem; }
|
||||
.card { background: #111827; border: 1px solid #334155; border-radius: .75rem; padding: .9rem; }
|
||||
.stat-value { font-size: 1.7rem; font-weight: 700; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { padding: .55rem .65rem; border-bottom: 1px solid #1e293b; text-align: left; vertical-align: top; }
|
||||
th { color: #93c5fd; }
|
||||
th { color: #93c5fd; white-space: nowrap; }
|
||||
a { color: #93c5fd; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
.status { display: inline-block; padding: .15rem .45rem; border-radius: 999px; font-size: .8rem; background: #1e293b; }
|
||||
@@ -318,6 +352,16 @@ const overviewHTML = `<!doctype html>
|
||||
.muted { color: #94a3b8; }
|
||||
.mono { font-family: ui-monospace, monospace; }
|
||||
.panel { background: #111827; border: 1px solid #334155; border-radius: .75rem; padding: 1rem; overflow: auto; }
|
||||
.toolbar { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; margin-bottom: .75rem; }
|
||||
.toolbar .meta { font-size: .95rem; color: #94a3b8; }
|
||||
.sort-button { appearance: none; background: transparent; border: 0; color: inherit; cursor: pointer; font: inherit; padding: 0; }
|
||||
.sort-button[data-active="true"] { color: #dbeafe; }
|
||||
.actions { display: flex; gap: .35rem; flex-wrap: wrap; }
|
||||
.action-link, button { display: inline-flex; align-items: center; justify-content: center; gap: .35rem; border-radius: .45rem; padding: .3rem .6rem; font-size: .9rem; }
|
||||
.action-link { background: #1e293b; color: #e2e8f0; text-decoration: none; }
|
||||
button { background: #2563eb; color: white; border: 0; cursor: pointer; }
|
||||
button.secondary { background: #475569; }
|
||||
button.danger { background: #dc2626; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -326,27 +370,41 @@ const overviewHTML = `<!doctype html>
|
||||
<div class="muted">Local-only review and enforcement console</div>
|
||||
</header>
|
||||
<main>
|
||||
<section class="stats" id="stats"></section>
|
||||
<section class="panel">
|
||||
<h2>Recent IPs</h2>
|
||||
<div class="toolbar">
|
||||
<h2>Recent IPs</h2>
|
||||
<div class="meta">Last 24 hours · click a column to sort</div>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>IP</th><th>State</th><th>Override</th><th>Events</th><th>Last seen</th><th>Reason</th></tr>
|
||||
<tr>
|
||||
<th><button class="sort-button" data-sort="ip" onclick="setSort('ip')">IP</button></th>
|
||||
<th><button class="sort-button" data-sort="source" onclick="setSort('source')">Source</button></th>
|
||||
<th><button class="sort-button" data-sort="state" onclick="setSort('state')">State</button></th>
|
||||
<th><button class="sort-button" data-sort="events" onclick="setSort('events')">Events</button></th>
|
||||
<th><button class="sort-button" data-sort="last_seen" onclick="setSort('last_seen')">Last seen</button></th>
|
||||
<th><button class="sort-button" data-sort="reason" onclick="setSort('reason')">Reason</button></th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="ips-body"></tbody>
|
||||
</table>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h2>Recent events</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Time</th><th>Source</th><th>IP</th><th>Host</th><th>Method</th><th>Path</th><th>Status</th><th>Decision</th></tr>
|
||||
</thead>
|
||||
<tbody id="events-body"></tbody>
|
||||
</table>
|
||||
</section>
|
||||
</main>
|
||||
<script>
|
||||
const recentHours = 24;
|
||||
const sortLabels = {
|
||||
ip: 'IP',
|
||||
source: 'Source',
|
||||
state: 'State',
|
||||
events: 'Events',
|
||||
last_seen: 'Last seen',
|
||||
reason: 'Reason',
|
||||
};
|
||||
const stateOrder = { blocked: 0, review: 1, observed: 2, allowed: 3 };
|
||||
let currentItems = [];
|
||||
let currentSort = { key: 'events', direction: 'desc' };
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '').replace(/[&<>"']/g, character => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[character]));
|
||||
}
|
||||
@@ -358,59 +416,136 @@ const overviewHTML = `<!doctype html>
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
|
||||
function renderStats(data) {
|
||||
const stats = [
|
||||
['Total events', data.total_events],
|
||||
['Tracked IPs', data.total_ips],
|
||||
['Blocked', data.blocked_ips],
|
||||
['Review', data.review_ips],
|
||||
['Allowed', data.allowed_ips],
|
||||
['Observed', data.observed_ips],
|
||||
function compareText(left, right) {
|
||||
return String(left || '').localeCompare(String(right || ''), undefined, { sensitivity: 'base' });
|
||||
}
|
||||
|
||||
function compareNumber(left, right) {
|
||||
return Number(left || 0) - Number(right || 0);
|
||||
}
|
||||
|
||||
function compareState(left, right) {
|
||||
const leftRank = Object.prototype.hasOwnProperty.call(stateOrder, left) ? stateOrder[left] : 999;
|
||||
const rightRank = Object.prototype.hasOwnProperty.call(stateOrder, right) ? stateOrder[right] : 999;
|
||||
if (leftRank === rightRank) {
|
||||
return compareText(left, right);
|
||||
}
|
||||
return leftRank - rightRank;
|
||||
}
|
||||
|
||||
function updateSortButtons() {
|
||||
document.querySelectorAll('button[data-sort]').forEach(button => {
|
||||
const key = button.dataset.sort;
|
||||
const active = key === currentSort.key;
|
||||
button.dataset.active = active ? 'true' : 'false';
|
||||
button.textContent = sortLabels[key] + (active ? (currentSort.direction === 'asc' ? ' ↑' : ' ↓') : '');
|
||||
});
|
||||
}
|
||||
|
||||
function sortItems(items) {
|
||||
return [...items].sort((left, right) => {
|
||||
let result = 0;
|
||||
switch (currentSort.key) {
|
||||
case 'ip':
|
||||
result = compareText(left.ip, right.ip);
|
||||
break;
|
||||
case 'source':
|
||||
result = compareText(left.source_name, right.source_name);
|
||||
break;
|
||||
case 'state':
|
||||
result = compareState(left.state, right.state);
|
||||
break;
|
||||
case 'events':
|
||||
result = compareNumber(left.events, right.events);
|
||||
break;
|
||||
case 'last_seen':
|
||||
result = compareNumber(left.last_seen_at ? Date.parse(left.last_seen_at) : 0, right.last_seen_at ? Date.parse(right.last_seen_at) : 0);
|
||||
break;
|
||||
case 'reason':
|
||||
result = compareText(left.reason, right.reason);
|
||||
break;
|
||||
}
|
||||
if (result === 0) {
|
||||
result = compareText(left.ip, right.ip);
|
||||
}
|
||||
return currentSort.direction === 'asc' ? result : -result;
|
||||
});
|
||||
}
|
||||
|
||||
function renderActions(item) {
|
||||
const actions = item.actions || {};
|
||||
const buttons = [
|
||||
'<a class="action-link" href="/ips/' + encodeURIComponent(item.ip) + '">Open</a>'
|
||||
];
|
||||
document.getElementById('stats').innerHTML = stats.map(([label, value]) => [
|
||||
'<div class="card">',
|
||||
' <div class="muted">' + escapeHtml(label) + '</div>',
|
||||
' <div class="stat-value">' + escapeHtml(value) + '</div>',
|
||||
'</div>'
|
||||
].join('')).join('');
|
||||
if (actions.can_unblock) {
|
||||
buttons.push('<button class="secondary" data-ip="' + escapeHtml(item.ip) + '" onclick="sendAction(this.dataset.ip, \'unblock\', \'Reason for manual unblock\')">Unblock</button>');
|
||||
} else if (actions.can_block) {
|
||||
buttons.push('<button class="danger" data-ip="' + escapeHtml(item.ip) + '" onclick="sendAction(this.dataset.ip, \'block\', \'Reason for manual block\')">Block</button>');
|
||||
}
|
||||
return '<div class="actions">' + buttons.join('') + '</div>';
|
||||
}
|
||||
|
||||
function renderIPs(items) {
|
||||
document.getElementById('ips-body').innerHTML = items.map(item => [
|
||||
const rows = sortItems(items).map(item => [
|
||||
'<tr>',
|
||||
' <td class="mono"><a href="/ips/' + encodeURIComponent(item.ip) + '">' + escapeHtml(item.ip) + '</a></td>',
|
||||
' <td>' + escapeHtml(item.source_name || '—') + '</td>',
|
||||
' <td><span class="status ' + escapeHtml(item.state) + '">' + escapeHtml(item.state) + '</span></td>',
|
||||
' <td>' + escapeHtml(item.manual_override) + '</td>',
|
||||
' <td>' + escapeHtml(item.total_events) + '</td>',
|
||||
' <td>' + escapeHtml(item.events) + '</td>',
|
||||
' <td>' + escapeHtml(formatDate(item.last_seen_at)) + '</td>',
|
||||
' <td>' + escapeHtml(item.state_reason) + '</td>',
|
||||
' <td>' + escapeHtml(item.reason || '—') + '</td>',
|
||||
' <td>' + renderActions(item) + '</td>',
|
||||
'</tr>'
|
||||
].join('')).join('');
|
||||
].join(''));
|
||||
document.getElementById('ips-body').innerHTML = rows.length > 0 ? rows.join('') : '<tr><td colspan="7" class="muted">No IPs seen in the last 24 hours.</td></tr>';
|
||||
}
|
||||
|
||||
function renderEvents(items) {
|
||||
document.getElementById('events-body').innerHTML = items.map(item => [
|
||||
'<tr>',
|
||||
' <td>' + escapeHtml(formatDate(item.occurred_at)) + '</td>',
|
||||
' <td>' + escapeHtml(item.source_name) + '</td>',
|
||||
' <td class="mono"><a href="/ips/' + encodeURIComponent(item.client_ip) + '">' + escapeHtml(item.client_ip) + '</a></td>',
|
||||
' <td>' + escapeHtml(item.host) + '</td>',
|
||||
' <td>' + escapeHtml(item.method) + '</td>',
|
||||
' <td class="mono">' + escapeHtml(item.path) + '</td>',
|
||||
' <td>' + escapeHtml(item.status) + '</td>',
|
||||
' <td>' + escapeHtml(item.decision) + (item.enforced ? ' · enforced' : '') + '</td>',
|
||||
'</tr>'
|
||||
].join('')).join('');
|
||||
function render() {
|
||||
updateSortButtons();
|
||||
renderIPs(currentItems);
|
||||
}
|
||||
|
||||
function setSort(key) {
|
||||
if (currentSort.key === key) {
|
||||
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
currentSort.key = key;
|
||||
currentSort.direction = (key === 'events' || key === 'last_seen') ? 'desc' : 'asc';
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
async function sendAction(ip, action, promptLabel) {
|
||||
const reason = window.prompt(promptLabel, '');
|
||||
if (reason === null) {
|
||||
return;
|
||||
}
|
||||
const response = await fetch('/api/ips/' + encodeURIComponent(ip) + '/' + action, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ reason, actor: 'web-ui' }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({ error: response.statusText }));
|
||||
window.alert(payload.error || 'Request failed');
|
||||
return;
|
||||
}
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const response = await fetch('/api/overview?limit=50');
|
||||
const data = await response.json();
|
||||
renderStats(data);
|
||||
renderIPs(data.recent_ips || []);
|
||||
renderEvents(data.recent_events || []);
|
||||
const response = await fetch('/api/recent-ips?hours=' + recentHours + '&limit=250');
|
||||
const payload = await response.json().catch(() => []);
|
||||
if (!response.ok) {
|
||||
const message = Array.isArray(payload) ? response.statusText : (payload.error || response.statusText);
|
||||
document.getElementById('ips-body').innerHTML = '<tr><td colspan="7" class="muted">' + escapeHtml(message) + '</td></tr>';
|
||||
return;
|
||||
}
|
||||
currentItems = Array.isArray(payload) ? payload : [];
|
||||
render();
|
||||
}
|
||||
|
||||
render();
|
||||
refresh();
|
||||
setInterval(refresh, 2000);
|
||||
</script>
|
||||
@@ -437,6 +572,22 @@ const ipDetailsHTML = `<!doctype html>
|
||||
.status.observed { background: #1e293b; }
|
||||
.muted { color: #94a3b8; }
|
||||
.badge { display: inline-flex; align-items: center; gap: .35rem; padding: .2rem .55rem; border-radius: 999px; background: #1d4ed8; color: white; font-size: .8rem; }
|
||||
.bot-badge { background: #0f172a; border: 1px solid #334155; color: #e2e8f0; }
|
||||
.bot-badge.bot-verified { border-color: #1d4ed8; }
|
||||
.bot-badge.bot-hint { border-style: dashed; }
|
||||
.bot-mark { display: inline-flex; align-items: center; justify-content: center; width: 1.15rem; height: 1.15rem; border-radius: 999px; font-size: .72rem; font-weight: 700; color: white; background: #475569; }
|
||||
.bot-mark.google { background: #2563eb; }
|
||||
.bot-mark.bing { background: #0284c7; }
|
||||
.bot-mark.apple { background: #475569; }
|
||||
.bot-mark.meta { background: #2563eb; }
|
||||
.bot-mark.duckduckgo { background: #ea580c; }
|
||||
.bot-mark.openai { background: #059669; }
|
||||
.bot-mark.anthropic { background: #b45309; }
|
||||
.bot-mark.perplexity { background: #0f766e; }
|
||||
.bot-mark.semrush { background: #db2777; }
|
||||
.bot-mark.yandex { background: #dc2626; }
|
||||
.bot-mark.baidu { background: #7c3aed; }
|
||||
.bot-mark.bytespider { background: #111827; }
|
||||
.kv { display: grid; gap: .45rem; }
|
||||
.actions { display: flex; gap: .35rem; flex-wrap: wrap; margin-top: .9rem; }
|
||||
button { background: #2563eb; color: white; border: 0; border-radius: .45rem; padding: .35rem .6rem; cursor: pointer; }
|
||||
@@ -508,6 +659,37 @@ const ipDetailsHTML = `<!doctype html>
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
|
||||
function botVisual(bot) {
|
||||
const candidate = String((bot || {}).provider_id || (bot || {}).name || '').toLowerCase();
|
||||
const catalog = [
|
||||
{ match: ['google'], short: 'G', className: 'google' },
|
||||
{ match: ['bing', 'microsoft'], short: 'B', className: 'bing' },
|
||||
{ match: ['apple'], short: 'A', className: 'apple' },
|
||||
{ match: ['facebook', 'meta'], short: 'M', className: 'meta' },
|
||||
{ match: ['duckduckgo', 'duckduckbot'], short: 'D', className: 'duckduckgo' },
|
||||
{ match: ['gptbot', 'openai'], short: 'O', className: 'openai' },
|
||||
{ match: ['claudebot', 'anthropic'], short: 'C', className: 'anthropic' },
|
||||
{ match: ['perplexity'], short: 'P', className: 'perplexity' },
|
||||
{ match: ['semrush'], short: 'S', className: 'semrush' },
|
||||
{ match: ['yandex'], short: 'Y', className: 'yandex' },
|
||||
{ match: ['baidu'], short: 'B', className: 'baidu' },
|
||||
{ match: ['bytespider', 'tiktok'], short: 'T', className: 'bytespider' },
|
||||
];
|
||||
for (const entry of catalog) {
|
||||
if (entry.match.some(fragment => candidate.includes(fragment))) {
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
const name = String((bot || {}).name || '').trim();
|
||||
return { short: (name[0] || '?').toUpperCase(), className: 'generic' };
|
||||
}
|
||||
|
||||
function renderBotBadge(bot) {
|
||||
const visual = botVisual(bot);
|
||||
const badgeClass = bot.verified ? 'bot-verified' : 'bot-hint';
|
||||
return '<span class="badge bot-badge ' + badgeClass + '"><span class="bot-mark ' + escapeHtml(visual.className) + '">' + escapeHtml(visual.short) + '</span><span>' + escapeHtml(bot.name || 'Bot') + '</span></span>';
|
||||
}
|
||||
|
||||
async function sendAction(action, promptLabel) {
|
||||
const reason = window.prompt(promptLabel, '');
|
||||
if (reason === null) {
|
||||
@@ -579,7 +761,7 @@ const ipDetailsHTML = `<!doctype html>
|
||||
}
|
||||
const rows = [];
|
||||
if (investigation.bot) {
|
||||
rows.push('<div><strong>' + (investigation.bot.verified ? 'Bot' : 'Possible bot') + '</strong>: <span class="badge">' + escapeHtml(investigation.bot.icon || '🤖') + ' ' + escapeHtml(investigation.bot.name) + '</span> via ' + escapeHtml(investigation.bot.method) + (investigation.bot.verified ? '' : ' (not verified)') + '</div>');
|
||||
rows.push('<div><strong>' + (investigation.bot.verified ? 'Bot' : 'Possible bot') + '</strong>: ' + renderBotBadge(investigation.bot) + ' via ' + escapeHtml(investigation.bot.method) + (investigation.bot.verified ? '' : ' (not verified)') + '</div>');
|
||||
} else {
|
||||
rows.push('<div><strong>Bot</strong>: no verified bot match</div>');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user