Files
openwrt_packages/luci-app-bandix/htdocs/luci-static/resources/view/bandix/index.js
2025-11-21 00:13:07 +08:00

2821 lines
117 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
'require view';
'require ui';
'require uci';
'require rpc';
'require poll';
// 暗色模式检测已改为使用 CSS 媒体查询 @media (prefers-color-scheme: dark)
// 检测主题类型:返回 'wide'(宽主题,如 Argon或 'narrow'(窄主题,如 Bootstrap
function getThemeType() {
// 获取 LuCI 主题设置
var mediaUrlBase = uci.get('luci', 'main', 'mediaurlbase');
if (!mediaUrlBase) {
// 如果无法获取,尝试从 DOM 中检测
var linkTags = document.querySelectorAll('link[rel="stylesheet"]');
for (var i = 0; i < linkTags.length; i++) {
var href = linkTags[i].getAttribute('href') || '';
if (href.toLowerCase().includes('argon')) {
return 'wide';
}
}
// 默认返回窄主题
return 'narrow';
}
var mediaUrlBaseLower = mediaUrlBase.toLowerCase();
// 宽主题关键词列表(可以根据需要扩展)
var wideThemeKeywords = ['argon', 'material', 'design', 'edge'];
// 检查是否是宽主题
for (var i = 0; i < wideThemeKeywords.length; i++) {
if (mediaUrlBaseLower.includes(wideThemeKeywords[i])) {
return 'wide';
}
}
// 默认是窄主题Bootstrap 等)
return 'narrow';
}
function formatSize(bytes) {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return parseFloat((bytes / Math.pow(1024, i)).toFixed(2)) + ' ' + units[i];
}
function formatByterate(bytes_per_sec, unit) {
if (bytes_per_sec === 0) {
return unit === 'bits' ? '0 bps' : '0 B/s';
}
if (unit === 'bits') {
// 转换为比特单位
const bits_per_sec = bytes_per_sec * 8;
const units = ['bps', 'Kbps', 'Mbps', 'Gbps', 'Tbps'];
const i = Math.floor(Math.log(bits_per_sec) / Math.log(1000));
return parseFloat((bits_per_sec / Math.pow(1000, i)).toFixed(2)) + ' ' + units[i];
} else {
// 默认字节单位
const units = ['B/s', 'KB/s', 'MB/s', 'GB/s', 'TB/s'];
const i = Math.floor(Math.log(bytes_per_sec) / Math.log(1024));
return parseFloat((bytes_per_sec / Math.pow(1024, i)).toFixed(2)) + ' ' + units[i];
}
}
// 解析速度字符串为字节/秒
function parseSpeed(speedStr) {
if (!speedStr || speedStr === '0' || speedStr === '0 B/s' || speedStr === '0 bps') return 0;
// 匹配字节单位
const bytesMatch = speedStr.match(/^([\d.]+)\s*([KMGT]?B\/s)$/i);
if (bytesMatch) {
const value = parseFloat(bytesMatch[1]);
const unit = bytesMatch[2].toUpperCase();
const bytesMultipliers = {
'B/S': 1,
'KB/S': 1024,
'MB/S': 1024 * 1024,
'GB/S': 1024 * 1024 * 1024,
'TB/S': 1024 * 1024 * 1024 * 1024
};
return value * (bytesMultipliers[unit] || 1);
}
// 匹配比特单位
const bitsMatch = speedStr.match(/^([\d.]+)\s*([KMGT]?bps)$/i);
if (bitsMatch) {
const value = parseFloat(bitsMatch[1]);
const unit = bitsMatch[2].toLowerCase();
const bitsMultipliers = {
'bps': 1,
'kbps': 1000,
'mbps': 1000 * 1000,
'gbps': 1000 * 1000 * 1000,
'tbps': 1000 * 1000 * 1000 * 1000
};
// 转换为字节/秒
return (value * (bitsMultipliers[unit] || 1)) / 8;
}
return 0;
}
// 过滤 LAN IPv6 地址(排除本地链路地址)
function filterLanIPv6(ipv6Addresses) {
if (!ipv6Addresses || !Array.isArray(ipv6Addresses)) return [];
const lanPrefixes = [
'fd', // ULA
'fc' // ULA
];
const lanAddresses = ipv6Addresses.filter(addr => {
const lowerAddr = addr.toLowerCase();
return lanPrefixes.some(prefix => lowerAddr.startsWith(prefix));
});
// 最多返回 2 个 LAN IPv6 地址
return lanAddresses.slice(0, 2);
}
var callStatus = rpc.declare({
object: 'luci.bandix',
method: 'getStatus',
expect: {}
});
var callSetRateLimit = rpc.declare({
object: 'luci.bandix',
method: 'setRateLimit',
params: ['mac', 'wide_tx_rate_limit', 'wide_rx_rate_limit'],
expect: { success: true }
});
var callSetHostname = rpc.declare({
object: 'luci.bandix',
method: 'setHostname',
params: ['mac', 'hostname'],
expect: { success: true }
});
// 历史指标 RPC
var callGetMetrics = rpc.declare({
object: 'luci.bandix',
method: 'getMetrics',
params: ['mac'],
expect: {}
});
return view.extend({
load: function () {
return Promise.all([
uci.load('bandix'),
uci.load('luci'),
uci.load('argon').catch(function() {
// argon 配置可能不存在,忽略错误
return null;
})
]);
},
render: function (data) {
// 添加现代化样式
var style = E('style', {}, `
.bandix-container {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
.bandix-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.bandix-title {
font-size: 1.5rem;
font-weight: 600;
margin: 0;
}
.bandix-header-right {
display: flex;
align-items: center;
gap: 12px;
}
.device-mode-group {
display: inline-flex;
border-radius: 4px;
overflow: hidden;
}
.device-mode-btn {
border: none;
padding: 0 12px;
font-size: 0.8125rem;
line-height: 1.8;
cursor: pointer;
user-select: none;
transition: all 0.15s ease;
white-space: nowrap;
height: 28px;
}
.device-mode-btn:hover:not(.active) {
opacity: 0.7;
}
.device-mode-btn.active {
background-color: #3b82f6;
color: white;
}
.bandix-badge {
border-radius: 4px;
padding: 4px 10px;
font-size: 0.875rem;
}
#history-retention {
border: 1px solid rgba(107, 114, 128, 0.4);
}
.bandix-alert {
border-radius: 4px;
padding: 10px 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
font-size: 0.875rem;
}
.bandix-alert-icon {
font-size: 0.875rem;
font-weight: 700;
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
flex-shrink: 0;
}
.bandix-table {
width: 100%;
table-layout: fixed;
}
.bandix-table th {
padding: 10px 16px;
text-align: left;
font-weight: 600;
border: none;
font-size: 0.875rem;
cursor: pointer;
user-select: none;
position: relative;
transition: background-color 0.15s ease;
}
.bandix-table th:hover {
opacity: 0.7;
}
.bandix-table th.sortable::after {
content: '⇅';
margin-left: 6px;
opacity: 0.3;
font-size: 0.75rem;
}
.bandix-table th.sortable.active::after {
opacity: 1;
color: #3b82f6;
}
.bandix-table th.sortable.asc::after {
content: '↑';
}
.bandix-table th.sortable.desc::after {
content: '↓';
}
.th-split-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.th-split-section {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
transition: background-color 0.2s ease;
}
.th-split-section:hover {
opacity: 0.7;
}
.th-split-section.active {
opacity: 0.7;
}
.th-split-icon {
font-size: 0.7rem;
opacity: 0.5;
}
.th-split-section.active .th-split-icon {
opacity: 1;
color: #3b82f6;
}
.th-split-divider {
width: 1px;
height: 16px;
background-color: currentColor;
opacity: 0.5;
}
.bandix-table td {
padding: 12px 16px;
border: none;
vertical-align: middle;
word-wrap: break-word;
overflow-wrap: break-word;
}
.bandix-table th:nth-child(1),
.bandix-table td:nth-child(1) {
width: 25%;
}
.bandix-table th:nth-child(2),
.bandix-table td:nth-child(2) {
width: 22%;
}
.bandix-table th:nth-child(3),
.bandix-table td:nth-child(3) {
width: 22%;
}
.bandix-table th:nth-child(4),
.bandix-table td:nth-child(4) {
width: 15%;
}
.bandix-table th:nth-child(5),
.bandix-table td:nth-child(5) {
width: 9%;
}
/* 类型联动的高亮与弱化 */
.bandix-table .hi { font-weight: 700; }
.bandix-table .dim { opacity: 0.6; }
.device-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.device-name {
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
.device-status {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.device-status.online {
background-color: #10b981;
}
.device-status.offline {
background-color: #9ca3af;
}
.device-ip {
opacity: 0.7;
font-size: 0.875rem;
}
.device-ipv6 {
opacity: 0.7;
font-size: 0.75rem;
font-family: monospace;
}
.device-mac {
opacity: 0.6;
font-size: 0.75rem;
}
.device-last-online {
font-size: 0.75rem;
color: #6b7280;
}
.device-last-online-value {
color: #9ca3af;
}
.device-last-online-exact {
display: none;
color: #9ca3af;
}
.device-last-online:hover .device-last-online-value {
display: none;
}
.device-last-online:hover .device-last-online-exact {
display: inline;
}
.traffic-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.traffic-row {
display: flex;
align-items: center;
gap: 4px;
}
.traffic-icon {
font-size: 0.75rem;
font-weight: bold;
}
.traffic-icon.upload {
color: #f97316;
}
.traffic-icon.download {
color: #06b6d4;
}
.traffic-speed {
font-weight: 600;
font-size: 0.875rem;
}
.traffic-total {
font-size: 0.75rem;
opacity: 0.6;
margin-left: 4px;
}
.limit-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.limit-badge {
padding: 3px 8px;
border-radius: 3px;
font-size: 0.75rem;
text-align: center;
margin-top: 4px;
}
.loading {
text-align: center;
padding: 40px;
opacity: 0.7;
font-style: italic;
}
.error {
text-align: center;
padding: 40px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
margin-bottom: 0;
margin-top: 0;
}
.bandix-container > .cbi-section:last-of-type {
margin-bottom: 0;
}
.stats-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.stats-card-title {
font-size: 0.875rem;
font-weight: 600;
opacity: 0.7;
margin: 0 0 12px 0;
text-transform: uppercase;
letter-spacing: 0.025em;
}
.stats-grid .cbi-section {
padding: 16px;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
transition: box-shadow 0.2s ease, transform 0.2s ease, border-color 0.2s ease;
}
.stats-grid .cbi-section:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
transform: translateY(-2px);
}
@media (prefers-color-scheme: dark) {
.stats-grid .cbi-section {
border-color: rgba(255, 255, 255, 0.15);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.stats-grid .cbi-section:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
}
.stats-card-icon {
font-size: 0.875rem;
font-weight: 600;
padding: 4px 8px;
border-radius: 4px;
background-color: currentColor;
opacity: 0.1;
}
.stats-card-main-value {
font-size: 2.25rem;
font-weight: 700;
margin: 0 0 8px 0;
line-height: 1;
}
.stats-card-sub-value {
font-size: 0.875rem;
opacity: 0.7;
margin: 0;
}
.stats-card-details {
margin-top: 16px;
display: flex;
flex-direction: column;
gap: 8px;
}
.stats-detail-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.875rem;
}
.stats-detail-label {
opacity: 0.7;
font-weight: 500;
}
.stats-detail-value {
font-weight: 600;
}
.stats-title {
font-size: 0.875rem;
font-weight: 600;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 6px;
}
.stats-value {
font-size: 1.25rem;
font-weight: 700;
}
/* 模态框样式 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
opacity: 0;
visibility: hidden;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.modal-overlay.show {
background-color: rgba(0, 0, 0, 0.5);
opacity: 1;
visibility: visible;
}
.modal {
max-width: 500px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
opacity: 0;
transition: opacity 0.2s ease;
background-color: rgba(255, 255, 255, 0.98);
color: #1f2937;
}
.modal-overlay.show .modal {
opacity: 1;
}
@media (prefers-color-scheme: dark) {
.modal {
background-color: rgba(30, 30, 30, 0.98);
color: #e5e7eb;
}
}
.modal-header {
padding: 20px;
}
.modal-title {
font-size: 1.25rem;
font-weight: 600;
margin: 0;
}
.modal-body {
padding: 20px;
}
.modal-footer {
padding: 16px 20px 20px 20px;
display: flex;
gap: 10px;
justify-content: flex-end;
}
.form-group {
margin-bottom: 20px;
}
.form-label {
display: block;
font-weight: 600;
margin-bottom: 8px;
font-size: 0.875rem;
}
.form-input {
width: 100%;
border-radius: 4px;
padding: 8px 12px;
font-size: 0.875rem;
transition: border-color 0.15s ease;
box-sizing: border-box;
}
.form-input:focus {
outline: none;
}
.device-summary {
border-radius: 4px;
padding: 12px;
margin-bottom: 16px;
}
.device-summary-name {
font-weight: 600;
margin-bottom: 4px;
}
.device-summary-details {
opacity: 0.7;
font-size: 0.875rem;
}
/* 加载动画 */
.loading-spinner {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid transparent;
border-radius: 50%;
border-top-color: #3b82f6;
animation: spin 1s ease-in-out infinite;
margin-right: 8px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.btn-loading {
opacity: 0.7;
pointer-events: none;
}
/* 历史趋势 */
.history-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.history-controls {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
padding: 12px 16px;
}
.history-controls .cbi-select {
width: auto;
min-width: 160px;
}
.history-card-body {
padding: 16px;
position: relative;
}
.history-legend {
margin-left: auto;
display: flex;
align-items: center;
gap: 12px;
}
.legend-item { display: flex; align-items: center; gap: 6px; font-size: 0.875rem; }
.legend-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
.legend-up { background-color: #f97316; }
.legend-down { background-color: #06b6d4; }
#history-canvas { width: 100%; height: 200px; display: block; } /* 变窄的高度 */
.history-tooltip {
position: fixed;
display: none;
width: 320px;
box-sizing: border-box;
padding: 12px;
z-index: 10;
pointer-events: none;
font-size: 0.8125rem;
line-height: 1.5;
white-space: nowrap;
background-color: rgba(255, 255, 255, 0.98);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
color: #1f2937;
}
@media (prefers-color-scheme: dark) {
.history-tooltip {
background-color: rgba(30, 30, 30, 0.98);
border-color: rgba(255, 255, 255, 0.2);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
color: #e5e7eb;
}
}
.history-tooltip .ht-title { font-weight: 700; margin-bottom: 6px; }
.history-tooltip .ht-row { display: flex; justify-content: space-between; gap: 12px; }
.history-tooltip .ht-key { opacity: 0.7; }
.history-tooltip .ht-val { }
.history-tooltip .ht-device { margin-top: 4px; margin-bottom: 6px; opacity: 0.7; font-size: 0.75rem; }
/* 强调关键信息的排版 */
.history-tooltip .ht-kpis { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 2px; margin-bottom: 6px; }
.history-tooltip .ht-kpi .ht-k-label { opacity: 0.7; font-size: 0.75rem; }
.history-tooltip .ht-kpi .ht-k-value { font-size: 1rem; font-weight: 700; }
.history-tooltip .ht-kpi.down .ht-k-value { color: #06b6d4; }
.history-tooltip .ht-kpi.up .ht-k-value { color: #f97316; }
.history-tooltip .ht-divider { height: 1px; background-color: currentColor; opacity: 0.3; margin: 8px 0; }
.history-tooltip .ht-section-title { font-weight: 600; font-size: 0.75rem; opacity: 0.7; margin: 4px 0 6px 0; }
`);
document.head.appendChild(style);
var view = E('div', { 'class': 'bandix-container' }, [
// 头部
E('div', { 'class': 'bandix-header' }, [
E('h1', { 'class': 'bandix-title' }, _('Bandix Traffic Monitor'))
]),
// 警告提示(包含在线设备数)
E('div', { 'class': 'bandix-alert' }, [
E('span', {}, _('Rate limiting only applies to WAN traffic.')),
E('div', { 'class': 'bandix-badge', 'id': 'device-count' }, _('Online Devices') + ': 0 / 0')
]),
// 统计卡片
E('div', { 'class': 'stats-grid', 'id': 'stats-grid' }),
// 历史趋势卡片(无时间范围筛选)
E('div', { 'class': 'cbi-section', 'id': 'history-card' }, [
E('h3', { 'class': 'history-header', 'style': 'display: flex; align-items: center; justify-content: space-between;' }, [
E('span', {}, _('Traffic History')),
E('div', { 'class': 'history-legend' }, [
E('div', { 'class': 'legend-item' }, [
E('span', { 'class': 'legend-dot legend-up' }),
_('Upload Rate')
]),
E('div', { 'class': 'legend-item' }, [
E('span', { 'class': 'legend-dot legend-down' }),
_('Download Rate')
])
])
]),
E('div', { 'class': 'history-controls' }, [
E('label', { 'class': 'form-label', 'style': 'margin: 0;' }, _('Select Device')),
E('select', { 'class': 'cbi-select', 'id': 'history-device-select' }, [
E('option', { 'value': '' }, _('All Devices'))
]),
E('label', { 'class': 'form-label', 'style': 'margin: 0;' }, _('Type')),
E('select', { 'class': 'cbi-select', 'id': 'history-type-select' }, [
E('option', { 'value': 'total' }, _('Total')),
E('option', { 'value': 'lan' }, _('LAN Traffic')),
E('option', { 'value': 'wan' }, _('WAN Traffic'))
]),
E('span', { 'class': 'bandix-badge', 'id': 'history-zoom-level', 'style': 'margin-left: 16px; display: none;' }, ''),
E('span', { 'class': 'bandix-badge', 'id': 'history-retention', 'style': 'margin-left: auto;' }, '')
]),
E('div', { 'class': 'history-card-body' }, [
E('canvas', { 'id': 'history-canvas', 'height': '240' }),
E('div', { 'class': 'history-tooltip', 'id': 'history-tooltip' })
])
]),
// 主要内容卡片
E('div', { 'class': 'cbi-section' }, [
E('h3', { 'class': 'history-header', 'style': 'display: flex; align-items: center; justify-content: space-between;' }, [
E('span', {}, _('Device List')),
E('div', { 'class': 'device-mode-group' }, [
E('button', {
'class': 'device-mode-btn' + (localStorage.getItem('bandix_device_mode') !== 'detailed' ? ' active' : ''),
'data-mode': 'simple'
}, _('Simple Mode')),
E('button', {
'class': 'device-mode-btn' + (localStorage.getItem('bandix_device_mode') === 'detailed' ? ' active' : ''),
'data-mode': 'detailed'
}, _('Detailed Mode'))
])
]),
E('div', { 'id': 'traffic-status' }, [
E('table', { 'class': 'bandix-table' }, [
E('thead', {}, [
E('tr', {}, [
E('th', {}, _('Device Info')),
E('th', {}, _('LAN Traffic')),
E('th', {}, _('WAN Traffic')),
E('th', {}, _('Rate Limit')),
E('th', {}, _('Actions'))
])
]),
E('tbody', {})
])
])
])
]);
// 设备信息模式切换
var deviceModeButtons = view.querySelectorAll('.device-mode-btn');
deviceModeButtons.forEach(function(btn) {
btn.addEventListener('click', function() {
var newMode = this.getAttribute('data-mode');
// 如果已经是当前模式,不做任何操作
if (this.classList.contains('active')) {
return;
}
// 保存到 localStorage
localStorage.setItem('bandix_device_mode', newMode);
// 更新按钮状态
deviceModeButtons.forEach(function(b) {
b.classList.remove('active');
});
this.classList.add('active');
// 刷新设备列表以应用新的显示模式
updateDeviceData();
});
});
// 创建限速设置模态框
var modal = E('div', { 'class': 'modal-overlay', 'id': 'rate-limit-modal' }, [
E('div', { 'class': 'modal' }, [
E('div', { 'class': 'modal-header' }, [
E('h3', { 'class': 'modal-title' }, _('Device Settings'))
]),
E('div', { 'class': 'modal-body' }, [
E('div', { 'class': 'device-summary', 'id': 'modal-device-summary' }),
E('div', { 'class': 'form-group' }, [
E('label', { 'class': 'form-label' }, _('Hostname')),
E('input', { 'type': 'text', 'class': 'form-input', 'id': 'device-hostname-input', 'placeholder': _('Please enter hostname') }),
E('div', { 'style': 'font-size: 0.75rem; color: #6b7280; margin-top: 4px;' }, _('Set Hostname'))
]),
E('div', { 'class': 'form-group' }, [
E('label', { 'class': 'form-label' }, _('Upload Limit')),
E('div', { 'style': 'display: flex; gap: 8px;' }, [
E('input', { 'type': 'number', 'class': 'form-input', 'id': 'upload-limit-value', 'min': '0', 'step': '1', 'placeholder': '0' }),
E('select', { 'class': 'cbi-select', 'id': 'upload-limit-unit', 'style': 'width: 100px;' })
]),
E('div', { 'style': 'font-size: 0.75rem; color: #6b7280; margin-top: 4px;' }, _('Tip: Enter 0 for unlimited'))
]),
E('div', { 'class': 'form-group' }, [
E('label', { 'class': 'form-label' }, _('Download Limit')),
E('div', { 'style': 'display: flex; gap: 8px;' }, [
E('input', { 'type': 'number', 'class': 'form-input', 'id': 'download-limit-value', 'min': '0', 'step': '1', 'placeholder': '0' }),
E('select', { 'class': 'cbi-select', 'id': 'download-limit-unit', 'style': 'width: 100px;' })
]),
E('div', { 'style': 'font-size: 0.75rem; color: #6b7280; margin-top: 4px;' }, _('Tip: Enter 0 for unlimited'))
])
]),
E('div', { 'class': 'modal-footer' }, [
E('button', { 'class': 'cbi-button cbi-button-reset', 'id': 'modal-cancel' }, _('Cancel')),
E('button', { 'class': 'cbi-button cbi-button-positive', 'id': 'modal-save' }, _('Save'))
])
])
]);
document.body.appendChild(modal);
// 模态框事件处理
var currentDevice = null;
var showRateLimitModal;
// 显示模态框
showRateLimitModal = function (device) {
currentDevice = device;
var modal = document.getElementById('rate-limit-modal');
var deviceSummary = document.getElementById('modal-device-summary');
var speedUnit = uci.get('bandix', 'traffic', 'speed_unit') || 'bytes';
// 动态填充单位选择器
var uploadUnitSelect = document.getElementById('upload-limit-unit');
var downloadUnitSelect = document.getElementById('download-limit-unit');
// 清空现有选项
uploadUnitSelect.innerHTML = '';
downloadUnitSelect.innerHTML = '';
if (speedUnit === 'bits') {
// 比特单位选项 - 直接设置为对应的字节数
uploadUnitSelect.appendChild(E('option', { 'value': '125' }, 'Kbps')); // 1000 bits/s / 8 = 125 bytes/s
uploadUnitSelect.appendChild(E('option', { 'value': '125000' }, 'Mbps')); // 1000000 bits/s / 8 = 125000 bytes/s
uploadUnitSelect.appendChild(E('option', { 'value': '125000000' }, 'Gbps')); // 1000000000 bits/s / 8 = 125000000 bytes/s
downloadUnitSelect.appendChild(E('option', { 'value': '125' }, 'Kbps'));
downloadUnitSelect.appendChild(E('option', { 'value': '125000' }, 'Mbps'));
downloadUnitSelect.appendChild(E('option', { 'value': '125000000' }, 'Gbps'));
} else {
// 字节单位选项
uploadUnitSelect.appendChild(E('option', { 'value': '1024' }, 'KB/s'));
uploadUnitSelect.appendChild(E('option', { 'value': '1048576' }, 'MB/s'));
uploadUnitSelect.appendChild(E('option', { 'value': '1073741824' }, 'GB/s'));
downloadUnitSelect.appendChild(E('option', { 'value': '1024' }, 'KB/s'));
downloadUnitSelect.appendChild(E('option', { 'value': '1048576' }, 'MB/s'));
downloadUnitSelect.appendChild(E('option', { 'value': '1073741824' }, 'GB/s'));
}
// 更新设备信息
deviceSummary.innerHTML = E('div', {}, [
E('div', { 'class': 'device-summary-name' }, device.hostname || device.ip),
E('div', { 'class': 'device-summary-details' }, device.ip + ' (' + device.mac + ')')
]).innerHTML;
// 设置当前hostname值
document.getElementById('device-hostname-input').value = device.hostname || '';
// 设置当前限速值
var uploadLimit = device.wide_tx_rate_limit || 0;
var downloadLimit = device.wide_rx_rate_limit || 0;
// 设置上传限速值
var uploadValue = uploadLimit;
var uploadUnit;
if (uploadValue === 0) {
document.getElementById('upload-limit-value').value = 0;
uploadUnit = speedUnit === 'bits' ? '125' : '1024';
} else {
if (speedUnit === 'bits') {
// 转换为比特单位显示
var uploadBits = uploadValue * 8;
if (uploadBits >= 1000000000) {
uploadValue = uploadBits / 1000000000;
uploadUnit = '125000000'; // Gbps对应的字节倍数
} else if (uploadBits >= 1000000) {
uploadValue = uploadBits / 1000000;
uploadUnit = '125000'; // Mbps对应的字节倍数
} else {
uploadValue = uploadBits / 1000;
uploadUnit = '125'; // Kbps对应的字节倍数
}
} else {
// 字节单位显示
if (uploadValue >= 1073741824) {
uploadValue = uploadValue / 1073741824;
uploadUnit = '1073741824';
} else if (uploadValue >= 1048576) {
uploadValue = uploadValue / 1048576;
uploadUnit = '1048576';
} else {
uploadValue = uploadValue / 1024;
uploadUnit = '1024';
}
}
document.getElementById('upload-limit-value').value = Math.round(uploadValue);
}
document.getElementById('upload-limit-unit').value = uploadUnit;
// 设置下载限速值
var downloadValue = downloadLimit;
var downloadUnit;
if (downloadValue === 0) {
document.getElementById('download-limit-value').value = 0;
downloadUnit = speedUnit === 'bits' ? '125' : '1024';
} else {
if (speedUnit === 'bits') {
// 转换为比特单位显示
var downloadBits = downloadValue * 8;
if (downloadBits >= 1000000000) {
downloadValue = downloadBits / 1000000000;
downloadUnit = '125000000'; // Gbps对应的字节倍数
} else if (downloadBits >= 1000000) {
downloadValue = downloadBits / 1000000;
downloadUnit = '125000'; // Mbps对应的字节倍数
} else {
downloadValue = downloadBits / 1000;
downloadUnit = '125'; // Kbps对应的字节倍数
}
} else {
// 字节单位显示
if (downloadValue >= 1073741824) {
downloadValue = downloadValue / 1073741824;
downloadUnit = '1073741824';
} else if (downloadValue >= 1048576) {
downloadValue = downloadValue / 1048576;
downloadUnit = '1048576';
} else {
downloadValue = downloadValue / 1024;
downloadUnit = '1024';
}
}
document.getElementById('download-limit-value').value = Math.round(downloadValue);
}
document.getElementById('download-limit-unit').value = downloadUnit;
// 应用 cbi-section 的颜色到模态框
try {
// 优先从 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;
// 获取模态框元素
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.]+))?\)/);
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) {
modalElement.style.backgroundColor = 'rgb(' + r + ', ' + g + ', ' + b + ')';
} else {
modalElement.style.backgroundColor = bgColor;
}
} else {
modalElement.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) {
modalElement.style.backgroundColor = 'rgb(' + r + ', ' + g + ', ' + b + ')';
} else {
modalElement.style.backgroundColor = sectionBg;
}
} else {
modalElement.style.backgroundColor = sectionBg;
}
foundBgColor = true;
break;
}
}
// 如果无法获取背景色CSS 会通过媒体查询自动处理暗色模式
if (!foundBgColor) {
// 不设置背景色,让 CSS 媒体查询处理
}
}
// 应用文字颜色
if (textColor && textColor !== 'rgba(0, 0, 0, 0)') {
modalElement.style.color = textColor;
} else {
if (cbiSection) {
var sectionTextColor = window.getComputedStyle(cbiSection).color;
if (sectionTextColor && sectionTextColor !== 'rgba(0, 0, 0, 0)') {
modalElement.style.color = sectionTextColor;
}
}
}
} catch(e) {
// 如果出错CSS 会通过媒体查询自动处理暗色模式
// 不设置样式,让 CSS 处理
}
// 显示模态框并添加动画
modal.classList.add('show');
}
// 隐藏模态框
function hideRateLimitModal() {
var modal = document.getElementById('rate-limit-modal');
modal.classList.remove('show');
// 等待动画完成后清理
setTimeout(function () {
currentDevice = null;
}, 300);
}
// 保存限速设置
function saveRateLimit() {
if (!currentDevice) return;
var saveButton = document.getElementById('modal-save');
var originalText = saveButton.textContent;
// 显示加载状态
saveButton.innerHTML = '<span class="loading-spinner"></span>' + _('Saving...');
saveButton.classList.add('btn-loading');
var uploadLimit = 0;
var downloadLimit = 0;
var speedUnit = uci.get('bandix', 'traffic', 'speed_unit') || 'bytes';
// 获取hostname值
var newHostname = document.getElementById('device-hostname-input').value.trim();
// 获取上传限速值
var uploadValue = parseInt(document.getElementById('upload-limit-value').value) || 0;
var uploadUnit = parseInt(document.getElementById('upload-limit-unit').value);
if (uploadValue > 0) {
// 选择器的值已经是正确的字节倍数,直接计算即可
uploadLimit = uploadValue * uploadUnit;
}
// 获取下载限速值
var downloadValue = parseInt(document.getElementById('download-limit-value').value) || 0;
var downloadUnit = parseInt(document.getElementById('download-limit-unit').value);
if (downloadValue > 0) {
// 选择器的值已经是正确的字节倍数,直接计算即可
downloadLimit = downloadValue * downloadUnit;
}
// console.log("mac", currentDevice.mac)
// console.log("uploadLimit", uploadLimit)
// console.log("downloadLimit", downloadLimit)
// console.log("newHostname", newHostname)
// 创建Promise数组来并行处理hostname和限速设置
var promises = [];
// 如果hostname有变化添加hostname设置Promise
if (newHostname !== (currentDevice.hostname || '')) {
promises.push(
callSetHostname(currentDevice.mac, newHostname).catch(function(error) {
return { hostnameError: error };
})
);
}
// 添加限速设置Promise
promises.push(
callSetRateLimit(currentDevice.mac, uploadLimit, downloadLimit).catch(function(error) {
return { rateLimitError: error };
})
);
// 并行执行所有设置
Promise.all(promises).then(function (results) {
// 恢复按钮状态
saveButton.innerHTML = originalText;
saveButton.classList.remove('btn-loading');
var hasError = false;
var errorMessages = [];
// 检查结果
results.forEach(function(result, index) {
if (result && result.hostnameError) {
hasError = true;
errorMessages.push(_('Failed to set hostname'));
} else if (result && result.rateLimitError) {
hasError = true;
errorMessages.push(_('Failed to save settings'));
} else if (result !== true && result !== undefined) {
// 检查是否有其他错误
if (result && result.error) {
hasError = true;
errorMessages.push(result.error);
}
}
});
if (hasError) {
ui.addNotification(null, E('p', {}, errorMessages.join(', ')), 'error');
} else {
// 所有设置都成功
hideRateLimitModal();
}
}).catch(function (error) {
// 恢复按钮状态
saveButton.innerHTML = originalText;
saveButton.classList.remove('btn-loading');
ui.addNotification(null, E('p', {}, _('Failed to save settings')), 'error');
});
}
// 绑定模态框事件
document.getElementById('modal-cancel').addEventListener('click', hideRateLimitModal);
document.getElementById('modal-save').addEventListener('click', saveRateLimit);
// 点击模态框背景关闭
document.getElementById('rate-limit-modal').addEventListener('click', function (e) {
if (e.target === this) {
hideRateLimitModal();
}
});
// 历史趋势:状态与工具
var latestDevices = [];
var lastHistoryData = null; // 最近一次拉取的原始 metrics 数据
var isHistoryLoading = false; // 防止轮询重入
// 排序状态管理
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; // 延迟启用缩放的计时器
function updateDeviceOptions(devices) {
var select = document.getElementById('history-device-select');
if (!select) return;
// 对设备列表进行排序在线设备在前离线设备在后然后按IP地址从小到大排序
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; });
// 逐段比较IP地址
for (var i = 0; i < 4; i++) {
var aPart = aIpParts[i] || 0;
var bPart = bIpParts[i] || 0;
if (aPart !== bPart) {
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));
var same = currentValues.length === desiredValues.length && currentValues.every((v, i) => v === desiredValues[i]);
if (same) return;
var prev = select.value;
// 重建选项
select.innerHTML = '';
select.appendChild(E('option', { 'value': '' }, _('All Devices')));
sortedDevices.forEach(function (d) {
var label = (d.hostname || d.ip || d.mac || '-') + (d.ip ? ' (' + d.ip + ')' : '') + (d.mac ? ' [' + d.mac + ']' : '');
select.appendChild(E('option', { 'value': d.mac }, label));
});
// 尽量保留之前选择
if (desiredValues.indexOf(prev) !== -1) select.value = prev;
}
function getTypeKeys(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 fetchMetricsData(mac) {
// 通过 ubus RPC 获取,避免跨域与鉴权问题
return callGetMetrics(mac || '').then(function (res) { return res || { metrics: [] }; });
}
// 辅助函数:使用当前缩放设置绘制图表
function drawHistoryChartWithZoom(canvas, labels, upSeries, downSeries) {
drawHistoryChart(canvas, labels, upSeries, downSeries, zoomScale, zoomOffsetX);
}
// 更新缩放倍率显示
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 {
zoomLevelElement.style.display = 'inline-block';
zoomLevelElement.textContent = _('Zoom') + ': ' + zoomScale.toFixed(1) + 'x';
}
}
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;
var cssHeight = rect.height;
canvas.width = Math.max(1, Math.floor(cssWidth * dpr));
canvas.height = Math.max(1, Math.floor(cssHeight * dpr));
var ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
var width = cssWidth;
var height = cssHeight;
// 预留更大边距,避免标签被裁剪
var padding = { left: 90, right: 50, top: 16, bottom: 36 };
// 背景
ctx.clearRect(0, 0, width, height);
// 根据缩放和偏移处理数据
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);
}
var speedUnit = uci.get('bandix', 'traffic', 'speed_unit') || 'bytes';
var maxVal = 0;
for (var i = 0; i < upSeries.length; i++) maxVal = Math.max(maxVal, upSeries[i] || 0);
for (var j = 0; j < downSeries.length; j++) maxVal = Math.max(maxVal, downSeries[j] || 0);
if (!isFinite(maxVal) || maxVal <= 0) maxVal = 1;
// 动态测量Y轴最大标签宽度增大左边距
ctx.font = '12px sans-serif';
var maxLabelText = formatByterate(maxVal, speedUnit);
var zeroLabelText = formatByterate(0, speedUnit);
var maxLabelWidth = Math.max(ctx.measureText(maxLabelText).width, ctx.measureText(zeroLabelText).width);
padding.left = Math.max(padding.left, Math.ceil(maxLabelWidth) + 30);
// 保证右侧时间不被裁剪
var rightMin = 50; // 最小右边距
padding.right = Math.max(padding.right, rightMin);
var innerW = Math.max(1, width - padding.left - padding.right);
var innerH = Math.max(1, height - padding.top - padding.bottom);
// 记录用于交互的几何信息;保留已有的 hoverIndex 避免在重绘时丢失
var prevHover = (canvas.__bandixChart && typeof canvas.__bandixChart.hoverIndex === 'number') ? canvas.__bandixChart.hoverIndex : undefined;
canvas.__bandixChart = {
padding: padding,
innerW: innerW,
innerH: innerH,
width: width,
height: height,
labels: labels,
upSeries: upSeries,
downSeries: downSeries,
// 缩放相关信息
scale: scale,
offsetX: offsetX,
originalLabels: originalLabels,
originalUpSeries: originalUpSeries,
originalDownSeries: originalDownSeries
};
if (typeof prevHover === 'number') canvas.__bandixChart.hoverIndex = prevHover;
// 网格与Y轴刻度更细更淡
var gridLines = 4;
ctx.strokeStyle = 'rgba(148,163,184,0.08)';
ctx.lineWidth = 0.8;
for (var g = 0; g <= gridLines; g++) {
var y = padding.top + (innerH * g / gridLines);
ctx.beginPath();
ctx.moveTo(padding.left, y);
ctx.lineTo(width - padding.right, y);
ctx.stroke();
var val = Math.round(maxVal * (gridLines - g) / gridLines);
ctx.fillStyle = '#9ca3af';
ctx.font = '12px sans-serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
var yLabelY = (g === gridLines) ? y - 4 : y; // 底部刻度上移避免贴近X轴
ctx.fillText(formatByterate(val, speedUnit), padding.left - 8, yLabelY);
}
function drawAreaSeries(series, color, gradientFrom, gradientTo) {
if (!series || series.length === 0) return;
var n = series.length;
var stepX = n > 1 ? (innerW / (n - 1)) : 0;
// 先绘制填充区域路径
ctx.beginPath();
for (var k = 0; k < n; k++) {
var v = Math.max(0, series[k] || 0);
var x = padding.left + (n > 1 ? stepX * k : innerW / 2);
var y = padding.top + innerH - (v / maxVal) * innerH;
if (k === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
// 关闭到底部以形成区域
ctx.lineTo(padding.left + innerW, padding.top + innerH);
ctx.lineTo(padding.left, padding.top + innerH);
ctx.closePath();
// 创建渐变填充
var grad = ctx.createLinearGradient(0, padding.top, 0, padding.top + innerH);
grad.addColorStop(0, gradientFrom);
grad.addColorStop(1, gradientTo);
ctx.fillStyle = grad;
ctx.fill();
// 然后绘制细线
ctx.beginPath();
for (var k2 = 0; k2 < n; k2++) {
var v2 = Math.max(0, series[k2] || 0);
var x2 = padding.left + (n > 1 ? stepX * k2 : innerW / 2);
var y2 = padding.top + innerH - (v2 / maxVal) * innerH;
if (k2 === 0) ctx.moveTo(x2, y2); else ctx.lineTo(x2, y2);
}
ctx.strokeStyle = color;
ctx.lineWidth = 1.2; // 更细的线
ctx.stroke();
// 圆点已移除,只保留线条
}
// 橙色上行,青色下行,使用半透明渐变
drawAreaSeries(upSeries, '#f97316', 'rgba(249,115,22,0.16)', 'rgba(249,115,22,0.02)');
drawAreaSeries(downSeries, '#06b6d4', 'rgba(6,182,212,0.12)', 'rgba(6,182,212,0.02)');
// X 轴时间标签(首尾)
if (labels && labels.length > 0) {
ctx.fillStyle = '#9ca3af';
ctx.font = '12px sans-serif';
ctx.textBaseline = 'top';
var firstX = padding.left;
var lastX = width - padding.right;
var yBase = height - padding.bottom + 4;
// 左侧时间靠左对齐
ctx.textAlign = 'left';
ctx.fillText(labels[0], firstX, yBase);
// 右侧时间靠右对齐,避免被裁剪
if (labels.length > 1) {
ctx.textAlign = 'right';
ctx.fillText(labels[labels.length - 1], lastX, yBase);
}
}
// 如果存在 hoverIndex则绘制垂直虚线鼠标对着的 x 轴)
try {
var info = canvas.__bandixChart || {};
var useIdx = null;
if (typeof historyHoverIndex === 'number') useIdx = historyHoverIndex;
else if (typeof info.hoverIndex === 'number') useIdx = info.hoverIndex;
if (useIdx !== null && info.labels && info.labels.length > 0) {
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);
hoverIdx = useIdx - startIdx;
// 检查索引是否在当前显示范围内
if (hoverIdx < 0 || hoverIdx >= n) {
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);
ctx.save();
ctx.strokeStyle = 'rgba(156,163,175,0.9)';
ctx.lineWidth = 1;
ctx.setLineDash([6, 4]);
ctx.beginPath();
ctx.moveTo(hoverX, padding.top);
ctx.lineTo(hoverX, padding.top + innerH);
ctx.stroke();
ctx.setLineDash([]);
ctx.restore();
}
}
} catch (e) { /* 安全兜底 */ }
}
function msToTimeLabel(ts) {
var d = new Date(ts);
var hh = ('' + d.getHours()).padStart(2, '0');
var mm = ('' + d.getMinutes()).padStart(2, '0');
var ss = ('' + d.getSeconds()).padStart(2, '0');
return 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';
function row(label, val) {
lines.push('<div class="ht-row"><span class="ht-key">' + label + '</span><span class="ht-val">' + val + '</span></div>');
}
function rateValue(key) {
return formatByterate(point[key] || 0, speedUnit);
}
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 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' };
}
lines.push('<div class="ht-title">' + msToTimeLabel(point.ts_ms) + '</div>');
// 若选择了设备,显示设备信息
try {
var macSel = (typeof document !== 'undefined' ? document.getElementById('history-device-select') : null);
var macVal = (macSel && macSel.value) ? macSel.value : '';
if (macVal && Array.isArray(latestDevices)) {
var dev = latestDevices.find(function(d){ return d.mac === macVal; });
if (dev) {
var ipv6Info = '';
var lanIPv6 = filterLanIPv6(dev.ipv6_addresses);
if (lanIPv6.length > 0) {
ipv6Info = ' | IPv6: ' + lanIPv6.join(', ');
}
var devLabel = (dev.hostname || '-') + (dev.ip ? ' (' + dev.ip + ')' : '') + (dev.mac ? ' [' + dev.mac + ']' : '') + ipv6Info;
lines.push('<div class="ht-device">' + _('Device') + ': ' + devLabel + '</div>');
}
}
} catch (e) {}
// 关键信息:选中类型的上下行速率(大号显示)
var kpiLabels = labelsFor(selType);
var kpiRateKeys = rateKeysFor(selType);
lines.push(
'<div class="ht-kpis">' +
'<div class="ht-kpi up">' +
'<div class="ht-k-label">' + kpiLabels.up + '</div>' +
'<div class="ht-k-value">' + rateValue(kpiRateKeys.up) + '</div>' +
'</div>' +
'<div class="ht-kpi down">' +
'<div class="ht-k-label">' + kpiLabels.down + '</div>' +
'<div class="ht-k-value">' + rateValue(kpiRateKeys.down) + '</div>' +
'</div>' +
'</div>'
);
// 次要信息:其余类型的速率(精简展示)
var otherTypes = ['total', 'lan', 'wan'].filter(function (t) { return t !== selType; });
if (otherTypes.length) {
lines.push('<div class="ht-section-title">' + _('Other Rates') + '</div>');
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('<div class="ht-divider"></div>');
lines.push('<div class="ht-section-title">' + _('Cumulative') + '</div>');
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('');
}
// 排序逻辑函数
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 0;
return ascending ? (aOnline ? -1 : 1) : (aOnline ? 1 : -1);
});
break;
case 'ip':
sortedDevices.sort(function(a, b) {
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; });
// 逐段比较IP地址
for (var i = 0; i < 4; i++) {
var aPart = aIpParts[i] || 0;
var bPart = bIpParts[i] || 0;
if (aPart !== bPart) {
return ascending ? (aPart - bPart) : (bPart - aPart);
}
}
return 0;
});
break;
case 'hostname':
sortedDevices.sort(function(a, b) {
// 先按在线状态排序
var aOnline = isDeviceOnline(a);
var bOnline = isDeviceOnline(b);
if (aOnline !== bOnline) {
return aOnline ? -1 : 1; // 在线设备始终在前
}
// 在线状态相同时按IP地址排序
var aIp = a.ip || '';
var bIp = b.ip || '';
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;
if (aPart !== bPart) {
return ascending ? (aPart - bPart) : (bPart - aPart);
}
}
// IP相同时按MAC地址排序
return (a.mac || '').localeCompare(b.mac || '');
});
break;
case 'mac':
sortedDevices.sort(function(a, b) {
var aMac = (a.mac || '').toLowerCase();
var bMac = (b.mac || '').toLowerCase();
if (aMac === bMac) return 0;
return ascending ? aMac.localeCompare(bMac) : bMac.localeCompare(aMac);
});
break;
case 'upload_speed':
sortedDevices.sort(function(a, b) {
var aSpeed = (a.wide_tx_rate || 0) + (a.local_tx_rate || 0);
var bSpeed = (b.wide_tx_rate || 0) + (b.local_tx_rate || 0);
return ascending ? (aSpeed - bSpeed) : (bSpeed - aSpeed);
});
break;
case 'download_speed':
sortedDevices.sort(function(a, b) {
var aSpeed = (a.wide_rx_rate || 0) + (a.local_rx_rate || 0);
var bSpeed = (b.wide_rx_rate || 0) + (b.local_rx_rate || 0);
return ascending ? (aSpeed - bSpeed) : (bSpeed - aSpeed);
});
break;
case 'lan_speed':
sortedDevices.sort(function(a, b) {
var aSpeed = (a.local_tx_rate || 0) + (a.local_rx_rate || 0);
var bSpeed = (b.local_tx_rate || 0) + (b.local_rx_rate || 0);
return ascending ? (aSpeed - bSpeed) : (bSpeed - aSpeed);
});
break;
case 'wan_speed':
sortedDevices.sort(function(a, b) {
var aSpeed = (a.wide_tx_rate || 0) + (a.wide_rx_rate || 0);
var bSpeed = (b.wide_tx_rate || 0) + (b.wide_rx_rate || 0);
return ascending ? (aSpeed - bSpeed) : (bSpeed - aSpeed);
});
break;
case 'total_traffic':
sortedDevices.sort(function(a, b) {
var aTotal = (a.wide_tx_bytes || 0) + (a.wide_rx_bytes || 0) + (a.local_tx_bytes || 0) + (a.local_rx_bytes || 0);
var bTotal = (b.wide_tx_bytes || 0) + (b.wide_rx_bytes || 0) + (b.local_tx_bytes || 0) + (b.local_rx_bytes || 0);
return ascending ? (aTotal - bTotal) : (bTotal - aTotal);
});
break;
case 'last_online':
sortedDevices.sort(function(a, b) {
var aTime = a.last_online_ts || 0;
var bTime = b.last_online_ts || 0;
return ascending ? (aTime - bTime) : (bTime - aTime);
});
break;
case 'lan_traffic':
sortedDevices.sort(function(a, b) {
var aTraffic = (a.local_tx_bytes || 0) + (a.local_rx_bytes || 0);
var bTraffic = (b.local_tx_bytes || 0) + (b.local_rx_bytes || 0);
return ascending ? (aTraffic - bTraffic) : (bTraffic - aTraffic);
});
break;
case 'wan_traffic':
sortedDevices.sort(function(a, b) {
var aTraffic = (a.wide_tx_bytes || 0) + (a.wide_rx_bytes || 0);
var bTraffic = (b.wide_tx_bytes || 0) + (b.wide_rx_bytes || 0);
return ascending ? (aTraffic - bTraffic) : (bTraffic - aTraffic);
});
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 aIp = a.ip || '';
var bIp = b.ip || '';
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;
if (aPart !== bPart) {
return aPart - bPart;
}
}
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 '';
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 refreshHistory() {
// 若鼠标在历史图上悬停,则暂停刷新以避免自动滚动
if (historyHover) return Promise.resolve();
var mac = document.getElementById('history-device-select')?.value || '';
var type = document.getElementById('history-type-select')?.value || 'total';
var canvas = document.getElementById('history-canvas');
var tooltip = document.getElementById('history-tooltip');
if (!canvas) return Promise.resolve();
if (isHistoryLoading) return Promise.resolve();
isHistoryLoading = true;
return fetchMetricsData(mac).then(function (res) {
var data = Array.isArray(res && res.metrics) ? res.metrics.slice() : [];
lastHistoryData = data;
var retentionBadge = document.getElementById('history-retention');
if (retentionBadge) {
var text = formatRetentionSeconds(res && res.retention_seconds);
retentionBadge.textContent = text || '';
}
if (!data.length) {
var ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawHistoryChart(canvas, [], [], [], 1, 0);
return;
}
// 不做时间过滤,按时间升序排序,完整展示
var filtered = data.slice();
filtered.sort(function (a, b) { return (a.ts_ms || 0) - (b.ts_ms || 0); });
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); });
drawHistoryChartWithZoom(canvas, labels, upSeries, downSeries);
// 绑定或更新鼠标事件用于展示浮窗
function findNearestIndex(evt) {
var rect = canvas.getBoundingClientRect();
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);
return startIdx + minIdx;
}
return minIdx;
}
function onMove(evt) {
if (!tooltip) return;
var idx = findNearestIndex(evt);
if (idx < 0 || !lastHistoryData || !lastHistoryData[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;
}
var point = lastHistoryData[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 baseX = cx + padding; // 右上(水平向右)
var 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';
// 清除 hover 状态并请求重绘去掉虚线
historyHover = false;
historyHoverIndex = null;
// 重置缩放状态
if (zoomTimer) {
clearTimeout(zoomTimer);
zoomTimer = null;
}
zoomEnabled = false;
zoomScale = 1;
zoomOffsetX = 0;
// 更新缩放倍率显示
updateZoomLevelDisplay();
// 清除canvas中的hover信息
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){}
}
// 鼠标进入事件:启动延迟计时器
canvas.onmouseenter = function() {
if (zoomTimer) clearTimeout(zoomTimer);
zoomTimer = setTimeout(function() {
zoomEnabled = true;
zoomTimer = null;
}, 1000); // 1秒后启用缩放
};
// 鼠标滚轮事件:处理缩放
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);
// 如果有当前的hover索引重新绘制虚线
if (typeof historyHoverIndex === 'number' && canvas.__bandixChart) {
canvas.__bandixChart.hoverIndex = historyHoverIndex;
}
} catch(e){}
};
canvas.onmousemove = onMove;
canvas.onmouseleave = onLeave;
}).catch(function () {
var ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawHistoryChart(canvas, [], [], [], 1, 0);
// ui.addNotification(null, E('p', {}, _('Unable to fetch history data')), 'error');
}).finally(function () {
isHistoryLoading = false;
});
}
// 历史趋势:事件绑定
(function initHistoryControls() {
var typeSel = document.getElementById('history-type-select');
var devSel = document.getElementById('history-device-select');
if (typeSel) typeSel.value = 'total';
// 初始化缩放倍率显示
updateZoomLevelDisplay();
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 () {
if (lastHistoryData && lastHistoryData.length) {
// 重新绘制当前数据(保持当前筛选)
var type = document.getElementById('history-type-select')?.value || 'total';
var canvas = document.getElementById('history-canvas');
if (!canvas) return;
var filtered = lastHistoryData.slice();
filtered.sort(function (a, b) { return (a.ts_ms || 0) - (b.ts_ms || 0); });
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); });
drawHistoryChartWithZoom(canvas, labels, upSeries, downSeries);
} else {
refreshHistory();
}
});
// 首次加载
refreshHistory();
})();
// 历史趋势轮询每1秒
poll.add(function () {
return refreshHistory();
},1);
// 定义更新设备数据的函数
function updateDeviceData() {
return callStatus().then(function (result) {
var trafficDiv = document.getElementById('traffic-status');
var deviceCountDiv = document.getElementById('device-count');
var statsGrid = document.getElementById('stats-grid');
var speedUnit = uci.get('bandix', 'traffic', 'speed_unit') || 'bytes';
var stats = result;
if (!stats || !stats.devices) {
trafficDiv.innerHTML = '<div class="error">' + _('Unable to fetch data') + '</div>';
return;
}
// 更新设备计数
var onlineCount = stats.devices.filter(d => isDeviceOnline(d)).length;
deviceCountDiv.textContent = _('Online Devices') + ': ' + onlineCount + ' / ' + stats.devices.length;
// 计算统计数据(包含所有设备)
var totalLanUp = stats.devices.reduce((sum, d) => sum + (d.local_tx_bytes || 0), 0);
var totalLanDown = stats.devices.reduce((sum, d) => sum + (d.local_rx_bytes || 0), 0);
var totalWanUp = stats.devices.reduce((sum, d) => sum + (d.wide_tx_bytes || 0), 0);
var totalWanDown = stats.devices.reduce((sum, d) => sum + (d.wide_rx_bytes || 0), 0);
var totalLanSpeedUp = stats.devices.reduce((sum, d) => sum + (d.local_tx_rate || 0), 0);
var totalLanSpeedDown = stats.devices.reduce((sum, d) => sum + (d.local_rx_rate || 0), 0);
var totalWanSpeedUp = stats.devices.reduce((sum, d) => sum + (d.wide_tx_rate || 0), 0);
var totalWanSpeedDown = stats.devices.reduce((sum, d) => sum + (d.wide_rx_rate || 0), 0);
var totalSpeedUp = totalLanSpeedUp + totalWanSpeedUp;
var totalSpeedDown = totalLanSpeedDown + totalWanSpeedDown;
var totalUp = totalLanUp + totalWanUp;
var totalDown = totalLanDown + totalWanDown;
// 更新统计卡片
statsGrid.innerHTML = '';
// LAN 流量卡片
statsGrid.appendChild(E('div', { 'class': 'cbi-section' }, [
E('div', { 'class': 'stats-card-title' }, _('LAN Traffic')),
E('div', { 'style': 'display: flex; flex-direction: column; gap: 8px;' }, [
// 上传行
E('div', { 'style': 'display: flex; align-items: center; gap: 4px;' }, [
E('span', { 'style': 'color: #f97316; font-size: 0.75rem; font-weight: bold;' }, '↑'),
E('span', { 'style': 'color: #f97316; font-size: 1.125rem; font-weight: 700;' }, formatByterate(totalLanSpeedUp, speedUnit)),
E('span', { 'style': 'font-size: 0.75rem; color: #64748b; margin-left: 4px;' }, '(' + formatSize(totalLanUp) + ')')
]),
// 下载行
E('div', { 'style': 'display: flex; align-items: center; gap: 4px;' }, [
E('span', { 'style': 'color: #06b6d4; font-size: 0.75rem; font-weight: bold;' }, '↓'),
E('span', { 'style': 'color: #06b6d4; font-size: 1.125rem; font-weight: 700;' }, formatByterate(totalLanSpeedDown, speedUnit)),
E('span', { 'style': 'font-size: 0.75rem; color: #64748b; margin-left: 4px;' }, '(' + formatSize(totalLanDown) + ')')
])
])
]));
// WAN 流量卡片
statsGrid.appendChild(E('div', { 'class': 'cbi-section' }, [
E('div', { 'class': 'stats-card-title' }, _('WAN Traffic')),
E('div', { 'style': 'display: flex; flex-direction: column; gap: 8px;' }, [
// 上传行
E('div', { 'style': 'display: flex; align-items: center; gap: 4px;' }, [
E('span', { 'style': 'color: #f97316; font-size: 0.75rem; font-weight: bold;' }, '↑'),
E('span', { 'style': 'color: #f97316; font-size: 1.125rem; font-weight: 700;' }, formatByterate(totalWanSpeedUp, speedUnit)),
E('span', { 'style': 'font-size: 0.75rem; color: #64748b; margin-left: 4px;' }, '(' + formatSize(totalWanUp) + ')')
]),
// 下载行
E('div', { 'style': 'display: flex; align-items: center; gap: 4px;' }, [
E('span', { 'style': 'color: #06b6d4; font-size: 0.75rem; font-weight: bold;' }, '↓'),
E('span', { 'style': 'color: #06b6d4; font-size: 1.125rem; font-weight: 700;' }, formatByterate(totalWanSpeedDown, speedUnit)),
E('span', { 'style': 'font-size: 0.75rem; color: #64748b; margin-left: 4px;' }, '(' + formatSize(totalWanDown) + ')')
])
])
]));
// 总流量卡片
statsGrid.appendChild(E('div', { 'class': 'cbi-section' }, [
E('div', { 'class': 'stats-card-title' }, _('Total')),
E('div', { 'style': 'display: flex; flex-direction: column; gap: 8px;' }, [
// 上传行
E('div', { 'style': 'display: flex; align-items: center; gap: 4px;' }, [
E('span', { 'style': 'color: #f97316; font-size: 0.75rem; font-weight: bold;' }, '↑'),
E('span', { 'style': 'color: #f97316; font-size: 1.125rem; font-weight: 700;' }, formatByterate(totalSpeedUp, speedUnit)),
E('span', { 'style': 'font-size: 0.75rem; color: #64748b; margin-left: 4px;' }, '(' + formatSize(totalUp) + ')')
]),
// 下载行
E('div', { 'style': 'display: flex; align-items: center; gap: 4px;' }, [
E('span', { 'style': 'color: #06b6d4; font-size: 0.75rem; font-weight: bold;' }, '↓'),
E('span', { 'style': 'color: #06b6d4; font-size: 1.125rem; font-weight: 700;' }, formatByterate(totalSpeedDown, speedUnit)),
E('span', { 'style': 'font-size: 0.75rem; color: #64748b; margin-left: 4px;' }, '(' + formatSize(totalDown) + ')')
])
])
]));
// 创建表头点击处理函数
function createSortableHeader(text, sortKey) {
var th = E('th', {
'class': 'sortable' + (currentSortBy === sortKey ? ' active ' + (currentSortOrder ? 'asc' : 'desc') : ''),
'data-sort': sortKey
}, text);
th.addEventListener('click', function() {
var newSortBy = this.getAttribute('data-sort');
if (currentSortBy === newSortBy) {
// 同一列,切换升降序
currentSortOrder = !currentSortOrder;
} else {
// 不同列,默认降序(对于速度和流量,降序更有意义)
currentSortBy = newSortBy;
if (newSortBy === 'hostname' || newSortBy === 'ip' || newSortBy === 'mac') {
currentSortOrder = true; // 文本类默认升序
} else {
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' : ''),
'data-sort': speedKey,
'title': _('Sort by Speed')
}, [
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' : ''),
'data-sort': trafficKey,
'title': _('Sort by Traffic')
}, [
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) {
e.stopPropagation();
var newSortBy = this.getAttribute('data-sort');
if (currentSortBy === newSortBy) {
currentSortOrder = !currentSortOrder;
} else {
currentSortBy = newSortBy;
currentSortOrder = false; // 速度默认降序
}
localStorage.setItem('bandix_sort_by', currentSortBy);
localStorage.setItem('bandix_sort_order', currentSortOrder.toString());
if (window.__bandixRenderTable) {
window.__bandixRenderTable();
}
});
// 用量按钮点击事件
trafficBtn.addEventListener('click', function(e) {
e.stopPropagation();
var newSortBy = this.getAttribute('data-sort');
if (currentSortBy === newSortBy) {
currentSortOrder = !currentSortOrder;
} else {
currentSortBy = newSortBy;
currentSortOrder = false; // 用量默认降序
}
localStorage.setItem('bandix_sort_by', currentSortBy);
localStorage.setItem('bandix_sort_order', currentSortOrder.toString());
if (window.__bandixRenderTable) {
window.__bandixRenderTable();
}
});
return th;
}
// 创建表格
var table = E('table', { 'class': 'bandix-table' }, [
E('thead', {}, [
E('tr', {}, [
createSortableHeader(_('Device Info'), 'hostname'),
createSplitHeader(_('LAN Traffic'), 'lan_speed', 'lan_traffic'),
createSplitHeader(_('WAN Traffic'), 'wan_speed', 'wan_traffic'),
E('th', {}, _('Rate Limit')),
E('th', {}, _('Actions'))
])
]),
E('tbody', {})
]);
var tbody = table.querySelector('tbody');
// 过滤:按选择设备
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);
// 检查是否有任何设备有 IPv6 地址
var hasAnyIPv6 = filteredDevices.some(function(device) {
var lanIPv6 = filterLanIPv6(device.ipv6_addresses);
return lanIPv6.length > 0;
});
// 填充数据
filteredDevices.forEach(function (device) {
var isOnline = isDeviceOnline(device);
// 根据主题类型决定按钮显示内容
var themeType = getThemeType();
var buttonText = themeType === 'narrow' ? '⚙' : _('Settings');
var actionButton = E('button', {
'class': 'cbi-button cbi-button-action',
'title': _('Settings')
}, buttonText);
// 绑定点击事件
actionButton.addEventListener('click', function () {
showRateLimitModal(device);
});
// 获取当前显示模式
var deviceMode = 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)
];
// 详细模式下显示更多信息
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 和最后上线信息
deviceInfoElements.push(
E('div', { 'class': 'device-mac' }, device.mac),
E('div', { 'class': 'device-last-online' }, [
E('span', {}, _('Last Online') + ': '),
E('span', { 'class': 'device-last-online-value' }, formatLastOnlineTime(device.last_online_ts)),
E('span', { 'class': 'device-last-online-exact' }, formatLastOnlineExactTime(device.last_online_ts))
])
);
}
var row = E('tr', {}, [
// 设备信息
E('td', {}, [
E('div', { 'class': 'device-info' }, deviceInfoElements)
]),
// LAN 流量
E('td', {}, [
E('div', { 'class': 'traffic-info' }, [
E('div', { 'class': 'traffic-row' }, [
E('span', { 'class': 'traffic-icon upload' }, '↑'),
E('span', { 'class': 'traffic-speed lan' }, formatByterate(device.local_tx_rate || 0, speedUnit)),
E('span', { 'class': 'traffic-total' }, '(' + formatSize(device.local_tx_bytes || 0) + ')')
]),
E('div', { 'class': 'traffic-row' }, [
E('span', { 'class': 'traffic-icon download' }, '↓'),
E('span', { 'class': 'traffic-speed lan' }, formatByterate(device.local_rx_rate || 0, speedUnit)),
E('span', { 'class': 'traffic-total' }, '(' + formatSize(device.local_rx_bytes || 0) + ')')
])
])
]),
// WAN 流量
E('td', {}, [
E('div', { 'class': 'traffic-info' }, [
E('div', { 'class': 'traffic-row' }, [
E('span', { 'class': 'traffic-icon upload' }, '↑'),
E('span', { 'class': 'traffic-speed wan' }, formatByterate(device.wide_tx_rate || 0, speedUnit)),
E('span', { 'class': 'traffic-total' }, '(' + formatSize(device.wide_tx_bytes || 0) + ')')
]),
E('div', { 'class': 'traffic-row' }, [
E('span', { 'class': 'traffic-icon download' }, '↓'),
E('span', { 'class': 'traffic-speed wan' }, formatByterate(device.wide_rx_rate || 0, speedUnit)),
E('span', { 'class': 'traffic-total' }, '(' + formatSize(device.wide_rx_bytes || 0) + ')')
])
])
]),
// 限速设置
E('td', {}, [
E('div', { 'class': 'limit-info' }, [
E('div', { 'class': 'traffic-row' }, [
E('span', { 'class': 'traffic-icon upload', 'style': 'font-size: 0.75rem;' }, '↑'),
E('span', { 'style': 'font-size: 0.875rem;' }, formatByterate(device.wide_tx_rate_limit || 0, speedUnit))
]),
E('div', { 'class': 'traffic-row' }, [
E('span', { 'class': 'traffic-icon download', 'style': 'font-size: 0.75rem;' }, '↓'),
E('span', { 'style': 'font-size: 0.875rem;' }, formatByterate(device.wide_rx_rate_limit || 0, speedUnit))
]),
])
]),
// 操作
E('td', {}, [
actionButton
])
]);
tbody.appendChild(row);
});
// 更新表格内容
trafficDiv.innerHTML = '';
trafficDiv.appendChild(table);
// 暴露一个立即重绘表格的函数,供筛选变化时调用
try { window.__bandixRenderTable = function(){
// 重新触发完整的数据更新和渲染
updateDeviceData();
}; } catch (e) {}
// 更新历史趋势中的设备下拉
try {
latestDevices = stats.devices || [];
updateDeviceOptions(latestDevices);
} catch (e) {}
});
}
// 轮询获取数据
poll.add(updateDeviceData, 1);
// 立即执行一次,不等待轮询
updateDeviceData();
// 自动适应主题背景色和文字颜色的函数(仅应用于弹窗和 tooltip
function applyThemeColors() {
try {
// 优先从 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;
// 如果无法获取背景色,尝试从其他 cbi-section 获取
if (!bgColor || bgColor === 'rgba(0, 0, 0, 0)' || bgColor === 'transparent') {
var allCbiSections = document.querySelectorAll('.cbi-section');
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') {
bgColor = sectionBg;
textColor = sectionStyle.color;
break;
}
}
}
// 只应用到模态框和 tooltip不修改页面其他元素
if (bgColor && bgColor !== 'rgba(0, 0, 0, 0)' && bgColor !== 'transparent') {
// 应用到模态框(确保不透明)
var modal = document.querySelector('.modal');
if (modal) {
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;
if (alpha < 0.95) {
modal.style.backgroundColor = 'rgb(' + r + ', ' + g + ', ' + b + ')';
} else {
modal.style.backgroundColor = bgColor;
}
} else {
modal.style.backgroundColor = bgColor;
}
}
// 应用到 tooltip包括所有 tooltip 实例)
var tooltips = document.querySelectorAll('.history-tooltip');
tooltips.forEach(function(tooltip) {
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;
if (alpha < 0.95) {
tooltip.style.backgroundColor = 'rgb(' + r + ', ' + g + ', ' + b + ')';
} else {
tooltip.style.backgroundColor = bgColor;
}
} else {
tooltip.style.backgroundColor = bgColor;
}
});
}
// 检测文字颜色并应用(仅应用到模态框和 tooltip
if (textColor && textColor !== 'rgba(0, 0, 0, 0)') {
// 应用到模态框的文字颜色
var modal = document.querySelector('.modal');
if (modal) {
modal.style.color = textColor;
}
// 应用到 tooltip 的文字颜色
var tooltips = document.querySelectorAll('.history-tooltip');
tooltips.forEach(function(tooltip) {
tooltip.style.color = textColor;
});
}
} catch (e) {
// 如果检测失败,使用默认值
console.log('Theme adaptation:', e);
}
}
// 初始应用主题颜色
setTimeout(applyThemeColors, 100);
// 监听 DOM 变化,自动应用到新创建的元素
if (typeof MutationObserver !== 'undefined') {
var observer = new MutationObserver(function(mutations) {
applyThemeColors();
});
setTimeout(function() {
var container = document.querySelector('.bandix-container');
if (container) {
observer.observe(container, {
childList: true,
subtree: true
});
}
}, 200);
}
return view;
}
});