feat: rich Rspamd history viewer with score badges and symbol details

- Rspamd log type renders a structured table instead of raw JSON
- Each row shows: timestamp, sender, recipient, subject, colour-coded
  score (green/amber/red relative to required threshold), action badge
- Expand button reveals a detail panel per message with:
  - Meta grid: Sender SMTP/MIME, IP, size, process time, Message-ID
  - Threshold strip showing all configured score boundaries
  - Symbol table sorted by impact, with dashicons (warning/check/minus),
    +/- score in colour, description, and option values
  - Zero-score informational symbols shown dimmed at the bottom
- Other log types (postfix, dovecot, etc.) still render as before

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 08:57:00 +01:00
parent dbe4abccf7
commit dbaf3b330b
2 changed files with 234 additions and 2 deletions

View File

@@ -418,6 +418,85 @@
color: #fff; color: #fff;
} }
/* ── Rspamd history viewer ───────────────────────────────────── */
.woocow-badge-blue { background: #cce5ff; color: #004085; }
.woocow-badge-orange { background: #fff3cd; color: #856404; }
.woocow-badge-red { background: #f8d7da; color: #721c24; }
.woocow-rspamd-table th,
.woocow-rspamd-table td { vertical-align: middle; }
.woocow-rspamd-table .woocow-actions { white-space: nowrap; }
.woocow-score {
display: inline-block;
padding: 2px 7px;
border-radius: 4px;
font-family: monospace;
font-weight: 700;
font-size: 12px;
white-space: nowrap;
}
.woocow-score-clean { background: #d4edda; color: #155724; }
.woocow-score-low { background: #fff3cd; color: #856404; }
.woocow-score-high { background: #f8d7da; color: #721c24; }
/* Rspamd detail panel */
.woocow-rspamd-detail td { padding: 0 !important; background: #f9fafc !important; }
.woocow-rspamd-symbols {
padding: 14px 20px;
border-top: 2px solid #e0e4ea;
}
.woocow-rspamd-meta {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 8px 16px;
margin-bottom: 12px;
padding: 10px 0;
border-bottom: 1px solid #e8e8e8;
}
.woocow-rspamd-meta-item label {
display: block;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
color: #999;
margin-bottom: 2px;
}
.woocow-rspamd-meta-item {
font-size: 12px;
word-break: break-all;
}
.woocow-rspamd-thresholds {
font-size: 11px;
color: #888;
margin: 4px 0 10px;
}
.woocow-rspamd-sym-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.woocow-rspamd-sym-table th {
background: #f0f1f5;
padding: 5px 8px;
text-align: left;
font-weight: 700;
border-bottom: 1px solid #dde;
white-space: nowrap;
}
.woocow-rspamd-sym-table td {
padding: 4px 8px;
border-bottom: 1px solid #eef;
vertical-align: top;
}
.woocow-rspamd-sym-table tr:hover td { background: #f5f7ff; }
.woocow-rspamd-sym-table tr.sym-zero td { opacity: .55; }
.woocow-sym-score-pos { color: #c0392b; font-weight: 700; }
.woocow-sym-score-neg { color: #27ae60; font-weight: 700; }
.woocow-sym-score-zero { color: #aaa; }
.woocow-sym-name code { font-size: 11px; background: none; padding: 0; }
/* ── Spam filter panel ───────────────────────────────────────── */ /* ── Spam filter panel ───────────────────────────────────────── */
.woocow-spam-panel { font-size: 13px; } .woocow-spam-panel { font-size: 13px; }
.woocow-spam-panel input[type=range] { vertical-align: middle; } .woocow-spam-panel input[type=range] { vertical-align: middle; }

View File

@@ -769,6 +769,152 @@
$('#wc-log-load').prop('disabled', !$(this).val()); $('#wc-log-load').prop('disabled', !$(this).val());
}); });
// ── Rspamd history renderer ───────────────────────────────────────────
const rspamdActionBadge = (action) => {
const map = {
'no action': ['woocow-badge-green', 'No Action'],
'add header': ['woocow-badge-blue', 'Add Header'],
'rewrite subject': ['woocow-badge-orange', 'Rewrite Subject'],
'greylist': ['woocow-badge-orange', 'Greylist'],
'soft reject': ['woocow-badge-orange', 'Soft Reject'],
'reject': ['woocow-badge-red', 'Reject'],
};
const [cls, label] = map[(action || '').toLowerCase()] || ['woocow-badge-grey', esc(action || '—')];
return `<span class="woocow-badge ${cls}">${label}</span>`;
};
const rspamdScoreClass = (score, req) => {
const pct = (score || 0) / (req || 25);
if (pct < 0.3) return 'woocow-score-clean';
if (pct < 0.7) return 'woocow-score-low';
return 'woocow-score-high';
};
const rspamdDetail = (m) => {
let html = '<div class="woocow-rspamd-symbols">';
// Meta grid
const size = m.size ? ((m.size / 1024).toFixed(1) + ' KB') : '—';
const proc = m.time_real ? (m.time_real.toFixed(3) + 's') : '—';
html += `<div class="woocow-rspamd-meta">
<div class="woocow-rspamd-meta-item"><label>Sender SMTP</label>${esc(m.sender_smtp || '—')}</div>
<div class="woocow-rspamd-meta-item"><label>Sender MIME</label>${esc(m.sender_mime || '—')}</div>
<div class="woocow-rspamd-meta-item"><label>IP</label>${esc(m.ip || '—')}</div>
<div class="woocow-rspamd-meta-item"><label>Size</label>${size}</div>
<div class="woocow-rspamd-meta-item"><label>Process Time</label>${proc}</div>
<div class="woocow-rspamd-meta-item"><label>Message-ID</label><span style="word-break:break-all">${esc(m['message-id'] || '—')}</span></div>
</div>`;
// Thresholds
if (m.thresholds) {
const parts = Object.entries(m.thresholds)
.sort((a, b) => a[1] - b[1])
.map(([k, v]) => `<strong>${esc(k)}:</strong> ${v}`)
.join('&ensp;·&ensp;');
html += `<p class="woocow-rspamd-thresholds">Thresholds: ${parts}</p>`;
}
// Symbols table
if (m.symbols) {
const syms = Object.values(m.symbols);
const triggered = syms.filter(s => (s.metric_score || 0) !== 0)
.sort((a, b) => Math.abs(b.metric_score) - Math.abs(a.metric_score));
const info = syms.filter(s => (s.metric_score || 0) === 0)
.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
html += `<table class="woocow-rspamd-sym-table">
<thead><tr>
<th style="width:24px"></th>
<th style="width:30%">Symbol</th>
<th style="width:64px">Score</th>
<th>Description</th>
<th>Options</th>
</tr></thead><tbody>`;
[...triggered, ...info].forEach(s => {
const ms = s.metric_score || 0;
const icon = ms > 0
? '<span class="dashicons dashicons-warning" style="font-size:14px;color:#c0392b"></span>'
: ms < 0
? '<span class="dashicons dashicons-yes-alt" style="font-size:14px;color:#27ae60"></span>'
: '<span class="dashicons dashicons-minus" style="font-size:14px;color:#bbb"></span>';
const cls = ms > 0 ? 'woocow-sym-score-pos' : ms < 0 ? 'woocow-sym-score-neg' : 'woocow-sym-score-zero';
const sign = ms > 0 ? '+' : '';
const opts = (s.options || []).join(', ');
const zero = ms === 0 ? ' sym-zero' : '';
html += `<tr class="${zero}">
<td>${icon}</td>
<td class="woocow-sym-name"><code>${esc(s.name)}</code></td>
<td><span class="${cls}">${sign}${ms.toFixed(3)}</span></td>
<td>${esc(s.description || '')}</td>
<td style="color:#888">${esc(opts)}</td>
</tr>`;
});
html += '</tbody></table>';
}
html += '</div>';
return html;
};
const renderRspamdLog = (entries) => {
let html = `<div class="woocow-log-toolbar">
<strong>Rspamd History</strong>
<span style="color:#666;font-size:12px">${entries.length} messages (click row for details)</span>
</div>
<table class="wp-list-table widefat fixed striped woocow-rspamd-table woocow-table">
<thead><tr>
<th style="width:130px">Time</th>
<th>From</th>
<th>To</th>
<th>Subject</th>
<th style="width:90px">Score</th>
<th style="width:110px">Action</th>
<th style="width:34px"></th>
</tr></thead><tbody>`;
entries.forEach((m, i) => {
const dt = m.unix_time ? new Date(m.unix_time * 1000).toLocaleString() : '—';
const from = m.sender_mime || m.sender_smtp || '—';
const to = (m.rcpt_mime || m.rcpt_smtp || []).join(', ') || '—';
const subj = m.subject || '—';
const req = m.required_score || 25;
const scr = (m.score || 0).toFixed(2);
const cls = rspamdScoreClass(m.score, req);
html += `<tr>
<td style="font-size:11px;white-space:nowrap">${esc(dt)}</td>
<td style="font-size:11px">${esc(from)}</td>
<td style="font-size:11px">${esc(to)}</td>
<td style="font-size:12px">${esc(subj)}</td>
<td><span class="woocow-score ${cls}">${scr}<span style="opacity:.6;font-weight:400">/${req}</span></span></td>
<td>${rspamdActionBadge(m.action)}</td>
<td><button class="button button-small woocow-icon-btn wc-rspamd-toggle" data-idx="${i}" title="Details">
<span class="dashicons dashicons-arrow-down-alt2"></span>
</button></td>
</tr>
<tr class="woocow-rspamd-detail" id="woocow-rspamd-${i}" style="display:none">
<td colspan="7">${rspamdDetail(m)}</td>
</tr>`;
});
html += '</tbody></table>';
$('#wc-log-wrap').html(html);
};
$(document).on('click', '.wc-rspamd-toggle', function () {
const i = $(this).data('idx');
const $det = $(`#woocow-rspamd-${i}`);
const $ico = $(this).find('.dashicons');
if ($det.is(':visible')) {
$det.hide();
$ico.removeClass('dashicons-arrow-up-alt2').addClass('dashicons-arrow-down-alt2');
} else {
$det.show();
$ico.removeClass('dashicons-arrow-down-alt2').addClass('dashicons-arrow-up-alt2');
}
});
// ── Log load ──────────────────────────────────────────────────────────
$('#wc-log-load').on('click', () => { $('#wc-log-load').on('click', () => {
const sid = $('#wc-log-server').val(); const sid = $('#wc-log-server').val();
const type = $('#wc-log-type').val(); const type = $('#wc-log-type').val();
@@ -784,7 +930,14 @@
$('#wc-log-wrap').html('<p>No log entries.</p>'); $('#wc-log-wrap').html('<p>No log entries.</p>');
return; return;
} }
// Render as a scrollable pre block
if (type === 'rspamd-history') {
renderRspamdLog(entries);
return;
}
// Plain text log
const typeLabel = $('#wc-log-type option:selected').text();
const text = entries.map(e => { const text = entries.map(e => {
if (typeof e === 'string') return e; if (typeof e === 'string') return e;
if (e.time && e.message) return `[${e.time}] ${e.message}`; if (e.time && e.message) return `[${e.time}] ${e.message}`;
@@ -792,7 +945,7 @@
}).join('\n'); }).join('\n');
$('#wc-log-wrap').html(` $('#wc-log-wrap').html(`
<div class="woocow-log-toolbar"> <div class="woocow-log-toolbar">
<strong>${esc(type.charAt(0).toUpperCase() + type.slice(1))} log</strong> <strong>${esc(typeLabel)} log</strong>
<span style="color:#666;font-size:12px">${entries.length} entries</span> <span style="color:#666;font-size:12px">${entries.length} entries</span>
</div> </div>
<pre class="woocow-log-pre">${esc(text)}</pre> <pre class="woocow-log-pre">${esc(text)}</pre>