mirror of
https://github.com/openwrt/luci.git
synced 2026-10-12 05:07:26 +04:00
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:
3 files changed
+513
-104
No files matched your search
@@ -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'),
|
||||
|
||||
Reference in new issue
Block a user