' +
- _('No scheduled rules yet, click "Add Rule" to start setting') +
+ rulesList.innerHTML = '
' +
+ _('No scheduled rules yet, click "Add Rule" to start setting') +
'
';
}
-
+
// 加载定时限速规则列表
loadScheduleRules();
@@ -2239,10 +3497,10 @@ return view.extend({
var computedStyle = window.getComputedStyle(targetElement);
var bgColor = computedStyle.backgroundColor;
var textColor = computedStyle.color;
-
+
// 获取模态框元素
var modalElement = modal.querySelector('.modal');
-
+
// 确保背景色不透明
if (bgColor && bgColor !== 'rgba(0, 0, 0, 0)' && bgColor !== 'transparent') {
var rgbaMatch = bgColor.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
@@ -2251,7 +3509,7 @@ return view.extend({
var g = parseInt(rgbaMatch[2]);
var b = parseInt(rgbaMatch[3]);
var alpha = rgbaMatch[4] ? parseFloat(rgbaMatch[4]) : 1;
-
+
if (alpha < 0.95) {
modalElement.style.backgroundColor = 'rgb(' + r + ', ' + g + ', ' + b + ')';
} else {
@@ -2291,7 +3549,7 @@ return view.extend({
// 不设置背景色,让 CSS 媒体查询处理
}
}
-
+
// 应用文字颜色
if (textColor && textColor !== 'rgba(0, 0, 0, 0)') {
modalElement.style.color = textColor;
@@ -2303,11 +3561,11 @@ return view.extend({
}
}
}
- } catch(e) {
+ } catch (e) {
// 如果出错,CSS 会通过媒体查询自动处理暗色模式
// 不设置样式,让 CSS 处理
}
-
+
// 显示模态框并添加动画
modal.classList.add('show');
}
@@ -2326,26 +3584,26 @@ return view.extend({
// 加载定时限速规则列表
function loadScheduleRules() {
if (!currentDevice) return;
-
+
var rulesList = document.getElementById('schedule-rules-list');
if (!rulesList) return;
-
+
rulesList.innerHTML = '
';
-
- callGetScheduleLimits().then(function(res) {
+
+ callGetScheduleLimits().then(function (res) {
// 检查响应格式
if (!res) {
rulesList.innerHTML = '
';
return;
}
-
+
// 检查是否有错误
if (res.success === false || res.error) {
var errorMsg = res.error || _('Failed to load schedule rules');
rulesList.innerHTML = '
';
return;
}
-
+
// 检查数据格式
var limits = [];
if (res.data && res.data.limits && Array.isArray(res.data.limits)) {
@@ -2357,26 +3615,26 @@ return view.extend({
// 如果直接返回数组
limits = res;
}
-
+
var speedUnit = uci.get('bandix', 'traffic', 'speed_unit') || 'bytes';
-
+
// 过滤出当前设备的规则
- var deviceRules = limits.filter(function(rule) {
+ var deviceRules = limits.filter(function (rule) {
return rule && rule.mac === currentDevice.mac;
});
-
+
// 清空列表
rulesList.innerHTML = '';
-
+
if (deviceRules.length === 0) {
- rulesList.innerHTML = '
' +
- _('No scheduled rules yet, click "Add Rule" to start setting') +
+ rulesList.innerHTML = '
' +
+ _('No scheduled rules yet, click "Add Rule" to start setting') +
'
';
return;
}
-
+
// 显示所有规则(支持多个规则)
- deviceRules.forEach(function(rule) {
+ deviceRules.forEach(function (rule) {
var daysText = '';
// days 范围是 1-7 (Monday-Sunday)
var dayNames = {
@@ -2389,53 +3647,53 @@ return view.extend({
7: _('Sun')
};
if (rule.time_slot && rule.time_slot.days && Array.isArray(rule.time_slot.days)) {
- daysText = rule.time_slot.days.map(function(d) { return dayNames[d] || d; }).join(', ');
+ daysText = rule.time_slot.days.map(function (d) { return dayNames[d] || d; }).join(', ');
}
-
+
var startTime = rule.time_slot && rule.time_slot.start ? rule.time_slot.start : '';
var endTime = rule.time_slot && rule.time_slot.end ? rule.time_slot.end : '';
var uploadLimit = rule.wide_tx_rate_limit || 0;
var downloadLimit = rule.wide_rx_rate_limit || 0;
-
+
var ruleItem = E('div', { 'class': 'schedule-rule-item' }, [
E('div', { 'class': 'schedule-rule-info' }, [
E('div', { 'class': 'schedule-rule-time' }, startTime + ' - ' + endTime),
E('div', { 'class': 'schedule-rule-days' }, daysText),
- E('div', { 'class': 'schedule-rule-limits' },
- '↑ ' + formatByterate(uploadLimit, speedUnit) +
+ E('div', { 'class': 'schedule-rule-limits' },
+ '↑ ' + formatByterate(uploadLimit, speedUnit) +
' / ↓ ' + formatByterate(downloadLimit, speedUnit)
)
]),
- E('button', {
+ E('button', {
'class': 'schedule-rule-delete',
'title': _('Delete')
}, _('Delete'))
]);
-
- ruleItem.querySelector('.schedule-rule-delete').addEventListener('click', function() {
+
+ ruleItem.querySelector('.schedule-rule-delete').addEventListener('click', function () {
showConfirmDialog(
_('Delete Schedule Rule'),
_('Are you sure you want to delete this schedule rule?'),
- function() {
+ function () {
var days = rule.time_slot && rule.time_slot.days ? JSON.stringify(rule.time_slot.days) : '[]';
callDeleteScheduleLimit(
rule.mac,
startTime,
endTime,
days
- ).then(function() {
+ ).then(function () {
loadScheduleRules();
updateDeviceData();
- }).catch(function(error) {
+ }).catch(function (error) {
ui.addNotification(null, E('p', {}, _('Failed to delete schedule rule')), 'error');
});
}
);
});
-
+
rulesList.appendChild(ruleItem);
});
- }).catch(function(error) {
+ }).catch(function (error) {
console.error('Failed to load schedule rules:', error);
var errorMsg = _('Failed to load schedule rules');
if (error && error.message) {
@@ -2465,7 +3723,7 @@ return view.extend({
saveButton.classList.add('btn-loading');
saveButton.disabled = true;
- callSetHostname(currentDevice.mac, newHostname).then(function(result) {
+ callSetHostname(currentDevice.mac, newHostname).then(function (result) {
// 恢复按钮状态
saveButton.innerHTML = originalText;
saveButton.classList.remove('btn-loading');
@@ -2473,10 +3731,10 @@ return view.extend({
// 更新当前设备信息
currentDevice.hostname = newHostname;
-
+
// 刷新设备数据
updateDeviceData();
- }).catch(function(error) {
+ }).catch(function (error) {
// 恢复按钮状态
saveButton.innerHTML = originalText;
saveButton.classList.remove('btn-loading');
@@ -2495,30 +3753,30 @@ return view.extend({
var latestDevices = [];
var lastHistoryData = null; // 最近一次拉取的原始 metrics 数据
var isHistoryLoading = false; // 防止轮询重入
-
+
// 定时限速规则:全局存储
var allScheduleRules = []; // 存储所有设备的定时限速规则
var isScheduleRulesLoading = false; // 防止轮询重入
-
+
// 获取所有定时限速规则
function fetchAllScheduleRules() {
if (isScheduleRulesLoading) return Promise.resolve();
isScheduleRulesLoading = true;
-
- return callGetScheduleLimits().then(function(res) {
+
+ return callGetScheduleLimits().then(function (res) {
isScheduleRulesLoading = false;
-
+
if (!res) {
allScheduleRules = [];
return;
}
-
+
// 检查是否有错误
if (res.success === false || res.error) {
allScheduleRules = [];
return;
}
-
+
// 检查数据格式
var limits = [];
if (res.data && res.data.limits && Array.isArray(res.data.limits)) {
@@ -2528,41 +3786,41 @@ return view.extend({
} else if (Array.isArray(res)) {
limits = res;
}
-
+
allScheduleRules = limits || [];
- }).catch(function(error) {
+ }).catch(function (error) {
isScheduleRulesLoading = false;
console.error('Failed to fetch schedule rules:', error);
allScheduleRules = [];
});
}
-
+
// 判断规则是否在当前时间生效
function isRuleActive(rule) {
if (!rule || !rule.time_slot) return false;
-
+
var now = new Date();
var currentDay = now.getDay(); // 0=Sunday, 1=Monday, ..., 6=Saturday
// 转换为 1-7 (Monday-Sunday)
var dayOfWeek = currentDay === 0 ? 7 : currentDay;
-
+
// 检查是否在规则指定的日期中
var days = rule.time_slot.days || [];
if (!Array.isArray(days) || days.length === 0) return false;
if (days.indexOf(dayOfWeek) === -1) return false;
-
+
// 获取当前时间(HH:MM格式)
var currentTime = ('0' + now.getHours()).slice(-2) + ':' + ('0' + now.getMinutes()).slice(-2);
var startTime = rule.time_slot.start || '';
var endTime = rule.time_slot.end || '';
-
+
if (!startTime || !endTime) return false;
-
+
// 处理 24:00 的情况
if (endTime === '24:00') {
endTime = '23:59';
}
-
+
// 比较时间
if (startTime <= endTime) {
// 正常情况:开始时间 <= 结束时间
@@ -2572,30 +3830,30 @@ return view.extend({
return currentTime >= startTime || currentTime <= endTime;
}
}
-
+
// 获取设备当前生效的规则
function getActiveRulesForDevice(mac) {
if (!allScheduleRules || allScheduleRules.length === 0) return [];
-
- return allScheduleRules.filter(function(rule) {
+
+ return allScheduleRules.filter(function (rule) {
return rule && rule.mac === mac && isRuleActive(rule);
});
}
-
+
// 合并多个生效规则的限制值
// 返回合并后的上传和下载限制(取所有规则中非零的最小值)
function mergeActiveRulesLimits(activeRules) {
if (!activeRules || activeRules.length === 0) {
return { uploadLimit: 0, downloadLimit: 0 };
}
-
+
var uploadLimits = [];
var downloadLimits = [];
-
- activeRules.forEach(function(rule) {
+
+ activeRules.forEach(function (rule) {
var uploadLimit = rule.wide_tx_rate_limit || 0;
var downloadLimit = rule.wide_rx_rate_limit || 0;
-
+
// 只收集非零的限制值
if (uploadLimit > 0) {
uploadLimits.push(uploadLimit);
@@ -2604,24 +3862,24 @@ return view.extend({
downloadLimits.push(downloadLimit);
}
});
-
+
// 取最小值(如果有多个规则都有限制,取最严格的限制)
var mergedUploadLimit = uploadLimits.length > 0 ? Math.min.apply(Math, uploadLimits) : 0;
var mergedDownloadLimit = downloadLimits.length > 0 ? Math.min.apply(Math, downloadLimits) : 0;
-
+
return {
uploadLimit: mergedUploadLimit,
downloadLimit: mergedDownloadLimit
};
}
-
+
// 获取多个规则的时间段显示文本
// 如果所有规则的时间段相同,显示时间段;如果不同,显示"多个时间段"
function getTimeSlotDisplayText(activeRules) {
if (!activeRules || activeRules.length === 0) {
return '';
}
-
+
if (activeRules.length === 1) {
// 单个规则,直接显示时间段
var rule = activeRules[0];
@@ -2629,24 +3887,24 @@ return view.extend({
var endTime = rule.time_slot && rule.time_slot.end ? rule.time_slot.end : '';
return startTime + '-' + endTime;
}
-
+
// 多个规则,检查时间段是否相同
var firstRule = activeRules[0];
var firstStartTime = firstRule.time_slot && firstRule.time_slot.start ? firstRule.time_slot.start : '';
var firstEndTime = firstRule.time_slot && firstRule.time_slot.end ? firstRule.time_slot.end : '';
-
+
var allSame = true;
for (var i = 1; i < activeRules.length; i++) {
var rule = activeRules[i];
var startTime = rule.time_slot && rule.time_slot.start ? rule.time_slot.start : '';
var endTime = rule.time_slot && rule.time_slot.end ? rule.time_slot.end : '';
-
+
if (startTime !== firstStartTime || endTime !== firstEndTime) {
allSame = false;
break;
}
}
-
+
if (allSame) {
// 所有规则时间段相同,显示时间段和规则数量
return firstStartTime + '-' + firstEndTime + ' (' + activeRules.length + ' ' + _('rules') + ')';
@@ -2655,41 +3913,41 @@ return view.extend({
return _('Multiple time slots') + ' (' + activeRules.length + ' ' + _('rules') + ')';
}
}
-
+
// 排序状态管理
var currentSortBy = localStorage.getItem('bandix_sort_by') || 'online'; // 默认按在线状态排序
var currentSortOrder = localStorage.getItem('bandix_sort_order') === 'true'; // false = 降序, true = 升序
- // 当鼠标悬停在历史图表上时,置为 true,轮询将暂停刷新(实现"鼠标在趋势图上时不自动滚动")
- var historyHover = false;
- // 鼠标悬停时的索引(独立于 canvas.__bandixChart,避免重绘覆盖问题)
- var historyHoverIndex = null;
- // 缩放功能相关变量
- var zoomEnabled = false; // 缩放是否启用
- var zoomScale = 1; // 缩放比例
- var zoomOffsetX = 0; // X轴偏移
- var zoomTimer = null; // 延迟启用缩放的计时器
+ // 当鼠标悬停在历史图表上时,置为 true,轮询将暂停刷新(实现"鼠标在趋势图上时不自动滚动")
+ var historyHover = false;
+ // 鼠标悬停时的索引(独立于 canvas.__bandixChart,避免重绘覆盖问题)
+ var historyHoverIndex = null;
+ // 缩放功能相关变量
+ var zoomEnabled = false; // 缩放是否启用
+ var zoomScale = 1; // 缩放比例
+ var zoomOffsetX = 0; // X轴偏移
+ var zoomTimer = null; // 延迟启用缩放的计时器
function updateDeviceOptions(devices) {
var select = document.getElementById('history-device-select');
if (!select) return;
-
+
// 对设备列表进行排序:在线设备在前,离线设备在后,然后按IP地址从小到大排序
- var sortedDevices = devices.slice().sort(function(a, b) {
+ var sortedDevices = devices.slice().sort(function (a, b) {
var aOnline = isDeviceOnline(a);
var bOnline = isDeviceOnline(b);
-
+
// 首先按在线状态排序:在线设备在前
if (aOnline && !bOnline) return -1;
if (!aOnline && bOnline) return 1;
-
+
// 在线状态相同时,按IP地址排序
var aIp = a.ip || '';
var bIp = b.ip || '';
-
+
// 将IP地址转换为数字进行比较
- var aIpParts = aIp.split('.').map(function(part) { return parseInt(part) || 0; });
- var bIpParts = bIp.split('.').map(function(part) { return parseInt(part) || 0; });
-
+ var aIpParts = aIp.split('.').map(function (part) { return parseInt(part) || 0; });
+ var bIpParts = bIp.split('.').map(function (part) { return parseInt(part) || 0; });
+
// 逐段比较IP地址
for (var i = 0; i < 4; i++) {
var aPart = aIpParts[i] || 0;
@@ -2698,11 +3956,11 @@ return view.extend({
return aPart - bPart;
}
}
-
+
// IP地址相同时,按MAC地址排序
return (a.mac || '').localeCompare(b.mac || '');
});
-
+
// 对比是否需要更新
var currentValues = Array.from(select.options).map(o => o.value);
var desiredValues = [''].concat(sortedDevices.map(d => d.mac));
@@ -2727,7 +3985,7 @@ return view.extend({
// 所有类型都使用 WAN 数据
return { up: 'wide_tx_rate', down: 'wide_rx_rate' };
}
-
+
if (type === 'lan') return { up: 'local_tx_rate', down: 'local_rx_rate' };
if (type === 'wan') return { up: 'wide_tx_rate', down: 'wide_rx_rate' };
return { up: 'total_tx_rate', down: 'total_rx_rate' };
@@ -2735,12 +3993,12 @@ return view.extend({
// 当前选择的时间范围
var currentTimeRange = localStorage.getItem('bandix_time_range') || 'realtime';
-
+
function fetchMetricsData(mac) {
// 根据选择的时间范围调用不同的接口
var range = currentTimeRange;
var callFunction;
-
+
switch (range) {
case 'day':
callFunction = callGetMetricsDay;
@@ -2756,7 +4014,7 @@ return view.extend({
callFunction = callGetMetrics;
break;
}
-
+
// 通过 ubus RPC 获取,避免跨域与鉴权问题
return callFunction(mac || '').then(function (res) { return res || { metrics: [] }; });
}
@@ -2768,8 +4026,8 @@ return view.extend({
if (!Array.isArray(metricsArray)) {
return [];
}
-
- return metricsArray.map(function(arr) {
+
+ return metricsArray.map(function (arr) {
// 检查数据格式:如果是15个字段,说明是 day/week/month 格式
if (arr.length >= 15) {
// day/week/month 格式:使用 P95 作为主要显示值
@@ -2824,7 +4082,7 @@ return view.extend({
is_aggregated: false
};
}
- }).filter(function(item) { return item !== null; });
+ }).filter(function (item) { return item !== null; });
}
// 辅助函数:使用当前缩放设置绘制图表
@@ -2836,14 +4094,14 @@ return view.extend({
function updateZoomLevelDisplay() {
var zoomLevelElement = document.getElementById('history-zoom-level');
if (!zoomLevelElement) return;
-
+
// 如果是窄主题,隐藏 zoom 显示
var themeType = getThemeType();
if (themeType === 'narrow') {
zoomLevelElement.style.display = 'none';
return;
}
-
+
if (zoomScale <= 1) {
zoomLevelElement.style.display = 'none';
} else {
@@ -2854,11 +4112,11 @@ return view.extend({
function drawHistoryChart(canvas, labels, upSeries, downSeries, scale, offsetX) {
if (!canvas) return;
-
+
// 缩放参数默认值
scale = scale || 1;
offsetX = offsetX || 0;
-
+
var dpr = window.devicePixelRatio || 1;
var rect = canvas.getBoundingClientRect();
var cssWidth = rect.width;
@@ -2870,12 +4128,12 @@ return view.extend({
var width = cssWidth;
var height = cssHeight;
-
+
// 检测是否为移动端
var isMobile = width <= 768;
-
+
// 预留更大边距,避免标签被裁剪(移动端使用更小的边距)
- var padding = isMobile
+ var padding = isMobile
? { left: 50, right: 20, top: 12, bottom: 28 }
: { left: 90, right: 50, top: 16, bottom: 36 };
@@ -2886,13 +4144,13 @@ return view.extend({
var originalLabels = labels;
var originalUpSeries = upSeries;
var originalDownSeries = downSeries;
-
+
if (scale > 1) {
var totalLen = labels.length;
var visibleLen = Math.ceil(totalLen / scale);
var startIdx = Math.max(0, Math.floor(offsetX));
var endIdx = Math.min(totalLen, startIdx + visibleLen);
-
+
labels = labels.slice(startIdx, endIdx);
upSeries = upSeries.slice(startIdx, endIdx);
downSeries = downSeries.slice(startIdx, endIdx);
@@ -3024,7 +4282,7 @@ return view.extend({
// 移动端不绘制虚线
try {
if (isMobile) return; // 移动端不绘制悬浮虚线
-
+
var info = canvas.__bandixChart || {};
var useIdx = null;
if (typeof historyHoverIndex === 'number') useIdx = historyHoverIndex;
@@ -3033,7 +4291,7 @@ return view.extend({
var n = info.labels.length;
var stepX = n > 1 ? (innerW / (n - 1)) : 0;
var hoverIdx = useIdx;
-
+
// 在缩放状态下,需要将原始索引转换为显示索引
if (scale > 1 && originalLabels && originalLabels.length > 0) {
var startIdx = Math.floor(offsetX || 0);
@@ -3043,7 +4301,7 @@ return view.extend({
hoverIdx = null; // 不在显示范围内,不绘制虚线
}
}
-
+
if (hoverIdx !== null) {
hoverIdx = Math.max(0, Math.min(n - 1, hoverIdx));
var hoverX = info.padding.left + (n > 1 ? stepX * hoverIdx : innerW / 2);
@@ -3069,7 +4327,7 @@ return view.extend({
var ss = ('' + d.getSeconds()).padStart(2, '0');
return hh + ':' + mm + ':' + ss;
}
-
+
// 完整日期时间格式(用于聚合数据)
function msToFullDateTimeLabel(ts) {
var d = new Date(ts);
@@ -3082,145 +4340,145 @@ return view.extend({
return year + '-' + month + '-' + day + ' ' + hh + ':' + mm + ':' + ss;
}
- function buildTooltipHtml(point) {
- if (!point) return '';
- var lines = [];
- var typeSel = (typeof document !== 'undefined' ? document.getElementById('history-type-select') : null);
- var selType = (typeSel && typeSel.value) ? typeSel.value : 'total';
- var speedUnit = uci.get('bandix', 'traffic', 'speed_unit') || 'bytes';
- var isAggregated = point.is_aggregated || false;
+ function buildTooltipHtml(point) {
+ if (!point) return '';
+ var lines = [];
+ var typeSel = (typeof document !== 'undefined' ? document.getElementById('history-type-select') : null);
+ var selType = (typeSel && typeSel.value) ? typeSel.value : 'total';
+ var speedUnit = uci.get('bandix', 'traffic', 'speed_unit') || 'bytes';
+ var isAggregated = point.is_aggregated || false;
- function row(label, val) {
- lines.push('
' + label + '' + val + '
');
- }
+ function row(label, val) {
+ lines.push('
' + label + '' + val + '
');
+ }
- function rateValue(key) {
- return formatByterate(point[key] || 0, speedUnit);
- }
+ function rateValue(key) {
+ return formatByterate(point[key] || 0, speedUnit);
+ }
- function bytesValue(key) {
- return formatSize(point[key] || 0);
- }
+ function bytesValue(key) {
+ return formatSize(point[key] || 0);
+ }
- function labelsFor(type) {
- if (type === 'lan') return { up: _('LAN Upload'), down: _('LAN Download') };
- if (type === 'wan') return { up: _('WAN Upload'), down: _('WAN Download') };
- return { up: _('Total Upload'), down: _('Total Download') };
- }
+ function labelsFor(type) {
+ if (type === 'lan') return { up: _('LAN Upload'), down: _('LAN Download') };
+ if (type === 'wan') return { up: _('WAN Upload'), down: _('WAN Download') };
+ return { up: _('Total Upload'), down: _('Total Download') };
+ }
- function rateKeysFor(type) {
- if (type === 'lan') return { up: 'local_tx_rate', down: 'local_rx_rate' };
- if (type === 'wan') return { up: 'wide_tx_rate', down: 'wide_rx_rate' };
- return { up: 'total_tx_rate', down: 'total_rx_rate' };
- }
+ function rateKeysFor(type) {
+ if (type === 'lan') return { up: 'local_tx_rate', down: 'local_rx_rate' };
+ if (type === 'wan') return { up: 'wide_tx_rate', down: 'wide_rx_rate' };
+ return { up: 'total_tx_rate', down: 'total_rx_rate' };
+ }
- function bytesKeysFor(type) {
- if (type === 'lan') return { up: 'local_tx_bytes', down: 'local_rx_bytes' };
- if (type === 'wan') return { up: 'wide_tx_bytes', down: 'wide_rx_bytes' };
- return { up: 'total_tx_bytes', down: 'total_rx_bytes' };
- }
+ function bytesKeysFor(type) {
+ if (type === 'lan') return { up: 'local_tx_bytes', down: 'local_rx_bytes' };
+ if (type === 'wan') return { up: 'wide_tx_bytes', down: 'wide_rx_bytes' };
+ return { up: 'total_tx_bytes', down: 'total_rx_bytes' };
+ }
- // 标题:聚合数据显示完整日期时间,实时数据只显示时间
- if (isAggregated) {
- lines.push('
' + msToFullDateTimeLabel(point.ts_ms) + '
');
- var rangeLabel = currentTimeRange === 'day' ? _('Daily') :
- currentTimeRange === 'week' ? _('Weekly') :
- currentTimeRange === 'month' ? _('Monthly') : '';
- if (rangeLabel) {
- lines.push('
' + rangeLabel + ' ' + _('Statistics') + '
');
- }
- } else {
- lines.push('
' + msToTimeLabel(point.ts_ms) + '
');
- }
+ // 标题:聚合数据显示完整日期时间,实时数据只显示时间
+ if (isAggregated) {
+ lines.push('
' + msToFullDateTimeLabel(point.ts_ms) + '
');
+ var rangeLabel = currentTimeRange === 'day' ? _('Daily') :
+ currentTimeRange === 'week' ? _('Weekly') :
+ currentTimeRange === 'month' ? _('Monthly') : '';
+ if (rangeLabel) {
+ lines.push('
' + rangeLabel + ' ' + _('Statistics') + '
');
+ }
+ } else {
+ lines.push('
' + msToTimeLabel(point.ts_ms) + '
');
+ }
- // 关键信息:选中类型的上下行速率(大号显示)
- var kpiLabels = labelsFor(selType);
- var kpiRateKeys = rateKeysFor(selType);
-
- if (isAggregated) {
- // 聚合数据:显示 P95 值(主要指标)
- lines.push(
- '
' +
- '
' +
- '
' + _('WAN Upload') + ' (P95)
' +
- '
' + formatByterate(point.wide_tx_rate_p95 || 0, speedUnit) + '
' +
- '
' +
- '
' +
- '
' + _('WAN Download') + ' (P95)
' +
- '
' + formatByterate(point.wide_rx_rate_p95 || 0, speedUnit) + '
' +
- '
' +
- '
'
- );
-
- // 详细统计信息
- lines.push('
');
- lines.push('
' + _('Upload Statistics') + '
');
- row(_('Average'), formatByterate(point.wide_tx_rate_avg || 0, speedUnit));
- row(_('Maximum'), formatByterate(point.wide_tx_rate_max || 0, speedUnit));
- row(_('Minimum'), formatByterate(point.wide_tx_rate_min || 0, speedUnit));
- row('P90', formatByterate(point.wide_tx_rate_p90 || 0, speedUnit));
- row('P95', formatByterate(point.wide_tx_rate_p95 || 0, speedUnit));
- row('P99', formatByterate(point.wide_tx_rate_p99 || 0, speedUnit));
-
- lines.push('
' + _('Download Statistics') + '
');
- row(_('Average'), formatByterate(point.wide_rx_rate_avg || 0, speedUnit));
- row(_('Maximum'), formatByterate(point.wide_rx_rate_max || 0, speedUnit));
- row(_('Minimum'), formatByterate(point.wide_rx_rate_min || 0, speedUnit));
- row('P90', formatByterate(point.wide_rx_rate_p90 || 0, speedUnit));
- row('P95', formatByterate(point.wide_rx_rate_p95 || 0, speedUnit));
- row('P99', formatByterate(point.wide_rx_rate_p99 || 0, speedUnit));
-
- // 累计流量(只显示 WAN)
- lines.push('
');
- lines.push('
' + _('Cumulative Traffic') + '
');
- row(_('WAN Uploaded'), bytesValue('wide_tx_bytes'));
- row(_('WAN Downloaded'), bytesValue('wide_rx_bytes'));
- } else {
- // 实时数据:显示实时速率
- lines.push(
- '
' +
- '
' +
- '
' + kpiLabels.up + '
' +
- '
' + rateValue(kpiRateKeys.up) + '
' +
- '
' +
- '
' +
- '
' + kpiLabels.down + '
' +
- '
' + rateValue(kpiRateKeys.down) + '
' +
- '
' +
- '
'
- );
+ // 关键信息:选中类型的上下行速率(大号显示)
+ var kpiLabels = labelsFor(selType);
+ var kpiRateKeys = rateKeysFor(selType);
- // 次要信息:其余类型的速率(精简展示)
- var otherTypes = ['total', 'lan', 'wan'].filter(function (t) { return t !== selType; });
- if (otherTypes.length) {
- lines.push('
' + _('Other Rates') + '
');
- otherTypes.forEach(function (t) {
- var lbs = labelsFor(t);
- var ks = rateKeysFor(t);
- row(lbs.up, rateValue(ks.up));
- row(lbs.down, rateValue(ks.down));
- });
- }
+ if (isAggregated) {
+ // 聚合数据:显示 P95 值(主要指标)
+ lines.push(
+ '
' +
+ '
' +
+ '
' + _('WAN Upload') + ' (P95)
' +
+ '
' + formatByterate(point.wide_tx_rate_p95 || 0, speedUnit) + '
' +
+ '
' +
+ '
' +
+ '
' + _('WAN Download') + ' (P95)
' +
+ '
' + formatByterate(point.wide_rx_rate_p95 || 0, speedUnit) + '
' +
+ '
' +
+ '
'
+ );
- // 累计:区分LAN 流量与公网
- lines.push('
');
- lines.push('
' + _('Cumulative') + '
');
- row(_('Total Uploaded'), bytesValue('total_tx_bytes'));
- row(_('Total Downloaded'), bytesValue('total_rx_bytes'));
- row(_('LAN Uploaded'), bytesValue('local_tx_bytes'));
- row(_('LAN Downloaded'), bytesValue('local_rx_bytes'));
- row(_('WAN Uploaded'), bytesValue('wide_tx_bytes'));
- row(_('WAN Downloaded'), bytesValue('wide_rx_bytes'));
- }
+ // 详细统计信息
+ lines.push('
');
+ lines.push('
' + _('Upload Statistics') + '
');
+ row(_('Average'), formatByterate(point.wide_tx_rate_avg || 0, speedUnit));
+ row(_('Maximum'), formatByterate(point.wide_tx_rate_max || 0, speedUnit));
+ row(_('Minimum'), formatByterate(point.wide_tx_rate_min || 0, speedUnit));
+ row('P90', formatByterate(point.wide_tx_rate_p90 || 0, speedUnit));
+ row('P95', formatByterate(point.wide_tx_rate_p95 || 0, speedUnit));
+ row('P99', formatByterate(point.wide_tx_rate_p99 || 0, speedUnit));
- return lines.join('');
+ lines.push('
' + _('Download Statistics') + '
');
+ row(_('Average'), formatByterate(point.wide_rx_rate_avg || 0, speedUnit));
+ row(_('Maximum'), formatByterate(point.wide_rx_rate_max || 0, speedUnit));
+ row(_('Minimum'), formatByterate(point.wide_rx_rate_min || 0, speedUnit));
+ row('P90', formatByterate(point.wide_rx_rate_p90 || 0, speedUnit));
+ row('P95', formatByterate(point.wide_rx_rate_p95 || 0, speedUnit));
+ row('P99', formatByterate(point.wide_rx_rate_p99 || 0, speedUnit));
+
+ // 累计流量(只显示 WAN)
+ lines.push('
');
+ lines.push('
' + _('Cumulative Traffic') + '
');
+ row(_('WAN Uploaded'), bytesValue('wide_tx_bytes'));
+ row(_('WAN Downloaded'), bytesValue('wide_rx_bytes'));
+ } else {
+ // 实时数据:显示实时速率
+ lines.push(
+ '
' +
+ '
' +
+ '
' + kpiLabels.up + '
' +
+ '
' + rateValue(kpiRateKeys.up) + '
' +
+ '
' +
+ '
' +
+ '
' + kpiLabels.down + '
' +
+ '
' + rateValue(kpiRateKeys.down) + '
' +
+ '
' +
+ '
'
+ );
+
+ // 次要信息:其余类型的速率(精简展示)
+ var otherTypes = ['total', 'lan', 'wan'].filter(function (t) { return t !== selType; });
+ if (otherTypes.length) {
+ lines.push('
' + _('Other Rates') + '
');
+ otherTypes.forEach(function (t) {
+ var lbs = labelsFor(t);
+ var ks = rateKeysFor(t);
+ row(lbs.up, rateValue(ks.up));
+ row(lbs.down, rateValue(ks.down));
+ });
+ }
+
+ // 累计:区分LAN 流量与公网
+ lines.push('
');
+ lines.push('
' + _('Cumulative') + '
');
+ row(_('Total Uploaded'), bytesValue('total_tx_bytes'));
+ row(_('Total Downloaded'), bytesValue('total_rx_bytes'));
+ row(_('LAN Uploaded'), bytesValue('local_tx_bytes'));
+ row(_('LAN Downloaded'), bytesValue('local_rx_bytes'));
+ row(_('WAN Uploaded'), bytesValue('wide_tx_bytes'));
+ row(_('WAN Downloaded'), bytesValue('wide_rx_bytes'));
+ }
+
+ return lines.join('');
}
// 辅助函数:比较IP地址(小的在前)
function compareIP(aIp, bIp) {
- var aIpParts = (aIp || '').split('.').map(function(part) { return parseInt(part) || 0; });
- var bIpParts = (bIp || '').split('.').map(function(part) { return parseInt(part) || 0; });
-
+ var aIpParts = (aIp || '').split('.').map(function (part) { return parseInt(part) || 0; });
+ var bIpParts = (bIp || '').split('.').map(function (part) { return parseInt(part) || 0; });
+
for (var i = 0; i < 4; i++) {
var aPart = aIpParts[i] || 0;
var bPart = bIpParts[i] || 0;
@@ -3232,358 +4490,358 @@ return view.extend({
}
// 排序逻辑函数
-function sortDevices(devices, sortBy, ascending) {
- if (!devices || !Array.isArray(devices)) return devices;
-
- var sortedDevices = devices.slice();
-
- switch (sortBy) {
- case 'online':
- sortedDevices.sort(function(a, b) {
- var aOnline = isDeviceOnline(a);
- var bOnline = isDeviceOnline(b);
-
- // 如果在线状态不同,在线设备优先
- if (aOnline !== bOnline) {
- return ascending ? (aOnline ? 1 : -1) : (aOnline ? -1 : 1);
- }
-
- // 在线状态相同时,按IP地址排序(小的在前)
- var ipCompare = compareIP(a.ip, b.ip);
- if (ipCompare !== 0) return ipCompare;
-
- // IP地址也相同时,按MAC地址排序
- return (a.mac || '').localeCompare(b.mac || '');
- });
- break;
-
- case 'lan_speed':
- sortedDevices.sort(function(a, b) {
- // 先按在线状态排序(在线在前)
- var aOnline = isDeviceOnline(a);
- var bOnline = isDeviceOnline(b);
- if (aOnline !== bOnline) {
- return aOnline ? -1 : 1;
- }
-
- // 在线状态相同时,按LAN速度排序
- var aSpeed = (a.local_tx_rate || 0) + (a.local_rx_rate || 0);
- var bSpeed = (b.local_tx_rate || 0) + (b.local_rx_rate || 0);
- if (aSpeed !== bSpeed) {
- return ascending ? (aSpeed - bSpeed) : (bSpeed - aSpeed);
- }
-
- // 速度相同时,按IP地址排序
- return compareIP(a.ip, b.ip);
- });
- break;
-
- case 'wan_speed':
- sortedDevices.sort(function(a, b) {
- // 先按在线状态排序(在线在前)
- var aOnline = isDeviceOnline(a);
- var bOnline = isDeviceOnline(b);
- if (aOnline !== bOnline) {
- return aOnline ? -1 : 1;
- }
-
- // 在线状态相同时,按WAN速度排序
- var aSpeed = (a.wide_tx_rate || 0) + (a.wide_rx_rate || 0);
- var bSpeed = (b.wide_tx_rate || 0) + (b.wide_rx_rate || 0);
- if (aSpeed !== bSpeed) {
- return ascending ? (aSpeed - bSpeed) : (bSpeed - aSpeed);
- }
-
- // 速度相同时,按IP地址排序
- return compareIP(a.ip, b.ip);
- });
- break;
-
- case 'lan_traffic':
- sortedDevices.sort(function(a, b) {
- // 先按在线状态排序(在线在前)
- var aOnline = isDeviceOnline(a);
- var bOnline = isDeviceOnline(b);
- if (aOnline !== bOnline) {
- return aOnline ? -1 : 1;
- }
-
- // 在线状态相同时,按LAN流量排序
- var aTraffic = (a.local_tx_bytes || 0) + (a.local_rx_bytes || 0);
- var bTraffic = (b.local_tx_bytes || 0) + (b.local_rx_bytes || 0);
- if (aTraffic !== bTraffic) {
- return ascending ? (aTraffic - bTraffic) : (bTraffic - aTraffic);
- }
-
- // 流量相同时,按IP地址排序
- return compareIP(a.ip, b.ip);
- });
- break;
-
- case 'wan_traffic':
- sortedDevices.sort(function(a, b) {
- // 先按在线状态排序(在线在前)
- var aOnline = isDeviceOnline(a);
- var bOnline = isDeviceOnline(b);
- if (aOnline !== bOnline) {
- return aOnline ? -1 : 1;
- }
-
- // 在线状态相同时,按WAN流量排序
- var aTraffic = (a.wide_tx_bytes || 0) + (a.wide_rx_bytes || 0);
- var bTraffic = (b.wide_tx_bytes || 0) + (b.wide_rx_bytes || 0);
- if (aTraffic !== bTraffic) {
- return ascending ? (aTraffic - bTraffic) : (bTraffic - aTraffic);
- }
-
- // 流量相同时,按IP地址排序
- return compareIP(a.ip, b.ip);
- });
- break;
-
- default:
- // 默认按在线状态和IP地址排序
- sortedDevices.sort(function(a, b) {
- var aOnline = isDeviceOnline(a);
- var bOnline = isDeviceOnline(b);
-
- if (aOnline !== bOnline) {
- return aOnline ? -1 : 1;
- }
-
- // 在线状态相同时,按IP地址排序(小的在前)
- var ipCompare = compareIP(a.ip, b.ip);
- if (ipCompare !== 0) return ipCompare;
-
- // IP相同时,按MAC地址排序
- return (a.mac || '').localeCompare(b.mac || '');
- });
- }
-
- return sortedDevices;
-}
+ function sortDevices(devices, sortBy, ascending) {
+ if (!devices || !Array.isArray(devices)) return devices;
-// 判断设备是否在线(基于 last_online_ts)
-function isDeviceOnline(device) {
- // 如果没有 last_online_ts 字段,使用原有的 online 字段
- if (typeof device.last_online_ts === 'undefined') {
- return device.online !== false;
- }
-
- // 如果 last_online_ts 为 0 或无效值,认为离线
- if (!device.last_online_ts || device.last_online_ts <= 0) {
- return false;
- }
-
- // 计算当前时间与最后在线时间的差值(毫秒)
- var currentTime = Date.now();
- // 如果时间戳小于1000000000000,说明是秒级时间戳,需要转换为毫秒
- var lastOnlineTime = device.last_online_ts < 1000000000000 ? device.last_online_ts * 1000 : device.last_online_ts;
- var timeDiff = currentTime - lastOnlineTime;
-
- // 从UCI配置获取离线超时时间(秒),默认10分钟
- var offlineTimeoutSeconds = uci.get('bandix', 'traffic', 'offline_timeout') || 600;
- var offlineThreshold = offlineTimeoutSeconds * 1000; // 转换为毫秒
-
- return timeDiff <= offlineThreshold;
-}
+ var sortedDevices = devices.slice();
-// 格式化最后上线时间
-function formatLastOnlineTime(lastOnlineTs) {
- if (!lastOnlineTs || lastOnlineTs <= 0) {
- return _('Never Online');
- }
-
- // 如果时间戳小于1000000000000,说明是秒级时间戳,需要转换为毫秒
- var lastOnlineTime = lastOnlineTs < 1000000000000 ? lastOnlineTs * 1000 : lastOnlineTs;
- var currentTime = Date.now();
- var timeDiff = currentTime - lastOnlineTime;
-
- // 转换为分钟
- var minutesDiff = Math.floor(timeDiff / (60 * 1000));
-
- // 1分钟以内显示"刚刚"
- if (minutesDiff < 1) {
- return _('Just Now');
- }
-
- // 10分钟以内显示具体的"几分钟前"
- if (minutesDiff <= 10) {
- return minutesDiff + _('min ago');
- }
-
- // 转换为小时
- var hoursDiff = Math.floor(timeDiff / (60 * 60 * 1000));
-
- // 如果不满1小时,显示分钟
- if (hoursDiff < 1) {
- return minutesDiff + _('min ago');
- }
-
- // 转换为天
- var daysDiff = Math.floor(timeDiff / (24 * 60 * 60 * 1000));
-
- // 如果不满1天,显示小时(忽略分钟)
- if (daysDiff < 1) {
- return hoursDiff + _('h ago');
- }
-
- // 转换为月(按30天计算)
- var monthsDiff = Math.floor(daysDiff / 30);
-
- // 如果不满1个月,显示天(忽略小时)
- if (monthsDiff < 1) {
- return daysDiff + _('days ago');
- }
-
- // 转换为年(按365天计算)
- var yearsDiff = Math.floor(daysDiff / 365);
-
- // 如果不满1年,显示月(忽略天)
- if (yearsDiff < 1) {
- return monthsDiff + _('months ago');
- }
-
- // 超过1年,显示年(忽略月)
- return yearsDiff + _('years ago');
-}
+ switch (sortBy) {
+ case 'online':
+ sortedDevices.sort(function (a, b) {
+ var aOnline = isDeviceOnline(a);
+ var bOnline = isDeviceOnline(b);
-// 精确时间格式
-function formatLastOnlineExactTime(lastOnlineTs) {
- if (!lastOnlineTs || lastOnlineTs <= 0) {
- return '-';
- }
+ // 如果在线状态不同,在线设备优先
+ if (aOnline !== bOnline) {
+ return ascending ? (aOnline ? 1 : -1) : (aOnline ? -1 : 1);
+ }
- var lastOnlineTime = lastOnlineTs < 1000000000000 ? lastOnlineTs * 1000 : lastOnlineTs;
- var date = new Date(lastOnlineTime);
+ // 在线状态相同时,按IP地址排序(小的在前)
+ var ipCompare = compareIP(a.ip, b.ip);
+ if (ipCompare !== 0) return ipCompare;
- if (isNaN(date.getTime())) {
- return '-';
- }
+ // IP地址也相同时,按MAC地址排序
+ return (a.mac || '').localeCompare(b.mac || '');
+ });
+ break;
- function pad(value) {
- return value < 10 ? '0' + value : value;
- }
+ case 'lan_speed':
+ sortedDevices.sort(function (a, b) {
+ // 先按在线状态排序(在线在前)
+ var aOnline = isDeviceOnline(a);
+ var bOnline = isDeviceOnline(b);
+ if (aOnline !== bOnline) {
+ return aOnline ? -1 : 1;
+ }
- return date.getFullYear() + '-' +
- pad(date.getMonth() + 1) + '-' +
- pad(date.getDate()) + ' ' +
- pad(date.getHours()) + ':' +
- pad(date.getMinutes()) + ':' +
- pad(date.getSeconds());
-}
+ // 在线状态相同时,按LAN速度排序
+ var aSpeed = (a.local_tx_rate || 0) + (a.local_rx_rate || 0);
+ var bSpeed = (b.local_tx_rate || 0) + (b.local_rx_rate || 0);
+ if (aSpeed !== bSpeed) {
+ return ascending ? (aSpeed - bSpeed) : (bSpeed - aSpeed);
+ }
-function formatRetentionSeconds(seconds) {
- if (!seconds || seconds <= 0) return '';
-
- // 固定值映射
- if (seconds === 600) {
- return _('Last 10 Minutes');
- }
- if (seconds === 900) {
- return _('Last 15 Minutes');
- }
- if (seconds === 1800) {
- return _('Last 30 Minutes');
- }
- if (seconds === 3600) {
- return _('Last 1 Hour');
- }
- if (seconds === 86400) {
- return _('Last 24 Hours');
- }
- if (seconds === 604800) {
- return _('Last 7 Days');
- }
- if (seconds === 2592000) {
- return _('Last 30 Days');
- }
-
- var value;
- var unitKey;
- if (seconds < 60) {
- value = Math.round(seconds);
- unitKey = _('seconds');
- } else if (seconds < 3600) {
- value = Math.round(seconds / 60);
- if (value < 1) value = 1;
- unitKey = _('minutes');
- } else if (seconds < 86400) {
- value = Math.round(seconds / 3600);
- if (value < 1) value = 1;
- unitKey = _('hours');
- } else if (seconds < 604800) {
- value = Math.round(seconds / 86400);
- if (value < 1) value = 1;
- unitKey = _('days');
- } else {
- value = Math.round(seconds / 604800);
- if (value < 1) value = 1;
- unitKey = _('weeks');
- }
+ // 速度相同时,按IP地址排序
+ return compareIP(a.ip, b.ip);
+ });
+ break;
- return _('Last') + ' ' + value + ' ' + unitKey;
-}
+ case 'wan_speed':
+ sortedDevices.sort(function (a, b) {
+ // 先按在线状态排序(在线在前)
+ var aOnline = isDeviceOnline(a);
+ var bOnline = isDeviceOnline(b);
+ if (aOnline !== bOnline) {
+ return aOnline ? -1 : 1;
+ }
-// 移动端数据采样函数:最多显示指定数量的点,保留首尾点
-function downsampleForMobile(data, labels, upSeries, downSeries, maxPoints) {
- if (!data || data.length <= maxPoints) {
- return {
- data: data,
- labels: labels,
- upSeries: upSeries,
- downSeries: downSeries,
- indices: data.map(function(_, i) { return i; }) // 原始索引映射
- };
- }
-
- var n = data.length;
- var sampledData = [];
- var sampledLabels = [];
- var sampledUp = [];
- var sampledDown = [];
- var indices = []; // 记录每个采样点对应的原始数据索引
-
- // 均匀采样,保留首尾点
- var step = (n - 1) / (maxPoints - 1);
-
- for (var i = 0; i < maxPoints; i++) {
- var idx = Math.round(i * step);
- // 确保索引在有效范围内
- idx = Math.min(idx, n - 1);
-
- sampledData.push(data[idx]);
- sampledLabels.push(labels[idx]);
- sampledUp.push(upSeries[idx]);
- sampledDown.push(downSeries[idx]);
- indices.push(idx); // 保存原始索引
- }
-
- // 确保首尾点被包含
- if (indices[0] !== 0) {
- sampledData[0] = data[0];
- sampledLabels[0] = labels[0];
- sampledUp[0] = upSeries[0];
- sampledDown[0] = downSeries[0];
- indices[0] = 0;
- }
- if (indices[indices.length - 1] !== n - 1) {
- var lastIdx = sampledData.length - 1;
- sampledData[lastIdx] = data[n - 1];
- sampledLabels[lastIdx] = labels[n - 1];
- sampledUp[lastIdx] = upSeries[lastIdx];
- sampledDown[lastIdx] = downSeries[lastIdx];
- indices[lastIdx] = n - 1;
- }
-
- return {
- data: sampledData,
- labels: sampledLabels,
- upSeries: sampledUp,
- downSeries: sampledDown,
- indices: indices
- };
-}
+ // 在线状态相同时,按WAN速度排序
+ var aSpeed = (a.wide_tx_rate || 0) + (a.wide_rx_rate || 0);
+ var bSpeed = (b.wide_tx_rate || 0) + (b.wide_rx_rate || 0);
+ if (aSpeed !== bSpeed) {
+ return ascending ? (aSpeed - bSpeed) : (bSpeed - aSpeed);
+ }
+
+ // 速度相同时,按IP地址排序
+ return compareIP(a.ip, b.ip);
+ });
+ break;
+
+ case 'lan_traffic':
+ sortedDevices.sort(function (a, b) {
+ // 先按在线状态排序(在线在前)
+ var aOnline = isDeviceOnline(a);
+ var bOnline = isDeviceOnline(b);
+ if (aOnline !== bOnline) {
+ return aOnline ? -1 : 1;
+ }
+
+ // 在线状态相同时,按LAN流量排序
+ var aTraffic = (a.local_tx_bytes || 0) + (a.local_rx_bytes || 0);
+ var bTraffic = (b.local_tx_bytes || 0) + (b.local_rx_bytes || 0);
+ if (aTraffic !== bTraffic) {
+ return ascending ? (aTraffic - bTraffic) : (bTraffic - aTraffic);
+ }
+
+ // 流量相同时,按IP地址排序
+ return compareIP(a.ip, b.ip);
+ });
+ break;
+
+ case 'wan_traffic':
+ sortedDevices.sort(function (a, b) {
+ // 先按在线状态排序(在线在前)
+ var aOnline = isDeviceOnline(a);
+ var bOnline = isDeviceOnline(b);
+ if (aOnline !== bOnline) {
+ return aOnline ? -1 : 1;
+ }
+
+ // 在线状态相同时,按WAN流量排序
+ var aTraffic = (a.wide_tx_bytes || 0) + (a.wide_rx_bytes || 0);
+ var bTraffic = (b.wide_tx_bytes || 0) + (b.wide_rx_bytes || 0);
+ if (aTraffic !== bTraffic) {
+ return ascending ? (aTraffic - bTraffic) : (bTraffic - aTraffic);
+ }
+
+ // 流量相同时,按IP地址排序
+ return compareIP(a.ip, b.ip);
+ });
+ break;
+
+ default:
+ // 默认按在线状态和IP地址排序
+ sortedDevices.sort(function (a, b) {
+ var aOnline = isDeviceOnline(a);
+ var bOnline = isDeviceOnline(b);
+
+ if (aOnline !== bOnline) {
+ return aOnline ? -1 : 1;
+ }
+
+ // 在线状态相同时,按IP地址排序(小的在前)
+ var ipCompare = compareIP(a.ip, b.ip);
+ if (ipCompare !== 0) return ipCompare;
+
+ // IP相同时,按MAC地址排序
+ return (a.mac || '').localeCompare(b.mac || '');
+ });
+ }
+
+ return sortedDevices;
+ }
+
+ // 判断设备是否在线(基于 last_online_ts)
+ function isDeviceOnline(device) {
+ // 如果没有 last_online_ts 字段,使用原有的 online 字段
+ if (typeof device.last_online_ts === 'undefined') {
+ return device.online !== false;
+ }
+
+ // 如果 last_online_ts 为 0 或无效值,认为离线
+ if (!device.last_online_ts || device.last_online_ts <= 0) {
+ return false;
+ }
+
+ // 计算当前时间与最后在线时间的差值(毫秒)
+ var currentTime = Date.now();
+ // 如果时间戳小于1000000000000,说明是秒级时间戳,需要转换为毫秒
+ var lastOnlineTime = device.last_online_ts < 1000000000000 ? device.last_online_ts * 1000 : device.last_online_ts;
+ var timeDiff = currentTime - lastOnlineTime;
+
+ // 从UCI配置获取离线超时时间(秒),默认10分钟
+ var offlineTimeoutSeconds = uci.get('bandix', 'traffic', 'offline_timeout') || 600;
+ var offlineThreshold = offlineTimeoutSeconds * 1000; // 转换为毫秒
+
+ return timeDiff <= offlineThreshold;
+ }
+
+ // 格式化最后上线时间
+ function formatLastOnlineTime(lastOnlineTs) {
+ if (!lastOnlineTs || lastOnlineTs <= 0) {
+ return _('Never Online');
+ }
+
+ // 如果时间戳小于1000000000000,说明是秒级时间戳,需要转换为毫秒
+ var lastOnlineTime = lastOnlineTs < 1000000000000 ? lastOnlineTs * 1000 : lastOnlineTs;
+ var currentTime = Date.now();
+ var timeDiff = currentTime - lastOnlineTime;
+
+ // 转换为分钟
+ var minutesDiff = Math.floor(timeDiff / (60 * 1000));
+
+ // 1分钟以内显示"刚刚"
+ if (minutesDiff < 1) {
+ return _('Just Now');
+ }
+
+ // 10分钟以内显示具体的"几分钟前"
+ if (minutesDiff <= 10) {
+ return minutesDiff + _('min ago');
+ }
+
+ // 转换为小时
+ var hoursDiff = Math.floor(timeDiff / (60 * 60 * 1000));
+
+ // 如果不满1小时,显示分钟
+ if (hoursDiff < 1) {
+ return minutesDiff + _('min ago');
+ }
+
+ // 转换为天
+ var daysDiff = Math.floor(timeDiff / (24 * 60 * 60 * 1000));
+
+ // 如果不满1天,显示小时(忽略分钟)
+ if (daysDiff < 1) {
+ return hoursDiff + _('h ago');
+ }
+
+ // 转换为月(按30天计算)
+ var monthsDiff = Math.floor(daysDiff / 30);
+
+ // 如果不满1个月,显示天(忽略小时)
+ if (monthsDiff < 1) {
+ return daysDiff + _('days ago');
+ }
+
+ // 转换为年(按365天计算)
+ var yearsDiff = Math.floor(daysDiff / 365);
+
+ // 如果不满1年,显示月(忽略天)
+ if (yearsDiff < 1) {
+ return monthsDiff + _('months ago');
+ }
+
+ // 超过1年,显示年(忽略月)
+ return yearsDiff + _('years ago');
+ }
+
+ // 精确时间格式
+ function formatLastOnlineExactTime(lastOnlineTs) {
+ if (!lastOnlineTs || lastOnlineTs <= 0) {
+ return '-';
+ }
+
+ var lastOnlineTime = lastOnlineTs < 1000000000000 ? lastOnlineTs * 1000 : lastOnlineTs;
+ var date = new Date(lastOnlineTime);
+
+ if (isNaN(date.getTime())) {
+ return '-';
+ }
+
+ function pad(value) {
+ return value < 10 ? '0' + value : value;
+ }
+
+ return date.getFullYear() + '-' +
+ pad(date.getMonth() + 1) + '-' +
+ pad(date.getDate()) + ' ' +
+ pad(date.getHours()) + ':' +
+ pad(date.getMinutes()) + ':' +
+ pad(date.getSeconds());
+ }
+
+ function formatRetentionSeconds(seconds) {
+ if (!seconds || seconds <= 0) return '';
+
+ // 固定值映射
+ if (seconds === 600) {
+ return _('Last 10 Minutes');
+ }
+ if (seconds === 900) {
+ return _('Last 15 Minutes');
+ }
+ if (seconds === 1800) {
+ return _('Last 30 Minutes');
+ }
+ if (seconds === 3600) {
+ return _('Last 1 Hour');
+ }
+ if (seconds === 86400) {
+ return _('Last 24 Hours');
+ }
+ if (seconds === 604800) {
+ return _('Last 7 Days');
+ }
+ if (seconds === 2592000) {
+ return _('Last 30 Days');
+ }
+
+ var value;
+ var unitKey;
+ if (seconds < 60) {
+ value = Math.round(seconds);
+ unitKey = _('seconds');
+ } else if (seconds < 3600) {
+ value = Math.round(seconds / 60);
+ if (value < 1) value = 1;
+ unitKey = _('minutes');
+ } else if (seconds < 86400) {
+ value = Math.round(seconds / 3600);
+ if (value < 1) value = 1;
+ unitKey = _('hours');
+ } else if (seconds < 604800) {
+ value = Math.round(seconds / 86400);
+ if (value < 1) value = 1;
+ unitKey = _('days');
+ } else {
+ value = Math.round(seconds / 604800);
+ if (value < 1) value = 1;
+ unitKey = _('weeks');
+ }
+
+ return _('Last') + ' ' + value + ' ' + unitKey;
+ }
+
+ // 移动端数据采样函数:最多显示指定数量的点,保留首尾点
+ function downsampleForMobile(data, labels, upSeries, downSeries, maxPoints) {
+ if (!data || data.length <= maxPoints) {
+ return {
+ data: data,
+ labels: labels,
+ upSeries: upSeries,
+ downSeries: downSeries,
+ indices: data.map(function (_, i) { return i; }) // 原始索引映射
+ };
+ }
+
+ var n = data.length;
+ var sampledData = [];
+ var sampledLabels = [];
+ var sampledUp = [];
+ var sampledDown = [];
+ var indices = []; // 记录每个采样点对应的原始数据索引
+
+ // 均匀采样,保留首尾点
+ var step = (n - 1) / (maxPoints - 1);
+
+ for (var i = 0; i < maxPoints; i++) {
+ var idx = Math.round(i * step);
+ // 确保索引在有效范围内
+ idx = Math.min(idx, n - 1);
+
+ sampledData.push(data[idx]);
+ sampledLabels.push(labels[idx]);
+ sampledUp.push(upSeries[idx]);
+ sampledDown.push(downSeries[idx]);
+ indices.push(idx); // 保存原始索引
+ }
+
+ // 确保首尾点被包含
+ if (indices[0] !== 0) {
+ sampledData[0] = data[0];
+ sampledLabels[0] = labels[0];
+ sampledUp[0] = upSeries[0];
+ sampledDown[0] = downSeries[0];
+ indices[0] = 0;
+ }
+ if (indices[indices.length - 1] !== n - 1) {
+ var lastIdx = sampledData.length - 1;
+ sampledData[lastIdx] = data[n - 1];
+ sampledLabels[lastIdx] = labels[n - 1];
+ sampledUp[lastIdx] = upSeries[lastIdx];
+ sampledDown[lastIdx] = downSeries[lastIdx];
+ indices[lastIdx] = n - 1;
+ }
+
+ return {
+ data: sampledData,
+ labels: sampledLabels,
+ upSeries: sampledUp,
+ downSeries: sampledDown,
+ indices: indices
+ };
+ }
function refreshHistory() {
// 若鼠标在历史图上悬停,则暂停刷新以避免自动滚动
@@ -3597,7 +4855,7 @@ function downsampleForMobile(data, labels, upSeries, downSeries, maxPoints) {
if (isHistoryLoading) return Promise.resolve();
isHistoryLoading = true;
-
+
return fetchMetricsData(mac).then(function (res) {
// 将数组数组格式转换为对象数组格式
@@ -3625,36 +4883,36 @@ function downsampleForMobile(data, labels, upSeries, downSeries, maxPoints) {
// 检测是否为移动端
var screenWidth = window.innerWidth || document.documentElement.clientWidth;
var isMobileScreen = screenWidth <= 768;
-
+
var displayData = filtered; // 用于 tooltip 显示的原始数据
var indexMapping = null; // 采样后的索引映射到原始数据的索引
var timeRangeBadge = document.getElementById('history-time-range');
-
+
// 移动端:只显示最近 20 秒的数据
if (isMobileScreen && filtered.length > 0) {
var currentTime = Date.now();
var twentySecondsAgo = currentTime - 20000; // 20 秒前
-
+
// 过滤出最近 20 秒的数据
- var recentData = filtered.filter(function(item) {
+ var recentData = filtered.filter(function (item) {
var ts = item.ts_ms || 0;
// 如果时间戳是秒级,转换为毫秒
if (ts < 1000000000000) ts = ts * 1000;
return ts >= twentySecondsAgo;
});
-
+
// 如果没有最近 20 秒的数据,使用最后 20 个数据点(或全部,如果少于 20 个)
if (recentData.length === 0 && filtered.length > 0) {
recentData = filtered.slice(-20); // 取最后 20 个点
}
-
+
// 如果数据点超过 20 个,进行采样
if (recentData.length > 20) {
var keys = getTypeKeys(type);
var tempUpSeries = recentData.map(function (x) { return x[keys.up] || 0; });
var tempDownSeries = recentData.map(function (x) { return x[keys.down] || 0; });
var tempLabels = recentData.map(function (x) { return msToTimeLabel(x.ts_ms); });
-
+
var sampled = downsampleForMobile(recentData, tempLabels, tempUpSeries, tempDownSeries, 20);
filtered = sampled.data;
// 索引映射:sampled.indices 是 recentData 中的索引,直接使用即可
@@ -3662,21 +4920,21 @@ function downsampleForMobile(data, labels, upSeries, downSeries, maxPoints) {
} else {
filtered = recentData;
// 创建完整的索引映射(1:1),索引直接对应 recentData 的索引
- indexMapping = recentData.map(function(_, i) { return i; });
+ indexMapping = recentData.map(function (_, i) { return i; });
}
-
+
// 保存原始数据用于 tooltip(recentData 的索引与 indexMapping 对应)
displayData = recentData;
} else {
// PC端:显示所有数据,创建完整的索引映射(1:1)
- indexMapping = filtered.map(function(_, i) { return i; });
+ indexMapping = filtered.map(function (_, i) { return i; });
}
var keys = getTypeKeys(type);
var upSeries = filtered.map(function (x) { return x[keys.up] || 0; });
var downSeries = filtered.map(function (x) { return x[keys.down] || 0; });
var labels = filtered.map(function (x) { return msToTimeLabel(x.ts_ms); });
-
+
// 保存索引映射到 canvas,供 tooltip 使用
if (canvas) {
canvas.__bandixIndexMapping = indexMapping;
@@ -3691,184 +4949,184 @@ function downsampleForMobile(data, labels, upSeries, downSeries, maxPoints) {
var x = evt.clientX - rect.left;
var info = canvas.__bandixChart;
if (!info || !info.labels || info.labels.length === 0) return -1;
-
+
// 当前显示的数据长度(缩放后)
var n = info.labels.length;
var stepX = n > 1 ? (info.innerW / (n - 1)) : 0;
var minIdx = 0;
var minDist = Infinity;
-
+
// 在当前显示的数据范围内找最近的点
for (var k = 0; k < n; k++) {
var px = info.padding.left + (n > 1 ? stepX * k : info.innerW / 2);
var dist = Math.abs(px - x);
if (dist < minDist) { minDist = dist; minIdx = k; }
}
-
+
// 如果处于缩放状态,需要将显示索引映射回原始数据索引
if (info.scale && info.scale > 1 && info.originalLabels) {
var startIdx = Math.floor(info.offsetX || 0);
minIdx = startIdx + minIdx;
}
-
+
// 使用索引映射将显示索引转换为原始数据索引(移动端采样后需要)
var indexMapping = canvas.__bandixIndexMapping;
if (indexMapping && indexMapping[minIdx] !== undefined) {
return indexMapping[minIdx];
}
-
+
return minIdx;
}
- function onMove(evt) {
- // 移动端禁用悬浮功能
- var screenWidth = window.innerWidth || document.documentElement.clientWidth;
- if (screenWidth <= 768) {
- if (tooltip) tooltip.style.display = 'none';
- return;
- }
-
- if (!tooltip) return;
- var idx = findNearestIndex(evt);
-
- // 优先使用 displayData(移动端过滤后的数据),否则使用 lastHistoryData
- var dataSource = (canvas && canvas.__bandixDisplayData) ? canvas.__bandixDisplayData : lastHistoryData;
-
- if (idx < 0 || !dataSource || !dataSource[idx]) {
+ function onMove(evt) {
+ // 移动端禁用悬浮功能
+ var screenWidth = window.innerWidth || document.documentElement.clientWidth;
+ if (screenWidth <= 768) {
+ if (tooltip) tooltip.style.display = 'none';
+ return;
+ }
+
+ if (!tooltip) return;
+ var idx = findNearestIndex(evt);
+
+ // 优先使用 displayData(移动端过滤后的数据),否则使用 lastHistoryData
+ var dataSource = (canvas && canvas.__bandixDisplayData) ? canvas.__bandixDisplayData : lastHistoryData;
+
+ if (idx < 0 || !dataSource || !dataSource[idx]) {
tooltip.style.display = 'none';
// 清除 hover 状态并请求重绘去掉虚线
historyHover = false;
- try { if (canvas && canvas.__bandixChart) { delete canvas.__bandixChart.hoverIndex; drawHistoryChart(canvas, canvas.__bandixChart.originalLabels || [], canvas.__bandixChart.originalUpSeries || [], canvas.__bandixChart.originalDownSeries || [], zoomScale, zoomOffsetX); } } catch(e){}
- return;
- }
+ try { if (canvas && canvas.__bandixChart) { delete canvas.__bandixChart.hoverIndex; drawHistoryChart(canvas, canvas.__bandixChart.originalLabels || [], canvas.__bandixChart.originalUpSeries || [], canvas.__bandixChart.originalDownSeries || [], zoomScale, zoomOffsetX); } } catch (e) { }
+ return;
+ }
var point = dataSource[idx];
// 设置 hover 状态,暂停历史轮询刷新
historyHover = true;
historyHoverIndex = idx;
// 立即重绘以显示垂直虚线
- try { drawHistoryChart(canvas, canvas.__bandixChart && canvas.__bandixChart.originalLabels ? canvas.__bandixChart.originalLabels : labels, canvas.__bandixChart && canvas.__bandixChart.originalUpSeries ? canvas.__bandixChart.originalUpSeries : upSeries, canvas.__bandixChart && canvas.__bandixChart.originalDownSeries ? canvas.__bandixChart.originalDownSeries : downSeries, zoomScale, zoomOffsetX); } catch(e){}
- tooltip.innerHTML = buildTooltipHtml(point);
-
- // 应用主题颜色到 tooltip,使用 cbi-section 的颜色
- try {
- // 优先从 cbi-section 获取颜色(历史趋势卡片就是 cbi-section)
- var cbiSection = document.querySelector('.cbi-section');
- var targetElement = cbiSection || document.querySelector('.main') || document.body;
- var computedStyle = window.getComputedStyle(targetElement);
- var bgColor = computedStyle.backgroundColor;
- var textColor = computedStyle.color;
-
- // 确保背景色不透明
- if (bgColor && bgColor !== 'rgba(0, 0, 0, 0)' && bgColor !== 'transparent') {
- // 检查是否是 rgba/rgb 格式,如果是半透明则转换为不透明
- var rgbaMatch = bgColor.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
- if (rgbaMatch) {
- var r = parseInt(rgbaMatch[1]);
- var g = parseInt(rgbaMatch[2]);
- var b = parseInt(rgbaMatch[3]);
- var alpha = rgbaMatch[4] ? parseFloat(rgbaMatch[4]) : 1;
-
- // 如果 alpha < 0.95,使用不透明版本
- if (alpha < 0.95) {
- tooltip.style.backgroundColor = 'rgb(' + r + ', ' + g + ', ' + b + ')';
- } else {
- tooltip.style.backgroundColor = bgColor;
- }
- } else {
- tooltip.style.backgroundColor = bgColor;
- }
- } else {
- // 如果无法获取背景色,尝试从其他 cbi-section 获取
- var allCbiSections = document.querySelectorAll('.cbi-section');
- var foundBgColor = false;
- for (var i = 0; i < allCbiSections.length; i++) {
- var sectionStyle = window.getComputedStyle(allCbiSections[i]);
- var sectionBg = sectionStyle.backgroundColor;
- if (sectionBg && sectionBg !== 'rgba(0, 0, 0, 0)' && sectionBg !== 'transparent') {
- var rgbaMatch = sectionBg.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
- if (rgbaMatch) {
- var r = parseInt(rgbaMatch[1]);
- var g = parseInt(rgbaMatch[2]);
- var b = parseInt(rgbaMatch[3]);
- var alpha = rgbaMatch[4] ? parseFloat(rgbaMatch[4]) : 1;
- if (alpha < 0.95) {
- tooltip.style.backgroundColor = 'rgb(' + r + ', ' + g + ', ' + b + ')';
- } else {
- tooltip.style.backgroundColor = sectionBg;
- }
- } else {
- tooltip.style.backgroundColor = sectionBg;
- }
- foundBgColor = true;
- break;
- }
- }
- // 如果无法获取背景色,CSS 会通过媒体查询自动处理暗色模式
- if (!foundBgColor) {
- // 不设置背景色,让 CSS 媒体查询处理
- }
- }
-
- if (textColor && textColor !== 'rgba(0, 0, 0, 0)') {
- tooltip.style.color = textColor;
- } else {
- // 如果无法获取文字颜色,从 cbi-section 获取
- if (cbiSection) {
- var sectionTextColor = window.getComputedStyle(cbiSection).color;
- if (sectionTextColor && sectionTextColor !== 'rgba(0, 0, 0, 0)') {
- tooltip.style.color = sectionTextColor;
- }
- // 否则使用 CSS 默认颜色(已通过媒体查询设置)
- }
- // 否则使用 CSS 默认颜色(已通过媒体查询设置)
- }
-
- // 边框和阴影由 CSS 媒体查询自动处理
- } catch(e) {
- // 如果出错,CSS 会通过媒体查询自动处理暗色模式
- // 不设置样式,让 CSS 处理
- }
-
- // 先显示以计算尺寸
- tooltip.style.display = 'block';
- tooltip.style.left = '-9999px';
- tooltip.style.top = '-9999px';
- var tw = tooltip.offsetWidth || 0;
- var th = tooltip.offsetHeight || 0;
- var padding = 12;
- var maxX = (typeof window !== 'undefined' ? window.innerWidth : document.documentElement.clientWidth) - 4;
- var maxY = (typeof window !== 'undefined' ? window.innerHeight : document.documentElement.clientHeight) - 4;
- var cx = evt.clientX;
- var cy = evt.clientY;
-
- // 检测是否为移动端
- var isMobileScreen = maxX <= 768;
-
- var baseX, baseY;
- if (isMobileScreen) {
- // 移动端:居中显示在触摸点下方
- baseX = Math.max(4, Math.min(maxX - tw - 4, cx - tw / 2));
- baseY = cy + padding; // 显示在触摸点下方
- // 如果下方空间不足,显示在上方
- if (baseY + th > maxY) {
- baseY = cy - th - padding;
- }
- } else {
- // PC端:右上(水平向右)
- baseX = cx + padding;
- baseY = cy - th - padding; // 上方
- // 若右侧溢出,改为左上
- if (baseX + tw > maxX) {
- baseX = cx - tw - padding;
- }
- }
- // 边界收缩
- if (baseX < 4) baseX = 4;
- if (baseY < 4) baseY = 4;
+ try { drawHistoryChart(canvas, canvas.__bandixChart && canvas.__bandixChart.originalLabels ? canvas.__bandixChart.originalLabels : labels, canvas.__bandixChart && canvas.__bandixChart.originalUpSeries ? canvas.__bandixChart.originalUpSeries : upSeries, canvas.__bandixChart && canvas.__bandixChart.originalDownSeries ? canvas.__bandixChart.originalDownSeries : downSeries, zoomScale, zoomOffsetX); } catch (e) { }
+ tooltip.innerHTML = buildTooltipHtml(point);
- tooltip.style.left = baseX + 'px';
- tooltip.style.top = baseY + 'px';
- }
+ // 应用主题颜色到 tooltip,使用 cbi-section 的颜色
+ try {
+ // 优先从 cbi-section 获取颜色(历史趋势卡片就是 cbi-section)
+ var cbiSection = document.querySelector('.cbi-section');
+ var targetElement = cbiSection || document.querySelector('.main') || document.body;
+ var computedStyle = window.getComputedStyle(targetElement);
+ var bgColor = computedStyle.backgroundColor;
+ var textColor = computedStyle.color;
+
+ // 确保背景色不透明
+ if (bgColor && bgColor !== 'rgba(0, 0, 0, 0)' && bgColor !== 'transparent') {
+ // 检查是否是 rgba/rgb 格式,如果是半透明则转换为不透明
+ var rgbaMatch = bgColor.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
+ if (rgbaMatch) {
+ var r = parseInt(rgbaMatch[1]);
+ var g = parseInt(rgbaMatch[2]);
+ var b = parseInt(rgbaMatch[3]);
+ var alpha = rgbaMatch[4] ? parseFloat(rgbaMatch[4]) : 1;
+
+ // 如果 alpha < 0.95,使用不透明版本
+ if (alpha < 0.95) {
+ tooltip.style.backgroundColor = 'rgb(' + r + ', ' + g + ', ' + b + ')';
+ } else {
+ tooltip.style.backgroundColor = bgColor;
+ }
+ } else {
+ tooltip.style.backgroundColor = bgColor;
+ }
+ } else {
+ // 如果无法获取背景色,尝试从其他 cbi-section 获取
+ var allCbiSections = document.querySelectorAll('.cbi-section');
+ var foundBgColor = false;
+ for (var i = 0; i < allCbiSections.length; i++) {
+ var sectionStyle = window.getComputedStyle(allCbiSections[i]);
+ var sectionBg = sectionStyle.backgroundColor;
+ if (sectionBg && sectionBg !== 'rgba(0, 0, 0, 0)' && sectionBg !== 'transparent') {
+ var rgbaMatch = sectionBg.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
+ if (rgbaMatch) {
+ var r = parseInt(rgbaMatch[1]);
+ var g = parseInt(rgbaMatch[2]);
+ var b = parseInt(rgbaMatch[3]);
+ var alpha = rgbaMatch[4] ? parseFloat(rgbaMatch[4]) : 1;
+ if (alpha < 0.95) {
+ tooltip.style.backgroundColor = 'rgb(' + r + ', ' + g + ', ' + b + ')';
+ } else {
+ tooltip.style.backgroundColor = sectionBg;
+ }
+ } else {
+ tooltip.style.backgroundColor = sectionBg;
+ }
+ foundBgColor = true;
+ break;
+ }
+ }
+ // 如果无法获取背景色,CSS 会通过媒体查询自动处理暗色模式
+ if (!foundBgColor) {
+ // 不设置背景色,让 CSS 媒体查询处理
+ }
+ }
+
+ if (textColor && textColor !== 'rgba(0, 0, 0, 0)') {
+ tooltip.style.color = textColor;
+ } else {
+ // 如果无法获取文字颜色,从 cbi-section 获取
+ if (cbiSection) {
+ var sectionTextColor = window.getComputedStyle(cbiSection).color;
+ if (sectionTextColor && sectionTextColor !== 'rgba(0, 0, 0, 0)') {
+ tooltip.style.color = sectionTextColor;
+ }
+ // 否则使用 CSS 默认颜色(已通过媒体查询设置)
+ }
+ // 否则使用 CSS 默认颜色(已通过媒体查询设置)
+ }
+
+ // 边框和阴影由 CSS 媒体查询自动处理
+ } catch (e) {
+ // 如果出错,CSS 会通过媒体查询自动处理暗色模式
+ // 不设置样式,让 CSS 处理
+ }
+
+ // 先显示以计算尺寸
+ tooltip.style.display = 'block';
+ tooltip.style.left = '-9999px';
+ tooltip.style.top = '-9999px';
+ var tw = tooltip.offsetWidth || 0;
+ var th = tooltip.offsetHeight || 0;
+ var padding = 12;
+ var maxX = (typeof window !== 'undefined' ? window.innerWidth : document.documentElement.clientWidth) - 4;
+ var maxY = (typeof window !== 'undefined' ? window.innerHeight : document.documentElement.clientHeight) - 4;
+ var cx = evt.clientX;
+ var cy = evt.clientY;
+
+ // 检测是否为移动端
+ var isMobileScreen = maxX <= 768;
+
+ var baseX, baseY;
+ if (isMobileScreen) {
+ // 移动端:居中显示在触摸点下方
+ baseX = Math.max(4, Math.min(maxX - tw - 4, cx - tw / 2));
+ baseY = cy + padding; // 显示在触摸点下方
+ // 如果下方空间不足,显示在上方
+ if (baseY + th > maxY) {
+ baseY = cy - th - padding;
+ }
+ } else {
+ // PC端:右上(水平向右)
+ baseX = cx + padding;
+ baseY = cy - th - padding; // 上方
+ // 若右侧溢出,改为左上
+ if (baseX + tw > maxX) {
+ baseX = cx - tw - padding;
+ }
+ }
+ // 边界收缩
+ if (baseX < 4) baseX = 4;
+ if (baseY < 4) baseY = 4;
+
+ tooltip.style.left = baseX + 'px';
+ tooltip.style.top = baseY + 'px';
+ }
function onLeave() {
if (tooltip) tooltip.style.display = 'none';
@@ -3889,65 +5147,65 @@ function downsampleForMobile(data, labels, upSeries, downSeries, maxPoints) {
if (canvas && canvas.__bandixChart) {
delete canvas.__bandixChart.hoverIndex;
}
- try { drawHistoryChart(canvas, canvas.__bandixChart && canvas.__bandixChart.originalLabels ? canvas.__bandixChart.originalLabels : labels, canvas.__bandixChart && canvas.__bandixChart.originalUpSeries ? canvas.__bandixChart.originalUpSeries : upSeries, canvas.__bandixChart && canvas.__bandixChart.originalDownSeries ? canvas.__bandixChart.originalDownSeries : downSeries, 1, 0); } catch(e){}
+ try { drawHistoryChart(canvas, canvas.__bandixChart && canvas.__bandixChart.originalLabels ? canvas.__bandixChart.originalLabels : labels, canvas.__bandixChart && canvas.__bandixChart.originalUpSeries ? canvas.__bandixChart.originalUpSeries : upSeries, canvas.__bandixChart && canvas.__bandixChart.originalDownSeries ? canvas.__bandixChart.originalDownSeries : downSeries, 1, 0); } catch (e) { }
}
// 鼠标进入事件:启动延迟计时器
- canvas.onmouseenter = function() {
+ canvas.onmouseenter = function () {
if (zoomTimer) clearTimeout(zoomTimer);
- zoomTimer = setTimeout(function() {
+ zoomTimer = setTimeout(function () {
zoomEnabled = true;
zoomTimer = null;
}, 1000); // 1秒后启用缩放
};
// 鼠标滚轮事件:处理缩放
- canvas.onwheel = function(evt) {
+ canvas.onwheel = function (evt) {
if (!zoomEnabled) return;
evt.preventDefault();
-
+
var delta = evt.deltaY > 0 ? 0.9 : 1.1;
var newScale = zoomScale * delta;
-
+
// 限制缩放范围
if (newScale < 1) newScale = 1;
if (newScale > 10) newScale = 10;
-
+
var rect = canvas.getBoundingClientRect();
var mouseX = evt.clientX - rect.left;
var info = canvas.__bandixChart;
if (!info || !info.originalLabels) return;
-
+
// 计算鼠标在数据中的相对位置
var relativeX = (mouseX - info.padding.left) / info.innerW;
var totalLen = info.originalLabels.length;
var mouseDataIndex = relativeX * totalLen;
-
+
// 调整偏移以保持鼠标位置为缩放中心
var oldVisibleLen = totalLen / zoomScale;
var newVisibleLen = totalLen / newScale;
var centerShift = (oldVisibleLen - newVisibleLen) * (mouseDataIndex / totalLen);
-
+
zoomScale = newScale;
zoomOffsetX = Math.max(0, Math.min(totalLen - newVisibleLen, zoomOffsetX + centerShift));
-
+
// 更新缩放倍率显示
updateZoomLevelDisplay();
-
+
// 重绘图表 - 保持当前的hover状态
- try {
- drawHistoryChart(canvas, info.originalLabels, info.originalUpSeries, info.originalDownSeries, zoomScale, zoomOffsetX);
+ try {
+ drawHistoryChart(canvas, info.originalLabels, info.originalUpSeries, info.originalDownSeries, zoomScale, zoomOffsetX);
// 如果有当前的hover索引,重新绘制虚线
if (typeof historyHoverIndex === 'number' && canvas.__bandixChart) {
canvas.__bandixChart.hoverIndex = historyHoverIndex;
}
- } catch(e){}
+ } catch (e) { }
};
// 检测是否为移动端
var screenWidth = window.innerWidth || document.documentElement.clientWidth;
var isMobileScreen = screenWidth <= 768;
-
+
// 移动端禁用悬浮功能,PC端启用
if (!isMobileScreen) {
canvas.onmousemove = onMove;
@@ -3974,34 +5232,34 @@ function downsampleForMobile(data, labels, upSeries, downSeries, maxPoints) {
var typeSel = document.getElementById('history-type-select');
var devSel = document.getElementById('history-device-select');
if (typeSel) typeSel.value = 'total';
-
+
// 初始化缩放倍率显示
updateZoomLevelDisplay();
-
+
// Tab 切换事件处理
var tabButtons = document.querySelectorAll('.history-tab');
-
+
// 确保找到了 tab 按钮
if (tabButtons.length === 0) {
console.warn('History tab buttons not found, retrying...');
setTimeout(initHistoryControls, 100);
return;
}
-
- tabButtons.forEach(function(btn) {
- btn.addEventListener('click', function() {
+
+ tabButtons.forEach(function (btn) {
+ btn.addEventListener('click', function () {
var range = this.getAttribute('data-range');
-
+
// 更新当前选择的时间范围
currentTimeRange = range;
localStorage.setItem('bandix_time_range', range);
-
+
// 更新 tab 状态
- tabButtons.forEach(function(b) {
+ tabButtons.forEach(function (b) {
b.classList.remove('active');
});
this.classList.add('active');
-
+
// 对于非实时时间范围,禁用 LAN 和 Total 选项(因为只有 WAN 数据)
if (range !== 'realtime') {
if (typeSel) {
@@ -4024,15 +5282,15 @@ function downsampleForMobile(data, labels, upSeries, downSeries, maxPoints) {
if (totalOption) totalOption.disabled = false;
}
}
-
+
// 刷新历史数据
refreshHistory();
});
});
-
+
// 恢复之前选择的时间范围
var savedRange = localStorage.getItem('bandix_time_range') || 'realtime';
-
+
// 移动端强制使用 realtime
var screenWidth = window.innerWidth || document.documentElement.clientWidth;
var isMobileScreen = screenWidth <= 768;
@@ -4043,8 +5301,8 @@ function downsampleForMobile(data, labels, upSeries, downSeries, maxPoints) {
} else {
currentTimeRange = savedRange;
}
-
- tabButtons.forEach(function(btn) {
+
+ tabButtons.forEach(function (btn) {
if (btn.getAttribute('data-range') === savedRange) {
btn.classList.add('active');
// 触发一次点击以应用选项禁用逻辑
@@ -4061,14 +5319,14 @@ function downsampleForMobile(data, labels, upSeries, downSeries, maxPoints) {
btn.classList.remove('active');
}
});
-
- function onFilterChange() {
- refreshHistory();
- // 同步刷新表格(立即生效,不等轮询)
- try { window.__bandixRenderTable && window.__bandixRenderTable(); } catch (e) {}
- }
- if (typeSel) typeSel.addEventListener('change', onFilterChange);
- if (devSel) devSel.addEventListener('change', onFilterChange);
+
+ function onFilterChange() {
+ refreshHistory();
+ // 同步刷新表格(立即生效,不等轮询)
+ try { window.__bandixRenderTable && window.__bandixRenderTable(); } catch (e) { }
+ }
+ if (typeSel) typeSel.addEventListener('change', onFilterChange);
+ if (devSel) devSel.addEventListener('change', onFilterChange);
window.addEventListener('resize', function () {
// 窗口大小改变时,重新刷新历史数据以应用移动端过滤逻辑
@@ -4078,7 +5336,7 @@ function downsampleForMobile(data, labels, upSeries, downSeries, maxPoints) {
// 首次加载
refreshHistory();
}
-
+
// 延迟执行以确保 DOM 已加载
setTimeout(initHistoryControls, 0);
@@ -4191,12 +5449,12 @@ function downsampleForMobile(data, labels, upSeries, downSeries, maxPoints) {
// 创建表头点击处理函数
function createSortableHeader(text, sortKey) {
- var th = E('th', {
+ var th = E('th', {
'class': 'sortable' + (currentSortBy === sortKey ? ' active ' + (currentSortOrder ? 'asc' : 'desc') : ''),
'data-sort': sortKey
}, text);
-
- th.addEventListener('click', function() {
+
+ th.addEventListener('click', function () {
var newSortBy = this.getAttribute('data-sort');
if (currentSortBy === newSortBy) {
// 同一列,切换升降序
@@ -4206,30 +5464,30 @@ function downsampleForMobile(data, labels, upSeries, downSeries, maxPoints) {
currentSortBy = newSortBy;
currentSortOrder = false; // 所有排序默认降序
}
-
+
// 保存状态
localStorage.setItem('bandix_sort_by', currentSortBy);
localStorage.setItem('bandix_sort_order', currentSortOrder.toString());
-
+
// 触发重新渲染
if (window.__bandixRenderTable) {
window.__bandixRenderTable();
}
});
-
+
return th;
}
-
+
// 创建分栏表头(速度 | 用量)
function createSplitHeader(text, speedKey, trafficKey) {
var th = E('th', {});
-
+
var header = E('div', { 'class': 'th-split-header' }, [
E('span', {}, text)
]);
-
+
var controls = E('div', { 'style': 'display: flex; align-items: center; gap: 4px;' });
-
+
// 速度排序按钮
var speedBtn = E('div', {
'class': 'th-split-section' + (currentSortBy === speedKey ? ' active' : ''),
@@ -4239,10 +5497,10 @@ function downsampleForMobile(data, labels, upSeries, downSeries, maxPoints) {
E('span', { 'class': 'th-split-icon' }, '⚡'),
E('span', { 'style': 'font-size: 0.75rem;' }, currentSortBy === speedKey ? (currentSortOrder ? '↑' : '↓') : '')
]);
-
+
// 分隔线
var divider = E('div', { 'class': 'th-split-divider' });
-
+
// 用量排序按钮
var trafficBtn = E('div', {
'class': 'th-split-section' + (currentSortBy === trafficKey ? ' active' : ''),
@@ -4252,15 +5510,15 @@ function downsampleForMobile(data, labels, upSeries, downSeries, maxPoints) {
E('span', { 'class': 'th-split-icon' }, '∑'),
E('span', { 'style': 'font-size: 0.75rem;' }, currentSortBy === trafficKey ? (currentSortOrder ? '↑' : '↓') : '')
]);
-
+
controls.appendChild(speedBtn);
controls.appendChild(divider);
controls.appendChild(trafficBtn);
header.appendChild(controls);
th.appendChild(header);
-
+
// 速度按钮点击事件
- speedBtn.addEventListener('click', function(e) {
+ speedBtn.addEventListener('click', function (e) {
e.stopPropagation();
var newSortBy = this.getAttribute('data-sort');
if (currentSortBy === newSortBy) {
@@ -4275,9 +5533,9 @@ function downsampleForMobile(data, labels, upSeries, downSeries, maxPoints) {
window.__bandixRenderTable();
}
});
-
+
// 用量按钮点击事件
- trafficBtn.addEventListener('click', function(e) {
+ trafficBtn.addEventListener('click', function (e) {
e.stopPropagation();
var newSortBy = this.getAttribute('data-sort');
if (currentSortBy === newSortBy) {
@@ -4292,7 +5550,7 @@ function downsampleForMobile(data, labels, upSeries, downSeries, maxPoints) {
window.__bandixRenderTable();
}
});
-
+
return th;
}
@@ -4311,25 +5569,25 @@ function downsampleForMobile(data, labels, upSeries, downSeries, maxPoints) {
]);
var tbody = table.querySelector('tbody');
-
+
// 创建移动端卡片容器
var cardsContainer = E('div', { 'class': 'device-list-cards' });
- // 过滤:按选择设备
- var selectedMac = (typeof document !== 'undefined' ? (document.getElementById('history-device-select')?.value || '') : '');
- var filteredDevices = (!selectedMac) ? stats.devices : stats.devices.filter(function(d){ return (d.mac === selectedMac); });
+ // 过滤:按选择设备
+ var selectedMac = (typeof document !== 'undefined' ? (document.getElementById('history-device-select')?.value || '') : '');
+ var filteredDevices = (!selectedMac) ? stats.devices : stats.devices.filter(function (d) { return (d.mac === selectedMac); });
- // 应用排序
- filteredDevices = sortDevices(filteredDevices, currentSortBy, currentSortOrder);
+ // 应用排序
+ filteredDevices = sortDevices(filteredDevices, currentSortBy, currentSortOrder);
- // 检查是否有任何设备有 IPv6 地址
- var hasAnyIPv6 = filteredDevices.some(function(device) {
- var lanIPv6 = filterLanIPv6(device.ipv6_addresses);
- return lanIPv6.length > 0;
- });
+ // 检查是否有任何设备有 IPv6 地址
+ var hasAnyIPv6 = filteredDevices.some(function (device) {
+ var lanIPv6 = filterLanIPv6(device.ipv6_addresses);
+ return lanIPv6.length > 0;
+ });
- // 填充数据
- filteredDevices.forEach(function (device) {
+ // 填充数据
+ filteredDevices.forEach(function (device) {
var isOnline = isDeviceOnline(device);
// 根据主题类型决定按钮显示内容
@@ -4346,41 +5604,41 @@ function downsampleForMobile(data, labels, upSeries, downSeries, maxPoints) {
showRateLimitModal(device);
});
- // 获取当前显示模式
- // 移动端强制使用 SimpleMode
- var screenWidth = window.innerWidth || document.documentElement.clientWidth;
- var isMobileScreen = screenWidth <= 768;
- var deviceMode = isMobileScreen ? 'simple' : (localStorage.getItem('bandix_device_mode') || 'simple');
- var isDetailedMode = deviceMode === 'detailed';
+ // 获取当前显示模式
+ // 移动端强制使用 SimpleMode
+ var screenWidth = window.innerWidth || document.documentElement.clientWidth;
+ var isMobileScreen = screenWidth <= 768;
+ var deviceMode = isMobileScreen ? 'simple' : (localStorage.getItem('bandix_device_mode') || 'simple');
+ var isDetailedMode = deviceMode === 'detailed';
- // 构建设备信息元素
- var deviceInfoElements = [
- E('div', { 'class': 'device-name' }, [
- E('span', {
- 'class': 'device-status ' + (isOnline ? 'online' : 'offline')
- }),
- device.hostname || '-'
- ]),
- E('div', { 'class': 'device-ip' }, device.ip)
- ];
+ // 构建设备信息元素
+ var deviceInfoElements = [
+ E('div', { 'class': 'device-name' }, [
+ E('span', {
+ 'class': 'device-status ' + (isOnline ? 'online' : 'offline')
+ }),
+ device.hostname || '-'
+ ]),
+ E('div', { 'class': 'device-ip' }, device.ip)
+ ];
- // 详细模式下显示更多信息
- if (isDetailedMode) {
- // 只有当有设备有 IPv6 时才添加 IPv6 行
- if (hasAnyIPv6) {
- var lanIPv6 = filterLanIPv6(device.ipv6_addresses);
- if (lanIPv6.length > 0) {
- var allIPv6 = device.ipv6_addresses ? device.ipv6_addresses.join(', ') : '';
- deviceInfoElements.push(E('div', {
- 'class': 'device-ipv6',
- 'title': allIPv6
- }, lanIPv6.join(', ')));
- } else {
- deviceInfoElements.push(E('div', { 'class': 'device-ipv6' }, '-'));
- }
- }
+ // 详细模式下显示更多信息
+ if (isDetailedMode) {
+ // 只有当有设备有 IPv6 时才添加 IPv6 行
+ if (hasAnyIPv6) {
+ var lanIPv6 = filterLanIPv6(device.ipv6_addresses);
+ if (lanIPv6.length > 0) {
+ var allIPv6 = device.ipv6_addresses ? device.ipv6_addresses.join(', ') : '';
+ deviceInfoElements.push(E('div', {
+ 'class': 'device-ipv6',
+ 'title': allIPv6
+ }, lanIPv6.join(', ')));
+ } else {
+ deviceInfoElements.push(E('div', { 'class': 'device-ipv6' }, '-'));
+ }
+ }
- // 添加 MAC 和最后上线信息
+ // 添加 MAC 和最后上线信息
deviceInfoElements.push(
E('div', { 'class': 'device-mac' }, device.mac),
E('div', { 'class': 'device-last-online' }, [
@@ -4389,7 +5647,7 @@ function downsampleForMobile(data, labels, upSeries, downSeries, maxPoints) {
E('span', { 'class': 'device-last-online-exact' }, formatLastOnlineExactTime(device.last_online_ts))
])
);
- }
+ }
var row = E('tr', {}, [
// 设备信息
@@ -4430,64 +5688,64 @@ function downsampleForMobile(data, labels, upSeries, downSeries, maxPoints) {
]),
// 定时限速规则
- (function() {
+ (function () {
var activeRules = getActiveRulesForDevice(device.mac);
- var allDeviceRules = allScheduleRules.filter(function(r) { return r && r.mac === device.mac; });
-
+ var allDeviceRules = allScheduleRules.filter(function (r) { return r && r.mac === device.mac; });
+
var rulesInfo = E('div', { 'class': 'schedule-rules-info' }, []);
-
+
if (allDeviceRules.length === 0) {
rulesInfo.appendChild(E('div', { 'style': 'font-size: 0.75rem; opacity: 0.6;' }, '-'));
} else {
// 显示规则总数
- rulesInfo.appendChild(E('div', {
- 'style': 'font-size: 0.75rem; font-weight: 600; margin-bottom: 4px;'
+ rulesInfo.appendChild(E('div', {
+ 'style': 'font-size: 0.75rem; font-weight: 600; margin-bottom: 4px;'
}, allDeviceRules.length + ' ' + (allDeviceRules.length === 1 ? _('rule') : _('rules'))));
-
+
// 显示当前生效的规则
if (activeRules.length > 0) {
// 合并多个规则的限制值
var mergedLimits = mergeActiveRulesLimits(activeRules);
var uploadLimit = mergedLimits.uploadLimit;
var downloadLimit = mergedLimits.downloadLimit;
-
+
// 显示限速值(箭头固定颜色,文字默认颜色)
- var limitsContainer = E('div', {
- 'style': 'font-size: 0.75rem; display: flex; align-items: center; gap: 8px; flex-wrap: wrap;'
+ var limitsContainer = E('div', {
+ 'style': 'font-size: 0.75rem; display: flex; align-items: center; gap: 8px; flex-wrap: wrap;'
});
-
+
// 上传限速(橙色箭头)
var uploadSpan = E('span', {});
uploadSpan.appendChild(E('span', { 'style': 'color: #f97316;' }, '↑'));
uploadSpan.appendChild(document.createTextNode(uploadLimit > 0 ? formatByterate(uploadLimit, speedUnit) : _('Unlimited')));
limitsContainer.appendChild(uploadSpan);
-
+
// 下载限速(青色箭头)
var downloadSpan = E('span', {});
downloadSpan.appendChild(E('span', { 'style': 'color: #06b6d4;' }, '↓'));
downloadSpan.appendChild(document.createTextNode(downloadLimit > 0 ? formatByterate(downloadLimit, speedUnit) : _('Unlimited')));
limitsContainer.appendChild(downloadSpan);
-
+
rulesInfo.appendChild(limitsContainer);
} else {
- rulesInfo.appendChild(E('div', {
- 'style': 'font-size: 0.75rem; opacity: 0.5;'
+ rulesInfo.appendChild(E('div', {
+ 'style': 'font-size: 0.75rem; opacity: 0.5;'
}, _('No active rule')));
}
}
-
+
// PC 端添加鼠标悬浮事件(显示所有规则)- 只要有规则就绑定事件
var screenWidth = window.innerWidth || document.documentElement.clientWidth;
if (screenWidth > 768 && allDeviceRules.length > 0) {
- rulesInfo.onmouseenter = function(evt) {
+ rulesInfo.onmouseenter = function (evt) {
var tooltip = document.getElementById('schedule-rules-tooltip');
if (!tooltip) return;
-
+
var html = buildScheduleRulesTooltipHtml(allDeviceRules, activeRules, speedUnit);
if (!html) return;
-
+
tooltip.innerHTML = html;
-
+
// 应用主题颜色
try {
var cbiSection = document.querySelector('.cbi-section');
@@ -4495,7 +5753,7 @@ function downsampleForMobile(data, labels, upSeries, downSeries, maxPoints) {
var computedStyle = window.getComputedStyle(targetElement);
var bgColor = computedStyle.backgroundColor;
var textColor = computedStyle.color;
-
+
if (bgColor && bgColor !== 'rgba(0, 0, 0, 0)' && bgColor !== 'transparent') {
var rgbaMatch = bgColor.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
if (rgbaMatch) {
@@ -4512,89 +5770,89 @@ function downsampleForMobile(data, labels, upSeries, downSeries, maxPoints) {
tooltip.style.backgroundColor = bgColor;
}
}
-
+
if (textColor && textColor !== 'rgba(0, 0, 0, 0)') {
tooltip.style.color = textColor;
}
- } catch(e) {}
-
+ } catch (e) { }
+
// 先隐藏,设置内容后再显示以计算尺寸
tooltip.style.display = 'block';
tooltip.style.visibility = 'hidden';
tooltip.style.left = '-9999px';
tooltip.style.top = '-9999px';
-
+
// 强制浏览器计算尺寸
var tw = tooltip.offsetWidth || 0;
var th = tooltip.offsetHeight || 0;
-
+
if (tw === 0 || th === 0) {
tooltip.style.display = 'none';
return;
}
-
+
tooltip.style.visibility = 'visible';
-
+
var padding = 12;
var maxX = window.innerWidth - 4;
var maxY = window.innerHeight - 4;
-
+
var rect = evt.currentTarget.getBoundingClientRect();
var cx = rect.left + rect.width / 2;
var cy = rect.top + rect.height / 2;
-
+
// 计算位置:优先显示在右侧,如果空间不足则显示在左侧
var baseX = cx + padding;
var baseY = cy - th / 2;
-
+
if (baseX + tw > maxX) {
baseX = cx - tw - padding;
}
-
+
if (baseY < 4) baseY = 4;
if (baseY + th > maxY) baseY = maxY - th - 4;
-
+
tooltip.style.left = baseX + 'px';
tooltip.style.top = baseY + 'px';
};
-
- rulesInfo.onmouseleave = function() {
+
+ rulesInfo.onmouseleave = function () {
var tooltip = document.getElementById('schedule-rules-tooltip');
if (tooltip) {
tooltip.style.display = 'none';
tooltip.style.visibility = 'visible';
}
};
-
- rulesInfo.onmousemove = function(evt) {
+
+ rulesInfo.onmousemove = function (evt) {
var tooltip = document.getElementById('schedule-rules-tooltip');
if (!tooltip || tooltip.style.display === 'none') return;
-
+
var tw = tooltip.offsetWidth || 0;
var th = tooltip.offsetHeight || 0;
var padding = 12;
var maxX = window.innerWidth - 4;
var maxY = window.innerHeight - 4;
-
+
var rect = evt.currentTarget.getBoundingClientRect();
var cx = rect.left + rect.width / 2;
var cy = rect.top + rect.height / 2;
-
+
var baseX = cx + padding;
var baseY = cy - th / 2;
-
+
if (baseX + tw > maxX) {
baseX = cx - tw - padding;
}
-
+
if (baseY < 4) baseY = 4;
if (baseY + th > maxY) baseY = maxY - th - 4;
-
+
tooltip.style.left = baseX + 'px';
tooltip.style.top = baseY + 'px';
};
}
-
+
return E('td', {}, rulesInfo);
})(),
@@ -4605,7 +5863,7 @@ function downsampleForMobile(data, labels, upSeries, downSeries, maxPoints) {
]);
tbody.appendChild(row);
-
+
// 创建移动端卡片
var card = E('div', { 'class': 'device-card' }, [
// 卡片头部
@@ -4618,12 +5876,12 @@ function downsampleForMobile(data, labels, upSeries, downSeries, maxPoints) {
])
]),
E('div', { 'class': 'device-card-action' }, [
- (function() {
+ (function () {
var cardActionBtn = E('button', {
'class': 'cbi-button cbi-button-action',
'title': _('Settings')
}, buttonText);
- cardActionBtn.addEventListener('click', function() {
+ cardActionBtn.addEventListener('click', function () {
showRateLimitModal(device);
});
return cardActionBtn;
@@ -4650,44 +5908,44 @@ function downsampleForMobile(data, labels, upSeries, downSeries, maxPoints) {
])
]),
// 定时限速规则
- (function() {
+ (function () {
var activeRules = getActiveRulesForDevice(device.mac);
- var allDeviceRules = allScheduleRules.filter(function(r) { return r && r.mac === device.mac; });
-
+ var allDeviceRules = allScheduleRules.filter(function (r) { return r && r.mac === device.mac; });
+
if (allDeviceRules.length === 0) {
return E('div', { 'class': 'device-card-section device-card-rules' }, [
E('div', { 'class': 'device-card-section-label' }, _('Schedule Rules')),
E('div', { 'class': 'device-card-rules-empty' }, '-')
]);
}
-
+
var rulesContent = E('div', { 'class': 'device-card-rules-content' });
-
+
// 显示规则总数
- rulesContent.appendChild(E('div', {
- 'class': 'device-card-rules-count'
+ rulesContent.appendChild(E('div', {
+ 'class': 'device-card-rules-count'
}, allDeviceRules.length + ' ' + (allDeviceRules.length === 1 ? _('rule') : _('rules'))));
-
+
if (activeRules.length > 0) {
// 合并多个规则的限制值
var mergedLimits = mergeActiveRulesLimits(activeRules);
var uploadLimit = mergedLimits.uploadLimit;
var downloadLimit = mergedLimits.downloadLimit;
-
+
// 显示限速值
var limitsText = [];
limitsText.push('↑' + (uploadLimit > 0 ? formatByterate(uploadLimit, speedUnit) : _('Unlimited')));
limitsText.push('↓' + (downloadLimit > 0 ? formatByterate(downloadLimit, speedUnit) : _('Unlimited')));
-
- rulesContent.appendChild(E('div', {
- 'class': 'device-card-rules-active-time'
+
+ rulesContent.appendChild(E('div', {
+ 'class': 'device-card-rules-active-time'
}, limitsText.join(' ')));
} else {
- rulesContent.appendChild(E('div', {
- 'class': 'device-card-rules-inactive'
+ rulesContent.appendChild(E('div', {
+ 'class': 'device-card-rules-inactive'
}, _('No active rule')));
}
-
+
return E('div', { 'class': 'device-card-section device-card-rules' }, [
E('div', { 'class': 'device-card-section-label' }, _('Schedule Rules')),
rulesContent
@@ -4710,34 +5968,600 @@ function downsampleForMobile(data, labels, upSeries, downSeries, maxPoints) {
])
])
]);
-
+
cardsContainer.appendChild(card);
});
// 更新表格内容
- trafficDiv.innerHTML = '';
- trafficDiv.appendChild(table);
- trafficDiv.appendChild(cardsContainer);
- // 暴露一个立即重绘表格的函数,供筛选变化时调用
- try { window.__bandixRenderTable = function(){
- // 重新触发完整的数据更新和渲染
- updateDeviceData();
- }; } catch (e) {}
+ trafficDiv.innerHTML = '';
+ trafficDiv.appendChild(table);
+ trafficDiv.appendChild(cardsContainer);
+ // 暴露一个立即重绘表格的函数,供筛选变化时调用
+ try {
+ window.__bandixRenderTable = function () {
+ // 重新触发完整的数据更新和渲染
+ updateDeviceData();
+ };
+ } catch (e) { }
// 更新历史趋势中的设备下拉
try {
latestDevices = stats.devices || [];
updateDeviceOptions(latestDevices);
- } catch (e) {}
+ } catch (e) { }
+ });
+ }
+
+ // 初始化统计控件(延迟执行,确保 DOM 已渲染)
+
+ // 更新统计数据
+ var usageRankingShowAll = false;
+ var usageRankingData = [];
+ var USAGE_RANKING_DEFAULT_LIMIT = 10;
+ var usageRankingCustomRange = null; // 存储自定义时间范围
+
+ // Traffic Timeline 独立的时间范围管理
+ var trafficIncrementsCustomRange = null;
+
+ // 格式化时间范围显示 - 总是显示完整日期时间
+ function formatTimeRange(startMs, endMs) {
+ if (!startMs || !endMs) return '';
+
+ var startDate = new Date(startMs);
+ var endDate = new Date(endMs);
+
+ var formatDateTime = function(date) {
+ var year = date.getFullYear();
+ var month = (date.getMonth() + 1).toString().padStart(2, '0');
+ var day = date.getDate().toString().padStart(2, '0');
+ var hours = date.getHours().toString().padStart(2, '0');
+ var minutes = date.getMinutes().toString().padStart(2, '0');
+ return year + '/' + month + '/' + day + ' ' + hours + ':' + minutes;
+ };
+
+ return formatDateTime(startDate) + ' - ' + formatDateTime(endDate);
+ }
+
+ function renderUsageRanking(data, showAll) {
+ var container = document.getElementById('usage-ranking-container');
+ if (!container) return;
+
+ if (data.length === 0) {
+ container.innerHTML = '
' + _('No data') + '
';
+ return;
+ }
+
+ var displayData = showAll ? data : data.slice(0, USAGE_RANKING_DEFAULT_LIMIT);
+ var rankingList = E('div', { 'class': 'usage-ranking-list' });
+
+ displayData.forEach(function (item) {
+ var rankingItem = E('div', {
+ 'class': 'usage-ranking-item',
+ 'style': '--progress-width: ' + (item.percentage || 0) + '%;'
+ }, [
+ E('div', { 'class': 'usage-ranking-rank' }, (item.rank || '-')),
+ E('div', { 'class': 'usage-ranking-info' }, [
+ E('div', { 'class': 'usage-ranking-device' }, [
+ E('div', { 'class': 'usage-ranking-name' }, item.hostname || item.ip || item.mac || '-'),
+ E('div', { 'class': 'usage-ranking-meta' }, [
+ E('span', {}, item.ip || '-'),
+ E('span', {}, item.mac || '-'),
+ E('span', { 'class': 'usage-ranking-meta-total' }, formatSize(item.total_bytes || 0))
+ ])
+ ]),
+ E('div', { 'class': 'usage-ranking-stats' }, [
+ E('div', { 'class': 'usage-ranking-traffic' }, [
+ E('span', { 'class': 'usage-ranking-traffic-item tx' }, [
+ E('span', { 'class': 'usage-ranking-traffic-arrow' }, '↑'),
+ E('span', {}, formatSize(item.tx_bytes || 0))
+ ]),
+ E('span', { 'class': 'usage-ranking-traffic-item rx' }, [
+ E('span', { 'class': 'usage-ranking-traffic-arrow' }, '↓'),
+ E('span', {}, formatSize(item.rx_bytes || 0))
+ ]),
+ E('span', { 'class': 'usage-ranking-traffic-item total' }, [
+ E('span', {}, formatSize(item.total_bytes || 0))
+ ])
+ ]),
+ E('div', { 'class': 'usage-ranking-percentage' }, (item.percentage || 0).toFixed(1) + '%')
+ ])
+ ])
+ ]);
+
+ rankingList.appendChild(rankingItem);
+ });
+
+ container.innerHTML = '';
+ container.appendChild(rankingList);
+
+ // 如果设备数量超过默认限制,显示控制栏
+ if (data.length > USAGE_RANKING_DEFAULT_LIMIT) {
+ var controls = E('div', { 'class': 'usage-ranking-controls' }, [
+ E('span', { 'class': 'usage-ranking-info-text' },
+ showAll
+ ? _('Showing all %d devices').format(data.length)
+ : _('Showing top %d of %d devices').format(displayData.length, data.length)
+ ),
+ E('button', {
+ 'class': 'usage-ranking-toggle-btn',
+ 'onclick': function () {
+ usageRankingShowAll = !usageRankingShowAll;
+ renderUsageRanking(usageRankingData, usageRankingShowAll);
+ }
+ }, showAll ? _('Show Top %d').format(USAGE_RANKING_DEFAULT_LIMIT) : _('Show All'))
+ ]);
+ container.appendChild(controls);
+ }
+ }
+
+ function updateTrafficStatistics(customRange, callback) {
+ // 准备查询参数
+ var startMs = null;
+ var endMs = null;
+ if (customRange && customRange.start_ms && customRange.end_ms) {
+ startMs = customRange.start_ms;
+ endMs = customRange.end_ms;
+ console.log('Querying with custom range:', { start_ms: startMs, end_ms: endMs });
+ } else {
+ console.log('Querying with default range (no params)');
+ }
+
+ // 获取设备用量排行
+ callGetTrafficUsageRanking(startMs, endMs).then(function (result) {
+ console.log('Query result:', result);
+ if (!result || !result.rankings) {
+ return;
+ }
+
+ usageRankingData = result.rankings;
+
+ // 更新时间范围显示(包含上下行流量和总流量)
+ var timeRangeEl = document.getElementById('usage-ranking-timerange');
+ if (timeRangeEl && result.start_ms && result.end_ms) {
+ var timeRangeText = formatTimeRange(result.start_ms, result.end_ms);
+ var parts = [];
+ if (result.total_tx_bytes !== undefined && result.total_tx_bytes !== null) {
+ parts.push('↑' + formatSize(result.total_tx_bytes));
+ }
+ if (result.total_rx_bytes !== undefined && result.total_rx_bytes !== null) {
+ parts.push('↓' + formatSize(result.total_rx_bytes));
+ }
+ if (result.total_bytes !== undefined && result.total_bytes !== null) {
+ parts.push(formatSize(result.total_bytes));
+ }
+ if (parts.length > 0) {
+ timeRangeText += ' · ' + parts.join(' · ');
+ }
+ timeRangeEl.textContent = timeRangeText;
+ }
+
+ // 更新设备下拉框
+ updateDeviceSelectForIncrements(result.rankings || []);
+
+ renderUsageRanking(usageRankingData, usageRankingShowAll);
+
+ // 调用回调函数
+ if (callback) callback();
+ }).catch(function (err) {
+ console.error('Failed to load usage ranking:', err);
+ var container = document.getElementById('usage-ranking-container');
+ if (container) {
+ container.innerHTML = '
' + _('Failed to load data') + '
';
+ }
+
+ // 调用回调函数(即使失败也要移除 loading)
+ if (callback) callback();
+ });
+
+ }
+
+ // 更新时间序列增量数据(使用 Traffic Timeline 自己的时间范围)
+ function updateTrafficIncrements(startMs, endMs, aggregation, mac, callback) {
+ // 如果没有传入时间范围,使用独立的时间范围变量
+ if (!startMs || !endMs) {
+ if (trafficIncrementsCustomRange) {
+ startMs = trafficIncrementsCustomRange.start_ms;
+ endMs = trafficIncrementsCustomRange.end_ms;
+ }
+ }
+
+ // 获取筛选条件
+ var aggregationSelect = document.getElementById('traffic-increments-aggregation');
+ var macSelect = document.getElementById('traffic-increments-mac');
+
+ var selectedAggregation = aggregation || (aggregationSelect ? aggregationSelect.value : 'hourly');
+ var selectedMac = mac || (macSelect ? macSelect.value : 'all');
+
+ // 如果选择的是 "all",传递 null 使用默认值
+ if (selectedMac === 'all') {
+ selectedMac = null;
+ }
+
+ callGetTrafficUsageIncrements(startMs, endMs, selectedAggregation, selectedMac).then(function (result) {
+ if (!result || !result.increments) {
+ return;
+ }
+
+ // 更新时间范围显示(包含上下行流量和总流量)
+ var timeRangeEl = document.getElementById('traffic-increments-timerange');
+ if (timeRangeEl && result.start_ms && result.end_ms) {
+ var timeRangeText = formatTimeRange(result.start_ms, result.end_ms);
+ var parts = [];
+ if (result.total_tx_bytes !== undefined && result.total_tx_bytes !== null) {
+ parts.push('↑' + formatSize(result.total_tx_bytes));
+ }
+ if (result.total_rx_bytes !== undefined && result.total_rx_bytes !== null) {
+ parts.push('↓' + formatSize(result.total_rx_bytes));
+ }
+ if (result.total_bytes !== undefined && result.total_bytes !== null) {
+ parts.push(formatSize(result.total_bytes));
+ }
+ if (parts.length > 0) {
+ timeRangeText += ' · ' + parts.join(' · ');
+ }
+ timeRangeEl.textContent = timeRangeText;
+ }
+
+ var container = document.getElementById('traffic-increments-container');
+ if (!container) return;
+
+ if (result.increments.length === 0) {
+ container.innerHTML = '
' + _('No data') + '
';
+ return;
+ }
+
+ // 创建图表容器
+ var chartContainer = E('div', { 'class': 'traffic-increments-chart' });
+ var canvas = E('canvas', { 'id': 'traffic-increments-chart-canvas' });
+ var tooltip = E('div', { 'class': 'traffic-increments-tooltip', 'id': 'traffic-increments-tooltip' });
+ chartContainer.appendChild(canvas);
+ chartContainer.appendChild(tooltip);
+
+ // 创建图例
+ var legend = E('div', { 'class': 'traffic-stats-legend' }, [
+ E('div', { 'class': 'traffic-stats-legend-item' }, [
+ E('span', { 'class': 'traffic-stats-legend-dot rx' }),
+ E('span', {}, _('Download'))
+ ]),
+ E('div', { 'class': 'traffic-stats-legend-item' }, [
+ E('span', { 'class': 'traffic-stats-legend-dot tx' }),
+ E('span', {}, _('Upload'))
+ ])
+ ]);
+
+ // 创建汇总信息
+ var summary = E('div', { 'class': 'traffic-increments-summary' }, [
+ E('div', { 'class': 'traffic-increments-summary-item' }, [
+ E('div', { 'class': 'traffic-increments-summary-label' }, _('Total Upload')),
+ E('div', { 'class': 'traffic-increments-summary-value' }, formatSize(result.total_tx_bytes || 0))
+ ]),
+ E('div', { 'class': 'traffic-increments-summary-item' }, [
+ E('div', { 'class': 'traffic-increments-summary-label' }, _('Total Download')),
+ E('div', { 'class': 'traffic-increments-summary-value' }, formatSize(result.total_rx_bytes || 0))
+ ]),
+ E('div', { 'class': 'traffic-increments-summary-item' }, [
+ E('div', { 'class': 'traffic-increments-summary-label' }, _('Total')),
+ E('div', { 'class': 'traffic-increments-summary-value' }, formatSize(result.total_bytes || 0))
+ ])
+ ]);
+
+ container.innerHTML = '';
+ container.appendChild(chartContainer);
+ container.appendChild(legend);
+ container.appendChild(summary);
+
+ // 绘制图表
+ setTimeout(function () {
+ var aggregation = result.aggregation || 'hourly';
+ drawIncrementsChart(canvas, result.increments, aggregation);
+
+ // 添加鼠标悬浮事件
+ setupChartTooltip(canvas, tooltip, result.increments, aggregation);
+
+ // 调用回调函数
+ if (callback) callback();
+ }, 100);
+ }).catch(function (err) {
+ console.error('Failed to load traffic increments:', err);
+ var container = document.getElementById('traffic-increments-container');
+ if (container) {
+ container.innerHTML = '
' + _('Failed to load data') + '
';
+ }
+
+ // 调用回调函数(即使失败也要移除 loading)
+ if (callback) callback();
+ });
+ }
+
+ // 更新设备下拉框
+ function updateDeviceSelectForIncrements(rankings) {
+ var macSelect = document.getElementById('traffic-increments-mac');
+ if (!macSelect) return;
+
+ // 保存当前选中的值
+ var currentValue = macSelect.value;
+
+ // 清空现有选项(保留 "All Devices")
+ macSelect.innerHTML = '';
+ macSelect.appendChild(E('option', { 'value': 'all' }, _('All Devices')));
+
+ // 添加设备选项
+ rankings.forEach(function(item) {
+ if (item.mac && item.hostname) {
+ var option = E('option', { 'value': item.mac }, item.hostname + ' (' + item.mac + ')');
+ macSelect.appendChild(option);
+ }
+ });
+
+ // 恢复之前选中的值(如果还在列表中)
+ if (currentValue && currentValue !== 'all') {
+ var optionExists = false;
+ for (var i = 0; i < macSelect.options.length; i++) {
+ if (macSelect.options[i].value === currentValue) {
+ optionExists = true;
+ break;
+ }
+ }
+ if (optionExists) {
+ macSelect.value = currentValue;
+ } else {
+ macSelect.value = 'all';
+ }
+ }
+ }
+
+ // 绘制时间序列增量图表
+ function drawIncrementsChart(canvas, increments, aggregation) {
+ if (!canvas || !increments || increments.length === 0) return;
+
+ var ctx = canvas.getContext('2d');
+ var width = canvas.parentElement.offsetWidth || 600;
+ var height = 300;
+ canvas.width = width;
+ canvas.height = height;
+
+ var padding = { top: 20, right: 20, bottom: 40, left: 80 };
+ var chartWidth = width - padding.left - padding.right;
+ var chartHeight = height - padding.top - padding.bottom;
+
+ // 清空画布
+ ctx.clearRect(0, 0, width, height);
+
+ // 计算最大值
+ var maxValue = 0;
+ increments.forEach(function (item) {
+ maxValue = Math.max(maxValue, item.total_bytes || 0);
+ });
+
+ if (maxValue === 0) {
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
+ ctx.font = '14px sans-serif';
+ ctx.textAlign = 'center';
+ ctx.fillText(_('No data'), width / 2, height / 2);
+ return;
+ }
+
+ // 设置样式
+ var isDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
+ ctx.fillStyle = isDark ? 'rgba(255, 255, 255, 0.9)' : 'rgba(0, 0, 0, 0.9)';
+ ctx.strokeStyle = isDark ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.3)';
+ ctx.font = '12px sans-serif';
+
+ // 绘制坐标轴
+ ctx.beginPath();
+ ctx.moveTo(padding.left, padding.top);
+ ctx.lineTo(padding.left, height - padding.bottom);
+ ctx.lineTo(width - padding.right, height - padding.bottom);
+ ctx.stroke();
+
+ // 绘制网格线和标签
+ var gridLines = 5;
+ for (var i = 0; i <= gridLines; i++) {
+ var y = padding.top + (chartHeight / gridLines) * i;
+ var value = maxValue * (1 - i / gridLines);
+
+ ctx.beginPath();
+ ctx.moveTo(padding.left, y);
+ ctx.lineTo(width - padding.right, y);
+ ctx.strokeStyle = isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
+ ctx.stroke();
+
+ ctx.fillStyle = isDark ? 'rgba(255, 255, 255, 0.6)' : 'rgba(0, 0, 0, 0.6)';
+ ctx.textAlign = 'right';
+ ctx.fillText(formatSize(value), padding.left - 12, y + 4);
+ }
+
+ // 绘制堆叠柱状图
+ var barWidth = chartWidth / increments.length;
+ var barDisplayWidth = barWidth * 0.7; // 柱子显示宽度(留出间距)
+
+ var baseY = height - padding.bottom;
+
+ increments.forEach(function (item, index) {
+ var barX = padding.left + barWidth * index + (barWidth - barDisplayWidth) / 2;
+ var rxHeight = chartHeight * ((item.rx_bytes || 0) / maxValue);
+ var txHeight = chartHeight * ((item.tx_bytes || 0) / maxValue);
+ var totalHeight = rxHeight + txHeight;
+
+ // 绘制 RX 柱子(下载,蓝色)- 底部(带透明度)
+ if (rxHeight > 0) {
+ var rxY = baseY - totalHeight;
+ ctx.fillStyle = 'rgba(6, 182, 212, 0.7)';
+ ctx.fillRect(barX, rxY, barDisplayWidth, rxHeight);
+
+ // 添加边框
+ ctx.strokeStyle = 'rgba(8, 145, 178, 0.8)';
+ ctx.lineWidth = 1;
+ ctx.strokeRect(barX, rxY, barDisplayWidth, rxHeight);
+ }
+
+ // 绘制 TX 柱子(上传,橙色)- 堆叠在 RX 上面(带透明度)
+ if (txHeight > 0) {
+ var txY = baseY - totalHeight + rxHeight;
+ ctx.fillStyle = 'rgba(249, 115, 22, 0.7)';
+ ctx.fillRect(barX, txY, barDisplayWidth, txHeight);
+
+ // 添加边框
+ ctx.strokeStyle = 'rgba(234, 88, 12, 0.8)';
+ ctx.lineWidth = 1;
+ ctx.strokeRect(barX, txY, barDisplayWidth, txHeight);
+ }
+ });
+
+ // 保存柱子的位置信息,用于鼠标悬浮检测
+ canvas.barPositions = [];
+ increments.forEach(function (item, index) {
+ var barX = padding.left + barWidth * index + (barWidth - barDisplayWidth) / 2;
+ canvas.barPositions.push({
+ x: barX,
+ width: barDisplayWidth,
+ index: index,
+ item: item
+ });
+ });
+
+ // 绘制时间标签
+ ctx.fillStyle = isDark ? 'rgba(255, 255, 255, 0.6)' : 'rgba(0, 0, 0, 0.6)';
+ ctx.textAlign = 'center';
+ var isMobile = window.innerWidth <= 768;
+ var labelStep = Math.max(1, Math.floor(increments.length / 6));
+ var isDaily = aggregation === 'daily';
+ var barWidth = chartWidth / increments.length;
+
+ increments.forEach(function (item, index) {
+ var shouldShowLabel = false;
+
+ if (isMobile) {
+ // 移动端:只显示第一个和最后一个
+ shouldShowLabel = index === 0 || index === increments.length - 1;
+ } else {
+ // 桌面端:按原来的逻辑显示
+ shouldShowLabel = index % labelStep === 0 || index === increments.length - 1;
+ }
+
+ if (shouldShowLabel) {
+ // 标签居中显示在每个柱子组的中心
+ var x = padding.left + barWidth * (index + 0.5);
+ var date = new Date(item.ts_ms);
+ var timeStr;
+
+ if (isDaily) {
+ // 按天聚合:只显示日期
+ var year = date.getFullYear();
+ var month = (date.getMonth() + 1).toString().padStart(2, '0');
+ var day = date.getDate().toString().padStart(2, '0');
+ timeStr = month + '/' + day;
+ } else {
+ // 按小时聚合:显示时间
+ var hours = date.getHours();
+ var minutes = date.getMinutes();
+ timeStr = (hours < 10 ? '0' : '') + hours + ':' + (minutes < 10 ? '0' : '') + minutes;
+ }
+
+ ctx.fillText(timeStr, x, height - padding.bottom + 20);
+ }
+ });
+ }
+
+ // 设置图表 tooltip
+ function setupChartTooltip(canvas, tooltip, increments, aggregation) {
+ if (!canvas || !tooltip || !increments || increments.length === 0) return;
+
+ var formatTime = function(tsMs, isDaily) {
+ var date = new Date(tsMs);
+ if (isDaily) {
+ var year = date.getFullYear();
+ var month = (date.getMonth() + 1).toString().padStart(2, '0');
+ var day = date.getDate().toString().padStart(2, '0');
+ return year + '/' + month + '/' + day;
+ } else {
+ var year = date.getFullYear();
+ var month = (date.getMonth() + 1).toString().padStart(2, '0');
+ var day = date.getDate().toString().padStart(2, '0');
+ var hours = date.getHours().toString().padStart(2, '0');
+ var minutes = date.getMinutes().toString().padStart(2, '0');
+ return year + '/' + month + '/' + day + ' ' + hours + ':' + minutes;
+ }
+ };
+
+ var isDaily = aggregation === 'daily';
+ var padding = { top: 20, right: 20, bottom: 40, left: 80 };
+ var chartWidth = (canvas.parentElement.offsetWidth || 600) - padding.left - padding.right;
+ var barWidth = chartWidth / increments.length;
+ var barDisplayWidth = barWidth * 0.7;
+
+ canvas.addEventListener('mousemove', function(e) {
+ var rect = canvas.getBoundingClientRect();
+ var x = e.clientX - rect.left;
+ var y = e.clientY - rect.top;
+
+ // 检查鼠标是否在图表区域内
+ if (x < padding.left || x > rect.width - padding.right ||
+ y < padding.top || y > rect.height - padding.bottom) {
+ tooltip.style.display = 'none';
+ return;
+ }
+
+ // 找到对应的柱子
+ var barIndex = -1;
+ if (canvas.barPositions) {
+ for (var i = 0; i < canvas.barPositions.length; i++) {
+ var bar = canvas.barPositions[i];
+ if (x >= bar.x && x <= bar.x + bar.width) {
+ barIndex = bar.index;
+ break;
+ }
+ }
+ }
+
+ if (barIndex >= 0 && barIndex < increments.length) {
+ var item = increments[barIndex];
+ var timeStr = formatTime(item.ts_ms, isDaily);
+
+ tooltip.innerHTML =
+ '
' + timeStr + '
' +
+ '
' +
+ '' +
+ '' + _('Upload') + ': ' + formatSize(item.tx_bytes || 0) + '' +
+ '
' +
+ '
' +
+ '' +
+ '' + _('Download') + ': ' + formatSize(item.rx_bytes || 0) + '' +
+ '
' +
+ '
' +
+ '' + _('Total') + ': ' + formatSize(item.total_bytes || 0) + '' +
+ '
';
+
+ tooltip.style.display = 'block';
+ var tooltipX = e.clientX - rect.left + 10;
+ var tooltipY = e.clientY - rect.top - 10;
+
+ // 确保 tooltip 不超出画布边界
+ if (tooltipX + 200 > rect.width) {
+ tooltipX = e.clientX - rect.left - 200;
+ }
+ if (tooltipY + 100 > rect.height) {
+ tooltipY = e.clientY - rect.top - 100;
+ }
+
+ tooltip.style.left = tooltipX + 'px';
+ tooltip.style.top = tooltipY + 'px';
+ } else {
+ tooltip.style.display = 'none';
+ }
+ });
+
+ canvas.addEventListener('mouseleave', function() {
+ tooltip.style.display = 'none';
});
}
// 轮询获取数据
poll.add(updateDeviceData, 1);
-
+
// 轮询获取定时限速规则(每5秒)
- poll.add(function() {
- return fetchAllScheduleRules().then(function() {
+ poll.add(function () {
+ return fetchAllScheduleRules().then(function () {
// 规则更新后,重新渲染表格以显示最新的规则状态
if (window.__bandixRenderTable) {
window.__bandixRenderTable();
@@ -4745,42 +6569,422 @@ function downsampleForMobile(data, labels, upSeries, downSeries, maxPoints) {
});
}, 5000);
+ // Traffic Statistics 不再自动刷新,改为手动查询
+
// 立即执行一次,不等待轮询
updateDeviceData();
fetchAllScheduleRules();
+ updateTrafficStatistics();
+ // 初始化时间范围查询功能
+ setTimeout(function() {
+ var presetBtns = document.querySelectorAll('.usage-ranking-preset-btn');
+ var startDateInput = document.getElementById('usage-ranking-start-date');
+ var endDateInput = document.getElementById('usage-ranking-end-date');
+ var queryBtn = document.getElementById('usage-ranking-query-btn');
+ var resetBtn = document.getElementById('usage-ranking-reset-btn');
+ var timeline = document.getElementById('usage-ranking-timeline');
+ var timelineRange = document.getElementById('usage-ranking-timeline-range');
+
+ if (!presetBtns.length || !startDateInput || !endDateInput || !queryBtn || !resetBtn) {
+ console.error('Time range query elements not found');
+ return;
+ }
+
+ var today = new Date();
+ today.setHours(0, 0, 0, 0);
+ var todayMs = today.getTime();
+
+ var formatDateInput = function(date) {
+ var year = date.getFullYear();
+ var month = (date.getMonth() + 1).toString().padStart(2, '0');
+ var day = date.getDate().toString().padStart(2, '0');
+ return year + '-' + month + '-' + day;
+ };
+
+ // 设置最大日期为今天(不能选择未来)
+ var todayStr = formatDateInput(today);
+ startDateInput.max = todayStr;
+ endDateInput.max = todayStr;
+
+ var updateTimeline = function(startDate, endDate) {
+ if (!timeline || !timelineRange || !startDate || !endDate) return;
+
+ var startMs = new Date(startDate + 'T00:00:00').getTime();
+ var endMs = new Date(endDate + 'T23:59:59').getTime();
+
+ // 计算时间范围在时间轴上的位置(假设时间轴代表最近一年)
+ var oneYearAgoMs = todayMs - 365 * 24 * 60 * 60 * 1000;
+ var totalRange = todayMs - oneYearAgoMs;
+ var selectedRange = endMs - startMs;
+
+ var leftPercent = Math.max(0, ((startMs - oneYearAgoMs) / totalRange) * 100);
+ var widthPercent = Math.min(100, (selectedRange / totalRange) * 100);
+
+ timelineRange.style.left = leftPercent + '%';
+ timelineRange.style.width = widthPercent + '%';
+ };
+
+ var setDateRange = function(startDate, endDate, preset) {
+ startDateInput.value = formatDateInput(new Date(startDate));
+ endDateInput.value = formatDateInput(new Date(endDate));
+
+ // 更新快捷按钮状态
+ presetBtns.forEach(function(btn) {
+ btn.classList.remove('active');
+ });
+ if (preset) {
+ var presetBtn = document.querySelector('.usage-ranking-preset-btn[data-preset="' + preset + '"]');
+ if (presetBtn) presetBtn.classList.add('active');
+ }
+
+ // 更新时间轴
+ updateTimeline(startDateInput.value, endDateInput.value);
+ };
+
+ var queryData = function() {
+ var startDate = startDateInput.value;
+ var endDate = endDateInput.value;
+
+ if (!startDate || !endDate) {
+ alert(_('Please select both start and end dates'));
+ return;
+ }
+
+ var startMs = new Date(startDate + 'T00:00:00').getTime();
+ var endMs = new Date(endDate + 'T23:59:59').getTime();
+
+ if (startMs > endMs) {
+ alert(_('Start date must be earlier than end date'));
+ return;
+ }
+
+ usageRankingCustomRange = {
+ start_ms: startMs,
+ end_ms: endMs
+ };
+
+ // 设置 loading 状态
+ if (queryBtn) {
+ queryBtn.disabled = true;
+ queryBtn.classList.add('loading');
+ }
+
+ console.log('Querying with range:', usageRankingCustomRange);
+ updateTrafficStatistics(usageRankingCustomRange, function() {
+ // 查询完成后移除 loading 状态
+ if (queryBtn) {
+ queryBtn.disabled = false;
+ queryBtn.classList.remove('loading');
+ }
+ });
+ };
+
+ // 快捷选项按钮事件
+ presetBtns.forEach(function(btn) {
+ btn.addEventListener('click', function() {
+ var preset = this.getAttribute('data-preset');
+ var startDate, endDate;
+
+ switch(preset) {
+ case 'today':
+ startDate = new Date(today);
+ endDate = new Date(today);
+ break;
+ case 'thisweek':
+ // 本周:周一到今天
+ var dayOfWeek = today.getDay(); // 0=周日, 1=周一, ..., 6=周六
+ var mondayOffset = dayOfWeek === 0 ? -6 : 1 - dayOfWeek; // 计算到本周一的偏移天数
+ startDate = new Date(todayMs + mondayOffset * 24 * 60 * 60 * 1000);
+ startDate.setHours(0, 0, 0, 0);
+ endDate = new Date(today);
+ break;
+ case 'lastweek':
+ // 上周:周一到周日(完整的一周)
+ var lastWeekDayOfWeek = today.getDay();
+ // 计算到上周一的偏移天数
+ var lastWeekMondayOffset = lastWeekDayOfWeek === 0 ? -13 : -6 - lastWeekDayOfWeek;
+ startDate = new Date(todayMs + lastWeekMondayOffset * 24 * 60 * 60 * 1000);
+ startDate.setHours(0, 0, 0, 0);
+ endDate = new Date(startDate);
+ endDate.setDate(endDate.getDate() + 6); // 上周日(周一+6天)
+ endDate.setHours(23, 59, 59, 999);
+ break;
+ case 'thismonth':
+ // 本月:1号到今天
+ startDate = new Date(today.getFullYear(), today.getMonth(), 1);
+ endDate = new Date(today);
+ break;
+ case 'lastmonth':
+ // 上月:1号到最后一天
+ var lastMonth = new Date(today.getFullYear(), today.getMonth() - 1, 1);
+ startDate = new Date(lastMonth.getFullYear(), lastMonth.getMonth(), 1);
+ endDate = new Date(lastMonth.getFullYear(), lastMonth.getMonth() + 1, 0); // 上个月的最后一天
+ break;
+ case '7days':
+ startDate = new Date(todayMs - 6 * 24 * 60 * 60 * 1000);
+ endDate = new Date(today);
+ break;
+ case '30days':
+ startDate = new Date(todayMs - 29 * 24 * 60 * 60 * 1000);
+ endDate = new Date(today);
+ break;
+ case '90days':
+ startDate = new Date(todayMs - 89 * 24 * 60 * 60 * 1000);
+ endDate = new Date(today);
+ break;
+ case '1year':
+ startDate = new Date(today.getFullYear() - 1, today.getMonth(), today.getDate());
+ endDate = new Date(today);
+ break;
+ }
+
+ setDateRange(startDate, endDate, preset);
+ queryData();
+ });
+ });
+
+ // 日期输入变化事件
+ startDateInput.addEventListener('change', function() {
+ updateTimeline(this.value, endDateInput.value);
+ });
+
+ endDateInput.addEventListener('change', function() {
+ updateTimeline(startDateInput.value, this.value);
+ });
+
+ // 查询按钮
+ if (queryBtn) {
+ queryBtn.addEventListener('click', queryData);
+ }
+
+ // 重置按钮
+ if (resetBtn) {
+ resetBtn.addEventListener('click', function() {
+ var oneYearAgo = new Date(today.getFullYear() - 1, today.getMonth(), today.getDate());
+ setDateRange(oneYearAgo, today, '1year');
+ queryData();
+ });
+ }
+
+ // 初始化:默认选择最近一年
+ var oneYearAgo = new Date(today.getFullYear() - 1, today.getMonth(), today.getDate());
+ setDateRange(oneYearAgo, today, '1year');
+ }, 500);
+
+ // 初始化时间序列筛选条件
+ setTimeout(function() {
+ var aggregationSelect = document.getElementById('traffic-increments-aggregation');
+ var macSelect = document.getElementById('traffic-increments-mac');
+
+ if (aggregationSelect) {
+ aggregationSelect.addEventListener('change', function() {
+ // 使用 Traffic Timeline 自己的时间范围
+ updateTrafficIncrements();
+ });
+ }
+
+ if (macSelect) {
+ macSelect.addEventListener('change', function() {
+ // 使用 Traffic Timeline 自己的时间范围
+ updateTrafficIncrements();
+ });
+ }
+ }, 600);
+
+ // 初始化 Traffic Timeline 时间范围选择功能
+ setTimeout(function() {
+ var presetBtns = document.querySelectorAll('.traffic-increments-preset-btn');
+ var startDateInput = document.getElementById('traffic-increments-start-date');
+ var endDateInput = document.getElementById('traffic-increments-end-date');
+ var queryBtn = document.getElementById('traffic-increments-query-btn');
+ var resetBtn = document.getElementById('traffic-increments-reset-btn');
+
+ if (!presetBtns.length || !startDateInput || !endDateInput || !queryBtn || !resetBtn) {
+ console.error('Traffic Timeline time range query elements not found');
+ return;
+ }
+
+ var today = new Date();
+ today.setHours(0, 0, 0, 0);
+ var todayMs = today.getTime();
+
+ var formatDateInput = function(date) {
+ var year = date.getFullYear();
+ var month = (date.getMonth() + 1).toString().padStart(2, '0');
+ var day = date.getDate().toString().padStart(2, '0');
+ return year + '-' + month + '-' + day;
+ };
+
+ // 设置最大日期为今天(不能选择未来)
+ var todayStr = formatDateInput(today);
+ startDateInput.max = todayStr;
+ endDateInput.max = todayStr;
+
+ var setDateRange = function(startDate, endDate, preset) {
+ startDateInput.value = formatDateInput(new Date(startDate));
+ endDateInput.value = formatDateInput(new Date(endDate));
+
+ // 更新快捷按钮状态
+ presetBtns.forEach(function(btn) {
+ btn.classList.remove('active');
+ });
+ if (preset) {
+ var presetBtn = document.querySelector('.traffic-increments-preset-btn[data-preset="' + preset + '"]');
+ if (presetBtn) presetBtn.classList.add('active');
+ }
+ };
+
+ var queryData = function() {
+ var startDate = startDateInput.value;
+ var endDate = endDateInput.value;
+
+ if (!startDate || !endDate) {
+ alert(_('Please select both start and end dates'));
+ return;
+ }
+
+ var startMs = new Date(startDate + 'T00:00:00').getTime();
+ var endMs = new Date(endDate + 'T23:59:59').getTime();
+
+ if (startMs > endMs) {
+ alert(_('Start date must be earlier than end date'));
+ return;
+ }
+
+ trafficIncrementsCustomRange = {
+ start_ms: startMs,
+ end_ms: endMs
+ };
+
+ // 设置 loading 状态
+ if (queryBtn) {
+ queryBtn.disabled = true;
+ queryBtn.classList.add('loading');
+ }
+
+ console.log('Traffic Timeline querying with range:', trafficIncrementsCustomRange);
+ updateTrafficIncrements(startMs, endMs, null, null, function() {
+ // 查询完成后移除 loading 状态
+ if (queryBtn) {
+ queryBtn.disabled = false;
+ queryBtn.classList.remove('loading');
+ }
+ });
+ };
+
+ // 快捷选项按钮事件
+ presetBtns.forEach(function(btn) {
+ btn.addEventListener('click', function() {
+ var preset = this.getAttribute('data-preset');
+ var startDate, endDate;
+
+ switch(preset) {
+ case 'today':
+ startDate = new Date(today);
+ endDate = new Date(today);
+ break;
+ case 'thisweek':
+ var dayOfWeek = today.getDay();
+ var mondayOffset = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
+ startDate = new Date(todayMs + mondayOffset * 24 * 60 * 60 * 1000);
+ startDate.setHours(0, 0, 0, 0);
+ endDate = new Date(today);
+ break;
+ case 'lastweek':
+ var lastWeekDayOfWeek = today.getDay();
+ var lastWeekMondayOffset = lastWeekDayOfWeek === 0 ? -13 : -6 - lastWeekDayOfWeek;
+ startDate = new Date(todayMs + lastWeekMondayOffset * 24 * 60 * 60 * 1000);
+ startDate.setHours(0, 0, 0, 0);
+ endDate = new Date(startDate);
+ endDate.setDate(endDate.getDate() + 6);
+ endDate.setHours(23, 59, 59, 999);
+ break;
+ case 'thismonth':
+ startDate = new Date(today.getFullYear(), today.getMonth(), 1);
+ endDate = new Date(today);
+ break;
+ case 'lastmonth':
+ var lastMonth = new Date(today.getFullYear(), today.getMonth() - 1, 1);
+ startDate = new Date(lastMonth.getFullYear(), lastMonth.getMonth(), 1);
+ endDate = new Date(lastMonth.getFullYear(), lastMonth.getMonth() + 1, 0);
+ break;
+ case '7days':
+ startDate = new Date(todayMs - 6 * 24 * 60 * 60 * 1000);
+ endDate = new Date(today);
+ break;
+ case '30days':
+ startDate = new Date(todayMs - 29 * 24 * 60 * 60 * 1000);
+ endDate = new Date(today);
+ break;
+ case '90days':
+ startDate = new Date(todayMs - 89 * 24 * 60 * 60 * 1000);
+ endDate = new Date(today);
+ break;
+ case '1year':
+ startDate = new Date(today.getFullYear() - 1, today.getMonth(), today.getDate());
+ endDate = new Date(today);
+ break;
+ }
+
+ setDateRange(startDate, endDate, preset);
+ queryData();
+ });
+ });
+
+ // 查询按钮
+ if (queryBtn) {
+ queryBtn.addEventListener('click', queryData);
+ }
+
+ // 重置按钮
+ if (resetBtn) {
+ resetBtn.addEventListener('click', function() {
+ var oneYearAgo = new Date(today.getFullYear() - 1, today.getMonth(), today.getDate());
+ setDateRange(oneYearAgo, today, '1year');
+ queryData();
+ });
+ }
+
+ // 初始化:默认选择最近一年
+ var oneYearAgo = new Date(today.getFullYear() - 1, today.getMonth(), today.getDate());
+ setDateRange(oneYearAgo, today, '1year');
+ // 初始化时也查询一次数据
+ queryData();
+ }, 700);
+
// 异步加载版本信息(不阻塞主流程)
- (function() {
+ (function () {
// 延迟执行,确保页面先完成初始化
- setTimeout(function() {
- callGetVersion().then(function(result) {
+ setTimeout(function () {
+ callGetVersion().then(function (result) {
if (result) {
// 显示 luci-app-bandix 版本
var luciVersionEl = document.getElementById('bandix-luci-version');
if (luciVersionEl && result.luci_app_version) {
luciVersionEl.textContent = result.luci_app_version;
}
-
+
// 显示 bandix 版本
var coreVersionEl = document.getElementById('bandix-core-version');
if (coreVersionEl && result.bandix_version) {
coreVersionEl.textContent = result.bandix_version;
}
}
- }).catch(function(err) {
+ }).catch(function (err) {
// 静默失败,不影响页面功能
console.debug('Failed to load version:', err);
});
}, 100);
})();
-
+
// 异步检查更新(不阻塞主流程)
- (function() {
+ (function () {
// 延迟执行,确保页面先完成初始化,更新检查可能需要网络请求
- setTimeout(function() {
- callCheckUpdate().then(function(result) {
+ setTimeout(function () {
+ callCheckUpdate().then(function (result) {
if (!result) return;
-
+
// 检查是否有更新(luci-app-bandix 或 bandix)
var hasUpdate = false;
if (result.luci_has_update === true || result.luci_has_update === '1' || result.luci_has_update === 1) {
@@ -4789,14 +6993,14 @@ function downsampleForMobile(data, labels, upSeries, downSeries, maxPoints) {
if (result.bandix_has_update === true || result.bandix_has_update === '1' || result.bandix_has_update === 1) {
hasUpdate = true;
}
-
+
// 显示或隐藏更新提示
var updateBadge = document.getElementById('bandix-update-badge');
if (updateBadge) {
if (hasUpdate) {
updateBadge.style.display = 'inline-block';
// 点击跳转到设置页面
- updateBadge.onclick = function() {
+ updateBadge.onclick = function () {
window.location.href = '/cgi-bin/luci/admin/network/bandix/settings';
};
updateBadge.title = _('Update available, click to go to settings');
@@ -4804,7 +7008,7 @@ function downsampleForMobile(data, labels, upSeries, downSeries, maxPoints) {
updateBadge.style.display = 'none';
}
}
- }).catch(function(err) {
+ }).catch(function (err) {
// 静默失败,不影响页面功能
console.debug('Failed to check update:', err);
});
@@ -4820,7 +7024,7 @@ function downsampleForMobile(data, labels, upSeries, downSeries, maxPoints) {
var computedStyle = window.getComputedStyle(targetElement);
var bgColor = computedStyle.backgroundColor;
var textColor = computedStyle.color;
-
+
// 如果无法获取背景色,尝试从其他 cbi-section 获取
if (!bgColor || bgColor === 'rgba(0, 0, 0, 0)' || bgColor === 'transparent') {
var allCbiSections = document.querySelectorAll('.cbi-section');
@@ -4834,7 +7038,7 @@ function downsampleForMobile(data, labels, upSeries, downSeries, maxPoints) {
}
}
}
-
+
// 只应用到模态框和 tooltip,不修改页面其他元素
if (bgColor && bgColor !== 'rgba(0, 0, 0, 0)' && bgColor !== 'transparent') {
// 应用到模态框(确保不透明)
@@ -4855,10 +7059,10 @@ function downsampleForMobile(data, labels, upSeries, downSeries, maxPoints) {
modal.style.backgroundColor = bgColor;
}
}
-
+
// 应用到 tooltip(包括所有 tooltip 实例)
var tooltips = document.querySelectorAll('.history-tooltip');
- tooltips.forEach(function(tooltip) {
+ tooltips.forEach(function (tooltip) {
var rgbaMatch = bgColor.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
if (rgbaMatch) {
var r = parseInt(rgbaMatch[1]);
@@ -4875,7 +7079,7 @@ function downsampleForMobile(data, labels, upSeries, downSeries, maxPoints) {
}
});
}
-
+
// 检测文字颜色并应用(仅应用到模态框和 tooltip)
if (textColor && textColor !== 'rgba(0, 0, 0, 0)') {
// 应用到模态框的文字颜色
@@ -4883,10 +7087,10 @@ function downsampleForMobile(data, labels, upSeries, downSeries, maxPoints) {
if (modal) {
modal.style.color = textColor;
}
-
+
// 应用到 tooltip 的文字颜色
var tooltips = document.querySelectorAll('.history-tooltip');
- tooltips.forEach(function(tooltip) {
+ tooltips.forEach(function (tooltip) {
tooltip.style.color = textColor;
});
}
@@ -4895,17 +7099,17 @@ function downsampleForMobile(data, labels, upSeries, downSeries, maxPoints) {
console.log('Theme adaptation:', e);
}
}
-
+
// 初始应用主题颜色
setTimeout(applyThemeColors, 100);
-
+
// 监听 DOM 变化,自动应用到新创建的元素
if (typeof MutationObserver !== 'undefined') {
- var observer = new MutationObserver(function(mutations) {
+ var observer = new MutationObserver(function (mutations) {
applyThemeColors();
});
-
- setTimeout(function() {
+
+ setTimeout(function () {
var container = document.querySelector('.bandix-container');
if (container) {
observer.observe(container, {
diff --git a/luci-app-bandix/po/es/bandix.po b/luci-app-bandix/po/es/bandix.po
index 6971bbe..e2b306c 100644
--- a/luci-app-bandix/po/es/bandix.po
+++ b/luci-app-bandix/po/es/bandix.po
@@ -1021,3 +1021,57 @@ msgstr "Iniciando instalación... La página se actualizará automáticamente en
msgid "Please clear your browser cache manually after updating."
msgstr "Por favor, borre manualmente la caché del navegador después de actualizar."
+
+msgid "Traffic Statistics"
+msgstr "Estadísticas de Tráfico"
+
+msgid "Traffic Statistics (WAN Only)"
+msgstr "Estadísticas de Tráfico (Solo WAN)"
+
+msgid "Device Usage Ranking"
+msgstr "Clasificación de Uso de Dispositivos"
+
+msgid "(Data has 1 hour delay)"
+msgstr "(Los datos tienen 1 hora de retraso)"
+
+msgid "Start Date"
+msgstr "Fecha de Inicio"
+
+msgid "End Date"
+msgstr "Fecha de Fin"
+
+msgid "Reset"
+msgstr "Restablecer"
+
+msgid "Today"
+msgstr "Hoy"
+
+msgid "This Week"
+msgstr "Esta Semana"
+
+msgid "Last Week"
+msgstr "Semana Pasada"
+
+msgid "This Month"
+msgstr "Este Mes"
+
+msgid "Last Month"
+msgstr "Mes Pasado"
+
+msgid "Last 90 Days"
+msgstr "Últimos 90 Días"
+
+msgid "Last Year"
+msgstr "Último Año"
+
+msgid "Traffic Timeline"
+msgstr "Línea de Tiempo de Tráfico"
+
+msgid "Aggregation:"
+msgstr "Agregación:"
+
+msgid "Hourly"
+msgstr "Por Hora"
+
+msgid "Device:"
+msgstr "Dispositivo:"
diff --git a/luci-app-bandix/po/fr/bandix.po b/luci-app-bandix/po/fr/bandix.po
index 18862fe..07e8383 100644
--- a/luci-app-bandix/po/fr/bandix.po
+++ b/luci-app-bandix/po/fr/bandix.po
@@ -1020,4 +1020,58 @@ msgid "Starting installation... The page will refresh automatically in 5 seconds
msgstr "Démarrage de l'installation... La page sera actualisée automatiquement dans 5 secondes."
msgid "Please clear your browser cache manually after updating."
-msgstr "Veuillez vider manuellement le cache du navigateur après la mise à jour."
\ No newline at end of file
+msgstr "Veuillez vider manuellement le cache du navigateur après la mise à jour."
+
+msgid "Traffic Statistics"
+msgstr "Statistiques de Trafic"
+
+msgid "Traffic Statistics (WAN Only)"
+msgstr "Statistiques de Trafic (WAN uniquement)"
+
+msgid "Device Usage Ranking"
+msgstr "Classement d'Utilisation des Appareils"
+
+msgid "(Data has 1 hour delay)"
+msgstr "(Les données ont 1 heure de retard)"
+
+msgid "Start Date"
+msgstr "Date de Début"
+
+msgid "End Date"
+msgstr "Date de Fin"
+
+msgid "Reset"
+msgstr "Réinitialiser"
+
+msgid "Today"
+msgstr "Aujourd'hui"
+
+msgid "This Week"
+msgstr "Cette Semaine"
+
+msgid "Last Week"
+msgstr "Semaine Dernière"
+
+msgid "This Month"
+msgstr "Ce Mois"
+
+msgid "Last Month"
+msgstr "Mois Dernier"
+
+msgid "Last 90 Days"
+msgstr "90 Derniers Jours"
+
+msgid "Last Year"
+msgstr "Dernière Année"
+
+msgid "Traffic Timeline"
+msgstr "Chronologie du Trafic"
+
+msgid "Aggregation:"
+msgstr "Agrégation :"
+
+msgid "Hourly"
+msgstr "Par Heure"
+
+msgid "Device:"
+msgstr "Appareil :"
\ No newline at end of file
diff --git a/luci-app-bandix/po/id/bandix.po b/luci-app-bandix/po/id/bandix.po
index 885a019..3b092b5 100644
--- a/luci-app-bandix/po/id/bandix.po
+++ b/luci-app-bandix/po/id/bandix.po
@@ -1021,3 +1021,57 @@ msgstr "Memulai instalasi... Halaman akan dimuat ulang secara otomatis dalam 5 d
msgid "Please clear your browser cache manually after updating."
msgstr "Harap hapus cache browser secara manual setelah memperbarui."
+
+msgid "Traffic Statistics"
+msgstr "Statistik Lalu Lintas"
+
+msgid "Traffic Statistics (WAN Only)"
+msgstr "Statistik Lalu Lintas (Hanya WAN)"
+
+msgid "Device Usage Ranking"
+msgstr "Peringkat Penggunaan Perangkat"
+
+msgid "(Data has 1 hour delay)"
+msgstr "(Data memiliki penundaan 1 jam)"
+
+msgid "Start Date"
+msgstr "Tanggal Mulai"
+
+msgid "End Date"
+msgstr "Tanggal Akhir"
+
+msgid "Reset"
+msgstr "Setel Ulang"
+
+msgid "Today"
+msgstr "Hari Ini"
+
+msgid "This Week"
+msgstr "Minggu Ini"
+
+msgid "Last Week"
+msgstr "Minggu Lalu"
+
+msgid "This Month"
+msgstr "Bulan Ini"
+
+msgid "Last Month"
+msgstr "Bulan Lalu"
+
+msgid "Last 90 Days"
+msgstr "90 Hari Terakhir"
+
+msgid "Last Year"
+msgstr "Tahun Lalu"
+
+msgid "Traffic Timeline"
+msgstr "Timeline Lalu Lintas"
+
+msgid "Aggregation:"
+msgstr "Agregasi:"
+
+msgid "Hourly"
+msgstr "Per Jam"
+
+msgid "Device:"
+msgstr "Perangkat:"
diff --git a/luci-app-bandix/po/ja/bandix.po b/luci-app-bandix/po/ja/bandix.po
index 7979a0e..6cc5c64 100644
--- a/luci-app-bandix/po/ja/bandix.po
+++ b/luci-app-bandix/po/ja/bandix.po
@@ -1021,3 +1021,57 @@ msgstr "インストールを開始しています... ページは5秒後に自
msgid "Please clear your browser cache manually after updating."
msgstr "更新後、ブラウザのキャッシュを手動でクリアしてください。"
+
+msgid "Traffic Statistics"
+msgstr "トラフィック統計"
+
+msgid "Traffic Statistics (WAN Only)"
+msgstr "トラフィック統計(WANのみ)"
+
+msgid "Device Usage Ranking"
+msgstr "デバイス使用量ランキング"
+
+msgid "(Data has 1 hour delay)"
+msgstr "(データは1時間遅延)"
+
+msgid "Start Date"
+msgstr "開始日"
+
+msgid "End Date"
+msgstr "終了日"
+
+msgid "Reset"
+msgstr "リセット"
+
+msgid "Today"
+msgstr "今日"
+
+msgid "This Week"
+msgstr "今週"
+
+msgid "Last Week"
+msgstr "先週"
+
+msgid "This Month"
+msgstr "今月"
+
+msgid "Last Month"
+msgstr "先月"
+
+msgid "Last 90 Days"
+msgstr "過去90日"
+
+msgid "Last Year"
+msgstr "過去1年"
+
+msgid "Traffic Timeline"
+msgstr "トラフィックタイムライン"
+
+msgid "Aggregation:"
+msgstr "集計:"
+
+msgid "Hourly"
+msgstr "時間単位"
+
+msgid "Device:"
+msgstr "デバイス:"
diff --git a/luci-app-bandix/po/pl/bandix.po b/luci-app-bandix/po/pl/bandix.po
index 6c5c771..10e72f2 100644
--- a/luci-app-bandix/po/pl/bandix.po
+++ b/luci-app-bandix/po/pl/bandix.po
@@ -1022,3 +1022,57 @@ msgstr "Rozpoczynanie instalacji... Strona odświeży się automatycznie za 5 se
msgid "Please clear your browser cache manually after updating."
msgstr "Po aktualizacji należy ręcznie wyczyścić pamięć podręczną przeglądarki."
+msgid "Traffic Statistics"
+msgstr "Statystyki ruchu"
+
+msgid "Traffic Statistics (WAN Only)"
+msgstr "Statystyki ruchu (tylko WAN)"
+
+msgid "Device Usage Ranking"
+msgstr "Ranking Użycia Urządzeń"
+
+msgid "(Data has 1 hour delay)"
+msgstr "(Dane mają 1 godzinę opóźnienia)"
+
+msgid "Start Date"
+msgstr "Data Początkowa"
+
+msgid "End Date"
+msgstr "Data Końcowa"
+
+msgid "Reset"
+msgstr "Resetuj"
+
+msgid "Today"
+msgstr "Dzisiaj"
+
+msgid "This Week"
+msgstr "Ten Tydzień"
+
+msgid "Last Week"
+msgstr "Zeszły Tydzień"
+
+msgid "This Month"
+msgstr "Ten Miesiąc"
+
+msgid "Last Month"
+msgstr "Zeszły Miesiąc"
+
+msgid "Last 90 Days"
+msgstr "Ostatnie 90 Dni"
+
+msgid "Last Year"
+msgstr "Ostatni Rok"
+
+msgid "Traffic Timeline"
+msgstr "Oś Czasu Ruchu"
+
+msgid "Aggregation:"
+msgstr "Agregacja:"
+
+msgid "Hourly"
+msgstr "Co Godzinę"
+
+msgid "Device:"
+msgstr "Urządzenie:"
+
diff --git a/luci-app-bandix/po/ru/bandix.po b/luci-app-bandix/po/ru/bandix.po
index 8cbe7c3..2013204 100644
--- a/luci-app-bandix/po/ru/bandix.po
+++ b/luci-app-bandix/po/ru/bandix.po
@@ -1020,4 +1020,58 @@ msgid "Starting installation... The page will refresh automatically in 5 seconds
msgstr "Начало установки... Страница автоматически обновится через 5 секунд."
msgid "Please clear your browser cache manually after updating."
-msgstr "Пожалуйста, вручную очистите кеш браузера после обновления."
\ No newline at end of file
+msgstr "Пожалуйста, вручную очистите кеш браузера после обновления."
+
+msgid "Traffic Statistics"
+msgstr "Статистика трафика"
+
+msgid "Traffic Statistics (WAN Only)"
+msgstr "Статистика трафика (только WAN)"
+
+msgid "Device Usage Ranking"
+msgstr "Рейтинг использования устройств"
+
+msgid "(Data has 1 hour delay)"
+msgstr "(Данные имеют задержку 1 час)"
+
+msgid "Start Date"
+msgstr "Дата начала"
+
+msgid "End Date"
+msgstr "Дата окончания"
+
+msgid "Reset"
+msgstr "Сбросить"
+
+msgid "Today"
+msgstr "Сегодня"
+
+msgid "This Week"
+msgstr "На этой неделе"
+
+msgid "Last Week"
+msgstr "На прошлой неделе"
+
+msgid "This Month"
+msgstr "В этом месяце"
+
+msgid "Last Month"
+msgstr "В прошлом месяце"
+
+msgid "Last 90 Days"
+msgstr "Последние 90 дней"
+
+msgid "Last Year"
+msgstr "За последний год"
+
+msgid "Traffic Timeline"
+msgstr "Временная шкала трафика"
+
+msgid "Aggregation:"
+msgstr "Агрегация:"
+
+msgid "Hourly"
+msgstr "По часам"
+
+msgid "Device:"
+msgstr "Устройство:"
\ No newline at end of file
diff --git a/luci-app-bandix/po/th/bandix.po b/luci-app-bandix/po/th/bandix.po
index 8cd145f..fa9f00b 100644
--- a/luci-app-bandix/po/th/bandix.po
+++ b/luci-app-bandix/po/th/bandix.po
@@ -1021,3 +1021,57 @@ msgstr "กำลังเริ่มการติดตั้ง... หน
msgid "Please clear your browser cache manually after updating."
msgstr "กรุณาล้างแคชเบราว์เซอร์ด้วยตนเองหลังจากอัปเดต"
+
+msgid "Traffic Statistics"
+msgstr "สถิติการรับส่งข้อมูล"
+
+msgid "Traffic Statistics (WAN Only)"
+msgstr "สถิติการรับส่งข้อมูล (เฉพาะ WAN)"
+
+msgid "Device Usage Ranking"
+msgstr "อันดับการใช้งานอุปกรณ์"
+
+msgid "(Data has 1 hour delay)"
+msgstr "(ข้อมูลล่าช้า 1 ชั่วโมง)"
+
+msgid "Start Date"
+msgstr "วันที่เริ่มต้น"
+
+msgid "End Date"
+msgstr "วันที่สิ้นสุด"
+
+msgid "Reset"
+msgstr "รีเซ็ต"
+
+msgid "Today"
+msgstr "วันนี้"
+
+msgid "This Week"
+msgstr "สัปดาห์นี้"
+
+msgid "Last Week"
+msgstr "สัปดาห์ที่แล้ว"
+
+msgid "This Month"
+msgstr "เดือนนี้"
+
+msgid "Last Month"
+msgstr "เดือนที่แล้ว"
+
+msgid "Last 90 Days"
+msgstr "90 วันล่าสุด"
+
+msgid "Last Year"
+msgstr "ปีที่แล้ว"
+
+msgid "Traffic Timeline"
+msgstr "ไทม์ไลน์การรับส่งข้อมูล"
+
+msgid "Aggregation:"
+msgstr "การรวม:"
+
+msgid "Hourly"
+msgstr "รายชั่วโมง"
+
+msgid "Device:"
+msgstr "อุปกรณ์:"
diff --git a/luci-app-bandix/po/zh_Hans/bandix.po b/luci-app-bandix/po/zh_Hans/bandix.po
index 4b43ce3..a3191a8 100644
--- a/luci-app-bandix/po/zh_Hans/bandix.po
+++ b/luci-app-bandix/po/zh_Hans/bandix.po
@@ -1026,4 +1026,58 @@ msgid "Update available"
msgstr "有更新"
msgid "Update available, click to go to settings"
-msgstr "有更新,点击前往设置"
\ No newline at end of file
+msgstr "有更新,点击前往设置"
+
+msgid "Traffic Statistics"
+msgstr "流量统计"
+
+msgid "Traffic Statistics (WAN Only)"
+msgstr "流量统计(仅WAN)"
+
+msgid "Device Usage Ranking"
+msgstr "设备用量排行"
+
+msgid "(Data has 1 hour delay)"
+msgstr "(数据有1小时延迟)"
+
+msgid "Start Date"
+msgstr "开始日期"
+
+msgid "End Date"
+msgstr "结束日期"
+
+msgid "Reset"
+msgstr "重置"
+
+msgid "Today"
+msgstr "今天"
+
+msgid "This Week"
+msgstr "本周"
+
+msgid "Last Week"
+msgstr "上周"
+
+msgid "This Month"
+msgstr "本月"
+
+msgid "Last Month"
+msgstr "上月"
+
+msgid "Last 90 Days"
+msgstr "最近90天"
+
+msgid "Last Year"
+msgstr "最近一年"
+
+msgid "Traffic Timeline"
+msgstr "流量时间线"
+
+msgid "Aggregation:"
+msgstr "聚合方式:"
+
+msgid "Hourly"
+msgstr "按小时"
+
+msgid "Device:"
+msgstr "设备:"
\ No newline at end of file
diff --git a/luci-app-bandix/po/zh_Hant/bandix.po b/luci-app-bandix/po/zh_Hant/bandix.po
index 11c913d..a8f06f6 100644
--- a/luci-app-bandix/po/zh_Hant/bandix.po
+++ b/luci-app-bandix/po/zh_Hant/bandix.po
@@ -1020,4 +1020,58 @@ msgid "Starting installation... The page will refresh automatically in 5 seconds
msgstr "開始安裝... 頁面將在5秒後自動重新整理。"
msgid "Please clear your browser cache manually after updating."
-msgstr "更新後請手動清空瀏覽器快取。"
\ No newline at end of file
+msgstr "更新後請手動清空瀏覽器快取。"
+
+msgid "Traffic Statistics"
+msgstr "流量統計"
+
+msgid "Traffic Statistics (WAN Only)"
+msgstr "流量統計(僅WAN)"
+
+msgid "Device Usage Ranking"
+msgstr "設備用量排行"
+
+msgid "(Data has 1 hour delay)"
+msgstr "(資料有1小時延遲)"
+
+msgid "Start Date"
+msgstr "開始日期"
+
+msgid "End Date"
+msgstr "結束日期"
+
+msgid "Reset"
+msgstr "重置"
+
+msgid "Today"
+msgstr "今天"
+
+msgid "This Week"
+msgstr "本週"
+
+msgid "Last Week"
+msgstr "上週"
+
+msgid "This Month"
+msgstr "本月"
+
+msgid "Last Month"
+msgstr "上月"
+
+msgid "Last 90 Days"
+msgstr "最近90天"
+
+msgid "Last Year"
+msgstr "最近一年"
+
+msgid "Traffic Timeline"
+msgstr "流量時間線"
+
+msgid "Aggregation:"
+msgstr "聚合方式:"
+
+msgid "Hourly"
+msgstr "按小時"
+
+msgid "Device:"
+msgstr "設備:"
\ No newline at end of file
diff --git a/luci-app-bandix/root/usr/libexec/rpcd/luci.bandix b/luci-app-bandix/root/usr/libexec/rpcd/luci.bandix
index 67f9c29..23afcba 100755
--- a/luci-app-bandix/root/usr/libexec/rpcd/luci.bandix
+++ b/luci-app-bandix/root/usr/libexec/rpcd/luci.bandix
@@ -16,6 +16,8 @@ readonly BANDIX_CONNECTION_API="$BANDIX_API_BASE/api/connection/devices"
readonly BANDIX_DNS_QUERIES_API="$BANDIX_API_BASE/api/dns/queries"
readonly BANDIX_DNS_STATS_API="$BANDIX_API_BASE/api/dns/stats"
readonly BANDIX_SCHEDULE_LIMITS_API="$BANDIX_API_BASE/api/traffic/limits/schedule"
+readonly BANDIX_TRAFFIC_USAGE_RANKING_API="$BANDIX_API_BASE/api/traffic/usage/ranking"
+readonly BANDIX_TRAFFIC_USAGE_INCREMENTS_API="$BANDIX_API_BASE/api/traffic/usage/increments"
# 通用函数:创建简单的JSON响应
make_value() {
@@ -331,6 +333,98 @@ get_dns_stats() {
echo "$response"
}
+# 获取设备用量排行
+get_traffic_usage_ranking() {
+ local start_ms="$1"
+ local end_ms="$2"
+
+ # 构建查询参数(只添加非空且有效的参数)
+ local query_params=""
+ # 检查 start_ms 是否有效(非空且不是 "null" 或 "undefined")
+ if [ -n "$start_ms" ] && [ "$start_ms" != "null" ] && [ "$start_ms" != "undefined" ]; then
+ query_params="${query_params}start_ms=$start_ms&"
+ fi
+ # 检查 end_ms 是否有效
+ if [ -n "$end_ms" ] && [ "$end_ms" != "null" ] && [ "$end_ms" != "undefined" ]; then
+ query_params="${query_params}end_ms=$end_ms&"
+ fi
+
+ # 移除末尾的 &
+ query_params=$(echo "$query_params" | sed 's/&$//')
+
+ local url="$BANDIX_TRAFFIC_USAGE_RANKING_API"
+ [ -n "$query_params" ] && url="${url}?${query_params}"
+
+ # 从 Bandix API 获取设备用量排行数据
+ local response=$(curl -s --connect-timeout 2 --max-time 10 -X GET "$url" 2>/dev/null)
+
+ # 检查API调用是否成功
+ if [ $? -ne 0 ] || [ -z "$response" ]; then
+ make_error "Failed to connect to Bandix service"
+ return
+ fi
+
+ # 提取 data 部分并返回
+ local data_part=$(echo "$response" | jsonfilter -e '$.data' 2>/dev/null)
+ if [ -n "$data_part" ]; then
+ echo "$data_part"
+ else
+ # 如果提取失败,返回空结果
+ echo '{"start_ms":0,"end_ms":0,"total_rx_bytes":0,"total_tx_bytes":0,"total_bytes":0,"device_count":0,"rankings":[]}'
+ fi
+}
+
+# 获取时间序列增量数据
+get_traffic_usage_increments() {
+ local start_ms="$1"
+ local end_ms="$2"
+ local aggregation="$3"
+ local mac="$4"
+
+ # 构建查询参数(只添加非空且有效的参数)
+ local query_params=""
+ # 检查 start_ms 是否有效(非空且不是 "null" 或 "undefined")
+ if [ -n "$start_ms" ] && [ "$start_ms" != "null" ] && [ "$start_ms" != "undefined" ]; then
+ query_params="${query_params}start_ms=$start_ms&"
+ fi
+ # 检查 end_ms 是否有效
+ if [ -n "$end_ms" ] && [ "$end_ms" != "null" ] && [ "$end_ms" != "undefined" ]; then
+ query_params="${query_params}end_ms=$end_ms&"
+ fi
+ # 检查 aggregation 是否有效
+ if [ -n "$aggregation" ] && [ "$aggregation" != "null" ] && [ "$aggregation" != "undefined" ]; then
+ query_params="${query_params}aggregation=$aggregation&"
+ fi
+ # 检查 mac 是否有效
+ if [ -n "$mac" ] && [ "$mac" != "null" ] && [ "$mac" != "undefined" ]; then
+ query_params="${query_params}mac=$(printf '%s' "$mac" | sed 's/ /%20/g')&"
+ fi
+
+ # 移除末尾的 &
+ query_params=$(echo "$query_params" | sed 's/&$//')
+
+ local url="$BANDIX_TRAFFIC_USAGE_INCREMENTS_API"
+ [ -n "$query_params" ] && url="${url}?${query_params}"
+
+ # 从 Bandix API 获取时间序列增量数据
+ local response=$(curl -s --connect-timeout 2 --max-time 10 -X GET "$url" 2>/dev/null)
+
+ # 检查API调用是否成功
+ if [ $? -ne 0 ] || [ -z "$response" ]; then
+ make_error "Failed to connect to Bandix service"
+ return
+ fi
+
+ # 提取 data 部分并返回
+ local data_part=$(echo "$response" | jsonfilter -e '$.data' 2>/dev/null)
+ if [ -n "$data_part" ]; then
+ echo "$data_part"
+ else
+ # 如果提取失败,返回空结果
+ echo '{"start_ms":0,"end_ms":0,"aggregation":"hourly","mac":"all","increments":[],"total_rx_bytes":0,"total_tx_bytes":0,"total_bytes":0}'
+ fi
+}
+
# 获取定时限速规则
get_schedule_limits() {
# 从 Bandix API 获取定时限速规则
@@ -964,11 +1058,23 @@ case "$1" in
json_add_int "page_size"
json_close_object
- json_add_object "getDnsStats"
- json_close_object
+ json_add_object "getDnsStats"
+ json_close_object
- json_add_object "getScheduleLimits"
- json_close_object
+ json_add_object "getTrafficUsageRanking"
+ json_add_int "start_ms"
+ json_add_int "end_ms"
+ json_close_object
+
+ json_add_object "getTrafficUsageIncrements"
+ json_add_int "start_ms"
+ json_add_int "end_ms"
+ json_add_string "aggregation"
+ json_add_string "mac"
+ json_close_object
+
+ json_add_object "getScheduleLimits"
+ json_close_object
json_add_object "setScheduleLimit"
json_add_string "mac"
@@ -1153,6 +1259,50 @@ case "$1" in
# logger "luci.bandix: getDnsStats called"
get_dns_stats
;;
+ getTrafficUsageRanking)
+ start_ms=""
+ end_ms=""
+ input=""
+ if read -t 1 -r input; then
+ :
+ fi
+ if [ -n "$input" ]; then
+ start_ms="$(echo "$input" | jsonfilter -e '$[0]' 2>/dev/null)"
+ [ -z "$start_ms" ] && start_ms="$(echo "$input" | jsonfilter -e '$.start_ms' 2>/dev/null)"
+ end_ms="$(echo "$input" | jsonfilter -e '$[1]' 2>/dev/null)"
+ [ -z "$end_ms" ] && end_ms="$(echo "$input" | jsonfilter -e '$.end_ms' 2>/dev/null)"
+ else
+ [ -n "$3" ] && start_ms="$3"
+ [ -n "$4" ] && end_ms="$4"
+ fi
+ get_traffic_usage_ranking "$start_ms" "$end_ms"
+ ;;
+ getTrafficUsageIncrements)
+ start_ms=""
+ end_ms=""
+ aggregation=""
+ mac=""
+ input=""
+ if read -t 1 -r input; then
+ :
+ fi
+ if [ -n "$input" ]; then
+ start_ms="$(echo "$input" | jsonfilter -e '$[0]' 2>/dev/null)"
+ [ -z "$start_ms" ] && start_ms="$(echo "$input" | jsonfilter -e '$.start_ms' 2>/dev/null)"
+ end_ms="$(echo "$input" | jsonfilter -e '$[1]' 2>/dev/null)"
+ [ -z "$end_ms" ] && end_ms="$(echo "$input" | jsonfilter -e '$.end_ms' 2>/dev/null)"
+ aggregation="$(echo "$input" | jsonfilter -e '$[2]' 2>/dev/null)"
+ [ -z "$aggregation" ] && aggregation="$(echo "$input" | jsonfilter -e '$.aggregation' 2>/dev/null)"
+ mac="$(echo "$input" | jsonfilter -e '$[3]' 2>/dev/null)"
+ [ -z "$mac" ] && mac="$(echo "$input" | jsonfilter -e '$.mac' 2>/dev/null)"
+ else
+ [ -n "$3" ] && start_ms="$3"
+ [ -n "$4" ] && end_ms="$4"
+ [ -n "$5" ] && aggregation="$5"
+ [ -n "$6" ] && mac="$6"
+ fi
+ get_traffic_usage_increments "$start_ms" "$end_ms" "$aggregation" "$mac"
+ ;;
getScheduleLimits)
# logger "luci.bandix: getScheduleLimits called"
get_schedule_limits
diff --git a/luci-app-bandix/root/usr/share/rpcd/acl.d/luci-app-bandix.json b/luci-app-bandix/root/usr/share/rpcd/acl.d/luci-app-bandix.json
index ca741a7..002871e 100644
--- a/luci-app-bandix/root/usr/share/rpcd/acl.d/luci-app-bandix.json
+++ b/luci-app-bandix/root/usr/share/rpcd/acl.d/luci-app-bandix.json
@@ -21,7 +21,9 @@
"getVersion",
"getSystemArch",
"checkUpdate",
- "installUpdate"
+ "installUpdate",
+ "getTrafficUsageRanking",
+ "getTrafficUsageIncrements"
]
},
"uci": [
@@ -48,7 +50,9 @@
"getVersion",
"getSystemArch",
"checkUpdate",
- "installUpdate"
+ "installUpdate",
+ "getTrafficUsageRanking",
+ "getTrafficUsageIncrements"
]
},
"uci": [
diff --git a/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/advanced.lua b/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/advanced.lua
index 8134860..b57d437 100644
--- a/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/advanced.lua
+++ b/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/advanced.lua
@@ -138,7 +138,7 @@ o:depends("shunt_dns_mode", "2")
o.description = translate("Custom DNS Server format as IP:PORT (default: 8.8.4.4:53)")
o.datatype = "ip4addrport"
-o = s:option(ListValue, "shunt_mosdns_dnsserver", translate("Anti-pollution DNS Server"))
+o = s:option(Value, "shunt_mosdns_dnsserver", translate("Anti-pollution DNS Server"))
o:value("tcp://8.8.4.4:53,tcp://8.8.8.8:53", translate("Google Public DNS"))
o:value("tcp://208.67.222.222:53,tcp://208.67.220.220:53", translate("OpenDNS"))
o:value("tcp://209.244.0.3:53,tcp://209.244.0.4:53", translate("Level 3 Public DNS-1 (209.244.0.3-4)"))
@@ -146,7 +146,7 @@ o:value("tcp://4.2.2.1:53,tcp://4.2.2.2:53", translate("Level 3 Public DNS-2 (4.
o:value("tcp://4.2.2.3:53,tcp://4.2.2.4:53", translate("Level 3 Public DNS-3 (4.2.2.3-4)"))
o:value("tcp://1.1.1.1:53,tcp://1.0.0.1:53", translate("Cloudflare DNS"))
o:depends("shunt_dns_mode", "3")
-o.description = translate("Custom DNS Server for MosDNS")
+o.description = translate("Custom DNS Server format as tcp://IP:PORT or tls://DOMAIN:PORT (tcp://8.8.8.8 or tls://dns.google:853)")
o = s:option(Flag, "shunt_mosdns_ipv6", translate("Disable IPv6 In MosDNS Query Mode (Shunt Mode)"))
o:depends("shunt_dns_mode", "3")
diff --git a/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client.lua b/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client.lua
index e055920..99d3a31 100644
--- a/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client.lua
+++ b/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client.lua
@@ -161,7 +161,7 @@ o.description = translate("Custom DNS Server format as IP:PORT (default: 8.8.4.4
o.datatype = "ip4addrport"
o.default = "8.8.4.4:53"
-o = s:option(ListValue, "tunnel_forward_mosdns", translate("Anti-pollution DNS Server"))
+o = s:option(Value, "tunnel_forward_mosdns", translate("Anti-pollution DNS Server"))
o:value("tcp://8.8.4.4:53,tcp://8.8.8.8:53", translate("Google Public DNS"))
o:value("tcp://208.67.222.222:53,tcp://208.67.220.220:53", translate("OpenDNS"))
o:value("tcp://209.244.0.3:53,tcp://209.244.0.4:53", translate("Level 3 Public DNS-1 (209.244.0.3-4)"))
@@ -169,7 +169,7 @@ o:value("tcp://4.2.2.1:53,tcp://4.2.2.2:53", translate("Level 3 Public DNS-2 (4.
o:value("tcp://4.2.2.3:53,tcp://4.2.2.4:53", translate("Level 3 Public DNS-3 (4.2.2.3-4)"))
o:value("tcp://1.1.1.1:53,tcp://1.0.0.1:53", translate("Cloudflare DNS"))
o:depends("pdnsd_enable", "4")
-o.description = translate("Custom DNS Server for MosDNS")
+o.description = translate("Custom DNS Server format as tcp://IP:PORT or tls://DOMAIN:PORT (tcp://8.8.8.8 or tls://dns.google:853)")
o = s:option(Flag, "mosdns_ipv6", translate("Disable IPv6 in MOSDNS query mode"))
o:depends("pdnsd_enable", "4")
diff --git a/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/status.lua b/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/status.lua
index 9f0eff1..4f52aab 100644
--- a/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/status.lua
+++ b/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/status.lua
@@ -73,6 +73,10 @@ if Process_list:find("tcp.udp.ssr.retcp") then
reudp_run = 1
end
+if Process_list:find("nft.ssr.retcp") then
+ redir_run = 1
+end
+
if Process_list:find("local.ssr.retcp") then
redir_run = 1
sock5_run = 1
diff --git a/luci-app-ssr-plus/po/zh_Hans/ssr-plus.po b/luci-app-ssr-plus/po/zh_Hans/ssr-plus.po
index a48a1d5..e56d839 100644
--- a/luci-app-ssr-plus/po/zh_Hans/ssr-plus.po
+++ b/luci-app-ssr-plus/po/zh_Hans/ssr-plus.po
@@ -416,8 +416,8 @@ msgstr ""
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/advanced.lua:149
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client.lua:172
-msgid "Custom DNS Server for MosDNS"
-msgstr "MosDNS 自定义 DNS 服务器"
+msgid "Custom DNS Server format as tcp://IP:PORT or tls://DOMAIN:PORT (tcp://8.8.8.8 or tls://dns.google:853)"
+msgstr "格式为tcp://IP:Port或tls://域名:Port (tcp://8.8.8.8或tls://dns.google:853)"
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/advanced.lua:138
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/advanced.lua:220
diff --git a/luci-app-ssr-plus/root/etc/init.d/shadowsocksr b/luci-app-ssr-plus/root/etc/init.d/shadowsocksr
index 3acb750..95e03a6 100755
--- a/luci-app-ssr-plus/root/etc/init.d/shadowsocksr
+++ b/luci-app-ssr-plus/root/etc/init.d/shadowsocksr
@@ -211,8 +211,10 @@ start_dns() {
local run_mode="$(uci_get_by_type global run_mode)"
if [ "$ssrplus_dns" != "0" ]; then
- if [ -n "$dnsserver" ]; then
- add_dns_into_ipset $run_mode $dnsserver
+ if command -v iptables-legacy >/dev/null 2>&1; then
+ if [ -n "$dnsserver" ]; then
+ add_dns_into_ipset $run_mode $dnsserver
+ fi
fi
case "$ssrplus_dns" in
1)
@@ -236,12 +238,14 @@ start_dns() {
output=$(for i in $(echo $mosdns_dnsserver | sed "s/,/ /g"); do
dnsserver=${i%:*}
dnsserver=${i##*/}
- add_dns_into_ipset $run_mode $dnsserver
+ if command -v iptables-legacy >/dev/null 2>&1; then
+ add_dns_into_ipset $run_mode $dnsserver
+ fi
echo " - addr: $i"
echo " enable_pipeline: true"
done)
- awk -v line=14 -v text="$output" 'NR == line+1 {print text} 1' /etc/ssrplus/mosdns-config.yaml | sed "s/DNS_PORT/$dns_port/g" > $TMP_PATH/mosdns-config.yaml
+ awk -v line=14 -v text="$output" 'NR == line+1 {print text} 1' /etc/ssrplus/mosdns-config.yaml | sed "s/DNS_PORT/$dns_port/g" | sed "s/\(concurrent:\).*/\1 $(echo "$mosdns_dnsserver" | sed 's/,/ /g' | wc -w)/g"> $TMP_PATH/mosdns-config.yaml
if [ "$mosdns_ipv6" == "0" ]; then
sed -i "s/DNS_MODE/main_sequence_with_IPv6/g" $TMP_PATH/mosdns-config.yaml
else
@@ -648,7 +652,7 @@ shunt_dns_command() {
echo " socks5: \"127.0.0.1:$tmp_port\""
echo " enable_pipeline: true"
done)
- awk -v line=14 -v text="$output" 'NR == line+1 {print text} 1' /etc/ssrplus/mosdns-config.yaml | sed "s/DNS_PORT/$tmp_shunt_dns_port/g" > $TMP_PATH/mosdns-config-shunt.yaml
+ awk -v line=14 -v text="$output" 'NR == line+1 {print text} 1' /etc/ssrplus/mosdns-config.yaml | sed "s/DNS_PORT/$tmp_shunt_dns_port/g" | sed "s/\(concurrent:\).*/\1 $(echo "$mosdns_dnsserver" | sed 's/,/ /g' | wc -w)/g" > $TMP_PATH/mosdns-config-shunt.yaml
if [ "$shunt_mosdns_ipv6" == "0" ]; then
sed -i "s/DNS_MODE/main_sequence_with_IPv6/g" $TMP_PATH/mosdns-config-shunt.yaml
@@ -1172,7 +1176,17 @@ load_config() {
tcp_config_file=$TMP_PATH/tcp-only-ssr-retcp.json
case "$UDP_RELAY_SERVER" in
nil)
- mode="tcp"
+ if command -v nft >/dev/null 2>&1; then
+ # nftables / fw4
+ mode="tcp,udp"
+ ARG_UDP=""
+ udp_config_file=""
+ UDP_RELAY_SERVER="nil"
+ tcp_config_file=$TMP_PATH/nft-ssr-retcp.json
+ else
+ # iptables / fw3
+ mode="tcp"
+ fi
;;
$GLOBAL_SERVER | same)
mode="tcp,udp"
@@ -1185,7 +1199,13 @@ load_config() {
udp_config_file=$TMP_PATH/udp-only-ssr-reudp.json
ARG_UDP="-U"
start_udp
- mode="tcp"
+ if command -v nft >/dev/null 2>&1; then
+ # nftables / fw4
+ mode="tcp,udp"
+ else
+ # iptables / fw3
+ mode="tcp"
+ fi
;;
esac
case "$LOCAL_SERVER" in
@@ -1419,6 +1439,11 @@ start_rules() {
2) echo "-O" ;;
esac
}
+ if command -v nft >/dev/null 2>&1; then
+ ARG_A="-A"
+ else
+ ARG_A=""
+ fi
/usr/share/shadowsocksr/gfw2ipset.sh
/usr/bin/ssr-rules \
-s "$server" \
@@ -1438,7 +1463,8 @@ start_rules() {
-N "$shunt_ip" \
-M "$(uci_get_by_type global netflix_proxy 0)" \
-I "/etc/ssrplus/netflixip.list" \
- $(get_arg_out) $(gfwmode) $ARG_UDP
+ $(get_arg_out) $(gfwmode) $ARG_UDP $ARG_A
+
return $?
}
@@ -1486,6 +1512,13 @@ boot() {
stop() {
unlock
set_lock
+ if command -v nft >/dev/null 2>&1; then
+ /usr/bin/ssr-rules -K
+ #local CLEANUP_PERSISTENCE="$(uci_get_by_type global global_server nil)"
+ #if [ "$CLEANUP_PERSISTENCE" == "nil" ]; then
+ # /usr/bin/ssr-rules -X
+ #fi
+ fi
/usr/bin/ssr-rules -f
local srulecount=0
if command -v nft >/dev/null 2>&1; then
@@ -1527,6 +1560,7 @@ stop() {
killall -q -9 kcptun-client
fi
$PS -w | grep -v "grep" | grep ssr-monitor | awk '{print $1}' | xargs kill -9 >/dev/null 2>&1 &
+ $PS -w | grep -v "grep" | grep ssr-rules | awk '{print $1}' | xargs kill -9 >/dev/null 2>&1 &
$PS -w | grep -v "grep" | grep "sleep 0000" | awk '{print $1}' | xargs kill -9 >/dev/null 2>&1 &
( \
# Graceful kill first, so programs have the chance to stop its subprocesses
diff --git a/luci-app-ssr-plus/root/usr/bin/ssr-rules b/luci-app-ssr-plus/root/usr/bin/ssr-rules
index 8fb813a..fba38d8 100755
--- a/luci-app-ssr-plus/root/usr/bin/ssr-rules
+++ b/luci-app-ssr-plus/root/usr/bin/ssr-rules
@@ -28,6 +28,24 @@ detect_firewall
TAG="_SS_SPEC_RULE_" # comment tag
+# 这些变量将在后续的 getopts 参数解析中被赋值
+ENABLE_AUTO_UPDATE=0
+STOP_AUTO_UPDATE=0
+FORCE_UPDATE=0
+CHECK_STATUS=0
+RESTORE_RULES=0
+FLUSH_RULES=0
+CLEANUP_PERSISTENCE=0
+
+if [ "$USE_NFT" = "1" ]; then
+ # NFTables persistence directory
+ NFTABLES_RULES_DIR="/usr/share/nftables.d/ruleset-post"
+ NFTABLES_RULES_FILE="$NFTABLES_RULES_DIR/99-shadowsocksr.nft"
+ # Auto-update configuration
+ AUTO_UPDATE_INTERVAL=300 # 自动更新检查间隔(秒),0表示禁用自动更新
+fi
+
+# 修改 usage 函数
usage() {
cat <<-EOF
Usage: ssr-rules [options]
@@ -63,6 +81,15 @@ usage() {
-r router mode
-c oversea mode
-z all mode
+
+ # 新增持久化管理选项 (使用不同的字母避免冲突)
+ -A enable auto-update daemon
+ -K stop auto-update daemon
+ -P force update persistence
+ -C check rules status
+ -R restore rules from persistence file
+ -X cleanup persistence files on stop
+
-h show this help message and exit
EOF
exit $1
@@ -73,6 +100,40 @@ loger() {
logger -st ssr-rules[$$] -p$1 $2
}
+# 清理持久化和运行模块文件等
+cleanup_persistence_files() {
+ if [ "$USE_NFT" != "1" ]; then
+ return 0
+ fi
+
+ # 删除持久化规则文件
+ if [ -f "$NFTABLES_RULES_FILE" ]; then
+ rm -f "$NFTABLES_RULES_FILE" 2>/dev/null
+ loger 5 "Removed persistence file: $NFTABLES_RULES_FILE"
+ fi
+
+ # 删除运行模块文件
+ if [ -f "/tmp/.ssr_run_mode" ]; then
+ rm -f "/tmp/.ssr_run_mode" 2>/dev/null
+ loger 5 "Removed run mode file: /tmp/.ssr_run_mode"
+ fi
+
+ # 删除 TPROXY 文件
+ if [ -f "/tmp/.last_tproxy" ]; then
+ rm -f "/tmp/.last_tproxy" 2>/dev/null
+ loger 5 "Removed run mode file: /tmp/.last_tproxy"
+ fi
+
+ # 删除 PROXY_PORTS 文件
+ if [ -f "/tmp/.last_proxy_ports" ]; then
+ rm -f "/tmp/.last_proxy_ports" 2>/dev/null
+ loger 5 "Removed run mode file: /tmp/.last_proxy_ports"
+ fi
+
+ loger 5 "Persistence cleanup completed"
+ return 0
+}
+
flush_r() {
if [ "$USE_NFT" = "1" ]; then
flush_nftables
@@ -136,6 +197,13 @@ flush_nftables() {
# 重置防火墙 include 文件
[ -n "$FWI" ] && echo '#!/bin/sh' >"$FWI"
+ # 清理持久化和运行模块文件
+ if [ "$CLEANUP_PERSISTENCE" = "1" ]; then
+ cleanup_persistence_files
+ fi
+
+ loger 6 "Memory rules flushed successfully"
+
return 0
}
@@ -182,7 +250,7 @@ ipset_nft() {
fi
# Create necessary collections
- for setname in china gmlan fplan bplan whitelist blacklist netflix; do
+ for setname in china gmlan fplan bplan whitelist blacklist netflix music; do
if ! $NFT list set inet ss_spec $setname >/dev/null 2>&1; then
$NFT add set inet ss_spec $setname '{ type ipv4_addr; flags interval; auto-merge; }' 2>/dev/null
else
@@ -190,7 +258,7 @@ ipset_nft() {
fi
done
- # 批量导入中国IP列表
+ # Bulk import china ip list safely (avoid huge single element limitation)
if [ -f "${china_ip:=/etc/ssrplus/china_ssr.txt}" ]; then
$NFT add element inet ss_spec china "{ $(tr '\n' ',' < "${china_ip}" | sed 's/,$//') }" 2>/dev/null
fi
@@ -212,109 +280,111 @@ ipset_nft() {
[ -n "$ip" ] && $NFT add element inet ss_spec blacklist "{ $ip }" 2>/dev/null
done
- # Create main chain for WAN access control
- if ! $NFT list chain inet ss_spec ss_spec_wan_ac >/dev/null 2>&1; then
- $NFT add chain inet ss_spec ss_spec_wan_ac 2>/dev/null
- fi
- $NFT flush chain inet ss_spec ss_spec_wan_ac 2>/dev/null
-
- # Create forward chain with better error handling
- if ! $NFT list chain inet ss_spec ss_spec_wan_fw >/dev/null 2>&1; then
- $NFT add chain inet ss_spec ss_spec_wan_fw 2>/dev/null || {
- loger 3 "Failed to create forward chain"
- return 1
- }
- fi
- # Clear existing rules
- $NFT flush chain inet ss_spec ss_spec_wan_fw 2>/dev/null
-
- EXT_ARGS=""
- if [ -n "$PROXY_PORTS" ]; then
- PORTS_ARGS=$(echo "$PROXY_PORTS" | sed 's/-m multiport --dports //')
- if [ -n "$PORTS_ARGS" ]; then
- EXT_ARGS="th dport { $PORTS_ARGS }"
+ # Create main chains for WAN access control
+ for chain in ss_spec_wan_fw_tcp ss_spec_wan_fw_udp ss_spec_wan_ac_tcp ss_spec_wan_ac_udp; do
+ if ! $NFT list chain inet ss_spec $chain >/dev/null 2>&1; then
+ $NFT add chain inet ss_spec $chain
fi
- fi
+ $NFT flush chain inet ss_spec $chain
+ done
# Add basic rules
- # ========== 按照正确顺序添加规则 ==========
+ # BASIC RULES (exceptions first) — TCP
+ $NFT add rule inet ss_spec ss_spec_wan_ac_tcp meta l4proto tcp tcp dport 53 ip daddr 127.0.0.0/8 return
+ [ -n "$server" ] && $NFT add rule inet ss_spec ss_spec_wan_ac_tcp meta l4proto tcp tcp dport != 53 ip daddr "$server" return
- # 1. 基础例外规则(最高优先级)
- $NFT add rule inet ss_spec ss_spec_wan_ac tcp dport 53 ip daddr 127.0.0.0/8 return
- [ -n "$server" ] && $NFT add rule inet ss_spec ss_spec_wan_ac tcp dport != 53 ip daddr "$server" return
+ # Access control: blacklist -> whitelist -> fplan/bplan — TCP
+ $NFT add rule inet ss_spec ss_spec_wan_ac_tcp ip daddr @blacklist jump ss_spec_wan_fw_tcp
+ $NFT add rule inet ss_spec ss_spec_wan_ac_tcp ip daddr @whitelist return
+ $NFT add rule inet ss_spec ss_spec_wan_ac_tcp ip saddr @fplan jump ss_spec_wan_fw_tcp
+ $NFT add rule inet ss_spec ss_spec_wan_ac_tcp ip saddr @bplan return
- # 2. 强制访问控制
- $NFT add rule inet ss_spec ss_spec_wan_ac ip daddr @blacklist jump ss_spec_wan_fw
- $NFT add rule inet ss_spec ss_spec_wan_ac ip daddr @whitelist return
- $NFT add rule inet ss_spec ss_spec_wan_ac ip saddr @fplan jump ss_spec_wan_fw
- $NFT add rule inet ss_spec ss_spec_wan_ac ip saddr @bplan return
+ # BASIC RULES (exceptions first) — UDP
+ $NFT add rule inet ss_spec ss_spec_wan_ac_udp meta l4proto udp udp dport 53 ip daddr 127.0.0.0/8 return
+ [ -n "$server" ] && $NFT add rule inet ss_spec ss_spec_wan_ac_udp meta l4proto udp udp dport != 53 ip daddr "$server" return
+
+ # Access control: blacklist -> whitelist -> fplan/bplan — UDP
+ $NFT add rule inet ss_spec ss_spec_wan_ac_udp ip daddr @blacklist jump ss_spec_wan_fw_udp
+ $NFT add rule inet ss_spec ss_spec_wan_ac_udp ip daddr @whitelist return
+ $NFT add rule inet ss_spec ss_spec_wan_ac_udp ip saddr @fplan jump ss_spec_wan_fw_udp
+ $NFT add rule inet ss_spec ss_spec_wan_ac_udp ip saddr @bplan return
- # 3. 特殊功能规则
# Music unlocking support
if $NFT list set inet ss_spec music >/dev/null 2>&1; then
- $NFT add rule inet ss_spec ss_spec_wan_ac ip daddr @music return
+ $NFT add rule inet ss_spec ss_spec_wan_ac_tcp meta l4proto tcp ip daddr @music return
+ $NFT add rule inet ss_spec ss_spec_wan_ac_udp meta l4proto udp ip daddr @music return
fi
# Shunt/Netflix rules
- if [ "$SHUNT_PORT" != "0" ] && [ -f "$SHUNT_LIST" ]; then
+ if [ -f "$SHUNT_LIST" ]; then
for ip in $(cat "$SHUNT_LIST" 2>/dev/null); do
[ -n "$ip" ] && $NFT add element inet ss_spec netflix "{ $ip }" 2>/dev/null
done
- case "$SHUNT_PORT" in
- 1)
- $NFT add rule inet ss_spec ss_spec_wan_ac meta l4proto tcp $EXT_ARGS ip daddr @netflix counter redirect to :$local_port
- ;;
- *)
- $NFT add rule inet ss_spec ss_spec_wan_ac meta l4proto tcp $EXT_ARGS ip daddr @netflix counter redirect to :$SHUNT_PORT
- if [ "$SHUNT_PROXY" = "1" ]; then
- $NFT add rule inet ss_spec ss_spec_wan_ac meta l4proto tcp $EXT_ARGS ip daddr $SHUNT_IP counter redirect to :$local_port
- else
- [ -n "$SHUNT_IP" ] && $NFT add element inet ss_spec whitelist "{ $SHUNT_IP }" 2>/dev/null
- fi
- ;;
- esac
fi
- # 4. 模式特定规则
# Set up mode-specific rules
case "$RUNMODE" in
router)
- if ! $NFT list set inet ss_spec ss_spec_wan_ac >/dev/null 2>&1; then
- $NFT add set inet ss_spec ss_spec_wan_ac '{ type ipv4_addr; flags interval; auto-merge; }'
+ if ! $NFT list set inet ss_spec ss_spec_wan_ac_tcp >/dev/null 2>&1; then
+ $NFT add set inet ss_spec ss_spec_wan_ac_tcp '{ type ipv4_addr; flags interval; auto-merge; }'
else
- $NFT flush set inet ss_spec ss_spec_wan_ac 2>/dev/null
+ $NFT flush set inet ss_spec ss_spec_wan_ac_tcp 2>/dev/null
fi
# Add special IP ranges to WAN AC set
for ip in $(gen_spec_iplist); do
- [ -n "$ip" ] && $NFT add element inet ss_spec ss_spec_wan_ac "{ $ip }" 2>/dev/null
+ [ -n "$ip" ] && $NFT add element inet ss_spec ss_spec_wan_ac_tcp "{ $ip }" 2>/dev/null
done
- $NFT add rule inet ss_spec ss_spec_wan_ac ip daddr @ss_spec_wan_ac return
- $NFT add rule inet ss_spec ss_spec_wan_ac ip daddr @china return
- if $NFT list chain inet ss_spec ss_spec_wan_fw >/dev/null 2>&1; then
- $NFT add rule inet ss_spec ss_spec_wan_ac ip saddr @gmlan ip daddr != @china jump ss_spec_wan_fw
- $NFT add rule inet ss_spec ss_spec_wan_ac jump ss_spec_wan_fw
+ $NFT add rule inet ss_spec ss_spec_wan_ac_tcp ip daddr @ss_spec_wan_ac_tcp return
+ $NFT add rule inet ss_spec ss_spec_wan_ac_tcp ip daddr @china return
+ if $NFT list chain inet ss_spec ss_spec_wan_ac_tcp >/dev/null 2>&1; then
+ $NFT add rule inet ss_spec ss_spec_wan_ac_tcp ip saddr @gmlan ip daddr != @china jump ss_spec_wan_fw_tcp
+ $NFT add rule inet ss_spec ss_spec_wan_ac_tcp jump ss_spec_wan_fw_tcp
+ fi
+ if ! $NFT list set inet ss_spec ss_spec_wan_ac_udp >/dev/null 2>&1; then
+ $NFT add set inet ss_spec ss_spec_wan_ac_udp '{ type ipv4_addr; flags interval; auto-merge; }'
+ else
+ $NFT flush set inet ss_spec ss_spec_wan_ac_udp 2>/dev/null
+ fi
+ # Add special IP ranges to WAN AC set
+ for ip in $(gen_spec_iplist); do
+ [ -n "$ip" ] && $NFT add element inet ss_spec ss_spec_wan_ac_udp "{ $ip }" 2>/dev/null
+ done
+ $NFT add rule inet ss_spec ss_spec_wan_ac_udp ip daddr @ss_spec_wan_ac_udp return
+ $NFT add rule inet ss_spec ss_spec_wan_ac_udp ip daddr @china return
+ if $NFT list chain inet ss_spec ss_spec_wan_fw_udp >/dev/null 2>&1; then
+ $NFT add rule inet ss_spec ss_spec_wan_ac_udp ip saddr @gmlan ip daddr != @china jump ss_spec_wan_fw_udp
+ $NFT add rule inet ss_spec ss_spec_wan_ac_udp jump ss_spec_wan_fw_udp
fi
;;
gfw)
if ! $NFT list set inet ss_spec gfwlist >/dev/null 2>&1; then
$NFT add set inet ss_spec gfwlist '{ type ipv4_addr; flags interval; auto-merge; }' 2>/dev/null
fi
- $NFT add rule inet ss_spec ss_spec_wan_ac ip daddr @china return
- $NFT add rule inet ss_spec ss_spec_wan_ac ip daddr @gfwlist jump ss_spec_wan_fw
- $NFT add rule inet ss_spec ss_spec_wan_ac ip saddr @gmlan ip daddr != @china jump ss_spec_wan_fw
+ $NFT add rule inet ss_spec ss_spec_wan_ac_tcp ip daddr @china return
+ $NFT add rule inet ss_spec ss_spec_wan_ac_tcp ip daddr @gfwlist jump ss_spec_wan_fw_tcp
+ $NFT add rule inet ss_spec ss_spec_wan_ac_tcp ip saddr @gmlan ip daddr != @china jump ss_spec_wan_fw_tcp
+ $NFT add rule inet ss_spec ss_spec_wan_ac_udp ip daddr @china return
+ $NFT add rule inet ss_spec ss_spec_wan_ac_udp ip daddr @gfwlist jump ss_spec_wan_fw_udp
+ $NFT add rule inet ss_spec ss_spec_wan_ac_udp ip saddr @gmlan ip daddr != @china jump ss_spec_wan_fw_udp
;;
oversea)
if ! $NFT list set inet ss_spec oversea >/dev/null 2>&1; then
$NFT add set inet ss_spec oversea '{ type ipv4_addr; flags interval; auto-merge; }' 2>/dev/null
fi
- $NFT add rule inet ss_spec ss_spec_wan_ac ip daddr @oversea jump ss_spec_wan_fw
- $NFT add rule inet ss_spec ss_spec_wan_ac ip saddr @gmlan jump ss_spec_wan_fw
- $NFT add rule inet ss_spec ss_spec_wan_ac ip daddr @china jump ss_spec_wan_fw
+ $NFT add rule inet ss_spec ss_spec_wan_ac_tcp ip daddr @oversea jump ss_spec_wan_fw_tcp
+ $NFT add rule inet ss_spec ss_spec_wan_ac_tcp ip saddr @gmlan jump ss_spec_wan_fw_tcp
+ $NFT add rule inet ss_spec ss_spec_wan_ac_tcp ip daddr @china jump ss_spec_wan_fw_tcp
+ $NFT add rule inet ss_spec ss_spec_wan_ac_udp ip daddr @oversea jump ss_spec_wan_fw_udp
+ $NFT add rule inet ss_spec ss_spec_wan_ac_udp ip saddr @gmlan jump ss_spec_wan_fw_udp
+ $NFT add rule inet ss_spec ss_spec_wan_ac_udp ip daddr @china jump ss_spec_wan_fw_udp
;;
all)
- if $NFT list chain inet ss_spec ss_spec_wan_fw >/dev/null 2>&1; then
- $NFT add rule inet ss_spec ss_spec_wan_ac jump ss_spec_wan_fw
+ if $NFT list chain inet ss_spec ss_spec_wan_fw_tcp >/dev/null 2>&1; then
+ $NFT add rule inet ss_spec ss_spec_wan_ac_tcp jump ss_spec_wan_fw_tcp
+ fi
+ if $NFT list chain inet ss_spec ss_spec_wan_fw_udp >/dev/null 2>&1; then
+ $NFT add rule inet ss_spec ss_spec_wan_ac_udp jump ss_spec_wan_fw_udp
fi
;;
esac
@@ -402,28 +472,68 @@ fw_rule() {
}
fw_rule_nft() {
- # Exclude special local addresses
- if $NFT list chain inet ss_spec ss_spec_wan_fw >/dev/null 2>&1; then
- for net in 0.0.0.0/8 10.0.0.0/8 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.168.0.0/16 224.0.0.0/4 240.0.0.0/4; do
- $NFT add rule inet ss_spec ss_spec_wan_fw ip daddr $net return 2>/dev/null
- done
+ # set up routing table for tproxy
+ if ! ip rule show | grep -Eq "fwmark 0x0*1.*lookup 100"; then
+ ip rule add fwmark 0x01/0x01 table 100 2>/dev/null
+ fi
+
+ if ! ip route show table 100 | grep -q "^local.*dev lo"; then
+ ip route add local 0.0.0.0/0 dev lo table 100 2>/dev/null
fi
# redirect/translation: when PROXY_PORTS present, redirect those tcp ports to local_port
if [ -n "$PROXY_PORTS" ]; then
- PORTS=$(echo "$PROXY_PORTS" | sed 's/-m multiport --dports //')
- RULE="tcp dport { $PORTS } counter redirect to :"$local_port""
+ PORTS_ARGS=$(echo "$PROXY_PORTS" | sed 's/-m multiport --dports //')
+ if [ -n "$PORTS_ARGS" ]; then
+ TCP_EXT_ARGS="meta l4proto tcp tcp dport { $PORTS_ARGS }"
+ UDP_EXT_ARGS="meta l4proto udp udp dport { $PORTS_ARGS }"
+
+ TCP_RULE="meta l4proto tcp tcp dport { $PORTS_ARGS } counter redirect to :$local_port"
+ UDP_RULE="meta l4proto udp udp dport { $PORTS_ARGS } counter tproxy ip to :$local_port meta mark set 0x01"
+ fi
else
+ TCP_EXT_ARGS="meta l4proto tcp"
+ UDP_EXT_ARGS="meta l4proto udp"
+
# default: redirect everything except ssh(22)
- RULE="tcp dport != 22 counter redirect to :"$local_port""
+ TCP_RULE="meta l4proto tcp tcp dport != 22 counter redirect to :$local_port"
+ # default: when PROXY_PORTS present, redirect those udp ports to local_port
+ UDP_RULE="meta l4proto udp counter tproxy ip to :$local_port meta mark set 0x01"
fi
- if ! $NFT list chain inet ss_spec ss_spec_wan_fw 2>/dev/null | grep -q "$RULE"; then
- if ! $NFT add rule inet ss_spec ss_spec_wan_fw $RULE 2>/dev/null; then
- loger 3 "Can't redirect, please check nftables."
+ # add TCP rule to fw chain if not exists (use -F exact match)
+ if ! $NFT list chain inet ss_spec ss_spec_wan_fw_tcp 2>/dev/null | grep -F -- "$TCP_RULE" >/dev/null 2>&1; then
+ if ! $NFT add rule inet ss_spec ss_spec_wan_fw_tcp $TCP_RULE 2>/dev/null; then
+ loger 3 "Can't redirect TCP, please check nftables."
return 1
fi
fi
+ if ! $NFT list chain inet ss_spec ss_spec_wan_fw_udp 2>/dev/null | grep -F -- "$UDP_RULE" >/dev/null 2>&1; then
+ if ! $NFT add rule inet ss_spec ss_spec_wan_fw_udp $UDP_RULE 2>/dev/null; then
+ loger 3 "Can't tproxy UDP, please check nftables."
+ return 1
+ fi
+ fi
+
+ if [ "$SHUNT_PORT" != "0" ] && [ -f "$SHUNT_LIST" ]; then
+ case "$SHUNT_PORT" in
+ 1)
+ $NFT add rule inet ss_spec ss_spec_wan_ac_tcp $TCP_EXT_ARGS ip daddr @netflix counter redirect to :$local_port
+ $NFT add rule inet ss_spec ss_spec_wan_ac_udp $UDP_EXT_ARGS ip daddr @netflix counter tproxy ip to :$local_port meta mark set 0x01
+ ;;
+ *)
+ $NFT add rule inet ss_spec ss_spec_wan_ac_tcp $TCP_EXT_ARGS ip daddr @netflix counter redirect to :$SHUNT_PORT
+ $NFT add rule inet ss_spec ss_spec_wan_ac_udp $UDP_EXT_ARGS ip daddr @netflix counter tproxy ip to :$SHUNT_PORT meta mark set 0x01
+ if [ "$SHUNT_PROXY" = "1" ]; then
+ $NFT add rule inet ss_spec ss_spec_wan_ac_tcp $TCP_EXT_ARGS ip daddr $SHUNT_IP counter redirect to :$local_port
+ $NFT add rule inet ss_spec ss_spec_wan_ac_udp $UDP_EXT_ARGS ip daddr $SHUNT_IP counter tproxy ip to :$local_port meta mark set 0x01
+ else
+ [ -n "$SHUNT_IP" ] && $NFT add element inet ss_spec whitelist "{ $SHUNT_IP }" 2>/dev/null
+ fi
+ ;;
+ esac
+ fi
+
return $?
}
@@ -482,34 +592,67 @@ ac_rule_nft() {
esac
fi
- # 创建ss_spec_prerouting链
- if ! $NFT list chain inet ss_spec ss_spec_prerouting >/dev/null 2>&1; then
- $NFT add chain inet ss_spec ss_spec_prerouting '{ type nat hook prerouting priority 0; policy accept; }'
+ # Create ss_spec_prerouting tcp chain
+ if ! $NFT list chain inet ss_spec ss_spec_prerouting_tcp >/dev/null 2>&1; then
+ $NFT add chain inet ss_spec ss_spec_prerouting_tcp '{ type nat hook prerouting priority 0; policy accept; }'
fi
- $NFT flush chain inet ss_spec ss_spec_prerouting 2>/dev/null
+ $NFT flush chain inet ss_spec ss_spec_prerouting_tcp 2>/dev/null
- # 创建ss_spec_output链
- if ! $NFT list chain inet ss_spec ss_spec_output >/dev/null 2>&1; then
- $NFT add chain inet ss_spec ss_spec_output '{ type nat hook output priority 0; policy accept; }'
+ # Exclude special local addresses
+ if $NFT list chain inet ss_spec ss_spec_prerouting_tcp >/dev/null 2>&1; then
+ for net in 0.0.0.0/8 10.0.0.0/8 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.168.0.0/16 224.0.0.0/4 240.0.0.0/4; do
+ $NFT add rule inet ss_spec ss_spec_prerouting_tcp ip daddr $net return 2>/dev/null
+ done
fi
- $NFT flush chain inet ss_spec ss_spec_output 2>/dev/null
+
+ # 暂注释 IPV6 用于后续开启 IPV6
+ #if $NFT list chain inet ss_spec ss_spec_prerouting_tcp >/dev/null 2>&1; then
+ # for net in ::1/128 fe80::/10 fc00::/7 ff00::/8 ::/128 ::ffff:0:0/96; do
+ # $NFT add rule inet ss_spec ss_spec_prerouting_tcp ip6 daddr $net return 2>/dev/null
+ # done
+ #fi
+
+ # Create ss_spec_prerouting udp chain
+ if ! $NFT list chain inet ss_spec ss_spec_prerouting_udp >/dev/null 2>&1; then
+ $NFT add chain inet ss_spec ss_spec_prerouting_udp '{ type filter hook prerouting priority -150; policy accept; }'
+ fi
+ $NFT flush chain inet ss_spec ss_spec_prerouting_udp 2>/dev/null
+
+ # Exclude special local addresses
+ if $NFT list chain inet ss_spec ss_spec_prerouting_udp >/dev/null 2>&1; then
+ for net in 0.0.0.0/8 10.0.0.0/8 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.168.0.0/16 224.0.0.0/4 240.0.0.0/4; do
+ $NFT add rule inet ss_spec ss_spec_prerouting_udp ip daddr $net return 2>/dev/null
+ done
+ fi
+
+ # 暂注释 IPV6 用于后续开启 IPV6
+ #if $NFT list chain inet ss_spec ss_spec_prerouting_udp >/dev/null 2>&1; then
+ # for net in ::1/128 fe80::/10 fc00::/7 ff00::/8 ::/128 ::ffff:0:0/96; do
+ # $NFT add rule inet ss_spec ss_spec_prerouting_udp ip6 daddr $net return 2>/dev/null
+ # done
+ #fi
# Build a rule in the prerouting hook chain that jumps to business chain with conditions
- EXT_ARGS=""
if [ -n "$PROXY_PORTS" ]; then
PORTS_ARGS=$(echo "$PROXY_PORTS" | sed 's/-m multiport --dports //')
if [ -n "$PORTS_ARGS" ]; then
- EXT_ARGS="th dport { $PORTS_ARGS }"
+ TCP_EXT_ARGS="meta l4proto tcp tcp dport { $PORTS_ARGS }"
+ UDP_EXT_ARGS="meta l4proto udp udp dport { $PORTS_ARGS }"
fi
+ else
+ TCP_EXT_ARGS="meta l4proto tcp"
+ UDP_EXT_ARGS="meta l4proto udp"
fi
if [ -z "$Interface" ]; then
# generic prerouting jump already exists (see ipset_nft), but if we have MATCH_SET_CONDITION we add a more specific rule
if [ -n "$MATCH_SET" ]; then
# add a more specific rule at the top of ss_spec_prerouting
- $NFT insert rule inet ss_spec ss_spec_prerouting meta l4proto tcp $EXT_ARGS $MATCH_SET jump ss_spec_wan_ac comment "\"$TAG\"" 2>/dev/null
+ $NFT add rule inet ss_spec ss_spec_prerouting_tcp $TCP_EXT_ARGS $MATCH_SET jump ss_spec_wan_ac_tcp comment "\"$TAG\"" 2>/dev/null
+ $NFT add rule inet ss_spec ss_spec_prerouting_udp $UDP_EXT_ARGS $MATCH_SET jump ss_spec_wan_ac_udp comment "\"$TAG\"" 2>/dev/null
else
- $NFT insert rule inet ss_spec ss_spec_prerouting meta l4proto tcp $EXT_ARGS jump ss_spec_wan_ac comment "\"$TAG\"" 2>/dev/null
+ $NFT add rule inet ss_spec ss_spec_prerouting_tcp $TCP_EXT_ARGS jump ss_spec_wan_ac_tcp comment "\"$TAG\"" 2>/dev/null
+ $NFT add rule inet ss_spec ss_spec_prerouting_udp $UDP_EXT_ARGS jump ss_spec_wan_ac_udp comment "\"$TAG\"" 2>/dev/null
fi
else
# For each Interface, find its actual ifname and add an iifname-limited prerouting rule
@@ -518,9 +661,11 @@ ac_rule_nft() {
[ -z "$IFNAME" ] && IFNAME=$(uci -P /var/state get network."$name".device 2>/dev/null)
if [ -n "$IFNAME" ]; then
if [ -n "$MATCH_SET" ]; then
- $NFT insert rule inet ss_spec ss_spec_prerouting meta iifname "$IFNAME" meta l4proto tcp $EXT_ARGS $MATCH_SET jump ss_spec_wan_ac comment "\"$TAG\"" 2>/dev/null
+ $NFT add rule inet ss_spec ss_spec_prerouting_tcp meta iifname "$IFNAME" $TCP_EXT_ARGS $MATCH_SET jump ss_spec_wan_ac_tcp comment "\"$TAG\"" 2>/dev/null
+ $NFT add rule inet ss_spec ss_spec_prerouting_udp meta iifname "$IFNAME" $UDP_EXT_ARGS $MATCH_SET jump ss_spec_wan_ac_udp comment "\"$TAG\"" 2>/dev/null
else
- $NFT insert rule inet ss_spec ss_spec_prerouting meta iifname "$IFNAME" meta l4proto tcp $EXT_ARGS jump ss_spec_wan_ac comment "\"$TAG\"" 2>/dev/null
+ $NFT add rule inet ss_spec ss_spec_prerouting_tcp meta iifname "$IFNAME" $TCP_EXT_ARGS jump ss_spec_wan_ac_tcp comment "\"$TAG\"" 2>/dev/null
+ $NFT add rule inet ss_spec ss_spec_prerouting_udp meta iifname "$IFNAME" $UDP_EXT_ARGS jump ss_spec_wan_ac_udp comment "\"$TAG\"" 2>/dev/null
fi
fi
done
@@ -528,8 +673,51 @@ ac_rule_nft() {
case "$OUTPUT" in
1)
+ # Create ss_spec_output tcp chain
+ if ! $NFT list chain inet ss_spec ss_spec_output_tcp >/dev/null 2>&1; then
+ $NFT add chain inet ss_spec ss_spec_output_tcp '{ type nat hook output priority 0; policy accept; }'
+ fi
+ $NFT flush chain inet ss_spec ss_spec_output_tcp 2>/dev/null
+
+ # Exclude special local addresses
+ if $NFT list chain inet ss_spec ss_spec_output_tcp >/dev/null 2>&1; then
+ for net in 0.0.0.0/8 10.0.0.0/8 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.168.0.0/16 224.0.0.0/4 240.0.0.0/4; do
+ $NFT add rule inet ss_spec ss_spec_output_tcp ip daddr $net return 2>/dev/null
+ done
+ fi
+
+ # 暂注释 IPV6 用于后续开启 IPV6
+ #if $NFT list chain inet ss_spec ss_spec_output_tcp >/dev/null 2>&1; then
+ # for net in ::1/128 fe80::/10 fc00::/7 ff00::/8 ::/128 ::ffff:0:0/96; do
+ # $NFT add rule inet ss_spec ss_spec_output_tcp ip6 daddr $net return 2>/dev/null
+ # done
+ #fi
+
# create output hook chain & route output traffic into router chain
- $NFT insert rule inet ss_spec ss_spec_output meta l4proto tcp $EXT_ARGS jump ss_spec_wan_ac comment "\"$TAG\"" 2>/dev/null
+ $NFT add rule inet ss_spec ss_spec_output_tcp $TCP_EXT_ARGS jump ss_spec_wan_ac_tcp comment "\"$TAG\"" 2>/dev/null
+
+ # Create ss_spec_output udp chain
+ if ! $NFT list chain inet ss_spec ss_spec_output_udp >/dev/null 2>&1; then
+ $NFT add chain inet ss_spec ss_spec_output_udp '{ type filter hook output priority -150; policy accept; }'
+ fi
+ $NFT flush chain inet ss_spec ss_spec_output_udp 2>/dev/null
+
+ # Exclude special local addresses
+ if $NFT list chain inet ss_spec ss_spec_output_udp >/dev/null 2>&1; then
+ for net in 0.0.0.0/8 10.0.0.0/8 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.168.0.0/16 224.0.0.0/4 240.0.0.0/4; do
+ $NFT add rule inet ss_spec ss_spec_output_udp ip daddr $net return 2>/dev/null
+ done
+ fi
+
+ # 暂注释 IPV6 用于后续开启 IPV6
+ #if $NFT list chain inet ss_spec ss_spec_output_udp >/dev/null 2>&1; then
+ # for net in ::1/128 fe80::/10 fc00::/7 ff00::/8 ::/128 ::ffff:0:0/96; do
+ # $NFT add rule inet ss_spec ss_spec_output_udp ip6 daddr $net return 2>/dev/null
+ # done
+ #fi
+
+ # create output hook chain & route output traffic into router chain
+ $NFT add rule inet ss_spec ss_spec_output_udp $UDP_EXT_ARGS meta mark set 0x01 comment "\"$TAG\"" 2>/dev/null
;;
2)
# router mode output chain: create ssr_gen_router set & router chain
@@ -539,8 +727,10 @@ ac_rule_nft() {
done
$NFT add chain inet ss_spec ss_spec_router 2>/dev/null
$NFT add rule inet ss_spec ss_spec_router ip daddr @ssr_gen_router return 2>/dev/null
- $NFT add rule inet ss_spec ss_spec_router jump ss_spec_wan_fw 2>/dev/null
- $NFT add rule inet ss_spec ss_spec_output meta l4proto tcp $EXT_ARGS jump ss_spec_router comment "\"$TAG\"" 2>/dev/null
+ $NFT add rule inet ss_spec ss_spec_router jump ss_spec_wan_fw_tcp 2>/dev/null
+ $NFT add rule inet ss_spec ss_spec_output $TCP_EXT_ARGS jump ss_spec_router comment "\"$TAG\"" 2>/dev/null
+ $NFT add rule inet ss_spec ss_spec_router jump ss_spec_wan_fw_udp 2>/dev/null
+ $NFT add rule inet ss_spec ss_spec_output $UDP_EXT_ARGS jump ss_spec_router comment "\"$TAG\"" 2>/dev/null
;;
esac
return 0
@@ -605,8 +795,13 @@ tp_rule() {
tp_rule_nft() {
# set up routing table for tproxy
- ip rule add fwmark 0x01/0x01 table 100 2>/dev/null
- ip route add local 0.0.0.0/0 dev lo table 100 2>/dev/null
+ if ! ip rule show | grep -Eq "fwmark 0x0*1.*lookup 100"; then
+ ip rule add fwmark 0x01/0x01 table 100 2>/dev/null
+ fi
+
+ if ! ip route show table 100 | grep -q "^local.*dev lo"; then
+ ip route add local 0.0.0.0/0 dev lo table 100 2>/dev/null
+ fi
# create mangle table and tproxy chain
if ! $NFT list table ip ss_spec_mangle >/dev/null 2>&1; then
@@ -615,20 +810,15 @@ tp_rule_nft() {
local MATCH_SET=""
- EXT_ARGS=""
if [ -n "$PROXY_PORTS" ]; then
PORTS_ARGS=$(echo "$PROXY_PORTS" | sed 's/-m multiport --dports //')
if [ -n "$PORTS_ARGS" ]; then
- EXT_ARGS="th dport { $PORTS_ARGS }"
+ EXT_ARGS="udp dport { $PORTS_ARGS }"
else
EXT_ARGS=""
fi
fi
- # 有端口 => 1,无端口 => 0
- HAS_PORTS=0
- [ -n "$EXT_ARGS" ] && HAS_PORTS=1
-
if [ -n "$LAN_AC_IP" ]; then
# Create LAN access control set if needed
if ! $NFT list set ip ss_spec_mangle ss_spec_lan_ac >/dev/null 2>&1; then
@@ -664,7 +854,7 @@ tp_rule_nft() {
fi
done
- # 批量导入中国IP列表
+ # Bulk import china ip list safely (avoid huge single element limitation)
if [ -f "${china_ip:=/etc/ssrplus/china_ssr.txt}" ]; then
$NFT add element ip ss_spec_mangle china "{ $(tr '\n' ',' < "${china_ip}" | sed 's/,$//') }" 2>/dev/null
fi
@@ -682,12 +872,19 @@ tp_rule_nft() {
done
fi
+ # 暂注释 IPV6 用于后续开启 IPV6
+ #if $NFT list chain ip ss_spec_mangle ss_spec_tproxy >/dev/null 2>&1; then
+ # for net in ::1/128 fe80::/10 fc00::/7 ff00::/8 fe80::/10 ::/128 ::ffff:0:0/96; do
+ # $NFT add rule ip ss_spec_mangle ss_spec_tproxy ip6 daddr $net return 2>/dev/null
+ # done
+ #fi
+
# basic return rules in tproxy chain
- $NFT add rule ip ss_spec_mangle ss_spec_tproxy udp dport 53 return 2>/dev/null
+ $NFT add rule ip ss_spec_mangle ss_spec_tproxy meta l4proto udp dport 53 return 2>/dev/null
# avoid redirecting to udp server address
if [ -n "$server" ]; then
- $NFT add rule ip ss_spec_mangle ss_spec_tproxy udp dport != 53 ip daddr "$server" return 2>/dev/null
+ $NFT add rule ip ss_spec_mangle ss_spec_tproxy meta l4proto udp dport != 53 ip daddr "$server" return 2>/dev/null
fi
# if server != SERVER add SERVER to whitelist set (so tproxy won't touch it)
@@ -700,12 +897,7 @@ tp_rule_nft() {
# access control and tproxy rules
$NFT add rule ip ss_spec_mangle ss_spec_tproxy meta l4proto udp ip saddr @bplan return 2>/dev/null
-
- if [ $HAS_PORTS -eq 1 ]; then
- $NFT add rule ip ss_spec_mangle ss_spec_tproxy meta l4proto udp $EXT_ARGS ip saddr @fplan counter tproxy ip to :"$LOCAL_PORT" meta mark set 0x01
- else
- $NFT add rule ip ss_spec_mangle ss_spec_tproxy meta l4proto udp ip saddr @fplan counter tproxy ip to :"$LOCAL_PORT" meta mark set 0x01 2>/dev/null
- fi
+ $NFT add rule ip ss_spec_mangle ss_spec_tproxy meta l4proto udp $EXT_ARGS ip saddr @fplan counter tproxy ip to :"$LOCAL_PORT" meta mark set 0x01 2>/dev/null
# Handle different run modes for nftables
case "$RUNMODE" in
@@ -722,50 +914,38 @@ tp_rule_nft() {
$NFT add rule ip ss_spec_mangle ss_spec_tproxy meta l4proto udp ip daddr @ss_spec_wan_ac return 2>/dev/null
$NFT add rule ip ss_spec_mangle ss_spec_tproxy meta l4proto udp ip daddr @china return 2>/dev/null
- $NFT add rule ip ss_spec_mangle ss_spec_tproxy meta l4proto udp dport 80 drop 2>/dev/null
+ $NFT add rule ip ss_spec_mangle ss_spec_tproxy meta l4proto udp udp dport 80 drop 2>/dev/null
$NFT add rule ip ss_spec_mangle ss_spec_tproxy meta l4proto udp ip saddr @gmlan ip daddr != @china counter tproxy ip to :"$LOCAL_PORT" meta mark set 0x01 2>/dev/null
- if [ $HAS_PORTS -eq 1 ]; then
- $NFT add rule ip ss_spec_mangle ss_spec_tproxy meta l4proto udp $EXT_ARGS ip daddr != @ss_spec_wan_ac counter tproxy ip to :"$LOCAL_PORT" meta mark set 0x01 2>/dev/null
- else
- $NFT add rule ip ss_spec_mangle ss_spec_tproxy meta l4proto udp ip daddr != @ss_spec_wan_ac counter tproxy ip to :"$LOCAL_PORT" meta mark set 0x01 2>/dev/null
- fi
+ $NFT add rule ip ss_spec_mangle ss_spec_tproxy meta l4proto udp $EXT_ARGS ip daddr != @ss_spec_wan_ac counter tproxy ip to :"$LOCAL_PORT" meta mark set 0x01 2>/dev/null
;;
gfw)
- $NFT add rule ip ss_spec_mangle ss_spec_tproxy meta l4proto udp ip daddr @china return 2>/dev/null
- $NFT add rule ip ss_spec_mangle ss_spec_tproxy meta l4proto udp dport 80 drop 2>/dev/null
- if [ $HAS_PORTS -eq 1 ]; then
- $NFT add rule ip ss_spec_mangle ss_spec_tproxy meta l4proto udp $EXT_ARGS ip daddr @gfwlist counter tproxy ip to :"$LOCAL_PORT" meta mark set 0x01 2>/dev/null
+ if ! $NFT list set ip ss_spec_mangle gfwlist >/dev/null 2>&1; then
+ $NFT add set ip ss_spec_mangle gfwlist '{ type ipv4_addr; flags interval; auto-merge; }' 2>/dev/null
fi
+ $NFT add rule ip ss_spec_mangle ss_spec_tproxy meta l4proto udp ip daddr @china return 2>/dev/null
+ $NFT add rule ip ss_spec_mangle ss_spec_tproxy meta l4proto udp udp dport 80 drop 2>/dev/null
+ $NFT add rule ip ss_spec_mangle ss_spec_tproxy meta l4proto udp $EXT_ARGS ip daddr @gfwlist counter tproxy ip to :"$LOCAL_PORT" meta mark set 0x01 2>/dev/null
$NFT add rule ip ss_spec_mangle ss_spec_tproxy meta l4proto udp ip saddr @gmlan ip daddr != @china counter tproxy ip to :"$LOCAL_PORT" meta mark set 0x01 2>/dev/null
;;
oversea)
if ! $NFT list set ip ss_spec_mangle oversea >/dev/null 2>&1; then
$NFT add set ip ss_spec_mangle oversea '{ type ipv4_addr; flags interval; auto-merge; }' 2>/dev/null
fi
- if ! $NFT list set ip ss_spec_mangle china >/dev/null 2>&1; then
- $NFT add set ip ss_spec_mangle china '{ type ipv4_addr; flags interval; auto-merge; }' 2>/dev/null
- fi
- if [ $HAS_PORTS -eq 1 ]; then
- $NFT add rule ip ss_spec_mangle ss_spec_tproxy meta l4proto udp $EXT_ARGS ip saddr @oversea counter tproxy ip to :"$LOCAL_PORT" meta mark set 0x01 2>/dev/null
- $NFT add rule ip ss_spec_mangle ss_spec_tproxy meta l4proto udp $EXT_ARGS ip daddr @china counter tproxy ip to :"$LOCAL_PORT" meta mark set 0x01 2>/dev/null
- fi
+ $NFT add rule ip ss_spec_mangle ss_spec_tproxy meta l4proto udp $EXT_ARGS ip saddr @oversea counter tproxy ip to :"$LOCAL_PORT" meta mark set 0x01 2>/dev/null
+ $NFT add rule ip ss_spec_mangle ss_spec_tproxy meta l4proto udp $EXT_ARGS ip daddr @china counter tproxy ip to :"$LOCAL_PORT" meta mark set 0x01 2>/dev/null
$NFT add rule ip ss_spec_mangle ss_spec_tproxy meta l4proto udp ip saddr @gmlan counter tproxy ip to :"$LOCAL_PORT" meta mark set 0x01 2>/dev/null
;;
all)
- if [ $HAS_PORTS -eq 1 ]; then
- $NFT add rule ip ss_spec_mangle ss_spec_tproxy meta l4proto udp $EXT_ARGS counter tproxy ip to :"$LOCAL_PORT" meta mark set 0x01 2>/dev/null
- else
- $NFT add rule ip ss_spec_mangle ss_spec_tproxy meta l4proto udp counter tproxy ip to :"$LOCAL_PORT" meta mark set 0x01 2>/dev/null
- fi
+ $NFT add rule ip ss_spec_mangle ss_spec_tproxy meta l4proto udp $EXT_ARGS counter tproxy ip to :"$LOCAL_PORT" meta mark set 0x01 2>/dev/null
;;
esac
- # 创建 prerouting 链(hook prerouting)
+ # finally, ensure prerouting hook entry to jump to tproxy chain
if ! $NFT list chain ip ss_spec_mangle prerouting >/dev/null 2>&1; then
$NFT add chain ip ss_spec_mangle prerouting '{ type filter hook prerouting priority mangle; policy accept; }'
fi
- # 添加规则到 prerouting 链
+ # add prerouting jump (idempotent)
if [ -z "$Interface" ]; then
# 全局规则
if [ -n "$MATCH_SET" ]; then
@@ -884,17 +1064,23 @@ gen_include() {
return $?
}
+# 修改gen_include_nft,调用持久化功能
gen_include_nft() {
# Generate nftables include file for firewall4
[ -n "$FWI" ] && echo '#!/bin/sh' >"$FWI"
- cat <<-'EOF' >>"$FWI"
+ cat <<-EOF >>"$FWI"
# Clear existing ss_spec tables
nft delete table inet ss_spec 2>/dev/null
nft delete table ip ss_spec 2>/dev/null
nft delete table ip ss_spec_mangle 2>/dev/null
- # Restore shadowsocks nftables rules
- nft list ruleset | awk '/^table (inet|ip) ss_spec/{flag=1} /^table / && !/^table (inet|ip) ss_spec/{flag=0} flag'
+ # Restore shadowsocks nftables rules from persistent file
+ if [ -f "/usr/share/nftables.d/ruleset-post/99-shadowsocksr.nft" ]; then
+ nft -f /usr/share/nftables.d/ruleset-post/99-shadowsocksr.nft
+ else
+ # Fallback: restore from current ruleset (filtered)
+ nft list ruleset | awk '/^table (inet|ip) ss_spec/{flag=1} /^table / && !/^table (inet|ip) ss_spec/{flag=0} flag' | nft -f -
+ fi
EOF
chmod +x "$FWI"
}
@@ -914,7 +1100,273 @@ gen_include_iptables() {
EOF
}
-while getopts ":m:s:l:S:L:i:e:a:B:b:w:p:G:D:F:N:M:I:oOuUfgrczh" arg; do
+# 检查 nftables 规则状态
+check_nftables_status() {
+ if [ "$USE_NFT" != "1" ]; then
+ echo "NFTables not in use"
+ return 0
+ fi
+
+ # 检查ss_spec表是否存在
+ if ! $NFT list table inet ss_spec >/dev/null 2>&1 && \
+ ! $NFT list table ip ss_spec_mangle >/dev/null 2>&1; then
+ echo "ss_spec tables missing in nftables"
+ return 1
+ fi
+
+ # 检查是否有基本规则
+ if ! $NFT list table inet ss_spec 2>/dev/null | grep -q "chain.*ss_spec_wan_ac" || \
+ ! $NFT list table inet ss_spec 2>/dev/null | grep -q "jump.*ss_spec_wan_fw"; then
+ echo "Basic SSR rules missing"
+ return 1
+ fi
+
+ echo "NFTables rules status: OK"
+ return 0
+}
+
+# 比较当前规则与持久化规则
+compare_rules() {
+ if [ "$USE_NFT" != "1" ]; then
+ return 1 # NFTables未使用,需要更新
+ fi
+
+ # 如果没有持久化文件,更新持久化文件
+ if [ ! -f "$NFTABLES_RULES_FILE" ]; then
+ loger 6 "No persistence file found, update needed"
+ return 1 # 需要更新持久化文件
+ fi
+
+ # 检查ss_spec表是否存在
+ if ! $NFT list table inet ss_spec >/dev/null 2>&1 && \
+ ! $NFT list table ip ss_spec_mangle >/dev/null 2>&1; then
+ loger 6 "ss_spec tables missing, update needed"
+ return 1 # 需要更新ss_spec表
+ fi
+
+ # 生成当前规则的临时文件
+ local temp_file=$(mktemp)
+ local rules_file=$(mktemp)
+ loger 7 "DEBUG: Temporary file path: $current_rules_file"
+
+ # 导出当前规则到临时文件
+ $NFT list ruleset | awk '
+ /^table (inet ss_spec|ip ss_spec_mangle)/ {flag=1}
+ /^table / && !/^table (inet ss_spec|ip ss_spec_mangle)/ {flag=0}
+ flag
+ ' > "$rules_file" 2>/dev/null
+
+ # 检查是否成功导出了当前规则
+ if [ ! -s "$rules_file" ] || ! grep -q "table" "$rules_file" 2>/dev/null; then
+ loger 4 "Failed to export current rules"
+ rm -f "$temp_file" "$rules_file"
+ return 1 # 导出失败,需要更新
+ fi
+
+ # 比较当前规则与持久化文件中的规则
+ if ! cmp -s "$rules_file" "$NFTABLES_RULES_FILE"; then
+ loger 6 "Rules differ, update needed"
+ rm -f "$temp_file" "$rules_file"
+ return 1 # 需要更新
+ fi
+
+ rm -f "$temp_file" "$rules_file"
+ loger 6 "Rules unchanged, no update needed"
+ return 0 # 无需更新
+}
+
+# 自动更新持久化规则
+persist_nftables_rules() {
+ if [ "$USE_NFT" != "1" ]; then
+ return 0
+ fi
+
+ # 如果模式未改变且存在持久化文件,跳过更新
+ if [ "$MODE_CHANGED" = "0" ] && [ -f "$NFTABLES_RULES_FILE" ]; then
+ loger 6 "Mode unchanged and persistence file exists, skipping update"
+ return 0
+ fi
+
+ # 强制更新时,跳过比较检查并删除旧文件
+ if [ "$FORCE_UPDATE" = "1" ]; then
+ loger 6 "Force update requested, removing old persistence file"
+ rm -f "$NFTABLES_RULES_FILE" 2>/dev/null
+ # 非强制更新时,进行规则比较
+ elif [ -f "$NFTABLES_RULES_FILE" ]; then
+ if compare_rules; then
+ loger 6 "Rules unchanged, skipping persistence update"
+ return 0
+ fi
+ fi
+
+ # 确保目录存在
+ mkdir -p "$NFTABLES_RULES_DIR" 2>/dev/null
+
+ # 生成nftables规则文件
+ cat <<-'EOF' >>$NFTABLES_RULES_FILE
+ #!/usr/sbin/nft -f
+
+ # ShadowsocksR nftables rules
+ # Generated by ssr-rules script
+ EOF
+
+ echo "# Auto-updated: $(date)" >> "$NFTABLES_RULES_FILE"
+ echo "# Runmode: ${RUNMODE:-router}" >> "$NFTABLES_RULES_FILE"
+ echo "# Server: $server, Port: $local_port" >> "$NFTABLES_RULES_FILE"
+ echo "" >> "$NFTABLES_RULES_FILE"
+
+ local HAS_RULES=0
+
+ # 分别导出每个表
+ if $NFT list table inet ss_spec >/dev/null 2>&1; then
+ loger 6 "Exporting table inet ss_spec"
+ {
+ echo ""
+ echo "# inet ss_spec table for main rules"
+ $NFT list table inet ss_spec 2>/dev/null
+ } >> "$NFTABLES_RULES_FILE"
+ HAS_RULES=1
+ fi
+
+ if $NFT list table ip ss_spec_mangle >/dev/null 2>&1; then
+ loger 6 "Exporting table ip ss_spec_mangle"
+ {
+ echo ""
+ echo "# ip ss_spec_mangle table for TPROXY rules"
+ $NFT list table ip ss_spec_mangle 2>/dev/null
+ } >> "$NFTABLES_RULES_FILE"
+ HAS_RULES=1
+ fi
+
+ # 检查是否成功导出了规则
+ if [ $HAS_RULES -eq 0 ] || [ ! -s "$NFTABLES_RULES_FILE" ] || ! grep -q "table" "$NFTABLES_RULES_FILE" 2>/dev/null; then
+ loger 4 "No ss_spec nftables rules found to persist"
+ rm -f "$NFTABLES_RULES_FILE" 2>/dev/null
+ return 1
+ fi
+
+ # 设置文件权限
+ chmod 644 "$NFTABLES_RULES_FILE" 2>/dev/null
+
+ # 记录成功信息
+ local TABLES=$(grep "^table" "$NFTABLES_RULES_FILE" | awk '{print $2 " " $3}' | tr '\n' ',' | sed 's/,$//')
+ loger 5 "NFTables rules persisted to $NFTABLES_RULES_FILE (Tables: $TABLES)"
+
+ return 0
+}
+
+# 自动更新守护进程
+start_auto_update_daemon() {
+ if [ "$USE_NFT" != "1" ] || [ "$AUTO_UPDATE_INTERVAL" = "0" ]; then
+ return 0
+ fi
+
+ loger 6 "Starting nftables rules auto-update daemon"
+
+ # 停止已经运行的守护进程
+ stop_auto_update_daemon
+
+ # 直接在后台启动守护进程
+ (
+ logger -t ssr-rules[daemon] "Auto-update daemon started - PID: $$"
+ echo $$ > "/var/run/ssr-rules-daemon.pid"
+
+ while true; do
+ sleep 300
+ if [ -x "/usr/bin/ssr-rules" ]; then
+ if /usr/bin/ssr-rules -C >/dev/null 2>&1; then
+ logger -t ssr-rules[daemon] "Rules changed or missing, updating persistence"
+ if /usr/bin/ssr-rules -P >/dev/null 2>&1; then
+ logger -t ssr-rules[daemon] "Persistence rules updated successfully"
+ else
+ logger -t ssr-rules[daemon] "Failed to update persistence"
+ fi
+ else
+ logger -t ssr-rules[daemon] "Rules status OK, no update needed"
+ fi
+ else
+ logger -t ssr-rules[daemon] "Script not found, exiting daemon"
+ exit 1
+ fi
+ done
+ ) &
+
+ local DAEMON_PID=$!
+ sleep 2
+
+ if kill -0 "$DAEMON_PID" 2>/dev/null; then
+ loger 6 "Auto-update daemon started with PID: $DAEMON_PID"
+ return 0
+ else
+ loger 3 "Auto-update daemon failed to start"
+ return 1
+ fi
+}
+
+# 停止自动更新守护进程函数
+stop_auto_update_daemon() {
+ local PID_FILE="/var/run/ssr-rules-daemon.pid"
+
+ if [ -f "$PID_FILE" ]; then
+ local DAEMON_PID=$(cat "$PID_FILE" 2>/dev/null)
+ if [ -n "$DAEMON_PID" ] && kill -0 "$DAEMON_PID" 2>/dev/null; then
+ kill "$DAEMON_PID" 2>/dev/null
+ loger 6 "Stopped auto-update daemon (PID: $DAEMON_PID)"
+ fi
+ rm -f "$PID_FILE" 2>/dev/null
+ fi
+
+ loger 6 "Auto-update daemon stopped"
+}
+
+# 强制更新持久化规则函数
+force_update_persistence() {
+ if [ "$USE_NFT" != "1" ]; then
+ echo "NFTables not in use"
+ return 0
+ fi
+
+ # 移除现有规则文件确保重新创建
+ rm -f "$NFTABLES_RULES_FILE" 2>/dev/null
+
+ # 调用持久化函数
+ if persist_nftables_rules; then
+ loger 5 "Persistence update completed successfully"
+ return 0
+ else
+ loger 3 "Persistence update failed"
+ return 1
+ fi
+}
+
+# 从持久化文件恢复规则
+restore_from_persistence() {
+ if [ "$USE_NFT" != "1" ]; then
+ loger 3 "NFTables not in use, cannot restore rules"
+ return 1
+ fi
+
+ if [ ! -f "$NFTABLES_RULES_FILE" ]; then
+ loger 4 "Persistence file not found: $NFTABLES_RULES_FILE"
+ return 1
+ fi
+
+ loger 6 "Restoring rules from persistence file"
+
+ # 清理现有规则
+ flush_r
+
+ # 从文件恢复规则
+ if $NFT -f "$NFTABLES_RULES_FILE" 2>/dev/null; then
+ loger 5 "Rules restored successfully from persistence file"
+ return 0
+ else
+ loger 4 "Failed to restore rules from persistence file"
+ return 1
+ fi
+}
+
+while getopts ":m:s:l:S:L:i:e:a:B:b:w:p:G:D:F:N:M:I:oOuUfgrczAKPCRXh" arg; do
case "$arg" in
m)
Interface=$OPTARG
@@ -994,58 +1446,283 @@ while getopts ":m:s:l:S:L:i:e:a:B:b:w:p:G:D:F:N:M:I:oOuUfgrczh" arg; do
z)
RUNMODE=all
;;
- f)
- flush_r
- exit 0
+ # 新增持久化管理选项
+ A)
+ ENABLE_AUTO_UPDATE=1
+ ;;
+ K)
+ STOP_AUTO_UPDATE=1
+ ;;
+ P)
+ FORCE_UPDATE=1
+ ;;
+ C)
+ CHECK_STATUS=1
+ ;;
+ R)
+ RESTORE_RULES=1
+ ;;
+ X)
+ CLEANUP_PERSISTENCE=1
+ ;;
+ f)
+ FLUSH_RULES=1
+ ;;
+ h)
+ usage 0
;;
- h) usage 0 ;;
esac
done
-if [ -z "$server" ] || [ -z "$local_port" ]; then
- usage 2
+# 首先处理需要立即退出的选项
+if [ "$CHECK_STATUS" = "1" ]; then
+ check_nftables_status
+ exit $?
fi
-if ! echo "$local_port" | grep -qE '^[0-9]+$'; then
- loger 3 "Invalid local port: $local_port"
- exit 1
+if [ "$STOP_AUTO_UPDATE" = "1" ]; then
+ stop_auto_update_daemon
+ exit 0
fi
-case "$TPROXY" in
-1)
- SERVER=$server
- LOCAL_PORT=$local_port
- ;;
-2)
- : ${SERVER:?"You must assign an ip for the udp relay server."}
- : ${LOCAL_PORT:?"You must assign a port for the udp relay server."}
- ;;
-esac
-
-# First check whether nftables is working properly
-if [ "$USE_NFT" = "1" ]; then
- if ! $NFT list tables 2>/dev/null; then
- loger 3 "nftables is not working properly, check if nftables is installed and running"
- exit 1
- fi
+# 只有-X选项,执行清理后退出
+if [ "$CLEANUP_PERSISTENCE" = "1" ] && [ "$FLUSH_RULES" != "1" ] && [ -z "$server" ] && [ -z "$local_port" ] && \
+ [ "$FORCE_UPDATE" != "1" ] && [ "$RESTORE_RULES" != "1" ] && [ "$ENABLE_AUTO_UPDATE" != "1" ]; then
+ cleanup_persistence_files
+ exit $?
fi
-if [ "$USE_NFT" = "1" ]; then
- # NFTables
- if flush_r && ipset_r && fw_rule && ac_rule && tp_rule && gen_include; then
- loger 5 "NFTables rules applied successfully"
- exit 0
+# 检查是否有持久化管理选项单独处理
+PERSISTENCE_ONLY=0
+if [ -z "$server" ] && [ -z "$local_port" ] && [ "$FLUSH_RULES" != "1" ]; then
+ if [ "$FORCE_UPDATE" = "1" ] || [ "$RESTORE_RULES" = "1" ] || [ "$ENABLE_AUTO_UPDATE" = "1" ] || [ "$CLEANUP_PERSISTENCE" = "1" ]; then
+ PERSISTENCE_ONLY=1
else
- loger 3 "NFTables setup failed!"
- exit 1
- fi
-else
- # iptables
- if flush_r && fw_rule && ipset_r && ac_rule && tp_rule && gen_include; then
- loger 5 "iptables rules applied successfully"
- exit 0
- else
- loger 3 "iptables setup failed!"
- exit 1
+ usage 2
+ fi
+fi
+
+# 处理持久化管理选项的情况
+if [ "$PERSISTENCE_ONLY" = "1" ]; then
+ if [ "$FORCE_UPDATE" = "1" ]; then
+ force_update_persistence
+ exit $?
+ fi
+
+ if [ "$RESTORE_RULES" = "1" ]; then
+ restore_from_persistence
+ exit $?
+ fi
+
+ if [ "$ENABLE_AUTO_UPDATE" = "1" ]; then
+ start_auto_update_daemon
+ exit $?
+ fi
+fi
+
+# 强制刷新规则
+if [ "$FLUSH_RULES" = "1" ]; then
+ flush_r
+ # 如果只有 -f 选项,则退出
+ if [ -z "$server" ] && [ -z "$local_port" ] && [ "$FORCE_UPDATE" != "1" ] && \
+ [ "$RESTORE_RULES" != "1" ] && [ "$ENABLE_AUTO_UPDATE" != "1" ] && \
+ [ "$CLEANUP_PERSISTENCE" != "1" ]; then
+ exit 0
+ fi
+fi
+
+# 从持久化文件恢复规则(在规则应用之前)
+if [ "$RESTORE_RULES" = "1" ]; then
+ restore_from_persistence
+ if [ $? -ne 0 ]; then
+ loger 3 "Failed to restore from persistence, continuing with rule application"
+ fi
+fi
+
+# 运行模式更改
+runmode_change() {
+ local mode_file="/tmp/.ssr_run_mode"
+ local new_mode=""
+ local old_mode=""
+
+ # 从参数获取模式
+ if [ -n "$1" ]; then
+ new_mode="$1"
+ fi
+
+ # 从文件中读取上一次的运行模式
+ if [ -f "$mode_file" ]; then
+ old_mode=$(cat "$mode_file" 2>/dev/null)
+ fi
+
+ # 比较模式是否改变
+ if [ "$old_mode" = "$new_mode" ] && [ -n "$old_mode" ]; then
+ # 模式未改变
+ echo "$new_mode" > "$mode_file" # 更新文件时间戳
+ loger 6 "Runmode unchanged: $new_mode"
+ return 1 # 返回1表示未改变
+ else
+ # 模式已改变或首次运行
+ echo "$new_mode" > "$mode_file"
+ if [ -n "$old_mode" ]; then
+ loger 6 "Runmode changed from '$old_mode' to '$new_mode'"
+ else
+ loger 6 "Runmode set to '$new_mode'"
+ fi
+ return 0 # 返回0表示已改变
+ fi
+}
+
+# Main process
+if [ -n "$server" ] && [ -n "$local_port" ]; then
+ if ! echo "$local_port" | grep -qE '^[0-9]+$'; then
+ loger 3 "Invalid local port: $local_port"
+ exit 1
+ fi
+
+ case "$TPROXY" in
+ 1)
+ SERVER=$server
+ LOCAL_PORT=$local_port
+ ;;
+ 2)
+ : ${SERVER:?"You must assign an ip for the udp relay server."}
+ : ${LOCAL_PORT:?"You must assign a port for the udp relay server."}
+ ;;
+ esac
+
+ if [ "$USE_NFT" = "1" ]; then
+ # NFTables
+ # 保存上一次 TPROXY 状态文件
+ TPROXY_STATE_FILE="/tmp/.last_tproxy"
+ if [ -f "$TPROXY_STATE_FILE" ]; then
+ LAST_TPROXY=$(cat "$TPROXY_STATE_FILE")
+ else
+ LAST_TPROXY=""
+ fi
+
+ # 保存上一次 PROXY_PORTS 状态
+ PROXY_PORTS_STATE_FILE="/tmp/.last_proxy_ports"
+ if [ -f "$PROXY_PORTS_STATE_FILE" ]; then
+ LAST_PROXY_PORTS=$(cat "$PROXY_PORTS_STATE_FILE")
+ else
+ LAST_PROXY_PORTS=""
+ fi
+
+ # STEP 1: 判断 TPROXY 是否有值(1 或 2)
+ if [ "$TPROXY" = "1" ] || [ "$TPROXY" = "2" ]; then
+ TPROXY_HAS_VALUE=1
+ else
+ TPROXY_HAS_VALUE=0
+ fi
+
+ if [ "$LAST_TPROXY" = "1" ] || [ "$LAST_TPROXY" = "2" ]; then
+ LAST_HAS_VALUE=1
+ else
+ LAST_HAS_VALUE=0
+ fi
+
+ # STEP 2: 判断 PROXY_PORTS 是否有值(非空字符串)
+ if [ -n "${PROXY_PORTS// }" ]; then
+ PROXY_HAS_VALUE=1
+ else
+ PROXY_HAS_VALUE=0
+ fi
+
+ if [ -n "${LAST_PROXY_PORTS// }" ]; then
+ LAST_PROXY_HAS_VALUE=1
+ else
+ LAST_PROXY_HAS_VALUE=0
+ fi
+
+ # STEP 3: 判断是否需要强制重建
+ FORCE_RECREATE=0
+ PERSISTENCE_EXISTS=0
+
+ # 触发条件:
+ # 1. TPROXY 从空 ↔ 有值变化
+ # 2. PROXY_PORTS 从空 ↔ 有值变化
+ if [ "$TPROXY_HAS_VALUE" != "$LAST_HAS_VALUE" ] || [ "$PROXY_HAS_VALUE" != "$LAST_PROXY_HAS_VALUE" ]; then
+ FORCE_RECREATE=1
+ loger 6 "TPROXY or PROXY_PORTS changed → force rebuild rules"
+ rm -f "$NFTABLES_RULES_FILE" 2>/dev/null
+ else
+ # 未触发 FORCE_RECREATE → 检查持久化文件
+ if [ -f "$NFTABLES_RULES_FILE" ] && [ -s "$NFTABLES_RULES_FILE" ]; then
+ PERSISTENCE_EXISTS=1
+ loger 6 "Persistence file exists: $NFTABLES_RULES_FILE"
+ else
+ PERSISTENCE_EXISTS=0
+ loger 6 "Persistence file does not exist or empty"
+ fi
+ fi
+
+ # STEP 4: 保存当前状态
+ echo "$TPROXY" > "$TPROXY_STATE_FILE"
+ echo "$PROXY_PORTS" > "$PROXY_PORTS_STATE_FILE"
+
+ # STEP 5: 判断运行模式是否改变
+ if runmode_change "$RUNMODE"; then
+ MODE_CHANGED=1
+ loger 6 "Runmode changed: MODE_CHANGED=1"
+ else
+ MODE_CHANGED=0
+ loger 6 "Runmode unchanged: MODE_CHANGED=0"
+ fi
+
+ # STEP 6: 模式改变且持久化存在 → 删除一次
+ if [ "$MODE_CHANGED" = "1" ] && [ "$PERSISTENCE_EXISTS" = "1" ]; then
+ loger 6 "Mode changed → removing persistence file"
+ rm -f "$NFTABLES_RULES_FILE"
+ PERSISTENCE_EXISTS=0
+ fi
+
+ # STEP 7: FORCE_RECREATE 优先 → 必须重建规则
+ if [ "$FORCE_RECREATE" = "1" ]; then
+ loger 5 "Forced regeneration of NFTables rules"
+ if flush_r && ipset_r && fw_rule && ac_rule && tp_rule && gen_include; then
+ loger 5 "NFT rules applied successfully (forced rebuild)"
+ persist_nftables_rules
+ [ "$ENABLE_AUTO_UPDATE" = "1" ] && start_auto_update_daemon
+ exit 0
+ else
+ loger 3 "NFT forced rebuild failed!"
+ exit 1
+ fi
+ fi
+
+ # STEP 8: 持久化存在 → 尝试 restore
+ if [ "$PERSISTENCE_EXISTS" = "1" ]; then
+ # 恢复规则
+ if restore_from_persistence; then
+ loger 5 "NFT rules restored from persistence"
+ gen_include
+ [ "$ENABLE_AUTO_UPDATE" = "1" ] && start_auto_update_daemon
+ exit 0
+ else
+ loger 3 "Restore failed → fallback to full setup"
+ PERSISTENCE_EXISTS=0
+ fi
+ fi
+
+ # STEP 9: 持久化不存在或 restore 失败 → 生成新规则
+ if flush_r && ipset_r && fw_rule && ac_rule && tp_rule && gen_include; then
+ loger 5 "NFTables rules applied successfully"
+ persist_nftables_rules
+ [ "$ENABLE_AUTO_UPDATE" = "1" ] && start_auto_update_daemon
+ exit 0
+ else
+ loger 3 "NFTables setup failed!"
+ exit 1
+ fi
+ else
+ # iptables
+ if flush_r && fw_rule && ipset_r && ac_rule && tp_rule && gen_include; then
+ loger 5 "iptables rules applied successfully"
+ exit 0
+ else
+ loger 3 "iptables setup failed!"
+ exit 1
+ fi
fi
fi
diff --git a/luci-app-ssr-plus/root/usr/share/shadowsocksr/update.lua b/luci-app-ssr-plus/root/usr/share/shadowsocksr/update.lua
index 65e98a7..34d13dc 100755
--- a/luci-app-ssr-plus/root/usr/share/shadowsocksr/update.lua
+++ b/luci-app-ssr-plus/root/usr/share/shadowsocksr/update.lua
@@ -213,7 +213,9 @@ local function update(url, file, type, file2)
if type == "gfw_data" or type == "ad_data" then
luci.sys.call("/usr/share/shadowsocksr/gfw2ipset.sh")
else
- luci.sys.call("/usr/share/shadowsocksr/chinaipset.sh " .. TMP_PATH .. "/china_ssr.txt")
+ if luci.sys.call("command -v ipset >/dev/null 2>&1") == 0 then
+ luci.sys.call("/usr/share/shadowsocksr/chinaipset.sh " .. TMP_PATH .. "/china_ssr.txt")
+ end
end
if args then
log(0, tonumber(icount) / Num)
diff --git a/luci-theme-aurora/ucode/template/themes/aurora/header.ut b/luci-theme-aurora/ucode/template/themes/aurora/header.ut
index d4683d4..376f279 100644
--- a/luci-theme-aurora/ucode/template/themes/aurora/header.ut
+++ b/luci-theme-aurora/ucode/template/themes/aurora/header.ut
@@ -25,7 +25,7 @@
-
{{ striptags(`${boardinfo.hostname ?? '?'}${node ? ` - ${node.title}` : ''}`) }} - LuCI
+
{{ striptags(`${dispatched ? `${dispatched.title} - ` : ''}${boardinfo.hostname ?? '?'}`) }}