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