feat: Enhance monitoring page layout and fix nDPId detailed flows

Monitoring page:
- Move Current Statistics card above histogram charts
- Replace Network Throughput with System Load chart
- Fix API field mapping (usage_percent vs percent)
- Parse load from cpu.load string format

nDPId app:
- Add get_detailed_flows and get_categories RPCD methods
- Fix subshell variable scope bug in RPCD script
- Add interface scanning from /sys/class/net
- Update ACL permissions for new methods
- Enhance flows.js with Array.isArray data handling

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
CyberMind-FR 2026-01-09 12:43:01 +01:00
parent 66f4f32655
commit 50bd0c872e
11 changed files with 1120 additions and 212 deletions

View File

@ -29,6 +29,7 @@ return view.extend({
cpuHistory: [],
memoryHistory: [],
diskHistory: [],
loadHistory: [],
networkHistory: [],
maxDataPoints: 60,
latestHealth: {},
@ -50,15 +51,23 @@ return view.extend({
addDataPoint: function(health) {
var timestamp = Date.now();
this.cpuHistory.push({ time: timestamp, value: (health.cpu && health.cpu.percent) || 0 });
this.memoryHistory.push({ time: timestamp, value: (health.memory && health.memory.percent) || 0 });
this.diskHistory.push({ time: timestamp, value: (health.disk && health.disk.percent) || 0 });
// API returns usage_percent, not percent
this.cpuHistory.push({ time: timestamp, value: (health.cpu && (health.cpu.usage_percent || health.cpu.percent)) || 0 });
this.memoryHistory.push({ time: timestamp, value: (health.memory && (health.memory.usage_percent || health.memory.percent)) || 0 });
this.diskHistory.push({ time: timestamp, value: (health.disk && (health.disk.usage_percent || health.disk.percent)) || 0 });
// System load - parse from string "2.14 1.86 1.70" or array, scale to percentage (assume 4 cores = 400% max)
var loadStr = (health.cpu && health.cpu.load) || (health.load && (Array.isArray(health.load) ? health.load[0] : health.load)) || '0';
var loadAvg = Array.isArray(loadStr) ? loadStr[0] : (typeof loadStr === 'string' ? parseFloat(loadStr.split(' ')[0]) : loadStr);
var numCores = (health.cpu && health.cpu.count) || 4;
var loadPercent = Math.min(100, (parseFloat(loadAvg) / numCores) * 100);
this.loadHistory.push({ time: timestamp, value: loadPercent, raw: loadAvg });
var netRx = (health.network && health.network.rx_bytes) || 0;
var netTx = (health.network && health.network.tx_bytes) || 0;
this.networkHistory.push({ time: timestamp, rx: netRx, tx: netTx });
['cpuHistory', 'memoryHistory', 'diskHistory', 'networkHistory'].forEach(function(key) {
['cpuHistory', 'memoryHistory', 'diskHistory', 'loadHistory', 'networkHistory'].forEach(function(key) {
if (this[key].length > this.maxDataPoints)
this[key].shift();
}, this);
@ -73,8 +82,8 @@ return view.extend({
E('link', { 'rel': 'stylesheet', 'href': L.resource('secubox/monitoring.css') }),
SecuNav.renderTabs('monitoring'),
this.renderHeader(),
this.renderChartsGrid(),
this.renderCurrentStatsCard()
this.renderCurrentStatsCard(),
this.renderChartsGrid()
]);
this.updateCharts();
@ -131,7 +140,7 @@ return view.extend({
this.renderChartCard('cpu', _('CPU Usage'), '%', '#6366f1'),
this.renderChartCard('memory', _('Memory Usage'), '%', '#22c55e'),
this.renderChartCard('disk', _('Disk Usage'), '%', '#f59e0b'),
this.renderChartCard('network', _('Network Throughput'), 'B/s', '#3b82f6')
this.renderChartCard('load', _('System Load'), '', '#ec4899')
]);
},
@ -167,7 +176,10 @@ return view.extend({
renderStatsTable: function() {
var snapshot = this.getLatestSnapshot();
var rates = this.getNetworkRateSummary();
var load = (this.latestHealth.load && this.latestHealth.load[0]) || '0.00';
// Load from cpu.load string "2.14 1.86 1.70" or load array
var loadStr = (this.latestHealth.cpu && this.latestHealth.cpu.load) ||
(this.latestHealth.load && (Array.isArray(this.latestHealth.load) ? this.latestHealth.load[0] : this.latestHealth.load)) || '0.00';
var load = typeof loadStr === 'string' ? loadStr.split(' ')[0] : loadStr;
var stats = [
{ label: _('CPU Usage'), value: snapshot.cpu.value.toFixed(1) + '%', icon: '⚡' },
@ -193,7 +205,7 @@ return view.extend({
this.drawChart('cpu', this.cpuHistory, '#6366f1');
this.drawChart('memory', this.memoryHistory, '#22c55e');
this.drawChart('disk', this.diskHistory, '#f59e0b');
this.drawNetworkChart();
this.drawLoadChart();
},
drawChart: function(type, data, color) {
@ -282,6 +294,42 @@ return view.extend({
}
},
drawLoadChart: function() {
var svg = document.getElementById('chart-load');
var currentEl = document.getElementById('current-load');
if (!svg || this.loadHistory.length === 0)
return;
var width = 600;
var height = 200;
var padding = 12;
var values = this.loadHistory.map(function(d) { return d.value; });
var maxValue = Math.max(100, Math.max.apply(Math, values));
var minValue = 0;
var pathPoints = this.loadHistory.map(function(point, idx) {
var x = padding + (width - 2 * padding) * (idx / Math.max(1, this.maxDataPoints - 1));
var y = height - padding - ((point.value - minValue) / (maxValue - minValue)) * (height - 2 * padding);
return x + ',' + y;
}, this).join(' ');
svg.innerHTML = '';
svg.appendChild(E('polyline', {
'points': pathPoints,
'fill': 'none',
'stroke': '#ec4899',
'stroke-width': '2',
'stroke-linejoin': 'round',
'stroke-linecap': 'round'
}));
if (currentEl) {
var lastPoint = this.loadHistory[this.loadHistory.length - 1];
currentEl.textContent = (lastPoint.raw || '0.00');
}
},
updateCurrentStats: function() {
var statsContainer = document.getElementById('current-stats');
if (statsContainer)

View File

@ -7,8 +7,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-ndpid
PKG_VERSION:=0.9.1
PKG_RELEASE:=2
PKG_VERSION:=1.0.0
PKG_RELEASE:=1
PKG_ARCH:=all
PKG_LICENSE:=Apache-2.0

View File

@ -20,6 +20,18 @@ var callRealtimeFlows = rpc.declare({
expect: { }
});
var callDetailedFlows = rpc.declare({
object: 'luci.ndpid',
method: 'get_detailed_flows',
expect: { flows: [] }
});
var callCategories = rpc.declare({
object: 'luci.ndpid',
method: 'get_categories',
expect: { categories: [] }
});
var callInterfaceStats = rpc.declare({
object: 'luci.ndpid',
method: 'get_interface_stats',
@ -136,9 +148,11 @@ return baseclass.extend({
// Read methods
getServiceStatus: callServiceStatus,
getRealtimeFlows: callRealtimeFlows,
getDetailedFlows: callDetailedFlows,
getInterfaceStats: callInterfaceStats,
getTopApplications: callTopApplications,
getTopProtocols: callTopProtocols,
getCategories: callCategories,
getConfig: callConfig,
getDashboard: callDashboard,
getInterfaces: callInterfaces,

View File

@ -527,3 +527,276 @@
background: var(--ndpi-border);
border-radius: 4px;
}
/* Grid Layouts */
.ndpi-grid-2 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 20px;
margin-bottom: 20px;
}
.ndpi-card-wide {
grid-column: 1 / -1;
}
/* Applications List */
.ndpi-apps-list {
display: flex;
flex-direction: column;
gap: 14px;
}
.ndpi-app-item {
padding: 12px 0;
border-bottom: 1px solid var(--ndpi-border);
}
.ndpi-app-item:last-child {
border-bottom: none;
}
.ndpi-app-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.ndpi-app-icon {
font-size: 20px;
width: 28px;
text-align: center;
}
.ndpi-app-info {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
.ndpi-app-name {
font-weight: 600;
color: var(--ndpi-text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ndpi-app-host {
font-size: 11px;
color: var(--ndpi-text-muted);
font-family: var(--ndpi-font-mono);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ndpi-app-bytes {
font-family: var(--ndpi-font-mono);
font-size: 12px;
font-weight: 600;
color: var(--ndpi-accent-cyan);
margin-left: auto;
}
.ndpi-app-bar {
height: 6px;
background: var(--ndpi-bg-primary);
border-radius: 3px;
overflow: hidden;
}
.ndpi-app-bar-fill {
height: 100%;
border-radius: 3px;
transition: width 0.5s ease;
}
.ndpi-app-meta {
font-size: 10px;
color: var(--ndpi-text-muted);
margin-top: 6px;
}
/* Categories List */
.ndpi-categories-list {
display: flex;
flex-direction: column;
gap: 14px;
}
.ndpi-category-item {
padding: 10px 0;
border-bottom: 1px solid var(--ndpi-border);
}
.ndpi-category-item:last-child {
border-bottom: none;
}
.ndpi-category-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.ndpi-category-name {
font-weight: 600;
font-size: 14px;
}
.ndpi-category-bytes {
font-family: var(--ndpi-font-mono);
font-size: 12px;
color: var(--ndpi-text-secondary);
}
.ndpi-category-bar {
height: 6px;
background: var(--ndpi-bg-primary);
border-radius: 3px;
overflow: hidden;
}
.ndpi-category-bar-fill {
height: 100%;
border-radius: 3px;
transition: width 0.5s ease;
}
.ndpi-category-meta {
font-size: 10px;
color: var(--ndpi-text-muted);
margin-top: 6px;
}
/* Flows Table */
.ndpi-flows-table-container {
max-height: 500px;
overflow-y: auto;
}
.ndpi-flows-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.ndpi-flows-table th {
text-align: left;
padding: 10px 12px;
background: var(--ndpi-bg-tertiary);
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
color: var(--ndpi-text-muted);
border-bottom: 1px solid var(--ndpi-border);
position: sticky;
top: 0;
z-index: 1;
}
.ndpi-flow-row {
transition: background 0.2s;
}
.ndpi-flow-row:hover {
background: rgba(6, 182, 212, 0.05);
}
.ndpi-flow-row td {
padding: 10px 12px;
border-bottom: 1px solid var(--ndpi-border);
vertical-align: middle;
}
.ndpi-flow-ended {
opacity: 0.5;
}
.ndpi-flow-app {
display: flex;
align-items: center;
gap: 8px;
min-width: 150px;
}
.ndpi-flow-src, .ndpi-flow-dst {
font-family: var(--ndpi-font-mono);
font-size: 11px;
white-space: nowrap;
}
.ndpi-flow-arrow {
color: var(--ndpi-text-muted);
font-size: 14px;
}
/* Protocol Badges */
.ndpi-proto-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
}
.ndpi-proto-tcp {
background: rgba(14, 165, 233, 0.2);
color: #0ea5e9;
}
.ndpi-proto-udp {
background: rgba(16, 185, 129, 0.2);
color: #10b981;
}
.ndpi-proto-icmp {
background: rgba(245, 158, 11, 0.2);
color: #f59e0b;
}
/* Category Badge */
.ndpi-category-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 10px;
font-weight: 500;
color: white;
white-space: nowrap;
}
/* Flow State */
.ndpi-state-active {
color: var(--ndpi-accent-green);
font-size: 14px;
}
.ndpi-state-ended {
color: var(--ndpi-text-muted);
font-size: 14px;
}
.ndpi-empty-row {
text-align: center;
padding: 40px 20px;
color: var(--ndpi-text-muted);
}
.ndpi-empty-hint {
font-size: 12px;
color: var(--ndpi-text-muted);
margin-top: 8px;
}
/* Responsive for grids */
@media (max-width: 900px) {
.ndpi-grid-2 {
grid-template-columns: 1fr;
}
}

View File

@ -11,7 +11,56 @@ return view.extend({
pollActive: true,
load: function() {
return api.getAllData();
return Promise.all([
api.getAllData(),
api.getCategories().catch(function() { return { categories: [] }; })
]).then(function(results) {
var data = results[0];
data.categories = results[1];
return data;
});
},
getAppIcon: function(app, category) {
var icons = {
'HTTP': '🌐', 'HTTPS': '🔒', 'TLS': '🔒', 'SSL': '🔒',
'DNS': '📡', 'NTP': '🕐', 'DHCP': '📋',
'SSH': '🖥️', 'Telnet': '💻',
'YouTube': '▶️', 'Netflix': '🎬', 'Twitch': '🎮',
'Facebook': '👤', 'Twitter': '🐦', 'Instagram': '📷', 'TikTok': '🎵',
'WhatsApp': '💬', 'Telegram': '✈️', 'Discord': '🎧',
'BitTorrent': '📥', 'eDonkey': '📥',
'Spotify': '🎵', 'AppleMusic': '🎵',
'Dropbox': '📦', 'GoogleDrive': '📦', 'OneDrive': '📦',
'Zoom': '📹', 'Teams': '👥', 'Skype': '📞',
'VPN': '🛡️', 'OpenVPN': '🛡️', 'WireGuard': '🛡️',
'QUIC': '⚡', 'HTTP2': '⚡',
'SMTP': '📧', 'IMAP': '📧', 'POP3': '📧',
'FTP': '📁', 'SFTP': '📁', 'SMB': '📁',
'ICMP': '📶', 'IGMP': '📡',
'Unknown': '❓'
};
return icons[app] || icons[category] || '📦';
},
getCategoryColor: function(category) {
var colors = {
'Web': '#3b82f6',
'Video': '#ef4444',
'Streaming': '#f59e0b',
'SocialNetwork': '#ec4899',
'Chat': '#8b5cf6',
'VoIP': '#10b981',
'Game': '#06b6d4',
'Download': '#f97316',
'Cloud': '#6366f1',
'VPN': '#14b8a6',
'Mail': '#84cc16',
'FileTransfer': '#a855f7',
'Network': '#64748b',
'Unknown': '#94a3b8'
};
return colors[category] || '#64748b';
},
updateDashboard: function(data) {
@ -46,7 +95,7 @@ return view.extend({
});
// Update interface stats
var interfaces = (data.interfaces || {}).interfaces || [];
var interfaces = Array.isArray(data.interfaces) ? data.interfaces : (data.interfaces || {}).interfaces || [];
interfaces.forEach(function(iface) {
var card = document.querySelector('.ndpi-iface-card[data-iface="' + iface.name + '"]');
if (!card) return;
@ -121,9 +170,11 @@ return view.extend({
var service = dashboard.service || {};
var flows = dashboard.flows || {};
var system = dashboard.system || {};
var interfaces = (data.interfaces || {}).interfaces || [];
var applications = (data.applications || {}).applications || [];
var protocols = (data.protocols || {}).protocols || [];
// Handle both array and object formats from API
var interfaces = Array.isArray(data.interfaces) ? data.interfaces : (data.interfaces || {}).interfaces || [];
var applications = Array.isArray(data.applications) ? data.applications : (data.applications || {}).applications || [];
var protocols = Array.isArray(data.protocols) ? data.protocols : (data.protocols || {}).protocols || [];
var categories = Array.isArray(data.categories) ? data.categories : (data.categories || {}).categories || [];
var view = E('div', { 'class': 'ndpid-dashboard' }, [
E('link', { 'rel': 'stylesheet', 'href': L.resource('ndpid/dashboard.css') }),
@ -276,43 +327,81 @@ return view.extend({
)
]),
// Top Applications
E('div', { 'class': 'ndpi-card' }, [
E('div', { 'class': 'ndpi-card-header' }, [
E('div', { 'class': 'ndpi-card-title' }, [
E('span', { 'class': 'ndpi-card-title-icon' }, '📱'),
'Top Applications'
])
]),
E('div', { 'class': 'ndpi-card-body' },
applications.length > 0 ?
E('div', { 'class': 'ndpi-table-container' }, [
E('table', { 'class': 'ndpi-table' }, [
E('thead', {}, [
E('tr', {}, [
E('th', {}, 'Application'),
E('th', {}, 'Flows'),
E('th', {}, 'Traffic')
])
]),
E('tbody', {},
applications.map(function(app) {
return E('tr', {}, [
E('td', {}, [
E('span', { 'class': 'ndpi-app-name' }, app.name || 'unknown')
// Grid layout for Applications and Categories
E('div', { 'class': 'ndpi-grid-2' }, [
// Top Applications
E('div', { 'class': 'ndpi-card' }, [
E('div', { 'class': 'ndpi-card-header' }, [
E('div', { 'class': 'ndpi-card-title' }, [
E('span', { 'class': 'ndpi-card-title-icon' }, '📱'),
'Top Applications'
]),
E('div', { 'class': 'ndpi-card-badge' }, applications.length + ' detected')
]),
E('div', { 'class': 'ndpi-card-body' },
applications.length > 0 ?
E('div', { 'class': 'ndpi-apps-list' },
(function() {
var maxBytes = Math.max.apply(null, applications.map(function(a) { return a.bytes || 0; })) || 1;
return applications.slice(0, 8).map(function(app) {
var pct = Math.round(((app.bytes || 0) / maxBytes) * 100);
return E('div', { 'class': 'ndpi-app-item' }, [
E('div', { 'class': 'ndpi-app-header' }, [
E('span', { 'class': 'ndpi-app-icon' }, self.getAppIcon(app.name, app.category)),
E('span', { 'class': 'ndpi-app-name' }, app.name || 'Unknown'),
E('span', { 'class': 'ndpi-app-bytes' }, api.formatBytes(app.bytes || 0))
]),
E('td', { 'class': 'mono' }, api.formatNumber(app.flows)),
E('td', { 'class': 'mono' }, api.formatBytes(app.bytes))
E('div', { 'class': 'ndpi-app-bar' }, [
E('div', { 'class': 'ndpi-app-bar-fill', 'style': 'width:' + pct + '%;background:' + self.getCategoryColor(app.category) })
]),
E('div', { 'class': 'ndpi-app-meta' }, (app.flows || 0) + ' flows · ' + (app.category || 'Unknown'))
]);
})
)
});
})()
) :
E('div', { 'class': 'ndpi-empty' }, [
E('div', { 'class': 'ndpi-empty-icon' }, '📱'),
E('div', { 'class': 'ndpi-empty-text' }, 'No applications detected yet'),
E('p', {}, 'Generate network traffic to see app detection')
])
]) :
E('div', { 'class': 'ndpi-empty' }, [
E('div', { 'class': 'ndpi-empty-icon' }, '📱'),
E('div', { 'class': 'ndpi-empty-text' }, 'No applications detected yet')
])
)
)
]),
// Traffic Categories
E('div', { 'class': 'ndpi-card' }, [
E('div', { 'class': 'ndpi-card-header' }, [
E('div', { 'class': 'ndpi-card-title' }, [
E('span', { 'class': 'ndpi-card-title-icon' }, '🏷️'),
'Traffic Categories'
]),
E('div', { 'class': 'ndpi-card-badge' }, categories.length + ' types')
]),
E('div', { 'class': 'ndpi-card-body' },
categories.length > 0 ?
E('div', { 'class': 'ndpi-categories-list' },
(function() {
var maxBytes = Math.max.apply(null, categories.map(function(c) { return c.bytes || 0; })) || 1;
return categories.slice(0, 8).map(function(cat) {
var pct = Math.round(((cat.bytes || 0) / maxBytes) * 100);
return E('div', { 'class': 'ndpi-category-item' }, [
E('div', { 'class': 'ndpi-category-header' }, [
E('span', { 'class': 'ndpi-category-name', 'style': 'color:' + self.getCategoryColor(cat.name) }, cat.name),
E('span', { 'class': 'ndpi-category-bytes' }, api.formatBytes(cat.bytes || 0))
]),
E('div', { 'class': 'ndpi-category-bar' }, [
E('div', { 'class': 'ndpi-category-bar-fill', 'style': 'width:' + pct + '%;background:' + self.getCategoryColor(cat.name) })
]),
E('div', { 'class': 'ndpi-category-meta' }, (cat.apps || 0) + ' apps · ' + (cat.flows || 0) + ' flows')
]);
});
})()
) :
E('div', { 'class': 'ndpi-empty' }, [
E('div', { 'class': 'ndpi-empty-icon' }, '🏷️'),
E('div', { 'class': 'ndpi-empty-text' }, 'No categories detected yet')
])
)
])
]),
// Top Protocols

View File

@ -12,27 +12,85 @@ return view.extend({
load: function() {
return Promise.all([
api.getRealtimeFlows(),
api.getInterfaceStats(),
api.getTopProtocols()
api.getRealtimeFlows().catch(function(e) { console.log('getRealtimeFlows error:', e); return {}; }),
api.getDetailedFlows().catch(function(e) { console.log('getDetailedFlows error:', e); return { flows: [] }; }),
api.getTopApplications().catch(function(e) { console.log('getTopApplications error:', e); return { applications: [] }; }),
api.getCategories().catch(function(e) { console.log('getCategories error:', e); return { categories: [] }; }),
api.getInterfaceStats().catch(function(e) { console.log('getInterfaceStats error:', e); return { interfaces: [] }; }),
api.getTopProtocols().catch(function(e) { console.log('getTopProtocols error:', e); return { protocols: [] }; })
]).then(function(results) {
console.log('nDPId flows.js load results:', results);
console.log('Detailed flows:', results[1]);
console.log('Applications:', results[2]);
console.log('Categories:', results[3]);
return {
flows: results[0],
interfaces: results[1],
protocols: results[2]
status: results[0],
flows: results[1],
applications: results[2],
categories: results[3],
interfaces: results[4],
protocols: results[5]
};
});
},
updateFlows: function(data) {
var flows = data.flows || {};
getProtoName: function(proto) {
var protos = { '6': 'TCP', '17': 'UDP', '1': 'ICMP', 'tcp': 'TCP', 'udp': 'UDP', 'icmp': 'ICMP' };
return protos[proto] || proto || '?';
},
getAppIcon: function(app, category) {
var icons = {
'HTTP': '🌐', 'HTTPS': '🔒', 'TLS': '🔒', 'SSL': '🔒',
'DNS': '📡', 'NTP': '🕐', 'DHCP': '📋',
'SSH': '🖥️', 'Telnet': '💻',
'YouTube': '▶️', 'Netflix': '🎬', 'Twitch': '🎮',
'Facebook': '👤', 'Twitter': '🐦', 'Instagram': '📷', 'TikTok': '🎵',
'WhatsApp': '💬', 'Telegram': '✈️', 'Discord': '🎧',
'BitTorrent': '📥', 'eDonkey': '📥',
'Spotify': '🎵', 'AppleMusic': '🎵',
'Dropbox': '📦', 'GoogleDrive': '📦', 'OneDrive': '📦',
'Zoom': '📹', 'Teams': '👥', 'Skype': '📞',
'VPN': '🛡️', 'OpenVPN': '🛡️', 'WireGuard': '🛡️',
'QUIC': '⚡', 'HTTP2': '⚡',
'SMTP': '📧', 'IMAP': '📧', 'POP3': '📧',
'FTP': '📁', 'SFTP': '📁', 'SMB': '📁',
'ICMP': '📶', 'IGMP': '📡',
'Unknown': '❓'
};
return icons[app] || icons[category] || '📦';
},
getCategoryColor: function(category) {
var colors = {
'Web': '#3b82f6',
'Video': '#ef4444',
'Streaming': '#f59e0b',
'SocialNetwork': '#ec4899',
'Chat': '#8b5cf6',
'VoIP': '#10b981',
'Game': '#06b6d4',
'Download': '#f97316',
'Cloud': '#6366f1',
'VPN': '#14b8a6',
'Mail': '#84cc16',
'FileTransfer': '#a855f7',
'Network': '#64748b',
'Unknown': '#94a3b8'
};
return colors[category] || '#64748b';
},
updateData: function(data) {
var self = this;
// Update flow counts
var status = data.status || {};
var activeEl = document.querySelector('.ndpi-flows-active');
var totalEl = document.querySelector('.ndpi-flows-total');
if (activeEl) {
var newActive = api.formatNumber(flows.flows_active || 0);
var newActive = api.formatNumber(status.flows_active || 0);
if (activeEl.textContent !== newActive) {
activeEl.textContent = newActive;
activeEl.classList.add('ndpi-value-updated');
@ -41,23 +99,61 @@ return view.extend({
}
if (totalEl) {
var newTotal = api.formatNumber(flows.flow_count || 0);
if (totalEl.textContent !== newTotal) {
totalEl.textContent = newTotal;
}
totalEl.textContent = api.formatNumber(status.flow_count || 0);
}
// Update interface stats
var interfaces = (data.interfaces || {}).interfaces || [];
interfaces.forEach(function(iface) {
var row = document.querySelector('.ndpi-iface-row[data-iface="' + iface.name + '"]');
if (!row) return;
// Update flows table
var flows = Array.isArray(data.flows) ? data.flows : (data.flows || {}).flows || [];
var tbody = document.querySelector('.ndpi-flows-tbody');
if (tbody && flows.length > 0) {
tbody.innerHTML = '';
flows.slice(0, 50).forEach(function(flow) {
var tr = document.createElement('tr');
tr.className = 'ndpi-flow-row ' + (flow.state === 'ended' ? 'ndpi-flow-ended' : 'ndpi-flow-active');
tr.innerHTML = [
'<td class="ndpi-flow-app">',
'<span class="ndpi-app-icon">' + self.getAppIcon(flow.app, flow.category) + '</span>',
'<div class="ndpi-app-info">',
'<span class="ndpi-app-name">' + (flow.app || 'Unknown') + '</span>',
'<span class="ndpi-app-host">' + (flow.hostname || '') + '</span>',
'</div>',
'</td>',
'<td class="ndpi-flow-src mono">' + flow.src_ip + ':' + flow.src_port + '</td>',
'<td class="ndpi-flow-arrow">→</td>',
'<td class="ndpi-flow-dst mono">' + flow.dst_ip + ':' + flow.dst_port + '</td>',
'<td class="ndpi-flow-proto"><span class="ndpi-proto-badge ndpi-proto-' + self.getProtoName(flow.proto).toLowerCase() + '">' + self.getProtoName(flow.proto) + '</span></td>',
'<td class="ndpi-flow-category"><span class="ndpi-category-badge" style="background:' + self.getCategoryColor(flow.category) + '">' + (flow.category || 'Unknown') + '</span></td>',
'<td class="ndpi-flow-bytes mono">' + api.formatBytes((flow.bytes_rx || 0) + (flow.bytes_tx || 0)) + '</td>',
'<td class="ndpi-flow-state"><span class="ndpi-state-' + flow.state + '">' + (flow.state === 'active' ? '●' : '○') + '</span></td>'
].join('');
tbody.appendChild(tr);
});
} else if (tbody) {
tbody.innerHTML = '<tr><td colspan="8" class="ndpi-empty-row">No flows detected yet</td></tr>';
}
row.querySelector('.ndpi-iface-tcp').textContent = api.formatNumber(iface.tcp);
row.querySelector('.ndpi-iface-udp').textContent = api.formatNumber(iface.udp);
row.querySelector('.ndpi-iface-icmp').textContent = api.formatNumber(iface.icmp);
row.querySelector('.ndpi-iface-bytes').textContent = api.formatBytes(iface.ip_bytes);
});
// Update top applications
var apps = Array.isArray(data.applications) ? data.applications : (data.applications || {}).applications || [];
var appsContainer = document.querySelector('.ndpi-apps-list');
if (appsContainer && apps.length > 0) {
var maxBytes = Math.max.apply(null, apps.map(function(a) { return a.bytes || 0; })) || 1;
appsContainer.innerHTML = '';
apps.slice(0, 10).forEach(function(app) {
var pct = Math.round(((app.bytes || 0) / maxBytes) * 100);
var div = document.createElement('div');
div.className = 'ndpi-app-item';
div.innerHTML = [
'<div class="ndpi-app-header">',
'<span class="ndpi-app-icon">' + self.getAppIcon(app.name, app.category) + '</span>',
'<span class="ndpi-app-name">' + app.name + '</span>',
'<span class="ndpi-app-bytes">' + api.formatBytes(app.bytes || 0) + '</span>',
'</div>',
'<div class="ndpi-app-bar"><div class="ndpi-app-bar-fill" style="width:' + pct + '%;background:' + self.getCategoryColor(app.category) + '"></div></div>',
'<div class="ndpi-app-meta">' + (app.flows || 0) + ' flows · ' + (app.category || 'Unknown') + '</div>'
].join('');
appsContainer.appendChild(div);
});
}
},
startPolling: function() {
@ -68,12 +164,14 @@ return view.extend({
if (!this.pollActive) return Promise.resolve();
return Promise.all([
api.getRealtimeFlows(),
api.getInterfaceStats()
api.getRealtimeFlows().catch(function() { return {}; }),
api.getDetailedFlows().catch(function() { return { flows: [] }; }),
api.getTopApplications().catch(function() { return { applications: [] }; })
]).then(L.bind(function(results) {
this.updateFlows({
flows: results[0],
interfaces: results[1]
this.updateData({
status: results[0],
flows: results[1],
applications: results[2]
});
}, this));
}, this), this.pollInterval);
@ -86,25 +184,37 @@ return view.extend({
render: function(data) {
var self = this;
var flows = data.flows || {};
var interfaces = (data.interfaces || {}).interfaces || [];
var protocols = (data.protocols || {}).protocols || [];
var status = data.status || {};
// Debug: log raw data
console.log('RENDER - raw data.flows:', data.flows);
console.log('RENDER - Array.isArray(data.flows):', Array.isArray(data.flows));
// Handle both array and object formats from API
var flows = Array.isArray(data.flows) ? data.flows : (data.flows || {}).flows || [];
var applications = Array.isArray(data.applications) ? data.applications : (data.applications || {}).applications || [];
var categories = Array.isArray(data.categories) ? data.categories : (data.categories || {}).categories || [];
var interfaces = Array.isArray(data.interfaces) ? data.interfaces : (data.interfaces || {}).interfaces || [];
var protocols = Array.isArray(data.protocols) ? data.protocols : (data.protocols || {}).protocols || [];
// Debug: log processed data
console.log('RENDER - processed flows.length:', flows.length);
console.log('RENDER - processed apps.length:', applications.length);
// Calculate protocol totals
var totalPackets = protocols.reduce(function(sum, p) { return sum + (p.count || 0); }, 0);
var view = E('div', { 'class': 'ndpid-dashboard' }, [
var view = E('div', { 'class': 'ndpid-dashboard ndpid-flows-page' }, [
E('link', { 'rel': 'stylesheet', 'href': L.resource('ndpid/dashboard.css') }),
// Header
E('div', { 'class': 'ndpi-header' }, [
E('div', { 'class': 'ndpi-logo' }, [
E('div', { 'class': 'ndpi-logo-icon' }, '📊'),
E('div', { 'class': 'ndpi-logo-text' }, ['Flow ', E('span', {}, 'Statistics')])
E('div', { 'class': 'ndpi-logo-icon' }, '🔍'),
E('div', { 'class': 'ndpi-logo-text' }, ['Deep Packet ', E('span', {}, 'Inspection')])
])
]),
// Flow Summary
// Quick Stats
E('div', { 'class': 'ndpi-quick-stats' }, [
E('div', { 'class': 'ndpi-quick-stat', 'style': '--stat-gradient: linear-gradient(135deg, #10b981, #34d399)' }, [
E('div', { 'class': 'ndpi-quick-stat-header' }, [
@ -112,8 +222,8 @@ return view.extend({
E('span', { 'class': 'ndpi-quick-stat-label' }, 'Active Flows')
]),
E('div', { 'class': 'ndpi-quick-stat-value ndpi-flows-active' },
api.formatNumber(flows.flows_active || 0)),
E('div', { 'class': 'ndpi-quick-stat-sub' }, 'Currently tracked')
api.formatNumber(status.flows_active || 0)),
E('div', { 'class': 'ndpi-quick-stat-sub' }, 'Real-time tracking')
]),
E('div', { 'class': 'ndpi-quick-stat' }, [
E('div', { 'class': 'ndpi-quick-stat-header' }, [
@ -121,109 +231,197 @@ return view.extend({
E('span', { 'class': 'ndpi-quick-stat-label' }, 'Total Flows')
]),
E('div', { 'class': 'ndpi-quick-stat-value ndpi-flows-total' },
api.formatNumber(flows.flow_count || 0)),
E('div', { 'class': 'ndpi-quick-stat-sub' }, 'Since service start')
api.formatNumber(status.flow_count || 0)),
E('div', { 'class': 'ndpi-quick-stat-sub' }, 'Since start')
]),
E('div', { 'class': 'ndpi-quick-stat' }, [
E('div', { 'class': 'ndpi-quick-stat-header' }, [
E('span', { 'class': 'ndpi-quick-stat-icon' }, '📦'),
E('span', { 'class': 'ndpi-quick-stat-label' }, 'Total Packets')
E('span', { 'class': 'ndpi-quick-stat-icon' }, '📱'),
E('span', { 'class': 'ndpi-quick-stat-label' }, 'Applications')
]),
E('div', { 'class': 'ndpi-quick-stat-value' },
api.formatNumber(totalPackets)),
E('div', { 'class': 'ndpi-quick-stat-sub' }, 'TCP + UDP + ICMP')
api.formatNumber(applications.length)),
E('div', { 'class': 'ndpi-quick-stat-sub' }, 'Detected')
]),
E('div', { 'class': 'ndpi-quick-stat' }, [
E('div', { 'class': 'ndpi-quick-stat-header' }, [
E('span', { 'class': 'ndpi-quick-stat-icon' }, ''),
E('span', { 'class': 'ndpi-quick-stat-label' }, 'Uptime')
E('span', { 'class': 'ndpi-quick-stat-icon' }, '🏷️'),
E('span', { 'class': 'ndpi-quick-stat-label' }, 'Categories')
]),
E('div', { 'class': 'ndpi-quick-stat-value' },
api.formatUptime(flows.uptime || 0)),
E('div', { 'class': 'ndpi-quick-stat-sub' }, 'Service runtime')
api.formatNumber(categories.length)),
E('div', { 'class': 'ndpi-quick-stat-sub' }, 'Traffic types')
])
]),
// Interface Statistics Table
E('div', { 'class': 'ndpi-card' }, [
E('div', { 'class': 'ndpi-card-header' }, [
E('div', { 'class': 'ndpi-card-title' }, [
E('span', { 'class': 'ndpi-card-title-icon' }, '🌐'),
'Per-Interface Statistics'
// Main content grid
E('div', { 'class': 'ndpi-grid-2' }, [
// Flows Table
E('div', { 'class': 'ndpi-card ndpi-card-wide' }, [
E('div', { 'class': 'ndpi-card-header' }, [
E('div', { 'class': 'ndpi-card-title' }, [
E('span', { 'class': 'ndpi-card-title-icon' }, '🔍'),
'Live Flow Detection'
]),
E('div', { 'class': 'ndpi-card-badge' }, flows.length + ' flows')
]),
E('div', { 'class': 'ndpi-card-badge' },
interfaces.length + ' interface' + (interfaces.length !== 1 ? 's' : ''))
]),
E('div', { 'class': 'ndpi-card-body' },
interfaces.length > 0 ?
E('div', { 'class': 'ndpi-table-container' }, [
E('table', { 'class': 'ndpi-table' }, [
E('div', { 'class': 'ndpi-card-body ndpi-flows-table-container' },
flows.length > 0 ?
E('table', { 'class': 'ndpi-table ndpi-flows-table' }, [
E('thead', {}, [
E('tr', {}, [
E('th', {}, 'Interface'),
E('th', {}, 'TCP'),
E('th', {}, 'UDP'),
E('th', {}, 'ICMP'),
E('th', {}, 'Total Bytes')
E('th', {}, 'Application'),
E('th', {}, 'Source'),
E('th', {}, ''),
E('th', {}, 'Destination'),
E('th', {}, 'Proto'),
E('th', {}, 'Category'),
E('th', {}, 'Traffic'),
E('th', {}, '')
])
]),
E('tbody', {},
interfaces.map(function(iface) {
return E('tr', { 'class': 'ndpi-iface-row', 'data-iface': iface.name }, [
E('td', {}, [
E('span', { 'class': 'ndpi-app-name' }, iface.name)
E('tbody', { 'class': 'ndpi-flows-tbody' },
flows.slice(0, 50).map(function(flow) {
return E('tr', { 'class': 'ndpi-flow-row ' + (flow.state === 'ended' ? 'ndpi-flow-ended' : 'ndpi-flow-active') }, [
E('td', { 'class': 'ndpi-flow-app' }, [
E('span', { 'class': 'ndpi-app-icon' }, self.getAppIcon(flow.app, flow.category)),
E('div', { 'class': 'ndpi-app-info' }, [
E('span', { 'class': 'ndpi-app-name' }, flow.app || 'Unknown'),
E('span', { 'class': 'ndpi-app-host' }, flow.hostname || '')
])
]),
E('td', { 'class': 'mono ndpi-iface-tcp' }, api.formatNumber(iface.tcp)),
E('td', { 'class': 'mono ndpi-iface-udp' }, api.formatNumber(iface.udp)),
E('td', { 'class': 'mono ndpi-iface-icmp' }, api.formatNumber(iface.icmp)),
E('td', { 'class': 'mono ndpi-iface-bytes' }, api.formatBytes(iface.ip_bytes))
E('td', { 'class': 'ndpi-flow-src mono' }, flow.src_ip + ':' + flow.src_port),
E('td', { 'class': 'ndpi-flow-arrow' }, '→'),
E('td', { 'class': 'ndpi-flow-dst mono' }, flow.dst_ip + ':' + flow.dst_port),
E('td', { 'class': 'ndpi-flow-proto' }, [
E('span', { 'class': 'ndpi-proto-badge ndpi-proto-' + self.getProtoName(flow.proto).toLowerCase() }, self.getProtoName(flow.proto))
]),
E('td', { 'class': 'ndpi-flow-category' }, [
E('span', { 'class': 'ndpi-category-badge', 'style': 'background:' + self.getCategoryColor(flow.category) }, flow.category || 'Unknown')
]),
E('td', { 'class': 'ndpi-flow-bytes mono' }, api.formatBytes((flow.bytes_rx || 0) + (flow.bytes_tx || 0))),
E('td', { 'class': 'ndpi-flow-state' }, [
E('span', { 'class': 'ndpi-state-' + flow.state }, flow.state === 'active' ? '●' : '○')
])
]);
})
)
]) :
E('div', { 'class': 'ndpi-empty' }, [
E('div', { 'class': 'ndpi-empty-icon' }, '🔍'),
E('div', { 'class': 'ndpi-empty-text' }, 'No flows detected yet'),
E('div', { 'class': 'ndpi-empty-hint' }, 'Generate some network traffic to see detection')
])
]) :
E('div', { 'class': 'ndpi-empty' }, [
E('div', { 'class': 'ndpi-empty-icon' }, '📊'),
E('div', { 'class': 'ndpi-empty-text' }, 'No interface statistics available')
])
)
)
]),
// Top Applications
E('div', { 'class': 'ndpi-card' }, [
E('div', { 'class': 'ndpi-card-header' }, [
E('div', { 'class': 'ndpi-card-title' }, [
E('span', { 'class': 'ndpi-card-title-icon' }, '📱'),
'Top Applications'
])
]),
E('div', { 'class': 'ndpi-card-body' },
applications.length > 0 ?
E('div', { 'class': 'ndpi-apps-list' },
(function() {
var maxBytes = Math.max.apply(null, applications.map(function(a) { return a.bytes || 0; })) || 1;
return applications.slice(0, 10).map(function(app) {
var pct = Math.round(((app.bytes || 0) / maxBytes) * 100);
return E('div', { 'class': 'ndpi-app-item' }, [
E('div', { 'class': 'ndpi-app-header' }, [
E('span', { 'class': 'ndpi-app-icon' }, self.getAppIcon(app.name, app.category)),
E('span', { 'class': 'ndpi-app-name' }, app.name),
E('span', { 'class': 'ndpi-app-bytes' }, api.formatBytes(app.bytes || 0))
]),
E('div', { 'class': 'ndpi-app-bar' }, [
E('div', { 'class': 'ndpi-app-bar-fill', 'style': 'width:' + pct + '%;background:' + self.getCategoryColor(app.category) })
]),
E('div', { 'class': 'ndpi-app-meta' }, (app.flows || 0) + ' flows · ' + (app.category || 'Unknown'))
]);
});
})()
) :
E('div', { 'class': 'ndpi-empty' }, [
E('div', { 'class': 'ndpi-empty-icon' }, '📱'),
E('div', { 'class': 'ndpi-empty-text' }, 'No applications detected yet')
])
)
])
]),
// Protocol Breakdown
E('div', { 'class': 'ndpi-card' }, [
E('div', { 'class': 'ndpi-card-header' }, [
E('div', { 'class': 'ndpi-card-title' }, [
E('span', { 'class': 'ndpi-card-title-icon' }, '📡'),
'Protocol Breakdown'
])
// Protocol & Category breakdown
E('div', { 'class': 'ndpi-grid-2' }, [
// Protocol Distribution
E('div', { 'class': 'ndpi-card' }, [
E('div', { 'class': 'ndpi-card-header' }, [
E('div', { 'class': 'ndpi-card-title' }, [
E('span', { 'class': 'ndpi-card-title-icon' }, '📡'),
'Protocol Distribution'
])
]),
E('div', { 'class': 'ndpi-card-body' },
protocols.length > 0 ?
E('div', { 'class': 'ndpi-protocol-grid' },
protocols.map(function(proto) {
var pct = totalPackets > 0 ? Math.round((proto.count / totalPackets) * 100) : 0;
var color = proto.name === 'TCP' ? '#0ea5e9' :
proto.name === 'UDP' ? '#10b981' : '#f59e0b';
return E('div', { 'class': 'ndpi-protocol-item' }, [
E('div', { 'class': 'ndpi-protocol-header' }, [
E('span', { 'class': 'ndpi-protocol-name' }, proto.name),
E('span', { 'class': 'ndpi-protocol-count' }, api.formatNumber(proto.count))
]),
E('div', { 'class': 'ndpi-protocol-bar' }, [
E('div', { 'class': 'ndpi-protocol-bar-fill', 'style': 'width:' + pct + '%;background:' + color })
]),
E('div', { 'class': 'ndpi-protocol-pct' }, pct + '%')
]);
})
) :
E('div', { 'class': 'ndpi-empty' }, [
E('div', { 'class': 'ndpi-empty-icon' }, '📡'),
E('div', { 'class': 'ndpi-empty-text' }, 'No protocol data')
])
)
]),
E('div', { 'class': 'ndpi-card-body' },
protocols.length > 0 ?
E('div', { 'class': 'ndpi-protocol-grid' },
protocols.map(function(proto) {
var pct = totalPackets > 0 ? Math.round((proto.count / totalPackets) * 100) : 0;
var color = proto.name === 'TCP' ? '#0ea5e9' :
proto.name === 'UDP' ? '#10b981' : '#f59e0b';
return E('div', { 'class': 'ndpi-protocol-item' }, [
E('div', { 'class': 'ndpi-protocol-header' }, [
E('span', { 'class': 'ndpi-protocol-name' }, proto.name),
E('span', { 'class': 'ndpi-protocol-count' }, api.formatNumber(proto.count))
]),
E('div', { 'class': 'ndpi-protocol-bar' }, [
E('div', {
'class': 'ndpi-protocol-bar-fill',
'style': 'width: ' + pct + '%; background: ' + color
})
]),
E('div', { 'class': 'ndpi-protocol-pct' }, pct + '%')
]);
})
) :
E('div', { 'class': 'ndpi-empty' }, [
E('div', { 'class': 'ndpi-empty-icon' }, '📡'),
E('div', { 'class': 'ndpi-empty-text' }, 'No protocol data available')
])
)
// Categories
E('div', { 'class': 'ndpi-card' }, [
E('div', { 'class': 'ndpi-card-header' }, [
E('div', { 'class': 'ndpi-card-title' }, [
E('span', { 'class': 'ndpi-card-title-icon' }, '🏷️'),
'Traffic Categories'
])
]),
E('div', { 'class': 'ndpi-card-body' },
categories.length > 0 ?
E('div', { 'class': 'ndpi-categories-list' },
(function() {
var maxBytes = Math.max.apply(null, categories.map(function(c) { return c.bytes || 0; })) || 1;
return categories.slice(0, 8).map(function(cat) {
var pct = Math.round(((cat.bytes || 0) / maxBytes) * 100);
return E('div', { 'class': 'ndpi-category-item' }, [
E('div', { 'class': 'ndpi-category-header' }, [
E('span', { 'class': 'ndpi-category-name', 'style': 'color:' + self.getCategoryColor(cat.name) }, cat.name),
E('span', { 'class': 'ndpi-category-bytes' }, api.formatBytes(cat.bytes || 0))
]),
E('div', { 'class': 'ndpi-category-bar' }, [
E('div', { 'class': 'ndpi-category-bar-fill', 'style': 'width:' + pct + '%;background:' + self.getCategoryColor(cat.name) })
]),
E('div', { 'class': 'ndpi-category-meta' }, (cat.apps || 0) + ' apps · ' + (cat.flows || 0) + ' flows')
]);
});
})()
) :
E('div', { 'class': 'ndpi-empty' }, [
E('div', { 'class': 'ndpi-empty-icon' }, '🏷️'),
E('div', { 'class': 'ndpi-empty-text' }, 'No categories detected')
])
)
])
])
]);

View File

View File

@ -1,6 +1,6 @@
#!/bin/sh
# nDPId to Netifyd Compatibility Layer
# Translates nDPId events to Netifyd-compatible format
# Translates nDPId events to Netifyd-compatible format with enhanced detection
# Copyright (C) 2025 CyberMind.fr
. /lib/functions.sh
@ -10,16 +10,19 @@
DISTRIBUTOR_SOCK="/var/run/ndpid/distributor.sock"
STATUS_FILE="/var/run/netifyd/status.json"
FLOWS_FILE="/tmp/ndpid-flows.json"
APPS_FILE="/tmp/ndpid-apps.json"
STATS_FILE="/tmp/ndpid-stats.json"
STATS_HISTORY="/tmp/ndpid-stats-history.json"
UPDATE_INTERVAL=1
MAX_HISTORY=1440
MAX_FLOWS=500
MAX_APPS=100
# State variables (stored in temp files for shell compatibility)
# State directory
STATE_DIR="/tmp/ndpid-state"
FLOWS_ACTIVE_FILE="$STATE_DIR/flows_active"
FLOW_COUNT_FILE="$STATE_DIR/flow_count"
STATS_FILE_TMP="$STATE_DIR/stats"
FLOWS_TMP="$STATE_DIR/flows"
APPS_TMP="$STATE_DIR/apps"
# Initialize state
init_state() {
@ -28,16 +31,17 @@ init_state() {
echo "0" > "$FLOWS_ACTIVE_FILE"
echo "0" > "$FLOW_COUNT_FILE"
echo "{}" > "$STATS_FILE_TMP"
echo "[]" > "$FLOWS_TMP"
echo "{}" > "$APPS_TMP"
}
# Increment counter
# Counter operations
inc_counter() {
local file="$1"
local val=$(cat "$file" 2>/dev/null || echo 0)
echo $((val + 1)) > "$file"
}
# Decrement counter
dec_counter() {
local file="$1"
local val=$(cat "$file" 2>/dev/null || echo 0)
@ -45,7 +49,6 @@ dec_counter() {
echo "$val" > "$file"
}
# Get counter value
get_counter() {
cat "$1" 2>/dev/null || echo 0
}
@ -56,62 +59,177 @@ update_iface_stats() {
local proto="$2"
local bytes="$3"
[ -z "$iface" ] && return
local stats=$(cat "$STATS_FILE_TMP" 2>/dev/null || echo "{}")
# Use jq to update stats (if available) or simple JSON
if command -v jq >/dev/null 2>&1; then
stats=$(echo "$stats" | jq --arg iface "$iface" --arg proto "$proto" --argjson bytes "$bytes" '
stats=$(echo "$stats" | jq --arg iface "$iface" --arg proto "$proto" --argjson bytes "${bytes:-0}" '
.[$iface] //= {"ip_bytes": 0, "wire_bytes": 0, "tcp": 0, "udp": 0, "icmp": 0} |
.[$iface].ip_bytes += $bytes |
.[$iface].wire_bytes += $bytes |
if $proto == "tcp" then .[$iface].tcp += 1
elif $proto == "udp" then .[$iface].udp += 1
elif $proto == "icmp" then .[$iface].icmp += 1
if $proto == "tcp" or $proto == "6" then .[$iface].tcp += 1
elif $proto == "udp" or $proto == "17" then .[$iface].udp += 1
elif $proto == "icmp" or $proto == "1" then .[$iface].icmp += 1
else . end
')
echo "$stats" > "$STATS_FILE_TMP"
fi
}
# Update application stats
update_app_stats() {
local app="$1"
local category="$2"
local bytes="$3"
[ -z "$app" ] && return
if command -v jq >/dev/null 2>&1; then
local apps=$(cat "$APPS_TMP" 2>/dev/null || echo "{}")
apps=$(echo "$apps" | jq --arg app "$app" --arg cat "${category:-Unknown}" --argjson bytes "${bytes:-0}" '
.[$app] //= {"name": $app, "category": $cat, "flows": 0, "bytes": 0} |
.[$app].flows += 1 |
.[$app].bytes += $bytes
')
echo "$apps" > "$APPS_TMP"
fi
}
# Add flow to list
add_flow() {
local json="$1"
if command -v jq >/dev/null 2>&1; then
local flow_info=$(echo "$json" | jq -c '{
id: .flow_id,
src_ip: .src_ip,
src_port: .src_port,
dst_ip: .dst_ip,
dst_port: .dst_port,
proto: .l4_proto,
app: (.ndpi.app_proto // .ndpi.proto // "Unknown"),
category: (.ndpi.category // "Unknown"),
hostname: (.ndpi.hostname // .flow_dst_hostname // null),
confidence: (.ndpi.confidence // "Unknown"),
risk: (.ndpi.flow_risk // []),
bytes_rx: (.flow_src_tot_l4_payload_len // 0),
bytes_tx: (.flow_dst_tot_l4_payload_len // 0),
packets: ((.flow_src_packets_processed // 0) + (.flow_dst_packets_processed // 0)),
first_seen: .flow_first_seen,
last_seen: .flow_last_seen,
state: "active",
iface: .source
}' 2>/dev/null)
[ -z "$flow_info" ] && return
local flows=$(cat "$FLOWS_TMP" 2>/dev/null || echo "[]")
flows=$(echo "$flows" | jq --argjson flow "$flow_info" --argjson max "$MAX_FLOWS" '
[. | .[] | select(.id != $flow.id)] + [$flow] | .[-$max:]
')
echo "$flows" > "$FLOWS_TMP"
fi
}
# Update existing flow
update_flow() {
local json="$1"
local flow_id=$(echo "$json" | jsonfilter -e '@.flow_id' 2>/dev/null)
[ -z "$flow_id" ] && return
if command -v jq >/dev/null 2>&1; then
local update_info=$(echo "$json" | jq -c '{
bytes_rx: (.flow_src_tot_l4_payload_len // 0),
bytes_tx: (.flow_dst_tot_l4_payload_len // 0),
packets: ((.flow_src_packets_processed // 0) + (.flow_dst_packets_processed // 0)),
last_seen: .flow_last_seen
}' 2>/dev/null)
local flows=$(cat "$FLOWS_TMP" 2>/dev/null || echo "[]")
flows=$(echo "$flows" | jq --arg id "$flow_id" --argjson update "$update_info" '
map(if .id == ($id | tonumber) then . + $update else . end)
')
echo "$flows" > "$FLOWS_TMP"
fi
}
# Mark flow as ended
end_flow() {
local json="$1"
local flow_id=$(echo "$json" | jsonfilter -e '@.flow_id' 2>/dev/null)
[ -z "$flow_id" ] && return
if command -v jq >/dev/null 2>&1; then
local flows=$(cat "$FLOWS_TMP" 2>/dev/null || echo "[]")
flows=$(echo "$flows" | jq --arg id "$flow_id" '
map(if .id == ($id | tonumber) then .state = "ended" else . end)
')
echo "$flows" > "$FLOWS_TMP"
fi
}
# Process a single nDPId event
process_event() {
local raw="$1"
# Strip 5-digit length prefix
local json="${raw:5}"
# Strip 5-digit length prefix if present
local json="$raw"
if echo "$raw" | grep -q '^[0-9]\{5\}'; then
json="${raw:5}"
fi
# Parse event type
local event_name=$(echo "$json" | jsonfilter -e '@.flow_event_name' 2>/dev/null)
[ -z "$event_name" ] && event_name=$(echo "$json" | jsonfilter -e '@.daemon_event_name' 2>/dev/null)
# Extract common fields
local iface=$(echo "$json" | jsonfilter -e '@.source' 2>/dev/null)
local proto=$(echo "$json" | jsonfilter -e '@.l4_proto' 2>/dev/null)
local src_bytes=$(echo "$json" | jsonfilter -e '@.flow_src_tot_l4_payload_len' 2>/dev/null || echo 0)
local dst_bytes=$(echo "$json" | jsonfilter -e '@.flow_dst_tot_l4_payload_len' 2>/dev/null || echo 0)
local total_bytes=$((src_bytes + dst_bytes))
case "$event_name" in
new)
inc_counter "$FLOW_COUNT_FILE"
inc_counter "$FLOWS_ACTIVE_FILE"
add_flow "$json"
;;
detected|guessed|detection-update)
# Extract application info
local app=$(echo "$json" | jsonfilter -e '@.ndpi.app_proto' 2>/dev/null)
[ -z "$app" ] && app=$(echo "$json" | jsonfilter -e '@.ndpi.proto' 2>/dev/null)
local category=$(echo "$json" | jsonfilter -e '@.ndpi.category' 2>/dev/null)
# Update stats
[ -n "$iface" ] && update_iface_stats "$iface" "$proto" "$total_bytes"
[ -n "$app" ] && update_app_stats "$app" "$category" "$total_bytes"
# Update flow details
add_flow "$json"
;;
update)
update_flow "$json"
[ -n "$iface" ] && update_iface_stats "$iface" "$proto" "$total_bytes"
;;
end|idle)
dec_counter "$FLOWS_ACTIVE_FILE"
;;
detected|guessed)
# Extract flow info for stats
local iface=$(echo "$json" | jsonfilter -e '@.source' 2>/dev/null)
local proto=$(echo "$json" | jsonfilter -e '@.l4_proto' 2>/dev/null)
local src_bytes=$(echo "$json" | jsonfilter -e '@.flow_src_tot_l4_payload_len' 2>/dev/null || echo 0)
local dst_bytes=$(echo "$json" | jsonfilter -e '@.flow_dst_tot_l4_payload_len' 2>/dev/null || echo 0)
local total_bytes=$((src_bytes + dst_bytes))
[ -n "$iface" ] && update_iface_stats "$iface" "$proto" "$total_bytes"
end_flow "$json"
;;
esac
}
# Generate Netifyd-compatible status.json
# Generate status files
generate_status() {
local flow_count=$(get_counter "$FLOW_COUNT_FILE")
local flows_active=$(get_counter "$FLOWS_ACTIVE_FILE")
local stats=$(cat "$STATS_FILE_TMP" 2>/dev/null || echo "{}")
local uptime=$(($(date +%s) - START_TIME))
# Generate main status file
if command -v jq >/dev/null 2>&1; then
jq -n \
--argjson flow_count "$flow_count" \
@ -127,6 +245,13 @@ generate_status() {
uptime: $uptime,
source: "ndpid-compat"
}' > "$STATUS_FILE"
# Generate flows file
cp "$FLOWS_TMP" "$FLOWS_FILE" 2>/dev/null
# Generate apps file (sorted by bytes)
local apps=$(cat "$APPS_TMP" 2>/dev/null || echo "{}")
echo "$apps" | jq '[.[] | select(.name != null)] | sort_by(-.bytes) | .[0:100]' > "$APPS_FILE" 2>/dev/null
else
cat > "$STATUS_FILE" << EOF
{
@ -142,21 +267,38 @@ EOF
fi
}
# Cleanup old ended flows
cleanup_flows() {
if command -v jq >/dev/null 2>&1; then
local flows=$(cat "$FLOWS_TMP" 2>/dev/null || echo "[]")
local now=$(date +%s)
# Keep active flows and ended flows from last 5 minutes
flows=$(echo "$flows" | jq --argjson now "$now" '
[.[] | select(.state == "active" or (.last_seen != null and ($now - .last_seen) < 300))]
')
echo "$flows" > "$FLOWS_TMP"
fi
}
# Main loop
main() {
START_TIME=$(date +%s)
logger -t ndpid-compat "Starting nDPId compatibility layer"
logger -t ndpid-compat "Starting nDPId compatibility layer (enhanced)"
# Initialize state
init_state
# Check for socat
# Check for dependencies
if ! command -v socat >/dev/null 2>&1; then
logger -t ndpid-compat "ERROR: socat not found, using nc fallback"
logger -t ndpid-compat "WARNING: socat not found, using nc fallback"
USE_NC=1
fi
if ! command -v jq >/dev/null 2>&1; then
logger -t ndpid-compat "WARNING: jq not found, detailed stats disabled"
fi
# Wait for distributor socket
local wait_count=0
while [ ! -S "$DISTRIBUTOR_SOCK" ] && [ $wait_count -lt 30 ]; do
@ -179,7 +321,17 @@ main() {
done
) &
STATUS_PID=$!
trap "kill $STATUS_PID 2>/dev/null" EXIT
# Background cleanup
(
while true; do
sleep 60
cleanup_flows
done
) &
CLEANUP_PID=$!
trap "kill $STATUS_PID $CLEANUP_PID 2>/dev/null" EXIT
# Read events from distributor
if [ -z "$USE_NC" ]; then
@ -193,11 +345,12 @@ main() {
fi
}
# Run main if not sourced
# Run modes
case "$1" in
-h|--help)
echo "Usage: $0 [-d|--daemon]"
echo " Translates nDPId events to Netifyd-compatible format"
echo " Enhanced nDPId to Netifyd compatibility layer"
echo " Captures detailed flow and application information"
exit 0
;;
-d|--daemon)

View File

View File

@ -13,6 +13,7 @@ STATUS_FILE="/var/run/netifyd/status.json"
DISTRIBUTOR_SOCK="/var/run/ndpid/distributor.sock"
COLLECTOR_SOCK="/var/run/ndpid/collector.sock"
FLOWS_CACHE="/tmp/ndpid-flows.json"
APPS_CACHE="/tmp/ndpid-apps.json"
STATS_CACHE="/tmp/ndpid-stats.json"
LOG_FILE="/var/log/ndpid.log"
@ -138,31 +139,124 @@ get_interface_stats() {
json_dump
}
# Get top applications (from flow analysis)
# Get top applications (from aggregated apps file)
get_top_applications() {
json_init
json_add_array "applications"
# Read from flows cache if available
if [ -f "$FLOWS_CACHE" ] && command -v jq >/dev/null 2>&1; then
# Aggregate by application
jq -r '
group_by(.application) |
local app_data=""
# Try apps cache first (pre-aggregated by ndpid-compat)
if [ -f "$APPS_CACHE" ] && command -v jq >/dev/null 2>&1; then
app_data=$(jq -r '.[0:15][] | "\(.name // "Unknown")|\(.category // "Unknown")|\(.flows // 0)|\(.bytes // 0)"' \
"$APPS_CACHE" 2>/dev/null)
# Fallback to flows cache
elif [ -f "$FLOWS_CACHE" ] && command -v jq >/dev/null 2>&1; then
app_data=$(jq -r '
group_by(.app) |
map({
name: .[0].application,
name: (.[0].app // "Unknown"),
category: (.[0].category // "Unknown"),
flows: length,
bytes: (map(.bytes_rx + .bytes_tx) | add)
bytes: ([.[].bytes_rx, .[].bytes_tx] | add // 0)
}) |
sort_by(-.bytes) |
.[0:10][] |
"\(.name)|\(.flows)|\(.bytes)"
' "$FLOWS_CACHE" 2>/dev/null | while IFS='|' read -r name flows bytes; do
.[0:15][] |
"\(.name)|\(.category)|\(.flows)|\(.bytes)"
' "$FLOWS_CACHE" 2>/dev/null)
fi
if [ -n "$app_data" ]; then
while IFS='|' read -r name category flows bytes; do
[ -z "$name" ] && continue
json_add_object
json_add_string "name" "${name:-unknown}"
json_add_string "name" "${name:-Unknown}"
json_add_string "category" "${category:-Unknown}"
json_add_int "flows" "${flows:-0}"
json_add_int "bytes" "${bytes:-0}"
json_close_object
done
done <<EOF
$app_data
EOF
fi
json_close_array
json_dump
}
# Get detailed flows with detection info
get_detailed_flows() {
json_init
json_add_array "flows"
if [ -f "$FLOWS_CACHE" ] && command -v jq >/dev/null 2>&1; then
# Get recent active flows sorted by bytes - use heredoc to avoid subshell
local flow_data
flow_data=$(jq -r '
sort_by(-(.bytes_rx + .bytes_tx)) |
.[0:100][] |
"\(.id // 0)|\(.src_ip // "")|\(.src_port // 0)|\(.dst_ip // "")|\(.dst_port // 0)|\(.proto // "")|\(.app // "Unknown")|\(.category // "Unknown")|\(.hostname // "")|\(.confidence // "")|\(.bytes_rx // 0)|\(.bytes_tx // 0)|\(.packets // 0)|\(.state // "unknown")|\(.iface // "")"
' "$FLOWS_CACHE" 2>/dev/null)
while IFS='|' read -r id src_ip src_port dst_ip dst_port proto app category hostname confidence bytes_rx bytes_tx packets state iface; do
[ -z "$id" ] && continue
json_add_object
json_add_int "id" "${id:-0}"
json_add_string "src_ip" "${src_ip}"
json_add_int "src_port" "${src_port:-0}"
json_add_string "dst_ip" "${dst_ip}"
json_add_int "dst_port" "${dst_port:-0}"
json_add_string "proto" "${proto}"
json_add_string "app" "${app:-Unknown}"
json_add_string "category" "${category:-Unknown}"
json_add_string "hostname" "${hostname}"
json_add_string "confidence" "${confidence}"
json_add_int "bytes_rx" "${bytes_rx:-0}"
json_add_int "bytes_tx" "${bytes_tx:-0}"
json_add_int "packets" "${packets:-0}"
json_add_string "state" "${state:-unknown}"
json_add_string "iface" "${iface}"
json_close_object
done <<EOF
$flow_data
EOF
fi
json_close_array
json_dump
}
# Get category breakdown
get_categories() {
json_init
json_add_array "categories"
if [ -f "$APPS_CACHE" ] && command -v jq >/dev/null 2>&1; then
local cat_data
cat_data=$(jq -r '
group_by(.category) |
map({
name: (.[0].category // "Unknown"),
apps: length,
flows: ([.[].flows] | add),
bytes: ([.[].bytes] | add)
}) |
sort_by(-.bytes) |
.[] |
"\(.name)|\(.apps)|\(.flows)|\(.bytes)"
' "$APPS_CACHE" 2>/dev/null)
while IFS='|' read -r name apps flows bytes; do
[ -z "$name" ] && continue
json_add_object
json_add_string "name" "${name:-Unknown}"
json_add_int "apps" "${apps:-0}"
json_add_int "flows" "${flows:-0}"
json_add_int "bytes" "${bytes:-0}"
json_close_object
done <<EOF
$cat_data
EOF
fi
json_close_array
@ -505,11 +599,40 @@ get_interfaces() {
json_close_array
# Also list available interfaces
# Scan available interfaces from network config and system
json_add_array "available"
for iface in $(ls /sys/class/net/ 2>/dev/null | grep -E '^(br-|eth|wlan)'); do
json_add_string "" "$iface"
# Get bridges and network interfaces from UCI
local net_ifaces=""
config_load network
# Get all defined interfaces (br-lan, br-wan, etc)
for iface in $(ls /sys/class/net/ 2>/dev/null); do
case "$iface" in
lo|sit*|ip6*|gre*|ifb*|teql*)
# Skip loopback and virtual tunnel interfaces
;;
br-*|eth*|wlan*|lan*|wan*)
# Include bridges, ethernet, wifi, and named interfaces
local state=$(cat /sys/class/net/$iface/operstate 2>/dev/null || echo "unknown")
local type=$(cat /sys/class/net/$iface/type 2>/dev/null || echo "0")
# Only include if it's a real interface (type 1 = ethernet)
if [ "$type" = "1" ] || [ -d "/sys/class/net/$iface/bridge" ]; then
json_add_object
json_add_string "name" "$iface"
json_add_string "state" "$state"
# Check if it's a bridge
if [ -d "/sys/class/net/$iface/bridge" ]; then
json_add_string "type" "bridge"
else
json_add_string "type" "interface"
fi
json_close_object
fi
;;
esac
done
json_close_array
json_dump
@ -522,9 +645,11 @@ case "$1" in
{
"get_service_status": {},
"get_realtime_flows": {},
"get_detailed_flows": {},
"get_interface_stats": {},
"get_top_applications": {},
"get_top_protocols": {},
"get_categories": {},
"get_config": {},
"get_dashboard": {},
"get_interfaces": {},
@ -546,6 +671,9 @@ EOF
get_realtime_flows)
get_realtime_flows
;;
get_detailed_flows)
get_detailed_flows
;;
get_interface_stats)
get_interface_stats
;;
@ -555,6 +683,9 @@ EOF
get_top_protocols)
get_top_protocols
;;
get_categories)
get_categories
;;
get_config)
get_config
;;

View File

@ -6,9 +6,11 @@
"luci.ndpid": [
"get_service_status",
"get_realtime_flows",
"get_detailed_flows",
"get_interface_stats",
"get_top_applications",
"get_top_protocols",
"get_categories",
"get_config",
"get_dashboard",
"get_interfaces"