You've already forked caddy-opnsense-blocker
Add on-demand IP investigation and richer IP details
This commit is contained in:
@@ -21,6 +21,7 @@ type App interface {
|
||||
ListEvents(ctx context.Context, limit int) ([]model.Event, error)
|
||||
ListIPs(ctx context.Context, limit int, state string) ([]model.IPState, 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
|
||||
ForceAllow(ctx context.Context, ip string, actor string, reason string) error
|
||||
ClearOverride(ctx context.Context, ip string, actor string, reason string) error
|
||||
@@ -165,6 +166,16 @@ func (h *handler) handleAPIIP(w http.ResponseWriter, r *http.Request) {
|
||||
methodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
if action == "investigate" {
|
||||
details, err := h.app.InvestigateIP(r.Context(), ip)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, details)
|
||||
return
|
||||
}
|
||||
|
||||
payload, err := decodeActionPayload(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
@@ -175,7 +186,7 @@ func (h *handler) handleAPIIP(w http.ResponseWriter, r *http.Request) {
|
||||
err = h.app.ForceBlock(r.Context(), ip, payload.Actor, payload.Reason)
|
||||
case "unblock":
|
||||
err = h.app.ForceAllow(r.Context(), ip, payload.Actor, payload.Reason)
|
||||
case "reset":
|
||||
case "clear-override", "reset":
|
||||
err = h.app.ClearOverride(r.Context(), ip, payload.Actor, payload.Reason)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
@@ -199,26 +210,50 @@ func decodeActionPayload(r *http.Request) (actionPayload, error) {
|
||||
if r.ContentLength == 0 {
|
||||
return payload, nil
|
||||
}
|
||||
decoder := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
|
||||
if err := decoder.Decode(&payload); err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return payload, nil
|
||||
}
|
||||
return actionPayload{}, fmt.Errorf("decode request body: %w", err)
|
||||
limited := io.LimitReader(r.Body, 1<<20)
|
||||
if err := json.NewDecoder(limited).Decode(&payload); err != nil {
|
||||
return actionPayload{}, fmt.Errorf("decode action payload: %w", err)
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func queryLimit(r *http.Request, fallback int) int {
|
||||
if fallback <= 0 {
|
||||
fallback = 50
|
||||
}
|
||||
value := strings.TrimSpace(r.URL.Query().Get("limit"))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil || parsed <= 0 {
|
||||
return fallback
|
||||
}
|
||||
if parsed > 1000 {
|
||||
return 1000
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, payload any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, err error) {
|
||||
writeJSON(w, status, map[string]any{"error": err.Error()})
|
||||
}
|
||||
|
||||
func extractPathValue(path string, prefix string) (string, bool) {
|
||||
if !strings.HasPrefix(path, prefix) {
|
||||
return "", false
|
||||
}
|
||||
rest := strings.TrimPrefix(path, prefix)
|
||||
rest = strings.Trim(rest, "/")
|
||||
if rest == "" {
|
||||
value := strings.Trim(strings.TrimPrefix(path, prefix), "/")
|
||||
if value == "" {
|
||||
return "", false
|
||||
}
|
||||
decoded, err := url.PathUnescape(rest)
|
||||
decoded, err := url.PathUnescape(value)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
@@ -229,48 +264,19 @@ func extractAPIPath(path string) (ip string, action string, ok bool) {
|
||||
if !strings.HasPrefix(path, "/api/ips/") {
|
||||
return "", "", false
|
||||
}
|
||||
rest := strings.TrimPrefix(path, "/api/ips/")
|
||||
rest = strings.Trim(rest, "/")
|
||||
rest := strings.Trim(strings.TrimPrefix(path, "/api/ips/"), "/")
|
||||
if rest == "" {
|
||||
return "", "", false
|
||||
}
|
||||
parts := strings.Split(rest, "/")
|
||||
decoded, err := url.PathUnescape(parts[0])
|
||||
decodedIP, err := url.PathUnescape(parts[0])
|
||||
if err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
if len(parts) == 1 {
|
||||
return decoded, "", true
|
||||
return decodedIP, "", true
|
||||
}
|
||||
if len(parts) == 2 {
|
||||
return decoded, parts[1], true
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
func queryLimit(r *http.Request, fallback int) int {
|
||||
value := strings.TrimSpace(r.URL.Query().Get("limit"))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil || parsed <= 0 {
|
||||
return fallback
|
||||
}
|
||||
if parsed > 500 {
|
||||
return 500
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, payload any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, err error) {
|
||||
writeJSON(w, status, map[string]any{"error": err.Error()})
|
||||
return decodedIP, parts[1], true
|
||||
}
|
||||
|
||||
func methodNotAllowed(w http.ResponseWriter) {
|
||||
@@ -309,10 +315,6 @@ const overviewHTML = `<!doctype html>
|
||||
.status.review { background: #78350f; }
|
||||
.status.allowed { background: #14532d; }
|
||||
.status.observed { background: #1e293b; }
|
||||
.actions { display: flex; gap: .35rem; flex-wrap: wrap; }
|
||||
button { background: #2563eb; color: white; border: 0; border-radius: .45rem; padding: .35rem .6rem; cursor: pointer; }
|
||||
button.secondary { background: #475569; }
|
||||
button.danger { background: #dc2626; }
|
||||
.muted { color: #94a3b8; }
|
||||
.mono { font-family: ui-monospace, monospace; }
|
||||
.panel { background: #111827; border: 1px solid #334155; border-radius: .75rem; padding: 1rem; overflow: auto; }
|
||||
@@ -329,13 +331,13 @@ const overviewHTML = `<!doctype html>
|
||||
<h2>Recent IPs</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>IP</th><th>State</th><th>Override</th><th>Events</th><th>Last seen</th><th>Reason</th><th>Actions</th></tr>
|
||||
<tr><th>IP</th><th>State</th><th>Override</th><th>Events</th><th>Last seen</th><th>Reason</th></tr>
|
||||
</thead>
|
||||
<tbody id="ips-body"></tbody>
|
||||
</table>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h2>Recent Events</h2>
|
||||
<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>
|
||||
@@ -349,18 +351,11 @@ const overviewHTML = `<!doctype html>
|
||||
return String(value ?? '').replace(/[&<>"']/g, character => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[character]));
|
||||
}
|
||||
|
||||
async function sendAction(ip, action) {
|
||||
const reason = window.prompt('Optional reason', '');
|
||||
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');
|
||||
function formatDate(value) {
|
||||
if (!value) {
|
||||
return '—';
|
||||
}
|
||||
await refresh();
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
|
||||
function renderStats(data) {
|
||||
@@ -387,15 +382,8 @@ const overviewHTML = `<!doctype html>
|
||||
' <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(new Date(item.last_seen_at).toLocaleString()) + '</td>',
|
||||
' <td>' + escapeHtml(formatDate(item.last_seen_at)) + '</td>',
|
||||
' <td>' + escapeHtml(item.state_reason) + '</td>',
|
||||
' <td>',
|
||||
' <div class="actions">',
|
||||
' <button class="danger" onclick="sendAction("' + escapeHtml(item.ip) + '", "block")">Block</button>',
|
||||
' <button onclick="sendAction("' + escapeHtml(item.ip) + '", "unblock")">Unblock</button>',
|
||||
' <button class="secondary" onclick="sendAction("' + escapeHtml(item.ip) + '", "reset")">Reset</button>',
|
||||
' </div>',
|
||||
' </td>',
|
||||
'</tr>'
|
||||
].join('')).join('');
|
||||
}
|
||||
@@ -403,7 +391,7 @@ const overviewHTML = `<!doctype html>
|
||||
function renderEvents(items) {
|
||||
document.getElementById('events-body').innerHTML = items.map(item => [
|
||||
'<tr>',
|
||||
' <td>' + escapeHtml(new Date(item.occurred_at).toLocaleString()) + '</td>',
|
||||
' <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>',
|
||||
@@ -437,24 +425,29 @@ const ipDetailsHTML = `<!doctype html>
|
||||
<title>{{ .Title }}</title>
|
||||
<style>
|
||||
: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; }
|
||||
body { font-family: system-ui, sans-serif; margin: 0; background: #020617; color: #e2e8f0; }
|
||||
header { padding: 1rem 1.5rem; border-bottom: 1px solid #334155; position: sticky; top: 0; background: rgba(2,6,23,.97); }
|
||||
main { padding: 1.5rem; display: grid; gap: 1.5rem; }
|
||||
.panel { background: #111827; border: 1px solid #334155; border-radius: .75rem; padding: 1rem; overflow: auto; }
|
||||
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; }
|
||||
h1, h2 { margin: 0 0 .75rem 0; }
|
||||
.status { display: inline-block; padding: .15rem .45rem; border-radius: 999px; font-size: .8rem; background: #1e293b; }
|
||||
.status.blocked { background: #7f1d1d; }
|
||||
.status.review { background: #78350f; }
|
||||
.status.allowed { background: #14532d; }
|
||||
.status.observed { background: #1e293b; }
|
||||
.actions { display: flex; gap: .35rem; flex-wrap: wrap; }
|
||||
.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; }
|
||||
.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; }
|
||||
button.secondary { background: #475569; }
|
||||
button.danger { background: #dc2626; }
|
||||
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; }
|
||||
.mono { font-family: ui-monospace, monospace; }
|
||||
a { color: #93c5fd; text-decoration: none; }
|
||||
.hint { font-size: .9rem; color: #94a3b8; margin-top: .75rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -465,18 +458,19 @@ const ipDetailsHTML = `<!doctype html>
|
||||
<main>
|
||||
<section class="panel">
|
||||
<h2>State</h2>
|
||||
<div id="state"></div>
|
||||
<div class="actions">
|
||||
<button class="danger" onclick="sendAction('block')">Block</button>
|
||||
<button onclick="sendAction('unblock')">Unblock</button>
|
||||
<button class="secondary" onclick="sendAction('reset')">Reset</button>
|
||||
</div>
|
||||
<div id="state" class="kv"></div>
|
||||
<div id="actions" class="actions"></div>
|
||||
<div class="hint">Clear override removes the local manual override only. It does not change the current OPNsense alias entry.</div>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h2>Recent events</h2>
|
||||
<h2>Investigation</h2>
|
||||
<div id="investigation" class="kv"></div>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h2>Requests from this IP</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Time</th><th>Source</th><th>Method</th><th>Path</th><th>Status</th><th>Decision</th></tr>
|
||||
<tr><th>Time</th><th>Source</th><th>Host</th><th>Method</th><th>URI</th><th>Status</th><th>Decision</th><th>User agent</th></tr>
|
||||
</thead>
|
||||
<tbody id="events-body"></tbody>
|
||||
</table>
|
||||
@@ -507,8 +501,18 @@ const ipDetailsHTML = `<!doctype html>
|
||||
return String(value ?? '').replace(/[&<>"']/g, character => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[character]));
|
||||
}
|
||||
|
||||
async function sendAction(action) {
|
||||
const reason = window.prompt('Optional reason', '');
|
||||
function formatDate(value) {
|
||||
if (!value) {
|
||||
return '—';
|
||||
}
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
|
||||
async function sendAction(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' },
|
||||
@@ -517,67 +521,148 @@ const ipDetailsHTML = `<!doctype html>
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({ error: response.statusText }));
|
||||
window.alert(payload.error || 'Request failed');
|
||||
return;
|
||||
}
|
||||
await refresh();
|
||||
const data = await response.json();
|
||||
renderAll(data);
|
||||
}
|
||||
|
||||
function renderState(state) {
|
||||
async function investigate() {
|
||||
document.getElementById('investigation').innerHTML = '<div class="muted">Refreshing investigation…</div>';
|
||||
const response = await fetch('/api/ips/' + encodeURIComponent(ip) + '/investigate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ actor: 'web-ui' }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({ error: response.statusText }));
|
||||
document.getElementById('investigation').innerHTML = '<div class="muted">' + escapeHtml(payload.error || 'Investigation failed') + '</div>';
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
renderAll(data);
|
||||
}
|
||||
|
||||
function renderState(data) {
|
||||
const state = data.state || {};
|
||||
const opnsense = data.opnsense || {};
|
||||
const opnsenseLabel = opnsense.configured ? (opnsense.error ? ('unknown (' + opnsense.error + ')') : (opnsense.present ? 'blocked' : 'not blocked')) : 'disabled';
|
||||
document.getElementById('state').innerHTML = [
|
||||
'<div><strong>State</strong>: <span class="status ' + escapeHtml(state.state) + '">' + escapeHtml(state.state) + '</span></div>',
|
||||
'<div><strong>Override</strong>: ' + escapeHtml(state.manual_override) + '</div>',
|
||||
'<div><strong>Total events</strong>: ' + escapeHtml(state.total_events) + '</div>',
|
||||
'<div><strong>Last seen</strong>: ' + escapeHtml(new Date(state.last_seen_at).toLocaleString()) + '</div>',
|
||||
'<div><strong>Reason</strong>: ' + escapeHtml(state.state_reason) + '</div>'
|
||||
'<div><strong>Last seen</strong>: ' + escapeHtml(formatDate(state.last_seen_at)) + '</div>',
|
||||
'<div><strong>Reason</strong>: ' + escapeHtml(state.state_reason) + '</div>',
|
||||
'<div><strong>OPNsense alias</strong>: ' + escapeHtml(opnsenseLabel) + '</div>'
|
||||
].join('');
|
||||
}
|
||||
|
||||
function renderActions(data) {
|
||||
const actions = data.actions || {};
|
||||
const buttons = [];
|
||||
if (actions.can_unblock) {
|
||||
buttons.push('<button onclick="sendAction("unblock", "Reason for manual unblock")">Unblock</button>');
|
||||
} else if (actions.can_block) {
|
||||
buttons.push('<button class="danger" onclick="sendAction("block", "Reason for manual block")">Block</button>');
|
||||
}
|
||||
if (actions.can_clear_override) {
|
||||
buttons.push('<button class="secondary" onclick="sendAction("clear-override", "Reason for clearing the manual override")">Clear override</button>');
|
||||
}
|
||||
buttons.push('<button class="secondary" onclick="investigate()">Refresh investigation</button>');
|
||||
document.getElementById('actions').innerHTML = buttons.join('');
|
||||
}
|
||||
|
||||
function renderInvestigation(investigation) {
|
||||
if (!investigation) {
|
||||
document.getElementById('investigation').innerHTML = '<div class="muted">No cached investigation yet.</div>';
|
||||
return;
|
||||
}
|
||||
const rows = [];
|
||||
if (investigation.bot) {
|
||||
rows.push('<div><strong>Bot</strong>: <span class="badge">' + escapeHtml(investigation.bot.icon || '🤖') + ' ' + escapeHtml(investigation.bot.name) + '</span> via ' + escapeHtml(investigation.bot.method) + '</div>');
|
||||
} else {
|
||||
rows.push('<div><strong>Bot</strong>: no verified bot match</div>');
|
||||
}
|
||||
if (investigation.reverse_dns) {
|
||||
rows.push('<div><strong>Reverse DNS</strong>: <span class="mono">' + escapeHtml(investigation.reverse_dns.ptr || '—') + '</span>' + (investigation.reverse_dns.forward_confirmed ? ' · forward-confirmed' : '') + '</div>');
|
||||
}
|
||||
if (investigation.registration) {
|
||||
rows.push('<div><strong>Registration</strong>: ' + escapeHtml(investigation.registration.organization || investigation.registration.name || '—') + '</div>');
|
||||
rows.push('<div><strong>Prefix</strong>: <span class="mono">' + escapeHtml(investigation.registration.prefix || '—') + '</span></div>');
|
||||
rows.push('<div><strong>Country</strong>: ' + escapeHtml(investigation.registration.country || '—') + '</div>');
|
||||
rows.push('<div><strong>Abuse contact</strong>: ' + escapeHtml(investigation.registration.abuse_email || '—') + '</div>');
|
||||
}
|
||||
if (investigation.reputation) {
|
||||
const label = investigation.reputation.spamhaus_listed ? ('listed (' + (investigation.reputation.spamhaus_codes || []).join(', ') + ')') : 'not listed';
|
||||
rows.push('<div><strong>Spamhaus</strong>: ' + escapeHtml(label) + '</div>');
|
||||
if (investigation.reputation.error) {
|
||||
rows.push('<div><strong>Spamhaus error</strong>: ' + escapeHtml(investigation.reputation.error) + '</div>');
|
||||
}
|
||||
}
|
||||
rows.push('<div><strong>Updated</strong>: ' + escapeHtml(formatDate(investigation.updated_at)) + '</div>');
|
||||
if (investigation.error) {
|
||||
rows.push('<div><strong>Lookup warning</strong>: ' + escapeHtml(investigation.error) + '</div>');
|
||||
}
|
||||
document.getElementById('investigation').innerHTML = rows.join('');
|
||||
}
|
||||
|
||||
function renderEvents(items) {
|
||||
document.getElementById('events-body').innerHTML = items.map(item => [
|
||||
const rows = items.map(item => [
|
||||
'<tr>',
|
||||
' <td>' + escapeHtml(new Date(item.occurred_at).toLocaleString()) + '</td>',
|
||||
' <td>' + escapeHtml(formatDate(item.occurred_at)) + '</td>',
|
||||
' <td>' + escapeHtml(item.source_name) + '</td>',
|
||||
' <td>' + escapeHtml(item.host) + '</td>',
|
||||
' <td>' + escapeHtml(item.method) + '</td>',
|
||||
' <td class="mono">' + escapeHtml(item.path) + '</td>',
|
||||
' <td class="mono">' + escapeHtml(item.uri || item.path) + '</td>',
|
||||
' <td>' + escapeHtml(item.status) + '</td>',
|
||||
' <td>' + escapeHtml(item.decision) + (item.enforced ? ' · enforced' : '') + '</td>',
|
||||
' <td>' + escapeHtml(item.user_agent || '—') + '</td>',
|
||||
'</tr>'
|
||||
].join('')).join('');
|
||||
].join(''));
|
||||
document.getElementById('events-body').innerHTML = rows.length > 0 ? rows.join('') : '<tr><td colspan="8" class="muted">No requests stored for this IP yet.</td></tr>';
|
||||
}
|
||||
|
||||
function renderDecisions(items) {
|
||||
document.getElementById('decisions-body').innerHTML = items.map(item => [
|
||||
const rows = items.map(item => [
|
||||
'<tr>',
|
||||
' <td>' + escapeHtml(new Date(item.created_at).toLocaleString()) + '</td>',
|
||||
' <td>' + escapeHtml(formatDate(item.created_at)) + '</td>',
|
||||
' <td>' + escapeHtml(item.kind) + '</td>',
|
||||
' <td>' + escapeHtml(item.action) + '</td>',
|
||||
' <td>' + escapeHtml(item.reason) + '</td>',
|
||||
' <td>' + escapeHtml(item.actor) + '</td>',
|
||||
'</tr>'
|
||||
].join('')).join('');
|
||||
].join(''));
|
||||
document.getElementById('decisions-body').innerHTML = rows.length > 0 ? rows.join('') : '<tr><td colspan="5" class="muted">No decisions recorded for this IP yet.</td></tr>';
|
||||
}
|
||||
|
||||
function renderBackend(items) {
|
||||
document.getElementById('backend-body').innerHTML = items.map(item => [
|
||||
const rows = items.map(item => [
|
||||
'<tr>',
|
||||
' <td>' + escapeHtml(new Date(item.created_at).toLocaleString()) + '</td>',
|
||||
' <td>' + escapeHtml(formatDate(item.created_at)) + '</td>',
|
||||
' <td>' + escapeHtml(item.action) + '</td>',
|
||||
' <td>' + escapeHtml(item.result) + '</td>',
|
||||
' <td>' + escapeHtml(item.message) + '</td>',
|
||||
'</tr>'
|
||||
].join('')).join('');
|
||||
].join(''));
|
||||
document.getElementById('backend-body').innerHTML = rows.length > 0 ? rows.join('') : '<tr><td colspan="4" class="muted">No backend actions recorded for this IP yet.</td></tr>';
|
||||
}
|
||||
|
||||
function renderAll(data) {
|
||||
renderState(data || {});
|
||||
renderActions(data || {});
|
||||
renderInvestigation((data || {}).investigation || null);
|
||||
renderEvents((data || {}).recent_events || []);
|
||||
renderDecisions((data || {}).decisions || []);
|
||||
renderBackend((data || {}).backend_actions || []);
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const response = await fetch('/api/ips/' + encodeURIComponent(ip));
|
||||
const data = await response.json();
|
||||
renderState(data.state || {});
|
||||
renderEvents(data.recent_events || []);
|
||||
renderDecisions(data.decisions || []);
|
||||
renderBackend(data.backend_actions || []);
|
||||
renderAll(data);
|
||||
}
|
||||
|
||||
refresh();
|
||||
setInterval(refresh, 2000);
|
||||
refresh().then(() => investigate());
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
Reference in New Issue
Block a user