From e646593c56c7cd558d1a6aa9e86f1adb5a96ab9c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 27 Oct 2026 12:12:04 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=A4=20Sync=202026-10-27=2012:12:04?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- luci-app-bandix/Makefile | 2 +- .../luci-static/resources/bandix/geoip.js | 10 +- .../luci-static/resources/bandix/quota.js | 181 +++++++ .../resources/view/bandix/connection.js | 15 +- .../resources/view/bandix/index.js | 501 +++++++----------- .../resources/view/bandix/settings.js | 4 +- luci-app-bandix/po/es/bandix.po | 12 +- luci-app-bandix/po/fr/bandix.po | 12 +- luci-app-bandix/po/ja/bandix.po | 12 +- luci-app-bandix/po/pl/bandix.po | 12 +- luci-app-bandix/po/ru/bandix.po | 12 +- luci-app-bandix/po/zh_Hans/bandix.po | 153 +++++- luci-app-bandix/po/zh_Hant/bandix.po | 180 ++++++- .../root/usr/libexec/rpcd/luci.bandix | 112 ++++ .../usr/share/rpcd/acl.d/luci-app-bandix.json | 2 + .../htdocs/luci-static/resources/network.js | 27 + luci-base/root/usr/share/rpcd/ucode/luci | 4 + .../resources/view/network/wireless.js | 81 ++- luci-mod-status/Makefile | 2 +- .../resources/view/status/include/60_wifi.js | 33 +- mise/Makefile | 4 +- natflow/Makefile | 4 +- node/Makefile | 18 +- openwrt-bandix/Makefile | 17 +- openwrt-bandix/files/bandix.config | 2 + openwrt-bandix/files/bandix.init | 18 + xradio/Makefile | 88 +++ .../0001-fix-build-with-recent-mac80211.patch | 114 ++++ .../0002-fix-more-missing-prototypes.patch | 37 ++ ...-missed-interrupt-message-to-dev_dbg.patch | 28 + 30 files changed, 1281 insertions(+), 416 deletions(-) create mode 100644 luci-app-bandix/htdocs/luci-static/resources/bandix/quota.js create mode 100644 xradio/Makefile create mode 100644 xradio/patches/0001-fix-build-with-recent-mac80211.patch create mode 100644 xradio/patches/0002-fix-more-missing-prototypes.patch create mode 100644 xradio/patches/0003-bh-demote-missed-interrupt-message-to-dev_dbg.patch diff --git a/luci-app-bandix/Makefile b/luci-app-bandix/Makefile index 9a47358ad..ff3c7c400 100644 --- a/luci-app-bandix/Makefile +++ b/luci-app-bandix/Makefile @@ -11,7 +11,7 @@ LUCI_DEPENDS:=+luci-base +luci-lib-jsonc +curl +bandix PKG_MAINTAINER:=timsaya PKG_VERSION:=0.12.12 -PKG_RELEASE:=14 +PKG_RELEASE:=15 # Rebuild language packages when a package revision changes without a PO commit. PKG_PO_VERSION=$(if $(DUMP),x,$(strip $(call findrev)))-r$(PKG_RELEASE) diff --git a/luci-app-bandix/htdocs/luci-static/resources/bandix/geoip.js b/luci-app-bandix/htdocs/luci-static/resources/bandix/geoip.js index 431e9d5aa..9d24a1bf3 100644 --- a/luci-app-bandix/htdocs/luci-static/resources/bandix/geoip.js +++ b/luci-app-bandix/htdocs/luci-static/resources/bandix/geoip.js @@ -18,14 +18,18 @@ function unwrap(reply) { if (!reply || reply.status !== 'success' || !reply.data) throw new Error(reply && (reply.message || reply.error) || _('GeoIP service is unavailable. Enable connection monitoring and apply settings, or update Bandix.')); return reply.data; } +function locale() { + // Standard LuCI exposes the active language on , not in L.env. + return (L.env.lang || document.documentElement.lang || 'en').replace(/_/g, '-'); +} function localized(names) { - var language = (L.env.lang || document.documentElement.lang || 'en').replace(/_/g, '-'); + var language = locale(); var key = /^zh/i.test(language) ? 'zh-CN' : language.split('-')[0]; return names && (names[key] || names.en) || ''; } function countryName(info) { if (info.country_code && typeof Intl !== 'undefined' && Intl.DisplayNames) { - try { return new Intl.DisplayNames([(L.env.lang || 'en').replace(/_/g, '-')], { type: 'region' }).of(info.country_code); } catch (e) { /* use database names */ } + try { return new Intl.DisplayNames([locale()], { type: 'region' }).of(info.country_code); } catch (e) { /* use database names */ } } return localized(info.country_names) || info.country_code || ''; } @@ -119,7 +123,7 @@ return baseclass.extend({ var builds = databases.map(function (db) { var date = '—'; if (db.build_epoch) { - try { date = new Date(db.build_epoch * 1000).toLocaleDateString((L.env.lang || 'en').replace(/_/g, '-')); } + try { date = new Date(db.build_epoch * 1000).toLocaleDateString(locale()); } catch (e) { date = new Date(db.build_epoch * 1000).toLocaleDateString(); } } return databaseName(db.kind) + ': ' + date; diff --git a/luci-app-bandix/htdocs/luci-static/resources/bandix/quota.js b/luci-app-bandix/htdocs/luci-static/resources/bandix/quota.js new file mode 100644 index 000000000..a32fbf0d4 --- /dev/null +++ b/luci-app-bandix/htdocs/luci-static/resources/bandix/quota.js @@ -0,0 +1,181 @@ +'use strict'; +'require baseclass'; +'require rpc'; + +var callSave = rpc.declare({ object: 'luci.bandix', method: 'setTrafficQuotaConfig', params: ['quota'], expect: {} }); +var periods = [ + { key: 'minute', label: _('Minute') }, { key: 'hourly', label: _('Hourly') }, + { key: 'daily', label: _('Daily') }, { key: 'weekly', label: _('Weekly') }, + { key: 'monthly', label: _('Monthly') }, { key: 'total', label: _('Lifetime') } +]; +var units = [1, 1024, 1048576, 1073741824, 1099511627776]; +var unitNames = ['B', 'KiB', 'MiB', 'GiB', 'TiB']; + +function modeOf(quota) { return quota && quota.mode === 'directional' ? 'directional' : 'combined'; } +function blockedText(quota) { + if (!quota || !quota.blocked) return ''; + if (modeOf(quota) === 'combined' || !quota.upload || !quota.download || (quota.upload.blocked && quota.download.blocked)) return _('Upload and download blocked'); + if (quota.upload.blocked) return _('Upload blocked'); + if (quota.download.blocked) return _('Download blocked'); + return _('Quota blocked'); +} +function summary(quota, formatSize) { + if (!quota) return null; + var groups = modeOf(quota) === 'directional' + ? [{ label: _('Upload'), data: quota.upload || {} }, { label: _('Download'), data: quota.download || {} }] + : [{ label: _('Combined'), data: quota }]; + return E('div', { 'class': 'traffic-quota-cell', title: blockedText(quota) || _('Traffic Quota') }, groups.map(function (group) { + var mostUsed = null; + periods.forEach(function (period) { + var limit = group.data[period.key + '_bytes'] || 0, used = group.data[period.key + '_used_bytes'] || 0; + if (limit > 0 && (!mostUsed || used / limit > mostUsed.ratio)) mostUsed = { label: period.label, used: used, limit: limit, ratio: used / limit }; + }); + var text = mostUsed ? mostUsed.label + ': ' + formatSize(mostUsed.used) + ' / ' + formatSize(mostUsed.limit) : _('Unlimited'); + var blocked = group.data.blocked; + return E('div', { 'class': blocked ? 'traffic-quota-exceeded' : '' }, + group.label + ' · ' + text + (blocked ? ' · ' + _('Blocked') : '')); + })); +} + +return baseclass.extend({ + blockedText: blockedText, + summary: summary, + save: function (quota) { + return callSave(JSON.stringify(quota)).then(function (result) { + if (!result || result.success !== true) { + var errors = { + 'Update the Bandix service to configure separate upload and download quotas.': _('Update the Bandix service to configure separate upload and download quotas.'), + 'Failed to connect to Bandix service': _('Failed to connect to Bandix service') + }; + throw new Error(errors[result && result.error] || _('Failed to save traffic quota')); + } + return result; + }); + }, + create: function (options) { + var inputs = {}, badges = {}, busy = false, ready = false, supported = false, error = null, current = null; + var message = E('div', { 'class': 'traffic-quota-message', role: 'status' }); + var combinedState = E('span', { 'class': 'traffic-quota-status unconfigured' }); + var separateOption = E('option', { value: 'directional' }, _('Separate upload / download')); + var mode = E('select', { 'class': 'cbi-input-select', id: 'traffic-quota-mode' }, [ + E('option', { value: 'combined' }, _('Combined quota')), separateOption + ]); + mode.value = 'combined'; + var help = E('p', { 'class': 'traffic-quota-help' }); + function changed() { syncDisabled(); if (options.changed) options.changed(); } + function inputGroup(direction, period) { + var id = 'traffic-quota-' + direction + '-' + period.key; + var title = (direction === 'upload' ? _('Upload') : direction === 'download' ? _('Download') : _('Combined')) + ' · ' + period.label; + var value = E('input', { type: 'number', min: '0', step: 'any', 'class': 'form-input', id: id, 'aria-label': title, placeholder: '0' }); + var unit = E('select', { 'class': 'cbi-input-select', 'aria-label': title + ' · ' + _('Unit') }, units.map(function (size, index) { + return E('option', { value: String(size) }, unitNames[index]); + })); + var usage = E('div', { 'class': 'traffic-quota-period-usage' }); + inputs[direction][period.key] = { value: value, unit: unit, usage: usage }; + value.addEventListener('input', changed); unit.addEventListener('change', changed); + return E('div', { 'class': 'traffic-quota-period' }, [ + E('label', { 'class': 'form-label', 'for': id }, period.label), + E('div', { 'class': 'traffic-quota-input-row' }, [value, unit]), usage + ]); + } + function fields(direction) { + inputs[direction] = {}; + return periods.map(function (period) { return inputGroup(direction, period); }); + } + function directionCard(direction, label) { + badges[direction] = E('span', { 'class': 'traffic-quota-status unconfigured' }); + return E('section', { 'class': 'traffic-quota-direction traffic-quota-' + direction, 'aria-label': label }, [ + E('div', { 'class': 'traffic-quota-header' }, [E('strong', {}, label), badges[direction]]), + E('div', {}, fields(direction)) + ]); + } + var directions = E('div', { 'class': 'traffic-quota-directions' }, [ + directionCard('upload', '↑ ' + _('Upload Quota')), directionCard('download', '↓ ' + _('Download Quota')) + ]); + var combined = E('section', { 'class': 'traffic-quota-combined' }, [ + E('div', { 'class': 'traffic-quota-header' }, [E('strong', {}, _('Combined quota (upload + download)')), combinedState]), + E('div', { 'class': 'traffic-quota-grid' }, fields('combined')) + ]); + var root = E('div', { 'class': 'traffic-quota-form' }, [ + E('div', { 'class': 'form-group' }, [E('label', { 'class': 'form-label', 'for': 'traffic-quota-mode' }, _('Quota mode')), mode]), + E('p', { 'class': 'traffic-quota-help' }, _('Only the selected mode takes effect. Switching modes preserves usage and clears the other limits when saved.')), + message, help, combined, directions + ]); + function fill(input, bytes, period) { + var unit = bytes > 0 ? 1 : period === 'minute' ? 1048576 : 1073741824; + for (var i = units.length - 1; bytes > 0 && i >= 0; i--) { + if (bytes >= units[i] && bytes % units[i] === 0) { unit = units[i]; break; } + } + input.unit.value = String(unit); + input.value.value = String(bytes / unit); + } + function canEdit() { return options.writable !== false && ready && !error && !busy; } + function canSave() { return canEdit() && (mode.value === 'combined' || supported); } + function syncDisabled() { + var directional = mode.value === 'directional'; + mode.disabled = !canEdit(); separateOption.disabled = !supported; + directions.style.display = directional ? '' : 'none'; + combined.style.display = directional ? 'none' : ''; + help.textContent = directional + ? _('WAN upload and download have separate quotas. Only the exhausted direction is blocked. Enter 0 for unlimited.') + : _('WAN upload and download are counted together. Enter 0 for unlimited.'); + Object.keys(inputs).forEach(function (direction) { + var active = directional ? direction !== 'combined' : direction === 'combined'; + periods.forEach(function (period) { + var input = inputs[direction][period.key]; + input.value.disabled = !canSave() || !active; input.unit.disabled = !canSave() || !active; + }); + var badge = direction === 'combined' ? combinedState : badges[direction]; + var data = current && (direction === 'combined' ? current : current[direction]); + var pending = current && mode.value !== modeOf(current); + badge.textContent = error ? _('Unavailable') : !ready ? _('Loading...') : pending ? _('Not saved') + : !data ? _('Not configured') : data.blocked ? _('Blocked') : _('Active'); + badge.classList.toggle('blocked', !pending && !!(data && data.blocked)); + badge.classList.toggle('unconfigured', !data || !!error || !!pending); + }); + } + mode.addEventListener('change', changed); + return { + root: root, + canSave: canSave, + setBusy: function (value) { busy = value; syncDisabled(); }, + render: function (quota, state) { + ready = state.ready; supported = state.supported; error = state.error; current = quota; + if (!state.preserveInputs && !busy) mode.value = modeOf(quota); + message.textContent = error || (!ready ? _('Loading...') : !supported + ? _('This service supports combined quotas only. Update Bandix to use separate quotas.') + : !quota ? _('Usage starts when a quota is first saved.') : ''); + message.classList.toggle('traffic-quota-exceeded', !!error); + Object.keys(inputs).forEach(function (direction) { + var data = quota && (direction === 'combined' ? quota : quota[direction]); + periods.forEach(function (period) { + var input = inputs[direction][period.key]; + var limit = data && data[period.key + '_bytes'] || 0, used = data && data[period.key + '_used_bytes'] || 0; + if (!state.preserveInputs && !busy) fill(input, limit, period.key); + input.usage.textContent = data ? _('Used') + ': ' + options.formatSize(used) + ' / ' + (limit > 0 ? options.formatSize(limit) : _('Unlimited')) : '—'; + input.usage.classList.toggle('traffic-quota-exceeded', limit > 0 && used >= limit); + }); + }); + syncDisabled(); + }, + read: function (mac) { + var result = { mac: mac, mode: mode.value, upload: {}, download: {} }; + if (!canSave()) throw new Error(_('Failed to save traffic quota')); + Object.keys(inputs).forEach(function (direction) { + var target = direction === 'combined' ? result : result[direction]; + var active = mode.value === 'combined' ? direction === 'combined' : direction !== 'combined'; + periods.forEach(function (period) { + if (!active) { target[period.key + '_bytes'] = 0; return; } + var input = inputs[direction][period.key], value = Number(input.value.value || 0), unit = Number(input.unit.value); + var bytes = Math.round(value * unit); + if ((input.value.validity && input.value.validity.badInput) || !Number.isFinite(value) || value < 0 || units.indexOf(unit) < 0 || !Number.isSafeInteger(bytes)) { + throw new Error(_('Quota values must be non-negative and within the supported range.')); + } + target[period.key + '_bytes'] = bytes; + }); + }); + return result; + } + }; + } +}); diff --git a/luci-app-bandix/htdocs/luci-static/resources/view/bandix/connection.js b/luci-app-bandix/htdocs/luci-static/resources/view/bandix/connection.js index 3af9908b4..1710c39de 100644 --- a/luci-app-bandix/htdocs/luci-static/resources/view/bandix/connection.js +++ b/luci-app-bandix/htdocs/luci-static/resources/view/bandix/connection.js @@ -129,7 +129,7 @@ function getThemeColors() { function formatSize(bytes) { if (!bytes || bytes === 0) return '0 B'; - var units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; + var units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB']; var i = Math.floor(Math.log(bytes) / Math.log(1024)); return parseFloat((bytes / Math.pow(1024, i)).toFixed(2)) + ' ' + units[i]; } @@ -148,12 +148,13 @@ function flowEndpointCell(addr, port, information) { E('span', { 'class': 'flows-addr' }, text), E('span', { 'class': 'flows-port' }, port ? ':' + port : '') ])]; - if (description) children.push(E('div', { - 'class': 'flows-geoip', title: description.details - }, [ - E('div', {}, description.location || description.summary), - description.organization ? E('div', { 'class': 'flows-geoip-network' }, description.organization) : null - ])); + if (description) { + var location = [E('div', {}, description.location || description.summary)]; + if (description.organization) location.push(E('div', { 'class': 'flows-geoip-network' }, description.organization)); + children.push(E('div', { + 'class': 'flows-geoip', title: description.details + }, location)); + } return E('td', { 'class': 'flows-addr-cell' }, children); } 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 6fb39d7f2..1eb156922 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 @@ -5,6 +5,7 @@ 'require rpc'; 'require poll'; 'require bandix.devices as devices'; +'require bandix.quota as quotaUi'; // 颜色常量定义 var BANDIX_COLOR_UPLOAD = '#f97316'; // 橙色 - 上传/上行 @@ -188,7 +189,7 @@ function getThemeType() { function formatSize(bytes) { if (bytes === 0) return '0 B'; - const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; + const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB']; const i = Math.floor(Math.log(bytes) / Math.log(1024)); return parseFloat((bytes / Math.pow(1024, i)).toFixed(2)) + ' ' + units[i]; } @@ -201,12 +202,12 @@ function formatByterate(bytes_per_sec, unit) { if (unit === 'bits') { // 转换为比特单位 const bits_per_sec = bytes_per_sec * 8; - const units = ['bps', 'Kbps', 'Mbps', 'Gbps', 'Tbps']; + const units = ['bps', 'kbps', 'Mbps', 'Gbps', 'Tbps']; const i = Math.floor(Math.log(bits_per_sec) / Math.log(1000)); return parseFloat((bits_per_sec / Math.pow(1000, i)).toFixed(2)) + ' ' + units[i]; } else { // 默认字节单位 - const units = ['B/s', 'KB/s', 'MB/s', 'GB/s', 'TB/s']; + const units = ['B/s', 'KiB/s', 'MiB/s', 'GiB/s', 'TiB/s']; const i = Math.floor(Math.log(bytes_per_sec) / Math.log(1024)); return parseFloat((bytes_per_sec / Math.pow(1024, i)).toFixed(2)) + ' ' + units[i]; } @@ -252,17 +253,18 @@ function parseSpeed(speedStr) { if (!speedStr || speedStr === '0' || speedStr === '0 B/s' || speedStr === '0 bps') return 0; // 匹配字节单位 - const bytesMatch = speedStr.match(/^([\d.]+)\s*([KMGT]?B\/s)$/i); + const bytesMatch = speedStr.match(/^([\d.]+)\s*((?:[KMGT]i?)?B\/s)$/i); if (bytesMatch) { const value = parseFloat(bytesMatch[1]); - const unit = bytesMatch[2].toUpperCase(); + // Older UI strings used KB/s for the same binary value. + const unit = bytesMatch[2].toUpperCase().replace(/^([KMGT])B\//, '$1IB/'); const bytesMultipliers = { 'B/S': 1, - 'KB/S': 1024, - 'MB/S': 1024 * 1024, - 'GB/S': 1024 * 1024 * 1024, - 'TB/S': 1024 * 1024 * 1024 * 1024 + 'KIB/S': 1024, + 'MIB/S': 1024 * 1024, + 'GIB/S': 1024 * 1024 * 1024, + 'TIB/S': 1024 * 1024 * 1024 * 1024 }; return value * (bytesMultipliers[unit] || 1); @@ -477,13 +479,6 @@ var callGetTrafficQuotas = rpc.declare({ expect: {} }); -var callSetTrafficQuota = rpc.declare({ - object: 'luci.bandix', - method: 'setTrafficQuota', - params: ['mac', 'minute_bytes', 'hourly_bytes', 'daily_bytes', 'weekly_bytes', 'monthly_bytes', 'total_bytes'], - expect: {} -}); - var callDeleteTrafficQuota = rpc.declare({ object: 'luci.bandix', method: 'deleteTrafficQuota', @@ -2051,6 +2046,34 @@ return view.extend({ } .traffic-quota-input-row .form-input { min-width: 0; flex: 1; } .traffic-quota-input-row .cbi-input-select { width: 82px; flex-shrink: 0; } + #device-settings-modal .bandix-modal { max-width: 720px; display: flex; flex-direction: column; overflow: hidden; } + #device-settings-modal .bandix-modal-body { overflow-y: auto; min-height: 0; } + #device-settings-modal .bandix-modal-header, + #device-settings-modal .bandix-modal-footer { flex-shrink: 0; } + #device-settings-modal [hidden] { display: none !important; } + .device-settings-tabs { display: flex; gap: 6px; padding: 0 20px; flex-shrink: 0; border-bottom: 1px solid ${scheme === 'dark' ? '#444' : '#ddd'}; } + .device-settings-tabs button { appearance: none; background: transparent; border: 0; border-bottom: 2px solid transparent; padding: 12px 18px; color: inherit; font: inherit; cursor: pointer; opacity: 0.65; } + .device-settings-tabs button[aria-selected="true"] { border-bottom-color: currentColor; opacity: 1; font-weight: 600; } + .device-settings-tabs button:focus-visible { outline: 2px solid currentColor; outline-offset: -3px; } + .traffic-quota-form { color-scheme: ${scheme === 'dark' ? 'dark' : 'light'}; } + .traffic-quota-directions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + } + .traffic-quota-direction { + min-width: 0; + padding: 14px; + border: 1px solid ${scheme === 'dark' ? 'rgba(255,255,255,0.16)' : 'rgba(0,0,0,0.12)'}; + border-radius: 8px; + } + .traffic-quota-period { margin-top: 12px; } + .traffic-quota-period .form-label { margin-bottom: 4px; font-size: 0.8125rem; } + .traffic-quota-period-usage { font-size: 0.75rem; opacity: 0.8; margin-top: 4px; overflow-wrap: anywhere; } + .traffic-quota-exceeded { color: #ef4444; font-weight: 600; } + .traffic-quota-help, .traffic-quota-message { font-size: 0.8125rem; margin: 0 0 12px; line-height: 1.6; } + .traffic-quota-message:empty { display: none; } + .traffic-quota-combined { margin-top: 16px; } .traffic-quota-usage { margin-top: 12px; padding: 10px 12px; @@ -2093,6 +2116,7 @@ return view.extend({ @media (max-width: 600px) { .traffic-quota-grid { grid-template-columns: 1fr; } + .traffic-quota-directions { grid-template-columns: 1fr; } } /* 统计区域样式 */ @@ -3366,108 +3390,64 @@ return view.extend({ }); } - function createQuotaInput(period, label) { - return E('div', { 'class': 'form-group', 'style': 'margin-bottom: 0;' }, [ - E('label', { 'class': 'form-label' }, label), - E('div', { 'class': 'traffic-quota-input-row' }, [ - E('input', { - 'type': 'number', - 'min': '0', - 'step': '0.01', - 'class': 'form-input', - 'id': 'traffic-quota-' + period + '-value', - 'placeholder': '0' - }), - E('select', { 'class': 'cbi-input-select', 'id': 'traffic-quota-' + period + '-unit' }, [ - E('option', { 'value': '1' }, 'B'), - E('option', { 'value': '1024' }, 'KB'), - E('option', { 'value': '1048576' }, 'MB'), - E('option', { 'value': '1073741824' }, 'GB'), - E('option', { 'value': '1099511627776' }, 'TB') - ]) - ]) - ]); - } - - // 创建设备设置模态框 - var bandixModal = E('div', { 'class': 'bandix-modal-overlay', 'id': 'rate-limit-bandix-modal' }, [ - E('div', { 'class': 'modal-content bandix-modal' }, [ - E('div', { 'class': 'bandix-modal-body', }, [ - E('div', { 'class': 'device-summary', 'id': 'bandix-modal-device-summary' }), - E('div', { 'class': 'form-group' }, [ - E('label', { 'class': 'form-label' }, _('Hostname')), - E('div', { 'style': 'display: flex; gap: 8px; align-items: center;' }, [ - E('input', { 'type': 'text', 'class': 'form-input', 'id': 'device-hostname-input', 'placeholder': _('Please enter hostname'), 'style': 'flex: 1;' }), - E('button', { 'class': 'cbi-button cbi-button-positive', 'id': 'hostname-save-btn', 'style': 'flex-shrink: 0;' }, _('Save')) - ]), - E('div', { 'style': 'font-size: 0.75rem; color: #6b7280; margin-top: 4px;' }, _('Set Hostname')) - ]), - // 定时限速 - E('div', { 'id': 'schedule-limit-tab' }, [ - // 描述和添加规则按钮 - 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', { - 'type': 'button', - 'class': 'cbi-button cbi-button-action', - 'id': 'schedule-add-rule-btn', - 'style': 'display: flex; align-items: center; gap: 4px;' - }, [ - E('span', {}, '+'), - _('Add Rule') - ]) - ]), - // 规则列表区域 - E('div', { 'class': 'schedule-rules-list', 'id': 'schedule-rules-list' }) - ]) - ]), - E('div', { 'class': 'bandix-modal-footer' }, [ - E('button', { 'class': 'cbi-button cbi-button-reset', 'id': 'bandix-modal-close' }, _('Close')) - ]) - ]) - ]); - - document.body.appendChild(bandixModal); - - // 创建独立的流量配额模态框 - var trafficQuotaModal = E('div', { 'class': 'bandix-modal-overlay', 'id': 'traffic-quota-bandix-modal' }, [ - E('div', { 'class': 'modal-content bandix-modal' }, [ + // One device settings dialog; tabs retain their drafts while switching. + var quotaForm = quotaUi.create({ + formatSize: formatSize, writable: !!L.hasViewPermission(), + changed: function () { quotaFormDirty = true; setTrafficQuotaFormDisabled(quotaFormBusy); } + }); + var deviceSettingsModal = E('div', { 'class': 'bandix-modal-overlay', 'id': 'device-settings-modal' }, [ + E('div', { 'class': 'modal-content bandix-modal', role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'device-settings-title' }, [ E('div', { 'class': 'bandix-modal-header' }, [ - E('h3', { 'class': 'bandix-modal-title' }, _('Traffic Quota')) + E('h3', { 'class': 'bandix-modal-title', id: 'device-settings-title' }, _('Device Settings')) ]), - E('div', { 'class': 'bandix-modal-body' }, [ - E('div', { 'class': 'device-summary', 'id': 'traffic-quota-device-summary' }), - E('div', { 'class': 'traffic-quota-section', 'style': 'margin-top: 0; padding-top: 0; border-top: none;' }, [ - E('div', { 'class': 'traffic-quota-header' }, [ - E('div', { 'style': 'font-weight: 600;' }, _('Quota Status')), - E('span', { 'class': 'traffic-quota-status', 'id': 'traffic-quota-status' }, _('Not configured')) + E('div', { 'class': 'device-settings-tabs', role: 'tablist', 'aria-label': _('Device Settings') }, [ + E('button', { type: 'button', role: 'tab', id: 'device-settings-rate-tab', 'aria-controls': 'device-settings-rate-panel', 'aria-selected': 'true', tabindex: '0' }, _('Rate Limits')), + E('button', { type: 'button', role: 'tab', id: 'device-settings-quota-tab', 'aria-controls': 'device-settings-quota-panel', 'aria-selected': 'false', tabindex: '-1' }, _('Traffic Quota')) + ]), + E('div', { 'class': 'bandix-modal-body', id: 'device-settings-body' }, [ + E('div', { 'class': 'device-summary', 'id': 'bandix-modal-device-summary' }), + E('div', { id: 'device-settings-rate-panel', role: 'tabpanel', 'aria-labelledby': 'device-settings-rate-tab' }, [ + E('div', { 'class': 'form-group' }, [ + E('label', { 'class': 'form-label' }, _('Hostname')), + E('div', { 'style': 'display: flex; gap: 8px; align-items: center;' }, [ + E('input', { 'type': 'text', 'class': 'form-input', 'id': 'device-hostname-input', 'placeholder': _('Please enter hostname'), 'style': 'flex: 1;' }), + E('button', { 'class': 'cbi-button cbi-button-positive', 'id': 'hostname-save-btn', 'style': 'flex-shrink: 0;' }, _('Save')) + ]), + E('div', { 'style': 'font-size: 0.75rem; color: #6b7280; margin-top: 4px;' }, _('Set Hostname')) ]), - E('div', { 'style': 'font-size: 0.75rem; opacity: 0.7;' }, - _('WAN upload and download are counted together. Enter 0 for unlimited.')), - E('div', { 'class': 'traffic-quota-grid' }, [ - createQuotaInput('minute', _('Minute Quota')), - createQuotaInput('hourly', _('Hourly Quota')), - createQuotaInput('daily', _('Daily Quota')), - createQuotaInput('weekly', _('Weekly Quota')), - createQuotaInput('monthly', _('Monthly Quota')), - createQuotaInput('total', _('Lifetime Quota')) - ]), - E('div', { 'class': 'traffic-quota-usage', 'id': 'traffic-quota-usage' }, [ - E('div', { 'style': 'opacity: 0.7;' }, _('No quota usage data')) - ]), - E('div', { 'style': 'display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px;' }, [ - E('button', { 'class': 'cbi-button cbi-button-negative', 'id': 'traffic-quota-delete-btn', 'style': 'display: none;' }, _('Delete Quota')), - E('button', { 'class': 'cbi-button cbi-button-positive', 'id': 'traffic-quota-save-btn' }, _('Save Quota')) + // 定时限速 + E('div', { 'id': 'schedule-limit-tab' }, [ + // 描述和添加规则按钮 + 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', { + 'type': 'button', + 'class': 'cbi-button cbi-button-action', + 'id': 'schedule-add-rule-btn', + 'style': 'display: flex; align-items: center; gap: 4px;' + }, [ + E('span', {}, '+'), + _('Add Rule') + ]) + ]), + // 规则列表区域 + E('div', { 'class': 'schedule-rules-list', 'id': 'schedule-rules-list' }) ]) - ]) + + ]), + E('div', { id: 'device-settings-quota-panel', role: 'tabpanel', 'aria-labelledby': 'device-settings-quota-tab', hidden: '' }, [quotaForm.root]) ]), - E('div', { 'class': 'bandix-modal-footer' }, [ - E('button', { 'class': 'cbi-button cbi-button-reset', 'id': 'traffic-quota-modal-close' }, _('Close')) + E('div', { 'class': 'bandix-modal-footer', id: 'device-settings-rate-footer' }, [ + E('button', { 'class': 'cbi-button cbi-button-reset', 'id': 'bandix-modal-close' }, _('Close')) + ]), + E('div', { 'class': 'bandix-modal-footer', id: 'device-settings-quota-footer', hidden: '' }, [ + E('button', { 'class': 'cbi-button cbi-button-negative', 'id': 'traffic-quota-delete-btn', 'style': 'display: none;' }, _('Delete Quota')), + E('button', { 'class': 'cbi-button cbi-button-reset', 'id': 'traffic-quota-modal-close' }, _('Close')), + E('button', { 'class': 'cbi-button cbi-button-positive', 'id': 'traffic-quota-save-btn' }, _('Save Quota')) ]) ]) ]); - - document.body.appendChild(trafficQuotaModal); + document.body.appendChild(deviceSettingsModal); // 创建添加规则模态框 var addRuleModal = E('div', { 'class': 'bandix-modal-overlay', 'id': 'add-rule-bandix-modal' }, [ @@ -3558,10 +3538,10 @@ return view.extend({ E('div', { 'style': 'display: flex; gap: 8px;' }, [ E('input', { 'type': 'number', 'min': '0', 'step': '0.01', 'class': 'form-input', 'id': 'whitelist_default_wan_tx', 'placeholder': '0', 'style': 'flex: 1;' }), E('select', { 'class': 'cbi-input-select', 'id': 'whitelist_default_wan_tx_unit', 'style': 'width: 110px; flex-shrink: 0;' }, [ - E('option', { 'value': '1024' }, 'KB/s'), - E('option', { 'value': '1048576' }, 'MB/s'), - E('option', { 'value': '1073741824' }, 'GB/s'), - E('option', { 'value': '125' }, 'Kbps'), + E('option', { 'value': '1024' }, 'KiB/s'), + E('option', { 'value': '1048576' }, 'MiB/s'), + E('option', { 'value': '1073741824' }, 'GiB/s'), + E('option', { 'value': '125' }, 'kbps'), E('option', { 'value': '125000' }, 'Mbps'), E('option', { 'value': '125000000' }, 'Gbps') ]) @@ -3573,10 +3553,10 @@ return view.extend({ E('div', { 'style': 'display: flex; gap: 8px;' }, [ E('input', { 'type': 'number', 'min': '0', 'step': '0.01', 'class': 'form-input', 'id': 'whitelist_default_wan_rx', 'placeholder': '0', 'style': 'flex: 1;' }), E('select', { 'class': 'cbi-input-select', 'id': 'whitelist_default_wan_rx_unit', 'style': 'width: 110px; flex-shrink: 0;' }, [ - E('option', { 'value': '1024' }, 'KB/s'), - E('option', { 'value': '1048576' }, 'MB/s'), - E('option', { 'value': '1073741824' }, 'GB/s'), - E('option', { 'value': '125' }, 'Kbps'), + E('option', { 'value': '1024' }, 'KiB/s'), + E('option', { 'value': '1048576' }, 'MiB/s'), + E('option', { 'value': '1073741824' }, 'GiB/s'), + E('option', { 'value': '125' }, 'kbps'), E('option', { 'value': '125000' }, 'Mbps'), E('option', { 'value': '125000000' }, 'Gbps') ]) @@ -3970,21 +3950,21 @@ return view.extend({ downloadUnitSelect.innerHTML = ''; if (speedUnit === 'bits') { - uploadUnitSelect.appendChild(E('option', { 'value': '125' }, 'Kbps')); + 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': '125' }, 'kbps')); downloadUnitSelect.appendChild(E('option', { 'value': '125000' }, 'Mbps')); downloadUnitSelect.appendChild(E('option', { 'value': '125000000' }, 'Gbps')); } else { - uploadUnitSelect.appendChild(E('option', { 'value': '1024' }, 'KB/s')); - uploadUnitSelect.appendChild(E('option', { 'value': '1048576' }, 'MB/s')); - uploadUnitSelect.appendChild(E('option', { 'value': '1073741824' }, 'GB/s')); + uploadUnitSelect.appendChild(E('option', { 'value': '1024' }, 'KiB/s')); + uploadUnitSelect.appendChild(E('option', { 'value': '1048576' }, 'MiB/s')); + uploadUnitSelect.appendChild(E('option', { 'value': '1073741824' }, 'GiB/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')); + downloadUnitSelect.appendChild(E('option', { 'value': '1024' }, 'KiB/s')); + downloadUnitSelect.appendChild(E('option', { 'value': '1048576' }, 'MiB/s')); + downloadUnitSelect.appendChild(E('option', { 'value': '1073741824' }, 'GiB/s')); } if (editingRule) { @@ -4206,65 +4186,40 @@ return view.extend({ var currentDevice = null; var currentQuotaDevice = null; var quotaFormDirty = false; + var quotaFormBusy = false; var editingRule = null; - var showRateLimitModal; + var scheduleRulesRequestId = 0; - trafficQuotaModal.querySelectorAll('.traffic-quota-input-row input, .traffic-quota-input-row select').forEach(function (input) { - input.addEventListener(input.tagName === 'SELECT' ? 'change' : 'input', function () { - quotaFormDirty = true; + function selectDeviceSettingsTab(tab, focus) { + document.getElementById('device-settings-body').scrollTop = 0; + ['rate', 'quota'].forEach(function (name) { + var selected = name === tab; + var button = document.getElementById('device-settings-' + name + '-tab'); + button.setAttribute('aria-selected', String(selected)); + button.tabIndex = selected ? 0 : -1; + document.getElementById('device-settings-' + name + '-panel').hidden = !selected; + document.getElementById('device-settings-' + name + '-footer').hidden = !selected; + if (selected && focus) button.focus(); + }); + } + + ['rate', 'quota'].forEach(function (tab) { + var button = document.getElementById('device-settings-' + tab + '-tab'); + button.addEventListener('click', function () { selectDeviceSettingsTab(tab); }); + button.addEventListener('keydown', function (event) { + var next; + if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') next = tab === 'rate' ? 'quota' : 'rate'; + else if (event.key === 'Home') next = 'rate'; + else if (event.key === 'End') next = 'quota'; + if (next) { event.preventDefault(); selectDeviceSettingsTab(next, true); } }); }); - // 显示模态框 - showRateLimitModal = function (device) { + function showDeviceSettingsModal(device) { + if (quotaFormBusy) return; currentDevice = device; - var bandixModal = document.getElementById('rate-limit-bandix-modal'); - var deviceSummary = document.getElementById('bandix-modal-device-summary'); - // 清空定时限速规则列表并加载 - var rulesList = document.getElementById('schedule-rules-list'); - if (rulesList) { - rulesList.innerHTML = '
' + - _('No scheduled rules yet, click "Add Rule" to start setting') + - '
'; - } - - // 加载定时限速规则列表 - loadScheduleRules(); - - var modalContent = [ - E('div', { 'class': 'device-summary-name' }, device.host || device.ip4), - E('div', { 'class': 'device-summary-details' }, device.ip4 + ' (' + device.mac + ')') - ]; - var modalBadges = buildDeviceUplinkChBadges(device); - if (modalBadges.length) { - modalContent.push(E('div', { 'class': 'device-summary-badges device-uplink-badges-wrap' }, modalBadges)); - } - deviceSummary.innerHTML = E('div', {}, modalContent).innerHTML; - - // 设置当前hostname值 - document.getElementById('device-hostname-input').value = device.host || ''; - - - // 显示模态框并添加动画 - bandixModal.classList.add('show'); - } - - // 隐藏模态框 - function hideRateLimitModal() { - var bandixModal = document.getElementById('rate-limit-bandix-modal'); - bandixModal.classList.remove('show'); - - // 等待动画完成后清理 - setTimeout(function () { - currentDevice = null; - }, 300); - } - - function showTrafficQuotaModal(device) { currentQuotaDevice = device; quotaFormDirty = false; - var modal = document.getElementById('traffic-quota-bandix-modal'); - var deviceSummary = document.getElementById('traffic-quota-device-summary'); var modalContent = [ E('div', { 'class': 'device-summary-name' }, device.host || device.ip4), E('div', { 'class': 'device-summary-details' }, device.ip4 + ' (' + device.mac + ')') @@ -4273,23 +4228,29 @@ return view.extend({ if (modalBadges.length) { modalContent.push(E('div', { 'class': 'device-summary-badges device-uplink-badges-wrap' }, modalBadges)); } - deviceSummary.innerHTML = E('div', {}, modalContent).innerHTML; + var summary = document.getElementById('bandix-modal-device-summary'); + summary.replaceChildren(E('div', {}, modalContent)); + document.getElementById('device-hostname-input').value = device.host || ''; + selectDeviceSettingsTab('rate'); renderTrafficQuotaForm(getTrafficQuotaForDevice(device.mac), false); - modal.classList.add('show'); + loadScheduleRules(); loadTrafficQuotaForm(); + document.getElementById('device-settings-modal').classList.add('show'); } - function hideTrafficQuotaModal() { - document.getElementById('traffic-quota-bandix-modal').classList.remove('show'); - setTimeout(function () { - currentQuotaDevice = null; - quotaFormDirty = false; - }, 300); + function hideDeviceSettingsModal() { + document.getElementById('device-settings-modal').classList.remove('show'); + currentDevice = null; + currentQuotaDevice = null; + quotaFormDirty = false; + scheduleRulesRequestId++; } // 加载定时限速规则列表 function loadScheduleRules() { if (!currentDevice) return; + var deviceMac = currentDevice.mac; + var requestId = ++scheduleRulesRequestId; var rulesList = document.getElementById('schedule-rules-list'); if (!rulesList) return; @@ -4297,6 +4258,7 @@ return view.extend({ rulesList.innerHTML = '
' + _('Loading...') + '
'; callGetScheduleLimits().then(function (res) { + if (!currentDevice || currentDevice.mac !== deviceMac || requestId !== scheduleRulesRequestId) return; // 检查响应格式 if (!res) { rulesList.innerHTML = '
' + _('No schedule rules') + '
'; @@ -4326,7 +4288,7 @@ return view.extend({ // 过滤出当前设备的规则 var deviceRules = limits.filter(function (rule) { - return rule && rule.mac === currentDevice.mac; + return rule && rule.mac === deviceMac; }); // 清空列表 @@ -4404,6 +4366,7 @@ return view.extend({ rulesList.appendChild(ruleItem); }); }).catch(function (error) { + if (!currentDevice || currentDevice.mac !== deviceMac || requestId !== scheduleRulesRequestId) return; console.error('Failed to load schedule rules:', error); var errorMsg = _('Failed to load schedule rules'); if (error && error.message) { @@ -4433,14 +4396,15 @@ return view.extend({ saveButton.classList.add('btn-loading'); saveButton.disabled = true; - callSetHostname(currentDevice.mac, newHostname).then(function (result) { + var savedDevice = currentDevice; + callSetHostname(savedDevice.mac, newHostname).then(function (result) { // 恢复按钮状态 saveButton.innerHTML = originalText; saveButton.classList.remove('btn-loading'); saveButton.disabled = false; // 更新当前设备信息 - currentDevice.host = newHostname; + savedDevice.host = newHostname; // 刷新设备数据 updateDeviceData(); @@ -4457,20 +4421,21 @@ return view.extend({ document.getElementById('hostname-save-btn').addEventListener('click', saveHostname); document.getElementById('traffic-quota-save-btn').addEventListener('click', function () { - if (!currentQuotaDevice) return; + if (!currentQuotaDevice || !quotaForm.canSave()) return; var mac = currentQuotaDevice.mac; - var values = ['minute', 'hourly', 'daily', 'weekly', 'monthly', 'total'].map(readQuotaInput); - if (values.some(function (value) { return value === null; })) { - ui.addNotification(null, E('p', {}, _('Quota values must be non-negative and within the supported range.')), 'error'); + var payload; + try { payload = quotaForm.read(mac); } catch (error) { + ui.addNotification(null, E('p', {}, error.message), 'error'); return; } var button = this; button.disabled = true; setTrafficQuotaFormDisabled(true); - callSetTrafficQuota(mac, values[0], values[1], values[2], values[3], values[4], values[5]).then(function (result) { + quotaUi.save(payload).then(function (result) { if (result && result.success === false) throw new Error(result.error || _('Failed to save traffic quota')); return fetchAllTrafficQuotas(true); }).then(function () { + setTrafficQuotaFormDisabled(false); if (currentQuotaDevice && currentQuotaDevice.mac === mac) { quotaFormDirty = false; renderTrafficQuotaForm(getTrafficQuotaForDevice(mac), false); @@ -4478,23 +4443,26 @@ return view.extend({ updateDeviceData(); }).catch(function (error) { ui.addNotification(null, E('p', {}, _('Failed to save traffic quota') + ': ' + (error.message || error)), 'error'); + if (currentQuotaDevice) renderTrafficQuotaForm(getTrafficQuotaForDevice(currentQuotaDevice.mac), true); }).finally(function () { - button.disabled = false; setTrafficQuotaFormDisabled(false); }); }); document.getElementById('traffic-quota-delete-btn').addEventListener('click', function () { - if (!currentQuotaDevice) return; + if (!currentQuotaDevice || quotaFormBusy || !L.hasViewPermission()) return; var mac = currentQuotaDevice.mac; showConfirmDialog( _('Delete Traffic Quota'), _('Delete this quota and all of its accumulated usage?'), function () { + if (quotaFormBusy) return; + setTrafficQuotaFormDisabled(true); callDeleteTrafficQuota(mac).then(function (result) { - if (result && result.success === false) throw new Error(result.error || _('Failed to delete traffic quota')); + if (!result || result.success !== true) throw new Error(result && result.error || _('Failed to delete traffic quota')); return fetchAllTrafficQuotas(true); }).then(function () { + setTrafficQuotaFormDisabled(false); if (currentQuotaDevice && currentQuotaDevice.mac === mac) { quotaFormDirty = false; renderTrafficQuotaForm(null, false); @@ -4502,14 +4470,15 @@ return view.extend({ updateDeviceData(); }).catch(function (error) { ui.addNotification(null, E('p', {}, _('Failed to delete traffic quota') + ': ' + (error.message || error)), 'error'); - }); + if (currentQuotaDevice) renderTrafficQuotaForm(getTrafficQuotaForDevice(currentQuotaDevice.mac), true); + }).finally(function () { setTrafficQuotaFormDisabled(false); }); } ); }); // 绑定关闭按钮事件 - document.getElementById('bandix-modal-close').addEventListener('click', hideRateLimitModal); - document.getElementById('traffic-quota-modal-close').addEventListener('click', hideTrafficQuotaModal); + document.getElementById('bandix-modal-close').addEventListener('click', hideDeviceSettingsModal); + document.getElementById('traffic-quota-modal-close').addEventListener('click', hideDeviceSettingsModal); // 历史趋势:状态与工具 var latestDevices = []; @@ -4523,6 +4492,8 @@ return view.extend({ var allTrafficQuotas = []; var trafficQuotasRequest = null; var trafficQuotasLoadError = null; + var trafficQuotasReady = false; + var trafficQuotasDirectional = false; function fetchAllTrafficQuotas(forceRefresh) { if (trafficQuotasRequest) { @@ -4540,15 +4511,19 @@ return view.extend({ quotas = res.data.quotas; } else if (res && Array.isArray(res.quotas)) { quotas = res.quotas; + } else { + throw new Error(_('Failed to load traffic quotas')); } allTrafficQuotas = quotas; + trafficQuotasReady = true; + trafficQuotasDirectional = !!(res.data && res.data.supports_quota_modes === true); trafficQuotasLoadError = null; return quotas; }).catch(function (error) { console.error('Failed to fetch traffic quotas:', error); - allTrafficQuotas = []; trafficQuotasLoadError = error && error.message ? error.message : _('Failed to load traffic quotas'); - return []; + if (forceRefresh) throw error; + return allTrafficQuotas; }).finally(function () { trafficQuotasRequest = null; }); @@ -4564,77 +4539,21 @@ return view.extend({ return null; } - function quotaPeriods(quota) { - return [ - { key: 'minute', label: _('Minute'), limit: quota.minute_bytes || 0, used: quota.minute_used_bytes || 0 }, - { key: 'hourly', label: _('Hourly'), limit: quota.hourly_bytes || 0, used: quota.hourly_used_bytes || 0 }, - { key: 'daily', label: _('Daily'), limit: quota.daily_bytes || 0, used: quota.daily_used_bytes || 0 }, - { key: 'weekly', label: _('Weekly'), limit: quota.weekly_bytes || 0, used: quota.weekly_used_bytes || 0 }, - { key: 'monthly', label: _('Monthly'), limit: quota.monthly_bytes || 0, used: quota.monthly_used_bytes || 0 }, - { key: 'total', label: _('Lifetime'), limit: quota.total_bytes || 0, used: quota.total_used_bytes || 0 } - ]; - } - - function setQuotaInput(period, bytes) { - var valueInput = document.getElementById('traffic-quota-' + period + '-value'); - var unitSelect = document.getElementById('traffic-quota-' + period + '-unit'); - if (!valueInput || !unitSelect) return; - var units = [1099511627776, 1073741824, 1048576, 1024, 1]; - var unit = period === 'minute' ? 1048576 : 1073741824; - if (bytes > 0) { - for (var i = 0; i < units.length; i++) { - if (bytes >= units[i] && bytes % units[i] === 0) { - unit = units[i]; - break; - } - } - } - unitSelect.value = String(unit); - valueInput.value = bytes > 0 ? String(bytes / unit) : '0'; - } - - function readQuotaInput(period) { - var value = Number(document.getElementById('traffic-quota-' + period + '-value').value || 0); - var unit = Number(document.getElementById('traffic-quota-' + period + '-unit').value || 1); - var bytes = Math.round(value * unit); - if (!Number.isFinite(bytes) || bytes < 0 || !Number.isSafeInteger(bytes)) return null; - return bytes; - } - function setTrafficQuotaFormDisabled(disabled) { - trafficQuotaModal.querySelectorAll('.traffic-quota-input-row input, .traffic-quota-input-row select').forEach(function (input) { - input.disabled = disabled; - }); + quotaFormBusy = disabled; + quotaForm.setBusy(disabled); + document.getElementById('traffic-quota-save-btn').disabled = !quotaForm.canSave(); + document.getElementById('traffic-quota-delete-btn').disabled = disabled || !L.hasViewPermission(); } function renderTrafficQuotaForm(quota, preserveInputs) { - if (!preserveInputs) { - var periods = ['minute', 'hourly', 'daily', 'weekly', 'monthly', 'total']; - periods.forEach(function (period) { - setQuotaInput(period, quota ? (quota[period + '_bytes'] || 0) : 0); - }); - } - - var status = document.getElementById('traffic-quota-status'); - var usage = document.getElementById('traffic-quota-usage'); - var deleteBtn = document.getElementById('traffic-quota-delete-btn'); - if (!status || !usage || !deleteBtn) return; - - status.classList.toggle('blocked', !!(quota && quota.blocked)); - status.classList.toggle('unconfigured', !quota); - status.textContent = !quota ? (trafficQuotasLoadError ? _('Unavailable') : _('Not configured')) : (quota.blocked ? _('Blocked') : _('Active')); - deleteBtn.style.display = quota ? '' : 'none'; - usage.innerHTML = ''; - if (!quota) { - usage.appendChild(E('div', { 'style': 'opacity: 0.7;' }, trafficQuotasLoadError || _('Usage starts when a quota is first saved.'))); - return; - } - quotaPeriods(quota).forEach(function (period) { - usage.appendChild(E('div', { 'class': 'traffic-quota-usage-row' }, [ - E('span', {}, period.label), - E('span', {}, formatSize(period.used) + ' / ' + (period.limit > 0 ? formatSize(period.limit) : _('Unlimited'))) - ])); + quotaForm.render(quota, { + ready: trafficQuotasReady, supported: trafficQuotasDirectional, error: trafficQuotasLoadError, + preserveInputs: preserveInputs || quotaFormBusy }); + var deleteBtn = document.getElementById('traffic-quota-delete-btn'); + deleteBtn.style.display = quota ? '' : 'none'; + setTrafficQuotaFormDisabled(quotaFormBusy); } function loadTrafficQuotaForm() { @@ -4649,23 +4568,7 @@ return view.extend({ } function buildTrafficQuotaCell(mac) { - var quota = getTrafficQuotaForDevice(mac); - if (!quota) return null; - var periods = quotaPeriods(quota).filter(function (period) { return period.limit > 0; }); - var mostUsed = null; - periods.forEach(function (period) { - var ratio = period.used / period.limit; - if (!mostUsed || ratio > mostUsed.ratio) mostUsed = { period: period, ratio: ratio }; - }); - var text = quota.blocked ? _('Quota exceeded') : _('Quota active'); - if (mostUsed) { - text = mostUsed.period.label + ': ' + formatSize(mostUsed.period.used) + ' / ' + formatSize(mostUsed.period.limit); - } - return E('div', { - 'class': 'traffic-quota-cell', - 'style': quota.blocked ? 'color: #ef4444; font-weight: 600;' : '', - 'title': quota.blocked ? _('WAN traffic is blocked because a quota was reached.') : _('Traffic Quota') - }, text); + return quotaUi.summary(getTrafficQuotaForDevice(mac), formatSize); } // 获取所有定时限速规则 @@ -6379,7 +6282,7 @@ return view.extend({ var isOnline = isDeviceOnline(device); var deviceQuota = getTrafficQuotaForDevice(device.mac); - var buttonText = 'R'; + var buttonText = _('Settings'); var actionButton = E('button', { 'class': 'cbi-button cbi-button-action', @@ -6387,16 +6290,7 @@ return view.extend({ }, buttonText); actionButton.addEventListener('click', function () { - showRateLimitModal(device); - }); - - var quotaButton = E('button', { - 'class': 'cbi-button cbi-button-neutral', - 'title': _('Traffic Quota') - }, 'Q'); - - quotaButton.addEventListener('click', function () { - showTrafficQuotaModal(device); + showDeviceSettingsModal(device); }); var deleteButton = E('button', { @@ -6433,7 +6327,7 @@ return view.extend({ }), device.host || '-', deviceQuota && deviceQuota.blocked - ? E('span', { 'class': 'traffic-quota-blocked-badge' }, _('Quota blocked')) + ? E('span', { 'class': 'traffic-quota-blocked-badge' }, quotaUi.blockedText(deviceQuota)) : '' ]), E('div', { 'class': 'device-ip' }, [ @@ -6658,7 +6552,6 @@ return view.extend({ // 操作 E('td', { 'class': 'device-actions' }, [ actionButton, - quotaButton, deleteButton ]) ]); @@ -6694,20 +6587,10 @@ return view.extend({ 'title': _('Settings') }, buttonText); cardActionBtn.addEventListener('click', function () { - showRateLimitModal(device); + showDeviceSettingsModal(device); }); return cardActionBtn; })(), - (function () { - var cardQuotaBtn = E('button', { - 'class': 'cbi-button cbi-button-neutral', - 'title': _('Traffic Quota') - }, 'Q'); - cardQuotaBtn.addEventListener('click', function () { - showTrafficQuotaModal(device); - }); - return cardQuotaBtn; - })(), (function () { var cardDeleteBtn = E('button', { 'class': 'cbi-button cbi-button-reset', @@ -7059,7 +6942,7 @@ return view.extend({ return increments.map(normalizeTrafficIncrementItem).filter(function (x) { return x; }); } - // 为了规避 ubus 1MB 响应限制,长时间范围按块分段请求 + // 为了规避 ubus 1MiB 响应限制,长时间范围按块分段请求 var TRAFFIC_INCREMENT_CHUNK_MS_HOURLY = 14 * 24 * 60 * 60 * 1000; // 14 天 var TRAFFIC_INCREMENT_CHUNK_MS_DAILY = 60 * 24 * 60 * 60 * 1000; // 60 天 diff --git a/luci-app-bandix/htdocs/luci-static/resources/view/bandix/settings.js b/luci-app-bandix/htdocs/luci-static/resources/view/bandix/settings.js index cf825a7e9..a80b22a11 100644 --- a/luci-app-bandix/htdocs/luci-static/resources/view/bandix/settings.js +++ b/luci-app-bandix/htdocs/luci-static/resources/view/bandix/settings.js @@ -694,8 +694,8 @@ return view.extend({ // 添加网速单位选择选项 o = s.option(form.ListValue, 'speed_unit', _('Speed Units'), _('Select the speed display unit format')); - o.value('bytes', _('Bytes Units (B/s, KB/s, MB/s)')); - o.value('bits', _('Bits Units (bps, Kbps, Mbps)')); + o.value('bytes', _('Bytes Units (B/s, KiB/s, MiB/s)')); + o.value('bits', _('Bits Units (bps, kbps, Mbps)')); o.default = 'bytes'; o.rmempty = false; diff --git a/luci-app-bandix/po/es/bandix.po b/luci-app-bandix/po/es/bandix.po index 9af8203e3..0263d40fc 100644 --- a/luci-app-bandix/po/es/bandix.po +++ b/luci-app-bandix/po/es/bandix.po @@ -653,11 +653,11 @@ msgstr "Unidades de velocidad" msgid "Select the speed display unit format" msgstr "Seleccione el formato de unidad de visualización de velocidad" -msgid "Bytes Units (B/s, KB/s, MB/s)" -msgstr "Unidades de bytes (B/s, KB/s, MB/s)" +msgid "Bytes Units (B/s, KiB/s, MiB/s)" +msgstr "Unidades de bytes (B/s, KiB/s, MiB/s)" -msgid "Bits Units (bps, Kbps, Mbps)" -msgstr "Unidades de bits (bps, Kbps, Mbps)" +msgid "Bits Units (bps, kbps, Mbps)" +msgstr "Unidades de bits (bps, kbps, Mbps)" msgid "Interface Theme" msgstr "Tema de la interfaz" @@ -692,8 +692,8 @@ msgstr "Establecer el tiempo de espera para la detección de dispositivos sin co msgid "Realtime Traffic Period" msgstr "Período de tráfico en tiempo real" -msgid "10 minutes interval uses about 60 KB per device" -msgstr "Intervalo de 10 minutos usa aproximadamente 60 KB por dispositivo" +msgid "10 minutes interval uses about 60 KiB per device" +msgstr "Intervalo de 10 minutos usa aproximadamente 60 KiB por dispositivo" msgid "Data Flush Interval" msgstr "Intervalo de vaciado de datos" diff --git a/luci-app-bandix/po/fr/bandix.po b/luci-app-bandix/po/fr/bandix.po index 4695932db..512b4fbd2 100644 --- a/luci-app-bandix/po/fr/bandix.po +++ b/luci-app-bandix/po/fr/bandix.po @@ -653,11 +653,11 @@ msgstr "Unités de vitesse" msgid "Select the speed display unit format" msgstr "Sélectionner le format d'unité d'affichage de la vitesse" -msgid "Bytes Units (B/s, KB/s, MB/s)" -msgstr "Unités en octets (B/s, Ko/s, Mo/s)" +msgid "Bytes Units (B/s, KiB/s, MiB/s)" +msgstr "Unités en octets (B/s, KiB/s, MiB/s)" -msgid "Bits Units (bps, Kbps, Mbps)" -msgstr "Unités en bits (bps, Kbps, Mbps)" +msgid "Bits Units (bps, kbps, Mbps)" +msgstr "Unités en bits (bps, kbps, Mbps)" msgid "Interface Theme" msgstr "Thème de l'interface" @@ -692,8 +692,8 @@ msgstr "Définir le délai de détection de déconnexion (secondes). Les apparei msgid "Realtime Traffic Period" msgstr "Période de trafic en temps réel" -msgid "10 minutes interval uses about 60 KB per device" -msgstr "Intervalle de 10 minutes utilise environ 60 Ko par appareil" +msgid "10 minutes interval uses about 60 KiB per device" +msgstr "Intervalle de 10 minutes utilise environ 60 KiB par appareil" msgid "Data Flush Interval" msgstr "Intervalle de vidage des données" diff --git a/luci-app-bandix/po/ja/bandix.po b/luci-app-bandix/po/ja/bandix.po index 1aa14ad0c..4d9021e29 100644 --- a/luci-app-bandix/po/ja/bandix.po +++ b/luci-app-bandix/po/ja/bandix.po @@ -653,11 +653,11 @@ msgstr "速度単位" msgid "Select the speed display unit format" msgstr "速度表示の単位形式を選択" -msgid "Bytes Units (B/s, KB/s, MB/s)" -msgstr "バイト単位 (B/s, KB/s, MB/s)" +msgid "Bytes Units (B/s, KiB/s, MiB/s)" +msgstr "バイト単位 (B/s, KiB/s, MiB/s)" -msgid "Bits Units (bps, Kbps, Mbps)" -msgstr "ビット単位 (bps, Kbps, Mbps)" +msgid "Bits Units (bps, kbps, Mbps)" +msgstr "ビット単位 (bps, kbps, Mbps)" msgid "Interface Theme" msgstr "インターフェーステーマ" @@ -692,8 +692,8 @@ msgstr "デバイスのオフライン検出のタイムアウトを設定(秒 msgid "Realtime Traffic Period" msgstr "リアルタイムトラフィック周期" -msgid "10 minutes interval uses about 60 KB per device" -msgstr "10分間隔でデバイスあたり約60KBを使用" +msgid "10 minutes interval uses about 60 KiB per device" +msgstr "10分間隔でデバイスあたり約60 KiBを使用" msgid "Data Flush Interval" msgstr "データフラッシュ間隔" diff --git a/luci-app-bandix/po/pl/bandix.po b/luci-app-bandix/po/pl/bandix.po index 9cfc198fa..436779dbe 100644 --- a/luci-app-bandix/po/pl/bandix.po +++ b/luci-app-bandix/po/pl/bandix.po @@ -653,11 +653,11 @@ msgstr "Jednostki prędkości" msgid "Select the speed display unit format" msgstr "Wybierz format jednostki wyświetlania prędkości" -msgid "Bytes Units (B/s, KB/s, MB/s)" -msgstr "Jednostki bajtów (B/s, KB/s, MB/s)" +msgid "Bytes Units (B/s, KiB/s, MiB/s)" +msgstr "Jednostki bajtów (B/s, KiB/s, MiB/s)" -msgid "Bits Units (bps, Kbps, Mbps)" -msgstr "Jednostki bitów (bps, Kbps, Mbps)" +msgid "Bits Units (bps, kbps, Mbps)" +msgstr "Jednostki bitów (bps, kbps, Mbps)" msgid "Interface Theme" msgstr "Motyw interfejsu" @@ -692,8 +692,8 @@ msgstr "Ustaw limit czasu wykrywania urządzeń offline (sekundy). Urządzenia n msgid "Realtime Traffic Period" msgstr "Okres ruchu w czasie rzeczywistym" -msgid "10 minutes interval uses about 60 KB per device" -msgstr "Interwał 10 minut używa około 60 KB na urządzenie" +msgid "10 minutes interval uses about 60 KiB per device" +msgstr "Interwał 10 minut używa około 60 KiB na urządzenie" msgid "Data Flush Interval" msgstr "Interwał zapisu danych" diff --git a/luci-app-bandix/po/ru/bandix.po b/luci-app-bandix/po/ru/bandix.po index fa27d4fa2..e24b3a821 100644 --- a/luci-app-bandix/po/ru/bandix.po +++ b/luci-app-bandix/po/ru/bandix.po @@ -653,11 +653,11 @@ msgstr "Единицы скорости" msgid "Select the speed display unit format" msgstr "Выберите формат единиц отображения скорости" -msgid "Bytes Units (B/s, KB/s, MB/s)" -msgstr "Единицы в байтах (B/s, KB/s, MB/s)" +msgid "Bytes Units (B/s, KiB/s, MiB/s)" +msgstr "Единицы в байтах (B/s, KiB/s, MiB/s)" -msgid "Bits Units (bps, Kbps, Mbps)" -msgstr "Единицы в битах (bps, Kbps, Mbps)" +msgid "Bits Units (bps, kbps, Mbps)" +msgstr "Единицы в битах (bps, kbps, Mbps)" msgid "Interface Theme" msgstr "Тема интерфейса" @@ -692,8 +692,8 @@ msgstr "Установить таймаут определения офлайн msgid "Realtime Traffic Period" msgstr "Период трафика в реальном времени" -msgid "10 minutes interval uses about 60 KB per device" -msgstr "Интервал 10 минут использует около 60 КБ на устройство" +msgid "10 minutes interval uses about 60 KiB per device" +msgstr "Интервал 10 минут использует около 60 KiB на устройство" msgid "Data Flush Interval" msgstr "Интервал сброса данных" diff --git a/luci-app-bandix/po/zh_Hans/bandix.po b/luci-app-bandix/po/zh_Hans/bandix.po index f24b9c3f8..bbb98a479 100644 --- a/luci-app-bandix/po/zh_Hans/bandix.po +++ b/luci-app-bandix/po/zh_Hans/bandix.po @@ -650,11 +650,11 @@ msgstr "网速单位" msgid "Select the speed display unit format" msgstr "选择网速显示的单位格式" -msgid "Bytes Units (B/s, KB/s, MB/s)" -msgstr "字节单位 (B/s, KB/s, MB/s)" +msgid "Bytes Units (B/s, KiB/s, MiB/s)" +msgstr "字节单位 (B/s, KiB/s, MiB/s)" -msgid "Bits Units (bps, Kbps, Mbps)" -msgstr "比特单位 (bps, Kbps, Mbps)" +msgid "Bits Units (bps, kbps, Mbps)" +msgstr "比特单位 (bps, kbps, Mbps)" msgid "Interface Theme" msgstr "界面主题" @@ -689,8 +689,8 @@ msgstr "设置设备离线判断的超时时间(秒)。超过此时间未活 msgid "Realtime Traffic Period" msgstr "实时流量周期" -msgid "10 minutes interval uses about 60 KB per device" -msgstr "10分钟历史周期,每个设备占用 60KB" +msgid "10 minutes interval uses about 60 KiB per device" +msgstr "10分钟历史周期,每个设备占用 60 KiB" msgid "Data Flush Interval" msgstr "数据 flush 间隔" @@ -1605,3 +1605,144 @@ msgstr "本页匹配" msgid "No matching connections on this page" msgstr "本页没有匹配的连接" + +msgid "Upload" +msgstr "上传" + +msgid "Download" +msgstr "下载" + +msgid "Used" +msgstr "已用" + +msgid "Unit" +msgstr "单位" + +msgid "Combined" +msgstr "合计" + +msgid "Upload Quota" +msgstr "上传配额" + +msgid "Download Quota" +msgstr "下载配额" + +msgid "Upload blocked" +msgstr "上传已阻断" + +msgid "Download blocked" +msgstr "下载已阻断" + +msgid "Upload and download blocked" +msgstr "上传、下载已阻断" + +msgid "Clear combined limits" +msgstr "清除合计限制" + +msgid "Combined quota (upload + download)" +msgstr "合计配额(上传 + 下载)" + +msgid "A combined limit blocks both directions when reached. Set it to 0 to remove that limit." +msgstr "达到合计上限会同时阻断上传和下载;设为 0 可取消该限制。" + +msgid "WAN upload and download have separate quotas. Only the exhausted direction is blocked. Enter 0 for unlimited." +msgstr "WAN 上传、下载分别计量,超额只阻断对应方向;输入 0 表示不限量。" + +msgid "Update the Bandix service to configure separate upload and download quotas." +msgstr "请更新 Bandix 服务端,以设置独立的上传、下载配额。" + +msgid "Add" +msgstr "添加" + +msgid "Combined quota" +msgstr "合计配额" + +msgid "Edit" +msgstr "编辑" + +msgid "Edit Schedule Rule" +msgstr "编辑定时规则" + +msgid "Failed" +msgstr "失败" + +msgid "Failed to connect to Bandix service" +msgstr "无法连接 Bandix 服务" + +msgid "Failed to load data" +msgstr "加载数据失败" + +msgid "Failed to load whitelist" +msgstr "加载白名单失败" + +msgid "Failed to save schedule rule: " +msgstr "保存定时规则失败: " + +msgid "Interface" +msgstr "接口" + +msgid "Invalid MAC address" +msgstr "MAC 地址无效" + +msgid "Invalid value" +msgstr "输入值无效" + +msgid "Multiple time slots" +msgstr "多个时间段" + +msgid "No data" +msgstr "暂无数据" + +msgid "Not saved" +msgstr "未保存" + +msgid "Only the selected mode takes effect. Switching modes preserves usage and clears the other limits when saved." +msgstr "仅选中的模式生效。切换模式后保存,会保留已用流量并清除另一模式的上限。" + +msgid "Please select both start and end dates" +msgstr "请选择开始和结束日期" + +msgid "Quota mode" +msgstr "配额模式" + +msgid "Router" +msgstr "路由器" + +msgid "Separate upload / download" +msgstr "独立上传/下载配额" + +msgid "Show All" +msgstr "显示全部" + +msgid "Show Top %d" +msgstr "显示前 %d 台" + +msgid "Showing all %d devices" +msgstr "显示全部 %d 台设备" + +msgid "Showing top %d of %d devices" +msgstr "显示前 %d 台,共 %d 台设备" + +msgid "Start date must be earlier than end date" +msgstr "开始日期必须早于结束日期" + +msgid "This service supports combined quotas only. Update Bandix to use separate quotas." +msgstr "此服务端仅支持合计配额。升级 Bandix 后可使用独立配额。" + +msgid "Try using filters or smaller page size" +msgstr "请尝试使用筛选或减少每页条数" + +msgid "WiFi Channel" +msgstr "WiFi 信道" + +msgid "days" +msgstr "天" + +msgid "hours" +msgstr "小时" + +msgid "seconds" +msgstr "秒" + +msgid "weeks" +msgstr "周" diff --git a/luci-app-bandix/po/zh_Hant/bandix.po b/luci-app-bandix/po/zh_Hant/bandix.po index 4656e5173..dcecf0930 100644 --- a/luci-app-bandix/po/zh_Hant/bandix.po +++ b/luci-app-bandix/po/zh_Hant/bandix.po @@ -653,11 +653,11 @@ msgstr "网速單位" msgid "Select the speed display unit format" msgstr "選擇网速顯示的單位格式" -msgid "Bytes Units (B/s, KB/s, MB/s)" -msgstr "位元組單位 (B/s, KB/s, MB/s)" +msgid "Bytes Units (B/s, KiB/s, MiB/s)" +msgstr "位元組單位 (B/s, KiB/s, MiB/s)" -msgid "Bits Units (bps, Kbps, Mbps)" -msgstr "位元單位 (bps, Kbps, Mbps)" +msgid "Bits Units (bps, kbps, Mbps)" +msgstr "位元單位 (bps, kbps, Mbps)" msgid "Interface Theme" msgstr "界面主題" @@ -692,8 +692,8 @@ msgstr "設定設備離線判斷的超时時間(秒)。超過此時間未活 msgid "Realtime Traffic Period" msgstr "即時流量週期" -msgid "10 minutes interval uses about 60 KB per device" -msgstr "10分鐘歷史週期,每个設備佔用 60KB" +msgid "10 minutes interval uses about 60 KiB per device" +msgstr "10分鐘歷史週期,每个設備佔用 60 KiB" msgid "Data Flush Interval" msgstr "數据 flush 間隔" @@ -1578,3 +1578,171 @@ msgstr "本頁符合" msgid "No matching connections on this page" msgstr "本頁沒有符合的連線" + +msgid "Upload" +msgstr "上傳" + +msgid "Download" +msgstr "下載" + +msgid "Used" +msgstr "已用" + +msgid "Unit" +msgstr "單位" + +msgid "Combined" +msgstr "合計" + +msgid "Upload Quota" +msgstr "上傳配額" + +msgid "Download Quota" +msgstr "下載配額" + +msgid "Upload blocked" +msgstr "上傳已阻斷" + +msgid "Download blocked" +msgstr "下載已阻斷" + +msgid "Upload and download blocked" +msgstr "上傳、下載已阻斷" + +msgid "Clear combined limits" +msgstr "清除合計限制" + +msgid "Combined quota (upload + download)" +msgstr "合計配額(上傳 + 下載)" + +msgid "A combined limit blocks both directions when reached. Set it to 0 to remove that limit." +msgstr "達到合計上限會同時阻斷上傳和下載;設為 0 可取消該限制。" + +msgid "WAN upload and download have separate quotas. Only the exhausted direction is blocked. Enter 0 for unlimited." +msgstr "WAN 上傳、下載分別計量,超額只阻斷對應方向;輸入 0 表示不限量。" + +msgid "Update the Bandix service to configure separate upload and download quotas." +msgstr "請更新 Bandix 服務端,以設定獨立的上傳、下載配額。" + +msgid "Add" +msgstr "新增" + +msgid "Combined quota" +msgstr "合計配額" + +msgid "Default Rate Limit" +msgstr "預設限速" + +msgid "Edit" +msgstr "編輯" + +msgid "Edit Schedule Rule" +msgstr "編輯排程規則" + +msgid "Exempt Devices (Whitelist)" +msgstr "豁免裝置(白名單)" + +msgid "Failed" +msgstr "失敗" + +msgid "Failed to connect to Bandix service" +msgstr "無法連線至 Bandix 服務" + +msgid "Failed to load data" +msgstr "載入資料失敗" + +msgid "Failed to load whitelist" +msgstr "載入白名單失敗" + +msgid "Failed to save schedule rule: " +msgstr "儲存排程規則失敗: " + +msgid "Global Rate Limit" +msgstr "全域限速" + +msgid "Interface" +msgstr "介面" + +msgid "Invalid MAC address" +msgstr "MAC 位址無效" + +msgid "Invalid value" +msgstr "輸入值無效" + +msgid "Multiple time slots" +msgstr "多個時段" + +msgid "No data" +msgstr "暫無資料" + +msgid "Not saved" +msgstr "尚未儲存" + +msgid "Only the selected mode takes effect. Switching modes preserves usage and clears the other limits when saved." +msgstr "僅選取的模式生效。切換模式後儲存,會保留已用流量並清除另一模式的上限。" + +msgid "Please select both start and end dates" +msgstr "請選擇開始與結束日期" + +msgid "Quota mode" +msgstr "配額模式" + +msgid "Remove this device from the list?" +msgstr "要從清單中移除此裝置嗎?" + +msgid "Router" +msgstr "路由器" + +msgid "Separate upload / download" +msgstr "獨立上傳/下載配額" + +msgid "Show All" +msgstr "顯示全部" + +msgid "Show Top %d" +msgstr "顯示前 %d 台" + +msgid "Showing all %d devices" +msgstr "顯示全部 %d 台裝置" + +msgid "Showing top %d of %d devices" +msgstr "顯示前 %d 台,共 %d 台裝置" + +msgid "Start date must be earlier than end date" +msgstr "開始日期必須早於結束日期" + +msgid "This service supports combined quotas only. Update Bandix to use separate quotas." +msgstr "此服務端僅支援合計配額。升級 Bandix 後可使用獨立配額。" + +msgid "Try using filters or smaller page size" +msgstr "請嘗試使用篩選或減少每頁筆數" + +msgid "Update available" +msgstr "有可用更新" + +msgid "Update available, click to go to settings" +msgstr "有可用更新,點擊前往設定" + +msgid "When enabled, all devices will be rate limited. Devices in the list are exempt (whitelist)." +msgstr "啟用後,所有裝置均會限速,清單中的裝置除外(白名單)。" + +msgid "WiFi Channel" +msgstr "WiFi 頻道" + +msgid "Wired" +msgstr "有線" + +msgid "Wireless" +msgstr "無線" + +msgid "days" +msgstr "天" + +msgid "hours" +msgstr "小時" + +msgid "seconds" +msgstr "秒" + +msgid "weeks" +msgstr "週" diff --git a/luci-app-bandix/root/usr/libexec/rpcd/luci.bandix b/luci-app-bandix/root/usr/libexec/rpcd/luci.bandix index f8daf0b67..7b9743325 100755 --- a/luci-app-bandix/root/usr/libexec/rpcd/luci.bandix +++ b/luci-app-bandix/root/usr/libexec/rpcd/luci.bandix @@ -191,6 +191,97 @@ set_traffic_quota() { fi } +# Normalize the six limits from the currently selected JSON object. JSON strings +# carry byte values through rpcd without the 32-bit integer parameter limit. +quota_limits_json() { + local key keys type value separator="" + json_get_keys keys + for key in $keys; do + case "$key" in + minute_bytes|hourly_bytes|daily_bytes|weekly_bytes|monthly_bytes|total_bytes) ;; + mac|mode|upload|download) [ "$1" = combined ] || return 1 ;; + *) return 1 ;; + esac + done + for key in minute_bytes hourly_bytes daily_bytes weekly_bytes monthly_bytes total_bytes; do + json_get_type type "$key" + value=0 + if [ -n "$type" ]; then + [ "$type" = int ] || return 1 + json_get_var value "$key" + fi + case "$value" in ''|*[!0-9]*) return 1 ;; esac + [ "${#value}" -le 16 ] && [ "$value" -le 9007199254740991 ] || return 1 + printf '%s"%s":%s' "$separator" "$key" "$value" + separator=, + done +} + +set_traffic_quota_config() { + local type mac mode combined upload download request_data response supported zero + json_load "{\"quota\":$1}" 2>/dev/null || { make_error "Invalid quota configuration"; return; } + json_get_type type quota + [ "$type" = object ] || { make_error "Invalid quota configuration"; return; } + json_select quota + json_get_type type mac + json_get_var mac mac + if [ "$type" != string ] || ! printf '%s\n' "$mac" | grep -Eq '^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$'; then + make_error "Invalid MAC address" + return + fi + json_get_type type mode + mode=combined + if [ -n "$type" ]; then + [ "$type" = string ] || { make_error "Invalid quota mode"; return; } + json_get_var mode mode + fi + case "$mode" in combined|directional) ;; *) make_error "Invalid quota mode"; return ;; esac + combined=$(quota_limits_json combined) || { make_error "Invalid quota limits"; return; } + zero='"minute_bytes":0,"hourly_bytes":0,"daily_bytes":0,"weekly_bytes":0,"monthly_bytes":0,"total_bytes":0' + upload="$zero" + download="$zero" + if [ "$mode" = directional ]; then + json_get_type type upload + [ "$type" = object ] || { make_error "Upload quotas are required"; return; } + json_select upload + upload=$(quota_limits_json) || { make_error "Invalid upload quota limits"; return; } + json_select .. + json_get_type type download + [ "$type" = object ] || { make_error "Download quotas are required"; return; } + json_select download + download=$(quota_limits_json) || { make_error "Invalid download quota limits"; return; } + combined="$zero" + fi + request_data="{\"mac\":\"$mac\",\"mode\":\"$mode\",$combined,\"upload\":{$upload},\"download\":{$download}}" + + # Combined requests retain the legacy fields. Separate mode requires explicit + # support: old services silently ignore fields they do not understand. + if [ "$mode" = directional ]; then + response=$(curl -s --connect-timeout 2 --max-time 5 -X GET "$BANDIX_TRAFFIC_QUOTAS_API" 2>/dev/null) + if [ $? -ne 0 ] || ! json_load "$response" 2>/dev/null || ! json_select data 2>/dev/null; then + make_error "Failed to connect to Bandix service" + return + fi + json_get_type type supports_quota_modes + json_get_var supported supports_quota_modes + if [ "$type" != boolean ] || [ "$supported" != 1 ]; then + make_error "Update the Bandix service to configure separate upload and download quotas." + return + fi + fi + response=$(curl -s --connect-timeout 3 --max-time 10 -X POST -H "Content-Type: application/json" --data-binary "$request_data" "$BANDIX_TRAFFIC_QUOTAS_API" 2>/dev/null) + if [ $? -ne 0 ] || ! json_load "$response" 2>/dev/null; then + make_error "Failed to save traffic quota" + return + fi + json_get_var type status + if [ "$type" = success ]; then + make_success "Traffic quota saved successfully" + else + make_error "Failed to save traffic quota" + fi +} + delete_traffic_quota() { local mac="$1" if [ -z "$mac" ]; then @@ -1531,6 +1622,13 @@ case "$1" in json_add_object "deleteTrafficQuota" json_add_string "mac" json_close_object + + json_add_object "setTrafficQuotaConfig" + json_add_string "quota" "" + json_close_object + json_add_object "setDirectionalTrafficQuota" + json_add_string "quota" + json_close_object json_dump json_cleanup @@ -2000,6 +2098,20 @@ case "$1" in getTrafficQuotas) get_traffic_quotas ;; + setTrafficQuotaConfig|setDirectionalTrafficQuota) + quota="" + input="" + if read -t 1 -r input; then :; fi + if [ -n "$input" ]; then + if json_load "{\"args\":$input}" 2>/dev/null && json_select args 2>/dev/null; then + json_get_var quota quota + [ -n "$quota" ] || json_get_var quota 1 + fi + else + quota="$3" + fi + set_traffic_quota_config "$quota" + ;; setTrafficQuota) mac="" minute_bytes="" 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 f381c3f6a..f4e881872 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 @@ -70,6 +70,8 @@ "updateScheduleLimit", "getTrafficQuotas", "setTrafficQuota", + "setDirectionalTrafficQuota", + "setTrafficQuotaConfig", "deleteTrafficQuota", "getGeoipStatus", "manageGeoip" diff --git a/luci-base/htdocs/luci-static/resources/network.js b/luci-base/htdocs/luci-static/resources/network.js index 6d7c100ac..3290ede30 100644 --- a/luci-base/htdocs/luci-static/resources/network.js +++ b/luci-base/htdocs/luci-static/resources/network.js @@ -61,6 +61,12 @@ const callLuciWirelessDevices = rpc.declare({ expect: { '': {} } }); +const callWirelessStatus = rpc.declare({ + object: 'network.wireless', + method: 'status', + expect: { '': {} } +}); + const callLuciBoardJSON = rpc.declare({ object: 'luci-rpc', method: 'getBoardJSON' @@ -1448,6 +1454,27 @@ Network = baseclass.extend(/** @lends LuCI.network.prototype */ { }, this)); }, + /** Return configured QCA VAP names reported by netifd, without driver probes. */ + getWifiRuntimeIfnames() { + // netifd supplies VAP names without probing disabled radios or creating VAPs. + return L.resolveDefault(callWirelessStatus(), {}).then((radios) => { + const names = Object.create(null); + for (const iface of uci.sections('wireless', 'wifi-iface')) { + const radio = radios[iface.device]; + if (!radio || !['qcawifi', 'qcawificfg80211'].includes(uci.get('wireless', iface.device, 'type'))) + continue; + names[iface['.name']] = null; + if (!radio.up || radio.disabled || iface.disabled == '1' || + uci.get('wireless', iface.device, 'disabled') == '1') + continue; + const live = (radio.interfaces || []).find((net) => net.section == iface['.name']); + if (live?.ifname) + names[iface['.name']] = live.ifname; + } + return names; + }); + }, + /** * Obtain configured radio devices without querying runtime state. * diff --git a/luci-base/root/usr/share/rpcd/ucode/luci b/luci-base/root/usr/share/rpcd/ucode/luci index e5be8e641..84d7e9584 100644 --- a/luci-base/root/usr/share/rpcd/ucode/luci +++ b/luci-base/root/usr/share/rpcd/ucode/luci @@ -19,6 +19,10 @@ function shellquote(s) { } function has_supported_hw_offloading_target(target) { + if (target == 'ipq40xx/generic') + return access('/sys/module/qca_ssdk') == true && + access('/sys/module/essedma') == true; + return type(target) == 'string' && match(target, /^(ramips\/mt7621|mediatek\/mt76..|mediatek\/filogic|airoha\/an758[0-9])$/) != null; } diff --git a/luci-mod-network/htdocs/luci-static/resources/view/network/wireless.js b/luci-mod-network/htdocs/luci-static/resources/view/network/wireless.js index 85329d7bb..4401e2aa2 100644 --- a/luci-mod-network/htdocs/luci-static/resources/view/network/wireless.js +++ b/luci-mod-network/htdocs/luci-static/resources/view/network/wireless.js @@ -62,7 +62,12 @@ function mergeUniqueValues(...lists) { return merged; } +let runtimeIfnames = Object.create(null); + function getQcaFallbackIfname(device, section) { + if (Object.prototype.hasOwnProperty.call(runtimeIfnames, section)) + return runtimeIfnames[section]; + if (/^ath\d+$/.test(String(section || ''))) return section; @@ -252,13 +257,14 @@ function loadIwinfoResolver(force) { if (!force && cachedIwinfoResolver != null) return Promise.resolve(cachedIwinfoResolver); - const configuredDevices = getIwinfoDevicesFromConfig(); - const deviceRequest = configuredDevices != null - ? Promise.resolve(configuredDevices) - : L.resolveDefault(callIwinfoDevices(), []); - - cachedIwinfoResolverPromise = deviceRequest.then((devices) => { - cachedIwinfoResolver = buildIwinfoResolver(devices, configuredDevices != null); + cachedIwinfoResolverPromise = network.getWifiRuntimeIfnames().then((names) => { + runtimeIfnames = names; + const configuredDevices = getIwinfoDevicesFromConfig(); + return (configuredDevices != null ? Promise.resolve(configuredDevices) + : L.resolveDefault(callIwinfoDevices(), [])).then((devices) => + buildIwinfoResolver(devices, configuredDevices != null)); + }).then((resolver) => { + cachedIwinfoResolver = resolver; cachedIwinfoResolverPromise = null; return cachedIwinfoResolver; }).catch(() => { @@ -444,6 +450,9 @@ function isNetworkDisplayUp(radioNet) { } function getRadioDisplayName(radioDev) { + const info = cachedIwinfoInfoMap?.[radioDev.getName()]; + if (info?.hardware?.name) + return info.hardware.name + (info.hwmodes_text ? ' 802.11' + info.hwmodes_text : ''); const name = radioDev.getI18n().replace(/ Wireless Controller .+$/, ''); const hwtype = uci.get('wireless', radioDev.getName(), 'type'); const hwmode = uci.get('wireless', radioDev.getName(), 'hwmode') || ''; @@ -608,7 +617,9 @@ function getDisplayEncryption(radioNet) { function getDisplayBSSID(radioNet) { // A radio MAC may differ from the BSSID of an individual virtual AP. + const target = cachedIwinfoResolver?.aliasMap[radioNet.getName()]; const candidates = [ + cachedIwinfoInfoMap?.[target]?.bssid, radioNet.getActiveBSSID(), radioNet.getBSSID(), uci.get('wireless', radioNet.getName(), 'macaddr') @@ -1408,7 +1419,7 @@ function getDisplaySignalValue(radioNet, hwtype, is_assoc) { if (hwtype == 'mt_dbdc' && radioNet.getMode() == 'ap') return is_assoc ? getDisplayTxPower(radioNet) : null; if (isQcaWifiHwtype(hwtype) && radioNet.getMode() == 'ap' && is_assoc) - return getDisplayTxPower(radioNet) ?? 30; + return getDisplayTxPower(radioNet); return radioNet.getSignal(); } @@ -1748,7 +1759,9 @@ var CBIWifiFrequencyValue = form.Value.extend({ const chval = +cfg_channel; const allow_auto = (hwtype == 'mt_dbdc' || isQcaWifiHwtype(hwtype) || cfg_channel == 'auto' || L.hasSystemFeature('hostapd', 'acs')); - const configOnly = isConfigWifiDeviceDisabled(device_section) || + const queryDevice = isQcaWifiHwtype(hwtype) + ? cachedIwinfoResolver?.aliasMap[device_section] : device_section; + const configOnly = !queryDevice || isConfigWifiDeviceDisabled(device_section) || (isQcaWifiHwtype(hwtype) && areQcaRuntimeProbesSuspended()); const configDevice = configOnly ? network.getWifiDevicesFromConfig().find((device) => device.getName() == device_section) @@ -1756,8 +1769,8 @@ var CBIWifiFrequencyValue = form.Value.extend({ return Promise.all([ configOnly ? configDevice : network.getWifiDevice(device_section), - configOnly ? [] : this.callFrequencyList(device_section), - configOnly ? {} : this.callDeviceInfo(device_section), + configOnly ? [] : this.callFrequencyList(queryDevice), + configOnly ? {} : this.callDeviceInfo(queryDevice), configOnly ? {} : L.resolveDefault(this.callWirelessStatus(), {}), !configOnly && isQcaWifiHwtype(hwtype) ? L.resolveDefault(fs.exec_direct('/usr/sbin/iw', [ 'dev' ]), '') : '', !configOnly && isQcaWifiHwtype(hwtype) ? L.resolveDefault(fs.exec_direct('/usr/sbin/iw', [ 'phy' ]), '') : '', @@ -1851,6 +1864,12 @@ var CBIWifiFrequencyValue = form.Value.extend({ if (!htmode_values.length) htmode_values = L.toArray(devinfo.htmodes); + // Config-only WifiDevice instances default to b/g, even for a 5 GHz radio. + if (isQcaWifiHwtype(hwtype) && Array.isArray(devinfo.hwmodes)) + hwmode_values = devinfo.hwmodes; + if (isQcaWifiHwtype(hwtype) && Array.isArray(devinfo.htmodes)) + htmode_values = devinfo.htmodes; + if (!hwmode_values.length) { if (/^11be/.test(hwval) || /^EHT/.test(htval)) hwmode_values = [ 'be' ]; @@ -1904,7 +1923,8 @@ var CBIWifiFrequencyValue = form.Value.extend({ const qca_ht320 = (!!(htmodelist.EHT320 || /^HT320$/.test(htval)) && qca_max_width >= 320); this.modes = [ - '', 'Legacy', { available: false }, + '', hwtype == 'qcawifi' ? (hwmodelist.a ? 'A' : 'B/G') : 'Legacy', + { available: hwtype == 'qcawifi' && (hwmodelist.a || hwmodelist.b || hwmodelist.g) }, 'n', 'N', { available: qca_has_n }, 'ac', 'AC', { available: qca_has_ac }, 'ax', 'AX', { available: qca_has_ax }, @@ -1939,13 +1959,20 @@ var CBIWifiFrequencyValue = form.Value.extend({ ] }; + if (hwtype == 'qcawifi') + for (let i = 0; i < this.htmodes.ac.length; i += 3) + this.htmodes.ac[i] = this.htmodes.ac[i].replace(/^HT/, 'VHT'); + this.bands = { '': [ '2g', '2.4 GHz', { available: has_band_channels('2g') }, '5g', '5 GHz', { available: has_band_channels('5g') }, '6g', '6 GHz', { available: has_band_channels('6g') } ], - 'n': [ '2g', '2.4 GHz', { available: has_band_channels('2g') } ], + 'n': [ + '2g', '2.4 GHz', { available: has_band_channels('2g') }, + '5g', '5 GHz', { available: hwtype == 'qcawifi' && has_band_channels('5g') } + ], 'ac': [ '5g', '5 GHz', { available: has_band_channels('5g') } ], 'ax': [ '2g', '2.4 GHz', { available: has_band_channels('2g') }, @@ -2406,10 +2433,9 @@ var CBIWifiFrequencyValue = form.Value.extend({ const qcaBand = (hwtype == 'qcawificfg80211') ? getQcaBandCode(band) : null; if (hwtype == 'qcawifi') { - if (mode == 'ac') - hwmode = '11ac'; - else if (mode == 'n') - hwmode = '11ng'; + // QCA10 netifd uses the band in hwmode and HT/VHT in htmode. + hwmode = band == '5g' ? '11a' : '11g'; + setValue('htmode', mode == 'ac' ? htmode.replace(/^HT/, 'VHT') : (htmode || null)); } else if (hwtype == 'qcawificfg80211') { switch (mode) { @@ -2469,7 +2495,9 @@ var CBIWifiTxPowerValue = form.ListValue.extend({ return form.ListValue.prototype.load.apply(this, [section_id]); } - return this.callTxPowerList(section_id).then(L.bind(function(pwrlist) { + const queryDevice = isQcaWifiHwtype(hwtype) + ? cachedIwinfoResolver?.aliasMap[device_section] : section_id; + return (queryDevice ? this.callTxPowerList(queryDevice) : Promise.resolve([])).then(L.bind(function(pwrlist) { this.powerval = this.wifiNetwork ? getDisplayTxPower(this.wifiNetwork) : null; this.poweroff = this.wifiNetwork ? this.wifiNetwork.getTXPowerOffset() : null; @@ -2673,10 +2701,12 @@ return view.extend({ callSystemBoard() ]).then((data) => { this.boardinfo = data[4] || {}; - /* MTK capability rates need iwinfo and the PHY MCS/NSS map. - * Populate them before the first render, not the next poll. */ + /* Config-only radios need their runtime iwinfo before the first render, + * including QCA VAP names and MTK PHY capability rates. */ if (!this.checkAnonymousSections() && - uci.sections('wireless', 'wifi-device').some(radio => radio.type == 'mt_dbdc')) + uci.sections('wireless', 'wifi-device').some(radio => + radio.type == 'mt_dbdc' || + (isQcaWifiHwtype(radio.type) && !areQcaRuntimeProbesSuspended()))) return loadIwinfoInfoMap(false).then(() => data); return data; }); @@ -3314,6 +3344,15 @@ return view.extend({ o.depends('mode', 'ap-wds'); o.default = o.enabled; + if (hwtype == 'qcawifi' && + cachedIwinfoInfoMap?.[radioNet.getWifiDeviceName()]?.hwmodes?.includes('ac')) { + o = ss.taboption('advanced', form.Flag, 'vhtmubfer', _('MU-MIMO')); + o.depends('mode', 'ap'); + o.depends('mode', 'ap-wds'); + o.default = o.disabled; + o.rmempty = false; + } + o = ss.taboption('advanced', form.Flag, 'doth', '802.11h'); o.depends('mode', 'ap'); o.depends('mode', 'ap-wds'); diff --git a/luci-mod-status/Makefile b/luci-mod-status/Makefile index 6ce3a2260..db8174615 100644 --- a/luci-mod-status/Makefile +++ b/luci-mod-status/Makefile @@ -9,7 +9,7 @@ include $(TOPDIR)/rules.mk LUCI_TITLE:=LuCI Status Pages LUCI_DEPENDS:=+luci-base +luci-app-oui +luci-mod-dashboard +libiwinfo +rpcd-mod-iwinfo +rpcd-mod-rrdns +ucode +ucode-mod-fs +ucode-mod-ubus +ucode-mod-socket -PKG_RELEASE:=35 +PKG_RELEASE:=36 PKG_BUILD_DEPENDS:=iwinfo PKG_LICENSE:=Apache-2.0 diff --git a/luci-mod-status/htdocs/luci-static/resources/view/status/include/60_wifi.js b/luci-mod-status/htdocs/luci-static/resources/view/status/include/60_wifi.js index 7b1f0dcc8..2eac55453 100644 --- a/luci-mod-status/htdocs/luci-static/resources/view/status/include/60_wifi.js +++ b/luci-mod-status/htdocs/luci-static/resources/view/status/include/60_wifi.js @@ -60,7 +60,12 @@ function pushUnique(list, value) { list.push(value); } +let runtimeIfnames = Object.create(null); + function getQcaFallbackIfname(device, section) { + if (Object.prototype.hasOwnProperty.call(runtimeIfnames, section)) + return runtimeIfnames[section]; + if (/^ath\d+$/.test(String(section || ''))) return section; @@ -162,7 +167,7 @@ function getRadioDisplayType(radio, iwinfoInfoMap) { let name = radio.getI18n().replace(/^Generic | Wireless Controller .+$/g, ''); if (hwname && hwname != 'Generic Atheros') - name = hwname; + name = hwname + (info.hwmodes_text ? ' 802.11' + info.hwmodes_text : ''); if (name && !/^unknown$/i.test(name) && !/^802\.11unknown$/i.test(name)) return name; @@ -241,9 +246,8 @@ function getDisplayEncryption(net) { } function getDisplayBSSID(net) { - const bssid = uci.get('wireless', net.getName(), 'macaddr') || - uci.get('wireless', net.getWifiDeviceName(), 'macaddr') || - net.getBSSID() || net.getActiveBSSID(); + const bssid = net.getActiveBSSID() || net.getBSSID() || + uci.get('wireless', net.getName(), 'macaddr'); if (bssid && bssid != '00:00:00:00:00:00') return String(bssid).toUpperCase(); @@ -638,18 +642,21 @@ return baseclass.extend({ loadIwinfoResolver(radios, networks) { // Vendor radios are queried through configured VIFs, not global probes. - const configOnly = useConfiguredWifi(); - const configuredDevices = []; + return network.getWifiRuntimeIfnames().then((names) => { + runtimeIfnames = names; + const configOnly = useConfiguredWifi(); + const configuredDevices = []; - for (const iface of uci.sections('wireless', 'wifi-iface')) { - if (iface.disabled == '1' || uci.get('wireless', iface.device, 'disabled') == '1') - continue; + for (const iface of uci.sections('wireless', 'wifi-iface')) { + if (iface.disabled == '1' || uci.get('wireless', iface.device, 'disabled') == '1') + continue; - pushUnique(configuredDevices, getConfiguredIfname(iface)); - } + pushUnique(configuredDevices, getConfiguredIfname(iface)); + } - return (configOnly ? Promise.resolve(configuredDevices) : L.resolveDefault(callIwinfoDevices(), [])).then((devices) => { - return buildIwinfoResolver(radios, networks, devices); + return (configOnly ? Promise.resolve(configuredDevices) : L.resolveDefault(callIwinfoDevices(), [])).then((devices) => { + return buildIwinfoResolver(radios, networks, devices); + }); }).catch(() => ({ deviceLookup: Object.create(null), aliasMap: Object.create(null), diff --git a/mise/Makefile b/mise/Makefile index 7b3fce9d0..7d68cc393 100644 --- a/mise/Makefile +++ b/mise/Makefile @@ -14,8 +14,8 @@ MISE_HASH_x86_64:=3832f39c325e343f81fe3d92b2447c5d1a5eea1bc85092bb7b6c2580622264 MISE_HASH_aarch64:=06186cfbfe947049b21d58575fb0ea800cc26ed1375f20f4b678cb3a9d679437 PKG_NAME:=mise -PKG_VERSION:=2026.10.19 -PKG_RELEASE:=42 +PKG_VERSION:=2026.10.20 +PKG_RELEASE:=43 PKG_SOURCE:=$(PKG_NAME)-v$(PKG_VERSION)-linux-$(MISE_ARCH)-musl.tar.gz PKG_SOURCE_URL:=https://github.com/jdx/mise/releases/download/v$(PKG_VERSION)/ PKG_HASH:=skip diff --git a/natflow/Makefile b/natflow/Makefile index a950cf099..b4622a654 100644 --- a/natflow/Makefile +++ b/natflow/Makefile @@ -10,12 +10,12 @@ include $(INCLUDE_DIR)/kernel.mk PKG_NAME:=natflow PKG_VERSION:=20260930 -PKG_RELEASE:=95 +PKG_RELEASE:=96 PKG_SOURCE:=$(PKG_VERSION).tar.xz PKG_SOURCE_URL:=https://github.com/ptpt52/natflow.git PKG_SOURCE_PROTO:=git -PKG_SOURCE_VERSION:=51496417511f8dc4ebef2618206db11ddedd7773 +PKG_SOURCE_VERSION:=7c5028430db40bb6246374f3dbbb73f1e3f4360c PKG_SOURCE_SUBDIR:=$(PKG_NAME)-$(PKG_VERSION) PKG_MAINTAINER:=Chen Minqiang PKG_LICENSE:=GPL-2.0 diff --git a/node/Makefile b/node/Makefile index 0af2fce5a..2f2efbbdb 100644 --- a/node/Makefile +++ b/node/Makefile @@ -7,26 +7,26 @@ include $(TOPDIR)/rules.mk PKG_NAME:=node ifeq ($(CONFIG_NODEJS_22),y) -PKG_VERSION:=22.23.3 -PKG_RELEASE:=2 +PKG_VERSION:=22.23.4 +PKG_RELEASE:=3 PKG_HASH:=skip PATCH_DIR:=./patches/v22.x -NODEJS_BIN_SUM:=1084aa36196bba4c3a5e69a1ee388a6e4ff729dad09445fbcd434b28fe3c24af +NODEJS_BIN_SUM:=b8492284c76a54baf58d61668dd0e41ee0fc98adabf9503d30f05d08f55b4406 NODE_MODULE_VERSION:=127 else ifeq ($(CONFIG_NODEJS_26),y) -PKG_VERSION:=26.12.0 -PKG_RELEASE:=2 +PKG_VERSION:=26.12.1 +PKG_RELEASE:=3 PKG_HASH:=skip PATCH_DIR:=./patches/v26.x -NODEJS_BIN_SUM:=d9f88af72ad34f7ddf8e67289829f1698f54b6254f7d4f54b7453117ff1a3d2c +NODEJS_BIN_SUM:=0fd1eb7770598f52a21995edf6ed35fc690400fa437c2386af329519e9143132 NODE_MODULE_VERSION:=147 else -PKG_VERSION:=24.22.0 -PKG_RELEASE:=2 +PKG_VERSION:=24.22.1 +PKG_RELEASE:=3 PKG_HASH:=skip PATCH_DIR:=./patches/v24.x -NODEJS_BIN_SUM:=28ea0a438de9a13bd7891fd758eee6c7819ee8af5442dfd8d45bd65e56d4228c +NODEJS_BIN_SUM:=eb6728e77c576a35162c122c45ccadf224d89ff00abc8dae46cc2897c6002d7c NODE_MODULE_VERSION:=137 endif endif diff --git a/openwrt-bandix/Makefile b/openwrt-bandix/Makefile index 415cbdb30..23961fbb3 100644 --- a/openwrt-bandix/Makefile +++ b/openwrt-bandix/Makefile @@ -1,8 +1,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=bandix -PKG_VERSION:=0.12.11 -PKG_RELEASE:=6 +PKG_VERSION:=0.12.12 +PKG_RELEASE:=7 PKG_LICENSE:=Apache-2.0 PKG_MAINTAINER:=timsaya @@ -13,7 +13,7 @@ include $(INCLUDE_DIR)/package.mk include $(TOPDIR)/feeds/packages/lang/rust/rust-values.mk # 二进制文件的文件名和URL -RUST_BANDIX_VERSION:=0.12.11 +RUST_BANDIX_VERSION:=0.12.12 RUST_BINARY_FILENAME:=bandix-$(RUST_BANDIX_VERSION)-$(RUSTC_TARGET_ARCH).tar.gz @@ -65,6 +65,15 @@ else PKG_HASH:=skip endif +# Build an unreleased service with an SDK without changing published hashes: +# make package/bandix/compile BANDIX_LOCAL_BINARY=/absolute/path/to/bandix +BANDIX_LOCAL_BINARY?= +ifneq ($(strip $(BANDIX_LOCAL_BINARY)),) + PKG_SOURCE:= + PKG_SOURCE_URL:= + PKG_FILE_DEPENDS+=$(BANDIX_LOCAL_BINARY) +endif + define Package/$(PKG_NAME) SECTION:=net CATEGORY:=Network @@ -78,7 +87,7 @@ endef define Build/Prepare mkdir -p $(PKG_BUILD_DIR)/bin - gzip -dc "$(DL_DIR)/$(PKG_SOURCE)" | tar -C $(PKG_BUILD_DIR)/bin --strip-components=1 -xf - + $(if $(strip $(BANDIX_LOCAL_BINARY)),$(INSTALL_BIN) "$(BANDIX_LOCAL_BINARY)" $(PKG_BUILD_DIR)/bin/bandix,gzip -dc "$(DL_DIR)/$(PKG_SOURCE)" | tar -C $(PKG_BUILD_DIR)/bin --strip-components=1 -xf -) $(Build/Patch) endef diff --git a/openwrt-bandix/files/bandix.config b/openwrt-bandix/files/bandix.config index 0341c1e78..a51f5c406 100644 --- a/openwrt-bandix/files/bandix.config +++ b/openwrt-bandix/files/bandix.config @@ -1,6 +1,7 @@ config bandix 'general' option iface 'br-lan' + option host '127.0.0.1' option port '8686' option data_dir '/usr/share/bandix' @@ -32,6 +33,7 @@ config bandix 'traffic' config bandix 'connections' option enabled '0' + option geoip_dir '/usr/share/bandix-geoip' config bandix 'dns' diff --git a/openwrt-bandix/files/bandix.init b/openwrt-bandix/files/bandix.init index 7cafa2df0..3af238f72 100644 --- a/openwrt-bandix/files/bandix.init +++ b/openwrt-bandix/files/bandix.init @@ -14,6 +14,7 @@ reload_service() { start_service() { local iface + local host local port local data_dir local log_level @@ -33,6 +34,7 @@ start_service() { local traffic_exclude_iface_device local traffic_neighbor_flush_enable local traffic_neighbor_flush_interval + local geoip_dir local connections_enabled local dns_enabled local dns_max_records @@ -41,6 +43,8 @@ start_service() { config_load 'bandix' config_get iface 'general' 'iface' + config_get host 'general' 'host' '127.0.0.1' + [ -n "$host" ] || host='127.0.0.1' config_get port 'general' 'port' config_get data_dir 'general' 'data_dir' config_get log_level 'general' 'log_level' @@ -60,6 +64,7 @@ start_service() { config_get_bool traffic_exclude_iface_device 'traffic' 'traffic_exclude_iface_device' '0' config_get_bool traffic_neighbor_flush_enable 'traffic' 'traffic_neighbor_flush_enable' '0' config_get traffic_neighbor_flush_interval 'traffic' 'traffic_neighbor_flush_interval' '600' + config_get geoip_dir 'connections' 'geoip_dir' '/usr/share/bandix-geoip' config_get_bool connections_enabled 'connections' 'enabled' config_get_bool dns_enabled 'dns' 'enabled' config_get dns_max_records 'dns' 'dns_max_records' @@ -150,8 +155,21 @@ start_service() { args="$args --dns-flush-interval $dns_flush_interval" fi + # Keep compatibility with published binaries predating these options. + local bandix_help + bandix_help=$("$PROG" --help 2>/dev/null) + if [ "$host" != '127.0.0.1' ] && ! printf '%s\n' "$bandix_help" | grep -q -- '--host'; then + logger -t bandix 'The installed Bandix binary does not support --host; upgrade Bandix to change the listening address' + return 1 + fi procd_open_instance bandix procd_set_param command $PROG $args + if printf '%s\n' "$bandix_help" | grep -q -- '--host'; then + procd_append_param command --host "$host" + fi + if printf '%s\n' "$bandix_help" | grep -q -- '--geoip-dir'; then + procd_append_param command --geoip-dir "$geoip_dir" + fi procd_set_param respawn ${respawn_threshold:-3600} ${respawn_timeout:-60} ${respawn_retry:-3} procd_set_param stderr 1 procd_set_param stdout 1 diff --git a/xradio/Makefile b/xradio/Makefile new file mode 100644 index 000000000..219dda2d9 --- /dev/null +++ b/xradio/Makefile @@ -0,0 +1,88 @@ + +include $(TOPDIR)/rules.mk + +PKG_NAME:=xradio +PKG_RELEASE:=1 + +PKG_LICENSE:=GPL-2.0-only +PKG_LICENSE_FILES:= + +PKG_SOURCE_URL:=https://github.com/fifteenhex/xradio.git +PKG_SOURCE_PROTO:=git +PKG_SOURCE_DATE:=2025-06-01 +PKG_SOURCE_VERSION:=3ce657ae533e2213f6fda2297da1598fa11d7a03 +PKG_HASH:=skip + +PKG_BUILD_PARALLEL:=1 + +include $(INCLUDE_DIR)/kernel.mk +include $(INCLUDE_DIR)/package.mk + +# +# XR819 firmware download +# + +define Download/xr819 + FILE:=xr819.tar.bz2 + URL:=https://github.com/melsem/openwrt-lede_xradio-xr819_soc-audio/raw/master/firmware/ + HASH:=skip +endef +$(eval $(call Download,xr819)) + +# +# Kernel module +# + +define KernelPackage/xradio + SUBMENU:=Wireless Drivers + TITLE:=Xradio XR819 wireless driver + DEPENDS:=@TARGET_sunxi +kmod-mac80211 +xr819-firmware + KCONFIG:= \ + CONFIG_WLAN_VENDOR_XRADIO=m \ + CONFIG_XRADIO_USE_EXTENSIONS=y + FILES:=$(PKG_BUILD_DIR)/xradio_wlan.ko + AUTOLOAD:=$(call AutoProbe,xradio_wlan) +endef + +NOSTDINC_FLAGS = \ + -I$(PKG_BUILD_DIR) \ + -I$(STAGING_DIR)/usr/include/mac80211-backport/uapi \ + -I$(STAGING_DIR)/usr/include/mac80211-backport \ + -I$(STAGING_DIR)/usr/include/mac80211/uapi \ + -I$(STAGING_DIR)/usr/include/mac80211 \ + -include backport/autoconf.h \ + -include backport/backport.h + +define Build/Compile + +$(MAKE) $(PKG_JOBS) -C "$(LINUX_DIR)" \ + $(KERNEL_MAKE_FLAGS) \ + $(PKG_MAKE_FLAGS) \ + M="$(PKG_BUILD_DIR)" \ + NOSTDINC_FLAGS="$(NOSTDINC_FLAGS)" \ + modules +endef + +$(eval $(call KernelPackage,xradio)) + +# +# XR819 firmware package +# + +define Package/xr819-firmware + SECTION:=firmware + CATEGORY:=Firmware + TITLE:=Xradio XR819 firmware + DEPENDS:=@TARGET_sunxi +endef + +define Package/xr819-firmware/description + Firmware files for the Xradio XR819 wireless chipset. +endef + +define Package/xr819-firmware/install + $(INSTALL_DIR) $(1)/lib/firmware + $(TAR) -C $(1)/lib/firmware -xjf $(DL_DIR)/xr819.tar.bz2 +endef + +$(eval $(call BuildPackage,xr819-firmware)) + diff --git a/xradio/patches/0001-fix-build-with-recent-mac80211.patch b/xradio/patches/0001-fix-build-with-recent-mac80211.patch new file mode 100644 index 000000000..31acabfcd --- /dev/null +++ b/xradio/patches/0001-fix-build-with-recent-mac80211.patch @@ -0,0 +1,114 @@ +From 301c85463b59270a884ce4ecc8a69bc5b70d378c Mon Sep 17 00:00:00 2001 +From: koshev-msk +Date: Mon, 26 Oct 2026 10:12:34 +0300 +Subject: [PATCH 1/2] Fix build with recent mac80211 and -Werror + +- add radio_idx to ieee80211_ops .config and .set_rts_threshold +- add prototypes for non-static functions in main.c (-Wmissing-prototypes) +- make debug printk stubs proper statements (-Wempty-body) +--- + debug.h | 14 +++++++------- + main.c | 1 + + main.h | 4 ++++ + sta.c | 4 ++-- + sta.h | 4 ++-- + 5 files changed, 16 insertions(+), 11 deletions(-) + +diff --git a/debug.h b/debug.h +index 30a59f3..90fc3b3 100644 +--- a/debug.h ++++ b/debug.h +@@ -11,12 +11,12 @@ + #ifndef XRADIO_DEBUG_H_INCLUDED + #define XRADIO_DEBUG_H_INCLUDED + +-#define xradio_dbg(level, ...) +-#define txrx_printk(level, ...) +-#define wsm_printk(level, ...) +-#define sta_printk(level, ...) +-#define scan_printk(level, ...) +-#define ap_printk(level, ...) +-#define pm_printk(level, ...) ++#define xradio_dbg(level, ...) do { } while (0) ++#define txrx_printk(level, ...) do { } while (0) ++#define wsm_printk(level, ...) do { } while (0) ++#define sta_printk(level, ...) do { } while (0) ++#define scan_printk(level, ...) do { } while (0) ++#define ap_printk(level, ...) do { } while (0) ++#define pm_printk(level, ...) do { } while (0) + + #endif /* XRADIO_DEBUG_H_INCLUDED */ +diff --git a/main.c b/main.c +index a30c604..a291b3d 100644 +--- a/main.c ++++ b/main.c +@@ -25,6 +25,7 @@ + #include "scan.h" + #include "pm.h" + #include "sdio.h" ++#include "main.h" + + /* TODO: use rates and channels from the device */ + #define RATETAB_ENT(_rate, _rateid, _flags) \ +diff --git a/main.h b/main.h +index 2238d75..be634a4 100644 +--- a/main.h ++++ b/main.h +@@ -1,6 +1,10 @@ + #ifndef __XRADIO_MAIN_H + #define __XRADIO_MAIN_H + ++struct ieee80211_hw *xradio_init_common(size_t hw_priv_data_len); ++void xradio_free_common(struct ieee80211_hw *dev); ++int xradio_register_common(struct ieee80211_hw *dev); ++void xradio_unregister_common(struct ieee80211_hw *dev); + int xradio_core_init(struct sdio_func* func); + void xradio_core_deinit(struct sdio_func* func); + +diff --git a/sta.c b/sta.c +index 2dddb26..db38cfb 100644 +--- a/sta.c ++++ b/sta.c +@@ -381,7 +381,7 @@ int xradio_change_interface(struct ieee80211_hw *dev, + return ret; + } + +-int xradio_config(struct ieee80211_hw *dev, u32 changed) ++int xradio_config(struct ieee80211_hw *dev, int radio_idx, u32 changed) + { + int ret = 0; + struct xradio_common *hw_priv = dev->priv; +@@ -796,7 +796,7 @@ void xradio_wep_key_work(struct work_struct *work) + wsm_unlock_tx(hw_priv); + } + +-int xradio_set_rts_threshold(struct ieee80211_hw *hw, u32 value) ++int xradio_set_rts_threshold(struct ieee80211_hw *hw, int radio_idx, u32 value) + { + struct xradio_common *hw_priv = hw->priv; + int ret = 0; +diff --git a/sta.h b/sta.h +index 39a023a..5d769f9 100644 +--- a/sta.h ++++ b/sta.h +@@ -42,7 +42,7 @@ int xradio_change_interface(struct ieee80211_hw *dev, + struct ieee80211_vif *vif, + enum nl80211_iftype new_type, + bool p2p); +-int xradio_config(struct ieee80211_hw *dev, u32 changed); ++int xradio_config(struct ieee80211_hw *dev, int radio_idx, u32 changed); + int xradio_change_interface(struct ieee80211_hw *dev, + struct ieee80211_vif *vif, + enum nl80211_iftype new_type, +@@ -65,7 +65,7 @@ int xradio_get_stats(struct ieee80211_hw *dev, + int xradio_get_tx_stats(struct ieee80211_hw *dev, + struct ieee80211_tx_queue_stats *stats); + */ +-int xradio_set_rts_threshold(struct ieee80211_hw *hw, u32 value); ++int xradio_set_rts_threshold(struct ieee80211_hw *hw, int radio_idx, u32 value); + + void xradio_flush(struct ieee80211_hw *hw, struct ieee80211_vif *vif, u32 queues, bool drop); + +-- +2.43.0 + diff --git a/xradio/patches/0002-fix-more-missing-prototypes.patch b/xradio/patches/0002-fix-more-missing-prototypes.patch new file mode 100644 index 000000000..23839ef21 --- /dev/null +++ b/xradio/patches/0002-fix-more-missing-prototypes.patch @@ -0,0 +1,37 @@ +From e9f45719716c4633e858495234616450894c5e93 Mon Sep 17 00:00:00 2001 +From: koshev-msk +Date: Mon, 26 Oct 2026 10:40:59 +0300 +Subject: [PATCH 2/2] Fix more missing prototypes + +- include p2p.h in p2p.c +- declare xradio_pending_offchanneltx_work in sta.h +--- + p2p.c | 1 + + sta.h | 1 + + 2 files changed, 2 insertions(+) + +diff --git a/p2p.c b/p2p.c +index f32fb43..91be9d5 100644 +--- a/p2p.c ++++ b/p2p.c +@@ -1,4 +1,5 @@ + #include "xradio.h" ++#include "p2p.h" + + #ifdef TES_P2P_0002_ROC_RESTART + ///w, TES_P2P_0002 WorkAround: +diff --git a/sta.h b/sta.h +index 5d769f9..5daa7e2 100644 +--- a/sta.h ++++ b/sta.h +@@ -65,6 +65,7 @@ int xradio_get_stats(struct ieee80211_hw *dev, + int xradio_get_tx_stats(struct ieee80211_hw *dev, + struct ieee80211_tx_queue_stats *stats); + */ ++void xradio_pending_offchanneltx_work(struct work_struct *work); + int xradio_set_rts_threshold(struct ieee80211_hw *hw, int radio_idx, u32 value); + + void xradio_flush(struct ieee80211_hw *hw, struct ieee80211_vif *vif, u32 queues, bool drop); +-- +2.43.0 + diff --git a/xradio/patches/0003-bh-demote-missed-interrupt-message-to-dev_dbg.patch b/xradio/patches/0003-bh-demote-missed-interrupt-message-to-dev_dbg.patch new file mode 100644 index 000000000..834f645e9 --- /dev/null +++ b/xradio/patches/0003-bh-demote-missed-interrupt-message-to-dev_dbg.patch @@ -0,0 +1,28 @@ +From be6b427d35803ad7ed2dd33d4e133b5791a99c9c Mon Sep 17 00:00:00 2001 +From: koshev-msk +Date: Mon, 26 Oct 2026 11:08:06 +0300 +Subject: [PATCH] bh: demote 'missed interrupt' message to dev_dbg + +The BH thread polls the device every HZ/30 and picks up pending data +when the edge-triggered IRQ was not seen. This is handled, so there is +no reason to warn about it on every occurrence. +--- + bh.c | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + +diff --git a/bh.c b/bh.c +index 592fad2..5663007 100644 +--- a/bh.c ++++ b/bh.c +@@ -776,7 +776,7 @@ static int xradio_bh(void *arg) + } else if (!status) { + /* check if there is data waiting but we missed the interrupt*/ + if (xradio_bh_rx_availlen(hw_priv) > 0) { +- dev_warn(hw_priv->pdev, "missed interrupt\n"); ++ dev_dbg(hw_priv->pdev, "missed interrupt\n"); + if(xradio_bh_exchange(hw_priv) < 0){ + break; + } +-- +2.43.0 +