luci-app-banip: frontpage/Set report UI redesign

- rework the Set report view into a card based dashboard layout
- rework the banIP frontpage into a card based dashboard layout

Signed-off-by: Dirk Brenken <dev@brenken.org>
This commit is contained in:
Dirk Brenken committed 2026-08-06 21:30:00 +02:00
1 parent a1303934e8
commit fa505f7cb1
3 files changed
+513 -104

No files matched your search

+2 -2
View File
@@ -6,8 +6,8 @@ include $(TOPDIR)/rules.mk
LUCI_TITLE:=LuCI support for banIP
LUCI_DEPENDS:=+luci-base +banip
PKG_VERSION:=1.8.10
PKG_RELEASE:=5
PKG_VERSION:=1.8.11
PKG_RELEASE:=1
PKG_LICENSE:=Apache-2.0
PKG_MAINTAINER:=Dirk Brenken <dev@brenken.org>
@@ -24,6 +24,145 @@ function handleAction(ev) {
}
}
/*
runtime string helpers
f_genstatus() packs most runtime fields into display strings of the form
"key: value, key: value, ...". Split them up again so they can be rendered
as chips and key/value rows instead of one long line.
*/
function parsePairs(text) {
const pairs = [];
(text || '').split(', ').forEach(function (item) {
const idx = item.indexOf(': ');
if (idx > 0) {
pairs.push([item.substring(0, idx), item.substring(idx + 2)]);
} else if (item.trim()) {
pairs.push([null, item.trim()]);
}
});
return pairs;
}
function pickValue(pairs, key) {
for (let i = 0; i < pairs.length; i++) {
if (pairs[i][0] === key) {
return pairs[i][1];
}
}
return '-';
}
/*
expand grouped entries, e.g. "proto (4/6): ✔/✘" or
"limit (icmp/syn/udp): 10/10/10" into one entry per member
*/
function expandPairs(pairs) {
const result = [];
pairs.forEach(function (pair) {
if (!pair[0]) {
result.push(pair);
return;
}
const group = pair[0].match(/^(.*?)\s*\(([^()]*\/[^()]*)\)$/);
if (group) {
const names = group[2].split('/');
const values = pair[1].split('/');
if (names.length === values.length) {
for (let i = 0; i < names.length; i++) {
result.push([group[1] + ' ' + names[i], values[i]]);
}
return;
}
}
result.push(pair);
});
return result;
}
function flagChips(text) {
return expandPairs(parsePairs(text)).filter(function (pair) {
return pair[0];
}).sort(function (a, b) {
return a[0].localeCompare(b[0]);
}).map(function (flag) {
const on = flag[1] === '\u2714';
return E('span', { 'class': 'ban-chip ' + (on ? 'ban-chip-on' : 'ban-chip-off') }, [
E('span', { 'class': 'ban-mark' }, [on ? '\u2714' : '\u2718']),
flag[0]
]);
});
}
function feedChips(feeds) {
const chips = (Array.isArray(feeds) ? feeds : []).filter(function (feed) {
return feed && feed !== '-';
}).map(function (feed) {
return E('span', { 'class': 'ban-chip ban-chip-feed' }, [feed]);
});
return chips.length ? chips : ['-'];
}
/*
system_info is "cores: n, log: cmd, fetch: cmd, model, target, release".
Keep the named entries and the board model, drop target and release.
*/
function sysPairs(text) {
let plain = 0;
return parsePairs(text).filter(function (pair) {
return pair[0] || ++plain === 1;
});
}
/*
element_count is "<count> (chains: n, sets: n, rules: n)". The count itself
carries spaces as thousands separators, so it is cut at the parenthesis.
*/
function splitCount(text) {
const value = text || '';
const idx = value.indexOf(' (');
if (idx < 0) {
return { 'count': value || '-', 'detail': '-' };
}
return {
'count': value.substring(0, idx) || '-',
'detail': value.substring(idx + 2).replace(/\)$/, '') || '-'
};
}
/* active_uplink mixes both families, an IPv6 literal always carries a colon */
function splitUplinks(list) {
const v4 = [], v6 = [];
(Array.isArray(list) ? list : []).forEach(function (addr) {
if (addr) {
(addr.indexOf(':') >= 0 ? v6 : v4).push(addr);
}
});
return {
'v4': v4.length ? v4.join(', ') : '-',
'v6': v6.length ? v6.join(', ') : '-'
};
}
/*
The container is a two column grid, so the nodes are emitted flat rather
than wrapped per row - that is what keeps the values aligned across rows.
Entries without a key span both columns. The trailing space in the key is
invisible but keeps the text copyable as "key value".
*/
function stackNodes(pairs, mono) {
const nodes = [];
pairs.forEach(function (pair) {
if (pair[0]) {
nodes.push(E('span', { 'class': 'ban-key' }, [pair[0], ' ']));
nodes.push(E('span', { 'class': mono ? 'ban-mono' : '' }, [pair[1]]));
} else {
nodes.push(E('span', { 'class': mono ? 'ban-full ban-mono' : 'ban-full' }, [pair[1]]));
}
});
return nodes.length ? nodes : ['-'];
}
return view.extend({
load: function () {
return Promise.all([
@@ -50,6 +189,13 @@ return view.extend({
/*
set text content helper function
*/
const setNodes = (id, nodes) => {
const el = document.getElementById(id);
if (el) {
dom.content(el, nodes);
}
};
const setText = (id, value) => {
const el = document.getElementById(id);
if (el) {
@@ -82,7 +228,8 @@ return view.extend({
info = null;
parseErrCount++;
if (status) {
status.textContent = '-';
status.setAttribute('data-state', '');
setText('state', '-');
buttons.forEach(function (btn) {
btn.disabled = false;
});
@@ -95,12 +242,11 @@ return view.extend({
return;
}
if (status && info) {
let statusText = info.status || '-';
if (actual) {
statusText += `: ${actual}`;
}
statusText += ` (frontend: ${info.frontend_ver || '-'} / backend: ${info.backend_ver || '-'})`;
status.textContent = statusText;
const state = info.status || '-';
status.setAttribute('data-state', state);
setText('state', state);
setText('versions', `${info.frontend_ver || '-'} / ${info.backend_ver || '-'}`);
setNodes('actual', actual ? flagChips(actual) : ['-']);
if (info.status === "processing") {
buttons.forEach(function (btn) {
btn.disabled = true;
@@ -119,18 +265,26 @@ return view.extend({
}
}
if (info) {
setText('elements', info.element_count);
setText('feeds', info.active_feeds?.join(', ') || '-');
setText('devices', `wan-dev: ${info.wan_devices?.join(', ') || '-'} /
wan-if: ${info.wan_interfaces?.join(', ') || '-'} /
vlan-allow: ${info.vlan_allow?.join(', ') || '-'} /
vlan-block: ${info.vlan_block?.join(', ') || '-'}`);
setText('uplink', info.active_uplink?.join(', ') || '-');
setText('nft', info.nft_info);
setText('run', info.run_info);
setText('flags', info.run_flags);
setText('last', info.last_run);
setText('sys', info.system_info);
const elements = splitCount(info.element_count);
const runPairs = parsePairs(info.last_run);
const join = list => (Array.isArray(list) && list.length) ? list.join(', ') : '-';
setText('elements', elements.count);
setText('elements_sub', elements.detail);
setText('last', pickValue(runPairs, 'date / time'));
setText('last_sub', [pickValue(runPairs, 'mode'), pickValue(runPairs, 'duration'),
pickValue(runPairs, 'memory')].filter(v => v && v !== '-').join(', '));
setText('wan_dev', join(info.wan_devices));
setText('wan_if', join(info.wan_interfaces));
setText('vlan_allow', join(info.vlan_allow));
setText('vlan_block', join(info.vlan_block));
const uplinks = splitUplinks(info.active_uplink);
setText('uplink4', uplinks.v4);
setText('uplink6', uplinks.v6);
setNodes('nft', stackNodes(expandPairs(parsePairs(info.nft_info)), false));
setNodes('sys', stackNodes(sysPairs(info.system_info), false));
setNodes('feeds', feedChips(info.active_feeds));
setNodes('flags', flagChips(info.run_flags));
setNodes('run', stackNodes(parsePairs(info.run_info), true));
}
});
});
@@ -141,47 +295,121 @@ return view.extend({
*/
s = m.section(form.NamedSection, 'global');
s.render = function (view, section_id) {
return E('div', { 'class': 'cbi-section' }, [
E('h3', _('Information')),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title', 'style': 'margin-bottom:-5px;padding-top:0rem;' }, _('Status')),
E('div', { 'class': 'cbi-value-field spinning', 'id': 'status', 'style': 'margin-bottom:-5px;color:#37c;' }, '\xa0')
/*
scoped theme palette
Neutral surfaces are derived from translucent grey so they work on
top of any LuCI theme background. Only the semantic colors are
switched per color scheme, and both variants are chosen to stay
readable either way.
*/
const style = E('style', { 'type': 'text/css' }, [
'#ban-status {' +
'--ban-card-bg: rgba(128,128,128,.07);' +
'--ban-card-border: rgba(128,128,128,.28);' +
'--ban-muted: GrayText;' +
'--ban-ok: #1f8a5f;' +
'--ban-err: #c0392b;' +
'--ban-info: #2f6fb0;' +
'--ban-ok-bg: rgba(31,138,95,.14);' +
'--ban-info-bg: rgba(47,111,176,.14);' +
'}' +
'@media (prefers-color-scheme: dark) {' +
'#ban-status {' +
'--ban-ok: #63c79b;' +
'--ban-err: #e8897e;' +
'--ban-info: #7fb3e8;' +
'}}' +
'#ban-status .ban-grid { display: grid; gap: .75em; grid-template-columns: repeat(auto-fit, minmax(min(12em, 100%), 1fr)); margin-bottom: .75em; }' +
'#ban-status .ban-card { background: var(--ban-card-bg); border: 1px solid var(--ban-card-border); border-radius: 8px; padding: .7em .9em; min-width: 0; overflow-wrap: break-word; }' +
'#ban-status .ban-block { margin-bottom: .75em; }' +
'#ban-status .ban-label { font-size: .85em; color: var(--ban-muted); margin-bottom: .3em; }' +
'#ban-status .ban-sub { font-size: .8em; color: var(--ban-muted); margin-top: .3em; }' +
'#ban-status .ban-value { font-size: 1.5em; line-height: 1.3; font-variant-numeric: tabular-nums; }' +
'#ban-status .ban-state { display: flex; align-items: center; gap: .5em; }' +
'#ban-status .ban-dot { width: .6em; height: .6em; border-radius: 50%; background: var(--ban-muted); flex: 0 0 auto; }' +
'#ban-status .ban-state[data-state="active"] .ban-dot { background: var(--ban-ok); }' +
'#ban-status .ban-state[data-state="processing"] .ban-dot { background: var(--ban-info); }' +
'#ban-status .ban-state[data-state="error"] .ban-dot { background: var(--ban-err); }' +
'#ban-status .ban-title { font-weight: bold; margin-bottom: .6em; }' +
'#ban-status .ban-chips { display: flex; flex-wrap: wrap; gap: .35em; }' +
'#ban-status .ban-chips-sm { margin-top: .4em; }' +
'#ban-status .ban-chip { font-size: .85em; padding: .15em .6em; border-radius: 6px; border: 1px solid transparent; }' +
'#ban-status .ban-chip-on { background: var(--ban-ok-bg); color: var(--ban-ok); }' +
'#ban-status .ban-chip-off { color: var(--ban-muted); border-color: var(--ban-card-border); }' +
'#ban-status .ban-chip-feed { background: var(--ban-info-bg); color: var(--ban-info); }' +
'#ban-status .ban-mark { margin-right: .35em; }' +
'#ban-status .ban-stack { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: .25em .6em; font-size: .9em; }' +
'#ban-status .ban-stack .ban-full { grid-column: 1 / -1; }' +
'#ban-status .ban-key { color: var(--ban-muted); }' +
'#ban-status .ban-mono { font-family: monospace; word-break: break-all; }'
]);
/* static labels, only the value nodes are updated by the poll */
function kvRow(label, id) {
return [
E('span', { 'class': 'ban-key' }, [label, ' ']),
E('span', { 'id': id }, ['-'])
];
}
return E('div', { 'class': 'cbi-section', 'id': 'ban-status' }, [
style,
E('div', { 'class': 'ban-grid' }, [
E('div', { 'class': 'ban-card' }, [
E('div', { 'class': 'ban-label' }, [_('Status')]),
E('div', { 'class': 'ban-state spinning', 'id': 'status', 'data-state': '' }, [
E('span', { 'class': 'ban-dot' }),
E('span', { 'class': 'ban-value', 'id': 'state' }, ['-'])
]),
E('div', { 'class': 'ban-chips ban-chips-sm', 'id': 'actual' }, ['-']),
E('div', { 'class': 'ban-sub' }, [
_('Version'), ': ', E('span', { 'id': 'versions' }, ['-'])
])
]),
E('div', { 'class': 'ban-card' }, [
E('div', { 'class': 'ban-label' }, [_('Elements')]),
E('div', { 'class': 'ban-value', 'id': 'elements' }, ['-']),
E('div', { 'class': 'ban-sub', 'id': 'elements_sub' }, ['-'])
]),
E('div', { 'class': 'ban-card' }, [
E('div', { 'class': 'ban-label' }, [_('Last Run')]),
E('div', { 'class': 'ban-value', 'id': 'last' }, ['-']),
E('div', { 'class': 'ban-sub', 'id': 'last_sub' }, ['-'])
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title', 'style': 'margin-bottom:-5px;padding-top:0rem;' }, _('Element Count')),
E('div', { 'class': 'cbi-value-field', 'id': 'elements', 'style': 'margin-bottom:-5px;color:#37c;' }, '-')
E('div', { 'class': 'ban-grid' }, [
E('div', { 'class': 'ban-card' }, [
E('div', { 'class': 'ban-title' }, [_('NFT Information')]),
E('div', { 'class': 'ban-stack', 'id': 'nft' }, ['-'])
]),
E('div', { 'class': 'ban-card' }, [
E('div', { 'class': 'ban-title' }, [_('Interfaces')]),
E('div', { 'class': 'ban-stack' }, [].concat(
kvRow(_('wan-dev'), 'wan_dev'),
kvRow(_('wan-if'), 'wan_if'),
kvRow(_('vlan-allow'), 'vlan_allow'),
kvRow(_('vlan-block'), 'vlan_block'),
kvRow(_('uplink IPv4'), 'uplink4'),
kvRow(_('uplink IPv6'), 'uplink6')
))
]),
E('div', { 'class': 'ban-card' }, [
E('div', { 'class': 'ban-title' }, [_('System Info')]),
E('div', { 'class': 'ban-stack', 'id': 'sys' }, ['-'])
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title', 'style': 'margin-bottom:-5px;padding-top:0rem;' }, _('Active Feeds')),
E('div', { 'class': 'cbi-value-field', 'id': 'feeds', 'style': 'margin-bottom:-5px;color:#37c;' }, '-')
E('div', { 'class': 'ban-card ban-block' }, [
E('div', { 'class': 'ban-title' }, [_('Active Feeds')]),
E('div', { 'class': 'ban-chips', 'id': 'feeds' }, ['-'])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title', 'style': 'margin-bottom:-5px;padding-top:0rem;' }, _('Active Devices')),
E('div', { 'class': 'cbi-value-field', 'id': 'devices', 'style': 'margin-bottom:-5px;color:#37c;' }, '-')
E('div', { 'class': 'ban-card ban-block' }, [
E('div', { 'class': 'ban-title' }, [_('Run Flags')]),
E('div', { 'class': 'ban-chips', 'id': 'flags' }, ['-'])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title', 'style': 'margin-bottom:-5px;padding-top:0rem;' }, _('Active Uplink')),
E('div', { 'class': 'cbi-value-field', 'id': 'uplink', 'style': 'margin-bottom:-5px;color:#37c;' }, '-')
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title', 'style': 'margin-bottom:-5px;padding-top:0rem;' }, _('NFT Information')),
E('div', { 'class': 'cbi-value-field', 'id': 'nft', 'style': 'margin-bottom:-5px;color:#37c;' }, '-')
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title', 'style': 'margin-bottom:-5px;padding-top:0rem;' }, _('Run Information')),
E('div', { 'class': 'cbi-value-field', 'id': 'run', 'style': 'margin-bottom:-5px;color:#37c;' }, '-')
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title', 'style': 'margin-bottom:-5px;padding-top:0rem;' }, _('Run Flags')),
E('div', { 'class': 'cbi-value-field', 'id': 'flags', 'style': 'margin-bottom:-5px;color:#37c;' }, '-')
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title', 'style': 'margin-bottom:-5px;padding-top:0rem;' }, _('Last Run')),
E('div', { 'class': 'cbi-value-field', 'id': 'last', 'style': 'margin-bottom:-5px;color:#37c;' }, '-')
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title', 'style': 'margin-bottom:-5px;padding-top:0rem;' }, _('System Info')),
E('div', { 'class': 'cbi-value-field', 'id': 'sys', 'style': 'margin-bottom:-5px;color:#37c;' }, '-')
E('div', { 'class': 'ban-card ban-block' }, [
E('div', { 'class': 'ban-title' }, [_('Run Information')]),
E('div', { 'class': 'ban-stack', 'id': 'run' }, ['-'])
])
]);
};
@@ -227,6 +227,78 @@ return view.extend({
} else {
content[0] = "";
}
/*
derive the hit ratios from the per set nft counters
All three quotas share one denominator and one time window: the
counters reset on every reload, and they only exist when the
'count' option is enabled. An absent counter is an empty string,
which is what distinguishes "not counted" from "counted zero" -
without that distinction the quotas would silently report a
disabled feature as a perfect result.
*/
function hitStats(sets) {
const stats = { 'sets': 0, 'counted': 0, 'total': 0, 'hits': [], 'worst': [] };
Object.keys(sets || {}).forEach(function (key) {
const set = sets[key];
stats.sets++;
if (`${set.cnt_inbound ?? ''}${set.cnt_outbound ?? ''}` === '') {
return;
}
const count = (parseInt(set.cnt_inbound, 10) || 0) + (parseInt(set.cnt_outbound, 10) || 0);
const elements = parseInt(String(set.cnt_elements).replace(/\D/g, ''), 10) || 0;
stats.counted++;
stats.total += count;
/* 'value' carries the metric a list ranks and scales its bars by */
if (count > 0) {
stats.hits.push({ 'name': key, 'elements': elements, 'hits': count, 'value': count });
}
stats.worst.push({ 'name': key, 'elements': elements, 'hits': count, 'value': elements });
});
stats.hits.sort(function (a, b) { return b.value - a.value; });
/*
worst first: fewest hits, and among those the largest set. Two
ordinal keys rather than an elements per hit ratio, which would
need an invented smoothing term to survive a zero denominator.
*/
stats.worst.sort(function (a, b) { return (a.hits - b.hits) || (b.value - a.value); });
return stats;
}
/*
Two colour scales, because the same kind of number answers two
different questions here.
The flood and invalid packet counters measure attack traffic, so
zero is the good case - green, anything above it red.
The per set counters measure how much a feed actually catches, so
zero means the set earns nothing - red, while a set that matches is
working - blue. That matches the Top Sets and Worst Sets lists,
which rank exactly these numbers.
*/
function threatClass(value) {
return (String(value).trim() === '0') ? 'ban-zero' : 'ban-hit';
}
function setClass(value) {
return (String(value).trim() === '0') ? 'ban-idle' : 'ban-active';
}
/* "ON" plus the nft packet counter, only the counter is colour coded */
function dirNode(direction, count, bold) {
const attrs = bold ? { 'class': 'ban-nowrap', 'style': 'font-weight: bold' } : { 'class': 'ban-nowrap' };
if (!count) {
return E('em', attrs, [direction]);
}
return E('em', attrs, [
direction, bold ? ' (' : ': (',
E('span', { 'class': setClass(count) }, [fmtCount(count)]),
')'
]);
}
rowSets = [];
tblSets = E('table', { 'class': 'table', 'id': 'sets' }, [
E('tr', { 'class': 'tr table-titles' }, [
@@ -240,73 +312,182 @@ return view.extend({
]);
if (content[0].sets) {
let cnt1, cnt2;
Object.keys(content[0].sets).sort().forEach(function (key) {
cnt1 = content[0].sets[key].cnt_inbound ? ': (' + content[0].sets[key].cnt_inbound + ')' : '';
cnt2 = content[0].sets[key].cnt_outbound ? ': (' + content[0].sets[key].cnt_outbound + ')' : '';
rowSets.push([
E('em', key),
E('em', { 'style': 'padding-right: 20px' }, content[0].sets[key].cnt_elements),
E('em', content[0].sets[key].inbound + cnt1),
E('em', content[0].sets[key].outbound + cnt2),
E('em', { 'class': 'ban-nowrap', 'style': 'padding-right: 20px' }, fmtCount(content[0].sets[key].cnt_elements)),
dirNode(content[0].sets[key].inbound, content[0].sets[key].cnt_inbound, false),
dirNode(content[0].sets[key].outbound, content[0].sets[key].cnt_outbound, false),
E('em', content[0].sets[key].port),
E('em', content[0].sets[key].set_elements.join(", "))
]);
});
rowSets.push([
E('em', { 'style': 'font-weight: bold' }, content[0].sum_sets),
E('em', { 'style': 'font-weight: bold; padding-right: 20px' }, content[0].sum_cntelements),
E('em', { 'style': 'font-weight: bold' }, content[0].sum_setinbound + ' (' + content[0].sum_cntinbound + ')'),
E('em', { 'style': 'font-weight: bold' }, content[0].sum_setoutbound + ' (' + content[0].sum_cntoutbound + ')'),
E('em', { 'class': 'ban-nowrap', 'style': 'font-weight: bold; padding-right: 20px' }, fmtCount(content[0].sum_cntelements)),
dirNode(content[0].sum_setinbound, content[0].sum_cntinbound, true),
dirNode(content[0].sum_setoutbound, content[0].sum_cntoutbound, true),
E('em', { 'style': 'font-weight: bold' }, content[0].sum_setports),
E('em', { 'style': 'font-weight: bold' }, content[0].sum_setelements)
]);
}
cbi_update_table(tblSets, rowSets);
const page = E('div', { 'class': 'cbi-map', 'id': 'cbimap' }, [
/*
scoped theme palette, kept in sync with the overview page
*/
const style = E('style', { 'type': 'text/css' }, [
'#ban-report {' +
'--ban-card-bg: rgba(128,128,128,.07);' +
'--ban-card-border: rgba(128,128,128,.28);' +
'--ban-muted: GrayText;' +
'--ban-ok: #1f8a5f;' +
'--ban-err: #c0392b;' +
'--ban-info: #2f6fb0;' +
'--ban-track: rgba(128,128,128,.25);' +
'}' +
'@media (prefers-color-scheme: dark) {' +
'#ban-report {' +
'--ban-ok: #63c79b;' +
'--ban-err: #e8897e;' +
'--ban-info: #7fb3e8;' +
'}}' +
'#ban-report .ban-grid { display: grid; gap: .75em; grid-template-columns: repeat(auto-fit, minmax(min(12em, 100%), 1fr)); margin-bottom: .75em; }' +
'#ban-report .ban-card { background: var(--ban-card-bg); border: 1px solid var(--ban-card-border); border-radius: 8px; padding: .7em .9em; min-width: 0; overflow-wrap: break-word; }' +
'#ban-report .ban-label { font-size: .85em; color: var(--ban-muted); margin-bottom: .3em; }' +
'#ban-report .ban-value { font-size: 1.5em; line-height: 1.3; font-variant-numeric: tabular-nums; }' +
'#ban-report .ban-title { font-weight: bold; margin-bottom: .6em; }' +
'#ban-report .ban-stack { display: grid; grid-template-columns: minmax(0, 1fr) max-content; gap: .25em .9em; font-size: .9em; }' +
'#ban-report .ban-key { color: var(--ban-muted); }' +
'#ban-report .ban-num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }' +
'#ban-report .ban-zero { color: var(--ban-ok); }' +
'#ban-report .ban-hit { color: var(--ban-err); }' +
'#ban-report .ban-idle { color: var(--ban-err); }' +
'#ban-report .ban-active { color: var(--ban-info); }' +
'#ban-report .ban-nowrap { white-space: nowrap; }' +
'#ban-report .ban-hint { font-weight: normal; color: var(--ban-muted); margin-left: .4em; }' +
'#ban-report .ban-row { margin-bottom: .55em; }' +
'#ban-report .ban-row:last-child { margin-bottom: 0; }' +
'#ban-report .ban-row-top { display: flex; justify-content: space-between; gap: .75em; font-size: .9em; margin-bottom: .2em; }' +
'#ban-report .ban-row-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }' +
'#ban-report .ban-row-cnt { color: var(--ban-muted); font-variant-numeric: tabular-nums; white-space: nowrap; }' +
'#ban-report .ban-bar { height: 3px; background: var(--ban-track); border-radius: 2px; }' +
'#ban-report .ban-bar > div { height: 3px; border-radius: 2px; }'
]);
/* group digits with spaces, the same way f_genstatus() formats its counts */
function fmtCount(value) {
const text = String(value ?? '').trim();
if (!/^\d+$/.test(text)) {
return text || '-';
}
return text.replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
}
/*
counter rows are colour coded: nothing blocked is the good case,
anything above zero is worth looking at
*/
function statRow(label, value, counter) {
const text = fmtCount(value);
let cls = 'ban-num';
if (counter && /^[\d ]+$/.test(text)) {
cls += ' ' + threatClass(text.replace(/ /g, ''));
}
return [
E('span', { 'class': 'ban-key' }, [label, ' ']),
E('span', { 'class': cls }, [text])
];
}
const sum = content?.[0] || {};
const hits = hitStats(sum.sets);
/*
ranked lists with relative bars
A quota only means something once the counters exist. The report
carries an empty string rather than a zero while the 'count' option
is off, which is what separates "not counted" from "counted zero".
*/
function topList(title, hint, entries, color, empty) {
const list = entries.slice(0, 10);
/* the worst list is not ordered by the bar metric, so take the max */
const peak = list.reduce(function (max, entry) {
return entry.value > max ? entry.value : max;
}, 0);
let note = empty;
if (hits.sets === 0) {
note = '-';
} else if (hits.counted === 0) {
note = _('packet counters disabled');
}
const rows = list.map(function (entry) {
const width = peak > 0 ? Math.round(entry.value / peak * 100) : 0;
return E('div', { 'class': 'ban-row' }, [
E('div', { 'class': 'ban-row-top' }, [
E('span', { 'class': 'ban-row-name' }, [entry.name]),
E('span', { 'class': 'ban-row-cnt' }, [
`${fmtCount(entry.elements)} / ${fmtCount(entry.hits)}`
])
]),
E('div', { 'class': 'ban-bar' }, [
E('div', { 'style': `width:${width}%; background:var(--ban-${color})` })
])
]);
});
return E('div', { 'class': 'ban-card' }, [
E('div', { 'class': 'ban-title' }, [
title, E('span', { 'class': 'ban-hint' }, [hint])
]),
rows.length ? E('div', {}, rows) : E('em', { 'class': 'ban-label' }, [note])
]);
}
const page = E('div', { 'class': 'cbi-map', 'id': 'ban-report' }, [
style,
E('div', { 'class': 'cbi-section' }, [
E('p', { 'style': 'margin-bottom:1em;' },
_('This report shows the latest NFT Set statistics, press the \'Refresh\' button to get a new one. \
You can also display the specific content of Sets, search for suspicious IPs and finally, these IPs can also be displayed on a map.')),
E('div', { 'class': 'cbi-value' }, [
E('div', { 'class': 'cbi-value-title', 'style': 'margin-bottom:-5px;width:230px;font-weight:bold;' }, _('Timestamp')),
E('div', { 'class': 'cbi-value-title', 'style': 'margin-bottom:-5px;color:#37c;font-weight:bold;' }, content?.[0]?.timestamp || '-')
E('div', { 'class': 'ban-grid' }, [
E('div', { 'class': 'ban-card' }, [
E('div', { 'class': 'ban-label' }, [_('Sets')]),
E('div', { 'class': 'ban-value' }, [fmtCount(sum.sum_sets)])
]),
E('div', { 'class': 'ban-card' }, [
E('div', { 'class': 'ban-label' }, [_('Elements')]),
E('div', { 'class': 'ban-value' }, [fmtCount(sum.sum_cntelements)])
]),
E('div', { 'class': 'ban-card' }, [
E('div', { 'class': 'ban-label' }, [_('Timestamp')]),
E('div', { 'class': 'ban-value' }, [sum.timestamp || '-'])
])
]),
E('hr'),
E('div', { 'class': 'cbi-value' }, [
E('div', { 'class': 'cbi-value-title', 'style': 'margin-top:-5px;width:230px;font-weight:bold;' }, _('blocked syn-flood packets')),
E('div', { 'class': 'cbi-value-title', 'style': 'margin-top:-5px;color:#37c;font-weight:bold;' }, content?.[0]?.sum_synflood || '-')
E('div', { 'class': 'ban-grid' }, [
topList(_('Top Sets'), _('elements / packets'), hits.hits, 'info', _('no hits yet')),
topList(_('Worst Sets'), _('elements / packets'), hits.worst, 'err', '-')
]),
E('div', { 'class': 'cbi-value' }, [
E('div', { 'class': 'cbi-value-title', 'style': 'margin-top:-5px;width:230px;font-weight:bold;' }, _('blocked udp-flood packets')),
E('div', { 'class': 'cbi-value-title', 'style': 'margin-top:-5px;color:#37c;font-weight:bold;' }, content?.[0]?.sum_udpflood || '-')
]),
E('div', { 'class': 'cbi-value' }, [
E('div', { 'class': 'cbi-value-title', 'style': 'margin-top:-5px;width:230px;font-weight:bold;' }, _('blocked icmp-flood packets')),
E('div', { 'class': 'cbi-value-title', 'style': 'margin-top:-5px;color:#37c;font-weight:bold;' }, content?.[0]?.sum_icmpflood || '-')
]),
E('div', { 'class': 'cbi-value' }, [
E('div', { 'class': 'cbi-value-title', 'style': 'margin-top:-5px;width:230px;font-weight:bold;' }, _('blocked invalid ct packets')),
E('div', { 'class': 'cbi-value-title', 'style': 'margin-top:-5px;color:#37c;font-weight:bold;' }, content?.[0]?.sum_ctinvalid || '-')
]),
E('div', { 'class': 'cbi-value' }, [
E('div', { 'class': 'cbi-value-title', 'style': 'margin-top:-5px;width:230px;font-weight:bold;' }, _('blocked invalid tcp packets')),
E('div', { 'class': 'cbi-value-title', 'style': 'margin-top:-5px;color:#37c;font-weight:bold;' }, content?.[0]?.sum_tcpinvalid || '-')
]),
E('div', { 'class': 'cbi-value' }, [
E('div', { 'class': 'cbi-value-title', 'style': 'margin-top:-5px;width:230px;font-weight:bold;' }, _('blocked bcp38 packets')),
E('div', { 'class': 'cbi-value-title', 'style': 'margin-top:-5px;color:#37c;font-weight:bold;' }, content?.[0]?.sum_bcp38 || '-')
]),
E('hr'),
E('div', { 'class': 'cbi-value' }, [
E('div', { 'class': 'cbi-value-title', 'style': 'margin-top:-5px;width:230px;font-weight:bold;' }, _('auto-added IPs to allowlist')),
E('div', { 'class': 'cbi-value-title', 'style': 'margin-top:-5px;color:#37c;font-weight:bold;' }, content?.[0]?.autoadd_allow || '-')
]),
E('div', { 'class': 'cbi-value' }, [
E('div', { 'class': 'cbi-value-title', 'style': 'margin-top:-5px;width:230px;font-weight:bold;' }, _('auto-added IPs to blocklist')),
E('div', { 'class': 'cbi-value-title', 'style': 'margin-top:-5px;color:#37c;font-weight:bold;' }, content?.[0]?.autoadd_block || '-')
E('div', { 'class': 'ban-grid' }, [
E('div', { 'class': 'ban-card' }, [
E('div', { 'class': 'ban-title' }, [_('Blocked Packets')]),
E('div', { 'class': 'ban-stack' }, [].concat(
statRow(_('syn-flood'), sum.sum_synflood, true),
statRow(_('udp-flood'), sum.sum_udpflood, true),
statRow(_('icmp-flood'), sum.sum_icmpflood, true),
statRow(_('invalid ct'), sum.sum_ctinvalid, true),
statRow(_('invalid tcp'), sum.sum_tcpinvalid, true),
statRow(_('bcp38'), sum.sum_bcp38, true)
))
]),
E('div', { 'class': 'ban-card' }, [
E('div', { 'class': 'ban-title' }, [_('Auto-added IPs')]),
E('div', { 'class': 'ban-stack' }, [].concat(
statRow(_('allowlist'), sum.autoadd_allow),
statRow(_('blocklist'), sum.autoadd_block)
))
])
])
]),
E('br'),