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
+