'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 = '' + _('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('