Fixed "[object HTMLDivElement]" display bug in device and application list views.
## Problem:
- Device list showed "[object HTMLDivElement],[object HTMLDivElement],..." instead of table rows
- Applications list had the same issue
- Root cause: `sortedDevices.map()` and `sortedApps.map()` return arrays, but these arrays were being nested incorrectly in the E() children array
## Solution:
Changed table row structure from:
```javascript
E('div', { 'class': 'table' }, [
E('div', { 'class': 'tr table-titles' }, [...]), // header
sortedDevices.map(function(device) { // array nested wrong!
return E('div', {...});
})
])
```
To:
```javascript
E('div', { 'class': 'table' },
[
E('div', { 'class': 'tr table-titles' }, [...]) // header
].concat(
sortedDevices.map(function(device) { // properly flattened!
return E('div', {...});
})
)
)
```
## Technical Details:
- The E() helper expects children to be individual DOM elements, not nested arrays
- Using `.concat()` properly flattens the array of row elements
- Applied fix to both devices.js and applications.js views
## Testing:
- Deployed to router
- Device list now displays all 6 detected devices with IP, MAC, traffic stats
- Applications list displays all 4 application categories correctly
- Table formatting and styling render properly
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
411 lines
13 KiB
JavaScript
411 lines
13 KiB
JavaScript
'use strict';
|
|
'require view';
|
|
'require poll';
|
|
'require ui';
|
|
'require dom';
|
|
'require secubox-netifyd/api as netifydAPI';
|
|
|
|
return view.extend({
|
|
refreshInterval: 5,
|
|
devicesData: [],
|
|
sortColumn: 'bytes_sent',
|
|
sortDirection: 'desc',
|
|
searchQuery: '',
|
|
|
|
load: function() {
|
|
return Promise.all([
|
|
netifydAPI.getDetectedDevices(),
|
|
netifydAPI.getServiceStatus(),
|
|
netifydAPI.getDashboard()
|
|
]);
|
|
},
|
|
|
|
filterDevices: function(devices) {
|
|
if (!this.searchQuery) {
|
|
return devices;
|
|
}
|
|
|
|
var query = this.searchQuery.toLowerCase();
|
|
return devices.filter(function(device) {
|
|
var ip = (device.ip || '').toLowerCase();
|
|
var mac = (device.mac || '').toLowerCase();
|
|
return ip.indexOf(query) >= 0 || mac.indexOf(query) >= 0;
|
|
});
|
|
},
|
|
|
|
sortDevices: function(devices, column, direction) {
|
|
return devices.slice().sort(function(a, b) {
|
|
var valA = a[column] || 0;
|
|
var valB = b[column] || 0;
|
|
|
|
if (column === 'ip' || column === 'mac') {
|
|
valA = String(valA);
|
|
valB = String(valB);
|
|
}
|
|
|
|
if (direction === 'asc') {
|
|
return valA > valB ? 1 : valA < valB ? -1 : 0;
|
|
} else {
|
|
return valA < valB ? 1 : valA > valB ? -1 : 0;
|
|
}
|
|
});
|
|
},
|
|
|
|
handleSort: function(column, ev) {
|
|
if (this.sortColumn === column) {
|
|
this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
|
|
} else {
|
|
this.sortColumn = column;
|
|
this.sortDirection = 'desc';
|
|
}
|
|
this.renderDevicesTable();
|
|
},
|
|
|
|
handleFilterOnline: function(value, ev) {
|
|
this.filterOnline = value;
|
|
this.renderDevicesTable();
|
|
},
|
|
|
|
handleExport: function(ev) {
|
|
var csvContent = 'IP Address,MAC Address,Flows,Bytes Sent,Bytes Received,Total Traffic,Last Seen\n';
|
|
|
|
this.devicesData.forEach(function(device) {
|
|
var total = (device.bytes_sent || 0) + (device.bytes_received || 0);
|
|
var lastSeen = device.last_seen || 0;
|
|
var lastSeenStr = lastSeen > 0 ? new Date(lastSeen * 1000).toISOString() : 'N/A';
|
|
|
|
csvContent += [
|
|
'"' + (device.ip || 'N/A') + '"',
|
|
'"' + (device.mac || 'N/A') + '"',
|
|
device.flows || 0,
|
|
device.bytes_sent || 0,
|
|
device.bytes_received || 0,
|
|
total,
|
|
'"' + lastSeenStr + '"'
|
|
].join(',') + '\n';
|
|
});
|
|
|
|
var blob = new Blob([csvContent], { type: 'text/csv' });
|
|
var url = window.URL.createObjectURL(blob);
|
|
var a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = 'netifyd-devices-' + new Date().toISOString().slice(0,10) + '.csv';
|
|
a.click();
|
|
window.URL.revokeObjectURL(url);
|
|
|
|
ui.addNotification(null, E('p', _('Devices exported to CSV')), 'info');
|
|
},
|
|
|
|
renderSummaryCards: function(devices) {
|
|
var totalBytesSent = devices.reduce(function(sum, dev) {
|
|
return sum + (dev.bytes_sent || 0);
|
|
}, 0);
|
|
|
|
var totalBytesRecv = devices.reduce(function(sum, dev) {
|
|
return sum + (dev.bytes_received || 0);
|
|
}, 0);
|
|
|
|
var totalFlows = devices.reduce(function(sum, dev) {
|
|
return sum + (dev.flows || 0);
|
|
}, 0);
|
|
|
|
var cards = [
|
|
{
|
|
title: _('Active Devices'),
|
|
value: devices.length.toString(),
|
|
icon: 'network-wired',
|
|
gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
|
|
},
|
|
{
|
|
title: _('Total Flows'),
|
|
value: totalFlows.toLocaleString(),
|
|
icon: 'stream',
|
|
gradient: 'linear-gradient(135deg, #84fab0 0%, #8fd3f4 100%)'
|
|
},
|
|
{
|
|
title: _('Total Sent'),
|
|
value: netifydAPI.formatBytes(totalBytesSent),
|
|
icon: 'upload',
|
|
gradient: 'linear-gradient(135deg, #a18cd1 0%, #fbc2eb 100%)'
|
|
},
|
|
{
|
|
title: _('Total Received'),
|
|
value: netifydAPI.formatBytes(totalBytesRecv),
|
|
icon: 'download',
|
|
gradient: 'linear-gradient(135deg, #fa709a 0%, #fee140 100%)'
|
|
}
|
|
];
|
|
|
|
return E('div', {
|
|
'style': 'display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1rem; margin-bottom: 1.5rem'
|
|
}, cards.map(function(card) {
|
|
return E('div', {
|
|
'style': 'background: ' + card.gradient + '; color: white; padding: 1.5rem; border-radius: 8px; box-shadow: 0 4px 6px rgba(0,0,0,0.1)'
|
|
}, [
|
|
E('div', { 'style': 'display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1rem' }, [
|
|
E('div', { 'style': 'font-size: 0.9em; opacity: 0.9' }, card.title),
|
|
E('i', {
|
|
'class': 'fa fa-' + card.icon,
|
|
'style': 'font-size: 2em; opacity: 0.3'
|
|
})
|
|
]),
|
|
E('div', { 'style': 'font-size: 2em; font-weight: bold' }, card.value)
|
|
]);
|
|
}.bind(this)));
|
|
},
|
|
|
|
renderDevicesTable: function() {
|
|
var container = document.getElementById('devices-table-container');
|
|
if (!container) return;
|
|
|
|
var devices = this.filterDevices(this.devicesData);
|
|
var sortedDevices = this.sortDevices(devices, this.sortColumn, this.sortDirection);
|
|
|
|
var getSortIcon = function(column) {
|
|
if (this.sortColumn !== column) {
|
|
return E('i', { 'class': 'fa fa-sort', 'style': 'opacity: 0.3' });
|
|
}
|
|
return E('i', {
|
|
'class': 'fa fa-sort-' + (this.sortDirection === 'asc' ? 'up' : 'down'),
|
|
'style': 'color: #3b82f6'
|
|
});
|
|
}.bind(this);
|
|
|
|
dom.content(container, [
|
|
devices.length > 0 ? E('div', { 'class': 'table', 'style': 'font-size: 0.95em' },
|
|
[
|
|
// Header
|
|
E('div', { 'class': 'tr table-titles' }, [
|
|
E('div', {
|
|
'class': 'th left',
|
|
'style': 'width: 20%; cursor: pointer',
|
|
'click': ui.createHandlerFn(this, 'handleSort', 'ip')
|
|
}, [
|
|
_('IP Address'),
|
|
' ',
|
|
getSortIcon('ip')
|
|
]),
|
|
E('div', { 'class': 'th left', 'style': 'width: 20%' }, _('MAC Address')),
|
|
E('div', {
|
|
'class': 'th center',
|
|
'style': 'width: 10%; cursor: pointer',
|
|
'click': ui.createHandlerFn(this, 'handleSort', 'flows')
|
|
}, [
|
|
_('Flows'),
|
|
' ',
|
|
getSortIcon('flows')
|
|
]),
|
|
E('div', {
|
|
'class': 'th right',
|
|
'style': 'width: 15%; cursor: pointer',
|
|
'click': ui.createHandlerFn(this, 'handleSort', 'bytes_sent')
|
|
}, [
|
|
_('Sent'),
|
|
' ',
|
|
getSortIcon('bytes_sent')
|
|
]),
|
|
E('div', {
|
|
'class': 'th right',
|
|
'style': 'width: 15%; cursor: pointer',
|
|
'click': ui.createHandlerFn(this, 'handleSort', 'bytes_received')
|
|
}, [
|
|
_('Received'),
|
|
' ',
|
|
getSortIcon('bytes_received')
|
|
]),
|
|
E('div', { 'class': 'th', 'style': 'width: 20%' }, _('Traffic Distribution'))
|
|
])
|
|
].concat(
|
|
// Rows
|
|
sortedDevices.map(function(device, idx) {
|
|
var lastSeen = device.last_seen || 0;
|
|
var now = Math.floor(Date.now() / 1000);
|
|
var ago = now - lastSeen;
|
|
var lastSeenStr = 'N/A';
|
|
|
|
if (lastSeen > 0) {
|
|
if (ago < 60) {
|
|
lastSeenStr = _('Just now');
|
|
} else {
|
|
lastSeenStr = netifydAPI.formatDuration(ago) + ' ' + _('ago');
|
|
}
|
|
}
|
|
|
|
var totalBytes = (device.bytes_sent || 0) + (device.bytes_received || 0);
|
|
var sentPercent = totalBytes > 0 ? ((device.bytes_sent || 0) / totalBytes * 100) : 50;
|
|
var recvPercent = 100 - sentPercent;
|
|
|
|
return E('div', {
|
|
'class': 'tr',
|
|
'style': idx % 2 === 0 ? 'background: #f9fafb' : ''
|
|
}, [
|
|
E('div', { 'class': 'td left', 'style': 'width: 20%' }, [
|
|
E('code', { 'style': 'font-size: 0.9em' }, device.ip || 'Unknown'),
|
|
E('br'),
|
|
E('small', { 'class': 'text-muted' }, lastSeenStr)
|
|
]),
|
|
E('div', { 'class': 'td left', 'style': 'width: 20%' }, [
|
|
E('code', { 'style': 'font-size: 0.8em' }, device.mac || 'Unknown')
|
|
]),
|
|
E('div', { 'class': 'td center', 'style': 'width: 10%' },
|
|
(device.flows || 0).toLocaleString()),
|
|
E('div', { 'class': 'td right', 'style': 'width: 15%' }, [
|
|
E('span', {
|
|
'class': 'badge',
|
|
'style': 'background: #ef4444; color: white; padding: 0.25rem 0.5rem; border-radius: 4px'
|
|
}, netifydAPI.formatBytes(device.bytes_sent || 0))
|
|
]),
|
|
E('div', { 'class': 'td right', 'style': 'width: 15%' }, [
|
|
E('span', {
|
|
'class': 'badge',
|
|
'style': 'background: #10b981; color: white; padding: 0.25rem 0.5rem; border-radius: 4px'
|
|
}, netifydAPI.formatBytes(device.bytes_received || 0))
|
|
]),
|
|
E('div', { 'class': 'td', 'style': 'width: 20%' }, [
|
|
E('div', {
|
|
'style': 'display: flex; gap: 2px; height: 24px; border-radius: 4px; overflow: hidden'
|
|
}, [
|
|
E('div', {
|
|
'style': 'background: #ef4444; width: ' + sentPercent + '%; transition: width 0.3s',
|
|
'title': _('Upload: %s').format(sentPercent.toFixed(1) + '%')
|
|
}),
|
|
E('div', {
|
|
'style': 'background: #10b981; width: ' + recvPercent + '%; transition: width 0.3s',
|
|
'title': _('Download: %s').format(recvPercent.toFixed(1) + '%')
|
|
})
|
|
]),
|
|
E('div', { 'style': 'font-size: 0.75em; color: #6b7280; margin-top: 0.25rem; text-align: center' },
|
|
_('Total: %s').format(netifydAPI.formatBytes(totalBytes)))
|
|
])
|
|
]);
|
|
}.bind(this))
|
|
)
|
|
) : E('div', {
|
|
'class': 'alert-message info',
|
|
'style': 'text-align: center; padding: 3rem'
|
|
}, [
|
|
E('i', { 'class': 'fa fa-network-wired', 'style': 'font-size: 3em; opacity: 0.3; display: block; margin-bottom: 1rem' }),
|
|
E('h4', _('No Device Data')),
|
|
E('p', { 'class': 'text-muted' }, _('No devices have been detected yet')),
|
|
E('small', _('Data will appear once network traffic is analyzed'))
|
|
])
|
|
]);
|
|
},
|
|
|
|
render: function(data) {
|
|
var devicesData = data[0] || {};
|
|
var status = data[1] || {};
|
|
this.devicesData = devicesData.devices || [];
|
|
|
|
var self = this;
|
|
|
|
// Set up polling
|
|
poll.add(L.bind(function() {
|
|
return Promise.all([
|
|
netifydAPI.getDetectedDevices(),
|
|
netifydAPI.getServiceStatus()
|
|
]).then(L.bind(function(result) {
|
|
this.devicesData = (result[0] || {}).devices || [];
|
|
this.renderDevicesTable();
|
|
}, this));
|
|
}, this), this.refreshInterval);
|
|
|
|
var serviceRunning = status.running;
|
|
|
|
return E('div', { 'class': 'cbi-map' }, [
|
|
E('div', { 'style': 'display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem' }, [
|
|
E('h2', { 'name': 'content', 'style': 'margin: 0' }, [
|
|
E('i', { 'class': 'fa fa-network-wired', 'style': 'margin-right: 0.5rem' }),
|
|
_('Detected Devices')
|
|
]),
|
|
E('span', {
|
|
'class': 'badge',
|
|
'style': 'padding: 0.5rem 1rem; font-size: 0.9em; background: ' + (serviceRunning ? '#10b981' : '#ef4444')
|
|
}, [
|
|
E('i', { 'class': 'fa fa-circle', 'style': 'margin-right: 0.5rem' }),
|
|
serviceRunning ? _('Live') : _('Offline')
|
|
])
|
|
]),
|
|
E('div', { 'class': 'cbi-map-descr' },
|
|
_('Network devices detected and tracked by Netifyd deep packet inspection. Updates every 5 seconds.')),
|
|
|
|
E('div', { 'class': 'cbi-section' }, [
|
|
E('div', { 'class': 'cbi-section-node' }, [
|
|
E('div', { 'style': 'display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem' }, [
|
|
E('h3', { 'style': 'margin: 0' }, [
|
|
E('i', { 'class': 'fa fa-chart-bar', 'style': 'margin-right: 0.5rem' }),
|
|
_('Summary')
|
|
]),
|
|
E('button', {
|
|
'class': 'btn btn-primary',
|
|
'click': ui.createHandlerFn(this, 'handleExport')
|
|
}, [
|
|
E('i', { 'class': 'fa fa-download' }),
|
|
' ',
|
|
_('Export CSV')
|
|
])
|
|
]),
|
|
this.renderSummaryCards(this.devicesData)
|
|
])
|
|
]),
|
|
|
|
E('div', { 'class': 'cbi-section' }, [
|
|
E('div', { 'style': 'display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem' }, [
|
|
E('h3', { 'style': 'margin: 0' }, [
|
|
E('i', { 'class': 'fa fa-list', 'style': 'margin-right: 0.5rem' }),
|
|
_('Device List'),
|
|
' ',
|
|
E('span', {
|
|
'class': 'badge',
|
|
'style': 'background: #3b82f6; color: white; margin-left: 0.5rem'
|
|
}, this.devicesData.length)
|
|
]),
|
|
E('div', { 'style': 'display: flex; gap: 0.5rem; align-items: center' }, [
|
|
E('input', {
|
|
'type': 'text',
|
|
'class': 'cbi-input-text',
|
|
'placeholder': _('Search by IP or MAC...'),
|
|
'style': 'min-width: 250px',
|
|
'value': this.searchQuery,
|
|
'keyup': function(ev) {
|
|
self.searchQuery = ev.target.value;
|
|
self.renderDevicesTable();
|
|
}
|
|
})
|
|
])
|
|
]),
|
|
E('div', { 'class': 'cbi-section-node' }, [
|
|
E('div', {
|
|
'class': 'alert alert-info',
|
|
'style': 'margin-bottom: 1rem; display: flex; align-items: center; gap: 1rem'
|
|
}, [
|
|
E('div', { 'style': 'display: flex; align-items: center; gap: 0.5rem' }, [
|
|
E('div', { 'style': 'width: 12px; height: 12px; background: #ef4444; border-radius: 2px' }),
|
|
E('span', _('Upload'))
|
|
]),
|
|
E('div', { 'style': 'display: flex; align-items: center; gap: 0.5rem' }, [
|
|
E('div', { 'style': 'width: 12px; height: 12px; background: #10b981; border-radius: 2px' }),
|
|
E('span', _('Download'))
|
|
]),
|
|
E('span', { 'class': 'text-muted' }, '|'),
|
|
E('span', [
|
|
E('i', { 'class': 'fa fa-sync' }),
|
|
' ',
|
|
_('Auto-refresh: Every %d seconds').format(this.refreshInterval)
|
|
])
|
|
]),
|
|
E('div', { 'id': 'devices-table-container' })
|
|
])
|
|
])
|
|
]);
|
|
},
|
|
|
|
addFooter: function() {
|
|
this.renderDevicesTable();
|
|
},
|
|
|
|
handleSaveApply: null,
|
|
handleSave: null,
|
|
handleReset: null
|
|
});
|