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

@@ -769,6 +769,152 @@
$('#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', () => {
const sid = $('#wc-log-server').val();
const type = $('#wc-log-type').val();
@@ -784,7 +930,14 @@
$('#wc-log-wrap').html('<p>No log entries.</p>');
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 => {
if (typeof e === 'string') return e;
if (e.time && e.message) return `[${e.time}] ${e.message}`;
@@ -792,7 +945,7 @@
}).join('\n');
$('#wc-log-wrap').html(`
<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>
</div>
<pre class="woocow-log-pre">${esc(text)}</pre>