diff --git a/UA-Mask/Makefile b/UA-Mask/Makefile index 244d4055d..4e7eeee51 100644 --- a/UA-Mask/Makefile +++ b/UA-Mask/Makefile @@ -1 +1,106 @@ -include $(CURDIR)/openwrt/package/Makefile +include $(TOPDIR)/rules.mk + +REPO_ROOT:=$(abspath $(dir $(firstword $(MAKEFILE_LIST)))) +UAMASK_SOURCE_DIR?=$(REPO_ROOT) +UAMASK_VERSION?=$(strip $(shell cat $(UAMASK_SOURCE_DIR)/VERSION)) + +PKG_NAME:=UAmask +PKG_VERSION:=$(UAMASK_VERSION) +PKG_RELEASE:=2 + +PKG_MAINTAINER:=Zesuy +PKG_LICENSE:=GPL-3.0-only +PKG_LICENSE_FILES:=LICENSE + +PKG_BUILD_PARALLEL:=1 +PKG_BUILD_FLAGS:=no-mips16 + +ifeq ($(strip $(UAMASK_PREBUILT)),) +PKG_BUILD_DEPENDS:=golang/host +endif + +include $(INCLUDE_DIR)/package.mk + +ifeq ($(strip $(UAMASK_PREBUILT)),) +GO_PKG:=UAmask +GO_PKG_BUILD_PKG:=$(GO_PKG)/cmd/UAmask +GO_PKG_LDFLAGS_X:=main.version=$(PKG_VERSION) +include $(TOPDIR)/feeds/packages/lang/golang/golang-package.mk +UAMASK_ARCH_DEPENDS:=$(GO_ARCH_DEPENDS) +endif + +# 3. nftables 包定义 +define Package/UAmask + SECTION:=net + CATEGORY:=Network + SUBMENU:=Web Servers/Proxies + TITLE:=A transparent proxy for changing User-Agent (nftables) + URL:=https://github.com/Zesuy/UA-Mask + DEPENDS:=$(UAMASK_ARCH_DEPENDS) +luci-compat +libubox + CONFLICTS:=ua3f-tproxy ua3f-tproxy-ipt UAmask-ipt +endef + +define Package/UAmask/description + A transparent proxy (TPROXY) for modifying HTTP User-Agent. + This is the default version using nftables (firewall4). +endef + +ifeq ($(strip $(UAMASK_PREBUILT)),) +define Build/Prepare + $(call Build/Prepare/Default) + $(CP) -r $(UAMASK_SOURCE_DIR)/core/cmd $(PKG_BUILD_DIR)/ + $(CP) -r $(UAMASK_SOURCE_DIR)/core/internal $(PKG_BUILD_DIR)/ + $(CP) $(UAMASK_SOURCE_DIR)/core/go.mod $(PKG_BUILD_DIR)/ + $(CP) $(UAMASK_SOURCE_DIR)/core/go.sum $(PKG_BUILD_DIR)/ + $(CP) $(UAMASK_SOURCE_DIR)/LICENSE $(PKG_BUILD_DIR)/ +endef +else +define Build/Prepare + $(call Build/Prepare/Default) + test -x $(UAMASK_PREBUILT) + $(CP) $(UAMASK_PREBUILT) $(PKG_BUILD_DIR)/UAmask + $(CP) $(UAMASK_SOURCE_DIR)/LICENSE $(PKG_BUILD_DIR)/ +endef + +define Build/Compile + true +endef + +# The release builder has already stripped the static Go binary with +# -ldflags "-s -w". OpenWrt's sstrip corrupts its section-header string table +# when applied a second time, so prebuilt mode must preserve the ELF verbatim. +RSTRIP:=: +endif + +# 4. nftables 包 conffiles +define Package/UAmask/conffiles +/etc/config/UAmask +endef + +define Package/UAmask/install/assets + $(INSTALL_DIR) $(1)/etc/init.d/ + $(INSTALL_BIN) $(UAMASK_SOURCE_DIR)/openwrt/root/etc/init.d/UAmask $(1)/etc/init.d/UAmask + $(INSTALL_DIR) $(1)/etc/config/ + $(INSTALL_CONF) $(UAMASK_SOURCE_DIR)/openwrt/root/etc/config/UAmask $(1)/etc/config/UAmask + $(INSTALL_DIR) $(1)/usr/lib/lua/luci/model/cbi/ + $(INSTALL_DATA) $(UAMASK_SOURCE_DIR)/openwrt/luci/model/cbi/UAmask.lua $(1)/usr/lib/lua/luci/model/cbi/UAmask.lua + $(INSTALL_DIR) $(1)/usr/lib/lua/luci/controller/ + $(INSTALL_DATA) $(UAMASK_SOURCE_DIR)/openwrt/luci/controller/UAmask.lua $(1)/usr/lib/lua/luci/controller/UAmask.lua +endef + +ifeq ($(strip $(UAMASK_PREBUILT)),) +define Package/UAmask/install + $(call GoPackage/Package/Install/Bin,$(PKG_INSTALL_DIR)) + $(INSTALL_DIR) $(1)/usr/bin/ + $(INSTALL_BIN) $(PKG_INSTALL_DIR)/usr/bin/UAmask $(1)/usr/bin/UAmask + $(call Package/UAmask/install/assets,$(1)) +endef +$(eval $(call GoBinPackage,UAmask)) +else +define Package/UAmask/install + $(INSTALL_DIR) $(1)/usr/bin/ + $(INSTALL_BIN) $(PKG_BUILD_DIR)/UAmask $(1)/usr/bin/UAmask + $(call Package/UAmask/install/assets,$(1)) +endef +endif +$(eval $(call BuildPackage,UAmask)) diff --git a/dae/Makefile b/dae/Makefile index 7a31a0500..6e6b5b1cd 100644 --- a/dae/Makefile +++ b/dae/Makefile @@ -6,9 +6,9 @@ include $(TOPDIR)/rules.mk PKG_NAME:=dae PKG_VERSION:=2026.11.09 -PKG_RELEASE:=55 +PKG_RELEASE:=56 -PKG_SOURCE:=dae-src-2026.11.09-45df6d7de7ee.tar.gz +PKG_SOURCE:=dae-src-2026.11.09-8b744ffb65ee.tar.gz PKG_SOURCE_URL:=https://github.com/kenzok8/openwrt-daede/releases/download/dae-src PKG_SOURCE_SUBDIR:=$(PKG_NAME)-$(PKG_VERSION) PKG_HASH:=skip diff --git a/daed/Makefile b/daed/Makefile index ab1cc7caf..35198542e 100644 --- a/daed/Makefile +++ b/daed/Makefile @@ -6,9 +6,9 @@ include $(TOPDIR)/rules.mk PKG_NAME:=daed PKG_VERSION:=2026.11.09 -PKG_RELEASE:=67 +PKG_RELEASE:=68 -PKG_SOURCE:=daed-src-2026.11.09-82f4f6af67da.tar.gz +PKG_SOURCE:=daed-src-2026.11.09-a25f8d76d474.tar.gz PKG_SOURCE_URL:=https://github.com/kenzok8/openwrt-daede/releases/download/daed-src PKG_SOURCE_SUBDIR:=$(PKG_NAME)-$(PKG_VERSION) PKG_HASH:=skip diff --git a/luci-app-device-manager/README.md b/luci-app-device-manager/README.md index 27d380874..53ad7db49 100644 --- a/luci-app-device-manager/README.md +++ b/luci-app-device-manager/README.md @@ -11,13 +11,25 @@ and manual device types by MAC address, including saved offline devices. - 展示 IPv4、IPv6、接口和状态依据,区分在线、离线和未知状态。 - 按分组、状态和关键词筛选,支持排序、手动登记及地址隐藏。 - 支持 29 种显示类型和本地 SVG 图标;名称和少量有来源的厂商线索用于推测,用户可手动覆盖。 -- 刷新列表只读取现有发现数据;“扫描局域网”由用户主动触发,需要写权限。 +- 页面先显示界面,列表单独加载;使用当前标签页和登录会话内的 5 分钟缓存,后台读取实时数据。 +- 状态未知且有已知 IP 的设备会在后台逐台 Ping,收到回应即判为在线;未回应仍保留未知。 +- “扫描局域网”由用户主动触发,需要写权限;读取失败提示可手动关闭,并在 12 秒后自动收起。 - 只读账号可查看、刷新和单设备 Ping,不能修改记录或启动批量扫描。 - 使用 LuCI 原生语言设置和翻译包,不覆盖浏览器或 LuCI 的语言选择。 The application distinguishes confirmed online, offline and unknown states. Filters and sortable columns support device groups, status and text searches. Manual registration, details, one-device Ping and address masking are available. +The page renders before discovery finishes. A five-minute, per-tab and +per-session cache supports repeat visits while live data refreshes in the +background. Empty-state messages wait for discovery and online checks to finish. + +Known devices with an unknown status and an IP address are checked one at a +time in the background, with at most 16 checks and a 12-second budget per pass. +A Ping reply confirms online status; a timeout or error leaves it unknown. +Replies for an older snapshot cannot change the current list. Each refresh +re-evaluates the evidence; successful Pings are not retained as permanent status. + Type recognition is a best-effort display hint with a manual override; see [identification rules and provenance](docs/device-identification.md). diff --git a/luci-app-device-manager/docs/upstream-submission.md b/luci-app-device-manager/docs/upstream-submission.md index fd8ceabfe..e9eec51d3 100644 --- a/luci-app-device-manager/docs/upstream-submission.md +++ b/luci-app-device-manager/docs/upstream-submission.md @@ -40,9 +40,11 @@ Save names, remarks, groups and manual type overrides by MAC address. Unlike the existing host list, this application maintains custom device metadata and groups, including saved devices that are currently offline. -Keep ordinary refreshes passive. Provide an explicit write-authorized scan -limited to configured netifd interfaces and their actual IPv4 prefixes. -Bound concurrent probes, scan duration and the shared scan cooldown. +Render the page before discovery completes and cache snapshots per session. +Confirm unknown devices with bounded sequential Pings to known hosts. +Provide a separate write-authorized scan limited to configured netifd +interfaces and their actual IPv4 prefixes, with bounded concurrency, +duration and a shared scan cooldown. Use native LuCI message extraction and separately built translation packages. Include pinned Material Design Icons attribution and Apache-2.0 notices. @@ -62,23 +64,27 @@ Add a LAN device manager with MAC-keyed custom names, remarks, groups and manual device types. Discovery combines native LuCI host/lease/wireless RPCs with kernel neighbor data and distinguishes online, offline and unknown. -Refresh is passive. An explicit Scan LAN operation requires write permission -and scopes ICMP probes to configured netifd interfaces and actual prefixes. +Render the interface before discovery and show list loading and retry states. +Cache snapshots for five minutes per browser tab, session and access level. +Refresh live data in the background and confirm unknown devices with known +IP addresses using sequential Pings, capped at 16 checks and about 12 seconds +per pass. Unanswered Pings remain unknown; stale replies are ignored. + +An explicit Scan LAN operation requires write permission and scopes ICMP +probes to configured netifd interfaces and actual prefixes. Chinese translations are built by luci.mk; other languages use native LuCI catalogues and English fallback. Validation: -- 100 Node and 8 Python regression tests passed. -- 33 RPC regressions passed with SDK-native jshn and isolated network inputs. +- 124 Node and 8 Python regression tests passed. - Official LuCI message extraction, gettext checks and ESLint passed. -- Built main and Chinese language APKs in the official x86/64 SNAPSHOT SDK. -- Verified official SNAPSHOT and 24.10.4 BusyBox ip/flock compatibility; - 33 RPC regressions passed with each binary and SDK-native jshn. -- Checked resolved dependencies without extra ip or flock packages. -- Checked package license texts, configuration preservation metadata and LMO. +- Chromium fixture checks covered cold and expired-cache loading, background + refresh, online confirmation, empty-state timing and notification dismissal. -Real-router installation, upgrades, account enforcement, UI acceptance and -a stable-release SDK build remain unverified. +Targeted compatibility patches were applied to an OpenWrt 23.05.5 router. +Full-package upgrade, account enforcement and a new SDK build remain to be +verified for this revision. Earlier SDK results are recorded separately in +the independent repository's docs/validation.md. ``` Review the recorded validation results before sending the PR. diff --git a/luci-app-device-manager/docs/validation.md b/luci-app-device-manager/docs/validation.md index d24b5fc2d..8e96ca998 100644 --- a/luci-app-device-manager/docs/validation.md +++ b/luci-app-device-manager/docs/validation.md @@ -1,5 +1,27 @@ # Local upstream-readiness validation +## Loading and online-status updates: 2026-11-10 + +- Node regression suite: 124 passed, including cold/expired-cache loading, + cached-empty lists, sequential unknown-device checks and stale-reply handling. +- Python regression suite: 8 passed. +- Official LuCI ESLint configuration, native message extraction and GNU gettext + checks passed; shell/JavaScript syntax and whitespace checks passed. +- Chromium tests used synthetic RPC data to verify the installed legacy-version + compatibility patches: the page renders before slow discovery, empty messages + wait for online checks, failed loading can be retried, and source warnings close + manually or after 12 seconds. These are fixture-based browser results. +- Targeted frontend compatibility patches were installed on OpenWrt 23.05.5 at + 192.168.1.1. Served resources matched the validated patch files. Hash checks + confirmed network, wireless and device-manager configuration files were intact. + The stale wlan0 discovery failure was reproduced using live RPC data and fixed. + +The router runs an older development version with the targeted compatibility +patches; it is not a full-package acceptance test of the current upstream export. +SDK packaging, package upgrades, account enforcement and physical browser/theme +acceptance have not been rerun for this revision. Earlier SDK evidence below +belongs to the dated snapshots and does not validate the new revision. + ## PR review follow-up: 2026-10-11 Addressed the dependency, license installation, redundant initializer and diff --git a/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-dialog.js b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-dialog.js index d7b5d92c1..9cfef5a84 100644 --- a/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-dialog.js +++ b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/device-dialog.js @@ -1,5 +1,7 @@ // SPDX-License-Identifier: MIT 'use strict'; +/* global feedback */ +'require device-manager.feedback as feedback'; 'require baseclass'; 'require ui'; 'require device-manager.model as model'; @@ -252,7 +254,7 @@ return baseclass.extend({ }) .catch(function(err) { ui.hideModal(); - ui.addNotification(null, E('p', [ _('Clear failed: %s').format(err.message || err) ]), 'danger'); + feedback.notify(_('Clear failed: %s').format(err.message || err), 'danger'); }).finally(function() { button.classList.remove('spinning'); button.disabled = false; @@ -265,6 +267,7 @@ return baseclass.extend({ showDetailModal: function(dev) { const self = this; + const hero = deviceHero(dev); const addresses = key => (dev[key + 'Addresses'] || []).length ? dev[key + 'Addresses'].join('\n') : dev[key]; const pingStatus = E('strong', { 'class': 'dm-ping-status', 'role': 'status', 'aria-live': 'polite' }, [ _('Probing…') ]); @@ -284,6 +287,10 @@ return baseclass.extend({ pingDetail.textContent = _('Sending one Ping from the router to this device.'); pingLog.style.display = 'none'; pending = Promise.resolve().then(() => self.handlePingDevice(dev)).then(reply => { + const status = hero.querySelector('.dm-dialog-status'); + for (const value of [ 'online', 'offline', 'unknown' ]) status.classList.remove('dm-dialog-status-' + value); + status.classList.add('dm-dialog-status-' + dev.status); + status.textContent = statusLabel(dev.status); pingStatus.textContent = reply.reachable ? _('Online · Ping replied') : _('No Ping reply'); pingStatus.classList.add(reply.reachable ? 'dm-ping-success' : 'dm-ping-warning'); pingDetail.textContent = _('Probe address: %s').format(reply.ip + (reply.interface ? ' (' + reply.interface + ')' : '')) + @@ -311,7 +318,7 @@ return baseclass.extend({ const neighbors = (dev.neighbors || []).map(neighbor => [ neighbor.ip, neighbor.dev, neighbor.state ].filter(Boolean).join(' · ')).join('\n'); ui.showModal(_('Device details'), [ E('div', { 'class': 'dm-dialog' }, [ closeButton(), - deviceHero(dev), + hero, E('section', { 'class': 'dm-ping-card' }, [ E('div', { 'class': 'dm-ping-heading' }, [ pingStatus, pingButton ]), pingDetail, pingLog ]), diff --git a/luci-app-device-manager/htdocs/luci-static/resources/device-manager/feedback.js b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/feedback.js new file mode 100644 index 000000000..8ca2a4838 --- /dev/null +++ b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/feedback.js @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: MIT +'use strict'; +'require baseclass'; +'require ui'; + +const TIMEOUT = 12000; + +return baseclass.extend({ + createWarning: function() { + let previous = '', timer = null; + const message = E('span', { 'class': 'dm-warning-message' }); + const dismiss = () => { + clearTimeout(timer); + timer = null; + node.style.display = 'none'; + }; + const node = E('div', { + 'id': 'dm-source-warning', 'class': 'alert-message warning dm-source-warning', + 'role': 'status', 'style': 'display:none;' + }, [ message, E('button', { + 'type': 'button', 'class': 'btn cbi-button dm-warning-close', + 'click': dismiss + }, [ _('Close') ]) ]); + return { + node: node, + destroy: dismiss, + update: errors => { + const text = errors.length + ? _('Some device data could not be read. Unconfirmed devices are shown as unknown: %s').format(errors.join('; ')) : ''; + if (text === previous) return; + previous = text; + dismiss(); + message.textContent = text; + if (text) { + node.style.display = 'flex'; + timer = setTimeout(dismiss, TIMEOUT); + } + } + }; + }, + notify: function(message, level) { + const node = ui.addNotification(null, E('p', {}, [ message ]), level); + if (node) setTimeout(() => node.remove(), TIMEOUT); + } +}); diff --git a/luci-app-device-manager/htdocs/luci-static/resources/device-manager/group-dialog.js b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/group-dialog.js index 3065947f8..f1499a81e 100644 --- a/luci-app-device-manager/htdocs/luci-static/resources/device-manager/group-dialog.js +++ b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/group-dialog.js @@ -1,5 +1,7 @@ // SPDX-License-Identifier: MIT 'use strict'; +/* global feedback */ +'require device-manager.feedback as feedback'; 'require baseclass'; 'require ui'; 'require device-manager.model as model'; @@ -226,7 +228,7 @@ return baseclass.extend({ }) .catch(function(err) { ui.hideModal(); - ui.addNotification(null, E('p', [ _('Could not delete group: %s').format(err.message || err) ]), 'danger'); + feedback.notify(_('Could not delete group: %s').format(err.message || err), 'danger'); }).finally(function() { button.classList.remove('spinning'); button.disabled = false; diff --git a/luci-app-device-manager/htdocs/luci-static/resources/device-manager/online-check.js b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/online-check.js new file mode 100644 index 000000000..4368ed488 --- /dev/null +++ b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/online-check.js @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT +'use strict'; +'require baseclass'; + +const MAX_DEVICES = 16; +const MAX_DURATION = 12000; + +return baseclass.extend({ + run: function(devices, ping, isCurrent) { + // ping_device has a shared router lock, so check one device at a time. + const task = (this.pending || Promise.resolve()).then(() => { + const targets = devices.filter(device => device.status === 'unknown' && (device.ipv4 || device.ipv6)); + const started = Date.now(), limit = Math.min(targets.length, MAX_DEVICES); + let count = 0; + const next = () => { + if (!isCurrent() || count >= limit || Date.now() - started >= MAX_DURATION) return; + const index = (this.cursor || 0) % targets.length; + this.cursor = index + 1; + count++; + const device = targets[index]; + return Promise.resolve().then(() => { + if (isCurrent() && device.status === 'unknown') return ping(device); + }).then(next, next); + }; + return next(); + }); + this.pending = task.then(() => {}, () => {}); + return task; + } +}); diff --git a/luci-app-device-manager/htdocs/luci-static/resources/device-manager/service.js b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/service.js index 81f29fc56..3335ef68c 100644 --- a/luci-app-device-manager/htdocs/luci-static/resources/device-manager/service.js +++ b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/service.js @@ -1,10 +1,12 @@ // SPDX-License-Identifier: MIT 'use strict'; +/* global snapshotCache */ 'require baseclass'; 'require rpc'; 'require uci'; 'require fs'; 'require device-manager.model as model'; +'require device-manager.snapshot-cache as snapshotCache'; const CONFIG = 'device_manager'; const callHints = rpc.declare({ object: 'luci-rpc', method: 'getHostHints', reject: true }); @@ -13,6 +15,7 @@ const callNeighbors = rpc.declare({ object: 'luci.device-manager', method: 'get_ const callScan = rpc.declare({ object: 'luci.device-manager', method: 'scan_devices', reject: true }); const callPing = rpc.declare({ object: 'luci.device-manager', method: 'ping_device', params: [ 'mac' ], reject: true }); const callWireless = rpc.declare({ object: 'luci-rpc', method: 'getWirelessDevices', reject: true }); +const callNetworkDevices = rpc.declare({ object: 'luci-rpc', method: 'getNetworkDevices', reject: true }); const callAssoc = rpc.declare({ object: 'iwinfo', method: 'assoclist', params: [ 'device' ], reject: true }); const callCommit = rpc.declare({ object: 'uci', method: 'commit', params: [ 'config' ], reject: true }); const callRevert = rpc.declare({ object: 'uci', method: 'revert', params: [ 'config' ], reject: true }); @@ -28,7 +31,7 @@ function readSource(name, read) { } function wifiStations() { - return callWireless().then(requireObject).then(radios => { + return Promise.all([ callWireless().then(requireObject), callNetworkDevices().then(requireObject) ]).then(([ radios, devices ]) => { const interfaces = new Set(); for (const radio of Object.values(radios)) { for (const iface of radio.interfaces || []) { @@ -38,7 +41,9 @@ function wifiStations() { for (const vlan of iface.vlans || []) if (vlan.ifname) interfaces.add(vlan.ifname); } } - return Promise.all(Array.from(interfaces).map(device => readSource(device, () => callAssoc(device).then(reply => { + // netifd may retain an ifname after the actual wireless device has disappeared. + const present = Array.from(interfaces).filter(device => Object.prototype.hasOwnProperty.call(devices, device)); + return Promise.all(present.map(device => readSource(device, () => callAssoc(device).then(reply => { if (!Array.isArray(requireObject(reply).results)) throw new Error(_('Invalid discovery response')); return reply.results.map(station => ({ mac: station.mac, dev: device })); })))).then(results => ({ @@ -75,7 +80,7 @@ function requireGroup(id) { return baseclass.extend({ scanDevices: function() { if (L.hasViewPermission() !== true) return Promise.reject(new Error(_('Read-only access'))); - return this.queue(() => callScan().then(reply => { + return this.queue(() => snapshotCache.clear().then(() => callScan()).then(reply => { if (requireObject(reply).ok !== true) throw new Error(_('LAN scanning is unavailable. Check the selected interfaces and probe dependencies.')); })); @@ -136,10 +141,16 @@ return baseclass.extend({ data.errors.push(...result.value.errors); } else data[result.name] = result.value; } - return data; + return snapshotCache.save(data).then(() => data); }); })); }, + loadCached: function(cacheOnly) { + return snapshotCache.load().then(entry => entry + ? { snapshot: entry.snapshot, cached: true } + : cacheOnly ? { snapshot: null, cached: false } + : this.load().then(snapshot => ({ snapshot: snapshot, cached: false }))); + }, recoverPending: function() { if (!this.rollbackPending) return Promise.resolve(); return callRevert(CONFIG).then(() => { this.rollbackPending = false; }); @@ -148,7 +159,7 @@ return baseclass.extend({ return this.queue(() => { if (L.hasViewPermission() !== true) throw new Error(_('Read-only access')); let staged = false; - return this.recoverPending().then(reloadConfig).then(change).then(() => { + return this.recoverPending().then(() => snapshotCache.clear()).then(reloadConfig).then(change).then(() => { staged = true; return uci.save(); }).then(() => callCommit(CONFIG)).catch(error => { diff --git a/luci-app-device-manager/htdocs/luci-static/resources/device-manager/snapshot-cache.js b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/snapshot-cache.js new file mode 100644 index 000000000..a6d5ef4cf --- /dev/null +++ b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/snapshot-cache.js @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT +'use strict'; +'require baseclass'; +'require rpc'; + +const MAX_AGE = 5 * 60 * 1000; + +// Keep device data within this tab and the current LuCI session and access level. +function key() { + return 'luci-device-manager-snapshot-v1:' + rpc.getSessionID() + ':' + (L.hasViewPermission() === true ? 'write' : 'read'); +} + +return baseclass.extend({ + load: function() { + return Promise.resolve().then(() => window.sessionStorage.getItem(key())).then(raw => { + const entry = raw ? JSON.parse(raw) : null; + if (!entry || !Number.isFinite(entry.time) || Date.now() < entry.time || Date.now() - entry.time >= MAX_AGE) + return null; + const data = entry.snapshot; + if (!data || !Array.isArray(data.devices) || !Array.isArray(data.groups) || !Array.isArray(data.errors) + || typeof data.discoveryComplete !== 'boolean') return null; + return entry; + }).catch(() => null); + }, + save: function(snapshot) { + return Promise.resolve().then(() => window.sessionStorage.setItem(key(), JSON.stringify({ time: Date.now(), snapshot: snapshot }))) + .catch(() => {}); + }, + clear: function() { + return Promise.resolve().then(() => window.sessionStorage.removeItem(key())).catch(() => {}); + } +}); diff --git a/luci-app-device-manager/htdocs/luci-static/resources/device-manager/styles.css b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/styles.css index ffc368c5d..2251a3d85 100644 --- a/luci-app-device-manager/htdocs/luci-static/resources/device-manager/styles.css +++ b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/styles.css @@ -35,7 +35,9 @@ body.dark-mode .dm-status-dot-unknown { background-color:#fdd663; box-shadow:0 0 .dm-table .dm-action-link:focus-visible { outline:2px solid currentColor; outline-offset:2px; border-radius:2px; } .dm-status-tab:focus-visible { outline:2px solid #1a73e8; outline-offset:2px; } -.dm-source-warning { overflow-wrap:anywhere; } +.dm-source-warning { overflow-wrap:anywhere; align-items:flex-start; gap:12px; } +.dm-warning-message { flex:1; min-width:0; } +.dm-warning-close { flex-shrink:0; margin-left:auto; } .dm-type-th { width: 56px; text-align: center; } .dm-type-cell { text-align: center; vertical-align: middle; padding: 6px 8px; } diff --git a/luci-app-device-manager/htdocs/luci-static/resources/device-manager/table.js b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/table.js index 730657c31..4c38eedb2 100644 --- a/luci-app-device-manager/htdocs/luci-static/resources/device-manager/table.js +++ b/luci-app-device-manager/htdocs/luci-static/resources/device-manager/table.js @@ -127,10 +127,23 @@ return baseclass.extend({ renderTable: function() { if (!this.tableBodyNode) return; dom.content(this.tableBodyNode, null); - const filtered = this.devices.filter(device => (this.activeTab === 'all' || device.status === this.activeTab) && model.matchesDevice(device, this.groups, this.activeGroup, this.filterText)); + const loading = this.initialLoading || this.listRefreshing || this.checkingOnline || (this.showingCache && !this.cacheRefreshFailed); + if (!filtered.length && (loading || this.loadFailed)) { + this.tableBodyNode.appendChild(E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td', 'colspan': 8, 'style': 'text-align:center; padding:36px 16px;' }, [ + E('span', { 'class': loading ? 'spinning' : '', 'role': 'status' }, [ + loading ? _('Loading list...') : _('Could not load the device list.') + ]), + ...(loading ? [] : [ E('button', { + 'type': 'button', 'class': 'cbi-button', 'style': 'margin-left:12px;', 'click': () => this.refresh() + }, [ _('Retry') ]) ]) + ]) + ])); + return; + } if (filtered.length === 0) { let emptyMsg = _('No devices found. Refresh the list to try again.'); diff --git a/luci-app-device-manager/htdocs/luci-static/resources/view/device-manager/devices.js b/luci-app-device-manager/htdocs/luci-static/resources/view/device-manager/devices.js index dc35799b7..2e587e29a 100644 --- a/luci-app-device-manager/htdocs/luci-static/resources/view/device-manager/devices.js +++ b/luci-app-device-manager/htdocs/luci-static/resources/view/device-manager/devices.js @@ -1,6 +1,6 @@ // SPDX-License-Identifier: MIT 'use strict'; -/* global deviceDialog, groupDialog, preferences, service, table */ +/* global deviceDialog, feedback, groupDialog, onlineCheck, preferences, service, table */ 'require view'; 'require ui'; 'require device-manager.model as model'; @@ -9,6 +9,8 @@ 'require device-manager.table as table'; 'require device-manager.device-dialog as deviceDialog'; 'require device-manager.group-dialog as groupDialog'; +'require device-manager.feedback as feedback'; +'require device-manager.online-check as onlineCheck'; return view.extend({ devices: [], @@ -25,13 +27,14 @@ return view.extend({ handleReset: null, load: function() { - return Promise.all([ service.load(), preferences.load() ]) - .then(data => ({ snapshot: data[0], preferences: data[1] })); + return Promise.all([ service.loadCached(true), preferences.load() ]) + .then(data => Object.assign({ preferences: data[1] }, data[0])); }, acceptData: function(data) { this.groups = model.parseGroups(data.groups); this.devices = model.parseDevices(data, this.groups); + this.checkingOnline = !this.showingCache && this.devices.some(device => device.status === 'unknown' && (device.ipv4 || device.ipv6)); this.sourceErrors = data.errors || []; if (this.activeGroup !== 'all' && this.activeGroup !== 'ungrouped' && !this.getGroupById(this.activeGroup)) { this.activeGroup = 'all'; @@ -43,10 +46,21 @@ return view.extend({ const self = this; this.readonly = L.hasViewPermission() !== true; - this.acceptData(data.snapshot); + this.hasSnapshot = Boolean(data.snapshot); + this.initialLoading = !this.hasSnapshot; + this.loadFailed = false; + this.listRefreshing = false; + this.checkingOnline = false; + this.showingCache = Boolean(data.cached); + this.cacheRefreshFailed = false; + if (this.sourceWarning) this.sourceWarning.destroy(); + this.sourceWarning = feedback.createWarning(); + if (data.snapshot) this.acceptData(data.snapshot); + else { this.devices = []; this.groups = []; this.sourceErrors = []; } const savedView = data.preferences || {}; this.activeTab = ['all', 'online', 'offline', 'unknown'].includes(savedView.tab) ? savedView.tab : 'online'; - this.activeGroup = (savedView.group === 'all' || savedView.group === 'ungrouped' || this.getGroupById(savedView.group)) ? savedView.group : 'all'; + this.activeGroup = this.initialLoading ? (savedView.group || 'all') + : (savedView.group === 'all' || savedView.group === 'ungrouped' || this.getGroupById(savedView.group)) ? savedView.group : 'all'; this.maskInfo = Boolean(savedView.maskInfo); const sortHeader = (key, label, width, centered) => E('th', { @@ -87,7 +101,8 @@ return view.extend({ E('h2', {}, [ _('LAN Device Management') ]), E('div', { 'class': 'cbi-map-descr' }, [ _('Discover network devices and save custom names, remarks and groups by MAC address.') ]), - E('div', { 'id': 'dm-source-warning', 'class': 'alert-message warning dm-source-warning', 'style': 'display:none;' }), + E('div', { 'id': 'dm-cache-status', 'class': 'cbi-map-descr', 'role': 'status', 'style': 'display:none;' }), + this.sourceWarning.node, // Row 1: Status Tabs E('div', { 'class': 'dm-status-tabs', 'id': 'dm-tabs-container', 'role': 'tablist', 'aria-label': _('Device status') }), @@ -120,13 +135,13 @@ return view.extend({ ]), E('div', { 'class': 'dm-btn-group' }, [ E('button', { 'type': 'button', - 'class': 'cbi-button cbi-button-neutral', - 'disabled': self.readonly || null, + 'class': 'cbi-button cbi-button-neutral dm-requires-data', + 'disabled': self.readonly || self.initialLoading || null, 'click': function() { self.showGroupModal(); } }, [ _('Manage groups') ]), E('button', { 'type': 'button', - 'class': 'cbi-button cbi-button-action dm-btn-blue', - 'disabled': self.readonly || null, + 'class': 'cbi-button cbi-button-action dm-btn-blue dm-requires-data', + 'disabled': self.readonly || self.initialLoading || null, 'click': function() { self.showEditModal(null); } }, [ _('Add device') ]), E('button', { 'type': 'button', @@ -183,12 +198,16 @@ return view.extend({ this.tableHeadNode = theadNode; this.warningNode = viewNode.querySelector('#dm-source-warning'); + this.cacheStatusNode = viewNode.querySelector('#dm-cache-status'); this.tabMenuNode = viewNode.querySelector('#dm-tabs-container'); this.tableBodyNode = viewNode.querySelector('#device_manager_tbody'); this.groupSelectNode = viewNode.querySelector('#dm-group-select'); this.maskBtnNode = viewNode.querySelector('#dm-btn-mask'); + this.dataActionButtons = viewNode.querySelectorAll('.dm-requires-data'); this.updateView(); + clearTimeout(this.loadTimer); + this.loadTimer = setTimeout(() => this.initialLoading || this.showingCache ? this.refresh() : this.confirmUnknownDevices(), 0); return viewNode; }, @@ -202,7 +221,30 @@ return view.extend({ updateSortHeaders: function() { return table.updateSortHeaders.call(this); }, showEditModal: function(device) { return deviceDialog.showEditModal.call(this, device); }, showDetailModal: function(device) { return deviceDialog.showDetailModal.call(this, device); }, - handlePingDevice: function(device) { return service.pingDevice(device.mac); }, + handlePingDevice: function(device) { + return service.pingDevice(device.mac).then(reply => { + // A reply for an older snapshot must not change the current list. + if (reply.reachable && device.status === 'unknown' && this.devices.includes(device)) { + device.status = 'online'; + device.statusDetail = _('Online · Ping replied'); + this.updateView(); + } + return reply; + }); + }, + confirmUnknownDevices: function() { + const devices = this.devices; + if (this.initialLoading || this.showingCache || this.confirmingDevices === devices) return this.confirming || Promise.resolve(); + this.confirmingDevices = devices; + this.confirming = onlineCheck.run(devices, device => this.handlePingDevice(device), + () => this.devices === devices && !this.showingCache).finally(() => { + if (this.devices === devices) { + this.checkingOnline = false; + this.updateView(); + } + }); + return this.confirming; + }, confirmDelete: function(device) { return deviceDialog.confirmDelete.call(this, device); }, showGroupModal: function() { return groupDialog.showGroupModal.call(this); }, promptRenameGroup: function(group) { return groupDialog.promptRenameGroup.call(this, group); }, @@ -216,33 +258,57 @@ return view.extend({ updateView: function() { this.renderTabs(); this.renderGroupSelect(); + if (this.groupSelectNode) this.groupSelectNode.disabled = this.initialLoading; + for (const button of this.dataActionButtons || []) button.disabled = this.readonly || !this.hasSnapshot; this.updateSortHeaders(); if (this.maskBtnNode) { this.maskBtnNode.textContent = this.maskInfo ? _('Show info') : _('Hide info'); } this.renderTable(); - if (this.warningNode) { - this.warningNode.textContent = this.sourceErrors.length - ? _('Some device data could not be read. Unconfirmed devices are shown as unknown: %s').format(this.sourceErrors.join('; ')) : ''; - this.warningNode.style.display = this.sourceErrors.length ? '' : 'none'; + if (this.sourceWarning) this.sourceWarning.update(this.sourceErrors); + if (this.cacheStatusNode) { + this.cacheStatusNode.textContent = this.showingCache && this.cacheRefreshFailed + ? _('Showing cached device data. Refresh to retry.') : ''; + this.cacheStatusNode.style.display = this.showingCache && this.cacheRefreshFailed ? '' : 'none'; } }, scan: function() { if (this.scanning) return this.scanning; this.scanning = service.scanDevices().then(() => this.refresh()).catch(error => { - ui.addNotification(null, E('p', {}, [ _('Could not scan LAN devices: %s').format(error.message || error) ]), 'danger'); + feedback.notify(_('Could not scan LAN devices: %s').format(error.message || error), 'danger'); }).finally(() => { this.scanning = null; }); return this.scanning; }, refresh: function() { if (this.refreshing) return this.refreshing; + clearTimeout(this.loadTimer); + this.loadTimer = null; + this.listRefreshing = true; + if (!this.hasSnapshot) { + this.initialLoading = true; + this.loadFailed = false; + } + this.updateView(); this.refreshing = service.load().then(data => { + this.listRefreshing = false; + this.initialLoading = false; + this.loadFailed = false; + this.hasSnapshot = true; + this.showingCache = false; + this.cacheRefreshFailed = false; this.acceptData(data); this.updateView(); + this.loadTimer = setTimeout(() => this.confirmUnknownDevices(), 0); }).catch(error => { - ui.addNotification(null, E('p', {}, [ _('Could not refresh the device list: %s').format(error.message || error) ]), 'danger'); + this.listRefreshing = false; + this.checkingOnline = false; + this.initialLoading = false; + this.loadFailed = true; + this.cacheRefreshFailed = true; + this.updateView(); + feedback.notify(_('Could not refresh the device list: %s').format(error.message || error), 'danger'); }).finally(() => { this.refreshing = null; }); return this.refreshing; } diff --git a/luci-app-device-manager/po/templates/device-manager.pot b/luci-app-device-manager/po/templates/device-manager.pot index 1d359ac76..e52e18e9e 100644 --- a/luci-app-device-manager/po/templates/device-manager.pot +++ b/luci-app-device-manager/po/templates/device-manager.pot @@ -1,28 +1,28 @@ msgid "" msgstr "Content-Type: text/plain; charset=UTF-8" -#: htdocs/luci-static/resources/view/device-manager/devices.js:69 +#: htdocs/luci-static/resources/view/device-manager/devices.js:83 msgid "#" msgstr "" -#: htdocs/luci-static/resources/device-manager/group-dialog.js:38 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:40 msgid "%d devices" msgstr "" -#: htdocs/luci-static/resources/device-manager/service.js:158 +#: htdocs/luci-static/resources/device-manager/service.js:169 msgid "%s; failed to discard pending changes: %s" msgstr "" -#: htdocs/luci-static/resources/device-manager/service.js:172 +#: htdocs/luci-static/resources/device-manager/service.js:183 msgid "A conflicting configuration record exists" msgstr "" -#: htdocs/luci-static/resources/device-manager/group-dialog.js:70 -#: htdocs/luci-static/resources/device-manager/service.js:67 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:72 +#: htdocs/luci-static/resources/device-manager/service.js:72 msgid "A group with this name already exists" msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:309 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:316 msgid "ARP" msgstr "" @@ -34,8 +34,8 @@ msgstr "" msgid "Access point" msgstr "" -#: htdocs/luci-static/resources/device-manager/group-dialog.js:106 -#: htdocs/luci-static/resources/view/device-manager/devices.js:80 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:108 +#: htdocs/luci-static/resources/view/device-manager/devices.js:94 msgid "Actions" msgstr "" @@ -47,12 +47,12 @@ msgstr "" msgid "Active network neighbor (%s)" msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:55 -#: htdocs/luci-static/resources/view/device-manager/devices.js:131 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:57 +#: htdocs/luci-static/resources/view/device-manager/devices.js:146 msgid "Add device" msgstr "" -#: htdocs/luci-static/resources/device-manager/group-dialog.js:96 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:98 msgid "Add group" msgstr "" @@ -72,19 +72,19 @@ msgstr "" msgid "All groups" msgstr "" -#: htdocs/luci-static/resources/device-manager/service.js:89 +#: htdocs/luci-static/resources/device-manager/service.js:94 msgid "Another device is being probed. Please retry." msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:93 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:95 msgid "Auto (detect by MAC)" msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:323 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:330 msgid "Automatic" msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:131 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:133 msgid "" "Automatically detected from names and MAC address, or customize manually" msgstr "" @@ -93,39 +93,40 @@ msgstr "" msgid "Camera" msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:161 -#: htdocs/luci-static/resources/device-manager/device-dialog.js:238 -#: htdocs/luci-static/resources/device-manager/group-dialog.js:160 -#: htdocs/luci-static/resources/device-manager/group-dialog.js:211 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:163 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:240 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:162 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:213 msgid "Cancel" msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:255 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:257 msgid "Clear failed: %s" msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:212 -#: htdocs/luci-static/resources/device-manager/device-dialog.js:232 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:214 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:234 msgid "Clear record" msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:235 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:237 msgid "Clear the saved record for \"%s\" (%s)?" msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:236 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:238 msgid "" "Clears the custom name, remarks, group and device type. The device remains " "discoverable on the network." msgstr "" -#: htdocs/luci-static/resources/view/device-manager/devices.js:60 +#: htdocs/luci-static/resources/view/device-manager/devices.js:74 msgid "Click to sort by %s" msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:13 -#: htdocs/luci-static/resources/device-manager/device-dialog.js:331 -#: htdocs/luci-static/resources/device-manager/group-dialog.js:123 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:15 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:338 +#: htdocs/luci-static/resources/device-manager/feedback.js:23 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:125 msgid "Close" msgstr "" @@ -137,64 +138,68 @@ msgstr "" msgid "Computers" msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:261 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:263 msgid "Confirm clear" msgstr "" -#: htdocs/luci-static/resources/device-manager/group-dialog.js:235 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:237 msgid "Confirm delete" msgstr "" -#: htdocs/luci-static/resources/device-manager/group-dialog.js:88 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:90 msgid "Could not create group: %s" msgstr "" -#: htdocs/luci-static/resources/device-manager/group-dialog.js:229 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:231 msgid "Could not delete group: %s" msgstr "" -#: htdocs/luci-static/resources/view/device-manager/devices.js:245 +#: htdocs/luci-static/resources/device-manager/table.js:138 +msgid "Could not load the device list." +msgstr "" + +#: htdocs/luci-static/resources/view/device-manager/devices.js:311 msgid "Could not refresh the device list: %s" msgstr "" -#: htdocs/luci-static/resources/view/device-manager/devices.js:234 +#: htdocs/luci-static/resources/view/device-manager/devices.js:279 msgid "Could not scan LAN devices: %s" msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:220 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:222 msgid "Current IP address" msgstr "" -#: htdocs/luci-static/resources/device-manager/group-dialog.js:148 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:150 msgid "Current group name" msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:220 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:222 msgid "Current status" msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:143 -#: htdocs/luci-static/resources/device-manager/device-dialog.js:320 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:145 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:327 msgid "Custom name" msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:308 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:315 msgid "DHCP" msgstr "" -#: htdocs/luci-static/resources/device-manager/group-dialog.js:33 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:35 msgid "Delete" msgstr "" -#: htdocs/luci-static/resources/device-manager/group-dialog.js:207 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:209 msgid "Delete group" msgstr "" -#: htdocs/luci-static/resources/device-manager/group-dialog.js:208 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:210 msgid "Delete group \"%s\"?" msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:189 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:191 msgid "Device \"%s\" saved." msgstr "" @@ -202,75 +207,75 @@ msgstr "" msgid "Device Manager" msgstr "" -#: htdocs/luci-static/resources/device-manager/group-dialog.js:105 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:107 msgid "Device count" msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:312 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:319 msgid "Device details" msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:136 -#: htdocs/luci-static/resources/device-manager/device-dialog.js:320 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:138 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:327 msgid "Device group" msgstr "" -#: htdocs/luci-static/resources/device-manager/group-dialog.js:98 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:100 msgid "Device groups" msgstr "" -#: htdocs/luci-static/resources/view/device-manager/devices.js:72 +#: htdocs/luci-static/resources/view/device-manager/devices.js:86 msgid "Device name" msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:150 -#: htdocs/luci-static/resources/device-manager/device-dialog.js:328 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:152 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:335 msgid "Device remarks" msgstr "" -#: htdocs/luci-static/resources/view/device-manager/devices.js:93 +#: htdocs/luci-static/resources/view/device-manager/devices.js:108 msgid "Device status" msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:127 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:129 msgid "Device type" msgstr "" -#: htdocs/luci-static/resources/view/device-manager/devices.js:88 +#: htdocs/luci-static/resources/view/device-manager/devices.js:102 msgid "" "Discover network devices and save custom names, remarks and groups by MAC " "address." msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:325 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:332 msgid "Discovered on network" msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:326 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:333 msgid "Discovery sources" msgstr "" -#: htdocs/luci-static/resources/device-manager/table.js:257 +#: htdocs/luci-static/resources/device-manager/table.js:270 msgid "Edit" msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:54 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:56 msgid "Edit device" msgstr "" -#: htdocs/luci-static/resources/device-manager/service.js:85 -#: htdocs/luci-static/resources/device-manager/service.js:91 -#: htdocs/luci-static/resources/device-manager/service.js:166 +#: htdocs/luci-static/resources/device-manager/service.js:90 +#: htdocs/luci-static/resources/device-manager/service.js:96 +#: htdocs/luci-static/resources/device-manager/service.js:177 msgid "Enter a valid MAC address" msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:171 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:173 msgid "Enter a valid MAC address (e.g. AA:BB:CC:11:22:33)" msgstr "" -#: htdocs/luci-static/resources/device-manager/group-dialog.js:62 -#: htdocs/luci-static/resources/device-manager/group-dialog.js:168 -#: htdocs/luci-static/resources/device-manager/service.js:65 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:64 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:170 +#: htdocs/luci-static/resources/device-manager/service.js:70 msgid "Enter a valid group name" msgstr "" @@ -282,71 +287,71 @@ msgstr "" msgid "Grant access to LAN device manager" msgstr "" -#: htdocs/luci-static/resources/view/device-manager/devices.js:75 +#: htdocs/luci-static/resources/view/device-manager/devices.js:89 msgid "Group" msgstr "" -#: htdocs/luci-static/resources/device-manager/group-dialog.js:82 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:84 msgid "Group \"%s\" created." msgstr "" -#: htdocs/luci-static/resources/device-manager/group-dialog.js:222 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:224 msgid "Group \"%s\" deleted. Its devices are now ungrouped." msgstr "" -#: htdocs/luci-static/resources/device-manager/group-dialog.js:104 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:106 msgid "Group ID" msgstr "" -#: htdocs/luci-static/resources/device-manager/group-dialog.js:103 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:105 msgid "Group name" msgstr "" -#: htdocs/luci-static/resources/device-manager/group-dialog.js:115 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:117 msgid "Group names support Unicode text and common symbols" msgstr "" -#: htdocs/luci-static/resources/device-manager/group-dialog.js:184 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:186 msgid "Group renamed to \"%s\"." msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:75 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:77 msgid "Hardware address; colon or hyphen separators are accepted" msgstr "" -#: htdocs/luci-static/resources/view/device-manager/devices.js:141 -#: htdocs/luci-static/resources/view/device-manager/devices.js:221 +#: htdocs/luci-static/resources/view/device-manager/devices.js:156 +#: htdocs/luci-static/resources/view/device-manager/devices.js:265 msgid "Hide info" msgstr "" -#: htdocs/luci-static/resources/view/device-manager/devices.js:135 +#: htdocs/luci-static/resources/view/device-manager/devices.js:150 msgid "Hide/Show MAC and IPv6" msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:308 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:315 msgid "Host hints" msgstr "" -#: htdocs/luci-static/resources/view/device-manager/devices.js:73 +#: htdocs/luci-static/resources/view/device-manager/devices.js:87 msgid "IP address" msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:321 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:328 msgid "IPv4 addresses" msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:321 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:328 msgid "IPv6 addresses" msgstr "" -#: htdocs/luci-static/resources/device-manager/service.js:59 +#: htdocs/luci-static/resources/device-manager/service.js:64 msgid "Input is too long" msgstr "" -#: htdocs/luci-static/resources/device-manager/service.js:21 -#: htdocs/luci-static/resources/device-manager/service.js:42 -#: htdocs/luci-static/resources/device-manager/service.js:100 -#: htdocs/luci-static/resources/device-manager/service.js:119 +#: htdocs/luci-static/resources/device-manager/service.js:24 +#: htdocs/luci-static/resources/device-manager/service.js:47 +#: htdocs/luci-static/resources/device-manager/service.js:105 +#: htdocs/luci-static/resources/device-manager/service.js:124 msgid "Invalid discovery response" msgstr "" @@ -354,11 +359,11 @@ msgstr "" msgid "Known device without recent activity evidence" msgstr "" -#: htdocs/luci-static/resources/view/device-manager/devices.js:87 +#: htdocs/luci-static/resources/view/device-manager/devices.js:101 msgid "LAN Device Management" msgstr "" -#: htdocs/luci-static/resources/device-manager/service.js:80 +#: htdocs/luci-static/resources/device-manager/service.js:85 msgid "" "LAN scanning is unavailable. Check the selected interfaces and probe " "dependencies." @@ -368,22 +373,26 @@ msgstr "" msgid "Laptop" msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:146 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:148 msgid "Leave empty to display the default hostname" msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:70 -#: htdocs/luci-static/resources/device-manager/device-dialog.js:219 -#: htdocs/luci-static/resources/device-manager/device-dialog.js:319 -#: htdocs/luci-static/resources/view/device-manager/devices.js:74 +#: htdocs/luci-static/resources/device-manager/table.js:138 +msgid "Loading list..." +msgstr "" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:72 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:221 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:326 +#: htdocs/luci-static/resources/view/device-manager/devices.js:88 msgid "MAC address" msgstr "" -#: htdocs/luci-static/resources/view/device-manager/devices.js:126 +#: htdocs/luci-static/resources/view/device-manager/devices.js:141 msgid "Manage groups" msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:323 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:330 msgid "Manual" msgstr "" @@ -391,11 +400,11 @@ msgstr "" msgid "NAS" msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:328 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:335 msgid "Neighbor records" msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:309 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:316 msgid "Neighbor table" msgstr "" @@ -407,7 +416,7 @@ msgstr "" msgid "Network devices" msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:322 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:329 msgid "Network interfaces" msgstr "" @@ -415,54 +424,54 @@ msgstr "" msgid "Network neighbor probe failed (FAILED)" msgstr "" -#: htdocs/luci-static/resources/device-manager/group-dialog.js:111 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:113 msgid "New group" msgstr "" -#: htdocs/luci-static/resources/device-manager/group-dialog.js:152 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:154 msgid "New group name" msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:324 -#: htdocs/luci-static/resources/device-manager/device-dialog.js:325 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:331 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:332 msgid "No" msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:290 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:297 msgid "" "No ICMP reply was received. The device may block Ping; this does not confirm " "it is offline." msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:287 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:294 msgid "No Ping reply" msgstr "" -#: htdocs/luci-static/resources/device-manager/table.js:136 +#: htdocs/luci-static/resources/device-manager/table.js:149 msgid "No devices found. Refresh the list to try again." msgstr "" -#: htdocs/luci-static/resources/device-manager/table.js:146 +#: htdocs/luci-static/resources/device-manager/table.js:159 msgid "No devices in this group" msgstr "" -#: htdocs/luci-static/resources/device-manager/table.js:138 +#: htdocs/luci-static/resources/device-manager/table.js:151 msgid "No devices match \"%s\"" msgstr "" -#: htdocs/luci-static/resources/device-manager/table.js:144 +#: htdocs/luci-static/resources/device-manager/table.js:157 msgid "No devices with unknown status" msgstr "" -#: htdocs/luci-static/resources/device-manager/service.js:88 +#: htdocs/luci-static/resources/device-manager/service.js:93 msgid "No known IP address for this device" msgstr "" -#: htdocs/luci-static/resources/device-manager/table.js:142 +#: htdocs/luci-static/resources/device-manager/table.js:155 msgid "No offline devices" msgstr "" -#: htdocs/luci-static/resources/device-manager/table.js:140 +#: htdocs/luci-static/resources/device-manager/table.js:153 msgid "No online devices" msgstr "" @@ -470,12 +479,12 @@ msgstr "" msgid "No recent activity confirmed (%s)" msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:47 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:49 msgid "Not detected" msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:8 -#: htdocs/luci-static/resources/device-manager/table.js:190 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:10 +#: htdocs/luci-static/resources/device-manager/table.js:203 msgid "Offline" msgstr "" @@ -483,8 +492,8 @@ msgstr "" msgid "Offline devices" msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:8 -#: htdocs/luci-static/resources/device-manager/table.js:187 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:10 +#: htdocs/luci-static/resources/device-manager/table.js:200 msgid "Online" msgstr "" @@ -492,11 +501,12 @@ msgstr "" msgid "Online devices" msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:287 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:294 +#: htdocs/luci-static/resources/view/device-manager/devices.js:229 msgid "Online · Ping replied" msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:139 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:141 msgid "Organize this device by purpose or location" msgstr "" @@ -512,19 +522,19 @@ msgstr "" msgid "Phones" msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:306 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:313 msgid "Ping again" msgstr "" -#: htdocs/luci-static/resources/device-manager/service.js:95 +#: htdocs/luci-static/resources/device-manager/service.js:100 msgid "Ping could not be completed" msgstr "" -#: htdocs/luci-static/resources/device-manager/service.js:90 +#: htdocs/luci-static/resources/device-manager/service.js:95 msgid "Ping is unavailable" msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:274 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:277 msgid "Ping output" msgstr "" @@ -532,33 +542,33 @@ msgstr "" msgid "Printer" msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:289 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:296 msgid "Probe address: %s" msgstr "" -#: htdocs/luci-static/resources/view/device-manager/devices.js:159 +#: htdocs/luci-static/resources/view/device-manager/devices.js:174 msgid "Probe devices on the configured LAN interfaces" msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:294 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:301 msgid "Probe unavailable" msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:270 -#: htdocs/luci-static/resources/device-manager/device-dialog.js:283 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:273 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:286 msgid "Probing…" msgstr "" -#: htdocs/luci-static/resources/device-manager/service.js:77 -#: htdocs/luci-static/resources/device-manager/service.js:149 +#: htdocs/luci-static/resources/device-manager/service.js:82 +#: htdocs/luci-static/resources/device-manager/service.js:160 msgid "Read-only access" msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:153 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:155 msgid "Record the location, purpose or owner of this device" msgstr "" -#: htdocs/luci-static/resources/view/device-manager/devices.js:154 +#: htdocs/luci-static/resources/view/device-manager/devices.js:169 msgid "Refresh list" msgstr "" @@ -566,43 +576,47 @@ msgstr "" msgid "Refrigerator" msgstr "" -#: htdocs/luci-static/resources/view/device-manager/devices.js:77 +#: htdocs/luci-static/resources/view/device-manager/devices.js:91 msgid "Remarks" msgstr "" -#: htdocs/luci-static/resources/device-manager/group-dialog.js:28 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:30 msgid "Rename" msgstr "" -#: htdocs/luci-static/resources/device-manager/group-dialog.js:190 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:192 msgid "Rename failed: %s" msgstr "" -#: htdocs/luci-static/resources/device-manager/group-dialog.js:144 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:146 msgid "Rename group" msgstr "" -#: htdocs/luci-static/resources/device-manager/group-dialog.js:155 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:157 msgid "Renaming preserves the group ID and all device assignments" msgstr "" +#: htdocs/luci-static/resources/device-manager/table.js:142 +msgid "Retry" +msgstr "" + #: htdocs/luci-static/resources/device-manager/model.js:29 msgid "Router" msgstr "" -#: htdocs/luci-static/resources/view/device-manager/devices.js:68 +#: htdocs/luci-static/resources/view/device-manager/devices.js:82 msgid "Row number" msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:201 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:203 msgid "Save" msgstr "" -#: htdocs/luci-static/resources/device-manager/group-dialog.js:198 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:200 msgid "Save changes" msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:193 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:195 msgid "Save failed: %s" msgstr "" @@ -610,23 +624,23 @@ msgstr "" msgid "Saved device not found in the current network" msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:324 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:331 msgid "Saved record" msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:250 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:252 msgid "Saved record for \"%s\" cleared." msgstr "" -#: htdocs/luci-static/resources/view/device-manager/devices.js:169 +#: htdocs/luci-static/resources/view/device-manager/devices.js:184 msgid "Scan LAN" msgstr "" -#: htdocs/luci-static/resources/view/device-manager/devices.js:102 +#: htdocs/luci-static/resources/view/device-manager/devices.js:117 msgid "Search names, IP, MAC, remarks or groups..." msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:284 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:287 msgid "Sending one Ping from the router to this device." msgstr "" @@ -638,11 +652,15 @@ msgstr "" msgid "Server" msgstr "" -#: htdocs/luci-static/resources/view/device-manager/devices.js:141 -#: htdocs/luci-static/resources/view/device-manager/devices.js:221 +#: htdocs/luci-static/resources/view/device-manager/devices.js:156 +#: htdocs/luci-static/resources/view/device-manager/devices.js:265 msgid "Show info" msgstr "" +#: htdocs/luci-static/resources/view/device-manager/devices.js:271 +msgid "Showing cached device data. Refresh to retry." +msgstr "" + #: htdocs/luci-static/resources/device-manager/model.js:10 #: htdocs/luci-static/resources/device-manager/model.js:36 msgid "Smart home" @@ -656,7 +674,7 @@ msgstr "" msgid "Smart plug" msgstr "" -#: htdocs/luci-static/resources/view/device-manager/devices.js:226 +#: htdocs/luci-static/resources/device-manager/feedback.js:29 msgid "" "Some device data could not be read. Unconfirmed devices are shown as " "unknown: %s" @@ -670,7 +688,7 @@ msgstr "" msgid "Speaker" msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:327 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:334 msgid "Status evidence" msgstr "" @@ -678,8 +696,8 @@ msgstr "" msgid "Switch" msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:219 -#: htdocs/luci-static/resources/device-manager/device-dialog.js:319 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:221 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:326 msgid "System hostname" msgstr "" @@ -695,47 +713,47 @@ msgstr "" msgid "Tablet" msgstr "" -#: htdocs/luci-static/resources/device-manager/group-dialog.js:209 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:211 msgid "" "The %d devices in this group will become ungrouped. Their names, MAC " "addresses and remarks will be preserved." msgstr "" -#: htdocs/luci-static/resources/device-manager/service.js:72 +#: htdocs/luci-static/resources/device-manager/service.js:77 msgid "This group no longer exists" msgstr "" -#: htdocs/luci-static/resources/view/device-manager/devices.js:71 +#: htdocs/luci-static/resources/view/device-manager/devices.js:85 msgid "Type" msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:323 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:330 msgid "Type detection" msgstr "" -#: htdocs/luci-static/resources/device-manager/service.js:92 +#: htdocs/luci-static/resources/device-manager/service.js:97 msgid "Unable to read the kernel neighbor table" msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:48 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:50 msgid "Unavailable" msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:82 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:84 #: htdocs/luci-static/resources/device-manager/model.js:130 #: htdocs/luci-static/resources/device-manager/table.js:82 msgid "Ungrouped" msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:8 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:10 #: htdocs/luci-static/resources/device-manager/model.js:46 #: htdocs/luci-static/resources/device-manager/table.js:26 -#: htdocs/luci-static/resources/device-manager/table.js:183 +#: htdocs/luci-static/resources/device-manager/table.js:196 msgid "Unknown" msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:24 -#: htdocs/luci-static/resources/device-manager/table.js:179 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:26 +#: htdocs/luci-static/resources/device-manager/table.js:192 msgid "Unknown device" msgstr "" @@ -747,7 +765,7 @@ msgstr "" msgid "Vehicle" msgstr "" -#: htdocs/luci-static/resources/device-manager/table.js:256 +#: htdocs/luci-static/resources/device-manager/table.js:269 msgid "View" msgstr "" @@ -763,27 +781,27 @@ msgstr "" msgid "Water purifier" msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:308 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:315 msgid "Wi-Fi" msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:324 -#: htdocs/luci-static/resources/device-manager/device-dialog.js:325 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:331 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:332 msgid "Yes" msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:64 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:66 msgid "e.g. AA:BB:CC:11:22:33" msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:113 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:115 msgid "e.g. Living room TV" msgstr "" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:105 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:107 msgid "e.g. Living room TV, work computer" msgstr "" -#: htdocs/luci-static/resources/device-manager/group-dialog.js:46 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:48 msgid "e.g. Media devices, office devices" msgstr "" diff --git a/luci-app-device-manager/po/zh_Hans/device-manager.po b/luci-app-device-manager/po/zh_Hans/device-manager.po index de624b024..dd94d63ad 100644 --- a/luci-app-device-manager/po/zh_Hans/device-manager.po +++ b/luci-app-device-manager/po/zh_Hans/device-manager.po @@ -9,28 +9,28 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -#: htdocs/luci-static/resources/view/device-manager/devices.js:69 +#: htdocs/luci-static/resources/view/device-manager/devices.js:83 msgid "#" msgstr "序号" -#: htdocs/luci-static/resources/device-manager/group-dialog.js:38 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:40 msgid "%d devices" msgstr "%d 台设备" -#: htdocs/luci-static/resources/device-manager/service.js:158 +#: htdocs/luci-static/resources/device-manager/service.js:169 msgid "%s; failed to discard pending changes: %s" msgstr "%s;清理待提交配置失败:%s" -#: htdocs/luci-static/resources/device-manager/service.js:172 +#: htdocs/luci-static/resources/device-manager/service.js:183 msgid "A conflicting configuration record exists" msgstr "存在冲突的配置记录" -#: htdocs/luci-static/resources/device-manager/group-dialog.js:70 -#: htdocs/luci-static/resources/device-manager/service.js:67 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:72 +#: htdocs/luci-static/resources/device-manager/service.js:72 msgid "A group with this name already exists" msgstr "已存在同名分组,请使用其他名称" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:309 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:316 msgid "ARP" msgstr "ARP 表" @@ -42,8 +42,8 @@ msgstr "存在 ARP 记录,缺少近期活动证据" msgid "Access point" msgstr "无线接入点" -#: htdocs/luci-static/resources/device-manager/group-dialog.js:106 -#: htdocs/luci-static/resources/view/device-manager/devices.js:80 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:108 +#: htdocs/luci-static/resources/view/device-manager/devices.js:94 msgid "Actions" msgstr "操作" @@ -55,12 +55,12 @@ msgstr "Wi-Fi 活跃连接" msgid "Active network neighbor (%s)" msgstr "网络邻居活跃 (%s)" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:55 -#: htdocs/luci-static/resources/view/device-manager/devices.js:131 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:57 +#: htdocs/luci-static/resources/view/device-manager/devices.js:146 msgid "Add device" msgstr "手动添加设备" -#: htdocs/luci-static/resources/device-manager/group-dialog.js:96 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:98 msgid "Add group" msgstr "添加分组" @@ -80,19 +80,19 @@ msgstr "全部设备" msgid "All groups" msgstr "全部分组" -#: htdocs/luci-static/resources/device-manager/service.js:89 +#: htdocs/luci-static/resources/device-manager/service.js:94 msgid "Another device is being probed. Please retry." msgstr "另一个设备正在探测,请稍后重试。" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:93 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:95 msgid "Auto (detect by MAC)" msgstr "自动识别" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:323 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:330 msgid "Automatic" msgstr "自动识别" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:131 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:133 msgid "Automatically detected from names and MAC address, or customize manually" msgstr "根据名称和 MAC 地址推测类型,也可手动指定" @@ -100,37 +100,38 @@ msgstr "根据名称和 MAC 地址推测类型,也可手动指定" msgid "Camera" msgstr "摄像头" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:161 -#: htdocs/luci-static/resources/device-manager/device-dialog.js:238 -#: htdocs/luci-static/resources/device-manager/group-dialog.js:160 -#: htdocs/luci-static/resources/device-manager/group-dialog.js:211 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:163 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:240 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:162 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:213 msgid "Cancel" msgstr "取消" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:255 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:257 msgid "Clear failed: %s" msgstr "清空失败:%s" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:212 -#: htdocs/luci-static/resources/device-manager/device-dialog.js:232 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:214 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:234 msgid "Clear record" msgstr "清空记录" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:235 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:237 msgid "Clear the saved record for \"%s\" (%s)?" msgstr "确定清空设备【%s】(%s)的保存记录吗?" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:236 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:238 msgid "Clears the custom name, remarks, group and device type. The device remains discoverable on the network." msgstr "将清空自定义名称、备注、分组和设备类型设置。设备仍可在网络中被发现。" -#: htdocs/luci-static/resources/view/device-manager/devices.js:60 +#: htdocs/luci-static/resources/view/device-manager/devices.js:74 msgid "Click to sort by %s" msgstr "点击按%s排序" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:13 -#: htdocs/luci-static/resources/device-manager/device-dialog.js:331 -#: htdocs/luci-static/resources/device-manager/group-dialog.js:123 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:15 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:338 +#: htdocs/luci-static/resources/device-manager/feedback.js:23 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:125 msgid "Close" msgstr "关闭" @@ -142,64 +143,68 @@ msgstr "台式电脑" msgid "Computers" msgstr "电脑设备" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:261 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:263 msgid "Confirm clear" msgstr "确认清空" -#: htdocs/luci-static/resources/device-manager/group-dialog.js:235 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:237 msgid "Confirm delete" msgstr "确认删除" -#: htdocs/luci-static/resources/device-manager/group-dialog.js:88 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:90 msgid "Could not create group: %s" msgstr "创建分组失败: %s" -#: htdocs/luci-static/resources/device-manager/group-dialog.js:229 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:231 msgid "Could not delete group: %s" msgstr "删除分组失败: %s" -#: htdocs/luci-static/resources/view/device-manager/devices.js:245 +#: htdocs/luci-static/resources/device-manager/table.js:138 +msgid "Could not load the device list." +msgstr "设备列表加载失败。" + +#: htdocs/luci-static/resources/view/device-manager/devices.js:311 msgid "Could not refresh the device list: %s" msgstr "刷新设备列表失败:%s" -#: htdocs/luci-static/resources/view/device-manager/devices.js:234 +#: htdocs/luci-static/resources/view/device-manager/devices.js:279 msgid "Could not scan LAN devices: %s" msgstr "无法扫描局域网设备:%s" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:220 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:222 msgid "Current IP address" msgstr "当前 IP 地址" -#: htdocs/luci-static/resources/device-manager/group-dialog.js:148 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:150 msgid "Current group name" msgstr "原分组名称" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:220 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:222 msgid "Current status" msgstr "当前在线状态" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:143 -#: htdocs/luci-static/resources/device-manager/device-dialog.js:320 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:145 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:327 msgid "Custom name" msgstr "设备显示名称" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:308 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:315 msgid "DHCP" msgstr "DHCP 租约" -#: htdocs/luci-static/resources/device-manager/group-dialog.js:33 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:35 msgid "Delete" msgstr "删除" -#: htdocs/luci-static/resources/device-manager/group-dialog.js:207 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:209 msgid "Delete group" msgstr "确认删除分组" -#: htdocs/luci-static/resources/device-manager/group-dialog.js:208 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:210 msgid "Delete group \"%s\"?" msgstr "确定要删除分组【%s】吗?" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:189 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:191 msgid "Device \"%s\" saved." msgstr "设备【%s】信息已保存并生效!" @@ -207,73 +212,73 @@ msgstr "设备【%s】信息已保存并生效!" msgid "Device Manager" msgstr "设备管理" -#: htdocs/luci-static/resources/device-manager/group-dialog.js:105 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:107 msgid "Device count" msgstr "包含设备数" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:312 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:319 msgid "Device details" msgstr "设备详情" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:136 -#: htdocs/luci-static/resources/device-manager/device-dialog.js:320 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:138 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:327 msgid "Device group" msgstr "所属分组" -#: htdocs/luci-static/resources/device-manager/group-dialog.js:98 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:100 msgid "Device groups" msgstr "设备分组管理" -#: htdocs/luci-static/resources/view/device-manager/devices.js:72 +#: htdocs/luci-static/resources/view/device-manager/devices.js:86 msgid "Device name" msgstr "设备名称" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:150 -#: htdocs/luci-static/resources/device-manager/device-dialog.js:328 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:152 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:335 msgid "Device remarks" msgstr "设备备注" -#: htdocs/luci-static/resources/view/device-manager/devices.js:93 +#: htdocs/luci-static/resources/view/device-manager/devices.js:108 msgid "Device status" msgstr "设备状态" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:127 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:129 msgid "Device type" msgstr "设备类型" -#: htdocs/luci-static/resources/view/device-manager/devices.js:88 +#: htdocs/luci-static/resources/view/device-manager/devices.js:102 msgid "Discover network devices and save custom names, remarks and groups by MAC address." msgstr "自动识别局域网设备在线状态,支持按 MAC 地址自定义显示名称、备注和自定义设备分组,配置永久保存。" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:325 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:332 msgid "Discovered on network" msgstr "网络已发现" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:326 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:333 msgid "Discovery sources" msgstr "发现来源" -#: htdocs/luci-static/resources/device-manager/table.js:257 +#: htdocs/luci-static/resources/device-manager/table.js:270 msgid "Edit" msgstr "编辑" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:54 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:56 msgid "Edit device" msgstr "编辑设备" -#: htdocs/luci-static/resources/device-manager/service.js:85 -#: htdocs/luci-static/resources/device-manager/service.js:91 -#: htdocs/luci-static/resources/device-manager/service.js:166 +#: htdocs/luci-static/resources/device-manager/service.js:90 +#: htdocs/luci-static/resources/device-manager/service.js:96 +#: htdocs/luci-static/resources/device-manager/service.js:177 msgid "Enter a valid MAC address" msgstr "请输入有效的 MAC 地址" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:171 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:173 msgid "Enter a valid MAC address (e.g. AA:BB:CC:11:22:33)" msgstr "请输入有效的 MAC 地址(格式如:AA:BB:CC:11:22:33)" -#: htdocs/luci-static/resources/device-manager/group-dialog.js:62 -#: htdocs/luci-static/resources/device-manager/group-dialog.js:168 -#: htdocs/luci-static/resources/device-manager/service.js:65 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:64 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:170 +#: htdocs/luci-static/resources/device-manager/service.js:70 msgid "Enter a valid group name" msgstr "请输入有效的分组名称" @@ -285,71 +290,71 @@ msgstr "游戏机" msgid "Grant access to LAN device manager" msgstr "允许访问局域网设备管理" -#: htdocs/luci-static/resources/view/device-manager/devices.js:75 +#: htdocs/luci-static/resources/view/device-manager/devices.js:89 msgid "Group" msgstr "分组" -#: htdocs/luci-static/resources/device-manager/group-dialog.js:82 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:84 msgid "Group \"%s\" created." msgstr "成功创建分组【%s】!" -#: htdocs/luci-static/resources/device-manager/group-dialog.js:222 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:224 msgid "Group \"%s\" deleted. Its devices are now ungrouped." msgstr "分组【%s】已删除,所属设备已转为未分组。" -#: htdocs/luci-static/resources/device-manager/group-dialog.js:104 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:106 msgid "Group ID" msgstr "分组标识" -#: htdocs/luci-static/resources/device-manager/group-dialog.js:103 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:105 msgid "Group name" msgstr "分组名称" -#: htdocs/luci-static/resources/device-manager/group-dialog.js:115 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:117 msgid "Group names support Unicode text and common symbols" msgstr "分组名称支持中文、英文及常用符号,稳定保存" -#: htdocs/luci-static/resources/device-manager/group-dialog.js:184 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:186 msgid "Group renamed to \"%s\"." msgstr "分组已成功重命名为【%s】!" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:75 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:77 msgid "Hardware address; colon or hyphen separators are accepted" msgstr "设备的物理硬件地址,支持冒号或短横线分隔(不区分大小写)" -#: htdocs/luci-static/resources/view/device-manager/devices.js:141 -#: htdocs/luci-static/resources/view/device-manager/devices.js:221 +#: htdocs/luci-static/resources/view/device-manager/devices.js:156 +#: htdocs/luci-static/resources/view/device-manager/devices.js:265 msgid "Hide info" msgstr "隐藏信息" -#: htdocs/luci-static/resources/view/device-manager/devices.js:135 +#: htdocs/luci-static/resources/view/device-manager/devices.js:150 msgid "Hide/Show MAC and IPv6" msgstr "隐藏或显示 MAC 和 IPv6 地址" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:308 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:315 msgid "Host hints" msgstr "主机提示" -#: htdocs/luci-static/resources/view/device-manager/devices.js:73 +#: htdocs/luci-static/resources/view/device-manager/devices.js:87 msgid "IP address" msgstr "IP 地址" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:321 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:328 msgid "IPv4 addresses" msgstr "IPv4 地址" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:321 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:328 msgid "IPv6 addresses" msgstr "IPv6 地址" -#: htdocs/luci-static/resources/device-manager/service.js:59 +#: htdocs/luci-static/resources/device-manager/service.js:64 msgid "Input is too long" msgstr "输入内容超出长度限制" -#: htdocs/luci-static/resources/device-manager/service.js:21 -#: htdocs/luci-static/resources/device-manager/service.js:42 -#: htdocs/luci-static/resources/device-manager/service.js:100 -#: htdocs/luci-static/resources/device-manager/service.js:119 +#: htdocs/luci-static/resources/device-manager/service.js:24 +#: htdocs/luci-static/resources/device-manager/service.js:47 +#: htdocs/luci-static/resources/device-manager/service.js:105 +#: htdocs/luci-static/resources/device-manager/service.js:124 msgid "Invalid discovery response" msgstr "设备发现接口返回无效数据" @@ -357,11 +362,11 @@ msgstr "设备发现接口返回无效数据" msgid "Known device without recent activity evidence" msgstr "已知设备,缺少近期活动证据" -#: htdocs/luci-static/resources/view/device-manager/devices.js:87 +#: htdocs/luci-static/resources/view/device-manager/devices.js:101 msgid "LAN Device Management" msgstr "局域网设备管理" -#: htdocs/luci-static/resources/device-manager/service.js:80 +#: htdocs/luci-static/resources/device-manager/service.js:85 msgid "LAN scanning is unavailable. Check the selected interfaces and probe dependencies." msgstr "局域网扫描不可用,请检查所选接口及探测依赖。" @@ -369,22 +374,26 @@ msgstr "局域网扫描不可用,请检查所选接口及探测依赖。" msgid "Laptop" msgstr "笔记本" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:146 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:148 msgid "Leave empty to display the default hostname" msgstr "自定义设备显示名称。若留空则恢复默认主机名" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:70 -#: htdocs/luci-static/resources/device-manager/device-dialog.js:219 -#: htdocs/luci-static/resources/device-manager/device-dialog.js:319 -#: htdocs/luci-static/resources/view/device-manager/devices.js:74 +#: htdocs/luci-static/resources/device-manager/table.js:138 +msgid "Loading list..." +msgstr "正在加载列表……" + +#: htdocs/luci-static/resources/device-manager/device-dialog.js:72 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:221 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:326 +#: htdocs/luci-static/resources/view/device-manager/devices.js:88 msgid "MAC address" msgstr "MAC 地址" -#: htdocs/luci-static/resources/view/device-manager/devices.js:126 +#: htdocs/luci-static/resources/view/device-manager/devices.js:141 msgid "Manage groups" msgstr "分组管理" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:323 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:330 msgid "Manual" msgstr "手动指定" @@ -392,11 +401,11 @@ msgstr "手动指定" msgid "NAS" msgstr "网络存储" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:328 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:335 msgid "Neighbor records" msgstr "邻居记录" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:309 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:316 msgid "Neighbor table" msgstr "邻居表" @@ -408,7 +417,7 @@ msgstr "网络设备" msgid "Network devices" msgstr "网络设备" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:322 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:329 msgid "Network interfaces" msgstr "网络接口" @@ -416,52 +425,52 @@ msgstr "网络接口" msgid "Network neighbor probe failed (FAILED)" msgstr "网络邻居探测失败 (FAILED)" -#: htdocs/luci-static/resources/device-manager/group-dialog.js:111 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:113 msgid "New group" msgstr "新增分组" -#: htdocs/luci-static/resources/device-manager/group-dialog.js:152 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:154 msgid "New group name" msgstr "新分组名称" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:324 -#: htdocs/luci-static/resources/device-manager/device-dialog.js:325 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:331 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:332 msgid "No" msgstr "否" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:290 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:297 msgid "No ICMP reply was received. The device may block Ping; this does not confirm it is offline." msgstr "未收到 ICMP 回复。设备可能禁用 Ping,不能据此确认设备已离线。" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:287 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:294 msgid "No Ping reply" msgstr "Ping 未响应" -#: htdocs/luci-static/resources/device-manager/table.js:136 +#: htdocs/luci-static/resources/device-manager/table.js:149 msgid "No devices found. Refresh the list to try again." msgstr "暂无局域网设备数据,点击右上角【刷新】重试或【+ 手动添加设备】" -#: htdocs/luci-static/resources/device-manager/table.js:146 +#: htdocs/luci-static/resources/device-manager/table.js:159 msgid "No devices in this group" msgstr "当前分组下暂无设备记录" -#: htdocs/luci-static/resources/device-manager/table.js:138 +#: htdocs/luci-static/resources/device-manager/table.js:151 msgid "No devices match \"%s\"" msgstr "未找到与 “%s” 匹配的设备记录" -#: htdocs/luci-static/resources/device-manager/table.js:144 +#: htdocs/luci-static/resources/device-manager/table.js:157 msgid "No devices with unknown status" msgstr "当前没有状态未知的设备" -#: htdocs/luci-static/resources/device-manager/service.js:88 +#: htdocs/luci-static/resources/device-manager/service.js:93 msgid "No known IP address for this device" msgstr "暂无该设备的已知 IP 地址,无法 Ping。" -#: htdocs/luci-static/resources/device-manager/table.js:142 +#: htdocs/luci-static/resources/device-manager/table.js:155 msgid "No offline devices" msgstr "当前没有离线设备" -#: htdocs/luci-static/resources/device-manager/table.js:140 +#: htdocs/luci-static/resources/device-manager/table.js:153 msgid "No online devices" msgstr "当前没有在线设备" @@ -469,12 +478,12 @@ msgstr "当前没有在线设备" msgid "No recent activity confirmed (%s)" msgstr "无法确认近期活动 (%s)" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:47 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:49 msgid "Not detected" msgstr "未检测到" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:8 -#: htdocs/luci-static/resources/device-manager/table.js:190 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:10 +#: htdocs/luci-static/resources/device-manager/table.js:203 msgid "Offline" msgstr "离线" @@ -482,8 +491,8 @@ msgstr "离线" msgid "Offline devices" msgstr "离线设备" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:8 -#: htdocs/luci-static/resources/device-manager/table.js:187 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:10 +#: htdocs/luci-static/resources/device-manager/table.js:200 msgid "Online" msgstr "在线" @@ -491,11 +500,12 @@ msgstr "在线" msgid "Online devices" msgstr "在线设备" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:287 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:294 +#: htdocs/luci-static/resources/view/device-manager/devices.js:229 msgid "Online · Ping replied" msgstr "在线 · Ping 已响应" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:139 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:141 msgid "Organize this device by purpose or location" msgstr "为设备指定业务或摆放区域分类" @@ -511,19 +521,19 @@ msgstr "手机" msgid "Phones" msgstr "手机设备" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:306 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:313 msgid "Ping again" msgstr "重新 Ping" -#: htdocs/luci-static/resources/device-manager/service.js:95 +#: htdocs/luci-static/resources/device-manager/service.js:100 msgid "Ping could not be completed" msgstr "Ping 执行失败。" -#: htdocs/luci-static/resources/device-manager/service.js:90 +#: htdocs/luci-static/resources/device-manager/service.js:95 msgid "Ping is unavailable" msgstr "路由器未提供 Ping 命令。" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:274 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:277 msgid "Ping output" msgstr "查看 Ping 输出" @@ -531,33 +541,33 @@ msgstr "查看 Ping 输出" msgid "Printer" msgstr "打印机" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:289 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:296 msgid "Probe address: %s" msgstr "探测地址:%s" -#: htdocs/luci-static/resources/view/device-manager/devices.js:159 +#: htdocs/luci-static/resources/view/device-manager/devices.js:174 msgid "Probe devices on the configured LAN interfaces" msgstr "探测所配置的局域网接口中的设备" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:294 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:301 msgid "Probe unavailable" msgstr "无法完成探测" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:270 -#: htdocs/luci-static/resources/device-manager/device-dialog.js:283 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:273 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:286 msgid "Probing…" msgstr "正在探测…" -#: htdocs/luci-static/resources/device-manager/service.js:77 -#: htdocs/luci-static/resources/device-manager/service.js:149 +#: htdocs/luci-static/resources/device-manager/service.js:82 +#: htdocs/luci-static/resources/device-manager/service.js:160 msgid "Read-only access" msgstr "仅有只读权限" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:153 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:155 msgid "Record the location, purpose or owner of this device" msgstr "记录设备位置、用途、所有者等备注信息" -#: htdocs/luci-static/resources/view/device-manager/devices.js:154 +#: htdocs/luci-static/resources/view/device-manager/devices.js:169 msgid "Refresh list" msgstr "刷新列表" @@ -565,43 +575,47 @@ msgstr "刷新列表" msgid "Refrigerator" msgstr "冰箱" -#: htdocs/luci-static/resources/view/device-manager/devices.js:77 +#: htdocs/luci-static/resources/view/device-manager/devices.js:91 msgid "Remarks" msgstr "备注" -#: htdocs/luci-static/resources/device-manager/group-dialog.js:28 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:30 msgid "Rename" msgstr "重命名" -#: htdocs/luci-static/resources/device-manager/group-dialog.js:190 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:192 msgid "Rename failed: %s" msgstr "重命名失败: %s" -#: htdocs/luci-static/resources/device-manager/group-dialog.js:144 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:146 msgid "Rename group" msgstr "重命名分组" -#: htdocs/luci-static/resources/device-manager/group-dialog.js:155 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:157 msgid "Renaming preserves the group ID and all device assignments" msgstr "重命名不会改变分组 ID,已关联该分组的设备保持关联不变" +#: htdocs/luci-static/resources/device-manager/table.js:142 +msgid "Retry" +msgstr "重试" + #: htdocs/luci-static/resources/device-manager/model.js:29 msgid "Router" msgstr "路由器" -#: htdocs/luci-static/resources/view/device-manager/devices.js:68 +#: htdocs/luci-static/resources/view/device-manager/devices.js:82 msgid "Row number" msgstr "序号" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:201 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:203 msgid "Save" msgstr "保存" -#: htdocs/luci-static/resources/device-manager/group-dialog.js:198 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:200 msgid "Save changes" msgstr "保存修改" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:193 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:195 msgid "Save failed: %s" msgstr "保存失败: %s" @@ -609,23 +623,23 @@ msgstr "保存失败: %s" msgid "Saved device not found in the current network" msgstr "未在当前网络发现(已保存记录)" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:324 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:331 msgid "Saved record" msgstr "已保存记录" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:250 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:252 msgid "Saved record for \"%s\" cleared." msgstr "设备【%s】的保存记录已清空。" -#: htdocs/luci-static/resources/view/device-manager/devices.js:169 +#: htdocs/luci-static/resources/view/device-manager/devices.js:184 msgid "Scan LAN" msgstr "扫描局域网" -#: htdocs/luci-static/resources/view/device-manager/devices.js:102 +#: htdocs/luci-static/resources/view/device-manager/devices.js:117 msgid "Search names, IP, MAC, remarks or groups..." msgstr "搜索设备名称、主机名、IP、MAC、备注、分组..." -#: htdocs/luci-static/resources/device-manager/device-dialog.js:284 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:287 msgid "Sending one Ping from the router to this device." msgstr "正在从路由器向该设备发送一次 Ping。" @@ -637,11 +651,15 @@ msgstr "传感器" msgid "Server" msgstr "服务器" -#: htdocs/luci-static/resources/view/device-manager/devices.js:141 -#: htdocs/luci-static/resources/view/device-manager/devices.js:221 +#: htdocs/luci-static/resources/view/device-manager/devices.js:156 +#: htdocs/luci-static/resources/view/device-manager/devices.js:265 msgid "Show info" msgstr "显示信息" +#: htdocs/luci-static/resources/view/device-manager/devices.js:271 +msgid "Showing cached device data. Refresh to retry." +msgstr "当前显示缓存数据,刷新未成功,请重试。" + #: htdocs/luci-static/resources/device-manager/model.js:10 #: htdocs/luci-static/resources/device-manager/model.js:36 msgid "Smart home" @@ -655,7 +673,7 @@ msgstr "智能灯" msgid "Smart plug" msgstr "智能插座" -#: htdocs/luci-static/resources/view/device-manager/devices.js:226 +#: htdocs/luci-static/resources/device-manager/feedback.js:29 msgid "Some device data could not be read. Unconfirmed devices are shown as unknown: %s" msgstr "部分设备数据读取失败,无法确认的设备显示为状态未知:%s" @@ -667,7 +685,7 @@ msgstr "部分设备发现数据源不可用" msgid "Speaker" msgstr "音箱" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:327 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:334 msgid "Status evidence" msgstr "状态依据" @@ -675,8 +693,8 @@ msgstr "状态依据" msgid "Switch" msgstr "交换机" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:219 -#: htdocs/luci-static/resources/device-manager/device-dialog.js:319 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:221 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:326 msgid "System hostname" msgstr "系统主机名" @@ -692,45 +710,45 @@ msgstr "电视盒子" msgid "Tablet" msgstr "平板" -#: htdocs/luci-static/resources/device-manager/group-dialog.js:209 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:211 msgid "The %d devices in this group will become ungrouped. Their names, MAC addresses and remarks will be preserved." msgstr "删除分组后,该组下的 %d 台设备将自动转为【未分组】。设备名称、MAC 及备注将被完整保留,绝不删除设备。" -#: htdocs/luci-static/resources/device-manager/service.js:72 +#: htdocs/luci-static/resources/device-manager/service.js:77 msgid "This group no longer exists" msgstr "该分组已不存在" -#: htdocs/luci-static/resources/view/device-manager/devices.js:71 +#: htdocs/luci-static/resources/view/device-manager/devices.js:85 msgid "Type" msgstr "类型" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:323 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:330 msgid "Type detection" msgstr "类型识别" -#: htdocs/luci-static/resources/device-manager/service.js:92 +#: htdocs/luci-static/resources/device-manager/service.js:97 msgid "Unable to read the kernel neighbor table" msgstr "无法读取内核邻居表。" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:48 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:50 msgid "Unavailable" msgstr "未获取" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:82 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:84 #: htdocs/luci-static/resources/device-manager/model.js:130 #: htdocs/luci-static/resources/device-manager/table.js:82 msgid "Ungrouped" msgstr "未分组" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:8 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:10 #: htdocs/luci-static/resources/device-manager/model.js:46 #: htdocs/luci-static/resources/device-manager/table.js:26 -#: htdocs/luci-static/resources/device-manager/table.js:183 +#: htdocs/luci-static/resources/device-manager/table.js:196 msgid "Unknown" msgstr "状态未知" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:24 -#: htdocs/luci-static/resources/device-manager/table.js:179 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:26 +#: htdocs/luci-static/resources/device-manager/table.js:192 msgid "Unknown device" msgstr "未知设备" @@ -742,7 +760,7 @@ msgstr "VR 设备" msgid "Vehicle" msgstr "汽车" -#: htdocs/luci-static/resources/device-manager/table.js:256 +#: htdocs/luci-static/resources/device-manager/table.js:269 msgid "View" msgstr "查看" @@ -758,27 +776,31 @@ msgstr "手表" msgid "Water purifier" msgstr "净水器" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:308 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:315 msgid "Wi-Fi" msgstr "Wi-Fi 连接" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:324 -#: htdocs/luci-static/resources/device-manager/device-dialog.js:325 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:331 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:332 msgid "Yes" msgstr "是" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:64 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:66 msgid "e.g. AA:BB:CC:11:22:33" msgstr "例如:AA:BB:CC:11:22:33" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:113 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:115 msgid "e.g. Living room TV" msgstr "例如:55寸安卓电视,安装在客厅" -#: htdocs/luci-static/resources/device-manager/device-dialog.js:105 +#: htdocs/luci-static/resources/device-manager/device-dialog.js:107 msgid "e.g. Living room TV, work computer" msgstr "例如:客厅电视、主力工作电脑" -#: htdocs/luci-static/resources/device-manager/group-dialog.js:46 +#: htdocs/luci-static/resources/device-manager/group-dialog.js:48 msgid "e.g. Media devices, office devices" msgstr "例如:智能影音、办公设备" + + +#~ msgid "Showing cached device data. Updating..." +#~ msgstr "当前显示缓存数据,正在更新……" diff --git a/luci-app-device-manager/root/usr/share/rpcd/acl.d/luci-app-device-manager.json b/luci-app-device-manager/root/usr/share/rpcd/acl.d/luci-app-device-manager.json index a0898911d..9234aa5b6 100644 --- a/luci-app-device-manager/root/usr/share/rpcd/acl.d/luci-app-device-manager.json +++ b/luci-app-device-manager/root/usr/share/rpcd/acl.d/luci-app-device-manager.json @@ -3,7 +3,7 @@ "description": "Grant access to LAN device manager", "read": { "ubus": { - "luci-rpc": [ "getHostHints", "getDHCPLeases", "getWirelessDevices" ], + "luci-rpc": [ "getHostHints", "getDHCPLeases", "getWirelessDevices", "getNetworkDevices" ], "luci.device-manager": [ "get_online_status", "ping_device" ], "iwinfo": [ "assoclist" ], "file": [ "read" ] diff --git a/luci-app-device-manager/test/helpers/runtime.js b/luci-app-device-manager/test/helpers/runtime.js index 87672ada7..9efb6a216 100644 --- a/luci-app-device-manager/test/helpers/runtime.js +++ b/luci-app-device-manager/test/helpers/runtime.js @@ -45,6 +45,7 @@ class Node extends EventTarget { } querySelector(selector) { return this.querySelectorAll(selector)[0] || null; } focus() { this.focused = true; } + remove() { this.removed = true; } getAttribute(name) { return this.attrs[name]; } click() { if (this.disabled) return Promise.resolve(); @@ -55,6 +56,8 @@ class Node extends EventTarget { function environment(extra = {}) { const htmlSinks = [], notifications = []; + const timeouts = new Map(), session = new Map(); + let nextTimeout = 1; const E = (tag, attrs, children) => { if (!attrs || typeof attrs !== 'object' || Array.isArray(attrs)) { children = attrs; attrs = {}; } const node = new Node(tag, attrs); @@ -76,14 +79,23 @@ function environment(extra = {}) { modal: null, showModal(title, children) { this.modal = E('modal', { title }, children); }, hideModal() { ui.modal = null; }, - addNotification(title, content, level) { notifications.push({ content, level }); } + addNotification(title, content, level) { + const node = new Node('notification'); + notifications.push({ content, level, node }); + return node; + } }; return Object.assign({ baseclass: { extend: value => value }, view: { extend: value => value }, E, dom, ui, rpc: { declare: () => () => Promise.resolve({ language: 'auto' }) }, - _: value => value, htmlSinks, notifications, + _: value => value, htmlSinks, notifications, timeouts, + setTimeout: (callback, delay) => { const id = nextTimeout++; timeouts.set(id, { callback, delay }); return id; }, + clearTimeout: id => timeouts.delete(id), L: { hasViewPermission: () => true, resource: value => '/resources/' + value }, - window: { localStorage: { getItem: () => null, setItem() {} } } + window: { + localStorage: { getItem: () => null, setItem() {} }, + sessionStorage: { getItem: key => session.get(key) || null, setItem: (key, value) => session.set(key, value), removeItem: key => session.delete(key) } + } }, extra); } @@ -125,12 +137,13 @@ function backend(initial = []) { 'luci-rpc.getHostHints': {}, 'luci-rpc.getDHCPLeases': { dhcp_leases: [], dhcp6_leases: [] }, 'luci-rpc.getWirelessDevices': {}, + 'luci-rpc.getNetworkDevices': {}, 'luci.device-manager.get_online_status': { ok: true, neighbors: [] }, 'luci.device-manager.scan_devices': { ok: true }, 'luci.device-manager.ping_device': { ok: true, ip: '192.168.1.20', interface: 'br-lan', reachable: true, output: '1 packets transmitted, 1 packets received' }, 'iwinfo.assoclist': { results: [] } }; - const rpc = { declare(options) { + const rpc = { getSessionID: () => state.session || 'test-session', declare(options) { state.declarations.push(options); return (...args) => { const key = options.object + '.' + options.method; @@ -152,4 +165,10 @@ function backend(initial = []) { return { state, uci, rpc, replies, fs: { read: () => Promise.resolve('IP address HW type Flags HW address Mask Device\n') } }; } -module.exports = { root, Node, environment, loadModule, backend }; +async function renderLoadedPage(page) { + const node = page.render(await page.load()); + if (page.initialLoading) await page.refresh(); + return node; +} + +module.exports = { root, Node, environment, loadModule, backend, renderLoadedPage }; diff --git a/luci-app-device-manager/test/test_cache_feedback.js b/luci-app-device-manager/test/test_cache_feedback.js new file mode 100644 index 000000000..3676a0c8f --- /dev/null +++ b/luci-app-device-manager/test/test_cache_feedback.js @@ -0,0 +1,142 @@ +'use strict'; +const assert = require('node:assert/strict'); +const test = require('node:test'); +const { environment, loadModule, backend, renderLoadedPage } = require('./helpers/runtime'); + +const mac = 'AA:BB:CC:11:22:33'; +const saved = { '.type': 'device', '.name': 'saved', mac, name: 'TV' }; +function context() { + const mocks = backend([saved]); + const env = environment(mocks), modules = new Map(); + return Object.assign({ env, load: name => loadModule(name, env, modules) }, mocks); +} +function runTimer(env, delay) { + const timer = Array.from(env.timeouts).find(([, value]) => value.delay === delay); + assert.ok(timer, 'expected scheduled timer'); + env.timeouts.delete(timer[0]); + return timer[1].callback(); +} + +test('reopening uses a snapshot immediately and refreshes device data after rendering', async () => { + const ctx = context(), page = ctx.load('view.device-manager.devices'); + const first = await page.load(); + assert.equal(first.cached, false); + const firstNode = page.render(first); + assert.match(firstNode.querySelector('#device_manager_tbody').textContent, /Loading list/); + await runTimer(ctx.env, 0); + const calls = ctx.state.calls.length; + ctx.state.saved[0].name = 'Updated outside LuCI'; + const cached = await page.load(); + assert.equal(cached.cached, true); + assert.equal(ctx.state.calls.length, calls); + const node = page.render(cached); + assert.equal(page.devices[0].customName, 'TV'); + assert.equal(node.querySelector('#dm-cache-status').textContent, ''); + assert.equal(node.querySelector('#dm-cache-status').style.display, 'none'); + assert.equal(ctx.state.calls.length, calls); + await runTimer(ctx.env, 0); + assert.equal(page.devices[0].customName, 'Updated outside LuCI'); + assert.equal(node.querySelector('#dm-cache-status').style.display, 'none'); + assert.ok(ctx.state.calls.length > calls); +}); + +test('cache expires after five minutes and rejects future timestamps and malformed entries', async () => { + const ctx = context(); + let now = 1000000; + ctx.env.Date = class extends Date { static now() { return now; } }; + const service = ctx.load('device-manager.service'), cache = ctx.load('device-manager.snapshot-cache'); + await service.load(); + now += 299999; + assert.ok(await cache.load()); + now += 1; + assert.equal(await cache.load(), null); + now = 999999; + assert.equal(await cache.load(), null); + for (const raw of ['{broken', '{}', '[]', JSON.stringify({ time: now, snapshot: {} })]) { + ctx.env.window.sessionStorage.getItem = () => raw; + assert.equal(await cache.load(), null); + } +}); + +test('browser storage failure falls back to live loading and never breaks editing', async () => { + const ctx = context(); + ctx.env.window.sessionStorage = { + getItem() { throw Error('storage blocked'); }, + setItem() { throw Error('storage full'); }, + removeItem() { throw Error('storage blocked'); } + }; + const service = ctx.load('device-manager.service'); + assert.equal((await service.loadCached()).cached, false); + await service.saveDevice(mac, 'Changed', '', 'ungrouped'); + assert.equal(ctx.state.saved[0].name, 'Changed'); +}); + +test('snapshots are isolated between LuCI sessions and read/write permissions', async () => { + const ctx = context(), service = ctx.load('device-manager.service'), cache = ctx.load('device-manager.snapshot-cache'); + await service.load(); + assert.ok(await cache.load()); + ctx.state.session = 'another-session'; + assert.equal(await cache.load(), null); + ctx.state.session = 'test-session'; + ctx.env.L.hasViewPermission = () => false; + assert.equal(await cache.load(), null); +}); + +test('manual refresh reads live data and edits or scans invalidate the earlier snapshot', async () => { + const ctx = context(), service = ctx.load('device-manager.service'), cache = ctx.load('device-manager.snapshot-cache'); + await service.load(); + ctx.state.saved[0].name = 'External change'; + assert.equal((await service.load()).devices[0].name, 'External change'); + await service.saveDevice(mac, 'New edit', '', 'ungrouped'); + assert.equal(await cache.load(), null); + await service.load(); + await service.scanDevices(); + assert.equal(await cache.load(), null); +}); + +test('failed background refresh retains cached data and clearly labels it', async () => { + const ctx = context(), page = ctx.load('view.device-manager.devices'); + await renderLoadedPage(page); + const node = page.render(await page.load()); + ctx.uci.load = () => Promise.reject(Error('router unavailable')); + await runTimer(ctx.env, 0); + assert.equal(page.devices[0].customName, 'TV'); + assert.match(node.querySelector('#dm-cache-status').textContent, /cached.*retry/); + assert.match(ctx.env.notifications[0].content.textContent, /router unavailable/); + runTimer(ctx.env, 12000); + assert.equal(ctx.env.notifications[0].node.removed, true); +}); + +test('source errors close manually or after twelve seconds and stay closed during filtering', async () => { + const ctx = context(), page = ctx.load('view.device-manager.devices'); + ctx.replies['luci-rpc.getHostHints'] = Error('hints unavailable'); + const node = await renderLoadedPage(page), warning = node.querySelector('#dm-source-warning'); + assert.equal(warning.style.display, 'flex'); + assert.equal(page.devices[0].status, 'unknown'); + await warning.querySelector('button').click(); + assert.equal(warning.style.display, 'none'); + page.filterText = 'tv'; page.updateView(); + assert.equal(warning.style.display, 'none'); + assert.ok(!Array.from(ctx.env.timeouts.values()).some(timer => timer.delay === 12000)); + page.sourceErrors = ['new failure']; page.updateView(); + assert.equal(warning.style.display, 'flex'); + runTimer(ctx.env, 12000); + assert.equal(warning.style.display, 'none'); + assert.equal(page.devices[0].status, 'unknown'); + assert.deepEqual(page.sourceErrors, ['new failure']); + page.updateView(); + assert.equal(warning.style.display, 'none'); + page.sourceErrors = []; page.updateView(); + page.sourceErrors = ['new failure']; page.updateView(); + assert.equal(warning.style.display, 'flex'); +}); + +test('warning messages render untrusted errors as text', () => { + const ctx = context(), feedback = ctx.load('device-manager.feedback'); + const warning = feedback.createWarning(); + warning.update(['']); + assert.ok(warning.node.textContent.includes('')); + assert.deepEqual(ctx.env.htmlSinks, []); + warning.destroy(); + assert.equal(ctx.env.timeouts.size, 0); +}); diff --git a/luci-app-device-manager/test/test_details.js b/luci-app-device-manager/test/test_details.js index 5bc4c88ff..d4f47f7d1 100644 --- a/luci-app-device-manager/test/test_details.js +++ b/luci-app-device-manager/test/test_details.js @@ -1,7 +1,7 @@ 'use strict'; const assert = require('node:assert/strict'); const test = require('node:test'); -const { environment, loadModule, backend } = require('./helpers/runtime'); +const { environment, loadModule, backend, renderLoadedPage } = require('./helpers/runtime'); const mac = 'AA:BB:CC:11:22:33'; const attack = ''; @@ -20,7 +20,7 @@ async function context() { ] }; const env = environment(mocks); const page = loadModule('view.device-manager.devices', env); - const node = page.render(await page.load()); + const node = await renderLoadedPage(page); return Object.assign({ env, page, node, dev: page.devices[0] }, mocks); } diff --git a/luci-app-device-manager/test/test_device_icons.js b/luci-app-device-manager/test/test_device_icons.js index 1ddf224d4..40867b640 100644 --- a/luci-app-device-manager/test/test_device_icons.js +++ b/luci-app-device-manager/test/test_device_icons.js @@ -3,7 +3,7 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const test = require('node:test'); -const { environment, loadModule, backend, root } = require('./helpers/runtime'); +const { environment, loadModule, backend, root, renderLoadedPage } = require('./helpers/runtime'); const EXPECTED_ICONS = [ 'computer.svg', 'laptop.svg', 'phone.svg', 'tablet.svg', @@ -120,8 +120,7 @@ test('device table renders the device icon after the row number', async () => { [nasMac]: { name: 'Synology-NAS', ipaddrs: ['192.168.1.50'] } }; - const data = await ctx.page.load(); - const viewNode = ctx.page.render(data); + const viewNode = await renderLoadedPage(ctx.page); // Check table thead has type column const table = viewNode.querySelector('#device_manager_table'); @@ -153,8 +152,7 @@ test('custom device type override can be saved and displayed', async () => { ok: true, neighbors: [{ mac: customMac, state: 'REACHABLE' }] }; - const data = await ctx.page.load(); - const viewNode = ctx.page.render(data); + const viewNode = await renderLoadedPage(ctx.page); const row = viewNode.querySelector('#device_manager_tbody').querySelectorAll('tr')[0]; const iconImg = row.querySelectorAll('td')[1].querySelector('img'); diff --git a/luci-app-device-manager/test/test_device_manager.js b/luci-app-device-manager/test/test_device_manager.js index 564b1c97f..641c907e2 100644 --- a/luci-app-device-manager/test/test_device_manager.js +++ b/luci-app-device-manager/test/test_device_manager.js @@ -1,7 +1,7 @@ 'use strict'; const assert = require('node:assert/strict'); const test = require('node:test'); -const { environment, loadModule, backend } = require('./helpers/runtime'); +const { environment, loadModule, backend, renderLoadedPage } = require('./helpers/runtime'); const mac = 'AA:BB:CC:11:22:33', other = 'AA:BB:CC:44:55:66'; const model = loadModule('device-manager.model', environment()); const device = { '.type': 'device', '.name': 'dev_aabbcc112233', mac, name: 'TV', remark: 'Living room' }; @@ -167,6 +167,7 @@ test('discovery errors are reported and do not turn saved devices offline', asyn }); test('partial Wi-Fi RPC failure preserves active stations and VLAN discovery', async () => { const ctx = service([]); + ctx.replies['luci-rpc.getNetworkDevices'] = { wlan0: {}, wlan1: {}, 'wlan0.10': {} }; ctx.replies['luci-rpc.getWirelessDevices'] = { radio0: { interfaces: [ { ifname: 'wlan0', config: { mode: 'ap' }, vlans: [{ ifname: 'wlan0.10' }] }, { ifname: 'wlan1', config: { mode: 'ap' } }, { ifname: 'uplink', config: { mode: 'sta' } } @@ -178,6 +179,50 @@ test('partial Wi-Fi RPC failure preserves active stations and VLAN discovery', a assert.ok(!ctx.state.calls.some(call => call.args[0] === 'uplink')); }); +test('stale wireless status does not query interfaces missing from the kernel', async () => { + const ctx = service([device]); + ctx.replies['luci-rpc.getWirelessDevices'] = { radio0: { up: true, disabled: false, + interfaces: [{ ifname: 'wlan0', config: { mode: 'ap' } }] } }; + ctx.replies['luci-rpc.getNetworkDevices'] = { 'br-lan': {}, eth0: {} }; + ctx.replies['iwinfo.assoclist'] = new Error('ubus code 4: Not found'); + const snapshot = await ctx.service.load(); + assert.equal(snapshot.discoveryComplete, true); + assert.deepEqual(snapshot.errors, []); + assert.deepEqual(snapshot.wifi, []); + assert.equal(model.parseDevices(snapshot, [])[0].status, 'offline'); + assert.ok(!ctx.state.calls.some(call => call.key === 'iwinfo.assoclist')); +}); + +test('only existing AP and VLAN interfaces are queried and each refresh rechecks them', async () => { + const ctx = service([]); + ctx.replies['luci-rpc.getWirelessDevices'] = { radio0: { interfaces: [ + { ifname: 'wlan0', config: { mode: 'ap' }, vlans: [{ ifname: 'wlan0.10' }, { ifname: 'wlan0.20' }] }, + { ifname: 'wlan1', config: { mode: 'ap' } } + ] } }; + ctx.replies['luci-rpc.getNetworkDevices'] = { wlan0: {}, 'wlan0.10': {} }; + ctx.replies['iwinfo.assoclist'] = { results: [{ mac }] }; + const snapshot = await ctx.service.load(); + assert.deepEqual(snapshot.wifi, [{ mac, dev: 'wlan0' }, { mac, dev: 'wlan0.10' }]); + assert.equal(snapshot.discoveryComplete, true); + assert.deepEqual(ctx.state.calls.filter(call => call.key === 'iwinfo.assoclist').map(call => call.args[0]), ['wlan0', 'wlan0.10']); + ctx.replies['luci-rpc.getNetworkDevices'] = {}; + assert.deepEqual((await ctx.service.load()).wifi, []); + ctx.replies['luci-rpc.getNetworkDevices'] = { wlan1: {} }; + assert.deepEqual((await ctx.service.load()).wifi, [{ mac, dev: 'wlan1' }]); +}); + +test('failed or invalid kernel device discovery stays visible and keeps unconfirmed devices unknown', async () => { + for (const response of [new Error('access denied'), 4, null, []]) { + const ctx = service([device]); + ctx.replies['luci-rpc.getNetworkDevices'] = response; + const snapshot = await ctx.service.load(); + assert.equal(snapshot.discoveryComplete, false); + assert.ok(snapshot.errors.length > 0); + assert.equal(model.parseDevices(snapshot, [])[0].status, 'unknown'); + assert.ok(!ctx.state.calls.some(call => call.key === 'iwinfo.assoclist')); + } +}); + test('preference corruption and inaccessible storage safely fall back', async () => { for (const storage of [{ getItem: () => '{broken' }, { getItem() { throw Error('blocked'); } }]) { const env = environment({ window: { localStorage: storage } }); @@ -201,7 +246,7 @@ function pageContext(initial = []) { return Object.assign({ env, page: loadModule('view.device-manager.devices', env) }, mocks); } async function renderPage(ctx) { - const node = ctx.page.render(await ctx.page.load()); + const node = await renderLoadedPage(ctx.page); return node; } function button(node, label) { return node.querySelectorAll('button').find(item => item.textContent === label); } @@ -320,6 +365,7 @@ test('tab counts match group/search filters and tabs support keyboard activation const ctx = pageContext([group, Object.assign({}, device, { group: 'home' })]); ctx.replies['iwinfo.assoclist'] = { results: [{ mac: other }] }; ctx.replies['luci-rpc.getWirelessDevices'] = { radio0: { interfaces: [{ ifname: 'wlan0' }] } }; + ctx.replies['luci-rpc.getNetworkDevices'] = { wlan0: {} }; const node = await renderPage(ctx); const tabs = node.querySelector('#dm-tabs-container').children; assert.equal(tabs[0].textContent, 'Online devices1'); assert.equal(tabs[1].textContent, 'All devices2'); diff --git a/luci-app-device-manager/test/test_i18n.js b/luci-app-device-manager/test/test_i18n.js index 09f45d2bc..7e513ff06 100644 --- a/luci-app-device-manager/test/test_i18n.js +++ b/luci-app-device-manager/test/test_i18n.js @@ -3,7 +3,7 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const test = require('node:test'); -const { environment, loadModule, backend, root } = require('./helpers/runtime'); +const { environment, loadModule, backend, root, renderLoadedPage } = require('./helpers/runtime'); function catalogue() { const messages = {}; @@ -30,7 +30,7 @@ function context(translate = text => text, initial = []) { const page = loadModule('view.device-manager.devices', env); return { mocks, env, page }; } -async function render(ctx) { return ctx.page.render(await ctx.page.load()); } +async function render(ctx) { return await renderLoadedPage(ctx.page); } function button(node, label) { return node.querySelectorAll('button').find(item => item.textContent === label); } test('native LuCI catalogue translates the page, sort titles, types and dialogs', async () => { @@ -90,7 +90,7 @@ test('read-only users cannot start scans and scan failures restore the control', assert.ok(!ctx.mocks.state.calls.some(call => call.key.endsWith('.scan_devices'))); ctx.env.L.hasViewPermission = () => true; ctx.page.readonly = false; - const writable = ctx.page.render(await ctx.page.load()); + const writable = await renderLoadedPage(ctx.page); ctx.mocks.replies['luci.device-manager.scan_devices'] = { ok: false }; await button(writable, 'Scan LAN').click(); assert.equal(button(writable, 'Scan LAN').disabled, false); diff --git a/luci-app-device-manager/test/test_list_loading.js b/luci-app-device-manager/test/test_list_loading.js new file mode 100644 index 000000000..fd497e3ce --- /dev/null +++ b/luci-app-device-manager/test/test_list_loading.js @@ -0,0 +1,117 @@ +'use strict'; +const assert = require('node:assert/strict'); +const test = require('node:test'); +const { environment, loadModule, backend } = require('./helpers/runtime'); +const mac = 'AA:BB:CC:11:22:33'; +const snapshot = { + groups: [{ '.type': 'group', '.name': 'home', name: 'Home' }], + devices: [{ '.type': 'device', '.name': 'tv', mac, name: 'TV', group: 'home' }], + hints: { [mac]: { ipaddrs: ['192.168.1.20'] } }, + neighbors: { neighbors: [{ mac, ip: '192.168.1.20', state: 'REACHABLE' }] }, + errors: [], discoveryComplete: true +}; +function context() { + const mocks = backend(), env = environment(mocks), cache = new Map(); + return { env, mocks, load: name => loadModule(name, env, cache) }; +} +function runTimer(env) { + const [id, timer] = Array.from(env.timeouts).find(([, value]) => value.delay === 0); + env.timeouts.delete(id); + return timer.callback(); +} + +test('a cache miss renders the full page before discovery and only the list waits for slow data', async () => { + const ctx = context(), service = ctx.load('device-manager.service'); + let release, loads = 0; + service.load = () => { loads++; return new Promise(resolve => { release = resolve; }); }; + ctx.env.window.localStorage.getItem = () => JSON.stringify({ tab: 'all', group: 'home', maskInfo: true }); + const page = ctx.load('view.device-manager.devices'); + const initial = await page.load(); + assert.equal(initial.snapshot, null); + assert.equal(loads, 0); + assert.equal(ctx.mocks.state.calls.length, 0); + const node = page.render(initial), list = node.querySelector('#device_manager_tbody'); + assert.ok(node.textContent.includes('LAN Device Management')); + assert.ok(node.querySelector('#dm-search-input')); + assert.equal(node.querySelectorAll('th').length, 8); + assert.match(list.textContent, /Loading list/); + assert.ok(!list.textContent.includes('No online devices')); + assert.ok(node.querySelectorAll('.dm-requires-data').every(button => button.disabled)); + const pending = runTimer(ctx.env); + assert.equal(loads, 1); + assert.equal(page.refresh(), pending); + page.filterText = 'tv'; page.updateView(); + assert.match(list.textContent, /Loading list/); + release(snapshot); await pending; + assert.ok(!list.textContent.includes('Loading list')); + assert.ok(list.textContent.includes('TV')); + assert.equal(page.activeGroup, 'home'); + assert.equal(page.maskInfo, true); + assert.ok(node.querySelectorAll('.dm-requires-data').every(button => !button.disabled)); + assert.equal(node.querySelector('#dm-cache-status').style.display, 'none'); +}); + +test('an initial loading failure becomes a retry row and a retry replaces it with the device list', async () => { + const ctx = context(), service = ctx.load('device-manager.service'); + service.load = () => Promise.reject(Error('router unavailable')); + const page = ctx.load('view.device-manager.devices'), node = page.render(await page.load()); + await runTimer(ctx.env); + const list = node.querySelector('#device_manager_tbody'); + assert.match(list.textContent, /Could not load the device list/); + const retry = list.querySelector('button'); + assert.equal(retry.textContent, 'Retry'); + service.load = () => Promise.resolve(snapshot); + await retry.click(); + assert.equal(page.initialLoading, false); + assert.equal(page.loadFailed, false); + assert.ok(list.textContent.includes('TV')); + assert.ok(!list.textContent.includes('Retry')); +}); + +test('an expired snapshot shows the shell without starting or awaiting live discovery', async () => { + const ctx = context(); + let now = 1000000; + ctx.env.Date = class extends Date { static now() { return now; } }; + await ctx.load('device-manager.snapshot-cache').save(snapshot); + now += 300000; + const page = ctx.load('view.device-manager.devices'), initial = await page.load(); + assert.equal(initial.snapshot, null); + assert.equal(initial.cached, false); + const node = page.render(initial); + assert.match(node.querySelector('#device_manager_tbody').textContent, /Loading list/); + assert.equal(ctx.mocks.state.calls.length, 0); +}); + +test('an empty cached online list stays loading until live discovery and Ping both finish', async () => { + for (const reachable of [true, false]) { + const ctx = context(), service = ctx.load('device-manager.service'); + await ctx.load('device-manager.snapshot-cache').save({ groups: [], devices: [], errors: [], discoveryComplete: true }); + let readDone, pingDone; + service.load = () => new Promise(resolve => { readDone = resolve; }); + service.pingDevice = () => new Promise(resolve => { pingDone = resolve; }); + const page = ctx.load('view.device-manager.devices'), node = page.render(await page.load()); + const list = node.querySelector('#device_manager_tbody'); + assert.match(list.textContent, /Loading list/); + assert.ok(!list.textContent.includes('No online devices')); + const reading = runTimer(ctx.env); + readDone(Object.assign({}, snapshot, { neighbors: { neighbors: [{ mac, ip: '192.168.1.20', state: 'STALE' }] } })); + await reading; + assert.match(list.textContent, /Loading list/); + const checking = runTimer(ctx.env); + await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); + assert.match(list.textContent, /Loading list/); + pingDone({ reachable }); await checking; + assert.ok(!list.textContent.includes('Loading list')); + assert.ok(list.textContent.includes(reachable ? 'TV' : 'No online devices')); + } +}); + +test('a completed empty discovery shows no online devices after its loading phase', async () => { + const ctx = context(), service = ctx.load('device-manager.service'); + service.load = () => Promise.resolve({ groups: [], devices: [], errors: [], discoveryComplete: true }); + const page = ctx.load('view.device-manager.devices'), node = page.render(await page.load()); + const list = node.querySelector('#device_manager_tbody'); + assert.match(list.textContent, /Loading list/); + await runTimer(ctx.env); + assert.equal(list.textContent, 'No online devices'); +}); diff --git a/luci-app-device-manager/test/test_online_check.js b/luci-app-device-manager/test/test_online_check.js new file mode 100644 index 000000000..90a88ad87 --- /dev/null +++ b/luci-app-device-manager/test/test_online_check.js @@ -0,0 +1,127 @@ +'use strict'; +const assert = require('node:assert/strict'); +const test = require('node:test'); +const { environment, loadModule, backend, renderLoadedPage } = require('./helpers/runtime'); + +const mac = 'AA:BB:CC:11:22:33', other = 'AA:BB:CC:44:55:66'; +function context() { + const mocks = backend(), env = environment(mocks), cache = new Map(); + mocks.replies['luci-rpc.getHostHints'] = { [mac]: { ipaddrs: ['192.168.1.20'] } }; + mocks.replies['luci.device-manager.get_online_status'] = { ok: true, neighbors: [{ mac, ip: '192.168.1.20', state: 'STALE' }] }; + return Object.assign({ env, load: name => loadModule(name, env, cache) }, mocks); +} +function runTimer(env) { + const timer = Array.from(env.timeouts).find(([, value]) => value.delay === 0); + assert.ok(timer); + env.timeouts.delete(timer[0]); + return timer[1].callback(); +} + +test('unknown devices are checked after rendering and a Ping reply updates online rows and tab counts', async () => { + const ctx = context(), page = ctx.load('view.device-manager.devices'); + const node = await renderLoadedPage(page); + assert.equal(page.devices[0].status, 'unknown'); + assert.equal(ctx.state.calls.filter(call => call.key.endsWith('.ping_device')).length, 0); + await runTimer(ctx.env); + assert.equal(page.devices[0].status, 'online'); + assert.equal(page.devices[0].statusDetail, 'Online · Ping replied'); + assert.equal(node.querySelector('#dm-tabs-container').children[0].textContent, 'Online devices1'); + assert.equal(node.querySelectorAll('.dm-status-dot-online').length, 1); + assert.equal(ctx.state.commits, 0); +}); + +test('unanswered, failed and malformed Pings leave unknown devices unchanged', async () => { + for (const reply of [ + { ok: true, reachable: false, ip: '192.168.1.20', output: 'timeout' }, + { ok: false, error: 'Another device is being probed. Please retry.' }, + Error('RPC unavailable'), { ok: true } + ]) { + const ctx = context(), page = ctx.load('view.device-manager.devices'); + ctx.replies['luci.device-manager.ping_device'] = reply; + await renderLoadedPage(page); + await runTimer(ctx.env); + assert.equal(page.devices[0].status, 'unknown'); + assert.equal(ctx.env.notifications.length, 0); + } +}); + +test('automatic detection skips online, offline and addressless devices and supports IPv6', async () => { + const env = environment(), check = loadModule('device-manager.online-check', env), checked = []; + await check.run([ + { mac: 'online', status: 'online', ipv4: '192.168.1.1' }, + { mac: 'offline', status: 'offline', ipv4: '192.168.1.2' }, + { mac: 'addressless', status: 'unknown' }, + { mac: 'ipv6', status: 'unknown', ipv6: 'fd00::20' } + ], device => { checked.push(device.mac); return Promise.resolve(); }, () => true); + assert.deepEqual(checked, ['ipv6']); +}); + +test('successful manual detail Ping also confirms an unknown device', async () => { + const ctx = context(), page = ctx.load('view.device-manager.devices'); + await renderLoadedPage(page); + await page.showDetailModal(page.devices[0]); + assert.equal(page.devices[0].status, 'online'); + assert.equal(ctx.env.ui.modal.querySelector('.dm-ping-status').textContent, 'Online · Ping replied'); + assert.equal(ctx.env.ui.modal.querySelector('.dm-dialog-status').textContent, 'Online'); +}); + +test('late Ping replies cannot promote a device from a newer snapshot and old batches stop', async () => { + const ctx = context(), page = ctx.load('view.device-manager.devices'), service = ctx.load('device-manager.service'); + await renderLoadedPage(page); + page.devices.push(Object.assign({}, page.devices[0], { mac: other })); + const old = page.devices[0]; + let release, started; + const signal = new Promise(resolve => { started = resolve; }); + service.pingDevice = () => { started(); return new Promise(resolve => { release = resolve; }); }; + const pending = page.confirmUnknownDevices(); + await signal; + page.acceptData(await service.load()); + release({ reachable: true }); + await pending; + assert.equal(old.status, 'unknown'); + assert.equal(page.devices[0].status, 'unknown'); +}); + +test('cached lists wait for live refresh before probing and old Ping success is not reused', async () => { + const ctx = context(), page = ctx.load('view.device-manager.devices'); + await renderLoadedPage(page); + const cached = await page.load(); + assert.equal(cached.cached, true); + page.render(cached); + await page.confirmUnknownDevices(); + assert.equal(ctx.state.calls.filter(call => call.key.endsWith('.ping_device')).length, 0); + await runTimer(ctx.env); + await runTimer(ctx.env); + assert.equal(page.devices[0].status, 'online'); + ctx.replies['luci.device-manager.ping_device'] = { ok: true, reachable: false, ip: '192.168.1.20', output: 'timeout' }; + await page.refresh(); + assert.equal(page.devices[0].status, 'unknown'); + await runTimer(ctx.env); + assert.equal(page.devices[0].status, 'unknown'); +}); + +test('detection uses one request at a time, caps each pass and continues with remaining devices', async () => { + const env = environment(), check = loadModule('device-manager.online-check', env), checked = []; + const devices = Array.from({ length: 40 }, (_, index) => ({ mac: index, status: 'unknown', ipv4: '192.168.1.20' })); + let active = 0, maximum = 0; + const ping = async device => { + active++; maximum = Math.max(active, maximum); + await Promise.resolve(); + active--; checked.push(device.mac); + }; + await Promise.all([check.run(devices, ping, () => true), check.run(devices, ping, () => true)]); + assert.equal(maximum, 1); + assert.deepEqual(checked, Array.from({ length: 32 }, (_, index) => index)); +}); + +test('time budget stops slow probes and a cancelled snapshot starts no further requests', async () => { + const env = environment(); + let now = 0, current = true; + env.Date = class extends Date { static now() { return now; } }; + const check = loadModule('device-manager.online-check', env), checked = []; + const devices = Array.from({ length: 20 }, (_, index) => ({ mac: index, status: 'unknown', ipv4: '192.168.1.20' })); + await check.run(devices, async device => { checked.push(device.mac); now += 3000; }, () => current); + assert.deepEqual(checked, [0, 1, 2, 3]); + await check.run(devices, async device => { checked.push(device.mac); current = false; }, () => current); + assert.deepEqual(checked, [0, 1, 2, 3, 4]); +}); diff --git a/luci-app-device-manager/test/test_sdk.py b/luci-app-device-manager/test/test_sdk.py index 0af165070..853dfdc02 100644 --- a/luci-app-device-manager/test/test_sdk.py +++ b/luci-app-device-manager/test/test_sdk.py @@ -132,6 +132,7 @@ class SDKTests(unittest.TestCase): def test_scan_acl_requires_write_permission_but_keeps_passive_discovery_readable(self): acl = json.loads((ROOT / "root/usr/share/rpcd/acl.d/luci-app-device-manager.json").read_text())["luci-app-device-manager"] + self.assertIn("getNetworkDevices", acl["read"]["ubus"]["luci-rpc"]) self.assertIn("get_online_status", acl["read"]["ubus"]["luci.device-manager"]) self.assertNotIn("scan_devices", acl["read"]["ubus"]["luci.device-manager"]) self.assertIn("scan_devices", acl["write"]["ubus"]["luci.device-manager"]) diff --git a/luci-app-device-manager/test/test_table_sorting.js b/luci-app-device-manager/test/test_table_sorting.js index 03c2b083f..dd0d18fdb 100644 --- a/luci-app-device-manager/test/test_table_sorting.js +++ b/luci-app-device-manager/test/test_table_sorting.js @@ -1,7 +1,7 @@ 'use strict'; const assert = require('node:assert/strict'); const test = require('node:test'); -const { environment, loadModule, backend } = require('./helpers/runtime'); +const { environment, loadModule, backend, renderLoadedPage } = require('./helpers/runtime'); const model = loadModule('device-manager.model', environment()); @@ -93,8 +93,7 @@ test('clicking the IP column header sorts devices in the table', async () => { const env = environment(mocks); const page = loadModule('view.device-manager.devices', env); - const data = await page.load(); - const viewNode = page.render(data); + const viewNode = await renderLoadedPage(page); const table = viewNode.querySelector('#device_manager_table'); const thList = table.querySelectorAll('th'); @@ -175,8 +174,7 @@ test('status indicator renders as a small dot without text, and default tab is O const env = environment(mocks); const page = loadModule('view.device-manager.devices', env); - const data = await page.load(); - const viewNode = page.render(data); + const viewNode = await renderLoadedPage(page); // Check tabs order: 0 is online, 1 is all const tabs = viewNode.querySelector('#dm-tabs-container').children; @@ -215,8 +213,7 @@ test('table row display omits no-hostname subtitle, prefix from hostname subtitl const env = environment(mocks); const page = loadModule('view.device-manager.devices', env); - const data = await page.load(); - const viewNode = page.render(data); + const viewNode = await renderLoadedPage(page); const rows = viewNode.querySelector('#device_manager_tbody').querySelectorAll('tr'); assert.equal(rows.length, 2); @@ -265,8 +262,7 @@ test('mask/show info button toggles MAC and IPv6 masking with asterisks and reme })); const page = loadModule('view.device-manager.devices', env); - const data = await page.load(); - const viewNode = page.render(data); + const viewNode = await renderLoadedPage(page); // By default maskInfo is false: MAC and IPv6 are unmasked let row = viewNode.querySelector('#device_manager_tbody').querySelectorAll('tr')[0]; @@ -297,8 +293,7 @@ test('mask/show info button toggles MAC and IPv6 masking with asterisks and reme // Test persistence on page reload const pageReload = loadModule('view.device-manager.devices', env); - const reloadedData = await pageReload.load(); - const reloadedView = pageReload.render(reloadedData); + const reloadedView = await renderLoadedPage(pageReload); assert.equal(pageReload.maskInfo, true); row = reloadedView.querySelector('#device_manager_tbody').querySelectorAll('tr')[0]; diff --git a/luci-app-device-manager/tools/deploy.sh b/luci-app-device-manager/tools/deploy.sh index c3b2bf166..bf7edaf2c 100755 --- a/luci-app-device-manager/tools/deploy.sh +++ b/luci-app-device-manager/tools/deploy.sh @@ -131,7 +131,7 @@ if [[ ! -f "${PROJECT_ROOT}/root/usr/share/rpcd/acl.d/luci-app-device-manager.js exit 1 fi -for resource in model.js service.js preferences.js table.js device-dialog.js group-dialog.js styles.css; do +for resource in model.js service.js snapshot-cache.js feedback.js online-check.js preferences.js table.js device-dialog.js group-dialog.js styles.css; do if [[ ! -f "${PROJECT_ROOT}/htdocs/luci-static/resources/device-manager/${resource}" ]]; then print_error "Missing frontend resource: device-manager/${resource}" exit 1 diff --git a/luci-app-fchomo/htdocs/luci-static/resources/fchomo.js b/luci-app-fchomo/htdocs/luci-static/resources/fchomo.js index 92aa87f60..aa2088745 100644 --- a/luci-app-fchomo/htdocs/luci-static/resources/fchomo.js +++ b/luci-app-fchomo/htdocs/luci-static/resources/fchomo.js @@ -720,7 +720,7 @@ const CBIGenValue = form.Value.extend({ (node.querySelector('.control-group') || node).appendChild(E('button', { class: 'cbi-button cbi-button-add', title: _('Generate'), - click: ui.createHandlerFn(this, handleGenKey, section_id, this.et_options || this.option) + click: ui.createHandlerFn(this, handleGenKey, section_id, this.hm_options || this.option) }, [ _('Generate') ])); return node; @@ -737,7 +737,7 @@ const CBIGenText = CBITextValue.extend({ (node.querySelector('.control-group') || node).appendChild(E('button', { class: 'cbi-button cbi-button-add', title: _('Generate'), - click: ui.createHandlerFn(this, handleGenKey, section_id, this.et_options || this.option) + click: ui.createHandlerFn(this, handleGenKey, section_id, this.hm_options || this.option) }, [ _('Generate') ])); return node; @@ -1456,7 +1456,7 @@ function renderHy2Realm(s, uciconfig) { o.depends('hysteria2_realm', '1'); o.modalonly = true; - // @ 下面支持填写针对server-url的TLS配置(sni, skip-cert-verify, name-cert-verify, fingerprint, certificate, private-key, alpn) + // 下面支持填写针对server-url的TLS配置(sni, skip-cert-verify, name-cert-verify, fingerprint, certificate, private-key, alpn) o = s.taboption('field_hysteria2_realm', form.Value, 'hysteria2_realm_sni', _('TLS SNI'), _('Hostname that the client attempts to connect to at the start of the TLS handshake process.')); @@ -1812,12 +1812,11 @@ function validateHexstr(length, section_id, value) { function validateBase64Key(length, section_id, value) { /* Thanks to luci-proto-wireguard */ - if (value) + if (value) { if (value.length !== length || !value.match(/^(?:[A-Za-z0-9+\/]{4})*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=)?$/) || value[length-1] !== '=') return _('Expecting: %s').format(_('valid base64 key with %d characters').format(length)); - else - if (this.rmempty === false || (this.optional ?? true) !== false) - return _('Expecting: %s').format(_('non-empty value')); + } else if (this.rmempty === false || (this.optional ?? true) !== false) + return _('Expecting: %s').format(_('non-empty value')); return true; } diff --git a/luci-app-fchomo/htdocs/luci-static/resources/fchomo/listeners.js b/luci-app-fchomo/htdocs/luci-static/resources/fchomo/listeners.js index b25a7b4a9..546617b04 100644 --- a/luci-app-fchomo/htdocs/luci-static/resources/fchomo/listeners.js +++ b/luci-app-fchomo/htdocs/luci-static/resources/fchomo/listeners.js @@ -221,7 +221,7 @@ function renderListeners(s, uciconfig, isClient) { ['sudoku-keypair', _('sudoku-keypair')], ['uuid', _('UUID')] ] - o = s.taboption('field_general', hm.GenValue, 'sudoku_key', _('Key'), + o = s.taboption('field_general', hm.GenValue, 'sudoku_key', _('Public key'), _('The ED25519 master public key or UUID generated by Sudoku.')); o.password = true; o.hm_options = { @@ -275,7 +275,7 @@ function renderListeners(s, uciconfig, isClient) { o.depends('type', 'sudoku'); o.modalonly = true; - o = s.taboption('field_general', hm.CopyValue, 'sudoku_client_key', _('Client key')); + o = s.taboption('field_general', hm.CopyValue, 'sudoku_client_key', _('Client private key')); o.depends('type', 'sudoku'); o.modalonly = true; diff --git a/luci-app-fchomo/htdocs/luci-static/resources/view/fchomo/client.js b/luci-app-fchomo/htdocs/luci-static/resources/view/fchomo/client.js index 388517f92..60b88d027 100644 --- a/luci-app-fchomo/htdocs/luci-static/resources/view/fchomo/client.js +++ b/luci-app-fchomo/htdocs/luci-static/resources/view/fchomo/client.js @@ -204,13 +204,12 @@ class RulesEntry { if (rule.subrule) return 'SUB-RULE,(%s),%s'.format(payload, rule.subrule); + else if (rule.type === 'MATCH') + return [rule.type, rule.detour].join(','); else - if (rule.type === 'MATCH') - return [rule.type, rule.detour].join(','); - else - return [payload, rule.detour].concat( - rule.params ? ['no-resolve', 'src'].filter(k => rule.params[k]) : [] - ).join(','); + return [payload, rule.detour].concat( + rule.params ? ['no-resolve', 'src'].filter(k => rule.params[k]) : [] + ).join(','); } toString(format) { diff --git a/luci-app-fchomo/root/usr/share/fchomo/generate_client.uc b/luci-app-fchomo/root/usr/share/fchomo/generate_client.uc index 9952da33e..243cf8918 100644 --- a/luci-app-fchomo/root/usr/share/fchomo/generate_client.uc +++ b/luci-app-fchomo/root/usr/share/fchomo/generate_client.uc @@ -212,13 +212,12 @@ function parse_entry(cfg) { if (rule.subrule) return sprintf('SUB-RULE,(%s),%s', payload, rule.subrule); + else if (rule.type === 'MATCH') + return join(',', [rule.type, rule.detour]); else - if (rule.type === 'MATCH') - return join(',', [rule.type, rule.detour]); - else - return join(',', [...[payload, rule.detour], - ...(rule.params ? keys(rule.params) : []) - ]); + return join(',', [...[payload, rule.detour], + ...(rule.params ? keys(rule.params) : []) + ]); } /* Config helper END */ diff --git a/luci-app-passwall/Makefile b/luci-app-passwall/Makefile index dc412ec3d..75eb0addd 100644 --- a/luci-app-passwall/Makefile +++ b/luci-app-passwall/Makefile @@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-passwall PKG_VERSION:=26.11.3 -PKG_RELEASE:=372 +PKG_RELEASE:=373 PKG_PO_VERSION:=$(PKG_VERSION) PKG_CONFIG_DEPENDS:= \ diff --git a/luci-app-passwall/luasrc/model/cbi/passwall/client/node_config.lua b/luci-app-passwall/luasrc/model/cbi/passwall/client/node_config.lua index 9c4315d0f..057a34bcf 100644 --- a/luci-app-passwall/luasrc/model/cbi/passwall/client/node_config.lua +++ b/luci-app-passwall/luasrc/model/cbi/passwall/client/node_config.lua @@ -73,9 +73,13 @@ o = s:option(ListValue, "type", translate("Type")) local type_table = {} for filename in api.fs.dir(types_dir) do - table.insert(type_table, filename) + if filename:match("^%d+_") then + table.insert(type_table, filename) + end end -table.sort(type_table) +table.sort(type_table, function(a, b) + return tonumber(a:match("^%d+")) < tonumber(b:match("^%d+")) +end) for index, value in ipairs(type_table) do local p_func = loadfile(types_dir .. value) diff --git a/luci-app-passwall/luasrc/model/cbi/passwall/server/server_config.lua b/luci-app-passwall/luasrc/model/cbi/passwall/server/server_config.lua index bd7d2e48c..1ce2272d0 100644 --- a/luci-app-passwall/luasrc/model/cbi/passwall/server/server_config.lua +++ b/luci-app-passwall/luasrc/model/cbi/passwall/server/server_config.lua @@ -36,10 +36,12 @@ o = s:option(ListValue, "type", translate("Type")) local type_table = {} for filename in api.fs.dir(types_dir) do - table.insert(type_table, filename) + if filename:match("^%d+_") then + table.insert(type_table, filename) + end end table.sort(type_table, function(a, b) - return a < b + return tonumber(a:match("^%d+")) < tonumber(b:match("^%d+")) end) for index, value in ipairs(type_table) do diff --git a/luci-app-passwall2/Makefile b/luci-app-passwall2/Makefile index 994085f5d..767571cd7 100644 --- a/luci-app-passwall2/Makefile +++ b/luci-app-passwall2/Makefile @@ -7,7 +7,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-passwall2 PKG_VERSION:=26.11.3 -PKG_RELEASE:=173 +PKG_RELEASE:=174 PKG_PO_VERSION:=$(PKG_VERSION) PKG_CONFIG_DEPENDS:= \ diff --git a/luci-app-passwall2/luasrc/model/cbi/passwall2/client/node_config.lua b/luci-app-passwall2/luasrc/model/cbi/passwall2/client/node_config.lua index efa89e472..dee2c14c9 100644 --- a/luci-app-passwall2/luasrc/model/cbi/passwall2/client/node_config.lua +++ b/luci-app-passwall2/luasrc/model/cbi/passwall2/client/node_config.lua @@ -73,9 +73,13 @@ o = s:option(ListValue, "type", translate("Type")) local type_table = {} for filename in api.fs.dir(types_dir) do - table.insert(type_table, filename) + if filename:match("^%d+_") then + table.insert(type_table, filename) + end end -table.sort(type_table) +table.sort(type_table, function(a, b) + return tonumber(a:match("^%d+")) < tonumber(b:match("^%d+")) +end) for index, value in ipairs(type_table) do local p_func = loadfile(types_dir .. value) diff --git a/luci-app-passwall2/luasrc/model/cbi/passwall2/server/server_config.lua b/luci-app-passwall2/luasrc/model/cbi/passwall2/server/server_config.lua index 4b5a9c5c9..70bdaf8fd 100644 --- a/luci-app-passwall2/luasrc/model/cbi/passwall2/server/server_config.lua +++ b/luci-app-passwall2/luasrc/model/cbi/passwall2/server/server_config.lua @@ -36,10 +36,12 @@ o = s:option(ListValue, "type", translate("Type")) local type_table = {} for filename in api.fs.dir(types_dir) do - table.insert(type_table, filename) + if filename:match("^%d+_") then + table.insert(type_table, filename) + end end table.sort(type_table, function(a, b) - return a < b + return tonumber(a:match("^%d+")) < tonumber(b:match("^%d+")) end) for index, value in ipairs(type_table) do