From fa505f7cb15ffa82622b577686cc4c13eb738935 Mon Sep 17 00:00:00 2001 From: Dirk Brenken Date: Thu, 6 Aug 2026 21:30:00 +0200 Subject: [PATCH] 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 --- applications/luci-app-banip/Makefile | 4 +- .../resources/view/banip/overview.js | 340 +++++++++++++++--- .../resources/view/banip/setreport.js | 273 +++++++++++--- 3 files changed, 513 insertions(+), 104 deletions(-) diff --git a/applications/luci-app-banip/Makefile b/applications/luci-app-banip/Makefile index f9cacf156b..66f8042c8b 100644 --- a/applications/luci-app-banip/Makefile +++ b/applications/luci-app-banip/Makefile @@ -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 diff --git a/applications/luci-app-banip/htdocs/luci-static/resources/view/banip/overview.js b/applications/luci-app-banip/htdocs/luci-static/resources/view/banip/overview.js index 188a358025..70950aeaf7 100644 --- a/applications/luci-app-banip/htdocs/luci-static/resources/view/banip/overview.js +++ b/applications/luci-app-banip/htdocs/luci-static/resources/view/banip/overview.js @@ -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 " (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' }, ['-']) ]) ]); }; diff --git a/applications/luci-app-banip/htdocs/luci-static/resources/view/banip/setreport.js b/applications/luci-app-banip/htdocs/luci-static/resources/view/banip/setreport.js index ac739d6740..7597a34ae9 100644 --- a/applications/luci-app-banip/htdocs/luci-static/resources/view/banip/setreport.js +++ b/applications/luci-app-banip/htdocs/luci-static/resources/view/banip/setreport.js @@ -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'),