diff --git a/package/secubox/luci-app-crowdsec-dashboard/htdocs/luci-static/resources/crowdsec-dashboard/soc.css b/package/secubox/luci-app-crowdsec-dashboard/htdocs/luci-static/resources/crowdsec-dashboard/soc.css index a26755f1..41053ec6 100644 --- a/package/secubox/luci-app-crowdsec-dashboard/htdocs/luci-static/resources/crowdsec-dashboard/soc.css +++ b/package/secubox/luci-app-crowdsec-dashboard/htdocs/luci-static/resources/crowdsec-dashboard/soc.css @@ -272,6 +272,66 @@ body.cs-soc-fullwidth #maincontent { margin: 0 !important; width: 100% !importan .soc-empty-icon { font-size: 32px; margin-bottom: 12px; opacity: 0.5; } +/* Threat Types */ +.soc-threat-types { } + +.soc-threat-icon { + display: inline-block; + width: 24px; + height: 24px; + line-height: 24px; + text-align: center; + border-radius: 4px; + margin-right: 8px; + font-size: 14px; +} + +.soc-threat-icon.critical { background: rgba(248, 81, 73, 0.2); } +.soc-threat-icon.high { background: rgba(182, 35, 36, 0.2); } +.soc-threat-icon.medium { background: rgba(210, 153, 34, 0.2); } +.soc-threat-icon.low { background: rgba(88, 166, 255, 0.2); } + +.soc-threat-count { + font-weight: 600; + font-family: 'JetBrains Mono', Consolas, monospace; + color: var(--soc-danger); +} + +.soc-bar-wrap { + display: flex; + align-items: center; + gap: 8px; + height: 20px; +} + +.soc-bar { + height: 8px; + border-radius: 4px; + min-width: 4px; + transition: width 0.3s ease; +} + +.soc-bar.critical { background: var(--soc-danger); } +.soc-bar.high { background: #b62324; } +.soc-bar.medium { background: var(--soc-warning); } +.soc-bar.low { background: var(--soc-info); } + +.soc-bar-pct { + font-size: 11px; + color: var(--soc-text-muted); + font-family: monospace; + min-width: 35px; +} + +.soc-threat-total { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--soc-border); + text-align: right; + font-size: 12px; + color: var(--soc-text-muted); +} + /* Nav */ .soc-nav { display: flex; diff --git a/package/secubox/luci-app-crowdsec-dashboard/htdocs/luci-static/resources/view/crowdsec-dashboard/overview.js b/package/secubox/luci-app-crowdsec-dashboard/htdocs/luci-static/resources/view/crowdsec-dashboard/overview.js index 06bfebea..1a059ea6 100644 --- a/package/secubox/luci-app-crowdsec-dashboard/htdocs/luci-static/resources/view/crowdsec-dashboard/overview.js +++ b/package/secubox/luci-app-crowdsec-dashboard/htdocs/luci-static/resources/view/crowdsec-dashboard/overview.js @@ -50,8 +50,8 @@ return view.extend({ E('div', { 'class': 'soc-card-body', 'id': 'health-check' }, this.renderHealth(status)) ]), E('div', { 'class': 'soc-card' }, [ - E('div', { 'class': 'soc-card-header' }, 'Active Scenarios'), - E('div', { 'class': 'soc-card-body' }, this.renderScenarios(status.scenarios || [])) + E('div', { 'class': 'soc-card-header' }, 'Threat Types Blocked'), + E('div', { 'class': 'soc-card-body' }, this.renderThreatTypes(status.top_scenarios_raw)) ]) ]); @@ -91,16 +91,16 @@ return view.extend({ }, renderStats: function(d) { - var totalBans = (d.total_decisions || 0) + (d.capi_decisions || 0) + (d.local_decisions || 0); - // Use total_decisions if set, otherwise sum capi + local - if (d.total_decisions > 0) totalBans = d.total_decisions; + var totalBans = d.active_bans || d.total_decisions || 0; + var droppedPkts = parseInt(d.dropped_packets || 0); + var droppedBytes = parseInt(d.dropped_bytes || 0); var stats = [ - { label: 'CAPI Blocklist', value: d.capi_decisions || 0, type: (d.capi_decisions || 0) > 0 ? 'success' : '' }, - { label: 'Local Bans', value: d.local_decisions || 0, type: (d.local_decisions || 0) > 0 ? 'danger' : '' }, + { label: 'Active Bans', value: this.formatNumber(totalBans), type: totalBans > 0 ? 'success' : '' }, + { label: 'Blocked Packets', value: this.formatNumber(droppedPkts), type: droppedPkts > 0 ? 'danger' : '' }, + { label: 'Blocked Traffic', value: this.formatBytes(droppedBytes), type: droppedBytes > 0 ? 'danger' : '' }, { label: 'Alerts (24h)', value: d.alerts_24h || 0, type: (d.alerts_24h || 0) > 10 ? 'warning' : '' }, - { label: 'Scenarios', value: d.scenario_count || 0, type: 'success' }, - { label: 'Bouncers', value: d.bouncer_count || 0, type: (d.bouncer_count || 0) > 0 ? 'success' : 'warning' }, - { label: 'Countries', value: Object.keys(d.countries || {}).length, type: '' } + { label: 'Local Bans', value: d.local_decisions || 0, type: (d.local_decisions || 0) > 0 ? 'warning' : '' }, + { label: 'Bouncers', value: d.bouncer_count || 0, type: (d.bouncer_count || 0) > 0 ? 'success' : 'warning' } ]; return E('div', { 'class': 'soc-stats' }, stats.map(function(s) { return E('div', { 'class': 'soc-stat ' + s.type }, [ @@ -110,6 +110,19 @@ return view.extend({ })); }, + formatNumber: function(n) { + if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M'; + if (n >= 1000) return (n / 1000).toFixed(1) + 'K'; + return String(n); + }, + + formatBytes: function(bytes) { + if (bytes >= 1073741824) return (bytes / 1073741824).toFixed(1) + 'GB'; + if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + 'MB'; + if (bytes >= 1024) return (bytes / 1024).toFixed(1) + 'KB'; + return bytes + 'B'; + }, + renderAlerts: function(alerts) { if (!alerts || !alerts.length) { return E('div', { 'class': 'soc-empty' }, [ @@ -202,6 +215,56 @@ return view.extend({ ]); }, + renderThreatTypes: function(rawJson) { + var self = this; + var threats = []; + if (rawJson) { + try { threats = JSON.parse(rawJson); } catch(e) {} + } + if (!threats || !threats.length) { + return E('div', { 'class': 'soc-empty' }, [ + E('div', { 'class': 'soc-empty-icon' }, '\u{1F6E1}'), + 'No threats blocked yet' + ]); + } + var total = threats.reduce(function(sum, t) { return sum + (t.count || 0); }, 0); + return E('div', { 'class': 'soc-threat-types' }, [ + E('table', { 'class': 'soc-table' }, [ + E('thead', {}, E('tr', {}, [ + E('th', {}, 'Threat Type'), + E('th', {}, 'Blocked'), + E('th', { 'style': 'width:40%' }, 'Distribution') + ])), + E('tbody', {}, threats.map(function(t) { + var pct = total > 0 ? Math.round((t.count / total) * 100) : 0; + var severity = t.scenario.includes('bruteforce') ? 'high' : + t.scenario.includes('exploit') ? 'critical' : + t.scenario.includes('scan') ? 'medium' : 'low'; + return E('tr', {}, [ + E('td', {}, [ + E('span', { 'class': 'soc-threat-icon ' + severity }, self.getThreatIcon(t.scenario)), + E('span', { 'class': 'soc-scenario' }, t.scenario) + ]), + E('td', { 'class': 'soc-threat-count' }, self.formatNumber(t.count)), + E('td', {}, E('div', { 'class': 'soc-bar-wrap' }, [ + E('div', { 'class': 'soc-bar ' + severity, 'style': 'width:' + pct + '%' }), + E('span', { 'class': 'soc-bar-pct' }, pct + '%') + ])) + ]); + })) + ]), + E('div', { 'class': 'soc-threat-total' }, 'Total blocked: ' + self.formatNumber(total)) + ]); + }, + + getThreatIcon: function(scenario) { + if (scenario.includes('bruteforce')) return '\u{1F510}'; + if (scenario.includes('exploit')) return '\u{1F4A3}'; + if (scenario.includes('scan')) return '\u{1F50D}'; + if (scenario.includes('http')) return '\u{1F310}'; + return '\u26A0'; + }, + pollData: function() { var self = this; return api.getOverview().then(function(data) { diff --git a/package/secubox/luci-app-crowdsec-dashboard/root/usr/libexec/rpcd/luci.crowdsec-dashboard b/package/secubox/luci-app-crowdsec-dashboard/root/usr/libexec/rpcd/luci.crowdsec-dashboard index dfe646da..21478e23 100755 --- a/package/secubox/luci-app-crowdsec-dashboard/root/usr/libexec/rpcd/luci.crowdsec-dashboard +++ b/package/secubox/luci-app-crowdsec-dashboard/root/usr/libexec/rpcd/luci.crowdsec-dashboard @@ -285,17 +285,18 @@ get_dashboard_stats() { bouncers_count=$(run_cscli bouncers list -o json 2>/dev/null | jsonfilter -e '@[*]' 2>/dev/null | wc -l) json_add_int "bouncers" "${bouncers_count:-0}" - # Top scenarios (from alerts) + # Top scenarios (from cscli metrics - includes CAPI blocklist breakdown) local scenarios - scenarios=$(run_cscli alerts list -o json --limit 100 2>/dev/null | \ - jsonfilter -e '@[*].scenario' 2>/dev/null | \ - sort | uniq -c | sort -rn | head -5 | \ + scenarios=$(run_cscli metrics 2>/dev/null | \ + grep -E '^\| [a-z].*\| CAPI' | \ + sed 's/|//g;s/^[ ]*//;s/[ ]*$//' | \ + awk '{print $4, $1}' | sort -rn | head -5 | \ awk '{print "{\"scenario\":\"" $2 "\",\"count\":" $1 "}"}' | \ tr '\n' ',' | sed 's/,$//') - + json_add_string "top_scenarios_raw" "[$scenarios]" - - # Top countries (from alerts - GeoIP enriched) + + # Top countries (from alerts with GeoIP enrichment) local countries countries=$(run_cscli alerts list -o json --limit 500 2>/dev/null | \ jsonfilter -e '@[*].source.cn' 2>/dev/null | \ @@ -2216,6 +2217,32 @@ get_overview() { json_add_int "alerts_24h" "${alerts_count:-0}" json_add_int "bouncer_count" "${bouncers_count:-0}" + # Bouncer effectiveness stats (packets/bytes dropped vs processed) + local dropped_packets=0 + local dropped_bytes=0 + local processed_packets=0 + local processed_bytes=0 + local active_bans=0 + if [ "$cs_running" = "1" ]; then + # Parse Total line from Bouncer Metrics table + # Format: | Total | 16.00k | 13.72k | 231 | 356.19k | 6.02k | + local totals + totals=$(run_cscli metrics 2>/dev/null | grep -E '^\|.*Total' | sed 's/|//g') + if [ -n "$totals" ]; then + # Convert k/M suffixes to numbers + active_bans=$(echo "$totals" | awk '{gsub(/k$/,"",$2); gsub(/M$/,"",$2); if($2~/\./){$2=$2*1000}; print int($2)}') + dropped_bytes=$(echo "$totals" | awk '{v=$3; gsub(/k$/,"",v); gsub(/M$/,"",v); if(v~/\./){v=v*1000}; print v}') + dropped_packets=$(echo "$totals" | awk '{print $4}') + processed_bytes=$(echo "$totals" | awk '{v=$5; gsub(/k$/,"",v); gsub(/M$/,"",v); if(v~/\./){v=v*1000}; if(v=="-")v=0; print v}') + processed_packets=$(echo "$totals" | awk '{v=$6; gsub(/k$/,"",v); gsub(/M$/,"",v); if(v~/\./){v=v*1000}; if(v=="-")v=0; print v}') + fi + fi + json_add_int "active_bans" "${active_bans:-0}" + json_add_string "dropped_packets" "${dropped_packets:-0}" + json_add_string "dropped_bytes" "${dropped_bytes:-0}" + json_add_string "processed_packets" "${processed_packets:-0}" + json_add_string "processed_bytes" "${processed_bytes:-0}" + # GeoIP status - check if GeoIP database exists local geoip_enabled=0 [ -f "/var/lib/crowdsec/data/GeoLite2-City.mmdb" ] && geoip_enabled=1 @@ -2237,18 +2264,22 @@ get_overview() { fi json_add_int "scenario_count" "${scenario_count:-0}" - # Top scenarios (from cached/limited alerts) + # Top scenarios (from cscli metrics - includes CAPI blocklist breakdown) local scenarios="" if [ "$cs_running" = "1" ]; then - scenarios=$(run_cscli alerts list -o json --limit 100 2>/dev/null | \ - jsonfilter -e '@[*].scenario' 2>/dev/null | \ - sort | uniq -c | sort -rn | head -5 | \ + # Parse "Local API Decisions" table from cscli metrics + # Lines like: | ssh:bruteforce | CAPI | ban | 12095 | + scenarios=$(run_cscli metrics 2>/dev/null | \ + grep -E '^\| [a-z].*\| CAPI' | \ + sed 's/|//g;s/^[ ]*//;s/[ ]*$//' | \ + awk '{print $4, $1}' | sort -rn | head -5 | \ awk '{print "{\"scenario\":\"" $2 "\",\"count\":" $1 "}"}' | \ tr '\n' ',' | sed 's/,$//') fi json_add_string "top_scenarios_raw" "[$scenarios]" - # Top countries (from alerts with GeoIP) + # Top countries (from alerts with GeoIP enrichment) + # Note: CAPI decisions don't include country - only local detections have GeoIP local countries="" if [ "$cs_running" = "1" ]; then countries=$(run_cscli alerts list -o json --limit 200 2>/dev/null | \