feat(bandwidth-manager): Add Smart QoS, Device Groups, and Analytics (Phase 5)
Advanced Bandwidth Manager features v0.5.0 Smart QoS (DPI Integration): - Real-time application detection via nDPId - Smart traffic suggestions based on detected patterns - One-click DPI rule creation for applications - Gaming, streaming, video conferencing detection - Heavy downloader identification Device Groups: - Create device groups (Family, IoT, Work, Gaming, Kids, Guests) - Shared quota across group members - Unified priority assignment per group - Easy member management via drag-drop UI - Group usage tracking and visualization Analytics Dashboard: - Traffic summary with download/upload totals - Active client count and per-client averages - Application traffic breakdown charts - Protocol distribution pie chart - Top bandwidth users leaderboard - Download/upload ratio analysis - Historical data retention (30 days) - Period selection (1h, 6h, 24h, 7d, 30d) Backend Enhancements: - get_dpi_applications: Fetch detected apps from nDPId - get_smart_suggestions: AI-powered QoS recommendations - apply_dpi_rule: Create rules based on app detection - list_groups/create_group/update_group/delete_group - add_to_group/remove_from_group: Member management - get_analytics_summary: Traffic statistics - get_hourly_data: Historical trends - record_stats: Cron-based data collection Menu Additions: - Smart QoS (order: 10) - Device Groups (order: 11) - Analytics (order: 12) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
a9e5bc0262
commit
fb9722ccd6
@ -0,0 +1,383 @@
|
||||
'use strict';
|
||||
'require view';
|
||||
'require dom';
|
||||
'require poll';
|
||||
'require rpc';
|
||||
'require ui';
|
||||
|
||||
var callGetAnalyticsSummary = rpc.declare({
|
||||
object: 'luci.bandwidth-manager',
|
||||
method: 'get_analytics_summary',
|
||||
params: ['period'],
|
||||
expect: {}
|
||||
});
|
||||
|
||||
var callGetHourlyData = rpc.declare({
|
||||
object: 'luci.bandwidth-manager',
|
||||
method: 'get_hourly_data',
|
||||
params: ['days'],
|
||||
expect: { hourly_data: [] }
|
||||
});
|
||||
|
||||
return view.extend({
|
||||
summary: {},
|
||||
hourlyData: [],
|
||||
selectedPeriod: '24h',
|
||||
|
||||
load: function() {
|
||||
return Promise.all([
|
||||
callGetAnalyticsSummary('24h'),
|
||||
callGetHourlyData(7)
|
||||
]);
|
||||
},
|
||||
|
||||
render: function(data) {
|
||||
var self = this;
|
||||
this.summary = data[0] || {};
|
||||
this.hourlyData = (data[1] && data[1].hourly_data) || [];
|
||||
|
||||
document.body.setAttribute('data-secubox-app', 'bandwidth');
|
||||
|
||||
var view = E('div', { 'class': 'cbi-map' }, [
|
||||
E('h2', { 'class': 'cbi-map-title' }, 'Bandwidth Analytics'),
|
||||
E('div', { 'class': 'cbi-map-descr' },
|
||||
'Traffic analysis, usage trends, and application breakdown'),
|
||||
|
||||
// Period Selector
|
||||
E('div', { 'style': 'margin-bottom: 1.5rem;' }, [
|
||||
E('div', { 'style': 'display: flex; gap: 0.5rem; flex-wrap: wrap;' }, [
|
||||
this.renderPeriodButton('1h', '1 Hour'),
|
||||
this.renderPeriodButton('6h', '6 Hours'),
|
||||
this.renderPeriodButton('24h', '24 Hours', true),
|
||||
this.renderPeriodButton('7d', '7 Days'),
|
||||
this.renderPeriodButton('30d', '30 Days')
|
||||
])
|
||||
]),
|
||||
|
||||
// Stats Grid
|
||||
E('div', { 'id': 'stats-container' }, [
|
||||
this.renderStatsGrid()
|
||||
]),
|
||||
|
||||
// Charts Section
|
||||
E('div', { 'id': 'charts-container', 'style': 'margin-top: 1.5rem;' }, [
|
||||
this.renderCharts()
|
||||
]),
|
||||
|
||||
// Top Talkers & App Breakdown
|
||||
E('div', { 'id': 'details-container', 'style': 'margin-top: 1.5rem;' }, [
|
||||
this.renderDetails()
|
||||
])
|
||||
]);
|
||||
|
||||
poll.add(L.bind(this.pollData, this), 30);
|
||||
|
||||
return view;
|
||||
},
|
||||
|
||||
pollData: function() {
|
||||
var self = this;
|
||||
return callGetAnalyticsSummary(this.selectedPeriod).then(function(data) {
|
||||
self.summary = data || {};
|
||||
self.updateDisplay();
|
||||
});
|
||||
},
|
||||
|
||||
updateDisplay: function() {
|
||||
var statsEl = document.getElementById('stats-container');
|
||||
var chartsEl = document.getElementById('charts-container');
|
||||
var detailsEl = document.getElementById('details-container');
|
||||
|
||||
if (statsEl) {
|
||||
statsEl.innerHTML = '';
|
||||
statsEl.appendChild(this.renderStatsGrid());
|
||||
}
|
||||
if (chartsEl) {
|
||||
chartsEl.innerHTML = '';
|
||||
chartsEl.appendChild(this.renderCharts());
|
||||
}
|
||||
if (detailsEl) {
|
||||
detailsEl.innerHTML = '';
|
||||
detailsEl.appendChild(this.renderDetails());
|
||||
}
|
||||
},
|
||||
|
||||
renderPeriodButton: function(period, label, isDefault) {
|
||||
var self = this;
|
||||
var isActive = this.selectedPeriod === period || (isDefault && !this.selectedPeriod);
|
||||
|
||||
return E('button', {
|
||||
'class': 'cbi-button' + (isActive ? ' cbi-button-action' : ''),
|
||||
'style': 'padding: 0.5rem 1rem;',
|
||||
'click': function() {
|
||||
self.selectedPeriod = period;
|
||||
document.querySelectorAll('.cbi-button[data-period]').forEach(function(btn) {
|
||||
btn.classList.remove('cbi-button-action');
|
||||
});
|
||||
this.classList.add('cbi-button-action');
|
||||
self.pollData();
|
||||
},
|
||||
'data-period': period
|
||||
}, label);
|
||||
},
|
||||
|
||||
renderStatsGrid: function() {
|
||||
var stats = [
|
||||
{
|
||||
icon: '\u2b07\ufe0f',
|
||||
label: 'Total Download',
|
||||
value: this.formatBytes(this.summary.total_rx_bytes || 0),
|
||||
color: '#22c55e'
|
||||
},
|
||||
{
|
||||
icon: '\u2b06\ufe0f',
|
||||
label: 'Total Upload',
|
||||
value: this.formatBytes(this.summary.total_tx_bytes || 0),
|
||||
color: '#3b82f6'
|
||||
},
|
||||
{
|
||||
icon: '\ud83d\udcf1',
|
||||
label: 'Active Clients',
|
||||
value: (this.summary.active_clients || 0).toString(),
|
||||
color: '#8b5cf6'
|
||||
},
|
||||
{
|
||||
icon: '\ud83d\udcc8',
|
||||
label: 'Total Traffic',
|
||||
value: this.formatBytes((this.summary.total_rx_bytes || 0) + (this.summary.total_tx_bytes || 0)),
|
||||
color: '#f59e0b'
|
||||
}
|
||||
];
|
||||
|
||||
return E('div', {
|
||||
'style': 'display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem;'
|
||||
}, stats.map(function(stat) {
|
||||
return E('div', {
|
||||
'style': 'background: var(--cyber-bg-secondary, #141419); border: 1px solid var(--cyber-border-subtle, rgba(255,255,255,0.08)); border-radius: 12px; padding: 1.25rem;'
|
||||
}, [
|
||||
E('div', { 'style': 'display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.75rem;' }, [
|
||||
E('div', {
|
||||
'style': 'width: 40px; height: 40px; background: ' + stat.color + '20; color: ' + stat.color + '; border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 1.25rem;'
|
||||
}, stat.icon),
|
||||
E('span', { 'style': 'font-size: 0.875rem; color: var(--cyber-text-secondary, #a1a1aa);' }, stat.label)
|
||||
]),
|
||||
E('div', { 'style': 'font-size: 1.75rem; font-weight: 700;' }, stat.value)
|
||||
]);
|
||||
}));
|
||||
},
|
||||
|
||||
renderCharts: function() {
|
||||
var self = this;
|
||||
|
||||
// Create a simple SVG bar chart for traffic distribution
|
||||
var appBreakdown = this.summary.app_breakdown || [];
|
||||
var maxBytes = Math.max.apply(null, appBreakdown.map(function(a) { return a.bytes || 0; })) || 1;
|
||||
|
||||
return E('div', {
|
||||
'style': 'display: grid; grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); gap: 1.5rem;'
|
||||
}, [
|
||||
// Application Traffic Chart
|
||||
E('div', {
|
||||
'style': 'background: var(--cyber-bg-secondary, #141419); border: 1px solid var(--cyber-border-subtle, rgba(255,255,255,0.08)); border-radius: 12px; padding: 1.25rem;'
|
||||
}, [
|
||||
E('h4', { 'style': 'margin: 0 0 1rem 0; font-size: 1rem;' }, 'Traffic by Application'),
|
||||
appBreakdown.length > 0 ?
|
||||
E('div', { 'style': 'display: flex; flex-direction: column; gap: 0.75rem;' },
|
||||
appBreakdown.slice(0, 8).map(function(app, idx) {
|
||||
var percent = Math.round((app.bytes / maxBytes) * 100);
|
||||
var colors = ['#3b82f6', '#22c55e', '#f59e0b', '#ec4899', '#8b5cf6', '#06b6d4', '#ef4444', '#14b8a6'];
|
||||
var color = colors[idx % colors.length];
|
||||
|
||||
return E('div', {}, [
|
||||
E('div', { 'style': 'display: flex; justify-content: space-between; margin-bottom: 0.25rem;' }, [
|
||||
E('span', { 'style': 'font-size: 0.875rem; color: var(--cyber-text-primary);' }, app.app || 'Unknown'),
|
||||
E('span', { 'style': 'font-size: 0.875rem; color: var(--cyber-text-secondary);' }, self.formatBytes(app.bytes || 0))
|
||||
]),
|
||||
E('div', {
|
||||
'style': 'height: 8px; background: var(--cyber-bg-tertiary, rgba(255,255,255,0.05)); border-radius: 4px; overflow: hidden;'
|
||||
}, [
|
||||
E('div', {
|
||||
'style': 'height: 100%; width: ' + percent + '%; background: ' + color + '; transition: width 0.3s ease;'
|
||||
})
|
||||
])
|
||||
]);
|
||||
})
|
||||
) :
|
||||
E('div', {
|
||||
'style': 'padding: 2rem; text-align: center; color: var(--cyber-text-secondary);'
|
||||
}, 'No application data available')
|
||||
]),
|
||||
|
||||
// Protocol Breakdown
|
||||
E('div', {
|
||||
'style': 'background: var(--cyber-bg-secondary, #141419); border: 1px solid var(--cyber-border-subtle, rgba(255,255,255,0.08)); border-radius: 12px; padding: 1.25rem;'
|
||||
}, [
|
||||
E('h4', { 'style': 'margin: 0 0 1rem 0; font-size: 1rem;' }, 'Traffic by Protocol'),
|
||||
this.renderProtocolPieChart()
|
||||
])
|
||||
]);
|
||||
},
|
||||
|
||||
renderProtocolPieChart: function() {
|
||||
var protocols = this.summary.protocol_breakdown || [];
|
||||
if (protocols.length === 0) {
|
||||
return E('div', {
|
||||
'style': 'padding: 2rem; text-align: center; color: var(--cyber-text-secondary);'
|
||||
}, 'No protocol data available');
|
||||
}
|
||||
|
||||
var total = protocols.reduce(function(sum, p) { return sum + (p.bytes || 0); }, 0) || 1;
|
||||
var colors = ['#3b82f6', '#22c55e', '#f59e0b', '#ec4899', '#8b5cf6'];
|
||||
|
||||
return E('div', { 'style': 'display: flex; align-items: center; gap: 2rem;' }, [
|
||||
// Simple donut representation
|
||||
E('div', {
|
||||
'style': 'width: 120px; height: 120px; border-radius: 50%; background: conic-gradient(' +
|
||||
protocols.slice(0, 5).map(function(p, idx) {
|
||||
var startPercent = protocols.slice(0, idx).reduce(function(sum, pr) {
|
||||
return sum + ((pr.bytes || 0) / total * 100);
|
||||
}, 0);
|
||||
var endPercent = startPercent + ((p.bytes || 0) / total * 100);
|
||||
return colors[idx] + ' ' + startPercent + '% ' + endPercent + '%';
|
||||
}).join(', ') + '); position: relative;'
|
||||
}, [
|
||||
E('div', {
|
||||
'style': 'position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 70px; height: 70px; background: var(--cyber-bg-secondary, #141419); border-radius: 50%;'
|
||||
})
|
||||
]),
|
||||
// Legend
|
||||
E('div', { 'style': 'display: flex; flex-direction: column; gap: 0.5rem;' },
|
||||
protocols.slice(0, 5).map(function(p, idx) {
|
||||
var percent = Math.round((p.bytes || 0) / total * 100);
|
||||
return E('div', { 'style': 'display: flex; align-items: center; gap: 0.5rem;' }, [
|
||||
E('div', {
|
||||
'style': 'width: 12px; height: 12px; background: ' + colors[idx] + '; border-radius: 2px;'
|
||||
}),
|
||||
E('span', { 'style': 'font-size: 0.875rem;' }, (p.protocol || 'Unknown') + ' (' + percent + '%)')
|
||||
]);
|
||||
})
|
||||
)
|
||||
]);
|
||||
},
|
||||
|
||||
renderDetails: function() {
|
||||
var self = this;
|
||||
var topTalkers = this.summary.top_talkers || [];
|
||||
|
||||
return E('div', {
|
||||
'style': 'display: grid; grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); gap: 1.5rem;'
|
||||
}, [
|
||||
// Top Talkers
|
||||
E('div', {
|
||||
'style': 'background: var(--cyber-bg-secondary, #141419); border: 1px solid var(--cyber-border-subtle, rgba(255,255,255,0.08)); border-radius: 12px; overflow: hidden;'
|
||||
}, [
|
||||
E('div', {
|
||||
'style': 'padding: 1rem 1.25rem; border-bottom: 1px solid var(--cyber-border-subtle, rgba(255,255,255,0.08));'
|
||||
}, [
|
||||
E('h4', { 'style': 'margin: 0; font-size: 1rem;' }, 'Top Bandwidth Users')
|
||||
]),
|
||||
topTalkers.length > 0 ?
|
||||
E('table', { 'class': 'table', 'style': 'width: 100%;' }, [
|
||||
E('thead', {}, [
|
||||
E('tr', {}, [
|
||||
E('th', { 'style': 'padding: 0.75rem 1.25rem;' }, 'Device'),
|
||||
E('th', { 'style': 'padding: 0.75rem 1.25rem;' }, 'IP'),
|
||||
E('th', { 'style': 'padding: 0.75rem 1.25rem; text-align: right;' }, 'Usage')
|
||||
])
|
||||
]),
|
||||
E('tbody', {},
|
||||
topTalkers.map(function(client, idx) {
|
||||
var medals = ['\ud83e\udd47', '\ud83e\udd48', '\ud83e\udd49', '', ''];
|
||||
return E('tr', {}, [
|
||||
E('td', { 'style': 'padding: 0.75rem 1.25rem;' }, [
|
||||
E('div', { 'style': 'display: flex; align-items: center; gap: 0.5rem;' }, [
|
||||
E('span', { 'style': 'font-size: 1.25rem;' }, medals[idx] || ''),
|
||||
E('div', {}, [
|
||||
E('div', { 'style': 'font-weight: 500;' }, client.hostname || 'Unknown'),
|
||||
E('div', { 'style': 'font-size: 0.75rem; color: var(--cyber-text-tertiary);' }, client.mac)
|
||||
])
|
||||
])
|
||||
]),
|
||||
E('td', { 'style': 'padding: 0.75rem 1.25rem;' }, client.ip || '-'),
|
||||
E('td', { 'style': 'padding: 0.75rem 1.25rem; text-align: right; font-weight: 600;' },
|
||||
self.formatMB(client.used_mb || 0))
|
||||
]);
|
||||
})
|
||||
)
|
||||
]) :
|
||||
E('div', {
|
||||
'style': 'padding: 2rem; text-align: center; color: var(--cyber-text-secondary);'
|
||||
}, 'No usage data available')
|
||||
]),
|
||||
|
||||
// Quick Actions
|
||||
E('div', {
|
||||
'style': 'background: var(--cyber-bg-secondary, #141419); border: 1px solid var(--cyber-border-subtle, rgba(255,255,255,0.08)); border-radius: 12px; padding: 1.25rem;'
|
||||
}, [
|
||||
E('h4', { 'style': 'margin: 0 0 1rem 0; font-size: 1rem;' }, 'Analytics Summary'),
|
||||
E('div', { 'style': 'display: flex; flex-direction: column; gap: 1rem;' }, [
|
||||
E('div', {
|
||||
'style': 'padding: 1rem; background: var(--cyber-bg-tertiary, rgba(255,255,255,0.05)); border-radius: 8px;'
|
||||
}, [
|
||||
E('div', { 'style': 'display: flex; justify-content: space-between; margin-bottom: 0.5rem;' }, [
|
||||
E('span', { 'style': 'color: var(--cyber-text-secondary);' }, 'Download/Upload Ratio'),
|
||||
E('span', { 'style': 'font-weight: 600;' },
|
||||
this.summary.total_tx_bytes > 0 ?
|
||||
((this.summary.total_rx_bytes || 0) / (this.summary.total_tx_bytes || 1)).toFixed(1) + ':1' :
|
||||
'N/A')
|
||||
]),
|
||||
E('div', { 'style': 'font-size: 0.75rem; color: var(--cyber-text-tertiary);' },
|
||||
'Typical ratio is 5:1 to 10:1 for home networks')
|
||||
]),
|
||||
E('div', {
|
||||
'style': 'padding: 1rem; background: var(--cyber-bg-tertiary, rgba(255,255,255,0.05)); border-radius: 8px;'
|
||||
}, [
|
||||
E('div', { 'style': 'display: flex; justify-content: space-between; margin-bottom: 0.5rem;' }, [
|
||||
E('span', { 'style': 'color: var(--cyber-text-secondary);' }, 'Average per Client'),
|
||||
E('span', { 'style': 'font-weight: 600;' },
|
||||
this.summary.active_clients > 0 ?
|
||||
this.formatBytes(((this.summary.total_rx_bytes || 0) + (this.summary.total_tx_bytes || 0)) / this.summary.active_clients) :
|
||||
'N/A')
|
||||
]),
|
||||
E('div', { 'style': 'font-size: 0.75rem; color: var(--cyber-text-tertiary);' },
|
||||
'Based on ' + (this.summary.active_clients || 0) + ' active devices')
|
||||
]),
|
||||
E('div', {
|
||||
'style': 'padding: 1rem; background: var(--cyber-bg-tertiary, rgba(255,255,255,0.05)); border-radius: 8px;'
|
||||
}, [
|
||||
E('div', { 'style': 'display: flex; justify-content: space-between; margin-bottom: 0.5rem;' }, [
|
||||
E('span', { 'style': 'color: var(--cyber-text-secondary);' }, 'Applications Detected'),
|
||||
E('span', { 'style': 'font-weight: 600;' },
|
||||
(this.summary.app_breakdown || []).length.toString())
|
||||
]),
|
||||
E('div', { 'style': 'font-size: 0.75rem; color: var(--cyber-text-tertiary);' },
|
||||
'Via Deep Packet Inspection')
|
||||
])
|
||||
])
|
||||
])
|
||||
]);
|
||||
},
|
||||
|
||||
formatBytes: function(bytes) {
|
||||
if (!bytes || bytes === 0) return '0 B';
|
||||
var units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
var i = 0;
|
||||
while (bytes >= 1024 && i < units.length - 1) {
|
||||
bytes /= 1024;
|
||||
i++;
|
||||
}
|
||||
return bytes.toFixed(1) + ' ' + units[i];
|
||||
},
|
||||
|
||||
formatMB: function(mb) {
|
||||
if (!mb || mb === 0) return '0 MB';
|
||||
if (mb >= 1024) {
|
||||
return (mb / 1024).toFixed(1) + ' GB';
|
||||
}
|
||||
return mb + ' MB';
|
||||
},
|
||||
|
||||
handleSaveApply: null,
|
||||
handleSave: null,
|
||||
handleReset: null
|
||||
});
|
||||
@ -0,0 +1,592 @@
|
||||
'use strict';
|
||||
'require view';
|
||||
'require dom';
|
||||
'require poll';
|
||||
'require rpc';
|
||||
'require ui';
|
||||
|
||||
var callListGroups = rpc.declare({
|
||||
object: 'luci.bandwidth-manager',
|
||||
method: 'list_groups',
|
||||
expect: { groups: [] }
|
||||
});
|
||||
|
||||
var callGetGroup = rpc.declare({
|
||||
object: 'luci.bandwidth-manager',
|
||||
method: 'get_group',
|
||||
params: ['group_id'],
|
||||
expect: { success: false }
|
||||
});
|
||||
|
||||
var callCreateGroup = rpc.declare({
|
||||
object: 'luci.bandwidth-manager',
|
||||
method: 'create_group',
|
||||
params: ['name', 'description', 'quota_mb', 'priority', 'members'],
|
||||
expect: { success: false, message: '' }
|
||||
});
|
||||
|
||||
var callUpdateGroup = rpc.declare({
|
||||
object: 'luci.bandwidth-manager',
|
||||
method: 'update_group',
|
||||
params: ['group_id', 'name', 'description', 'quota_mb', 'priority', 'members'],
|
||||
expect: { success: false, message: '' }
|
||||
});
|
||||
|
||||
var callDeleteGroup = rpc.declare({
|
||||
object: 'luci.bandwidth-manager',
|
||||
method: 'delete_group',
|
||||
params: ['group_id'],
|
||||
expect: { success: false, message: '' }
|
||||
});
|
||||
|
||||
var callAddToGroup = rpc.declare({
|
||||
object: 'luci.bandwidth-manager',
|
||||
method: 'add_to_group',
|
||||
params: ['group_id', 'mac'],
|
||||
expect: { success: false, message: '' }
|
||||
});
|
||||
|
||||
var callRemoveFromGroup = rpc.declare({
|
||||
object: 'luci.bandwidth-manager',
|
||||
method: 'remove_from_group',
|
||||
params: ['group_id', 'mac'],
|
||||
expect: { success: false, message: '' }
|
||||
});
|
||||
|
||||
var callGetUsageRealtime = rpc.declare({
|
||||
object: 'luci.bandwidth-manager',
|
||||
method: 'get_usage_realtime',
|
||||
expect: { clients: [] }
|
||||
});
|
||||
|
||||
var callGetClasses = rpc.declare({
|
||||
object: 'luci.bandwidth-manager',
|
||||
method: 'get_classes',
|
||||
expect: { classes: [] }
|
||||
});
|
||||
|
||||
return view.extend({
|
||||
groups: [],
|
||||
clients: [],
|
||||
classes: [],
|
||||
|
||||
load: function() {
|
||||
return Promise.all([
|
||||
callListGroups(),
|
||||
callGetUsageRealtime(),
|
||||
callGetClasses()
|
||||
]);
|
||||
},
|
||||
|
||||
render: function(data) {
|
||||
var self = this;
|
||||
this.groups = (data[0] && data[0].groups) || [];
|
||||
this.clients = (data[1] && data[1].clients) || [];
|
||||
this.classes = (data[2] && data[2].classes) || [];
|
||||
|
||||
document.body.setAttribute('data-secubox-app', 'bandwidth');
|
||||
|
||||
var view = E('div', { 'class': 'cbi-map' }, [
|
||||
E('h2', { 'class': 'cbi-map-title' }, 'Device Groups'),
|
||||
E('div', { 'class': 'cbi-map-descr' },
|
||||
'Organize devices into groups for shared quotas and unified QoS policies'),
|
||||
|
||||
// Create Group Button
|
||||
E('div', { 'style': 'margin-bottom: 1rem;' }, [
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-add',
|
||||
'click': function() { self.showCreateGroupDialog(); }
|
||||
}, 'Create New Group')
|
||||
]),
|
||||
|
||||
// Groups Grid
|
||||
E('div', { 'id': 'groups-container' }, [
|
||||
this.renderGroupsGrid()
|
||||
])
|
||||
]);
|
||||
|
||||
poll.add(L.bind(this.pollData, this), 15);
|
||||
|
||||
return view;
|
||||
},
|
||||
|
||||
pollData: function() {
|
||||
var self = this;
|
||||
return callListGroups().then(function(data) {
|
||||
self.groups = (data && data.groups) || [];
|
||||
var container = document.getElementById('groups-container');
|
||||
if (container) {
|
||||
container.innerHTML = '';
|
||||
container.appendChild(self.renderGroupsGrid());
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
renderGroupsGrid: function() {
|
||||
var self = this;
|
||||
|
||||
if (this.groups.length === 0) {
|
||||
return E('div', {
|
||||
'style': 'padding: 3rem; text-align: center; color: var(--cyber-text-secondary, #a1a1aa); background: var(--cyber-bg-secondary, #141419); border-radius: 12px; border: 1px dashed var(--cyber-border-subtle, rgba(255,255,255,0.15));'
|
||||
}, [
|
||||
E('div', { 'style': 'font-size: 3rem; margin-bottom: 1rem;' }, '\ud83d\udc65'),
|
||||
E('div', { 'style': 'font-weight: 600; margin-bottom: 0.5rem;' }, 'No Groups Created'),
|
||||
E('div', { 'style': 'font-size: 0.875rem;' }, 'Create device groups to apply shared quotas and priorities'),
|
||||
E('br'),
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-add',
|
||||
'click': function() { self.showCreateGroupDialog(); }
|
||||
}, 'Create First Group')
|
||||
]);
|
||||
}
|
||||
|
||||
var presetIcons = {
|
||||
'Family': '\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d\udc66',
|
||||
'IoT': '\ud83d\udce1',
|
||||
'Work': '\ud83d\udcbc',
|
||||
'Gaming': '\ud83c\udfae',
|
||||
'Kids': '\ud83d\udc76',
|
||||
'Guests': '\ud83d\udc64'
|
||||
};
|
||||
|
||||
var presetColors = {
|
||||
'Family': '#8b5cf6',
|
||||
'IoT': '#06b6d4',
|
||||
'Work': '#3b82f6',
|
||||
'Gaming': '#ec4899',
|
||||
'Kids': '#22c55e',
|
||||
'Guests': '#f59e0b'
|
||||
};
|
||||
|
||||
return E('div', {
|
||||
'style': 'display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 1.25rem;'
|
||||
}, this.groups.map(function(group) {
|
||||
var icon = presetIcons[group.name] || '\ud83d\udc65';
|
||||
var color = presetColors[group.name] || '#667eea';
|
||||
|
||||
var usagePercent = 0;
|
||||
if (group.quota_mb && group.quota_mb > 0) {
|
||||
usagePercent = Math.min(100, Math.round((group.used_mb / group.quota_mb) * 100));
|
||||
}
|
||||
|
||||
var progressColor = usagePercent > 90 ? '#ef4444' : usagePercent > 70 ? '#f59e0b' : '#22c55e';
|
||||
|
||||
return E('div', {
|
||||
'style': 'background: var(--cyber-bg-secondary, #141419); border: 1px solid var(--cyber-border-subtle, rgba(255,255,255,0.08)); border-radius: 12px; overflow: hidden; transition: all 0.2s ease;'
|
||||
}, [
|
||||
// Header
|
||||
E('div', {
|
||||
'style': 'padding: 1.25rem; border-bottom: 1px solid var(--cyber-border-subtle, rgba(255,255,255,0.08));'
|
||||
}, [
|
||||
E('div', { 'style': 'display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.5rem;' }, [
|
||||
E('div', {
|
||||
'style': 'width: 44px; height: 44px; background: ' + color + '20; color: ' + color + '; border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 1.25rem;'
|
||||
}, icon),
|
||||
E('div', { 'style': 'flex: 1;' }, [
|
||||
E('div', { 'style': 'font-weight: 600; font-size: 1rem;' }, group.name),
|
||||
E('div', { 'style': 'font-size: 0.75rem; color: var(--cyber-text-tertiary, #71717a);' },
|
||||
group.description || 'No description')
|
||||
]),
|
||||
E('span', {
|
||||
'style': 'padding: 0.25rem 0.5rem; font-size: 0.6875rem; font-weight: 600; border-radius: 4px; background: ' + (group.enabled ? 'rgba(34, 197, 94, 0.15)' : 'rgba(239, 68, 68, 0.15)') + '; color: ' + (group.enabled ? '#22c55e' : '#ef4444') + ';'
|
||||
}, group.enabled ? 'Active' : 'Disabled')
|
||||
])
|
||||
]),
|
||||
|
||||
// Stats
|
||||
E('div', { 'style': 'padding: 1rem 1.25rem;' }, [
|
||||
E('div', { 'style': 'display: flex; justify-content: space-between; margin-bottom: 1rem;' }, [
|
||||
E('div', {}, [
|
||||
E('div', { 'style': 'font-size: 1.5rem; font-weight: 700;' }, group.member_count.toString()),
|
||||
E('div', { 'style': 'font-size: 0.75rem; color: var(--cyber-text-secondary);' }, 'Devices')
|
||||
]),
|
||||
E('div', { 'style': 'text-align: center;' }, [
|
||||
E('div', { 'style': 'font-size: 1.5rem; font-weight: 700;' }, 'P' + (group.priority || 5)),
|
||||
E('div', { 'style': 'font-size: 0.75rem; color: var(--cyber-text-secondary);' }, 'Priority')
|
||||
]),
|
||||
E('div', { 'style': 'text-align: right;' }, [
|
||||
E('div', { 'style': 'font-size: 1.5rem; font-weight: 700;' },
|
||||
group.quota_mb > 0 ? self.formatMB(group.quota_mb) : '\u221e'),
|
||||
E('div', { 'style': 'font-size: 0.75rem; color: var(--cyber-text-secondary);' }, 'Quota')
|
||||
])
|
||||
]),
|
||||
|
||||
// Usage Progress
|
||||
group.quota_mb > 0 ? E('div', { 'style': 'margin-bottom: 0.5rem;' }, [
|
||||
E('div', { 'style': 'display: flex; justify-content: space-between; font-size: 0.75rem; margin-bottom: 0.25rem;' }, [
|
||||
E('span', { 'style': 'color: var(--cyber-text-secondary);' }, 'Usage'),
|
||||
E('span', {}, self.formatMB(group.used_mb) + ' / ' + self.formatMB(group.quota_mb) + ' (' + usagePercent + '%)')
|
||||
]),
|
||||
E('div', {
|
||||
'style': 'height: 6px; background: var(--cyber-bg-tertiary, rgba(255,255,255,0.05)); border-radius: 3px; overflow: hidden;'
|
||||
}, [
|
||||
E('div', {
|
||||
'style': 'height: 100%; width: ' + usagePercent + '%; background: ' + progressColor + '; transition: width 0.3s ease;'
|
||||
})
|
||||
])
|
||||
]) : null
|
||||
]),
|
||||
|
||||
// Actions
|
||||
E('div', {
|
||||
'style': 'padding: 0.75rem 1.25rem; background: var(--cyber-bg-tertiary, rgba(255,255,255,0.03)); display: flex; gap: 0.5rem;'
|
||||
}, [
|
||||
E('button', {
|
||||
'class': 'cbi-button',
|
||||
'style': 'flex: 1; font-size: 0.75rem;',
|
||||
'click': function() { self.showGroupDetails(group.id); }
|
||||
}, 'Manage'),
|
||||
E('button', {
|
||||
'class': 'cbi-button',
|
||||
'style': 'flex: 1; font-size: 0.75rem;',
|
||||
'click': function() { self.showEditGroupDialog(group); }
|
||||
}, 'Edit'),
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-negative',
|
||||
'style': 'font-size: 0.75rem;',
|
||||
'click': function() { self.deleteGroup(group); }
|
||||
}, '\u2717')
|
||||
])
|
||||
]);
|
||||
}));
|
||||
},
|
||||
|
||||
showCreateGroupDialog: function() {
|
||||
var self = this;
|
||||
|
||||
var presets = [
|
||||
{ name: 'Family', desc: 'Family members devices', icon: '\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d\udc66' },
|
||||
{ name: 'IoT', desc: 'Smart home devices', icon: '\ud83d\udce1' },
|
||||
{ name: 'Work', desc: 'Work/business devices', icon: '\ud83d\udcbc' },
|
||||
{ name: 'Gaming', desc: 'Gaming consoles and PCs', icon: '\ud83c\udfae' },
|
||||
{ name: 'Kids', desc: 'Children\'s devices', icon: '\ud83d\udc76' },
|
||||
{ name: 'Guests', desc: 'Guest network devices', icon: '\ud83d\udc64' }
|
||||
];
|
||||
|
||||
ui.showModal('Create Device Group', [
|
||||
E('div', { 'style': 'margin-bottom: 1rem;' }, [
|
||||
E('label', { 'style': 'display: block; margin-bottom: 0.5rem; font-weight: 500;' }, 'Quick Presets'),
|
||||
E('div', { 'style': 'display: flex; flex-wrap: wrap; gap: 0.5rem;' },
|
||||
presets.map(function(preset) {
|
||||
return E('button', {
|
||||
'class': 'cbi-button',
|
||||
'style': 'display: flex; align-items: center; gap: 0.5rem;',
|
||||
'click': function() {
|
||||
document.getElementById('group-name').value = preset.name;
|
||||
document.getElementById('group-desc').value = preset.desc;
|
||||
}
|
||||
}, [preset.icon, ' ', preset.name]);
|
||||
})
|
||||
)
|
||||
]),
|
||||
E('hr', { 'style': 'margin: 1rem 0; border-color: var(--cyber-border-subtle);' }),
|
||||
E('div', { 'style': 'margin-bottom: 1rem;' }, [
|
||||
E('label', { 'style': 'display: block; margin-bottom: 0.5rem;' }, 'Group Name *'),
|
||||
E('input', {
|
||||
'type': 'text',
|
||||
'class': 'cbi-input-text',
|
||||
'id': 'group-name',
|
||||
'placeholder': 'Enter group name'
|
||||
})
|
||||
]),
|
||||
E('div', { 'style': 'margin-bottom: 1rem;' }, [
|
||||
E('label', { 'style': 'display: block; margin-bottom: 0.5rem;' }, 'Description'),
|
||||
E('input', {
|
||||
'type': 'text',
|
||||
'class': 'cbi-input-text',
|
||||
'id': 'group-desc',
|
||||
'placeholder': 'Optional description'
|
||||
})
|
||||
]),
|
||||
E('div', { 'style': 'display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1rem;' }, [
|
||||
E('div', {}, [
|
||||
E('label', { 'style': 'display: block; margin-bottom: 0.5rem;' }, 'Shared Quota (MB)'),
|
||||
E('input', {
|
||||
'type': 'number',
|
||||
'class': 'cbi-input-text',
|
||||
'id': 'group-quota',
|
||||
'value': '0',
|
||||
'min': '0',
|
||||
'placeholder': '0 = unlimited'
|
||||
})
|
||||
]),
|
||||
E('div', {}, [
|
||||
E('label', { 'style': 'display: block; margin-bottom: 0.5rem;' }, 'Priority Class'),
|
||||
E('select', { 'class': 'cbi-input-select', 'id': 'group-priority' },
|
||||
this.classes.map(function(c) {
|
||||
return E('option', { 'value': c.priority, 'selected': c.priority === 5 }, c.priority + ' - ' + c.name);
|
||||
})
|
||||
)
|
||||
])
|
||||
]),
|
||||
E('div', { 'class': 'right' }, [
|
||||
E('button', {
|
||||
'class': 'cbi-button',
|
||||
'click': ui.hideModal
|
||||
}, 'Cancel'),
|
||||
' ',
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-positive',
|
||||
'click': function() {
|
||||
var name = document.getElementById('group-name').value.trim();
|
||||
var desc = document.getElementById('group-desc').value.trim();
|
||||
var quota = parseInt(document.getElementById('group-quota').value) || 0;
|
||||
var priority = parseInt(document.getElementById('group-priority').value) || 5;
|
||||
|
||||
if (!name) {
|
||||
ui.addNotification(null, E('p', {}, 'Group name is required'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
ui.hideModal();
|
||||
callCreateGroup(name, desc, quota, priority, '').then(function(res) {
|
||||
if (res.success) {
|
||||
ui.addNotification(null, E('p', {}, 'Group created successfully'), 'success');
|
||||
self.pollData();
|
||||
} else {
|
||||
ui.addNotification(null, E('p', {}, res.message || 'Failed to create group'), 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
}, 'Create Group')
|
||||
])
|
||||
]);
|
||||
},
|
||||
|
||||
showEditGroupDialog: function(group) {
|
||||
var self = this;
|
||||
|
||||
ui.showModal('Edit Group: ' + group.name, [
|
||||
E('div', { 'style': 'margin-bottom: 1rem;' }, [
|
||||
E('label', { 'style': 'display: block; margin-bottom: 0.5rem;' }, 'Group Name *'),
|
||||
E('input', {
|
||||
'type': 'text',
|
||||
'class': 'cbi-input-text',
|
||||
'id': 'edit-group-name',
|
||||
'value': group.name
|
||||
})
|
||||
]),
|
||||
E('div', { 'style': 'margin-bottom: 1rem;' }, [
|
||||
E('label', { 'style': 'display: block; margin-bottom: 0.5rem;' }, 'Description'),
|
||||
E('input', {
|
||||
'type': 'text',
|
||||
'class': 'cbi-input-text',
|
||||
'id': 'edit-group-desc',
|
||||
'value': group.description || ''
|
||||
})
|
||||
]),
|
||||
E('div', { 'style': 'display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1rem;' }, [
|
||||
E('div', {}, [
|
||||
E('label', { 'style': 'display: block; margin-bottom: 0.5rem;' }, 'Shared Quota (MB)'),
|
||||
E('input', {
|
||||
'type': 'number',
|
||||
'class': 'cbi-input-text',
|
||||
'id': 'edit-group-quota',
|
||||
'value': group.quota_mb || '0',
|
||||
'min': '0'
|
||||
})
|
||||
]),
|
||||
E('div', {}, [
|
||||
E('label', { 'style': 'display: block; margin-bottom: 0.5rem;' }, 'Priority Class'),
|
||||
E('select', { 'class': 'cbi-input-select', 'id': 'edit-group-priority' },
|
||||
this.classes.map(function(c) {
|
||||
return E('option', {
|
||||
'value': c.priority,
|
||||
'selected': c.priority === (group.priority || 5)
|
||||
}, c.priority + ' - ' + c.name);
|
||||
})
|
||||
)
|
||||
])
|
||||
]),
|
||||
E('div', { 'class': 'right' }, [
|
||||
E('button', {
|
||||
'class': 'cbi-button',
|
||||
'click': ui.hideModal
|
||||
}, 'Cancel'),
|
||||
' ',
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-positive',
|
||||
'click': function() {
|
||||
var name = document.getElementById('edit-group-name').value.trim();
|
||||
var desc = document.getElementById('edit-group-desc').value.trim();
|
||||
var quota = parseInt(document.getElementById('edit-group-quota').value) || 0;
|
||||
var priority = parseInt(document.getElementById('edit-group-priority').value) || 5;
|
||||
|
||||
if (!name) {
|
||||
ui.addNotification(null, E('p', {}, 'Group name is required'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
ui.hideModal();
|
||||
callUpdateGroup(group.id, name, desc, quota, priority, '').then(function(res) {
|
||||
if (res.success) {
|
||||
ui.addNotification(null, E('p', {}, 'Group updated successfully'), 'success');
|
||||
self.pollData();
|
||||
} else {
|
||||
ui.addNotification(null, E('p', {}, res.message || 'Failed to update group'), 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
}, 'Save Changes')
|
||||
])
|
||||
]);
|
||||
},
|
||||
|
||||
showGroupDetails: function(groupId) {
|
||||
var self = this;
|
||||
|
||||
callGetGroup(groupId).then(function(group) {
|
||||
if (!group.success) {
|
||||
ui.addNotification(null, E('p', {}, 'Failed to load group details'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
var members = group.members || [];
|
||||
var availableClients = self.clients.filter(function(c) {
|
||||
return !members.some(function(m) { return m.mac.toLowerCase() === c.mac.toLowerCase(); });
|
||||
});
|
||||
|
||||
ui.showModal('Manage Group: ' + group.name, [
|
||||
E('h4', { 'style': 'margin-bottom: 1rem;' }, 'Group Members (' + members.length + ')'),
|
||||
|
||||
members.length > 0 ?
|
||||
E('div', { 'style': 'max-height: 200px; overflow-y: auto; margin-bottom: 1rem;' }, [
|
||||
E('table', { 'class': 'table', 'style': 'width: 100%;' }, [
|
||||
E('thead', {}, [
|
||||
E('tr', {}, [
|
||||
E('th', {}, 'Device'),
|
||||
E('th', {}, 'IP'),
|
||||
E('th', {}, 'Usage'),
|
||||
E('th', {}, 'Action')
|
||||
])
|
||||
]),
|
||||
E('tbody', {},
|
||||
members.map(function(member) {
|
||||
return E('tr', {}, [
|
||||
E('td', {}, [
|
||||
E('div', { 'style': 'font-weight: 500;' }, member.hostname || 'Unknown'),
|
||||
E('div', { 'style': 'font-size: 0.75rem; color: var(--cyber-text-tertiary);' }, member.mac)
|
||||
]),
|
||||
E('td', {}, member.ip || '-'),
|
||||
E('td', {}, self.formatMB(member.used_mb || 0)),
|
||||
E('td', {}, [
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-negative',
|
||||
'style': 'font-size: 0.75rem; padding: 0.25rem 0.5rem;',
|
||||
'click': function() {
|
||||
callRemoveFromGroup(groupId, member.mac).then(function(res) {
|
||||
if (res.success) {
|
||||
ui.hideModal();
|
||||
self.showGroupDetails(groupId);
|
||||
self.pollData();
|
||||
}
|
||||
});
|
||||
}
|
||||
}, 'Remove')
|
||||
])
|
||||
]);
|
||||
})
|
||||
)
|
||||
])
|
||||
]) :
|
||||
E('div', {
|
||||
'style': 'padding: 1rem; text-align: center; color: var(--cyber-text-secondary); background: var(--cyber-bg-tertiary); border-radius: 8px; margin-bottom: 1rem;'
|
||||
}, 'No devices in this group'),
|
||||
|
||||
E('h4', { 'style': 'margin-bottom: 1rem;' }, 'Add Devices'),
|
||||
|
||||
availableClients.length > 0 ?
|
||||
E('div', { 'style': 'max-height: 200px; overflow-y: auto; margin-bottom: 1rem;' }, [
|
||||
E('table', { 'class': 'table', 'style': 'width: 100%;' }, [
|
||||
E('thead', {}, [
|
||||
E('tr', {}, [
|
||||
E('th', {}, 'Device'),
|
||||
E('th', {}, 'IP'),
|
||||
E('th', {}, 'Action')
|
||||
])
|
||||
]),
|
||||
E('tbody', {},
|
||||
availableClients.map(function(client) {
|
||||
return E('tr', {}, [
|
||||
E('td', {}, [
|
||||
E('div', { 'style': 'font-weight: 500;' }, client.hostname || 'Unknown'),
|
||||
E('div', { 'style': 'font-size: 0.75rem; color: var(--cyber-text-tertiary);' }, client.mac)
|
||||
]),
|
||||
E('td', {}, client.ip || '-'),
|
||||
E('td', {}, [
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-positive',
|
||||
'style': 'font-size: 0.75rem; padding: 0.25rem 0.5rem;',
|
||||
'click': function() {
|
||||
callAddToGroup(groupId, client.mac).then(function(res) {
|
||||
if (res.success) {
|
||||
ui.hideModal();
|
||||
self.showGroupDetails(groupId);
|
||||
self.pollData();
|
||||
}
|
||||
});
|
||||
}
|
||||
}, 'Add')
|
||||
])
|
||||
]);
|
||||
})
|
||||
)
|
||||
])
|
||||
]) :
|
||||
E('div', {
|
||||
'style': 'padding: 1rem; text-align: center; color: var(--cyber-text-secondary); background: var(--cyber-bg-tertiary); border-radius: 8px; margin-bottom: 1rem;'
|
||||
}, 'No available devices to add'),
|
||||
|
||||
E('div', { 'class': 'right' }, [
|
||||
E('button', {
|
||||
'class': 'cbi-button',
|
||||
'click': ui.hideModal
|
||||
}, 'Close')
|
||||
])
|
||||
]);
|
||||
});
|
||||
},
|
||||
|
||||
deleteGroup: function(group) {
|
||||
var self = this;
|
||||
|
||||
ui.showModal('Delete Group', [
|
||||
E('p', {}, 'Are you sure you want to delete the group "' + group.name + '"?'),
|
||||
E('p', { 'style': 'color: var(--cyber-text-secondary);' },
|
||||
'This will not affect the devices in the group.'),
|
||||
E('div', { 'class': 'right' }, [
|
||||
E('button', {
|
||||
'class': 'cbi-button',
|
||||
'click': ui.hideModal
|
||||
}, 'Cancel'),
|
||||
' ',
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-negative',
|
||||
'click': function() {
|
||||
ui.hideModal();
|
||||
callDeleteGroup(group.id).then(function(res) {
|
||||
if (res.success) {
|
||||
ui.addNotification(null, E('p', {}, 'Group deleted'), 'success');
|
||||
self.pollData();
|
||||
} else {
|
||||
ui.addNotification(null, E('p', {}, res.message || 'Failed to delete group'), 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
}, 'Delete')
|
||||
])
|
||||
]);
|
||||
},
|
||||
|
||||
formatMB: function(mb) {
|
||||
if (!mb || mb === 0) return '0 MB';
|
||||
if (mb >= 1024) {
|
||||
return (mb / 1024).toFixed(1) + ' GB';
|
||||
}
|
||||
return mb + ' MB';
|
||||
},
|
||||
|
||||
handleSaveApply: null,
|
||||
handleSave: null,
|
||||
handleReset: null
|
||||
});
|
||||
@ -0,0 +1,399 @@
|
||||
'use strict';
|
||||
'require view';
|
||||
'require dom';
|
||||
'require poll';
|
||||
'require rpc';
|
||||
'require ui';
|
||||
|
||||
var callGetDpiApplications = rpc.declare({
|
||||
object: 'luci.bandwidth-manager',
|
||||
method: 'get_dpi_applications',
|
||||
expect: { applications: [], dpi_source: 'none' }
|
||||
});
|
||||
|
||||
var callGetSmartSuggestions = rpc.declare({
|
||||
object: 'luci.bandwidth-manager',
|
||||
method: 'get_smart_suggestions',
|
||||
expect: { suggestions: [] }
|
||||
});
|
||||
|
||||
var callApplyDpiRule = rpc.declare({
|
||||
object: 'luci.bandwidth-manager',
|
||||
method: 'apply_dpi_rule',
|
||||
params: ['app_name', 'priority', 'limit_down', 'limit_up'],
|
||||
expect: { success: false, message: '' }
|
||||
});
|
||||
|
||||
var callGetClasses = rpc.declare({
|
||||
object: 'luci.bandwidth-manager',
|
||||
method: 'get_classes',
|
||||
expect: { classes: [] }
|
||||
});
|
||||
|
||||
return view.extend({
|
||||
applications: [],
|
||||
suggestions: [],
|
||||
classes: [],
|
||||
dpiSource: 'none',
|
||||
|
||||
load: function() {
|
||||
return Promise.all([
|
||||
callGetDpiApplications(),
|
||||
callGetSmartSuggestions(),
|
||||
callGetClasses()
|
||||
]);
|
||||
},
|
||||
|
||||
render: function(data) {
|
||||
var self = this;
|
||||
var dpiData = data[0] || { applications: [], dpi_source: 'none' };
|
||||
var suggestionsData = data[1] || { suggestions: [] };
|
||||
var classesData = data[2] || { classes: [] };
|
||||
|
||||
this.applications = dpiData.applications || [];
|
||||
this.dpiSource = dpiData.dpi_source || 'none';
|
||||
this.suggestions = suggestionsData.suggestions || [];
|
||||
this.classes = classesData.classes || [];
|
||||
|
||||
document.body.setAttribute('data-secubox-app', 'bandwidth');
|
||||
|
||||
var view = E('div', { 'class': 'cbi-map' }, [
|
||||
E('h2', { 'class': 'cbi-map-title' }, 'Smart QoS'),
|
||||
E('div', { 'class': 'cbi-map-descr' },
|
||||
'AI-powered traffic classification using Deep Packet Inspection'),
|
||||
|
||||
// DPI Status
|
||||
this.renderDpiStatus(),
|
||||
|
||||
// Smart Suggestions
|
||||
this.renderSuggestions(),
|
||||
|
||||
// Detected Applications
|
||||
this.renderApplications()
|
||||
]);
|
||||
|
||||
poll.add(L.bind(this.pollData, this), 10);
|
||||
|
||||
return view;
|
||||
},
|
||||
|
||||
pollData: function() {
|
||||
var self = this;
|
||||
return Promise.all([
|
||||
callGetDpiApplications(),
|
||||
callGetSmartSuggestions()
|
||||
]).then(function(data) {
|
||||
self.applications = (data[0] && data[0].applications) || [];
|
||||
self.dpiSource = (data[0] && data[0].dpi_source) || 'none';
|
||||
self.suggestions = (data[1] && data[1].suggestions) || [];
|
||||
|
||||
var statusEl = document.getElementById('dpi-status-container');
|
||||
var suggestionsEl = document.getElementById('suggestions-container');
|
||||
var appsEl = document.getElementById('apps-container');
|
||||
|
||||
if (statusEl) {
|
||||
statusEl.innerHTML = '';
|
||||
statusEl.appendChild(self.renderDpiStatusContent());
|
||||
}
|
||||
if (suggestionsEl) {
|
||||
suggestionsEl.innerHTML = '';
|
||||
suggestionsEl.appendChild(self.renderSuggestionsContent());
|
||||
}
|
||||
if (appsEl) {
|
||||
appsEl.innerHTML = '';
|
||||
appsEl.appendChild(self.renderApplicationsContent());
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
renderDpiStatus: function() {
|
||||
return E('div', { 'class': 'cbi-section', 'id': 'dpi-status-container' }, [
|
||||
this.renderDpiStatusContent()
|
||||
]);
|
||||
},
|
||||
|
||||
renderDpiStatusContent: function() {
|
||||
var statusColor, statusText, statusIcon;
|
||||
switch (this.dpiSource) {
|
||||
case 'ndpid':
|
||||
statusColor = '#22c55e';
|
||||
statusText = 'nDPId Active';
|
||||
statusIcon = '\u2713';
|
||||
break;
|
||||
case 'netifyd':
|
||||
statusColor = '#3b82f6';
|
||||
statusText = 'Netifyd Active';
|
||||
statusIcon = '\u2713';
|
||||
break;
|
||||
default:
|
||||
statusColor = '#ef4444';
|
||||
statusText = 'No DPI Engine';
|
||||
statusIcon = '\u2717';
|
||||
}
|
||||
|
||||
return E('div', {
|
||||
'style': 'display: flex; align-items: center; gap: 1rem; padding: 1rem; background: var(--cyber-bg-secondary, #141419); border-radius: 8px; border-left: 4px solid ' + statusColor
|
||||
}, [
|
||||
E('div', {
|
||||
'style': 'width: 48px; height: 48px; background: ' + statusColor + '20; color: ' + statusColor + '; border-radius: 12px; display: flex; align-items: center; justify-content: center; font-size: 1.5rem;'
|
||||
}, statusIcon),
|
||||
E('div', {}, [
|
||||
E('div', { 'style': 'font-weight: 600; color: ' + statusColor }, statusText),
|
||||
E('div', { 'style': 'font-size: 0.875rem; color: var(--cyber-text-secondary, #a1a1aa);' },
|
||||
this.dpiSource !== 'none'
|
||||
? 'Deep Packet Inspection is analyzing your network traffic'
|
||||
: 'Install nDPId or netifyd to enable application detection')
|
||||
]),
|
||||
E('div', { 'style': 'margin-left: auto; font-size: 0.875rem;' }, [
|
||||
E('span', { 'style': 'color: var(--cyber-text-secondary);' }, 'Detected Apps: '),
|
||||
E('strong', { 'style': 'color: var(--cyber-text-primary);' }, this.applications.length.toString())
|
||||
])
|
||||
]);
|
||||
},
|
||||
|
||||
renderSuggestions: function() {
|
||||
return E('div', { 'class': 'cbi-section' }, [
|
||||
E('h3', { 'class': 'cbi-section-title' }, 'Smart Suggestions'),
|
||||
E('div', { 'id': 'suggestions-container' }, [
|
||||
this.renderSuggestionsContent()
|
||||
])
|
||||
]);
|
||||
},
|
||||
|
||||
renderSuggestionsContent: function() {
|
||||
var self = this;
|
||||
|
||||
if (this.suggestions.length === 0) {
|
||||
return E('div', {
|
||||
'style': 'padding: 2rem; text-align: center; color: var(--cyber-text-secondary, #a1a1aa); background: var(--cyber-bg-secondary, #141419); border-radius: 8px;'
|
||||
}, [
|
||||
E('div', { 'style': 'font-size: 2rem; margin-bottom: 0.5rem;' }, '\ud83d\udd0d'),
|
||||
'Analyzing traffic patterns...',
|
||||
E('br'),
|
||||
this.dpiSource === 'none'
|
||||
? 'Enable a DPI engine to get smart suggestions'
|
||||
: 'No optimization suggestions at this time'
|
||||
]);
|
||||
}
|
||||
|
||||
var typeIcons = {
|
||||
gaming: '\ud83c\udfae',
|
||||
streaming: '\ud83c\udfa5',
|
||||
videoconf: '\ud83d\udcf9',
|
||||
downloads: '\u2b07\ufe0f'
|
||||
};
|
||||
|
||||
var typeColors = {
|
||||
gaming: '#8b5cf6',
|
||||
streaming: '#ec4899',
|
||||
videoconf: '#3b82f6',
|
||||
downloads: '#f59e0b'
|
||||
};
|
||||
|
||||
return E('div', { 'style': 'display: grid; gap: 1rem;' },
|
||||
this.suggestions.map(function(suggestion) {
|
||||
var icon = typeIcons[suggestion.type] || '\ud83d\udca1';
|
||||
var color = typeColors[suggestion.type] || '#667eea';
|
||||
|
||||
return E('div', {
|
||||
'style': 'display: flex; align-items: flex-start; gap: 1rem; padding: 1rem; background: var(--cyber-bg-secondary, #141419); border-radius: 8px; border: 1px solid var(--cyber-border-subtle, rgba(255,255,255,0.08));'
|
||||
}, [
|
||||
E('div', {
|
||||
'style': 'width: 44px; height: 44px; background: ' + color + '20; color: ' + color + '; border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 1.25rem; flex-shrink: 0;'
|
||||
}, icon),
|
||||
E('div', { 'style': 'flex: 1;' }, [
|
||||
E('div', { 'style': 'font-weight: 600; margin-bottom: 0.25rem;' }, suggestion.title),
|
||||
E('div', { 'style': 'font-size: 0.875rem; color: var(--cyber-text-secondary, #a1a1aa); margin-bottom: 0.5rem;' }, suggestion.description),
|
||||
E('div', { 'style': 'display: flex; gap: 0.5rem; font-size: 0.75rem;' }, [
|
||||
E('span', {
|
||||
'style': 'padding: 0.25rem 0.5rem; background: var(--cyber-bg-tertiary, rgba(255,255,255,0.05)); border-radius: 4px;'
|
||||
}, 'Priority: ' + suggestion.priority),
|
||||
E('span', {
|
||||
'style': 'padding: 0.25rem 0.5rem; background: var(--cyber-bg-tertiary, rgba(255,255,255,0.05)); border-radius: 4px;'
|
||||
}, suggestion.affected_devices + ' device(s)')
|
||||
])
|
||||
]),
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-action',
|
||||
'style': 'flex-shrink: 0;',
|
||||
'click': function() { self.applySuggestion(suggestion); }
|
||||
}, 'Apply')
|
||||
]);
|
||||
})
|
||||
);
|
||||
},
|
||||
|
||||
renderApplications: function() {
|
||||
return E('div', { 'class': 'cbi-section' }, [
|
||||
E('h3', { 'class': 'cbi-section-title' }, 'Detected Applications'),
|
||||
E('div', { 'id': 'apps-container' }, [
|
||||
this.renderApplicationsContent()
|
||||
])
|
||||
]);
|
||||
},
|
||||
|
||||
renderApplicationsContent: function() {
|
||||
var self = this;
|
||||
|
||||
if (this.applications.length === 0) {
|
||||
return E('div', {
|
||||
'style': 'padding: 2rem; text-align: center; color: var(--cyber-text-secondary, #a1a1aa); background: var(--cyber-bg-secondary, #141419); border-radius: 8px;'
|
||||
}, 'No applications detected');
|
||||
}
|
||||
|
||||
// Sort by bytes
|
||||
var sortedApps = this.applications.slice().sort(function(a, b) {
|
||||
return (b.total_bytes || 0) - (a.total_bytes || 0);
|
||||
});
|
||||
|
||||
return E('div', { 'style': 'overflow-x: auto;' }, [
|
||||
E('table', { 'class': 'table cbi-section-table', 'style': 'width: 100%;' }, [
|
||||
E('thead', {}, [
|
||||
E('tr', { 'class': 'tr cbi-section-table-titles' }, [
|
||||
E('th', { 'class': 'th' }, 'Application'),
|
||||
E('th', { 'class': 'th' }, 'Flows'),
|
||||
E('th', { 'class': 'th' }, 'Traffic'),
|
||||
E('th', { 'class': 'th' }, 'Actions')
|
||||
])
|
||||
]),
|
||||
E('tbody', {},
|
||||
sortedApps.slice(0, 20).map(function(app) {
|
||||
return E('tr', { 'class': 'tr' }, [
|
||||
E('td', { 'class': 'td', 'style': 'font-weight: 500;' }, app.name || 'Unknown'),
|
||||
E('td', { 'class': 'td' }, (app.flow_count || 0).toString()),
|
||||
E('td', { 'class': 'td' }, self.formatBytes(app.total_bytes || 0)),
|
||||
E('td', { 'class': 'td' }, [
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-action',
|
||||
'style': 'font-size: 0.75rem; padding: 0.25rem 0.5rem;',
|
||||
'click': function() { self.showRuleDialog(app); }
|
||||
}, 'Create Rule')
|
||||
])
|
||||
]);
|
||||
})
|
||||
)
|
||||
])
|
||||
]);
|
||||
},
|
||||
|
||||
applySuggestion: function(suggestion) {
|
||||
var self = this;
|
||||
|
||||
ui.showModal('Apply Suggestion', [
|
||||
E('p', {}, suggestion.description),
|
||||
E('p', {}, 'This will create a QoS rule with priority ' + suggestion.priority + '.'),
|
||||
E('div', { 'class': 'right' }, [
|
||||
E('button', {
|
||||
'class': 'cbi-button',
|
||||
'click': ui.hideModal
|
||||
}, 'Cancel'),
|
||||
' ',
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-positive',
|
||||
'click': function() {
|
||||
ui.hideModal();
|
||||
// Apply rule based on suggestion type
|
||||
var appName = '';
|
||||
switch (suggestion.type) {
|
||||
case 'gaming':
|
||||
appName = 'Gaming';
|
||||
break;
|
||||
case 'streaming':
|
||||
appName = 'Streaming';
|
||||
break;
|
||||
case 'videoconf':
|
||||
appName = 'Video Conferencing';
|
||||
break;
|
||||
case 'downloads':
|
||||
appName = 'Downloads';
|
||||
break;
|
||||
}
|
||||
callApplyDpiRule(appName, suggestion.priority, 0, 0).then(function(res) {
|
||||
if (res.success) {
|
||||
ui.addNotification(null, E('p', {}, res.message), 'success');
|
||||
} else {
|
||||
ui.addNotification(null, E('p', {}, res.message || 'Failed to apply rule'), 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
}, 'Apply')
|
||||
])
|
||||
]);
|
||||
},
|
||||
|
||||
showRuleDialog: function(app) {
|
||||
var self = this;
|
||||
|
||||
var prioritySelect = E('select', { 'class': 'cbi-input-select', 'id': 'rule-priority' },
|
||||
this.classes.map(function(c) {
|
||||
return E('option', { 'value': c.priority }, c.priority + ' - ' + c.name);
|
||||
})
|
||||
);
|
||||
|
||||
ui.showModal('Create QoS Rule for ' + app.name, [
|
||||
E('div', { 'style': 'margin-bottom: 1rem;' }, [
|
||||
E('label', { 'style': 'display: block; margin-bottom: 0.5rem;' }, 'Priority Class'),
|
||||
prioritySelect
|
||||
]),
|
||||
E('div', { 'style': 'margin-bottom: 1rem;' }, [
|
||||
E('label', { 'style': 'display: block; margin-bottom: 0.5rem;' }, 'Download Limit (Kbps, 0 = unlimited)'),
|
||||
E('input', {
|
||||
'type': 'number',
|
||||
'class': 'cbi-input-text',
|
||||
'id': 'rule-limit-down',
|
||||
'value': '0',
|
||||
'min': '0'
|
||||
})
|
||||
]),
|
||||
E('div', { 'style': 'margin-bottom: 1rem;' }, [
|
||||
E('label', { 'style': 'display: block; margin-bottom: 0.5rem;' }, 'Upload Limit (Kbps, 0 = unlimited)'),
|
||||
E('input', {
|
||||
'type': 'number',
|
||||
'class': 'cbi-input-text',
|
||||
'id': 'rule-limit-up',
|
||||
'value': '0',
|
||||
'min': '0'
|
||||
})
|
||||
]),
|
||||
E('div', { 'class': 'right' }, [
|
||||
E('button', {
|
||||
'class': 'cbi-button',
|
||||
'click': ui.hideModal
|
||||
}, 'Cancel'),
|
||||
' ',
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-positive',
|
||||
'click': function() {
|
||||
var priority = parseInt(document.getElementById('rule-priority').value) || 5;
|
||||
var limitDown = parseInt(document.getElementById('rule-limit-down').value) || 0;
|
||||
var limitUp = parseInt(document.getElementById('rule-limit-up').value) || 0;
|
||||
|
||||
ui.hideModal();
|
||||
callApplyDpiRule(app.name, priority, limitDown, limitUp).then(function(res) {
|
||||
if (res.success) {
|
||||
ui.addNotification(null, E('p', {}, 'Rule created for ' + app.name), 'success');
|
||||
} else {
|
||||
ui.addNotification(null, E('p', {}, res.message || 'Failed to create rule'), 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
}, 'Create Rule')
|
||||
])
|
||||
]);
|
||||
},
|
||||
|
||||
formatBytes: function(bytes) {
|
||||
if (!bytes || bytes === 0) return '0 B';
|
||||
var units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
var i = 0;
|
||||
while (bytes >= 1024 && i < units.length - 1) {
|
||||
bytes /= 1024;
|
||||
i++;
|
||||
}
|
||||
return bytes.toFixed(1) + ' ' + units[i];
|
||||
},
|
||||
|
||||
handleSaveApply: null,
|
||||
handleSave: null,
|
||||
handleReset: null
|
||||
});
|
||||
@ -579,6 +579,615 @@ get_media() {
|
||||
json_dump
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# Smart QoS - DPI Integration with nDPId
|
||||
# ============================================
|
||||
|
||||
NDPID_FLOWS="/tmp/ndpid-flows.json"
|
||||
DPI_RULES_FILE="/tmp/bandwidth_dpi_rules.json"
|
||||
|
||||
# Get DPI detected applications from nDPId
|
||||
get_dpi_applications() {
|
||||
json_init
|
||||
json_add_array "applications"
|
||||
|
||||
if [ -f "$NDPID_FLOWS" ] && pgrep -x ndpid >/dev/null 2>&1; then
|
||||
# Extract unique applications from nDPId flows
|
||||
jq -r '.flows[]? | .app // "Unknown"' "$NDPID_FLOWS" 2>/dev/null | sort -u | while read app; do
|
||||
[ -z "$app" ] && continue
|
||||
local count=$(jq -r ".flows[] | select(.app == \"$app\") | .src_ip" "$NDPID_FLOWS" 2>/dev/null | wc -l)
|
||||
local bytes=$(jq -r ".flows[] | select(.app == \"$app\") | .bytes_rx + .bytes_tx" "$NDPID_FLOWS" 2>/dev/null | awk '{sum+=$1} END {print sum}')
|
||||
|
||||
json_add_object ""
|
||||
json_add_string "name" "$app"
|
||||
json_add_int "flow_count" "$count"
|
||||
json_add_int "total_bytes" "${bytes:-0}"
|
||||
json_close_object
|
||||
done
|
||||
fi
|
||||
|
||||
json_close_array
|
||||
|
||||
# Check DPI source status
|
||||
local dpi_source="none"
|
||||
if [ -f "$NDPID_FLOWS" ] && pgrep -x ndpid >/dev/null 2>&1; then
|
||||
dpi_source="ndpid"
|
||||
elif [ -f "/var/run/netifyd/status.json" ] && pgrep -x netifyd >/dev/null 2>&1; then
|
||||
dpi_source="netifyd"
|
||||
fi
|
||||
json_add_string "dpi_source" "$dpi_source"
|
||||
|
||||
json_dump
|
||||
}
|
||||
|
||||
# Get smart QoS suggestions based on detected traffic
|
||||
get_smart_suggestions() {
|
||||
json_init
|
||||
json_add_array "suggestions"
|
||||
|
||||
if [ -f "$NDPID_FLOWS" ]; then
|
||||
# Check for gaming traffic
|
||||
local gaming_count=$(jq -r '.flows[]? | select(.category == "Game" or .app | test("Steam|Xbox|PlayStation|Nintendo|Epic|Riot"; "i")) | .src_ip' "$NDPID_FLOWS" 2>/dev/null | sort -u | wc -l)
|
||||
if [ "$gaming_count" -gt 0 ]; then
|
||||
json_add_object ""
|
||||
json_add_string "type" "gaming"
|
||||
json_add_string "title" "Gaming Traffic Detected"
|
||||
json_add_string "description" "Detected $gaming_count devices with gaming traffic. Recommend priority class 1-2 for low latency."
|
||||
json_add_int "priority" 2
|
||||
json_add_int "affected_devices" "$gaming_count"
|
||||
json_close_object
|
||||
fi
|
||||
|
||||
# Check for streaming traffic
|
||||
local streaming_count=$(jq -r '.flows[]? | select(.app | test("YouTube|Netflix|Twitch|Disney|HBO|Amazon.*Video|Spotify"; "i")) | .src_ip' "$NDPID_FLOWS" 2>/dev/null | sort -u | wc -l)
|
||||
if [ "$streaming_count" -gt 0 ]; then
|
||||
json_add_object ""
|
||||
json_add_string "type" "streaming"
|
||||
json_add_string "title" "Streaming Traffic Detected"
|
||||
json_add_string "description" "Detected $streaming_count devices streaming video/audio. Recommend priority class 4 for consistent quality."
|
||||
json_add_int "priority" 4
|
||||
json_add_int "affected_devices" "$streaming_count"
|
||||
json_close_object
|
||||
fi
|
||||
|
||||
# Check for video conferencing
|
||||
local video_conf_count=$(jq -r '.flows[]? | select(.app | test("Zoom|Teams|Meet|WebEx|Skype"; "i")) | .src_ip' "$NDPID_FLOWS" 2>/dev/null | sort -u | wc -l)
|
||||
if [ "$video_conf_count" -gt 0 ]; then
|
||||
json_add_object ""
|
||||
json_add_string "type" "videoconf"
|
||||
json_add_string "title" "Video Conferencing Detected"
|
||||
json_add_string "description" "Detected $video_conf_count devices in video calls. Recommend priority class 1 for real-time communication."
|
||||
json_add_int "priority" 1
|
||||
json_add_int "affected_devices" "$video_conf_count"
|
||||
json_close_object
|
||||
fi
|
||||
|
||||
# Check for heavy downloaders
|
||||
local download_bytes=$(jq -r '.flows[]? | select(.bytes_rx > 100000000) | "\(.src_ip) \(.bytes_rx)"' "$NDPID_FLOWS" 2>/dev/null | sort -t' ' -k2 -nr | head -3)
|
||||
if [ -n "$download_bytes" ]; then
|
||||
local heavy_users=$(echo "$download_bytes" | wc -l)
|
||||
json_add_object ""
|
||||
json_add_string "type" "downloads"
|
||||
json_add_string "title" "Heavy Bandwidth Users"
|
||||
json_add_string "description" "Detected $heavy_users devices downloading heavily. Consider priority class 6-7 or quotas."
|
||||
json_add_int "priority" 7
|
||||
json_add_int "affected_devices" "$heavy_users"
|
||||
json_close_object
|
||||
fi
|
||||
fi
|
||||
|
||||
json_close_array
|
||||
json_dump
|
||||
}
|
||||
|
||||
# Apply smart QoS rule based on app detection
|
||||
apply_dpi_rule() {
|
||||
read -r input
|
||||
json_load "$input"
|
||||
|
||||
local app_name priority limit_down limit_up
|
||||
json_get_var app_name app_name
|
||||
json_get_var priority priority "5"
|
||||
json_get_var limit_down limit_down "0"
|
||||
json_get_var limit_up limit_up "0"
|
||||
json_cleanup
|
||||
|
||||
if [ -z "$app_name" ]; then
|
||||
json_init
|
||||
json_add_boolean "success" 0
|
||||
json_add_string "message" "Application name is required"
|
||||
json_dump
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Create DPI-based QoS rule
|
||||
local rule_id="dpi_rule_$(echo "$app_name" | tr ' ' '_' | tr '[:upper:]' '[:lower:]')_$(date +%s)"
|
||||
|
||||
uci -q batch << EOF
|
||||
set bandwidth.$rule_id=rule
|
||||
set bandwidth.$rule_id.name='DPI: $app_name'
|
||||
set bandwidth.$rule_id.type='dpi_app'
|
||||
set bandwidth.$rule_id.target='$app_name'
|
||||
set bandwidth.$rule_id.limit_down='$limit_down'
|
||||
set bandwidth.$rule_id.limit_up='$limit_up'
|
||||
set bandwidth.$rule_id.priority='$priority'
|
||||
set bandwidth.$rule_id.enabled='1'
|
||||
set bandwidth.$rule_id.dpi_match='1'
|
||||
commit bandwidth
|
||||
EOF
|
||||
|
||||
json_init
|
||||
json_add_boolean "success" 1
|
||||
json_add_string "rule_id" "$rule_id"
|
||||
json_add_string "message" "DPI rule created for $app_name"
|
||||
json_dump
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# Device Groups Management
|
||||
# ============================================
|
||||
|
||||
# List all device groups
|
||||
list_groups() {
|
||||
config_load bandwidth
|
||||
json_init
|
||||
json_add_array "groups"
|
||||
|
||||
_add_group() {
|
||||
local name description quota_mb priority members enabled
|
||||
config_get name "$1" name ""
|
||||
config_get description "$1" description ""
|
||||
config_get quota_mb "$1" quota_mb "0"
|
||||
config_get priority "$1" priority "5"
|
||||
config_get members "$1" members ""
|
||||
config_get enabled "$1" enabled "1"
|
||||
|
||||
# Calculate group usage
|
||||
local total_used=0
|
||||
local member_count=0
|
||||
for mac in $members; do
|
||||
member_count=$(( member_count + 1 ))
|
||||
local used=$(get_mac_usage "$mac")
|
||||
total_used=$(( total_used + used ))
|
||||
done
|
||||
|
||||
json_add_object ""
|
||||
json_add_string "id" "$1"
|
||||
json_add_string "name" "$name"
|
||||
json_add_string "description" "$description"
|
||||
json_add_int "quota_mb" "$quota_mb"
|
||||
json_add_int "priority" "$priority"
|
||||
json_add_int "member_count" "$member_count"
|
||||
json_add_int "used_mb" "$total_used"
|
||||
json_add_boolean "enabled" "$enabled"
|
||||
json_close_object
|
||||
}
|
||||
|
||||
config_foreach _add_group group
|
||||
|
||||
json_close_array
|
||||
json_dump
|
||||
}
|
||||
|
||||
# Get group details with members
|
||||
get_group() {
|
||||
read -r input
|
||||
json_load "$input"
|
||||
|
||||
local group_id
|
||||
json_get_var group_id group_id
|
||||
json_cleanup
|
||||
|
||||
if [ -z "$group_id" ]; then
|
||||
json_init
|
||||
json_add_boolean "success" 0
|
||||
json_add_string "message" "Group ID is required"
|
||||
json_dump
|
||||
return 1
|
||||
fi
|
||||
|
||||
config_load bandwidth
|
||||
|
||||
local name description quota_mb priority members enabled
|
||||
config_get name "$group_id" name ""
|
||||
config_get description "$group_id" description ""
|
||||
config_get quota_mb "$group_id" quota_mb "0"
|
||||
config_get priority "$group_id" priority "5"
|
||||
config_get members "$group_id" members ""
|
||||
config_get enabled "$group_id" enabled "1"
|
||||
|
||||
if [ -z "$name" ]; then
|
||||
json_init
|
||||
json_add_boolean "success" 0
|
||||
json_add_string "message" "Group not found"
|
||||
json_dump
|
||||
return 1
|
||||
fi
|
||||
|
||||
json_init
|
||||
json_add_boolean "success" 1
|
||||
json_add_string "id" "$group_id"
|
||||
json_add_string "name" "$name"
|
||||
json_add_string "description" "$description"
|
||||
json_add_int "quota_mb" "$quota_mb"
|
||||
json_add_int "priority" "$priority"
|
||||
json_add_boolean "enabled" "$enabled"
|
||||
|
||||
# Add members with details
|
||||
json_add_array "members"
|
||||
for mac in $members; do
|
||||
local hostname ip used_mb
|
||||
hostname=$(grep -i "$mac" /tmp/dhcp.leases 2>/dev/null | awk '{print $4}')
|
||||
ip=$(grep -i "$mac" /tmp/dhcp.leases 2>/dev/null | awk '{print $3}')
|
||||
used_mb=$(get_mac_usage "$mac")
|
||||
|
||||
json_add_object ""
|
||||
json_add_string "mac" "$mac"
|
||||
json_add_string "hostname" "${hostname:-unknown}"
|
||||
json_add_string "ip" "${ip:-unknown}"
|
||||
json_add_int "used_mb" "$used_mb"
|
||||
json_close_object
|
||||
done
|
||||
json_close_array
|
||||
|
||||
json_dump
|
||||
}
|
||||
|
||||
# Create device group
|
||||
create_group() {
|
||||
read -r input
|
||||
json_load "$input"
|
||||
|
||||
local name description quota_mb priority members
|
||||
json_get_var name name
|
||||
json_get_var description description ""
|
||||
json_get_var quota_mb quota_mb "0"
|
||||
json_get_var priority priority "5"
|
||||
json_get_var members members ""
|
||||
json_cleanup
|
||||
|
||||
if [ -z "$name" ]; then
|
||||
json_init
|
||||
json_add_boolean "success" 0
|
||||
json_add_string "message" "Group name is required"
|
||||
json_dump
|
||||
return 1
|
||||
fi
|
||||
|
||||
local group_id="group_$(date +%s)"
|
||||
|
||||
uci -q batch << EOF
|
||||
set bandwidth.$group_id=group
|
||||
set bandwidth.$group_id.name='$name'
|
||||
set bandwidth.$group_id.description='$description'
|
||||
set bandwidth.$group_id.quota_mb='$quota_mb'
|
||||
set bandwidth.$group_id.priority='$priority'
|
||||
set bandwidth.$group_id.members='$members'
|
||||
set bandwidth.$group_id.enabled='1'
|
||||
commit bandwidth
|
||||
EOF
|
||||
|
||||
json_init
|
||||
json_add_boolean "success" 1
|
||||
json_add_string "group_id" "$group_id"
|
||||
json_add_string "message" "Group created successfully"
|
||||
json_dump
|
||||
}
|
||||
|
||||
# Update device group
|
||||
update_group() {
|
||||
read -r input
|
||||
json_load "$input"
|
||||
|
||||
local group_id name description quota_mb priority members
|
||||
json_get_var group_id group_id
|
||||
json_get_var name name
|
||||
json_get_var description description ""
|
||||
json_get_var quota_mb quota_mb "0"
|
||||
json_get_var priority priority "5"
|
||||
json_get_var members members ""
|
||||
json_cleanup
|
||||
|
||||
if [ -z "$group_id" ]; then
|
||||
json_init
|
||||
json_add_boolean "success" 0
|
||||
json_add_string "message" "Group ID is required"
|
||||
json_dump
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Check if group exists
|
||||
config_load bandwidth
|
||||
local existing_name
|
||||
config_get existing_name "$group_id" name ""
|
||||
if [ -z "$existing_name" ]; then
|
||||
json_init
|
||||
json_add_boolean "success" 0
|
||||
json_add_string "message" "Group not found"
|
||||
json_dump
|
||||
return 1
|
||||
fi
|
||||
|
||||
uci -q batch << EOF
|
||||
set bandwidth.$group_id.name='$name'
|
||||
set bandwidth.$group_id.description='$description'
|
||||
set bandwidth.$group_id.quota_mb='$quota_mb'
|
||||
set bandwidth.$group_id.priority='$priority'
|
||||
set bandwidth.$group_id.members='$members'
|
||||
commit bandwidth
|
||||
EOF
|
||||
|
||||
json_init
|
||||
json_add_boolean "success" 1
|
||||
json_add_string "message" "Group updated successfully"
|
||||
json_dump
|
||||
}
|
||||
|
||||
# Delete device group
|
||||
delete_group() {
|
||||
read -r input
|
||||
json_load "$input"
|
||||
|
||||
local group_id
|
||||
json_get_var group_id group_id
|
||||
json_cleanup
|
||||
|
||||
if [ -z "$group_id" ]; then
|
||||
json_init
|
||||
json_add_boolean "success" 0
|
||||
json_add_string "message" "Group ID is required"
|
||||
json_dump
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Check if group exists
|
||||
if ! uci -q get bandwidth.$group_id >/dev/null 2>&1; then
|
||||
json_init
|
||||
json_add_boolean "success" 0
|
||||
json_add_string "message" "Group not found"
|
||||
json_dump
|
||||
return 1
|
||||
fi
|
||||
|
||||
uci -q delete bandwidth.$group_id
|
||||
uci -q commit bandwidth
|
||||
|
||||
json_init
|
||||
json_add_boolean "success" 1
|
||||
json_add_string "message" "Group deleted successfully"
|
||||
json_dump
|
||||
}
|
||||
|
||||
# Add device to group
|
||||
add_to_group() {
|
||||
read -r input
|
||||
json_load "$input"
|
||||
|
||||
local group_id mac
|
||||
json_get_var group_id group_id
|
||||
json_get_var mac mac
|
||||
json_cleanup
|
||||
|
||||
if [ -z "$group_id" ] || [ -z "$mac" ]; then
|
||||
json_init
|
||||
json_add_boolean "success" 0
|
||||
json_add_string "message" "Group ID and MAC address are required"
|
||||
json_dump
|
||||
return 1
|
||||
fi
|
||||
|
||||
config_load bandwidth
|
||||
local members
|
||||
config_get members "$group_id" members ""
|
||||
|
||||
# Check if already in group
|
||||
if echo "$members" | grep -qi "$mac"; then
|
||||
json_init
|
||||
json_add_boolean "success" 0
|
||||
json_add_string "message" "Device already in group"
|
||||
json_dump
|
||||
return 1
|
||||
fi
|
||||
|
||||
members="$members $mac"
|
||||
uci -q set bandwidth.$group_id.members="$members"
|
||||
uci -q commit bandwidth
|
||||
|
||||
json_init
|
||||
json_add_boolean "success" 1
|
||||
json_add_string "message" "Device added to group"
|
||||
json_dump
|
||||
}
|
||||
|
||||
# Remove device from group
|
||||
remove_from_group() {
|
||||
read -r input
|
||||
json_load "$input"
|
||||
|
||||
local group_id mac
|
||||
json_get_var group_id group_id
|
||||
json_get_var mac mac
|
||||
json_cleanup
|
||||
|
||||
if [ -z "$group_id" ] || [ -z "$mac" ]; then
|
||||
json_init
|
||||
json_add_boolean "success" 0
|
||||
json_add_string "message" "Group ID and MAC address are required"
|
||||
json_dump
|
||||
return 1
|
||||
fi
|
||||
|
||||
config_load bandwidth
|
||||
local members
|
||||
config_get members "$group_id" members ""
|
||||
|
||||
# Remove MAC from members list
|
||||
members=$(echo "$members" | sed "s/$mac//gi" | tr -s ' ')
|
||||
uci -q set bandwidth.$group_id.members="$members"
|
||||
uci -q commit bandwidth
|
||||
|
||||
json_init
|
||||
json_add_boolean "success" 1
|
||||
json_add_string "message" "Device removed from group"
|
||||
json_dump
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# Analytics
|
||||
# ============================================
|
||||
|
||||
ANALYTICS_DB="/tmp/bandwidth_analytics.db"
|
||||
|
||||
# Initialize analytics database
|
||||
init_analytics_db() {
|
||||
if [ ! -f "$ANALYTICS_DB" ]; then
|
||||
cat > "$ANALYTICS_DB" << 'EOF'
|
||||
# Hour|Date|Total_RX|Total_TX|Peak_RX|Peak_TX|Client_Count
|
||||
EOF
|
||||
fi
|
||||
}
|
||||
|
||||
# Get analytics summary
|
||||
get_analytics_summary() {
|
||||
read -r input
|
||||
json_load "$input"
|
||||
|
||||
local period
|
||||
json_get_var period period "24h"
|
||||
json_cleanup
|
||||
|
||||
json_init
|
||||
|
||||
# Current totals
|
||||
local interface
|
||||
config_load bandwidth
|
||||
config_get interface global interface "br-lan"
|
||||
|
||||
if [ -d "/sys/class/net/$interface" ]; then
|
||||
local rx_bytes=$(cat /sys/class/net/$interface/statistics/rx_bytes 2>/dev/null || echo 0)
|
||||
local tx_bytes=$(cat /sys/class/net/$interface/statistics/tx_bytes 2>/dev/null || echo 0)
|
||||
json_add_int "total_rx_bytes" "$rx_bytes"
|
||||
json_add_int "total_tx_bytes" "$tx_bytes"
|
||||
fi
|
||||
|
||||
# Client count
|
||||
local client_count=$(cat /tmp/dhcp.leases 2>/dev/null | wc -l)
|
||||
json_add_int "active_clients" "$client_count"
|
||||
|
||||
# Top talkers
|
||||
json_add_array "top_talkers"
|
||||
if [ -f /tmp/dhcp.leases ]; then
|
||||
while read -r expires mac ip hostname clientid; do
|
||||
local used_mb=$(get_mac_usage "$mac")
|
||||
[ "$used_mb" -gt 0 ] && echo "$used_mb|$mac|$ip|${hostname:-unknown}"
|
||||
done < /tmp/dhcp.leases | sort -t'|' -k1 -nr | head -5 | while IFS='|' read used mac ip hostname; do
|
||||
json_add_object ""
|
||||
json_add_string "mac" "$mac"
|
||||
json_add_string "ip" "$ip"
|
||||
json_add_string "hostname" "$hostname"
|
||||
json_add_int "used_mb" "$used"
|
||||
json_close_object
|
||||
done
|
||||
fi
|
||||
json_close_array
|
||||
|
||||
# Application breakdown (from nDPId)
|
||||
json_add_array "app_breakdown"
|
||||
if [ -f "$NDPID_FLOWS" ]; then
|
||||
jq -r '[.flows[]? | {app: (.app // "Unknown"), bytes: (.bytes_rx + .bytes_tx)}] | group_by(.app) | map({app: .[0].app, total_bytes: (map(.bytes) | add)}) | sort_by(.total_bytes) | reverse | .[:10][] | "\(.app)|\(.total_bytes)"' "$NDPID_FLOWS" 2>/dev/null | while IFS='|' read app bytes; do
|
||||
json_add_object ""
|
||||
json_add_string "app" "$app"
|
||||
json_add_int "bytes" "${bytes:-0}"
|
||||
json_close_object
|
||||
done
|
||||
fi
|
||||
json_close_array
|
||||
|
||||
# Protocol breakdown (from nDPId)
|
||||
json_add_array "protocol_breakdown"
|
||||
if [ -f "$NDPID_FLOWS" ]; then
|
||||
jq -r '[.flows[]? | {protocol: (.l4_proto // "Unknown"), bytes: (.bytes_rx + .bytes_tx)}] | group_by(.protocol) | map({protocol: .[0].protocol, total_bytes: (map(.bytes) | add)}) | sort_by(.total_bytes) | reverse | .[:5][] | "\(.protocol)|\(.total_bytes)"' "$NDPID_FLOWS" 2>/dev/null | while IFS='|' read proto bytes; do
|
||||
json_add_object ""
|
||||
json_add_string "protocol" "$proto"
|
||||
json_add_int "bytes" "${bytes:-0}"
|
||||
json_close_object
|
||||
done
|
||||
fi
|
||||
json_close_array
|
||||
|
||||
json_dump
|
||||
}
|
||||
|
||||
# Get hourly usage data for charts
|
||||
get_hourly_data() {
|
||||
read -r input
|
||||
json_load "$input"
|
||||
|
||||
local days
|
||||
json_get_var days days "7"
|
||||
json_cleanup
|
||||
|
||||
init_analytics_db
|
||||
|
||||
json_init
|
||||
json_add_array "hourly_data"
|
||||
|
||||
# Read from analytics DB
|
||||
local threshold_date=$(date -d "$days days ago" +%Y%m%d 2>/dev/null || date +%Y%m%d)
|
||||
if [ -f "$ANALYTICS_DB" ]; then
|
||||
while IFS='|' read hour date rx tx peak_rx peak_tx clients; do
|
||||
[ "$hour" = "# Hour" ] && continue
|
||||
[ "$date" -lt "$threshold_date" ] 2>/dev/null && continue
|
||||
|
||||
json_add_object ""
|
||||
json_add_int "hour" "$hour"
|
||||
json_add_string "date" "$date"
|
||||
json_add_int "rx_bytes" "$rx"
|
||||
json_add_int "tx_bytes" "$tx"
|
||||
json_add_int "peak_rx" "$peak_rx"
|
||||
json_add_int "peak_tx" "$peak_tx"
|
||||
json_add_int "clients" "$clients"
|
||||
json_close_object
|
||||
done < "$ANALYTICS_DB"
|
||||
fi
|
||||
|
||||
json_close_array
|
||||
json_dump
|
||||
}
|
||||
|
||||
# Record current stats (called by cron)
|
||||
record_stats() {
|
||||
init_analytics_db
|
||||
|
||||
local interface
|
||||
config_load bandwidth
|
||||
config_get interface global interface "br-lan"
|
||||
|
||||
local hour=$(date +%H)
|
||||
local date=$(date +%Y%m%d)
|
||||
local rx_bytes=0 tx_bytes=0
|
||||
|
||||
if [ -d "/sys/class/net/$interface" ]; then
|
||||
rx_bytes=$(cat /sys/class/net/$interface/statistics/rx_bytes 2>/dev/null || echo 0)
|
||||
tx_bytes=$(cat /sys/class/net/$interface/statistics/tx_bytes 2>/dev/null || echo 0)
|
||||
fi
|
||||
|
||||
local client_count=$(cat /tmp/dhcp.leases 2>/dev/null | wc -l)
|
||||
|
||||
echo "$hour|$date|$rx_bytes|$tx_bytes|$rx_bytes|$tx_bytes|$client_count" >> "$ANALYTICS_DB"
|
||||
|
||||
# Keep only last 30 days
|
||||
local old_date=$(date -d "30 days ago" +%Y%m%d 2>/dev/null || echo "0")
|
||||
if [ -f "$ANALYTICS_DB" ]; then
|
||||
awk -F'|' -v old="$old_date" '$2 >= old || /^#/' "$ANALYTICS_DB" > "${ANALYTICS_DB}.tmp"
|
||||
mv "${ANALYTICS_DB}.tmp" "$ANALYTICS_DB"
|
||||
fi
|
||||
|
||||
json_init
|
||||
json_add_boolean "success" 1
|
||||
json_dump
|
||||
}
|
||||
|
||||
# Get QoS priority classes
|
||||
get_classes() {
|
||||
json_init
|
||||
@ -676,7 +1285,20 @@ case "$1" in
|
||||
"get_usage_realtime": {},
|
||||
"get_usage_history": { "timeframe": "24h", "mac": "" },
|
||||
"get_media": {},
|
||||
"get_classes": {}
|
||||
"get_classes": {},
|
||||
"get_dpi_applications": {},
|
||||
"get_smart_suggestions": {},
|
||||
"apply_dpi_rule": { "app_name": "string", "priority": 5, "limit_down": 0, "limit_up": 0 },
|
||||
"list_groups": {},
|
||||
"get_group": { "group_id": "string" },
|
||||
"create_group": { "name": "string", "description": "", "quota_mb": 0, "priority": 5, "members": "" },
|
||||
"update_group": { "group_id": "string", "name": "string", "description": "", "quota_mb": 0, "priority": 5, "members": "" },
|
||||
"delete_group": { "group_id": "string" },
|
||||
"add_to_group": { "group_id": "string", "mac": "string" },
|
||||
"remove_from_group": { "group_id": "string", "mac": "string" },
|
||||
"get_analytics_summary": { "period": "24h" },
|
||||
"get_hourly_data": { "days": 7 },
|
||||
"record_stats": {}
|
||||
}
|
||||
EOF
|
||||
;;
|
||||
@ -694,6 +1316,19 @@ EOF
|
||||
get_usage_history) get_usage_history ;;
|
||||
get_media) get_media ;;
|
||||
get_classes) get_classes ;;
|
||||
get_dpi_applications) get_dpi_applications ;;
|
||||
get_smart_suggestions) get_smart_suggestions ;;
|
||||
apply_dpi_rule) apply_dpi_rule ;;
|
||||
list_groups) list_groups ;;
|
||||
get_group) get_group ;;
|
||||
create_group) create_group ;;
|
||||
update_group) update_group ;;
|
||||
delete_group) delete_group ;;
|
||||
add_to_group) add_to_group ;;
|
||||
remove_from_group) remove_from_group ;;
|
||||
get_analytics_summary) get_analytics_summary ;;
|
||||
get_hourly_data) get_hourly_data ;;
|
||||
record_stats) record_stats ;;
|
||||
*)
|
||||
json_init
|
||||
json_add_boolean "success" 0
|
||||
|
||||
@ -80,5 +80,29 @@
|
||||
"type": "view",
|
||||
"path": "bandwidth-manager/schedules"
|
||||
}
|
||||
},
|
||||
"admin/secubox/network/bandwidth-manager/smart-qos": {
|
||||
"title": "Smart QoS",
|
||||
"order": 10,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "bandwidth-manager/smart-qos"
|
||||
}
|
||||
},
|
||||
"admin/secubox/network/bandwidth-manager/groups": {
|
||||
"title": "Device Groups",
|
||||
"order": 11,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "bandwidth-manager/groups"
|
||||
}
|
||||
},
|
||||
"admin/secubox/network/bandwidth-manager/analytics": {
|
||||
"title": "Analytics",
|
||||
"order": 12,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "bandwidth-manager/analytics"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user