⛅ Sync 2026-10-11 13:16:00

This commit is contained in:
github-actions[bot] committed 2026-10-11 13:16:00 +08:00
1 parent 705dadd362
commit e6d1b4e2d7
105 files changed
+26511 -50

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=honk
PKG_VERSION:=2026.10.9_pre3
PKG_RELEASE:=15
PKG_RELEASE:=16
# Raw upstream release tag. It keeps the dots in qualifiers (e.g. v0.0.1.beta.49)
# because that is what the GitHub release asset name uses.
+2 -1
View File
@@ -2,10 +2,11 @@
dns {
bind: 'tcp+udp://[::]:53530'
client_subnet: auto
max_cache_size: 50000
#ipversion_prefer: 4
upstream {
local: 'udp://127.0.0.1:53' -> direct
remote: 'tcp+udp://one.one.one.one:53'
remote: 'tcp+udp://dns.google:53'
}
routing {
request {
+5
View File
@@ -0,0 +1,5 @@
__pycache__/
*.py[cod]
node_modules/
*.ipk
*.apk
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 luci-app-device-manager contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+23
View File
@@ -0,0 +1,23 @@
include $(TOPDIR)/rules.mk
LUCI_TITLE:=LuCI support for LAN Device Management
LUCI_DEPENDS:=+luci-base +rpcd-mod-iwinfo +jshn
LUCI_PKGARCH:=all
LUCI_MAINTAINER:=zhiyong zhang <zhang747@126.com>
PKG_LICENSE:=MIT Apache-2.0
PKG_LICENSE_FILES:=LICENSE htdocs/luci-static/resources/device-manager/device-icons/LICENSE
define Package/luci-app-device-manager/conffiles
/etc/config/device_manager
endef
# In-tree LuCI applications use the relative include. Standalone SDK builds
# use the installed feed, where this package lives outside applications/.
ifneq ($(wildcard ../../luci.mk),)
include $(TOPDIR)/feeds/luci/luci.mk
else
include $(TOPDIR)/feeds/luci/luci.mk
endif
# call BuildPackage - OpenWrt buildroot signature
+139
View File
@@ -0,0 +1,139 @@
# luci-app-device-manager
轻量级 OpenWrt LuCI 局域网设备管理插件,可按 MAC 地址保存名称、备注、分组和设备类型。已有自定义数据在升级时保留。
A lightweight LAN device manager for OpenWrt LuCI. Combine host hints, DHCP
leases, Wi-Fi associations and kernel neighbors; save names, remarks, groups
and manual device types by MAC address, including saved offline devices.
## 功能 / Features
- 展示 IPv4、IPv6、接口和状态依据,区分在线、离线和未知状态。
- 按分组、状态和关键词筛选,支持排序、手动登记及地址隐藏。
- 支持 29 种显示类型和本地 SVG 图标;名称和少量有来源的厂商线索用于推测,用户可手动覆盖。
- 刷新列表只读取现有发现数据;“扫描局域网”由用户主动触发,需要写权限。
- 只读账号可查看、刷新和单设备 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.
Type recognition is a best-effort display hint with a manual override; see
[identification rules and provenance](docs/device-identification.md).
## 安装 / Installation
本项目尚未合并到官方源。先用本仓库的 GitHub Actions 或 OpenWrt SDK 构建,再安装与目标系统版本匹配的本地包。
The package requires `luci-base`, `rpcd-mod-iwinfo` and `jshn`.
Standard OpenWrt BusyBox provides the required `ip` and `flock` commands;
`luci-base` supplies the file RPC dependency. Custom firmware that disables
these BusyBox applets must enable them or install `ip-tiny` (or `ip-full`)
and `flock` separately.
OpenWrt with opkg, in the directory containing the build artifacts:
```sh
opkg install luci-app-device-manager_*.ipk
opkg install luci-i18n-device-manager-zh-cn_*.ipk
```
OpenWrt with apk, for locally built packages:
```sh
apk add --allow-untrusted ./luci-app-device-manager-*.apk
apk add --allow-untrusted ./luci-i18n-device-manager-zh-cn-*.apk
```
The Chinese language package is optional. Without an installed translation,
LuCI displays the English source messages. After installation, reload LuCI
and open **Network → Device Manager**.
源码面向现代 JavaScript LuCI,构建 workflow 默认验证 SNAPSHOT,并支持指定稳定版 SDK。
真实路由器安装、升级、主题和账户权限验收需按下方步骤执行,不能由本地单元测试代替。
## 扫描范围 / Scan scope
默认仅扫描 netifd 的逻辑接口 `lan`。从接口的 `l3_device` 和内核 IPv4 地址获取真实网段,
不会根据全局路由表选择 WAN,也不会假设网段为 `/24`。
To add a separate trusted LAN, list its logical netifd interface explicitly:
```uci
config settings 'settings'
list scan_interface 'lan'
list scan_interface 'guest'
```
An existing configuration without settings defaults to `lan`. An explicitly
empty `scan_interface` disables scanning. At most eight interfaces and 64
subnets are considered. Never add an uplink interface unless probing that
interface is intentionally required.
Each Ping is bound to the selected LAN device. Router, network and broadcast
addresses are excluded; known leases and neighbors are also checked against
the selected subnet. Prefixes with at most 4096 addresses can be enumerated.
Larger subnets probe known in-scope hosts only. IPv6 is discovered passively;
single-device Ping can use a known IPv6 address.
Batch scans share a lock, allow at most 16 concurrent children and 128 targets
per request, stop after about eight seconds and use a 30-second cooldown
measured after completion. Further eligible clicks resume the sweep.
An unanswered ICMP Ping does not by itself prove a device is offline.
## 构建和开发 / Build and development
GitHub Actions **Build OpenWrt Package** accepts `SNAPSHOT` or a release
version and a `target/subtarget` such as `x86/64` or `mediatek/filogic`.
It verifies the official SDK checksum and builds an exported in-tree LuCI
application plus the Chinese language package. Standalone artifacts receive
LuCI-style versions from the source repository commit, rather than the
untracked export directory. Pull requests and main-branch
pushes build SNAPSHOT. Tags publish the resulting artifacts in a release.
Independent-repository tools are not installed on the router or exported as
top-level files into the upstream LuCI repository:
```sh
node --test test/test_*.js
python3 -B -m unittest discover -s test -p 'test_*.py'
python3 -B tools/update-translations.py --check --luci-dir /path/to/luci
```
Regenerate the native POT and merge the PO with the official LuCI extractor:
```sh
python3 -B tools/update-translations.py --luci-dir /path/to/luci
```
Future upstream translations should follow LuCI's Weblate workflow.
`luci.mk` compiles `.lmo` language packages; compiled catalogues are not
committed. The standalone `tools/deploy.sh` is only for development:
it preserves existing device configuration and reuses one SSH authentication,
but requires installing the native language package separately.
For upstream export and a suggested commit/PR description, see
[submission preparation](docs/upstream-submission.md) in the independent
repository. No tool commits, pushes or rewrites existing contributor history.
## 真实设备验收 / Router acceptance
1. Install the package and optional Chinese translation on a matching system;
verify English and Chinese language selection and a second LuCI language.
2. Save a device name, note, type and group; upgrade the package and reboot;
verify that the records and an intentionally empty group list survive.
3. Use a read-only account: verify refresh and details, and ensure editing and
batch scanning are denied by the RPC ACL even when called directly.
4. On isolated `/25` and `/23` LANs, inspect packet captures while scanning;
verify interface binding, subnet boundaries, concurrency and cooldown.
5. Test standard BusyBox `ip` and optionally `ip-tiny`/`ip-full`, an unavailable LAN,
IPv6-only details, Wi-Fi VLAN stations and incomplete discovery sources.
6. Check the stock Bootstrap/OpenWrt themes on desktop and mobile, including
dark mode, keyboard controls and dialogs.
## 许可证 / Licensing
Project code: MIT, see [LICENSE](LICENSE). Material Design Icons: Apache-2.0,
with source revision, per-icon URLs and local changes recorded under
[device-icons](htdocs/luci-static/resources/device-manager/device-icons/NOTICE).
Both applicable licenses are declared in the package Makefile.
@@ -0,0 +1,25 @@
# Device identification
Identification is a display hint, not an authenticated fingerprint. A saved
manual device type takes precedence. Specific hostname or custom-name clues
come next, followed by a small set of vendor hints and the default group.
Names and types never affect routing, firewall rules or DHCP configuration.
Short English words require token boundaries. A generic `switch` means a
network switch; `nintendo-switch` means a console. `gateway` means a router,
and `Cambridge-PC` means a computer. Chinese device keywords are also accepted.
The previous broad OUI tables had no recorded provenance. They were replaced
with the 13 documented entries in `oui-hints.json`. Manufacturer assignments
were checked against the Wireshark project's automatically generated
[manufacturer data](https://www.wireshark.org/download/automated/data/manuf.gz),
which incorporates IEEE registration data. The verification source checksum
is recorded with the entries. Only assignment facts are retained; the complete
database is not bundled.
A manufacturer assignment identifies an organization, not a product. Apple,
Google, HP and other vendors with diverse products therefore have no automatic
device type hint. Ubiquiti and H3C use the general network-device category;
Espressif and Lumi use the general smart-home category. These remain guesses,
and users can override them. Locally administered or randomized MAC addresses
never use vendor hints. Hostname and manual identification still work for them.
@@ -0,0 +1,59 @@
{
"source": "https://www.wireshark.org/download/automated/data/manuf.gz",
"verified_on": "2026-10-10",
"decompressed_source_sha256": "d542945307e2ab7c11f70c741f41f4a55585088b05da139fe5583154cb60ccc1",
"entries": {
"001132": {
"organization": "Synology Incorporated",
"type_hint": "nas"
},
"4CFCAA": {
"organization": "Tesla,Inc.",
"type_hint": "car"
},
"2C2617": {
"organization": "Oculus VR, LLC",
"type_hint": "vr"
},
"1012FB": {
"organization": "Hangzhou Hikvision Digital Technology Co.,Ltd.",
"type_hint": "camera"
},
"0009BF": {
"organization": "Nintendo Co., Ltd.",
"type_hint": "game"
},
"000E58": {
"organization": "Sonos, Inc.",
"type_hint": "speaker"
},
"001788": {
"organization": "Philips Lighting BV",
"type_hint": "light"
},
"18FE34": {
"organization": "Espressif Inc.",
"type_hint": "home"
},
"54EF44": {
"organization": "Lumi United Technology Co., Ltd",
"type_hint": "home"
},
"00156D": {
"organization": "Ubiquiti Inc",
"type_hint": "network"
},
"000FE2": {
"organization": "Hangzhou H3C Technologies Co., Limited",
"type_hint": "network"
},
"000C42": {
"organization": "Routerboard.com",
"type_hint": "router"
},
"B827EB": {
"organization": "Raspberry Pi Foundation",
"type_hint": "computer"
}
}
}
@@ -0,0 +1,85 @@
# Upstream submission
Target: `openwrt/luci`, branch `master`, directory
`applications/luci-app-device-manager/`.
The independent repository retains development tests, SDK build workflows and
SSH deployment tooling. They are not copied to the upstream repository root.
The configuration initializer lives under `tools/` for source deployments;
packages ship `/etc/config/device_manager` directly. The exported README uses
official-feed installation commands, while standalone build instructions stay
in the independent repository.
Export reviewed package files into a fresh LuCI feature branch:
```sh
git clone https://github.com/openwrt/luci.git /tmp/luci-device-manager-upstream
git -C /tmp/luci-device-manager-upstream switch -c add-device-manager
git -C /tmp/luci-device-manager-upstream config user.name "zhiyong zhang"
git -C /tmp/luci-device-manager-upstream config user.email "zhang747@126.com"
python3 -B tools/prepare-upstream.py /tmp/luci-device-manager-upstream
git -C /tmp/luci-device-manager-upstream diff --check
```
The exporter refuses an existing application directory and does not change
Git history, commit or push. It uses the standard relative `luci.mk` include
and exports only package sources, PO files, licenses and package documentation.
Suggested commit title:
```text
luci-app-device-manager: add LAN device management
```
Suggested commit body:
```text
Add a JavaScript LuCI view for LAN device discovery and metadata management.
Combine host hints, DHCP leases, wireless associations and kernel neighbors.
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.
Use native LuCI message extraction and separately built translation packages.
Include pinned Material Design Icons attribution and Apache-2.0 notices.
Signed-off-by: zhiyong zhang <zhang747@126.com>
```
Submission commits use `zhiyong zhang <zhang747@126.com>` for the author,
committer and Signed-off-by identity. Configure this identity locally in the
LuCI checkout before committing, as shown above. Existing contributor
attribution must be preserved; these tools do not rewrite historical commits.
Suggested PR description:
```text
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.
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.
- 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.
Real-router installation, upgrades, account enforcement, UI acceptance and
a stable-release SDK build remain unverified.
```
Review the recorded validation results before sending the PR.
Local tests do not certify real-router or upstream acceptance.
@@ -0,0 +1,66 @@
# Local upstream-readiness validation
## PR review follow-up: 2026-10-11
Addressed the dependency, license installation, redundant initializer and
in-tree README comments on OpenWrt LuCI PR #9125.
- Full Node regression suite: 100 passed, 0 failed, 0 skipped.
- Python regression suite, including the exported installation instructions:
8 passed.
- Official LuCI ESLint, native translation extraction, gettext, shell syntax
and whitespace checks passed.
- Official OpenWrt BusyBox packages tested locally with the SDK musl loader:
SNAPSHOT 1.38.0-r3 and 24.10.4 1.36.1-r3. Both support
`ip -o -4 addr show dev lo`, `ip neigh show` and `flock -n 9`.
The actual RPC helper parsed IPv4 and IPv6 neighbor output from both binaries.
- The 33 shell/Ping/scan regressions passed with each official BusyBox binary,
its flock applet and SDK-native jshn. Network probes, netifd and UCI inputs
were isolated fixtures; no router was modified.
- Rebuilt the main and Chinese language APKs in the x86/64 SNAPSHOT SDK.
Main-package dependencies are libc, luci-base, rpcd-mod-iwinfo and jshn.
ip-tiny, ip-full and util-linux flock were disabled in the SDK configuration.
The extra license installation hook and packaged configuration initializer
were removed; MIT and Apache-2.0 declarations and source notices remain.
Compatibility evidence and official binaries: /tmp/device-manager-review-compat/.
SDK build log: /tmp/device-manager-review-sdk-build.log.
Real-router acceptance and a stable-release SDK build remain unverified.
## Initial validation: 2026-10-10
The checks below describe the initial submission snapshot, before the PR review
follow-up. No router was modified during that validation.
## Passed checks
- Node regression suite: 100 passed, 0 failed, 0 skipped.
- Python regression suite: 8 passed.
- Official LuCI master ESLint configuration: no errors or warnings.
- Native POT extraction: 184 source messages, including menu, ACL and UI.
- GNU gettext template/catalogue comparison and PO checks.
- SDK-native jshn regression run: 33 passed, 0 skipped. Network and UCI inputs were isolated fixtures.
- Workflow YAML parsing and every run block checked with bash -n.
- Shell syntax and git diff --check.
## SDK package validation
- Official x86/64 SNAPSHOT SDK archive SHA256:
60b7182a36d0542810e5564dcf9f7c2531bbbc74546ededcbc619b2939b72127
- SDK base source commit: 53ec2d05a487e6011cfa096de9211d15197c501b.
- LuCI reference commit: 36720dd716c7f7008af95c0d520edefab621adb7.
- SDK download checksum was verified. Official GitHub mirrors were used for feeds, retaining the SDK base commit.
- Main package and Chinese language package compiled successfully.
- ip-tiny and ip-full dependency variants both built; each main package depends only on its selected IP implementation.
- Unpacked the ip-tiny packages and checked executable RPC helper, configuration preservation metadata, both applicable license texts, icon attribution and absence of legacy bundled translations.
- The language package LMO matches a fresh compilation of the current PO using SDK po2lmo.
Local APKs and manifest: /tmp/device-manager-built-packages/. These are local validation builds.
The local package version uses the existing source HEAD while compiling this uncommitted working tree.
CI builds calculate the version from the checked-out source commit; upstream builds use the LuCI feed revision.
## Remaining external acceptance
Real-router installation, upgrades, reboot persistence, account enforcement, packet captures, themes and mobile UI remain to be checked using the README acceptance steps.
GitHub Actions was validated locally but has not been triggered remotely.
Stable-release SDK/IPK builds have not been rerun in this validation.
@@ -0,0 +1,336 @@
// SPDX-License-Identifier: MIT
'use strict';
'require baseclass';
'require ui';
'require device-manager.model as model';
function statusLabel(status) {
return status === 'online' ? _('Online') : status === 'offline' ? _('Offline') : _('Unknown');
}
function closeButton() {
return E('button', { 'type': 'button', 'class': 'dm-dialog-close',
'aria-label': _('Close'), 'title': _('Close'), 'click': ui.hideModal
}, [ '×' ]);
}
function deviceHero(dev) {
const typeInfo = model.getTypeInfo(dev.type);
return E('div', { 'class': 'dm-dialog-hero' }, [
E('span', { 'class': 'dm-dialog-icon' }, [ E('img', {
'src': L.resource('device-manager/device-icons/' + typeInfo.icon), 'alt': ''
}) ]),
E('div', { 'class': 'dm-dialog-heading' }, [
E('strong', {}, [ dev.customName || dev.hostname || _('Unknown device') ]),
E('span', { 'class': 'dm-dialog-muted' }, [ model.getTypeLabel(dev.type) ])
]),
E('span', { 'class': 'dm-dialog-status dm-dialog-status-' + dev.status }, [ statusLabel(dev.status) ])
]);
}
function infoGrid(fields) {
return E('div', { 'class': 'dm-info-grid' }, fields.map(field => E('div', {
'class': 'dm-info-item' + (field[2] ? ' dm-info-wide' : '')
}, [
E('span', { 'class': 'dm-info-label' }, [ field[0] ]),
E('div', { 'class': 'dm-info-value' }, [ field[1] || '—' ])
])));
}
return baseclass.extend({
showEditModal: function(dev) {
if (this.readonly) return;
const self = this;
const isEdit = (dev !== null);
const curMac = isEdit ? dev.mac : '';
const curHostname = isEdit ? (dev.hostname || _('Not detected')) : '';
const curIp = isEdit ? (dev.ipv4 || dev.ipv6 || _('Unavailable')) : '';
const curName = isEdit ? (dev.customName || '') : '';
const curRemark = isEdit ? (dev.remark || '') : '';
const curGroup = isEdit ? (dev.group || 'ungrouped') : 'ungrouped';
const modalTitle = isEdit
? _('Edit device')
: _('Add device');
let macInput = null;
let macRow = null;
if (!isEdit) {
macInput = E('input', {
'type': 'text',
'class': 'cbi-input-text',
'placeholder': _('e.g. AA:BB:CC:11:22:33'),
'maxlength': 17,
'style': 'width:100%;'
});
macRow = E('div', { 'class': 'cbi-value dm-form-wide' }, [
E('label', { 'class': 'cbi-value-title' }, [
_('MAC address'),
E('span', { 'style': 'color:red;' }, [ ' *' ])
]),
E('div', { 'class': 'cbi-value-field' }, [
macInput,
E('div', { 'class': 'cbi-value-description' }, [ _('Hardware address; colon or hyphen separators are accepted') ])
])
]);
}
// Group dropdown options
const groupSelect = E('select', { 'class': 'cbi-input-select', 'style': 'width:100%;' }, [
E('option', { 'value': 'ungrouped' }, [ _('Ungrouped') ])
]);
for (let i = 0; i < this.groups.length; i++) {
const g = this.groups[i];
const opt = E('option', { 'value': g.id }, [ g.name ]);
if (curGroup === g.id) opt.selected = true;
groupSelect.appendChild(opt);
}
const curType = isEdit ? (dev.customType || 'auto') : 'auto';
const typeSelect = E('select', { 'class': 'cbi-input-select', 'style': 'width:100%;' }, [
E('option', { 'value': 'auto' }, [ _('Auto (detect by MAC)') ])
]);
for (const [ typeKey, typeObj ] of Object.entries(model.DEVICE_TYPES)) {
const opt = E('option', { 'value': typeKey }, [ typeObj.label() ]);
if (curType === typeKey) opt.selected = true;
typeSelect.appendChild(opt);
}
const nameInput = E('input', {
'type': 'text',
'class': 'cbi-input-text',
'value': curName,
'placeholder': _('e.g. Living room TV, work computer'),
'maxlength': 64,
'style': 'width:100%;'
});
const remarkTextarea = E('textarea', {
'class': 'cbi-input-textarea',
'rows': 3,
'placeholder': _('e.g. Living room TV'),
'maxlength': 256,
'style': 'width:100%;'
}, [ curRemark ]);
const errorDiv = E('div', {
'class': 'alert-message danger',
'style': 'display:none; margin-bottom:15px;'
});
const formFields = isEdit ? [] : [ macRow ];
formFields.push(
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, [ _('Device type') ]),
E('div', { 'class': 'cbi-value-field' }, [
typeSelect,
E('div', { 'class': 'cbi-value-description' }, [
_('Automatically detected from names and MAC address, or customize manually')
])
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, [ _('Device group') ]),
E('div', { 'class': 'cbi-value-field' }, [
groupSelect,
E('div', { 'class': 'cbi-value-description' }, [ _('Organize this device by purpose or location') ])
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, [ _('Custom name') ]),
E('div', { 'class': 'cbi-value-field' }, [
nameInput,
E('div', { 'class': 'cbi-value-description' }, [ _('Leave empty to display the default hostname') ])
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, [ _('Device remarks') ]),
E('div', { 'class': 'cbi-value-field' }, [
remarkTextarea,
E('div', { 'class': 'cbi-value-description' }, [ _('Record the location, purpose or owner of this device') ])
])
])
);
const btnCancel = E('button', { 'type': 'button',
'class': 'btn cbi-button',
'click': ui.hideModal
}, [ _('Cancel') ]);
const btnSave = E('button', { 'type': 'button',
'class': 'btn cbi-button cbi-button-action important dm-primary-button',
'click': function(ev) {
const button = ev.currentTarget;
const targetMac = isEdit ? curMac : (macInput ? macInput.value : '');
const normMac = model.normalizeMac(targetMac);
if (!normMac) {
errorDiv.textContent = _('Enter a valid MAC address (e.g. AA:BB:CC:11:22:33)');
errorDiv.style.display = 'block';
if (macInput) macInput.focus();
return;
}
errorDiv.style.display = 'none';
const newName = model.sanitizeInput(nameInput.value);
const newRemark = model.sanitizeInput(remarkTextarea.value);
const newGroup = groupSelect.value || 'ungrouped';
const newType = typeSelect.value || 'auto';
button.classList.add('spinning');
button.disabled = true;
return self.handleSaveDevice(normMac, newName, newRemark, newGroup, dev, newType)
.then(function() {
ui.hideModal();
ui.addNotification(null, E('p', [ _('Device "%s" saved.').format(newName || normMac) ]), 'info');
return self.refresh();
})
.catch(function(err) {
errorDiv.textContent = _('Save failed: %s').format(err.message || err);
errorDiv.style.display = 'block';
})
.finally(function() {
button.classList.remove('spinning');
button.disabled = false;
});
}
}, [ _('Save') ]);
const buttonRow = [];
if (isEdit && dev.isSaved) {
const btnDelete = E('button', { 'type': 'button',
'class': 'btn cbi-button cbi-button-remove dm-clear-record',
'click': function() {
ui.hideModal();
self.confirmDelete(dev);
}
}, [ _('Clear record') ]);
buttonRow.push(btnDelete);
}
buttonRow.push(E('div', { 'class': 'right dm-dialog-buttons' }, [ btnCancel, btnSave ]));
const content = [ closeButton(), errorDiv ];
if (isEdit) content.push(deviceHero(dev), infoGrid([
[ _('MAC address'), curMac ], [ _('System hostname'), curHostname ],
[ _('Current IP address'), curIp ], [ _('Current status'), statusLabel(dev.status) ]
]));
content.push(E('div', { 'class': 'dm-edit-fields' }, formFields),
E('div', { 'class': 'dm-dialog-footer' }, buttonRow));
ui.showModal(modalTitle, [ E('div', { 'class': 'dm-dialog' }, content) ], 'dm-device-modal');
},
confirmDelete: function(dev) {
if (this.readonly) return;
const self = this;
const name = dev.customName || dev.hostname || dev.mac;
ui.showModal(_('Clear record'), [ E('div', { 'class': 'dm-dialog' }, [
closeButton(),
deviceHero(dev),
E('p', { 'class': 'dm-clear-description' }, [ _('Clear the saved record for "%s" (%s)?').format(name, dev.mac) ]),
E('p', { 'class': 'dm-dialog-muted' }, [ _('Clears the custom name, remarks, group and device type. The device remains discoverable on the network.') ]),
E('div', { 'class': 'right dm-dialog-footer dm-dialog-buttons' }, [
E('button', { 'type': 'button', 'class': 'btn cbi-button', 'click': ui.hideModal }, [ _('Cancel') ]),
' ',
E('button', { 'type': 'button',
'class': 'btn cbi-button cbi-button-remove',
'click': function(ev) {
const button = ev.currentTarget;
button.classList.add('spinning');
button.disabled = true;
return self.handleDeleteDevice(dev)
.then(function() {
ui.hideModal();
ui.addNotification(null, E('p', [ _('Saved record for "%s" cleared.').format(name) ]), 'info');
return self.refresh();
})
.catch(function(err) {
ui.hideModal();
ui.addNotification(null, E('p', [ _('Clear failed: %s').format(err.message || err) ]), 'danger');
}).finally(function() {
button.classList.remove('spinning');
button.disabled = false;
});
}
}, [ _('Confirm clear') ])
])
]) ], 'dm-device-modal');
},
showDetailModal: function(dev) {
const self = this;
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…') ]);
const pingDetail = E('p', { 'class': 'dm-dialog-muted' });
const pingOutput = E('pre', { 'class': 'dm-ping-output' });
const pingLog = E('details', { 'class': 'dm-ping-log', 'style': 'display:none;' }, [
E('summary', {}, [ _('Ping output') ]), pingOutput
]);
let pending = null;
const runPing = () => {
if (pending) return pending;
pingButton.disabled = true;
pingButton.classList.add('spinning');
pingStatus.classList.remove('dm-ping-success');
pingStatus.classList.remove('dm-ping-warning');
pingStatus.textContent = _('Probing…');
pingDetail.textContent = _('Sending one Ping from the router to this device.');
pingLog.style.display = 'none';
pending = Promise.resolve().then(() => self.handlePingDevice(dev)).then(reply => {
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 + ')' : '')) +
(reply.reachable ? '' : '\n' + _('No ICMP reply was received. The device may block Ping; this does not confirm it is offline.'));
pingOutput.textContent = reply.output;
pingLog.style.display = reply.output ? '' : 'none';
}).catch(error => {
pingStatus.textContent = _('Probe unavailable');
pingStatus.classList.add('dm-ping-warning');
pingDetail.textContent = error.message || String(error);
pingOutput.textContent = error.output || '';
pingLog.style.display = error.output ? '' : 'none';
}).finally(() => {
pending = null;
pingButton.disabled = false;
pingButton.classList.remove('spinning');
});
return pending;
};
const pingButton = E('button', { 'type': 'button', 'class': 'cbi-button cbi-button-action', 'click': runPing }, [ _('Ping again') ]);
const sourceLabels = {
'DHCP': _('DHCP'), 'Host hints': _('Host hints'), 'Wi-Fi': _('Wi-Fi'),
'ARP': _('ARP'), 'Neighbor table': _('Neighbor table')
};
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),
E('section', { 'class': 'dm-ping-card' }, [
E('div', { 'class': 'dm-ping-heading' }, [ pingStatus, pingButton ]), pingDetail, pingLog
]),
infoGrid([
[ _('MAC address'), dev.mac ], [ _('System hostname'), dev.hostname ],
[ _('Custom name'), dev.customName ], [ _('Device group'), self.getGroupName(dev.group) ],
[ _('IPv4 addresses'), addresses('ipv4') ], [ _('IPv6 addresses'), addresses('ipv6') ],
[ _('Network interfaces'), (dev.interfaces || []).join(', ') ],
[ _('Type detection'), dev.customType && dev.customType !== 'auto' ? _('Manual') : _('Automatic') ],
[ _('Saved record'), dev.isSaved ? _('Yes') : _('No') ],
[ _('Discovered on network'), dev.isDiscovered ? _('Yes') : _('No') ],
[ _('Discovery sources'), (dev.discoverySources || []).map(source => sourceLabels[source] || source).join(', '), true ],
[ _('Status evidence'), dev.statusDetail, true ],
[ _('Neighbor records'), neighbors, true ], [ _('Device remarks'), dev.remark, true ]
]),
E('div', { 'class': 'right dm-dialog-footer dm-dialog-buttons' }, [
E('button', { 'type': 'button', 'class': 'cbi-button', 'click': ui.hideModal }, [ _('Close') ])
])
]) ], 'dm-device-modal');
return runPing();
}
});
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@@ -0,0 +1,12 @@
Material Design Icons
https://github.com/Templarian/MaterialDesign
By the Pictogrammers icon group and Material Design Icons contributors.
The SVG path geometry in this directory was verified against upstream revision
2424e748e0cc63ab7b9c095a099b9fe239b737c0. Original icon names and source URLs are
listed in sources.json. The Apache License, Version 2.0 and upstream license
summary are included as LICENSE and UPSTREAM-LICENSE.
Local modifications: filenames map to device types; width and height are set
to 24 pixels; CSS selects foreground colours for light and dark browser themes.
SPDX license comments were added. No SVG path geometry was changed.
@@ -0,0 +1,20 @@
Pictogrammers Free License
--------------------------
This icon collection is released as free, open source, and GPL friendly by
the [Pictogrammers](http://pictogrammers.com/) icon group. You may use it
for commercial projects, open source projects, or anything really.
# Icons: Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0)
Some of the icons are redistributed under the Apache 2.0 license. All other
icons are either redistributed under their respective licenses or are
distributed under the Apache 2.0 license.
# Fonts: Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0)
All web and desktop fonts are distributed under the Apache 2.0 license. Web
and desktop fonts contain some icons that are redistributed under the Apache
2.0 license. All other icons are either redistributed under their respective
licenses or are distributed under the Apache 2.0 license.
# Code: MIT (https://opensource.org/licenses/MIT)
The MIT license applies to all non-font and non-icon files.
@@ -0,0 +1,12 @@
<!-- SPDX-License-Identifier: Apache-2.0 -->
<svg
class="mdi mdi-air-conditioner"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
>
<style>:root{color:#374151;}@media(prefers-color-scheme:dark){:root{color:#e5e7eb;}}</style>
<path d="M6.59,0.66C8.93,-1.15 11.47,1.06 12.04,4.5C12.47,4.5 12.89,4.62 13.27,4.84C13.79,4.24 14.25,3.42 14.07,2.5C13.65,0.35 16.06,-1.39 18.35,1.58C20.16,3.92 17.95,6.46 14.5,7.03C14.5,7.46 14.39,7.89 14.16,8.27C14.76,8.78 15.58,9.24 16.5,9.06C18.63,8.64 20.38,11.04 17.41,13.34C15.07,15.15 12.53,12.94 11.96,9.5C11.53,9.5 11.11,9.37 10.74,9.15C10.22,9.75 9.75,10.58 9.93,11.5C10.35,13.64 7.94,15.39 5.65,12.42C3.83,10.07 6.05,7.53 9.5,6.97C9.5,6.54 9.63,6.12 9.85,5.74C9.25,5.23 8.43,4.76 7.5,4.94C5.37,5.36 3.62,2.96 6.59,0.66M5,16H7A2,2 0 0,1 9,18V24H7V22H5V24H3V18A2,2 0 0,1 5,16M5,18V20H7V18H5M12.93,16H15L12.07,24H10L12.93,16M18,16H21V18H18V22H21V24H18A2,2 0 0,1 16,22V18A2,2 0 0,1 18,16Z" />
</svg>

After

Width:  |  Height:  |  Size: 999 B

@@ -0,0 +1,12 @@
<!-- SPDX-License-Identifier: Apache-2.0 -->
<svg
class="mdi mdi-air-purifier"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
>
<style>:root{color:#374151;}@media(prefers-color-scheme:dark){:root{color:#e5e7eb;}}</style>
<path d="M11,9A4,4 0 0,1 15,13A4,4 0 0,1 11,17A4,4 0 0,1 7,13A4,4 0 0,1 11,9M11,11A2,2 0 0,0 9,13A2,2 0 0,0 11,15A2,2 0 0,0 13,13A2,2 0 0,0 11,11M7,4H14A4,4 0 0,1 18,8V9H16V8A2,2 0 0,0 14,6H7A2,2 0 0,0 5,8V20H16V18H18V22H3V8A4,4 0 0,1 7,4M16,11C18.5,11 18.5,9 21,9V11C18.5,11 18.5,13 16,13V11M16,15C18.5,15 18.5,13 21,13V15C18.5,15 18.5,17 16,17V15Z" />
</svg>

After

Width:  |  Height:  |  Size: 649 B

@@ -0,0 +1,12 @@
<!-- SPDX-License-Identifier: Apache-2.0 -->
<svg
class="mdi mdi-access-point"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
>
<style>:root{color:#374151;}@media(prefers-color-scheme:dark){:root{color:#e5e7eb;}}</style>
<path d="M4.93,4.93C3.12,6.74 2,9.24 2,12C2,14.76 3.12,17.26 4.93,19.07L6.34,17.66C4.89,16.22 4,14.22 4,12C4,9.79 4.89,7.78 6.34,6.34L4.93,4.93M19.07,4.93L17.66,6.34C19.11,7.78 20,9.79 20,12C20,14.22 19.11,16.22 17.66,17.66L19.07,19.07C20.88,17.26 22,14.76 22,12C22,9.24 20.88,6.74 19.07,4.93M7.76,7.76C6.67,8.85 6,10.35 6,12C6,13.65 6.67,15.15 7.76,16.24L9.17,14.83C8.45,14.11 8,13.11 8,12C8,10.89 8.45,9.89 9.17,9.17L7.76,7.76M16.24,7.76L14.83,9.17C15.55,9.89 16,10.89 16,12C16,13.11 15.55,14.11 14.83,14.83L16.24,16.24C17.33,15.15 18,13.65 18,12C18,10.35 17.33,8.85 16.24,7.76M12,10A2,2 0 0,0 10,12A2,2 0 0,0 12,14A2,2 0 0,0 14,12A2,2 0 0,0 12,10Z" />
</svg>

After

Width:  |  Height:  |  Size: 950 B

@@ -0,0 +1,12 @@
<!-- SPDX-License-Identifier: Apache-2.0 -->
<svg
class="mdi mdi-cctv"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
>
<style>:root{color:#374151;}@media(prefers-color-scheme:dark){:root{color:#e5e7eb;}}</style>
<path d="M6.03 12.03L8.03 15.5L5.5 18.68L2 12.62L6.03 12.03M17 18V15.29C17.88 14.9 18.5 14.03 18.5 13C18.5 12.43 18.3 11.9 17.97 11.5L19.94 10.35C20.95 9.76 21.3 8.47 20.71 7.46L19.33 5.06C18.74 4.05 17.45 3.7 16.44 4.28L8.31 9C7.36 9.53 7.03 10.75 7.58 11.71L9.08 14.31C9.63 15.26 10.86 15.59 11.81 15.04L13.69 13.96C13.94 14.55 14.41 15.03 15 15.29V18C15 19.1 15.9 20 17 20H22V18H17Z" />
</svg>

After

Width:  |  Height:  |  Size: 677 B

@@ -0,0 +1,12 @@
<!-- SPDX-License-Identifier: Apache-2.0 -->
<svg
class="mdi mdi-car"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
>
<style>:root{color:#374151;}@media(prefers-color-scheme:dark){:root{color:#e5e7eb;}}</style>
<path d="M5,11L6.5,6.5H17.5L19,11M17.5,16A1.5,1.5 0 0,1 16,14.5A1.5,1.5 0 0,1 17.5,13A1.5,1.5 0 0,1 19,14.5A1.5,1.5 0 0,1 17.5,16M6.5,16A1.5,1.5 0 0,1 5,14.5A1.5,1.5 0 0,1 6.5,13A1.5,1.5 0 0,1 8,14.5A1.5,1.5 0 0,1 6.5,16M18.92,6C18.72,5.42 18.16,5 17.5,5H6.5C5.84,5 5.28,5.42 5.08,6L3,12V20A1,1 0 0,0 4,21H5A1,1 0 0,0 6,20V19H18V20A1,1 0 0,0 19,21H20A1,1 0 0,0 21,20V12L18.92,6Z" />
</svg>

After

Width:  |  Height:  |  Size: 669 B

@@ -0,0 +1,12 @@
<!-- SPDX-License-Identifier: Apache-2.0 -->
<svg
class="mdi mdi-monitor"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
>
<style>:root{color:#374151;}@media(prefers-color-scheme:dark){:root{color:#e5e7eb;}}</style>
<path d="M21,16H3V4H21M21,2H3C1.89,2 1,2.89 1,4V16A2,2 0 0,0 3,18H10V20H8V22H16V20H14V18H21A2,2 0 0,0 23,16V4C23,2.89 22.1,2 21,2Z" />
</svg>

After

Width:  |  Height:  |  Size: 425 B

@@ -0,0 +1,12 @@
<!-- SPDX-License-Identifier: Apache-2.0 -->
<svg
class="mdi mdi-fridge"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
>
<style>:root{color:#374151;}@media(prefers-color-scheme:dark){:root{color:#e5e7eb;}}</style>
<path d="M7,2H17A2,2 0 0,1 19,4V9H5V4A2,2 0 0,1 7,2M19,19A2,2 0 0,1 17,21V22H15V21H9V22H7V21A2,2 0 0,1 5,19V10H19V19M8,5V7H10V5H8M8,12V15H10V12H8Z" />
</svg>

After

Width:  |  Height:  |  Size: 440 B

@@ -0,0 +1,12 @@
<!-- SPDX-License-Identifier: Apache-2.0 -->
<svg
class="mdi mdi-controller"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
>
<style>:root{color:#374151;}@media(prefers-color-scheme:dark){:root{color:#e5e7eb;}}</style>
<path d="M7.97,16L5,19C4.67,19.3 4.23,19.5 3.75,19.5A1.75,1.75 0 0,1 2,17.75V17.5L3,10.12C3.21,7.81 5.14,6 7.5,6H16.5C18.86,6 20.79,7.81 21,10.12L22,17.5V17.75A1.75,1.75 0 0,1 20.25,19.5C19.77,19.5 19.33,19.3 19,19L16.03,16H7.97M7,8V10H5V11H7V13H8V11H10V10H8V8H7M16.5,8A0.75,0.75 0 0,0 15.75,8.75A0.75,0.75 0 0,0 16.5,9.5A0.75,0.75 0 0,0 17.25,8.75A0.75,0.75 0 0,0 16.5,8M14.75,9.75A0.75,0.75 0 0,0 14,10.5A0.75,0.75 0 0,0 14.75,11.25A0.75,0.75 0 0,0 15.5,10.5A0.75,0.75 0 0,0 14.75,9.75M18.25,9.75A0.75,0.75 0 0,0 17.5,10.5A0.75,0.75 0 0,0 18.25,11.25A0.75,0.75 0 0,0 19,10.5A0.75,0.75 0 0,0 18.25,9.75M16.5,11.5A0.75,0.75 0 0,0 15.75,12.25A0.75,0.75 0 0,0 16.5,13A0.75,0.75 0 0,0 17.25,12.25A0.75,0.75 0 0,0 16.5,11.5Z" />
</svg>

After

Width:  |  Height:  |  Size: 1018 B

@@ -0,0 +1,12 @@
<!-- SPDX-License-Identifier: Apache-2.0 -->
<svg
class="mdi mdi-home-automation"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
>
<style>:root{color:#374151;}@media(prefers-color-scheme:dark){:root{color:#e5e7eb;}}</style>
<path d="M12,3L2,12H5V20H19V12H22L12,3M12,8.5C14.34,8.5 16.46,9.43 18,10.94L16.8,12.12C15.58,10.91 13.88,10.17 12,10.17C10.12,10.17 8.42,10.91 7.2,12.12L6,10.94C7.54,9.43 9.66,8.5 12,8.5M12,11.83C13.4,11.83 14.67,12.39 15.6,13.3L14.4,14.47C13.79,13.87 12.94,13.5 12,13.5C11.06,13.5 10.21,13.87 9.6,14.47L8.4,13.3C9.33,12.39 10.6,11.83 12,11.83M12,15.17C12.94,15.17 13.7,15.91 13.7,16.83C13.7,17.75 12.94,18.5 12,18.5C11.06,18.5 10.3,17.75 10.3,16.83C10.3,15.91 11.06,15.17 12,15.17Z" />
</svg>

After

Width:  |  Height:  |  Size: 785 B

@@ -0,0 +1,12 @@
<!-- SPDX-License-Identifier: Apache-2.0 -->
<svg
class="mdi mdi-laptop"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
>
<style>:root{color:#374151;}@media(prefers-color-scheme:dark){:root{color:#e5e7eb;}}</style>
<path d="M4,6H20V16H4M20,18A2,2 0 0,0 22,16V6C22,4.89 21.1,4 20,4H4C2.89,4 2,4.89 2,6V16A2,2 0 0,0 4,18H0V20H24V18H20Z" />
</svg>

After

Width:  |  Height:  |  Size: 412 B

@@ -0,0 +1,12 @@
<!-- SPDX-License-Identifier: Apache-2.0 -->
<svg
class="mdi mdi-lightbulb"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
>
<style>:root{color:#374151;}@media(prefers-color-scheme:dark){:root{color:#e5e7eb;}}</style>
<path d="M12,2A7,7 0 0,0 5,9C5,11.38 6.19,13.47 8,14.74V17A1,1 0 0,0 9,18H15A1,1 0 0,0 16,17V14.74C17.81,13.47 19,11.38 19,9A7,7 0 0,0 12,2M9,21A1,1 0 0,0 10,22H14A1,1 0 0,0 15,21V20H9V21Z" />
</svg>

After

Width:  |  Height:  |  Size: 485 B

@@ -0,0 +1,12 @@
<!-- SPDX-License-Identifier: Apache-2.0 -->
<svg
class="mdi mdi-nas"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
>
<style>:root{color:#374151;}@media(prefers-color-scheme:dark){:root{color:#e5e7eb;}}</style>
<path d="M4,5C2.89,5 2,5.89 2,7V17C2,18.11 2.89,19 4,19H20C21.11,19 22,18.11 22,17V7C22,5.89 21.11,5 20,5H4M4.5,7A1,1 0 0,1 5.5,8A1,1 0 0,1 4.5,9A1,1 0 0,1 3.5,8A1,1 0 0,1 4.5,7M7,7H20V17H7V7M8,8V16H11V8H8M12,8V16H15V8H12M16,8V16H19V8H16M9,9H10V10H9V9M13,9H14V10H13V9M17,9H18V10H17V9Z" />
</svg>

After

Width:  |  Height:  |  Size: 575 B

@@ -0,0 +1,12 @@
<!-- SPDX-License-Identifier: Apache-2.0 -->
<svg
class="mdi mdi-lan"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
>
<style>:root{color:#374151;}@media(prefers-color-scheme:dark){:root{color:#e5e7eb;}}</style>
<path d="M10,2C8.89,2 8,2.89 8,4V7C8,8.11 8.89,9 10,9H11V11H2V13H6V15H5C3.89,15 3,15.89 3,17V20C3,21.11 3.89,22 5,22H9C10.11,22 11,21.11 11,20V17C11,15.89 10.11,15 9,15H8V13H16V15H15C13.89,15 13,15.89 13,17V20C13,21.11 13.89,22 15,22H19C20.11,22 21,21.11 21,20V17C21,15.89 20.11,15 19,15H18V13H22V11H13V9H14C15.11,9 16,8.11 16,7V4C16,2.89 15.11,2 14,2H10M10,4H14V7H10V4M5,17H9V20H5V17M15,17H19V20H15V17Z" />
</svg>

After

Width:  |  Height:  |  Size: 694 B

@@ -0,0 +1,12 @@
<!-- SPDX-License-Identifier: Apache-2.0 -->
<svg
class="mdi mdi-cellphone"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
>
<style>:root{color:#374151;}@media(prefers-color-scheme:dark){:root{color:#e5e7eb;}}</style>
<path d="M17,19H7V5H17M17,1H7C5.89,1 5,1.89 5,3V21A2,2 0 0,0 7,23H17A2,2 0 0,0 19,21V3C19,1.89 18.1,1 17,1Z" />
</svg>

After

Width:  |  Height:  |  Size: 404 B

@@ -0,0 +1,12 @@
<!-- SPDX-License-Identifier: Apache-2.0 -->
<svg
class="mdi mdi-power-plug"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
>
<style>:root{color:#374151;}@media(prefers-color-scheme:dark){:root{color:#e5e7eb;}}</style>
<path d="M16,7V3H14V7H10V3H8V7H8C7,7 6,8 6,9V14.5L9.5,18V21H14.5V18L18,14.5V9C18,8 17,7 16,7Z" />
</svg>

After

Width:  |  Height:  |  Size: 391 B

@@ -0,0 +1,12 @@
<!-- SPDX-License-Identifier: Apache-2.0 -->
<svg
class="mdi mdi-printer"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
>
<style>:root{color:#374151;}@media(prefers-color-scheme:dark){:root{color:#e5e7eb;}}</style>
<path d="M18,3H6V7H18M19,12A1,1 0 0,1 18,11A1,1 0 0,1 19,10A1,1 0 0,1 20,11A1,1 0 0,1 19,12M16,19H8V14H16M19,8H5A3,3 0 0,0 2,11V17H6V21H18V17H22V11A3,3 0 0,0 19,8Z" />
</svg>

After

Width:  |  Height:  |  Size: 458 B

@@ -0,0 +1,12 @@
<!-- SPDX-License-Identifier: Apache-2.0 -->
<svg
class="mdi mdi-router-wireless"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
>
<style>:root{color:#374151;}@media(prefers-color-scheme:dark){:root{color:#e5e7eb;}}</style>
<path d="M20.2,5.9L21,5.1C19.6,3.7 17.8,3 16,3C14.2,3 12.4,3.7 11,5.1L11.8,5.9C13,4.8 14.5,4.2 16,4.2C17.5,4.2 19,4.8 20.2,5.9M19.3,6.7C18.4,5.8 17.2,5.3 16,5.3C14.8,5.3 13.6,5.8 12.7,6.7L13.5,7.5C14.2,6.8 15.1,6.5 16,6.5C16.9,6.5 17.8,6.8 18.5,7.5L19.3,6.7M19,13H17V9H15V13H5A2,2 0 0,0 3,15V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V15A2,2 0 0,0 19,13M8,18H6V16H8V18M11.5,18H9.5V16H11.5V18M15,18H13V16H15V18Z" />
</svg>

After

Width:  |  Height:  |  Size: 707 B

@@ -0,0 +1,12 @@
<!-- SPDX-License-Identifier: Apache-2.0 -->
<svg
class="mdi mdi-gauge"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
>
<style>:root{color:#374151;}@media(prefers-color-scheme:dark){:root{color:#e5e7eb;}}</style>
<path d="M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,4A8,8 0 0,1 20,12C20,14.4 19,16.5 17.3,18C15.9,16.7 14,16 12,16C10,16 8.2,16.7 6.7,18C5,16.5 4,14.4 4,12A8,8 0 0,1 12,4M14,5.89C13.62,5.9 13.26,6.15 13.1,6.54L11.81,9.77L11.71,10C11,10.13 10.41,10.6 10.14,11.26C9.73,12.29 10.23,13.45 11.26,13.86C12.29,14.27 13.45,13.77 13.86,12.74C14.12,12.08 14,11.32 13.57,10.76L13.67,10.5L14.96,7.29L14.97,7.26C15.17,6.75 14.92,6.17 14.41,5.96C14.28,5.91 14.15,5.89 14,5.89M10,6A1,1 0 0,0 9,7A1,1 0 0,0 10,8A1,1 0 0,0 11,7A1,1 0 0,0 10,6M7,9A1,1 0 0,0 6,10A1,1 0 0,0 7,11A1,1 0 0,0 8,10A1,1 0 0,0 7,9M17,9A1,1 0 0,0 16,10A1,1 0 0,0 17,11A1,1 0 0,0 18,10A1,1 0 0,0 17,9Z" />
</svg>

After

Width:  |  Height:  |  Size: 987 B

@@ -0,0 +1,12 @@
<!-- SPDX-License-Identifier: Apache-2.0 -->
<svg
class="mdi mdi-server"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
>
<style>:root{color:#374151;}@media(prefers-color-scheme:dark){:root{color:#e5e7eb;}}</style>
<path d="M4,1H20A1,1 0 0,1 21,2V6A1,1 0 0,1 20,7H4A1,1 0 0,1 3,6V2A1,1 0 0,1 4,1M4,9H20A1,1 0 0,1 21,10V14A1,1 0 0,1 20,15H4A1,1 0 0,1 3,14V10A1,1 0 0,1 4,9M4,17H20A1,1 0 0,1 21,18V22A1,1 0 0,1 20,23H4A1,1 0 0,1 3,22V18A1,1 0 0,1 4,17M9,5H10V3H9V5M9,13H10V11H9V13M9,21H10V19H9V21M5,3V5H7V3H5M5,11V13H7V11H5M5,19V21H7V19H5Z" />
</svg>

After

Width:  |  Height:  |  Size: 616 B

@@ -0,0 +1,121 @@
{
"revision": "2424e748e0cc63ab7b9c095a099b9fe239b737c0",
"icons": {
"sensor.svg": {
"name": "gauge",
"source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/gauge.svg"
},
"light.svg": {
"name": "lightbulb",
"source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/lightbulb.svg"
},
"computer.svg": {
"name": "monitor",
"source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/monitor.svg"
},
"fridge.svg": {
"name": "fridge",
"source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/fridge.svg"
},
"tablet.svg": {
"name": "tablet",
"source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/tablet.svg"
},
"airpurifier.svg": {
"name": "air-purifier",
"source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/air-purifier.svg"
},
"washer.svg": {
"name": "washing-machine",
"source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/washing-machine.svg"
},
"camera.svg": {
"name": "cctv",
"source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/cctv.svg"
},
"laptop.svg": {
"name": "laptop",
"source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/laptop.svg"
},
"speaker.svg": {
"name": "speaker",
"source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/speaker.svg"
},
"tv.svg": {
"name": "television",
"source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/television.svg"
},
"phone.svg": {
"name": "cellphone",
"source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/cellphone.svg"
},
"waterpurifier.svg": {
"name": "water-check",
"source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/water-check.svg"
},
"tvbox.svg": {
"name": "set-top-box",
"source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/set-top-box.svg"
},
"network.svg": {
"name": "lan",
"source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/lan.svg"
},
"airconditioner.svg": {
"name": "air-conditioner",
"source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/air-conditioner.svg"
},
"watch.svg": {
"name": "watch-variant",
"source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/watch-variant.svg"
},
"vr.svg": {
"name": "virtual-reality",
"source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/virtual-reality.svg"
},
"nas.svg": {
"name": "nas",
"source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/nas.svg"
},
"plug.svg": {
"name": "power-plug",
"source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/power-plug.svg"
},
"router.svg": {
"name": "router-wireless",
"source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/router-wireless.svg"
},
"home.svg": {
"name": "home-automation",
"source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/home-automation.svg"
},
"car.svg": {
"name": "car",
"source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/car.svg"
},
"server.svg": {
"name": "server",
"source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/server.svg"
},
"ap.svg": {
"name": "access-point",
"source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/access-point.svg"
},
"unknown.svg": {
"name": "help-circle-outline",
"source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/help-circle-outline.svg"
},
"printer.svg": {
"name": "printer",
"source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/printer.svg"
},
"game.svg": {
"name": "controller",
"source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/controller.svg"
},
"switch.svg": {
"name": "ethernet",
"source": "https://raw.githubusercontent.com/Templarian/MaterialDesign/2424e748e0cc63ab7b9c095a099b9fe239b737c0/svg/ethernet.svg"
}
}
}
@@ -0,0 +1,12 @@
<!-- SPDX-License-Identifier: Apache-2.0 -->
<svg
class="mdi mdi-speaker"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
>
<style>:root{color:#374151;}@media(prefers-color-scheme:dark){:root{color:#e5e7eb;}}</style>
<path d="M12,12A3,3 0 0,0 9,15A3,3 0 0,0 12,18A3,3 0 0,0 15,15A3,3 0 0,0 12,12M12,20A5,5 0 0,1 7,15A5,5 0 0,1 12,10A5,5 0 0,1 17,15A5,5 0 0,1 12,20M12,4A2,2 0 0,1 14,6A2,2 0 0,1 12,8C10.89,8 10,7.1 10,6C10,4.89 10.89,4 12,4M17,2H7C5.89,2 5,2.89 5,4V20A2,2 0 0,0 7,22H17A2,2 0 0,0 19,20V4C19,2.89 18.1,2 17,2Z" />
</svg>

After

Width:  |  Height:  |  Size: 603 B

@@ -0,0 +1,12 @@
<!-- SPDX-License-Identifier: Apache-2.0 -->
<svg
class="mdi mdi-ethernet"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
>
<style>:root{color:#374151;}@media(prefers-color-scheme:dark){:root{color:#e5e7eb;}}</style>
<path d="M7,15H9V18H11V15H13V18H15V15H17V18H19V9H15V6H9V9H5V18H7V15M4.38,3H19.63C20.94,3 22,4.06 22,5.38V19.63A2.37,2.37 0 0,1 19.63,22H4.38C3.06,22 2,20.94 2,19.63V5.38C2,4.06 3.06,3 4.38,3Z" />
</svg>

After

Width:  |  Height:  |  Size: 487 B

@@ -0,0 +1,12 @@
<!-- SPDX-License-Identifier: Apache-2.0 -->
<svg
class="mdi mdi-tablet"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
>
<style>:root{color:#374151;}@media(prefers-color-scheme:dark){:root{color:#e5e7eb;}}</style>
<path d="M19,18H5V6H19M21,4H3C1.89,4 1,4.89 1,6V18A2,2 0 0,0 3,20H21A2,2 0 0,0 23,18V6C23,4.89 22.1,4 21,4Z" />
</svg>

After

Width:  |  Height:  |  Size: 401 B

@@ -0,0 +1,12 @@
<!-- SPDX-License-Identifier: Apache-2.0 -->
<svg
class="mdi mdi-television"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
>
<style>:root{color:#374151;}@media(prefers-color-scheme:dark){:root{color:#e5e7eb;}}</style>
<path d="M21,17H3V5H21M21,3H3A2,2 0 0,0 1,5V17A2,2 0 0,0 3,19H8V21H16V19H21A2,2 0 0,0 23,17V5A2,2 0 0,0 21,3Z" />
</svg>

After

Width:  |  Height:  |  Size: 407 B

@@ -0,0 +1,12 @@
<!-- SPDX-License-Identifier: Apache-2.0 -->
<svg
class="mdi mdi-set-top-box"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
>
<style>:root{color:#374151;}@media(prefers-color-scheme:dark){:root{color:#e5e7eb;}}</style>
<path d="M5,15.5A0.5,0.5 0 0,1 4.5,16H3.5A0.5,0.5 0 0,1 3,15.5V15H2A1,1 0 0,1 1,14V11A1,1 0 0,1 2,10H22A1,1 0 0,1 23,11V14A1,1 0 0,1 22,15H21V15.5A0.5,0.5 0 0,1 20.5,16H19.5A0.5,0.5 0 0,1 19,15.5V15H5V15.5M3,12V13H5V12H3M6,12V13H8V12H6M20.5,11.5A1,1 0 0,0 19.5,12.5A1,1 0 0,0 20.5,13.5A1,1 0 0,0 21.5,12.5A1,1 0 0,0 20.5,11.5Z" />
</svg>

After

Width:  |  Height:  |  Size: 625 B

@@ -0,0 +1,12 @@
<!-- SPDX-License-Identifier: Apache-2.0 -->
<svg
class="mdi mdi-help-circle-outline"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
>
<style>:root{color:#374151;}@media(prefers-color-scheme:dark){:root{color:#e5e7eb;}}</style>
<path d="M11,18H13V16H11V18M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,6A4,4 0 0,0 8,10H10A2,2 0 0,1 12,8A2,2 0 0,1 14,10C14,12 11,11.75 11,15H13C13,12.75 16,12.5 16,10A4,4 0 0,0 12,6Z" />
</svg>

After

Width:  |  Height:  |  Size: 618 B

@@ -0,0 +1,12 @@
<!-- SPDX-License-Identifier: Apache-2.0 -->
<svg
class="mdi mdi-virtual-reality"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
>
<style>:root{color:#374151;}@media(prefers-color-scheme:dark){:root{color:#e5e7eb;}}</style>
<path d="M5,3C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5A2,2 0 0,0 19,3H5M6,9H7.5L8.5,12.43L9.5,9H11L9.25,15H7.75L6,9M13,9H16.5C17.35,9 18,9.65 18,10.5V11.5C18,12.1 17.6,12.65 17.1,12.9L18,15H16.5L15.65,13H14.5V15H13V9M14.5,10.5V11.5H16.5V10.5H14.5Z" />
</svg>

After

Width:  |  Height:  |  Size: 565 B

@@ -0,0 +1,12 @@
<!-- SPDX-License-Identifier: Apache-2.0 -->
<svg
class="mdi mdi-washing-machine"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
>
<style>:root{color:#374151;}@media(prefers-color-scheme:dark){:root{color:#e5e7eb;}}</style>
<path d="M14.83,11.17C16.39,12.73 16.39,15.27 14.83,16.83C13.27,18.39 10.73,18.39 9.17,16.83L14.83,11.17M6,2H18A2,2 0 0,1 20,4V20A2,2 0 0,1 18,22H6A2,2 0 0,1 4,20V4A2,2 0 0,1 6,2M7,4A1,1 0 0,0 6,5A1,1 0 0,0 7,6A1,1 0 0,0 8,5A1,1 0 0,0 7,4M10,4A1,1 0 0,0 9,5A1,1 0 0,0 10,6A1,1 0 0,0 11,5A1,1 0 0,0 10,4M12,8A6,6 0 0,0 6,14A6,6 0 0,0 12,20A6,6 0 0,0 18,14A6,6 0 0,0 12,8Z" />
</svg>

After

Width:  |  Height:  |  Size: 673 B

@@ -0,0 +1,12 @@
<!-- SPDX-License-Identifier: Apache-2.0 -->
<svg
class="mdi mdi-watch-variant"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
>
<style>:root{color:#374151;}@media(prefers-color-scheme:dark){:root{color:#e5e7eb;}}</style>
<path d="M8,0L7.17,5H7A2,2 0 0,0 5,7V17C5,18.11 5.9,19 7,19H7.17L8,24H16L16.83,19H17A2,2 0 0,0 19,17V7C19,5.89 18.1,5 17,5H16.83L16,0H8M7,7H17V17H7V7Z" />
</svg>

After

Width:  |  Height:  |  Size: 451 B

@@ -0,0 +1,12 @@
<!-- SPDX-License-Identifier: Apache-2.0 -->
<svg
class="mdi mdi-water-check"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
>
<style>:root{color:#374151;}@media(prefers-color-scheme:dark){:root{color:#e5e7eb;}}</style>
<path d="M21.75 16.25L17 21L14.25 18L15.41 16.84L17 18.43L20.59 14.84L21.75 16.25M17.62 12C16.31 8.1 12 3.25 12 3.25S6 10 6 14C6 17.31 8.69 20 12 20H12.34C12.12 19.36 12 18.7 12 18C12 14.82 14.5 12.22 17.62 12Z" />
</svg>

After

Width:  |  Height:  |  Size: 509 B

@@ -0,0 +1,239 @@
// SPDX-License-Identifier: MIT
'use strict';
'require baseclass';
'require ui';
'require device-manager.model as model';
return baseclass.extend({
showGroupModal: function() {
if (this.readonly) return;
const self = this;
// Calculate count of devices per group
const groupCounts = new Map();
for (let i = 0; i < this.devices.length; i++) {
const g = this.devices[i].group;
if (g && g !== 'ungrouped') {
groupCounts.set(g, (groupCounts.get(g) || 0) + 1);
}
}
const groupRows = this.groups.map(function(g) {
const count = groupCounts.get(g.id) || 0;
const btnRename = E('button', { 'type': 'button',
'class': 'btn cbi-button cbi-button-neutral',
'style': 'margin-right:6px;',
'click': function() { self.promptRenameGroup(g); }
}, [ _('Rename') ]);
const btnDelete = E('button', { 'type': 'button',
'class': 'btn cbi-button cbi-button-remove',
'click': function() { self.confirmDeleteGroup(g, count); }
}, [ _('Delete') ]);
return E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td', 'style': 'font-weight:bold;' }, [ g.name ]),
E('td', { 'class': 'td' }, E('code', {}, [ g.id ])),
E('td', { 'class': 'td' }, [ _('%d devices').format(count) ]),
E('td', { 'class': 'td', 'style': 'text-align:right;' }, [ btnRename, btnDelete ])
]);
});
const newGroupInput = E('input', {
'type': 'text',
'class': 'cbi-input-text',
'placeholder': _('e.g. Media devices, office devices'),
'maxlength': 32,
'style': 'width:240px; margin-right:8px;'
});
const errorDiv = E('div', {
'class': 'alert-message danger',
'style': 'display:none; margin-bottom:12px;'
});
const btnAddGroup = E('button', { 'type': 'button',
'class': 'btn cbi-button cbi-button-positive',
'click': function(ev) {
const button = ev.currentTarget;
const name = model.sanitizeInput(newGroupInput.value);
if (!name) {
errorDiv.textContent = _('Enter a valid group name');
errorDiv.style.display = 'block';
newGroupInput.focus();
return;
}
// Check duplicate name
if (self.groups.some(g => g.name === name)) {
errorDiv.textContent = _('A group with this name already exists');
errorDiv.style.display = 'block';
newGroupInput.focus();
return;
}
button.classList.add('spinning');
button.disabled = true;
return self.handleAddGroup(name)
.then(function() {
ui.hideModal();
ui.addNotification(null, E('p', [ _('Group "%s" created.').format(name) ]), 'info');
return self.refresh().then(function() {
self.showGroupModal();
});
})
.catch(function(err) {
errorDiv.textContent = _('Could not create group: %s').format(err.message || err);
errorDiv.style.display = 'block';
})
.finally(function() {
button.classList.remove('spinning');
button.disabled = false;
});
}
}, [ _('Add group') ]);
ui.showModal(_('Device groups'), [
E('div', { 'class': 'cbi-section' }, [
errorDiv,
E('table', { 'class': 'table', 'style': 'width:100%; margin-bottom:20px;' }, [
E('tr', { 'class': 'tr table-titles' }, [
E('th', { 'class': 'th' }, [ _('Group name') ]),
E('th', { 'class': 'th' }, [ _('Group ID') ]),
E('th', { 'class': 'th' }, [ _('Device count') ]),
E('th', { 'class': 'th', 'style': 'text-align:right;' }, [ _('Actions') ])
]),
E('tbody', {}, groupRows)
]),
E('div', { 'class': 'cbi-value', 'style': 'padding-top:10px; border-top:1px solid #eee;' }, [
E('label', { 'class': 'cbi-value-title' }, [ _('New group') ]),
E('div', { 'class': 'cbi-value-field' }, [
newGroupInput,
btnAddGroup,
E('div', { 'class': 'cbi-value-description' }, [ _('Group names support Unicode text and common symbols') ])
])
])
]),
E('div', { 'class': 'button-row', 'style': 'margin-top:20px; text-align:right;' }, [
E('button', { 'type': 'button',
'class': 'btn cbi-button',
'click': ui.hideModal
}, [ _('Close') ])
])
]);
},
promptRenameGroup: function(group) {
if (this.readonly) return;
const self = this;
const nameInput = E('input', {
'type': 'text',
'class': 'cbi-input-text',
'value': group.name,
'maxlength': 32,
'style': 'width:100%;'
});
const errorDiv = E('div', {
'class': 'alert-message danger',
'style': 'display:none; margin-bottom:12px;'
});
ui.showModal(_('Rename group'), [
E('div', { 'class': 'cbi-section' }, [
errorDiv,
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, [ _('Current group name') ]),
E('div', { 'class': 'cbi-value-field', 'style': 'padding-top:6px;' }, [ group.name ])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, [ _('New group name') ]),
E('div', { 'class': 'cbi-value-field' }, [
nameInput,
E('div', { 'class': 'cbi-value-description' }, [ _('Renaming preserves the group ID and all device assignments') ])
])
])
]),
E('div', { 'class': 'button-row', 'style': 'margin-top:20px; text-align:right;' }, [
E('button', { 'type': 'button', 'class': 'btn cbi-button', 'click': function() { self.showGroupModal(); } }, [ _('Cancel') ]),
' ',
E('button', { 'type': 'button',
'class': 'btn cbi-button cbi-button-positive',
'click': function(ev) {
const button = ev.currentTarget;
const newName = model.sanitizeInput(nameInput.value);
if (!newName) {
errorDiv.textContent = _('Enter a valid group name');
errorDiv.style.display = 'block';
nameInput.focus();
return;
}
if (newName === group.name) {
self.showGroupModal();
return;
}
button.classList.add('spinning');
button.disabled = true;
return self.handleRenameGroup(group.id, newName)
.then(function() {
ui.addNotification(null, E('p', [ _('Group renamed to "%s".').format(newName) ]), 'info');
return self.refresh().then(function() {
self.showGroupModal();
});
})
.catch(function(err) {
errorDiv.textContent = _('Rename failed: %s').format(err.message || err);
errorDiv.style.display = 'block';
})
.finally(function() {
button.classList.remove('spinning');
button.disabled = false;
});
}
}, [ _('Save changes') ])
])
]);
},
confirmDeleteGroup: function(group, deviceCount) {
if (this.readonly) return;
const self = this;
ui.showModal(_('Delete group'), [
E('p', {}, [ _('Delete group "%s"?').format(group.name) ]),
E('p', { 'class': 'cbi-value-description' }, [ _('The %d devices in this group will become ungrouped. Their names, MAC addresses and remarks will be preserved.').format(deviceCount) ]),
E('div', { 'class': 'button-row', 'style': 'margin-top:20px; text-align:right;' }, [
E('button', { 'type': 'button', 'class': 'btn cbi-button', 'click': function() { self.showGroupModal(); } }, [ _('Cancel') ]),
' ',
E('button', { 'type': 'button',
'class': 'btn cbi-button cbi-button-remove',
'click': function(ev) {
const button = ev.currentTarget;
button.classList.add('spinning');
button.disabled = true;
return self.handleDeleteGroup(group.id)
.then(function() {
ui.addNotification(null, E('p', [ _('Group "%s" deleted. Its devices are now ungrouped.').format(group.name) ]), 'info');
return self.refresh().then(function() {
self.showGroupModal();
});
})
.catch(function(err) {
ui.hideModal();
ui.addNotification(null, E('p', [ _('Could not delete group: %s').format(err.message || err) ]), 'danger');
}).finally(function() {
button.classList.remove('spinning');
button.disabled = false;
});
}
}, [ _('Confirm delete') ])
])
]);
}
});
@@ -0,0 +1,385 @@
// SPDX-License-Identifier: MIT
'use strict';
'require baseclass';
const ACTIVE_STATES = [ 'REACHABLE', 'DELAY', 'PROBE' ];
const STATE_PRIORITY = { REACHABLE: 6, DELAY: 5, PROBE: 4, STALE: 3, PERMANENT: 2, NOARP: 2, INCOMPLETE: 1, FAILED: 0 };
// Localize untouched defaults; user-defined names remain literal configuration data.
const DEFAULT_GROUPS = {
smart_home: { stored: [ '智能家居', 'Smart home' ], label: () => _('Smart home') },
phone: { stored: [ '手机设备', 'Phones' ], label: () => _('Phones') },
computer: { stored: [ '电脑设备', 'Computers' ], label: () => _('Computers') },
network: { stored: [ '网络设备', 'Network devices' ], label: () => _('Network devices') },
other: { stored: [ '其他设备', 'Other devices' ], label: () => _('Other devices') }
};
const DEVICE_TYPES = {
computer: { id: 'computer', icon: 'computer.svg', label: () => _('Computer') },
laptop: { id: 'laptop', icon: 'laptop.svg', label: () => _('Laptop') },
phone: { id: 'phone', icon: 'phone.svg', label: () => _('Phone') },
tablet: { id: 'tablet', icon: 'tablet.svg', label: () => _('Tablet') },
tv: { id: 'tv', icon: 'tv.svg', label: () => _('TV') },
tvbox: { id: 'tvbox', icon: 'tvbox.svg', label: () => _('TV box') },
nas: { id: 'nas', icon: 'nas.svg', label: () => _('NAS') },
printer: { id: 'printer', icon: 'printer.svg', label: () => _('Printer') },
camera: { id: 'camera', icon: 'camera.svg', label: () => _('Camera') },
speaker: { id: 'speaker', icon: 'speaker.svg', label: () => _('Speaker') },
game: { id: 'game', icon: 'game.svg', label: () => _('Game console') },
router: { id: 'router', icon: 'router.svg', label: () => _('Router') },
switch: { id: 'switch', icon: 'switch.svg', label: () => _('Switch') },
ap: { id: 'ap', icon: 'ap.svg', label: () => _('Access point') },
server: { id: 'server', icon: 'server.svg', label: () => _('Server') },
plug: { id: 'plug', icon: 'plug.svg', label: () => _('Smart plug') },
light: { id: 'light', icon: 'light.svg', label: () => _('Smart light') },
sensor: { id: 'sensor', icon: 'sensor.svg', label: () => _('Sensor') },
home: { id: 'home', icon: 'home.svg', label: () => _('Smart home') },
watch: { id: 'watch', icon: 'watch.svg', label: () => _('Watch') },
car: { id: 'car', icon: 'car.svg', label: () => _('Vehicle') },
vr: { id: 'vr', icon: 'vr.svg', label: () => _('VR headset') },
network: { id: 'network', icon: 'network.svg', label: () => _('Network device') },
airconditioner: { id: 'airconditioner', icon: 'airconditioner.svg', label: () => _('Air conditioner') },
washer: { id: 'washer', icon: 'washer.svg', label: () => _('Washing machine') },
fridge: { id: 'fridge', icon: 'fridge.svg', label: () => _('Refrigerator') },
waterpurifier: { id: 'waterpurifier', icon: 'waterpurifier.svg', label: () => _('Water purifier') },
airpurifier: { id: 'airpurifier', icon: 'airpurifier.svg', label: () => _('Air purifier') },
unknown: { id: 'unknown', icon: 'unknown.svg', label: () => _('Unknown') }
};
// Small, documented vendor hints, not a fingerprint database. See docs/device-identification.md.
// Broad vendors such as Apple, Google and HP do not imply one device type.
const OUI_MAP = {
'001132': 'nas', '4CFCAA': 'car', '2C2617': 'vr',
'1012FB': 'camera', '0009BF': 'game', '000E58': 'speaker',
'001788': 'light', '18FE34': 'home', '54EF44': 'home',
'00156D': 'network', '000FE2': 'network', '000C42': 'router',
'B827EB': 'computer'
};
function getTypeInfo(type) {
return DEVICE_TYPES[type] || DEVICE_TYPES.unknown;
}
function getTypeLabel(type) {
const info = getTypeInfo(type);
return info.label();
}
function detectDeviceType(mac, hostname, customName, group) {
const normalized = normalizeMac(mac);
if (!normalized) return 'unknown';
const text = (String(customName || '') + ' ' + String(hostname || '')).toLowerCase();
// Specific device names precede broad vendor hints. Short tokens require
// boundaries: "Cambridge-PC" is not "cam"; a network switch is not a console.
const clues = [
[ 'car', /(^|[^a-z0-9])(tesla|model[-_ ]*[3ysx]|byd|nio|xpeng|li-auto|polestar|rivian|zeekr)(?=$|[^a-z0-9])/ ],
[ 'vr', /(^|[^a-z0-9])(quest|oculus|pico|vive|vision[-_ ]?pro|vr)(?=$|[^a-z0-9])/ ],
[ 'nas', /(^|[^a-z0-9])(synology|diskstation|qnap|truenas|freenas|unraid|asustor|terramaster|openmediavault|omv|nas)(?=$|[^a-z0-9])/ ],
[ 'printer', /(^|[^a-z0-9])(printer|laserjet|deskjet|epson|xerox|print)(?=$|[^a-z0-9])/ ],
[ 'camera', /(^|[^a-z0-9])(camera|cctv|ipc|cam|webcam|hikvision|dahua|reolink|uniview|ezviz|wyze|imou)(?=$|[^a-z0-9])/ ],
[ 'game', /(^|[^a-z0-9])(playstation|ps[345]|xbox|nintendo|steamdeck|gamepad)(?=$|[^a-z0-9])/ ],
[ 'speaker', /(^|[^a-z0-9])(homepod|echo|alexa|sonos|soundbar|speaker|xiaoai)(?=$|[^a-z0-9])/ ],
[ 'tvbox', /(^|[^a-z0-9])(appletv|apple-tv|chromecast|mibox|mi-box|firetv|fire-tv|firestick|roku|shield-tv|tvbox|tv-box|stb)(?=$|[^a-z0-9])/ ],
[ 'tv', /(^|[^a-z0-9])(smarttv|smart-tv|mitv|mi-tv|bravia|tizen|webos|tv)(?=$|[^a-z0-9])/ ],
[ 'watch', /(^|[^a-z0-9])(applewatch|apple-watch|iwatch|galaxy-watch|smartwatch|fitbit|garmin|watch|band)(?=$|[^a-z0-9])/ ],
[ 'tablet', /(^|[^a-z0-9])(ipad|tablet|tab|mediapad|pad)(?=$|[^a-z0-9])/ ],
[ 'laptop', /(^|[^a-z0-9])(macbook|laptop|thinkpad|notebook|zenbook|ideapad|matebook|surface-laptop|latitude|inspiron|xps)(?=$|[^a-z0-9])/ ],
[ 'server', /(^|[^a-z0-9])(server|pve|proxmox|esxi|vmware|docker|kubernetes|k8s)(?=$|[^a-z0-9])/ ],
[ 'computer', /(^|[^a-z0-9])(desktop|imac|macmini|macstudio|macpro|pc|tower|workstation|optiplex|windows)(?=$|[^a-z0-9])/ ],
[ 'airconditioner', /(^|[^a-z0-9])(airconditioner|air-conditioner|aircon|air_cond|hvac|ac-unit|ac_unit)(?=$|[^a-z0-9])|空调/ ],
[ 'washer', /(^|[^a-z0-9])(washer|washing-machine|washingmachine|dryer|laundry)(?=$|[^a-z0-9])|洗衣机|烘干机/ ],
[ 'fridge', /(^|[^a-z0-9])(fridge|refrigerator|freezer)(?=$|[^a-z0-9])|冰箱|冷柜/ ],
[ 'waterpurifier', /(^|[^a-z0-9])(waterpurifier|water-purifier)(?=$|[^a-z0-9])|净水器|直饮机|净水机/ ],
[ 'airpurifier', /(^|[^a-z0-9])(airpurifier|air-purifier)(?=$|[^a-z0-9])|空净|空气净化/ ],
[ 'phone', /(^|[^a-z0-9])(iphone|galaxy|redmi|xiaomi|huawei|honor|pixel|oneplus|oppo|vivo|xperia|realme|meizu|phone|mobile)(?=$|[^a-z0-9])/ ],
[ 'plug', /(^|[^a-z0-9])(smartplug|smart-plug|socket|outlet|plug)(?=$|[^a-z0-9])/ ],
[ 'light', /(^|[^a-z0-9])(smartlight|smart-light|light|bulb|lamp|yeelight|hue|strip)(?=$|[^a-z0-9])/ ],
[ 'sensor', /(^|[^a-z0-9])(sensor|temp|humidity|motion|door|window|detector)(?=$|[^a-z0-9])/ ],
[ 'home', /(^|[^a-z0-9])(homeassistant|hass|homebridge|aqara|hub|smart-home|smarthome)(?=$|[^a-z0-9])/ ],
[ 'ap', /(^|[^a-z0-9])(accesspoint|unifi|uap|eap|ap)(?=$|[^a-z0-9])/ ],
[ 'switch', /(^|[^a-z0-9])(switch|usw|tl-sg)(?=$|[^a-z0-9])|交换机/ ],
[ 'router', /(^|[^a-z0-9])(router|openwrt|gateway|ax[0-9]{4}|ac[0-9]{4}|archer|asuswrt)(?=$|[^a-z0-9])/ ]
];
for (const [ type, pattern ] of clues) if (pattern.test(text)) return type;
// Locally administered/randomized MACs have no reliable vendor prefix.
if (!(parseInt(normalized.slice(0, 2), 16) & 2)) {
const oui = normalized.replace(/:/g, '').slice(0, 6);
if (OUI_MAP[oui]) return OUI_MAP[oui];
}
const groupTypes = { smart_home: 'home', phone: 'phone', computer: 'computer', network: 'network' };
return groupTypes[group] || 'unknown';
}
function normalizeMac(mac) {
if (typeof mac !== 'string') return null;
const value = mac.trim().toUpperCase().replace(/[:-]/g, '');
if (!/^[0-9A-F]{12}$/.test(value) || /^(0{12}|F{12})$/.test(value)) return null;
return value.match(/.{2}/g).join(':');
}
function sanitizeInput(value) {
return value == null ? '' : String(value).replace(/[\x00-\x1f\x7f]/g, ' ').trim();
}
function sectionMac(section) {
return normalizeMac(section.mac) || normalizeMac((section['.name'] || '').replace(/^dev_/, ''));
}
function groupName(groups, id) {
const group = groups.find(g => g.id === id);
return group ? group.name : _('Ungrouped');
}
function matchesDevice(device, groups, group, query) {
if (group !== 'all' && device.group !== group) return false;
if (!query) return true;
const typeInfo = getTypeInfo(device.type);
return [ device.customName, device.hostname, device.ipv4, device.ipv6, device.mac,
device.remark, groupName(groups, device.group),
typeInfo.label(), device.type ].some(value => String(value || '').toLowerCase().includes(query));
}
function parseDevices(data, groups) {
const devices = new Map();
const addresses = new Map();
const addressInterfaces = new Map();
const neighborStates = new Map();
const wifi = new Set();
const arp = new Set();
const getEntry = mac => {
const normalized = normalizeMac(mac);
if (!normalized) return null;
if (!devices.has(normalized)) devices.set(normalized, {
mac: normalized, hostname: '', ipv4: '', ipv6: '', customName: '', remark: '',
group: 'ungrouped', sid: null, isSaved: false, isDiscovered: false,
status: 'unknown', statusDetail: '', type: 'unknown',
ipv4Addresses: [], ipv6Addresses: [], interfaces: [], discoverySources: [], neighbors: []
});
return devices.get(normalized);
};
const addAddress = (device, ip, preferred, iface) => {
if (!device || typeof ip !== 'string' || !ip) return;
// DHCPv6 may include a prefix length. Keep address matching consistent.
const address = ip.split('/')[0].toLowerCase();
if (!addresses.has(address)) addresses.set(address, new Set());
addresses.get(address).add(device.mac);
if (iface) {
if (!addressInterfaces.has(address)) addressInterfaces.set(address, new Set());
addressInterfaces.get(address).add(iface);
}
const field = address.includes(':') ? 'ipv6' : 'ipv4';
if (!device[field + 'Addresses'].includes(address)) device[field + 'Addresses'].push(address);
if (iface && !device.interfaces.includes(iface)) device.interfaces.push(iface);
if (!device[field] || preferred) device[field] = address;
};
const addSource = (device, source) => {
if (!device.discoverySources.includes(source)) device.discoverySources.push(source);
};
const toArray = value => Array.isArray(value) ? value : value == null ? [] : [ value ];
const leases = data.leases || {};
for (const lease of [ ...toArray(leases.dhcp_leases), ...toArray(leases.dhcp6_leases) ]) {
const device = getEntry(lease.macaddr);
if (!device) continue;
device.isDiscovered = true;
addSource(device, 'DHCP');
device.hostname = sanitizeInput(lease.hostname || device.hostname);
for (const ip of [ ...toArray(lease.ipaddr), ...toArray(lease.ip6addrs || lease.ip6addr) ]) addAddress(device, ip);
}
for (const [ mac, hint ] of Object.entries(data.hints || {})) {
const device = getEntry(mac);
if (!device || !hint || typeof hint !== 'object') continue;
device.isDiscovered = true;
addSource(device, 'Host hints');
if (!device.hostname) device.hostname = sanitizeInput(hint.name);
for (const ip of [ ...toArray(hint.ipaddrs || hint.ipv4), ...toArray(hint.ip6addrs || hint.ipv6) ]) addAddress(device, ip);
}
for (const section of data.devices || []) {
const device = getEntry(sectionMac(section));
if (!device) continue;
device.isSaved = true;
device.sid = section['.name'];
device.customName = sanitizeInput(section.name);
device.remark = sanitizeInput(section.remark);
device.group = groups.some(g => g.id === section.group) ? section.group : 'ungrouped';
if (section.type) device.customType = sanitizeInput(section.type);
}
for (const station of data.wifi || []) {
const device = getEntry(typeof station === 'string' ? station : station.mac);
if (!device) continue;
device.isDiscovered = true;
addSource(device, 'Wi-Fi');
if (station.dev && !device.interfaces.includes(station.dev)) device.interfaces.push(station.dev);
wifi.add(device.mac);
}
for (const entry of data.arp || []) {
const device = getEntry(entry.mac);
if (!device) continue;
device.isDiscovered = true;
addSource(device, 'ARP');
addAddress(device, entry.ip, false, entry.dev);
if (Number(entry.flags) === 2) arp.add(device.mac);
}
const neighbors = (data.neighbors || {}).neighbors || [];
// Index all resolved neighbors first, so FAILED association is order independent.
for (const neighbor of neighbors) {
const device = getEntry(neighbor.mac);
if (!device) continue;
device.isDiscovered = true;
addAddress(device, neighbor.ip, ACTIVE_STATES.includes(neighbor.state), neighbor.dev);
}
for (const neighbor of neighbors) {
let mac = normalizeMac(neighbor.mac);
if (!mac && typeof neighbor.ip === 'string') {
const address = neighbor.ip.split('/')[0].toLowerCase();
const interfaces = addressInterfaces.get(address);
if (neighbor.dev && interfaces && !interfaces.has(neighbor.dev)) continue;
const owners = addresses.get(address);
// Never attach a failure to an ambiguous or reassigned address.
if (owners && owners.size === 1) mac = owners.values().next().value;
}
if (!mac || !devices.has(mac)) continue;
const device = devices.get(mac);
addSource(device, 'Neighbor table');
device.neighbors.push({ ip: neighbor.ip || '', dev: neighbor.dev || '', state: neighbor.state || '' });
if (neighbor.dev && !device.interfaces.includes(neighbor.dev)) device.interfaces.push(neighbor.dev);
const state = String(neighbor.state || '').toUpperCase();
const previous = neighborStates.get(mac);
if (previous == null || (STATE_PRIORITY[state] ?? -1) > (STATE_PRIORITY[previous] ?? -1)) neighborStates.set(mac, state);
}
const complete = data.discoveryComplete === true;
for (const device of devices.values()) {
device.type = (device.customType && device.customType !== 'auto')
? device.customType
: detectDeviceType(device.mac, device.hostname, device.customName, device.group);
const state = neighborStates.get(device.mac);
if (wifi.has(device.mac)) {
device.status = 'online';
device.statusDetail = _('Active Wi-Fi association');
} else if (ACTIVE_STATES.includes(state)) {
device.status = 'online';
device.statusDetail = _('Active network neighbor (%s)').format(state);
} else if (state === 'FAILED') {
device.status = 'offline';
device.statusDetail = _('Network neighbor probe failed (FAILED)');
} else if (!complete) {
device.statusDetail = _('Some discovery sources are unavailable');
} else if (!device.isDiscovered && !state && device.isSaved) {
device.status = 'offline';
device.statusDetail = _('Saved device not found in the current network');
} else if (state) {
device.statusDetail = _('No recent activity confirmed (%s)').format(state);
} else if (arp.has(device.mac)) {
device.statusDetail = _('ARP record exists without recent activity evidence');
} else {
device.statusDetail = _('Known device without recent activity evidence');
}
}
const statusOrder = { online: 0, unknown: 1, offline: 2 };
return Array.from(devices.values()).sort((a, b) => {
if (a.status !== b.status) return statusOrder[a.status] - statusOrder[b.status];
if (!!a.ipv4 !== !!b.ipv4) return a.ipv4 ? -1 : 1;
if (a.ipv4 && b.ipv4) {
const left = a.ipv4.split('.').map(Number), right = b.ipv4.split('.').map(Number);
for (let i = 0; i < 4; i++) if (left[i] !== right[i]) return left[i] - right[i];
}
return a.mac.localeCompare(b.mac);
});
}
function parseIpv4(ip) {
if (!ip || typeof ip !== 'string') return null;
const parts = ip.split('.').map(Number);
if (parts.length !== 4 || parts.some(n => isNaN(n) || n < 0 || n > 255)) return null;
return parts;
}
function sortDevices(devices, sortKey, sortDir, getGroupName) {
if (!sortKey || !Array.isArray(devices)) return devices || [];
const dir = (sortDir === 'desc') ? -1 : 1;
return devices.slice().sort((a, b) => {
if (sortKey === 'ip') {
const pa = parseIpv4(a.ipv4);
const pb = parseIpv4(b.ipv4);
// Devices with valid IPv4 always appear before devices without IPv4
if (Boolean(pa) !== Boolean(pb)) {
return pa ? -1 : 1;
}
if (pa && pb) {
for (let i = 0; i < 4; i++) {
if (pa[i] !== pb[i]) return (pa[i] - pb[i]) * dir;
}
}
// Both lack IPv4 (or identical IPv4): check IPv6
const v6a = a.ipv6 || '';
const v6b = b.ipv6 || '';
if (Boolean(v6a) !== Boolean(v6b)) {
return v6a ? -1 : 1;
}
if (v6a && v6b) {
const cmpV6 = v6a.localeCompare(v6b);
if (cmpV6 !== 0) return cmpV6 * dir;
}
// Tie-breaker: MAC
return (a.mac || '').localeCompare(b.mac || '') * dir;
}
if (sortKey === 'name') {
const nameA = a.customName || a.hostname || '';
const nameB = b.customName || b.hostname || '';
if (Boolean(nameA) !== Boolean(nameB)) return nameA ? -1 : 1;
if (nameA && nameB) {
const cmp = nameA.localeCompare(nameB);
if (cmp !== 0) return cmp * dir;
}
return (a.mac || '').localeCompare(b.mac || '') * dir;
}
if (sortKey === 'mac') {
return (a.mac || '').localeCompare(b.mac || '') * dir;
}
if (sortKey === 'group') {
const gA = (typeof getGroupName === 'function' ? getGroupName(a.group) : a.group) || '';
const gB = (typeof getGroupName === 'function' ? getGroupName(b.group) : b.group) || '';
if (Boolean(gA) !== Boolean(gB)) return gA ? -1 : 1;
if (gA && gB) {
const cmp = gA.localeCompare(gB);
if (cmp !== 0) return cmp * dir;
}
return (a.mac || '').localeCompare(b.mac || '') * dir;
}
if (sortKey === 'type') {
const tA = a.type || '';
const tB = b.type || '';
if (tA !== tB) return tA.localeCompare(tB) * dir;
return (a.mac || '').localeCompare(b.mac || '') * dir;
}
return 0;
});
}
return baseclass.extend({
DEVICE_TYPES: DEVICE_TYPES,
detectDeviceType: detectDeviceType,
getTypeInfo: getTypeInfo,
getTypeLabel: getTypeLabel,
normalizeMac: normalizeMac,
sanitizeInput: sanitizeInput,
sectionMac: sectionMac,
getSectionId: mac => 'dev_' + normalizeMac(mac).replace(/:/g, '').toLowerCase(),
groupName: groupName,
matchesDevice: matchesDevice,
parseDevices: parseDevices,
sortDevices: sortDevices,
parseGroups: sections => (sections || []).map(section => {
const id = section['.name'], name = sanitizeInput(section.name) || id;
const defaults = DEFAULT_GROUPS[id];
return { id: id, name: defaults && defaults.stored.includes(name) ? defaults.label() : name };
}),
parseArp: content => String(content || '').trim().split('\n').slice(1).map(line => {
const fields = line.trim().split(/\s+/);
return { ip: fields[0], flags: fields[2], mac: fields[3], dev: fields[5] };
}).filter(entry => entry.dev && normalizeMac(entry.mac))
});
@@ -0,0 +1,28 @@
// SPDX-License-Identifier: MIT
'use strict';
'require baseclass';
const STORAGE_KEY = 'luci-device-manager-view';
const DEFAULT_STATE = { tab: 'online', group: 'all' };
// Storage access and JSON parsing can throw synchronously; isolate them in promises.
return baseclass.extend({
load: function() {
return Promise.resolve().then(() => window.localStorage.getItem(STORAGE_KEY)).then(raw => {
const state = raw ? JSON.parse(raw) : null;
if (!state || typeof state !== 'object') return Object.assign({}, DEFAULT_STATE);
const result = {
tab: typeof state.tab === 'string' ? state.tab : 'online',
group: typeof state.group === 'string' ? state.group : 'all'
};
if (typeof state.maskInfo === 'boolean') result.maskInfo = state.maskInfo;
return result;
}).catch(() => Object.assign({}, DEFAULT_STATE));
},
save: function(tab, group, maskInfo) {
const payload = { tab: tab || 'online', group: group || 'all' };
if (typeof maskInfo === 'boolean') payload.maskInfo = maskInfo;
return Promise.resolve().then(() => window.localStorage.setItem(STORAGE_KEY,
JSON.stringify(payload))).catch(() => {});
}
});
@@ -0,0 +1,216 @@
// SPDX-License-Identifier: MIT
'use strict';
'require baseclass';
'require rpc';
'require uci';
'require fs';
'require device-manager.model as model';
const CONFIG = 'device_manager';
const callHints = rpc.declare({ object: 'luci-rpc', method: 'getHostHints', reject: true });
const callLeases = rpc.declare({ object: 'luci-rpc', method: 'getDHCPLeases', reject: true });
const callNeighbors = rpc.declare({ object: 'luci.device-manager', method: 'get_online_status', reject: true });
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 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 });
function requireObject(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(_('Invalid discovery response'));
return value;
}
function readSource(name, read) {
return Promise.resolve().then(read).then(value => ({ name: name, ok: true, value: value }),
error => ({ name: name, ok: false, error: error.message || String(error) }));
}
function wifiStations() {
return callWireless().then(requireObject).then(radios => {
const interfaces = new Set();
for (const radio of Object.values(radios)) {
for (const iface of radio.interfaces || []) {
const mode = (iface.config || {}).mode;
if (mode && mode !== 'ap' && mode !== 'ap-wds') continue;
if (iface.ifname) interfaces.add(iface.ifname);
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 => {
if (!Array.isArray(requireObject(reply).results)) throw new Error(_('Invalid discovery response'));
return reply.results.map(station => ({ mac: station.mac, dev: device }));
})))).then(results => ({
stations: results.filter(result => result.ok).flatMap(result => result.value),
complete: results.every(result => result.ok),
errors: results.filter(result => !result.ok).map(result => result.name + ': ' + result.error)
}));
});
}
function reloadConfig() {
uci.unload(CONFIG);
return uci.load(CONFIG);
}
function validateText(value, limit) {
const text = model.sanitizeInput(value);
if (text.length > limit) throw new Error(_('Input is too long'));
return text;
}
function validateGroupName(name, exceptId) {
const text = validateText(name, 32);
if (!text) throw new Error(_('Enter a valid group name'));
if (uci.sections(CONFIG, 'group').some(group => group['.name'] !== exceptId && model.sanitizeInput(group.name) === text))
throw new Error(_('A group with this name already exists'));
return text;
}
function requireGroup(id) {
if (!uci.sections(CONFIG, 'group').some(group => group['.name'] === id)) throw new Error(_('This group no longer exists'));
}
return baseclass.extend({
scanDevices: function() {
if (L.hasViewPermission() !== true) return Promise.reject(new Error(_('Read-only access')));
return this.queue(() => callScan().then(reply => {
if (requireObject(reply).ok !== true)
throw new Error(_('LAN scanning is unavailable. Check the selected interfaces and probe dependencies.'));
}));
},
pingDevice: function(mac) {
const normalized = model.normalizeMac(mac);
if (!normalized) return Promise.reject(new Error(_('Enter a valid MAC address')));
return callPing(normalized).then(reply => {
const errors = {
'No known IP address for this device': _('No known IP address for this device'),
'Another device is being probed. Please retry.': _('Another device is being probed. Please retry.'),
'Ping is unavailable': _('Ping is unavailable'),
'Enter a valid MAC address': _('Enter a valid MAC address'),
'Unable to read the kernel neighbor table': _('Unable to read the kernel neighbor table')
};
if (requireObject(reply).ok !== true) {
const error = new Error(errors[reply.error] || _('Ping could not be completed'));
error.output = typeof reply.output === 'string' ? reply.output : '';
throw error;
}
if (typeof reply.reachable !== 'boolean' || typeof reply.ip !== 'string' || typeof reply.output !== 'string')
throw new Error(_('Invalid discovery response'));
return reply;
});
},
// Reads and writes share the queue so a refresh cannot discard an in-flight edit.
queue: function(task) {
const result = (this.pending || Promise.resolve()).then(task);
this.pending = result.catch(() => {});
return result;
},
load: function() {
return this.queue(() => this.recoverPending().then(() => {
const config = reloadConfig().then(() => ({
groups: uci.sections(CONFIG, 'group'), devices: uci.sections(CONFIG, 'device')
}));
const sources = Promise.all([
readSource('hints', () => callHints().then(requireObject)),
readSource('leases', () => callLeases().then(requireObject)),
readSource('neighbors', () => callNeighbors().then(reply => {
if (requireObject(reply).ok !== true || !Array.isArray(reply.neighbors)) throw new Error(reply.error || _('Invalid discovery response'));
return reply;
})),
readSource('wifi', wifiStations),
readSource('arp', () => fs.read('/proc/net/arp').then(model.parseArp))
]);
return Promise.all([ config, sources ]).then(results => {
const data = Object.assign({ errors: [], discoveryComplete: true }, results[0]);
for (const result of results[1]) {
if (!result.ok) {
data.errors.push(result.name + ': ' + result.error);
data.discoveryComplete = false;
continue;
}
if (result.name === 'wifi') {
data.wifi = result.value.stations;
data.discoveryComplete = data.discoveryComplete && result.value.complete;
data.errors.push(...result.value.errors);
} else data[result.name] = result.value;
}
return data;
});
}));
},
recoverPending: function() {
if (!this.rollbackPending) return Promise.resolve();
return callRevert(CONFIG).then(() => { this.rollbackPending = false; });
},
mutate: function(change) {
return this.queue(() => {
if (L.hasViewPermission() !== true) throw new Error(_('Read-only access'));
let staged = false;
return this.recoverPending().then(reloadConfig).then(change).then(() => {
staged = true;
return uci.save();
}).then(() => callCommit(CONFIG)).catch(error => {
if (!staged) throw error;
this.rollbackPending = true;
return this.recoverPending().then(() => { throw error; }, rollbackError => {
throw new Error(_('%s; failed to discard pending changes: %s').format(error.message, rollbackError.message));
});
}).finally(() => uci.unload(CONFIG));
});
},
saveDevice: function(mac, name, remark, group, type) {
return this.mutate(() => {
const normalized = model.normalizeMac(mac);
if (!normalized) throw new Error(_('Enter a valid MAC address'));
const deviceName = validateText(name, 64), deviceRemark = validateText(remark, 256);
if (group && group !== 'ungrouped') requireGroup(group);
const matches = uci.sections(CONFIG, 'device').filter(section => model.sectionMac(section) === normalized);
const sid = matches.length ? matches[matches.length - 1]['.name'] : model.getSectionId(normalized);
if (!matches.length) {
if (uci.get(CONFIG, sid)) throw new Error(_('A conflicting configuration record exists'));
uci.add(CONFIG, 'device', sid);
}
for (const section of matches) if (section['.name'] !== sid) uci.remove(CONFIG, section['.name']);
uci.set(CONFIG, sid, 'mac', normalized);
for (const [ key, value ] of Object.entries({ name: deviceName, remark: deviceRemark,
group: group && group !== 'ungrouped' ? group : '',
type: type && type !== 'auto' ? type : '' })) {
if (value) uci.set(CONFIG, sid, key, value);
else uci.unset(CONFIG, sid, key);
}
});
},
deleteDevice: function(device) {
return this.mutate(() => {
for (const section of uci.sections(CONFIG, 'device')) {
if (model.sectionMac(section) === device.mac) uci.remove(CONFIG, section['.name']);
}
});
},
addGroup: function(name) {
return this.mutate(() => {
const text = validateGroupName(name);
let id;
do { id = 'grp_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8); } while (uci.get(CONFIG, id));
uci.add(CONFIG, 'group', id);
uci.set(CONFIG, id, 'name', text);
});
},
renameGroup: function(id, name) {
return this.mutate(() => {
requireGroup(id);
uci.set(CONFIG, id, 'name', validateGroupName(name, id));
});
},
deleteGroup: function(id) {
return this.mutate(() => {
requireGroup(id);
uci.remove(CONFIG, id);
for (const section of uci.sections(CONFIG, 'device')) {
if (section.group === id) uci.unset(CONFIG, section['.name'], 'group');
}
});
}
});
@@ -0,0 +1,191 @@
/* SPDX-License-Identifier: MIT */
.dm-status-tabs { display:flex; flex-wrap:wrap; margin:0 0 16px 0; padding:0; border-bottom:2px solid #e0e0e0; list-style:none; gap:6px; }
.dm-status-tab { border:0; font-family:inherit; cursor:pointer; padding:8px 16px; border-radius:6px 6px 0 0; font-weight:500; font-size:14px; color:inherit; background:rgba(0,0,0,0.03); transition:all 0.15s ease; border-bottom:3px solid transparent; }
.dm-status-tab:hover { background:rgba(0,0,0,0.06); color:inherit; }
.dm-status-tab.active { background:transparent; color:#1a73e8; font-weight:bold; border-bottom:3px solid #1a73e8; }
.dm-tab-count { font-size:12px; margin-left:4px; padding:2px 7px; border-radius:10px; background:rgba(0,0,0,0.06); color:inherit; font-weight:normal; }
.dm-status-tab.active .dm-tab-count { background:#e8f0fe; color:#1a73e8; font-weight:bold; }
.dm-toolbar { display:flex; flex-wrap:wrap; gap:10px; justify-content:space-between; align-items:center; margin-bottom:15px; }
.dm-controls { display:flex; flex-wrap:wrap; gap:8px; align-items:center; flex:1; min-width:260px; }
.dm-btn-group { display:flex; flex-wrap:wrap; gap:8px; align-items:center; }
.dm-table-wrapper { overflow-x:auto; width:100%; -webkit-overflow-scrolling:touch; }
.dm-device-title-row { display:inline-flex; align-items:center; max-width:100%; vertical-align:middle; }
.dm-device-title { font-weight:bold; font-size:14px; display:inline-block; }
.dm-device-subtitle { font-size:12px; color:#777; margin-top:2px; }
.dm-status-dot { display:inline-block; width:6px; height:6px; border-radius:50%; margin-right:5px; flex-shrink:0; vertical-align:middle; transition:transform 0.15s ease, box-shadow 0.15s ease; cursor:help; }
.dm-status-dot:hover { transform:scale(1.3); }
.dm-status-dot-online { background-color:#34a853; box-shadow:0 0 0 1.5px rgba(52,168,83,0.25); }
.dm-status-dot-offline { background-color:#ea4335; box-shadow:0 0 0 1.5px rgba(234,67,53,0.2); }
.dm-status-dot-unknown { background-color:#fbbc04; box-shadow:0 0 0 1.5px rgba(251,188,4,0.25); }
[data-theme="dark"] .dm-status-dot-online,
body.dark-mode .dm-status-dot-online { background-color:#81c995; box-shadow:0 0 0 1.5px rgba(129,201,149,0.3); }
[data-theme="dark"] .dm-status-dot-offline,
body.dark-mode .dm-status-dot-offline { background-color:#f28b82; box-shadow:0 0 0 1.5px rgba(242,139,130,0.25); }
[data-theme="dark"] .dm-status-dot-unknown,
body.dark-mode .dm-status-dot-unknown { background-color:#fdd663; box-shadow:0 0 0 1.5px rgba(253,214,99,0.3); }
.dm-group-badge { display:inline-block; font-size:12px; padding:3px 8px; border-radius:4px; background:#f1f3f4; color:#3c4043; border:1px solid #dadce0; font-weight:500; }
.dm-group-ungrouped { color:#888; font-style:italic; }
.dm-mac-code { font-family:monospace; font-size:13px; font-weight:600; color:#1a73e8; }
.dm-actions { white-space:nowrap; }
.dm-table .dm-actions-column { width:64px; min-width:64px; text-align:center; }
.dm-table tbody .td { vertical-align:middle; }
.dm-action-links { display:flex; flex-direction:column; align-items:center; gap:4px; }
.dm-table .dm-action-link { display:inline-block; padding:0 2px; border:0; background:none; box-shadow:none; color:var(--dm-sort-color); font-size:12px; line-height:20px; text-decoration:none; cursor:pointer; }
.dm-table .dm-action-link:hover { text-decoration:underline; }
.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-type-th { width: 56px; text-align: center; }
.dm-type-cell { text-align: center; vertical-align: middle; padding: 6px 8px; }
.dm-device-icon-wrap { display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 6px; background: rgba(0, 0, 0, 0.04); vertical-align: middle; transition: all 0.15s ease; }
.dm-device-icon-wrap:hover { background: rgba(26, 115, 232, 0.12); transform: scale(1.06); }
.dm-device-icon { width: 20px; height: 20px; display: block; }
[data-theme="dark"] .dm-device-icon-wrap,
body.dark-mode .dm-device-icon-wrap { background: rgba(255, 255, 255, 0.08); }
.dm-table { min-width:900px; --dm-sort-color:#0069d6; --dm-header-hover:rgba(0,105,214,0.06); }
.dm-table thead .tr.table-titles > .th {
height:44px;
/* Override theme padding so all header contents share the same height. */
padding:0;
background:rgba(148,163,184,0.08);
color:var(--text-color-high, #64748b);
font-size:12px;
font-weight:500;
line-height:20px;
text-align:left;
white-space:nowrap;
vertical-align:middle;
}
.dm-table .dm-index-column {
width:40px;
min-width:40px;
max-width:40px;
box-sizing:border-box;
padding:8px 6px;
text-align:center;
font-size:12px;
font-variant-numeric:tabular-nums;
color:var(--text-color-medium, #94a3b8);
}
.dm-table .dm-header-content {
display:flex;
align-items:center;
justify-content:flex-start;
gap:6px;
width:100%;
height:44px;
box-sizing:border-box;
margin:0;
padding:0 12px;
color:inherit;
font:inherit;
line-height:20px;
white-space:nowrap;
}
.dm-table .dm-sort-button {
border:0;
border-radius:0;
background:transparent;
box-shadow:none;
text-align:left;
cursor:pointer;
transition:background-color 0.15s ease, color 0.15s ease;
}
.dm-table .dm-type-th .dm-header-content,
.dm-table .dm-index-column .dm-header-content,
.dm-table .dm-actions-column .dm-header-content { justify-content:center; padding:0 6px; }
.dm-table .dm-sort-button:hover { background:var(--dm-header-hover); color:var(--dm-sort-color); }
.dm-sort-button:focus-visible { outline:2px solid var(--dm-sort-color); outline-offset:-2px; }
.dm-sort-icon { position:relative; display:block; flex:0 0 8px; width:8px; height:14px; color:inherit; }
.dm-sort-icon::before,
.dm-sort-icon::after {
content:'';
position:absolute;
left:1px;
width:4px;
height:4px;
transform:rotate(45deg);
opacity:0.25;
transition:opacity 0.15s ease;
}
.dm-sort-icon::before { top:1px; border-top:1.5px solid currentColor; border-left:1.5px solid currentColor; }
.dm-sort-icon::after { bottom:1px; border-bottom:1.5px solid currentColor; border-right:1.5px solid currentColor; }
.dm-sort-button:hover .dm-sort-icon::before,
.dm-sort-button:hover .dm-sort-icon::after { opacity:0.5; }
.dm-sortable.sorted-asc .dm-sort-button,
.dm-sortable.sorted-desc .dm-sort-button { color:var(--dm-sort-color); background:var(--dm-header-hover); }
.dm-sortable.sorted-asc .dm-sort-icon::before,
.dm-sortable.sorted-desc .dm-sort-icon::after { opacity:1; }
[data-theme="dark"] .dm-table,
[data-darkmode="true"] .dm-table,
body.dark-mode .dm-table { --dm-sort-color:#8ab4f8; --dm-header-hover:rgba(138,180,248,0.08); }
.dm-btn-group .cbi-button.dm-btn-blue {
/* Reset the theme's background image as well as its background color. */
background: #0069d6 !important;
border-color: #0069d6 !important;
color: #ffffff !important;
text-shadow: none;
}
.dm-btn-group .cbi-button.dm-btn-blue:hover,
.dm-btn-group .cbi-button.dm-btn-blue:focus-visible {
background: #0049d6 !important;
border-color: #0049d6 !important;
color: #ffffff !important;
}
.modal.dm-device-modal { position:relative; width:680px; min-width:0; max-width:calc(100vw - 32px); max-height:calc(100vh - 48px); margin:24px auto; overflow-y:auto; padding:24px; border-radius:12px; box-sizing:border-box; }
.dm-device-modal > h4 { margin-bottom:18px; padding-right:32px; font-size:17px; }
.dm-dialog .dm-dialog-close { position:absolute; top:18px; right:18px; display:flex; align-items:center; justify-content:center; width:28px; height:28px; padding:0; border:0; border-radius:6px; background:none; box-shadow:none; color:var(--dm-dialog-muted); font-size:22px; line-height:1; cursor:pointer; }
.dm-dialog .dm-dialog-close:hover { background:var(--dm-dialog-panel); color:inherit; }
.dm-dialog .dm-dialog-close:focus-visible { outline:2px solid #0069d6; outline-offset:2px; }
.dm-dialog { --dm-dialog-border:var(--border-color-low, #e5e7eb); --dm-dialog-muted:var(--text-color-medium, #7b8492); --dm-dialog-panel:rgba(148,163,184,0.07); --dm-dialog-warning:#9a6700; }
.dm-dialog-hero { display:flex; align-items:center; gap:14px; padding:16px; margin-bottom:18px; border:1px solid var(--dm-dialog-border); border-radius:10px; background:var(--dm-dialog-panel); }
.dm-dialog-icon { display:flex; align-items:center; justify-content:center; flex:0 0 48px; width:48px; height:48px; background:rgba(0,105,214,0.08); border-radius:12px; }
.dm-dialog-icon img { width:30px; height:30px; }
.dm-dialog-heading { display:flex; flex-direction:column; gap:5px; flex:1; min-width:0; }
.dm-dialog-heading strong { font-size:17px; line-height:1.4; overflow-wrap:anywhere; }
.dm-dialog-muted { color:var(--dm-dialog-muted); font-size:12px; line-height:1.7; }
.dm-dialog-status { flex-shrink:0; padding:4px 10px; border-radius:20px; font-size:12px; line-height:1.5; }
.dm-dialog-status-online { color:var(--success-color-medium, #137333); background:rgba(52,168,83,0.12); }
.dm-dialog-status-offline { color:var(--error-color-medium, #c5221f); background:rgba(234,67,53,0.1); }
.dm-dialog-status-unknown { color:var(--dm-dialog-warning); background:rgba(251,188,4,0.12); }
.dm-info-grid { display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); gap:16px 24px; padding:16px; margin-bottom:18px; border:1px solid var(--dm-dialog-border); border-radius:10px; }
.dm-info-item { min-width:0; }
.dm-info-wide { grid-column:1 / -1; }
.dm-info-label { display:block; margin-bottom:5px; color:var(--dm-dialog-muted); font-size:12px; }
.dm-info-value { font-size:13px; line-height:1.65; white-space:pre-wrap; overflow-wrap:anywhere; }
.dm-edit-fields { display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); gap:16px; }
.dm-edit-fields .cbi-value { display:block; min-width:0; padding:0; margin:0; border:0; }
.dm-edit-fields .cbi-value:nth-last-child(-n+2), .dm-edit-fields .dm-form-wide { grid-column:1 / -1; }
.dm-dialog .dm-edit-fields .cbi-value-title { display:block; float:none; width:auto; margin-bottom:7px; padding:0; text-align:left; font-size:13px; line-height:1.5; }
.dm-edit-fields .cbi-value-field { margin:0; padding:0; width:100%; }
.dm-edit-fields .cbi-value-description { margin:5px 0 0; color:var(--dm-dialog-muted); font-size:12px; line-height:1.6; }
.dm-edit-fields .cbi-value-description::before { display:none; }
.dm-dialog-footer { display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:12px; padding-top:16px; margin-top:20px; border-top:1px solid var(--dm-dialog-border); }
.dm-dialog-buttons { display:flex; justify-content:flex-end; align-items:center; gap:8px; }
.dm-dialog-footer .dm-dialog-buttons { margin-left:auto; }
.dm-dialog-buttons .dm-primary-button { background:#0069d6; border-color:#0069d6; color:#fff; text-shadow:none; }
.dm-dialog-buttons .dm-primary-button:hover { background:#0049d6; }
.dm-clear-description { margin:18px 0 10px; font-size:14px; line-height:1.7; overflow-wrap:anywhere; }
.dm-ping-card { padding:16px; margin-bottom:18px; border:1px solid var(--dm-dialog-border); border-radius:10px; background:var(--dm-dialog-panel); }
.dm-ping-heading { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:8px; }
.dm-ping-status { font-size:14px; line-height:1.5; }
.dm-ping-success { color:var(--success-color-medium, #137333); }
.dm-ping-warning { color:var(--dm-dialog-warning); }
.dm-ping-card p { margin:0; white-space:pre-line; }
.dm-ping-log { margin-top:10px; }
.dm-ping-log summary { color:var(--dm-dialog-muted); font-size:12px; cursor:pointer; }
.dm-ping-output { max-height:140px; overflow:auto; margin:12px 0 0; padding:12px; border:0; border-radius:6px; background:rgba(148,163,184,0.1); color:inherit; font-size:12px; line-height:1.6; white-space:pre-wrap; overflow-wrap:anywhere; }
[data-theme="dark"] .dm-dialog,
[data-darkmode="true"] .dm-dialog,
body.dark-mode .dm-dialog { --dm-dialog-warning:#f4ca64; }
@media (max-width:640px) {
.modal.dm-device-modal { padding:18px; }
.dm-info-grid, .dm-edit-fields { grid-template-columns:minmax(0, 1fr); }
.dm-dialog-hero { gap:10px; padding:12px; }
.dm-dialog-heading strong { font-size:15px; }
}
@@ -0,0 +1,272 @@
// SPDX-License-Identifier: MIT
'use strict';
/* global preferences */
'require baseclass';
'require device-manager.model as model';
'require dom';
'require device-manager.preferences as preferences';
return baseclass.extend({
renderTabs: function() {
if (!this.tabMenuNode) return;
dom.content(this.tabMenuNode, null);
const self = this;
const matching = this.devices.filter(device => model.matchesDevice(device, this.groups, this.activeGroup, this.filterText));
const total = matching.length;
const online = matching.filter(device => device.status === 'online').length;
const offline = matching.filter(device => device.status === 'offline').length;
const unknown = total - online - offline;
const tabs = [
{ key: 'online', label: _('Online devices'), count: online },
{ key: 'all', label: _('All devices'), count: total },
{ key: 'offline', label: _('Offline devices'), count: offline },
{ key: 'unknown', label: _('Unknown'), count: unknown }
];
tabs.forEach(function(t) {
const isActive = (self.activeTab === t.key);
const tabItem = E('button', {
'type': 'button',
'role': 'tab',
'data-tab': t.key,
'aria-selected': String(isActive),
'class': 'dm-status-tab' + (isActive ? ' active' : ''),
'click': function() {
self.activeTab = t.key;
preferences.save(self.activeTab, self.activeGroup, self.maskInfo);
self.updateView();
const current = Array.from(self.tabMenuNode.children).find(node => node.getAttribute('data-tab') === t.key);
if (current) current.focus();
}
}, [
t.label,
E('span', { 'class': 'dm-tab-count' }, [ t.count ])
]);
self.tabMenuNode.appendChild(tabItem);
});
},
renderGroupSelect: function() {
if (!this.groupSelectNode) return;
dom.content(this.groupSelectNode, null);
const self = this;
const options = [
E('option', { 'value': 'all' }, [ _('All groups') ])
];
// Count devices per group
const groupCounts = new Map();
let ungroupedCount = 0;
for (let i = 0; i < this.devices.length; i++) {
const g = this.devices[i].group;
if (!g || g === 'ungrouped') {
ungroupedCount++;
} else {
groupCounts.set(g, (groupCounts.get(g) || 0) + 1);
}
}
for (let i = 0; i < this.groups.length; i++) {
const g = this.groups[i];
const count = groupCounts.get(g.id) || 0;
const opt = E('option', { 'value': g.id }, [ '%s (%d)'.format(g.name, count) ]);
if (self.activeGroup === g.id) opt.selected = true;
options.push(opt);
}
const ungroupedOpt = E('option', { 'value': 'ungrouped' }, [ '%s (%d)'.format(_('Ungrouped'), ungroupedCount) ]);
if (self.activeGroup === 'ungrouped') ungroupedOpt.selected = true;
options.push(ungroupedOpt);
dom.content(this.groupSelectNode, options);
},
handleSort: function(key) {
if (this.sortKey === key) {
this.sortDir = (this.sortDir === 'asc') ? 'desc' : 'asc';
} else {
this.sortKey = key;
this.sortDir = 'asc';
}
this.updateSortHeaders();
this.renderTable();
},
updateSortHeaders: function() {
if (!this.tableHeadNode) return;
const ths = this.tableHeadNode.querySelectorAll('th');
for (let i = 0; i < ths.length; i++) {
const th = ths[i];
const col = th.getAttribute('data-sort');
if (!col) continue;
const isCurrent = (col === this.sortKey);
th.classList.remove('sorted-asc');
th.classList.remove('sorted-desc');
if (isCurrent) {
th.classList.add(this.sortDir === 'asc' ? 'sorted-asc' : 'sorted-desc');
if (typeof th.setAttribute === 'function') {
th.setAttribute('aria-sort', this.sortDir === 'asc' ? 'ascending' : 'descending');
} else if (th.attrs) {
th.attrs['aria-sort'] = this.sortDir === 'asc' ? 'ascending' : 'descending';
}
} else {
if (typeof th.setAttribute === 'function') {
th.setAttribute('aria-sort', 'none');
} else if (th.attrs) {
th.attrs['aria-sort'] = 'none';
}
}
}
},
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));
if (filtered.length === 0) {
let emptyMsg = _('No devices found. Refresh the list to try again.');
if (this.filterText)
emptyMsg = _('No devices match "%s"').format(this.filterText);
else if (this.activeTab === 'online')
emptyMsg = _('No online devices');
else if (this.activeTab === 'offline')
emptyMsg = _('No offline devices');
else if (this.activeTab === 'unknown')
emptyMsg = _('No devices with unknown status');
else if (this.activeGroup !== 'all')
emptyMsg = _('No devices in this group');
this.tableBodyNode.appendChild(E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td', 'colspan': 8, 'style': 'text-align:center; padding:36px 16px; color:#888;' }, [ emptyMsg ])
]));
return;
}
const sorted = model.sortDevices(filtered, this.sortKey, this.sortDir, id => this.getGroupName(id));
for (let i = 0; i < sorted.length; i++) {
this.tableBodyNode.appendChild(this.renderDeviceRow(sorted[i], i + 1));
}
},
renderDeviceRow: function(dev, rowNumber) {
const self = this;
// 0. Device type icon column
const typeInfo = model.getTypeInfo(dev.type);
const typeLabel = model.getTypeLabel(dev.type);
const iconNode = E('span', {
'class': 'dm-device-icon-wrap',
'title': typeLabel
}, [
E('img', {
'class': 'dm-device-icon',
'src': L.resource('device-manager/device-icons/' + typeInfo.icon),
'alt': typeLabel
})
]);
// 1. Device name column
const displayName = dev.customName || dev.hostname || _('Unknown device');
// Status dot with tooltip explanation (no text displayed)
let dotClass = 'dm-status-dot-unknown';
let statusLabel = _('Unknown');
if (dev.status === 'online') {
dotClass = 'dm-status-dot-online';
statusLabel = _('Online');
} else if (dev.status === 'offline') {
dotClass = 'dm-status-dot-offline';
statusLabel = _('Offline');
}
const statusTooltip = dev.statusDetail ? (statusLabel + ': ' + dev.statusDetail) : statusLabel;
const nameChildren = [
E('div', { 'class': 'dm-device-title-row' }, [
E('span', {
'class': 'dm-status-dot ' + dotClass,
'title': statusTooltip
}),
E('span', { 'class': 'dm-device-title' }, [ displayName ])
])
];
if (dev.customName && dev.hostname && dev.customName !== dev.hostname) {
nameChildren.push(E('div', { 'class': 'dm-device-subtitle' }, [ dev.hostname ]));
}
// 2. IP address column
const ipChildren = [];
if (dev.ipv4) {
ipChildren.push(E('div', {}, [ dev.ipv4 ]));
} else {
ipChildren.push(E('div', { 'style': 'color:#999;' }, [ '—' ]));
}
if (dev.ipv6) {
const displayIpv6 = (this.maskInfo && dev.ipv6) ? dev.ipv6.replace(/[0-9a-fA-F]/g, '*') : dev.ipv6;
ipChildren.push(E('div', {
'class': 'dm-device-subtitle',
'title': displayIpv6,
'style': 'max-width:180px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;'
}, [ displayIpv6 ]));
}
// 3. MAC address column
const displayMac = (this.maskInfo && dev.mac) ? dev.mac.replace(/[0-9a-fA-F]/g, '*') : dev.mac;
const macNode = E('span', { 'class': 'dm-mac-code' }, [ displayMac ]);
// 4. Group column
const groupName = this.getGroupName(dev.group);
const groupNode = (dev.group && dev.group !== 'ungrouped')
? E('span', { 'class': 'dm-group-badge' }, [ groupName ])
: null;
// 5. Remark column
const remarkNode = dev.remark
? E('span', {}, [ dev.remark ])
: null;
// 6. Action column
const actionLink = (label, className, action) => E('a', {
'href': '#',
'role': 'button',
'class': 'dm-action-link ' + className,
'click': function(ev) {
ev.preventDefault();
return action();
},
'keydown': function(ev) {
if (ev.key === ' ') {
ev.preventDefault();
return action();
}
}
}, [ label ]);
const actions = [ actionLink(_('View'), 'dm-action-detail', () => self.showDetailModal(dev)) ];
if (!self.readonly) actions.push(actionLink(_('Edit'), 'dm-action-edit', () => self.showEditModal(dev)));
return E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td dm-index-column' }, [ rowNumber == null ? '' : String(rowNumber) ]),
E('td', { 'class': 'td dm-type-cell', 'style': 'text-align:center; vertical-align:middle;' }, [ iconNode ]),
E('td', { 'class': 'td' }, nameChildren),
E('td', { 'class': 'td' }, ipChildren),
E('td', { 'class': 'td' }, macNode),
E('td', { 'class': 'td' }, groupNode),
E('td', { 'class': 'td' }, remarkNode),
E('td', { 'class': 'td dm-actions dm-actions-column' }, [
E('div', { 'class': 'dm-action-links' }, actions)
])
]);
}
});
@@ -0,0 +1,249 @@
// SPDX-License-Identifier: MIT
'use strict';
/* global deviceDialog, groupDialog, preferences, service, table */
'require view';
'require ui';
'require device-manager.model as model';
'require device-manager.service as service';
'require device-manager.preferences as preferences';
'require device-manager.table as table';
'require device-manager.device-dialog as deviceDialog';
'require device-manager.group-dialog as groupDialog';
return view.extend({
devices: [],
groups: [],
activeTab: 'online',
activeGroup: 'all',
maskInfo: false,
filterText: '',
readonly: true,
sortKey: null,
sortDir: 'asc',
handleSave: null,
handleSaveApply: null,
handleReset: null,
load: function() {
return Promise.all([ service.load(), preferences.load() ])
.then(data => ({ snapshot: data[0], preferences: data[1] }));
},
acceptData: function(data) {
this.groups = model.parseGroups(data.groups);
this.devices = model.parseDevices(data, this.groups);
this.sourceErrors = data.errors || [];
if (this.activeGroup !== 'all' && this.activeGroup !== 'ungrouped' && !this.getGroupById(this.activeGroup)) {
this.activeGroup = 'all';
preferences.save(this.activeTab, this.activeGroup, this.maskInfo);
}
},
render: function(data) {
const self = this;
this.readonly = L.hasViewPermission() !== true;
this.acceptData(data.snapshot);
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.maskInfo = Boolean(savedView.maskInfo);
const sortHeader = (key, label, width, centered) => E('th', {
'class': 'th dm-sortable' + (centered ? ' dm-type-th' : ''),
'scope': 'col',
'data-sort': key,
'style': 'width:' + width + ';'
}, [ E('button', {
'type': 'button',
'class': 'dm-header-content dm-sort-button',
'title': _('Click to sort by %s').format(label),
'click': function() { self.handleSort(key); }
}, [
E('span', { 'class': 'dm-sort-label' }, [ label ]),
E('span', { 'class': 'dm-sort-icon', 'aria-hidden': 'true' })
]) ]);
const theadNode = E('thead', {}, [ E('tr', { 'class': 'tr table-titles' }, [
E('th', { 'class': 'th dm-index-column', 'scope': 'col', 'title': _('Row number') }, [
E('span', { 'class': 'dm-header-content' }, [ _('#') ])
]),
sortHeader('type', _('Type'), '56px', true),
sortHeader('name', _('Device name'), '24%'),
sortHeader('ip', _('IP address'), '18%'),
sortHeader('mac', _('MAC address'), '18%'),
sortHeader('group', _('Group'), '14%'),
E('th', { 'class': 'th', 'scope': 'col', 'style': 'width:14%;' }, [
E('span', { 'class': 'dm-header-content' }, [ _('Remarks') ])
]),
E('th', { 'class': 'th dm-actions-column', 'scope': 'col' }, [
E('span', { 'class': 'dm-header-content' }, [ _('Actions') ])
])
]) ]);
const viewNode = E('div', { 'class': 'cbi-map' }, [
E('link', { 'rel': 'stylesheet', 'href': L.resource('device-manager/styles.css') }),
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;' }),
// Row 1: Status Tabs
E('div', { 'class': 'dm-status-tabs', 'id': 'dm-tabs-container', 'role': 'tablist', 'aria-label': _('Device status') }),
// Row 2: Controls Toolbar
E('div', { 'class': 'cbi-section dm-toolbar' }, [
E('div', { 'class': 'dm-controls' }, [
E('input', {
'type': 'text',
'id': 'dm-search-input',
'class': 'cbi-input-text',
'placeholder': _('Search names, IP, MAC, remarks or groups...'),
'style': 'flex:1; min-width:200px; max-width:360px;',
'value': self.filterText,
'input': function(ev) {
self.filterText = ev.target.value.trim().toLowerCase();
self.updateView();
}
}),
E('select', {
'id': 'dm-group-select',
'class': 'cbi-input-select',
'style': 'min-width:140px;',
'change': function(ev) {
self.activeGroup = ev.target.value;
preferences.save(self.activeTab, self.activeGroup, self.maskInfo);
self.updateView();
}
})
]),
E('div', { 'class': 'dm-btn-group' }, [
E('button', { 'type': 'button',
'class': 'cbi-button cbi-button-neutral',
'disabled': self.readonly || null,
'click': function() { self.showGroupModal(); }
}, [ _('Manage groups') ]),
E('button', { 'type': 'button',
'class': 'cbi-button cbi-button-action dm-btn-blue',
'disabled': self.readonly || null,
'click': function() { self.showEditModal(null); }
}, [ _('Add device') ]),
E('button', { 'type': 'button',
'class': 'cbi-button cbi-button-neutral',
'id': 'dm-btn-mask',
'title': _('Hide/Show MAC and IPv6'),
'click': function() {
self.maskInfo = !self.maskInfo;
preferences.save(self.activeTab, self.activeGroup, self.maskInfo);
self.updateView();
}
}, [ (self.maskInfo ? _('Show info') : _('Hide info')) ]),
E('button', { 'type': 'button',
'class': 'cbi-button cbi-button-neutral',
'id': 'dm-btn-refresh',
'click': function(ev) {
const button = ev.currentTarget;
button.classList.add('spinning');
button.disabled = true;
return self.refresh().finally(function() {
button.classList.remove('spinning');
button.disabled = false;
});
}
}, [ _('Refresh list') ]),
E('button', { 'type': 'button',
'class': 'cbi-button cbi-button-neutral',
'id': 'dm-btn-scan',
'disabled': self.readonly || null,
'title': _('Probe devices on the configured LAN interfaces'),
'click': function(ev) {
const button = ev.currentTarget;
button.disabled = true;
button.classList.add('spinning');
return self.scan().finally(() => {
button.disabled = self.readonly;
button.classList.remove('spinning');
});
}
}, [ _('Scan LAN') ])
])
]),
// Row 3: Device Table
E('div', { 'class': 'cbi-section' }, [
E('div', { 'class': 'dm-table-wrapper' }, [
E('table', { 'class': 'table dm-table', 'id': 'device_manager_table' }, [
theadNode,
E('tbody', { 'id': 'device_manager_tbody' })
])
])
])
]);
this.tableHeadNode = theadNode;
this.warningNode = viewNode.querySelector('#dm-source-warning');
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.updateView();
return viewNode;
},
getGroupById: function(id) { return this.groups.find(group => group.id === id) || null; },
getGroupName: function(id) { return model.groupName(this.groups, id); },
renderTabs: function() { return table.renderTabs.call(this); },
renderGroupSelect: function() { return table.renderGroupSelect.call(this); },
renderTable: function() { return table.renderTable.call(this); },
renderDeviceRow: function(device, rowNumber) { return table.renderDeviceRow.call(this, device, rowNumber); },
handleSort: function(key) { return table.handleSort.call(this, key); },
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); },
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); },
confirmDeleteGroup: function(group, count) { return groupDialog.confirmDeleteGroup.call(this, group, count); },
handleSaveDevice: function(mac, name, remark, group, dev, type) { return service.saveDevice(mac, name, remark, group, type); },
handleDeleteDevice: function(device) { return service.deleteDevice(device); },
handleAddGroup: function(name) { return service.addGroup(name); },
handleRenameGroup: function(id, name) { return service.renameGroup(id, name); },
handleDeleteGroup: function(id) { return service.deleteGroup(id); },
updateView: function() {
this.renderTabs();
this.renderGroupSelect();
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';
}
},
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');
}).finally(() => { this.scanning = null; });
return this.scanning;
},
refresh: function() {
if (this.refreshing) return this.refreshing;
this.refreshing = service.load().then(data => {
this.acceptData(data);
this.updateView();
}).catch(error => {
ui.addNotification(null, E('p', {}, [ _('Could not refresh the device list: %s').format(error.message || error) ]), 'danger');
}).finally(() => { this.refreshing = null; });
return this.refreshing;
}
});
@@ -0,0 +1,789 @@
msgid ""
msgstr "Content-Type: text/plain; charset=UTF-8"
#: htdocs/luci-static/resources/view/device-manager/devices.js:69
msgid "#"
msgstr ""
#: htdocs/luci-static/resources/device-manager/group-dialog.js:38
msgid "%d devices"
msgstr ""
#: htdocs/luci-static/resources/device-manager/service.js:158
msgid "%s; failed to discard pending changes: %s"
msgstr ""
#: htdocs/luci-static/resources/device-manager/service.js:172
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
msgid "A group with this name already exists"
msgstr ""
#: htdocs/luci-static/resources/device-manager/device-dialog.js:309
msgid "ARP"
msgstr ""
#: htdocs/luci-static/resources/device-manager/model.js:272
msgid "ARP record exists without recent activity evidence"
msgstr ""
#: htdocs/luci-static/resources/device-manager/model.js:31
msgid "Access point"
msgstr ""
#: htdocs/luci-static/resources/device-manager/group-dialog.js:106
#: htdocs/luci-static/resources/view/device-manager/devices.js:80
msgid "Actions"
msgstr ""
#: htdocs/luci-static/resources/device-manager/model.js:257
msgid "Active Wi-Fi association"
msgstr ""
#: htdocs/luci-static/resources/device-manager/model.js:260
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
msgid "Add device"
msgstr ""
#: htdocs/luci-static/resources/device-manager/group-dialog.js:96
msgid "Add group"
msgstr ""
#: htdocs/luci-static/resources/device-manager/model.js:41
msgid "Air conditioner"
msgstr ""
#: htdocs/luci-static/resources/device-manager/model.js:45
msgid "Air purifier"
msgstr ""
#: htdocs/luci-static/resources/device-manager/table.js:24
msgid "All devices"
msgstr ""
#: htdocs/luci-static/resources/device-manager/table.js:58
msgid "All groups"
msgstr ""
#: htdocs/luci-static/resources/device-manager/service.js:89
msgid "Another device is being probed. Please retry."
msgstr ""
#: htdocs/luci-static/resources/device-manager/device-dialog.js:93
msgid "Auto (detect by MAC)"
msgstr ""
#: htdocs/luci-static/resources/device-manager/device-dialog.js:323
msgid "Automatic"
msgstr ""
#: htdocs/luci-static/resources/device-manager/device-dialog.js:131
msgid ""
"Automatically detected from names and MAC address, or customize manually"
msgstr ""
#: htdocs/luci-static/resources/device-manager/model.js:26
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
msgid "Cancel"
msgstr ""
#: htdocs/luci-static/resources/device-manager/device-dialog.js:255
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
msgid "Clear record"
msgstr ""
#: htdocs/luci-static/resources/device-manager/device-dialog.js:235
msgid "Clear the saved record for \"%s\" (%s)?"
msgstr ""
#: htdocs/luci-static/resources/device-manager/device-dialog.js:236
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
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
msgid "Close"
msgstr ""
#: htdocs/luci-static/resources/device-manager/model.js:18
msgid "Computer"
msgstr ""
#: htdocs/luci-static/resources/device-manager/model.js:12
msgid "Computers"
msgstr ""
#: htdocs/luci-static/resources/device-manager/device-dialog.js:261
msgid "Confirm clear"
msgstr ""
#: htdocs/luci-static/resources/device-manager/group-dialog.js:235
msgid "Confirm delete"
msgstr ""
#: htdocs/luci-static/resources/device-manager/group-dialog.js:88
msgid "Could not create group: %s"
msgstr ""
#: htdocs/luci-static/resources/device-manager/group-dialog.js:229
msgid "Could not delete group: %s"
msgstr ""
#: htdocs/luci-static/resources/view/device-manager/devices.js:245
msgid "Could not refresh the device list: %s"
msgstr ""
#: htdocs/luci-static/resources/view/device-manager/devices.js:234
msgid "Could not scan LAN devices: %s"
msgstr ""
#: htdocs/luci-static/resources/device-manager/device-dialog.js:220
msgid "Current IP address"
msgstr ""
#: htdocs/luci-static/resources/device-manager/group-dialog.js:148
msgid "Current group name"
msgstr ""
#: htdocs/luci-static/resources/device-manager/device-dialog.js:220
msgid "Current status"
msgstr ""
#: htdocs/luci-static/resources/device-manager/device-dialog.js:143
#: htdocs/luci-static/resources/device-manager/device-dialog.js:320
msgid "Custom name"
msgstr ""
#: htdocs/luci-static/resources/device-manager/device-dialog.js:308
msgid "DHCP"
msgstr ""
#: htdocs/luci-static/resources/device-manager/group-dialog.js:33
msgid "Delete"
msgstr ""
#: htdocs/luci-static/resources/device-manager/group-dialog.js:207
msgid "Delete group"
msgstr ""
#: htdocs/luci-static/resources/device-manager/group-dialog.js:208
msgid "Delete group \"%s\"?"
msgstr ""
#: htdocs/luci-static/resources/device-manager/device-dialog.js:189
msgid "Device \"%s\" saved."
msgstr ""
#: root/usr/share/luci/menu.d/luci-app-device-manager.json:3
msgid "Device Manager"
msgstr ""
#: htdocs/luci-static/resources/device-manager/group-dialog.js:105
msgid "Device count"
msgstr ""
#: htdocs/luci-static/resources/device-manager/device-dialog.js:312
msgid "Device details"
msgstr ""
#: htdocs/luci-static/resources/device-manager/device-dialog.js:136
#: htdocs/luci-static/resources/device-manager/device-dialog.js:320
msgid "Device group"
msgstr ""
#: htdocs/luci-static/resources/device-manager/group-dialog.js:98
msgid "Device groups"
msgstr ""
#: htdocs/luci-static/resources/view/device-manager/devices.js:72
msgid "Device name"
msgstr ""
#: htdocs/luci-static/resources/device-manager/device-dialog.js:150
#: htdocs/luci-static/resources/device-manager/device-dialog.js:328
msgid "Device remarks"
msgstr ""
#: htdocs/luci-static/resources/view/device-manager/devices.js:93
msgid "Device status"
msgstr ""
#: htdocs/luci-static/resources/device-manager/device-dialog.js:127
msgid "Device type"
msgstr ""
#: htdocs/luci-static/resources/view/device-manager/devices.js:88
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
msgid "Discovered on network"
msgstr ""
#: htdocs/luci-static/resources/device-manager/device-dialog.js:326
msgid "Discovery sources"
msgstr ""
#: htdocs/luci-static/resources/device-manager/table.js:257
msgid "Edit"
msgstr ""
#: htdocs/luci-static/resources/device-manager/device-dialog.js:54
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
msgid "Enter a valid MAC address"
msgstr ""
#: htdocs/luci-static/resources/device-manager/device-dialog.js:171
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
msgid "Enter a valid group name"
msgstr ""
#: htdocs/luci-static/resources/device-manager/model.js:28
msgid "Game console"
msgstr ""
#: root/usr/share/rpcd/acl.d/luci-app-device-manager.json:3
msgid "Grant access to LAN device manager"
msgstr ""
#: htdocs/luci-static/resources/view/device-manager/devices.js:75
msgid "Group"
msgstr ""
#: htdocs/luci-static/resources/device-manager/group-dialog.js:82
msgid "Group \"%s\" created."
msgstr ""
#: htdocs/luci-static/resources/device-manager/group-dialog.js:222
msgid "Group \"%s\" deleted. Its devices are now ungrouped."
msgstr ""
#: htdocs/luci-static/resources/device-manager/group-dialog.js:104
msgid "Group ID"
msgstr ""
#: htdocs/luci-static/resources/device-manager/group-dialog.js:103
msgid "Group name"
msgstr ""
#: htdocs/luci-static/resources/device-manager/group-dialog.js:115
msgid "Group names support Unicode text and common symbols"
msgstr ""
#: htdocs/luci-static/resources/device-manager/group-dialog.js:184
msgid "Group renamed to \"%s\"."
msgstr ""
#: htdocs/luci-static/resources/device-manager/device-dialog.js:75
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
msgid "Hide info"
msgstr ""
#: htdocs/luci-static/resources/view/device-manager/devices.js:135
msgid "Hide/Show MAC and IPv6"
msgstr ""
#: htdocs/luci-static/resources/device-manager/device-dialog.js:308
msgid "Host hints"
msgstr ""
#: htdocs/luci-static/resources/view/device-manager/devices.js:73
msgid "IP address"
msgstr ""
#: htdocs/luci-static/resources/device-manager/device-dialog.js:321
msgid "IPv4 addresses"
msgstr ""
#: htdocs/luci-static/resources/device-manager/device-dialog.js:321
msgid "IPv6 addresses"
msgstr ""
#: htdocs/luci-static/resources/device-manager/service.js:59
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
msgid "Invalid discovery response"
msgstr ""
#: htdocs/luci-static/resources/device-manager/model.js:274
msgid "Known device without recent activity evidence"
msgstr ""
#: htdocs/luci-static/resources/view/device-manager/devices.js:87
msgid "LAN Device Management"
msgstr ""
#: htdocs/luci-static/resources/device-manager/service.js:80
msgid ""
"LAN scanning is unavailable. Check the selected interfaces and probe "
"dependencies."
msgstr ""
#: htdocs/luci-static/resources/device-manager/model.js:19
msgid "Laptop"
msgstr ""
#: htdocs/luci-static/resources/device-manager/device-dialog.js:146
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
msgid "MAC address"
msgstr ""
#: htdocs/luci-static/resources/view/device-manager/devices.js:126
msgid "Manage groups"
msgstr ""
#: htdocs/luci-static/resources/device-manager/device-dialog.js:323
msgid "Manual"
msgstr ""
#: htdocs/luci-static/resources/device-manager/model.js:24
msgid "NAS"
msgstr ""
#: htdocs/luci-static/resources/device-manager/device-dialog.js:328
msgid "Neighbor records"
msgstr ""
#: htdocs/luci-static/resources/device-manager/device-dialog.js:309
msgid "Neighbor table"
msgstr ""
#: htdocs/luci-static/resources/device-manager/model.js:40
msgid "Network device"
msgstr ""
#: htdocs/luci-static/resources/device-manager/model.js:13
msgid "Network devices"
msgstr ""
#: htdocs/luci-static/resources/device-manager/device-dialog.js:322
msgid "Network interfaces"
msgstr ""
#: htdocs/luci-static/resources/device-manager/model.js:263
msgid "Network neighbor probe failed (FAILED)"
msgstr ""
#: htdocs/luci-static/resources/device-manager/group-dialog.js:111
msgid "New group"
msgstr ""
#: htdocs/luci-static/resources/device-manager/group-dialog.js:152
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
msgid "No"
msgstr ""
#: htdocs/luci-static/resources/device-manager/device-dialog.js:290
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
msgid "No Ping reply"
msgstr ""
#: htdocs/luci-static/resources/device-manager/table.js:136
msgid "No devices found. Refresh the list to try again."
msgstr ""
#: htdocs/luci-static/resources/device-manager/table.js:146
msgid "No devices in this group"
msgstr ""
#: htdocs/luci-static/resources/device-manager/table.js:138
msgid "No devices match \"%s\""
msgstr ""
#: htdocs/luci-static/resources/device-manager/table.js:144
msgid "No devices with unknown status"
msgstr ""
#: htdocs/luci-static/resources/device-manager/service.js:88
msgid "No known IP address for this device"
msgstr ""
#: htdocs/luci-static/resources/device-manager/table.js:142
msgid "No offline devices"
msgstr ""
#: htdocs/luci-static/resources/device-manager/table.js:140
msgid "No online devices"
msgstr ""
#: htdocs/luci-static/resources/device-manager/model.js:270
msgid "No recent activity confirmed (%s)"
msgstr ""
#: htdocs/luci-static/resources/device-manager/device-dialog.js:47
msgid "Not detected"
msgstr ""
#: htdocs/luci-static/resources/device-manager/device-dialog.js:8
#: htdocs/luci-static/resources/device-manager/table.js:190
msgid "Offline"
msgstr ""
#: htdocs/luci-static/resources/device-manager/table.js:25
msgid "Offline devices"
msgstr ""
#: htdocs/luci-static/resources/device-manager/device-dialog.js:8
#: htdocs/luci-static/resources/device-manager/table.js:187
msgid "Online"
msgstr ""
#: htdocs/luci-static/resources/device-manager/table.js:23
msgid "Online devices"
msgstr ""
#: htdocs/luci-static/resources/device-manager/device-dialog.js:287
msgid "Online · Ping replied"
msgstr ""
#: htdocs/luci-static/resources/device-manager/device-dialog.js:139
msgid "Organize this device by purpose or location"
msgstr ""
#: htdocs/luci-static/resources/device-manager/model.js:14
msgid "Other devices"
msgstr ""
#: htdocs/luci-static/resources/device-manager/model.js:20
msgid "Phone"
msgstr ""
#: htdocs/luci-static/resources/device-manager/model.js:11
msgid "Phones"
msgstr ""
#: htdocs/luci-static/resources/device-manager/device-dialog.js:306
msgid "Ping again"
msgstr ""
#: htdocs/luci-static/resources/device-manager/service.js:95
msgid "Ping could not be completed"
msgstr ""
#: htdocs/luci-static/resources/device-manager/service.js:90
msgid "Ping is unavailable"
msgstr ""
#: htdocs/luci-static/resources/device-manager/device-dialog.js:274
msgid "Ping output"
msgstr ""
#: htdocs/luci-static/resources/device-manager/model.js:25
msgid "Printer"
msgstr ""
#: htdocs/luci-static/resources/device-manager/device-dialog.js:289
msgid "Probe address: %s"
msgstr ""
#: htdocs/luci-static/resources/view/device-manager/devices.js:159
msgid "Probe devices on the configured LAN interfaces"
msgstr ""
#: htdocs/luci-static/resources/device-manager/device-dialog.js:294
msgid "Probe unavailable"
msgstr ""
#: htdocs/luci-static/resources/device-manager/device-dialog.js:270
#: htdocs/luci-static/resources/device-manager/device-dialog.js:283
msgid "Probing…"
msgstr ""
#: htdocs/luci-static/resources/device-manager/service.js:77
#: htdocs/luci-static/resources/device-manager/service.js:149
msgid "Read-only access"
msgstr ""
#: htdocs/luci-static/resources/device-manager/device-dialog.js:153
msgid "Record the location, purpose or owner of this device"
msgstr ""
#: htdocs/luci-static/resources/view/device-manager/devices.js:154
msgid "Refresh list"
msgstr ""
#: htdocs/luci-static/resources/device-manager/model.js:43
msgid "Refrigerator"
msgstr ""
#: htdocs/luci-static/resources/view/device-manager/devices.js:77
msgid "Remarks"
msgstr ""
#: htdocs/luci-static/resources/device-manager/group-dialog.js:28
msgid "Rename"
msgstr ""
#: htdocs/luci-static/resources/device-manager/group-dialog.js:190
msgid "Rename failed: %s"
msgstr ""
#: htdocs/luci-static/resources/device-manager/group-dialog.js:144
msgid "Rename group"
msgstr ""
#: htdocs/luci-static/resources/device-manager/group-dialog.js:155
msgid "Renaming preserves the group ID and all device assignments"
msgstr ""
#: htdocs/luci-static/resources/device-manager/model.js:29
msgid "Router"
msgstr ""
#: htdocs/luci-static/resources/view/device-manager/devices.js:68
msgid "Row number"
msgstr ""
#: htdocs/luci-static/resources/device-manager/device-dialog.js:201
msgid "Save"
msgstr ""
#: htdocs/luci-static/resources/device-manager/group-dialog.js:198
msgid "Save changes"
msgstr ""
#: htdocs/luci-static/resources/device-manager/device-dialog.js:193
msgid "Save failed: %s"
msgstr ""
#: htdocs/luci-static/resources/device-manager/model.js:268
msgid "Saved device not found in the current network"
msgstr ""
#: htdocs/luci-static/resources/device-manager/device-dialog.js:324
msgid "Saved record"
msgstr ""
#: htdocs/luci-static/resources/device-manager/device-dialog.js:250
msgid "Saved record for \"%s\" cleared."
msgstr ""
#: htdocs/luci-static/resources/view/device-manager/devices.js:169
msgid "Scan LAN"
msgstr ""
#: htdocs/luci-static/resources/view/device-manager/devices.js:102
msgid "Search names, IP, MAC, remarks or groups..."
msgstr ""
#: htdocs/luci-static/resources/device-manager/device-dialog.js:284
msgid "Sending one Ping from the router to this device."
msgstr ""
#: htdocs/luci-static/resources/device-manager/model.js:35
msgid "Sensor"
msgstr ""
#: htdocs/luci-static/resources/device-manager/model.js:32
msgid "Server"
msgstr ""
#: htdocs/luci-static/resources/view/device-manager/devices.js:141
#: htdocs/luci-static/resources/view/device-manager/devices.js:221
msgid "Show info"
msgstr ""
#: htdocs/luci-static/resources/device-manager/model.js:10
#: htdocs/luci-static/resources/device-manager/model.js:36
msgid "Smart home"
msgstr ""
#: htdocs/luci-static/resources/device-manager/model.js:34
msgid "Smart light"
msgstr ""
#: htdocs/luci-static/resources/device-manager/model.js:33
msgid "Smart plug"
msgstr ""
#: htdocs/luci-static/resources/view/device-manager/devices.js:226
msgid ""
"Some device data could not be read. Unconfirmed devices are shown as "
"unknown: %s"
msgstr ""
#: htdocs/luci-static/resources/device-manager/model.js:265
msgid "Some discovery sources are unavailable"
msgstr ""
#: htdocs/luci-static/resources/device-manager/model.js:27
msgid "Speaker"
msgstr ""
#: htdocs/luci-static/resources/device-manager/device-dialog.js:327
msgid "Status evidence"
msgstr ""
#: htdocs/luci-static/resources/device-manager/model.js:30
msgid "Switch"
msgstr ""
#: htdocs/luci-static/resources/device-manager/device-dialog.js:219
#: htdocs/luci-static/resources/device-manager/device-dialog.js:319
msgid "System hostname"
msgstr ""
#: htdocs/luci-static/resources/device-manager/model.js:22
msgid "TV"
msgstr ""
#: htdocs/luci-static/resources/device-manager/model.js:23
msgid "TV box"
msgstr ""
#: htdocs/luci-static/resources/device-manager/model.js:21
msgid "Tablet"
msgstr ""
#: htdocs/luci-static/resources/device-manager/group-dialog.js:209
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
msgid "This group no longer exists"
msgstr ""
#: htdocs/luci-static/resources/view/device-manager/devices.js:71
msgid "Type"
msgstr ""
#: htdocs/luci-static/resources/device-manager/device-dialog.js:323
msgid "Type detection"
msgstr ""
#: htdocs/luci-static/resources/device-manager/service.js:92
msgid "Unable to read the kernel neighbor table"
msgstr ""
#: htdocs/luci-static/resources/device-manager/device-dialog.js:48
msgid "Unavailable"
msgstr ""
#: htdocs/luci-static/resources/device-manager/device-dialog.js:82
#: 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/model.js:46
#: htdocs/luci-static/resources/device-manager/table.js:26
#: htdocs/luci-static/resources/device-manager/table.js:183
msgid "Unknown"
msgstr ""
#: htdocs/luci-static/resources/device-manager/device-dialog.js:24
#: htdocs/luci-static/resources/device-manager/table.js:179
msgid "Unknown device"
msgstr ""
#: htdocs/luci-static/resources/device-manager/model.js:39
msgid "VR headset"
msgstr ""
#: htdocs/luci-static/resources/device-manager/model.js:38
msgid "Vehicle"
msgstr ""
#: htdocs/luci-static/resources/device-manager/table.js:256
msgid "View"
msgstr ""
#: htdocs/luci-static/resources/device-manager/model.js:42
msgid "Washing machine"
msgstr ""
#: htdocs/luci-static/resources/device-manager/model.js:37
msgid "Watch"
msgstr ""
#: htdocs/luci-static/resources/device-manager/model.js:44
msgid "Water purifier"
msgstr ""
#: htdocs/luci-static/resources/device-manager/device-dialog.js:308
msgid "Wi-Fi"
msgstr ""
#: htdocs/luci-static/resources/device-manager/device-dialog.js:324
#: htdocs/luci-static/resources/device-manager/device-dialog.js:325
msgid "Yes"
msgstr ""
#: htdocs/luci-static/resources/device-manager/device-dialog.js:64
msgid "e.g. AA:BB:CC:11:22:33"
msgstr ""
#: htdocs/luci-static/resources/device-manager/device-dialog.js:113
msgid "e.g. Living room TV"
msgstr ""
#: htdocs/luci-static/resources/device-manager/device-dialog.js:105
msgid "e.g. Living room TV, work computer"
msgstr ""
#: htdocs/luci-static/resources/device-manager/group-dialog.js:46
msgid "e.g. Media devices, office devices"
msgstr ""
+1
View File
@@ -0,0 +1 @@
zh_Hans
@@ -0,0 +1,784 @@
msgid ""
msgstr ""
"Project-Id-Version: luci-app-device-manager\n"
"PO-Revision-Date: 2026-10-08 00:00+0800\n"
"Last-Translator: luci-app-device-manager contributors\n"
"Language-Team: Chinese (Simplified)\n"
"Language: zh_Hans\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: htdocs/luci-static/resources/view/device-manager/devices.js:69
msgid "#"
msgstr "序号"
#: htdocs/luci-static/resources/device-manager/group-dialog.js:38
msgid "%d devices"
msgstr "%d 台设备"
#: htdocs/luci-static/resources/device-manager/service.js:158
msgid "%s; failed to discard pending changes: %s"
msgstr "%s;清理待提交配置失败:%s"
#: htdocs/luci-static/resources/device-manager/service.js:172
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
msgid "A group with this name already exists"
msgstr "已存在同名分组,请使用其他名称"
#: htdocs/luci-static/resources/device-manager/device-dialog.js:309
msgid "ARP"
msgstr "ARP 表"
#: htdocs/luci-static/resources/device-manager/model.js:272
msgid "ARP record exists without recent activity evidence"
msgstr "存在 ARP 记录,缺少近期活动证据"
#: htdocs/luci-static/resources/device-manager/model.js:31
msgid "Access point"
msgstr "无线接入点"
#: htdocs/luci-static/resources/device-manager/group-dialog.js:106
#: htdocs/luci-static/resources/view/device-manager/devices.js:80
msgid "Actions"
msgstr "操作"
#: htdocs/luci-static/resources/device-manager/model.js:257
msgid "Active Wi-Fi association"
msgstr "Wi-Fi 活跃连接"
#: htdocs/luci-static/resources/device-manager/model.js:260
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
msgid "Add device"
msgstr "手动添加设备"
#: htdocs/luci-static/resources/device-manager/group-dialog.js:96
msgid "Add group"
msgstr "添加分组"
#: htdocs/luci-static/resources/device-manager/model.js:41
msgid "Air conditioner"
msgstr "空调"
#: htdocs/luci-static/resources/device-manager/model.js:45
msgid "Air purifier"
msgstr "空气净化器"
#: htdocs/luci-static/resources/device-manager/table.js:24
msgid "All devices"
msgstr "全部设备"
#: htdocs/luci-static/resources/device-manager/table.js:58
msgid "All groups"
msgstr "全部分组"
#: htdocs/luci-static/resources/device-manager/service.js:89
msgid "Another device is being probed. Please retry."
msgstr "另一个设备正在探测,请稍后重试。"
#: htdocs/luci-static/resources/device-manager/device-dialog.js:93
msgid "Auto (detect by MAC)"
msgstr "自动识别"
#: htdocs/luci-static/resources/device-manager/device-dialog.js:323
msgid "Automatic"
msgstr "自动识别"
#: htdocs/luci-static/resources/device-manager/device-dialog.js:131
msgid "Automatically detected from names and MAC address, or customize manually"
msgstr "根据名称和 MAC 地址推测类型,也可手动指定"
#: htdocs/luci-static/resources/device-manager/model.js:26
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
msgid "Cancel"
msgstr "取消"
#: htdocs/luci-static/resources/device-manager/device-dialog.js:255
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
msgid "Clear record"
msgstr "清空记录"
#: htdocs/luci-static/resources/device-manager/device-dialog.js:235
msgid "Clear the saved record for \"%s\" (%s)?"
msgstr "确定清空设备【%s】(%s)的保存记录吗?"
#: htdocs/luci-static/resources/device-manager/device-dialog.js:236
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
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
msgid "Close"
msgstr "关闭"
#: htdocs/luci-static/resources/device-manager/model.js:18
msgid "Computer"
msgstr "台式电脑"
#: htdocs/luci-static/resources/device-manager/model.js:12
msgid "Computers"
msgstr "电脑设备"
#: htdocs/luci-static/resources/device-manager/device-dialog.js:261
msgid "Confirm clear"
msgstr "确认清空"
#: htdocs/luci-static/resources/device-manager/group-dialog.js:235
msgid "Confirm delete"
msgstr "确认删除"
#: htdocs/luci-static/resources/device-manager/group-dialog.js:88
msgid "Could not create group: %s"
msgstr "创建分组失败: %s"
#: htdocs/luci-static/resources/device-manager/group-dialog.js:229
msgid "Could not delete group: %s"
msgstr "删除分组失败: %s"
#: htdocs/luci-static/resources/view/device-manager/devices.js:245
msgid "Could not refresh the device list: %s"
msgstr "刷新设备列表失败:%s"
#: htdocs/luci-static/resources/view/device-manager/devices.js:234
msgid "Could not scan LAN devices: %s"
msgstr "无法扫描局域网设备:%s"
#: htdocs/luci-static/resources/device-manager/device-dialog.js:220
msgid "Current IP address"
msgstr "当前 IP 地址"
#: htdocs/luci-static/resources/device-manager/group-dialog.js:148
msgid "Current group name"
msgstr "原分组名称"
#: htdocs/luci-static/resources/device-manager/device-dialog.js:220
msgid "Current status"
msgstr "当前在线状态"
#: htdocs/luci-static/resources/device-manager/device-dialog.js:143
#: htdocs/luci-static/resources/device-manager/device-dialog.js:320
msgid "Custom name"
msgstr "设备显示名称"
#: htdocs/luci-static/resources/device-manager/device-dialog.js:308
msgid "DHCP"
msgstr "DHCP 租约"
#: htdocs/luci-static/resources/device-manager/group-dialog.js:33
msgid "Delete"
msgstr "删除"
#: htdocs/luci-static/resources/device-manager/group-dialog.js:207
msgid "Delete group"
msgstr "确认删除分组"
#: htdocs/luci-static/resources/device-manager/group-dialog.js:208
msgid "Delete group \"%s\"?"
msgstr "确定要删除分组【%s】吗?"
#: htdocs/luci-static/resources/device-manager/device-dialog.js:189
msgid "Device \"%s\" saved."
msgstr "设备【%s】信息已保存并生效!"
#: root/usr/share/luci/menu.d/luci-app-device-manager.json:3
msgid "Device Manager"
msgstr "设备管理"
#: htdocs/luci-static/resources/device-manager/group-dialog.js:105
msgid "Device count"
msgstr "包含设备数"
#: htdocs/luci-static/resources/device-manager/device-dialog.js:312
msgid "Device details"
msgstr "设备详情"
#: htdocs/luci-static/resources/device-manager/device-dialog.js:136
#: htdocs/luci-static/resources/device-manager/device-dialog.js:320
msgid "Device group"
msgstr "所属分组"
#: htdocs/luci-static/resources/device-manager/group-dialog.js:98
msgid "Device groups"
msgstr "设备分组管理"
#: htdocs/luci-static/resources/view/device-manager/devices.js:72
msgid "Device name"
msgstr "设备名称"
#: htdocs/luci-static/resources/device-manager/device-dialog.js:150
#: htdocs/luci-static/resources/device-manager/device-dialog.js:328
msgid "Device remarks"
msgstr "设备备注"
#: htdocs/luci-static/resources/view/device-manager/devices.js:93
msgid "Device status"
msgstr "设备状态"
#: htdocs/luci-static/resources/device-manager/device-dialog.js:127
msgid "Device type"
msgstr "设备类型"
#: htdocs/luci-static/resources/view/device-manager/devices.js:88
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
msgid "Discovered on network"
msgstr "网络已发现"
#: htdocs/luci-static/resources/device-manager/device-dialog.js:326
msgid "Discovery sources"
msgstr "发现来源"
#: htdocs/luci-static/resources/device-manager/table.js:257
msgid "Edit"
msgstr "编辑"
#: htdocs/luci-static/resources/device-manager/device-dialog.js:54
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
msgid "Enter a valid MAC address"
msgstr "请输入有效的 MAC 地址"
#: htdocs/luci-static/resources/device-manager/device-dialog.js:171
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
msgid "Enter a valid group name"
msgstr "请输入有效的分组名称"
#: htdocs/luci-static/resources/device-manager/model.js:28
msgid "Game console"
msgstr "游戏机"
#: root/usr/share/rpcd/acl.d/luci-app-device-manager.json:3
msgid "Grant access to LAN device manager"
msgstr "允许访问局域网设备管理"
#: htdocs/luci-static/resources/view/device-manager/devices.js:75
msgid "Group"
msgstr "分组"
#: htdocs/luci-static/resources/device-manager/group-dialog.js:82
msgid "Group \"%s\" created."
msgstr "成功创建分组【%s】!"
#: htdocs/luci-static/resources/device-manager/group-dialog.js:222
msgid "Group \"%s\" deleted. Its devices are now ungrouped."
msgstr "分组【%s】已删除,所属设备已转为未分组。"
#: htdocs/luci-static/resources/device-manager/group-dialog.js:104
msgid "Group ID"
msgstr "分组标识"
#: htdocs/luci-static/resources/device-manager/group-dialog.js:103
msgid "Group name"
msgstr "分组名称"
#: htdocs/luci-static/resources/device-manager/group-dialog.js:115
msgid "Group names support Unicode text and common symbols"
msgstr "分组名称支持中文、英文及常用符号,稳定保存"
#: htdocs/luci-static/resources/device-manager/group-dialog.js:184
msgid "Group renamed to \"%s\"."
msgstr "分组已成功重命名为【%s】!"
#: htdocs/luci-static/resources/device-manager/device-dialog.js:75
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
msgid "Hide info"
msgstr "隐藏信息"
#: htdocs/luci-static/resources/view/device-manager/devices.js:135
msgid "Hide/Show MAC and IPv6"
msgstr "隐藏或显示 MAC 和 IPv6 地址"
#: htdocs/luci-static/resources/device-manager/device-dialog.js:308
msgid "Host hints"
msgstr "主机提示"
#: htdocs/luci-static/resources/view/device-manager/devices.js:73
msgid "IP address"
msgstr "IP 地址"
#: htdocs/luci-static/resources/device-manager/device-dialog.js:321
msgid "IPv4 addresses"
msgstr "IPv4 地址"
#: htdocs/luci-static/resources/device-manager/device-dialog.js:321
msgid "IPv6 addresses"
msgstr "IPv6 地址"
#: htdocs/luci-static/resources/device-manager/service.js:59
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
msgid "Invalid discovery response"
msgstr "设备发现接口返回无效数据"
#: htdocs/luci-static/resources/device-manager/model.js:274
msgid "Known device without recent activity evidence"
msgstr "已知设备,缺少近期活动证据"
#: htdocs/luci-static/resources/view/device-manager/devices.js:87
msgid "LAN Device Management"
msgstr "局域网设备管理"
#: htdocs/luci-static/resources/device-manager/service.js:80
msgid "LAN scanning is unavailable. Check the selected interfaces and probe dependencies."
msgstr "局域网扫描不可用,请检查所选接口及探测依赖。"
#: htdocs/luci-static/resources/device-manager/model.js:19
msgid "Laptop"
msgstr "笔记本"
#: htdocs/luci-static/resources/device-manager/device-dialog.js:146
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
msgid "MAC address"
msgstr "MAC 地址"
#: htdocs/luci-static/resources/view/device-manager/devices.js:126
msgid "Manage groups"
msgstr "分组管理"
#: htdocs/luci-static/resources/device-manager/device-dialog.js:323
msgid "Manual"
msgstr "手动指定"
#: htdocs/luci-static/resources/device-manager/model.js:24
msgid "NAS"
msgstr "网络存储"
#: htdocs/luci-static/resources/device-manager/device-dialog.js:328
msgid "Neighbor records"
msgstr "邻居记录"
#: htdocs/luci-static/resources/device-manager/device-dialog.js:309
msgid "Neighbor table"
msgstr "邻居表"
#: htdocs/luci-static/resources/device-manager/model.js:40
msgid "Network device"
msgstr "网络设备"
#: htdocs/luci-static/resources/device-manager/model.js:13
msgid "Network devices"
msgstr "网络设备"
#: htdocs/luci-static/resources/device-manager/device-dialog.js:322
msgid "Network interfaces"
msgstr "网络接口"
#: htdocs/luci-static/resources/device-manager/model.js:263
msgid "Network neighbor probe failed (FAILED)"
msgstr "网络邻居探测失败 (FAILED)"
#: htdocs/luci-static/resources/device-manager/group-dialog.js:111
msgid "New group"
msgstr "新增分组"
#: htdocs/luci-static/resources/device-manager/group-dialog.js:152
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
msgid "No"
msgstr "否"
#: htdocs/luci-static/resources/device-manager/device-dialog.js:290
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
msgid "No Ping reply"
msgstr "Ping 未响应"
#: htdocs/luci-static/resources/device-manager/table.js:136
msgid "No devices found. Refresh the list to try again."
msgstr "暂无局域网设备数据,点击右上角【刷新】重试或【+ 手动添加设备】"
#: htdocs/luci-static/resources/device-manager/table.js:146
msgid "No devices in this group"
msgstr "当前分组下暂无设备记录"
#: htdocs/luci-static/resources/device-manager/table.js:138
msgid "No devices match \"%s\""
msgstr "未找到与 “%s” 匹配的设备记录"
#: htdocs/luci-static/resources/device-manager/table.js:144
msgid "No devices with unknown status"
msgstr "当前没有状态未知的设备"
#: htdocs/luci-static/resources/device-manager/service.js:88
msgid "No known IP address for this device"
msgstr "暂无该设备的已知 IP 地址,无法 Ping。"
#: htdocs/luci-static/resources/device-manager/table.js:142
msgid "No offline devices"
msgstr "当前没有离线设备"
#: htdocs/luci-static/resources/device-manager/table.js:140
msgid "No online devices"
msgstr "当前没有在线设备"
#: htdocs/luci-static/resources/device-manager/model.js:270
msgid "No recent activity confirmed (%s)"
msgstr "无法确认近期活动 (%s)"
#: htdocs/luci-static/resources/device-manager/device-dialog.js:47
msgid "Not detected"
msgstr "未检测到"
#: htdocs/luci-static/resources/device-manager/device-dialog.js:8
#: htdocs/luci-static/resources/device-manager/table.js:190
msgid "Offline"
msgstr "离线"
#: htdocs/luci-static/resources/device-manager/table.js:25
msgid "Offline devices"
msgstr "离线设备"
#: htdocs/luci-static/resources/device-manager/device-dialog.js:8
#: htdocs/luci-static/resources/device-manager/table.js:187
msgid "Online"
msgstr "在线"
#: htdocs/luci-static/resources/device-manager/table.js:23
msgid "Online devices"
msgstr "在线设备"
#: htdocs/luci-static/resources/device-manager/device-dialog.js:287
msgid "Online · Ping replied"
msgstr "在线 · Ping 已响应"
#: htdocs/luci-static/resources/device-manager/device-dialog.js:139
msgid "Organize this device by purpose or location"
msgstr "为设备指定业务或摆放区域分类"
#: htdocs/luci-static/resources/device-manager/model.js:14
msgid "Other devices"
msgstr "其他设备"
#: htdocs/luci-static/resources/device-manager/model.js:20
msgid "Phone"
msgstr "手机"
#: htdocs/luci-static/resources/device-manager/model.js:11
msgid "Phones"
msgstr "手机设备"
#: htdocs/luci-static/resources/device-manager/device-dialog.js:306
msgid "Ping again"
msgstr "重新 Ping"
#: htdocs/luci-static/resources/device-manager/service.js:95
msgid "Ping could not be completed"
msgstr "Ping 执行失败。"
#: htdocs/luci-static/resources/device-manager/service.js:90
msgid "Ping is unavailable"
msgstr "路由器未提供 Ping 命令。"
#: htdocs/luci-static/resources/device-manager/device-dialog.js:274
msgid "Ping output"
msgstr "查看 Ping 输出"
#: htdocs/luci-static/resources/device-manager/model.js:25
msgid "Printer"
msgstr "打印机"
#: htdocs/luci-static/resources/device-manager/device-dialog.js:289
msgid "Probe address: %s"
msgstr "探测地址:%s"
#: htdocs/luci-static/resources/view/device-manager/devices.js:159
msgid "Probe devices on the configured LAN interfaces"
msgstr "探测所配置的局域网接口中的设备"
#: htdocs/luci-static/resources/device-manager/device-dialog.js:294
msgid "Probe unavailable"
msgstr "无法完成探测"
#: htdocs/luci-static/resources/device-manager/device-dialog.js:270
#: htdocs/luci-static/resources/device-manager/device-dialog.js:283
msgid "Probing…"
msgstr "正在探测…"
#: htdocs/luci-static/resources/device-manager/service.js:77
#: htdocs/luci-static/resources/device-manager/service.js:149
msgid "Read-only access"
msgstr "仅有只读权限"
#: htdocs/luci-static/resources/device-manager/device-dialog.js:153
msgid "Record the location, purpose or owner of this device"
msgstr "记录设备位置、用途、所有者等备注信息"
#: htdocs/luci-static/resources/view/device-manager/devices.js:154
msgid "Refresh list"
msgstr "刷新列表"
#: htdocs/luci-static/resources/device-manager/model.js:43
msgid "Refrigerator"
msgstr "冰箱"
#: htdocs/luci-static/resources/view/device-manager/devices.js:77
msgid "Remarks"
msgstr "备注"
#: htdocs/luci-static/resources/device-manager/group-dialog.js:28
msgid "Rename"
msgstr "重命名"
#: htdocs/luci-static/resources/device-manager/group-dialog.js:190
msgid "Rename failed: %s"
msgstr "重命名失败: %s"
#: htdocs/luci-static/resources/device-manager/group-dialog.js:144
msgid "Rename group"
msgstr "重命名分组"
#: htdocs/luci-static/resources/device-manager/group-dialog.js:155
msgid "Renaming preserves the group ID and all device assignments"
msgstr "重命名不会改变分组 ID,已关联该分组的设备保持关联不变"
#: htdocs/luci-static/resources/device-manager/model.js:29
msgid "Router"
msgstr "路由器"
#: htdocs/luci-static/resources/view/device-manager/devices.js:68
msgid "Row number"
msgstr "序号"
#: htdocs/luci-static/resources/device-manager/device-dialog.js:201
msgid "Save"
msgstr "保存"
#: htdocs/luci-static/resources/device-manager/group-dialog.js:198
msgid "Save changes"
msgstr "保存修改"
#: htdocs/luci-static/resources/device-manager/device-dialog.js:193
msgid "Save failed: %s"
msgstr "保存失败: %s"
#: htdocs/luci-static/resources/device-manager/model.js:268
msgid "Saved device not found in the current network"
msgstr "未在当前网络发现(已保存记录)"
#: htdocs/luci-static/resources/device-manager/device-dialog.js:324
msgid "Saved record"
msgstr "已保存记录"
#: htdocs/luci-static/resources/device-manager/device-dialog.js:250
msgid "Saved record for \"%s\" cleared."
msgstr "设备【%s】的保存记录已清空。"
#: htdocs/luci-static/resources/view/device-manager/devices.js:169
msgid "Scan LAN"
msgstr "扫描局域网"
#: htdocs/luci-static/resources/view/device-manager/devices.js:102
msgid "Search names, IP, MAC, remarks or groups..."
msgstr "搜索设备名称、主机名、IP、MAC、备注、分组..."
#: htdocs/luci-static/resources/device-manager/device-dialog.js:284
msgid "Sending one Ping from the router to this device."
msgstr "正在从路由器向该设备发送一次 Ping。"
#: htdocs/luci-static/resources/device-manager/model.js:35
msgid "Sensor"
msgstr "传感器"
#: htdocs/luci-static/resources/device-manager/model.js:32
msgid "Server"
msgstr "服务器"
#: htdocs/luci-static/resources/view/device-manager/devices.js:141
#: htdocs/luci-static/resources/view/device-manager/devices.js:221
msgid "Show info"
msgstr "显示信息"
#: htdocs/luci-static/resources/device-manager/model.js:10
#: htdocs/luci-static/resources/device-manager/model.js:36
msgid "Smart home"
msgstr "智能家居"
#: htdocs/luci-static/resources/device-manager/model.js:34
msgid "Smart light"
msgstr "智能灯"
#: htdocs/luci-static/resources/device-manager/model.js:33
msgid "Smart plug"
msgstr "智能插座"
#: htdocs/luci-static/resources/view/device-manager/devices.js:226
msgid "Some device data could not be read. Unconfirmed devices are shown as unknown: %s"
msgstr "部分设备数据读取失败,无法确认的设备显示为状态未知:%s"
#: htdocs/luci-static/resources/device-manager/model.js:265
msgid "Some discovery sources are unavailable"
msgstr "部分设备发现数据源不可用"
#: htdocs/luci-static/resources/device-manager/model.js:27
msgid "Speaker"
msgstr "音箱"
#: htdocs/luci-static/resources/device-manager/device-dialog.js:327
msgid "Status evidence"
msgstr "状态依据"
#: htdocs/luci-static/resources/device-manager/model.js:30
msgid "Switch"
msgstr "交换机"
#: htdocs/luci-static/resources/device-manager/device-dialog.js:219
#: htdocs/luci-static/resources/device-manager/device-dialog.js:319
msgid "System hostname"
msgstr "系统主机名"
#: htdocs/luci-static/resources/device-manager/model.js:22
msgid "TV"
msgstr "电视"
#: htdocs/luci-static/resources/device-manager/model.js:23
msgid "TV box"
msgstr "电视盒子"
#: htdocs/luci-static/resources/device-manager/model.js:21
msgid "Tablet"
msgstr "平板"
#: htdocs/luci-static/resources/device-manager/group-dialog.js:209
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
msgid "This group no longer exists"
msgstr "该分组已不存在"
#: htdocs/luci-static/resources/view/device-manager/devices.js:71
msgid "Type"
msgstr "类型"
#: htdocs/luci-static/resources/device-manager/device-dialog.js:323
msgid "Type detection"
msgstr "类型识别"
#: htdocs/luci-static/resources/device-manager/service.js:92
msgid "Unable to read the kernel neighbor table"
msgstr "无法读取内核邻居表。"
#: htdocs/luci-static/resources/device-manager/device-dialog.js:48
msgid "Unavailable"
msgstr "未获取"
#: htdocs/luci-static/resources/device-manager/device-dialog.js:82
#: 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/model.js:46
#: htdocs/luci-static/resources/device-manager/table.js:26
#: htdocs/luci-static/resources/device-manager/table.js:183
msgid "Unknown"
msgstr "状态未知"
#: htdocs/luci-static/resources/device-manager/device-dialog.js:24
#: htdocs/luci-static/resources/device-manager/table.js:179
msgid "Unknown device"
msgstr "未知设备"
#: htdocs/luci-static/resources/device-manager/model.js:39
msgid "VR headset"
msgstr "VR 设备"
#: htdocs/luci-static/resources/device-manager/model.js:38
msgid "Vehicle"
msgstr "汽车"
#: htdocs/luci-static/resources/device-manager/table.js:256
msgid "View"
msgstr "查看"
#: htdocs/luci-static/resources/device-manager/model.js:42
msgid "Washing machine"
msgstr "洗衣机"
#: htdocs/luci-static/resources/device-manager/model.js:37
msgid "Watch"
msgstr "手表"
#: htdocs/luci-static/resources/device-manager/model.js:44
msgid "Water purifier"
msgstr "净水器"
#: htdocs/luci-static/resources/device-manager/device-dialog.js:308
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
msgid "Yes"
msgstr "是"
#: htdocs/luci-static/resources/device-manager/device-dialog.js:64
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
msgid "e.g. Living room TV"
msgstr "例如:55寸安卓电视,安装在客厅"
#: htdocs/luci-static/resources/device-manager/device-dialog.js:105
msgid "e.g. Living room TV, work computer"
msgstr "例如:客厅电视、主力工作电脑"
#: htdocs/luci-static/resources/device-manager/group-dialog.js:46
msgid "e.g. Media devices, office devices"
msgstr "例如:智能影音、办公设备"
@@ -0,0 +1,21 @@
# LAN Device Manager configuration
# Stores custom device names, remarks, and groups mapped by MAC address.
config group 'smart_home'
option name 'Smart home'
config group 'phone'
option name 'Phones'
config group 'computer'
option name 'Computers'
config group 'network'
option name 'Network devices'
config group 'other'
option name 'Other devices'
# Logical netifd interfaces eligible for the explicit Scan LAN operation.
config settings 'settings'
list scan_interface 'lan'
@@ -0,0 +1,272 @@
#!/bin/sh
# SPDX-License-Identifier: MIT
umask 077
# A nonblocking kernel lock covers all rpcd clients and is released on exit,
# including forced termination. Keep the lock file in place to preserve its inode.
probe_neighbors() (
set -f
command -v ping >/dev/null 2>&1 && command -v flock >/dev/null 2>&1 || exit 2
flock -n 9 || exit 0
read -r uptime rest < /proc/uptime
now="${uptime%%.*}"
last=""; cursor=""
read -r last cursor < /tmp/luci-device-manager-probe.state 2>/dev/null
case "$last" in ''|*[!0-9]*) last=0 ;; esac
case "$cursor" in ''|*[!0-9]*) cursor=0 ;; esac
if [ "$last" -gt 0 ] && [ "$now" -ge "$last" ] && [ "$((now-last))" -lt 30 ]; then
exit 0
fi
# Resolve only explicitly selected logical LAN interfaces through netifd.
# Never infer a LAN from connected routes or invent a /24 fallback.
interfaces="$(uci -q get device_manager.settings.scan_interface 2>/dev/null || printf lan)"
lan_subnets="$(
selected=0
for interface in $interfaces; do
case "$interface" in ''|*[!a-zA-Z0-9_]*) continue ;; esac
selected=$((selected+1))
[ "$selected" -le 8 ] || break
status="$(ubus call "network.interface.$interface" status 2>/dev/null)" || continue
json_load "$status" || continue
json_get_var device l3_device
case "$device" in ''|*[!a-zA-Z0-9_.:-]*) continue ;; esac
ip -o -4 addr show dev "$device" 2>/dev/null |
awk -v dev="$device" '$3=="inet" {print $4, dev}'
done | sort -u
)"
[ -n "$lan_subnets" ] || exit 2
# Bind every target to its LAN device. Exclude router/network/broadcast
# addresses. Subnets exceeding 4096 addresses use known hosts only.
targets="$(
{
printf '%s\n' "$lan_subnets" | awk '{print "N", $1, $2}'
awk '{print "H", $1, $6}' /proc/net/arp 2>/dev/null
ip neigh show 2>/dev/null | awk '{dev=""; for(i=2;i<NF;i++) if($i=="dev") dev=$(i+1); print "H", $1, dev}'
awk '{print "H", $3}' /tmp/dhcp.leases /var/dhcp.leases 2>/dev/null
} | awk '
function number(ip, a,n,i,v) {
n=split(ip,a,"."); if(n!=4) return -1
v=0; for(i=1;i<=4;i++) {if(a[i]!~/^[0-9]+$/ || a[i]>255) return -1; v=v*256+a[i]}
if(a[1]==0 || a[1]==127 || a[1]>=224) return -1
return v
}
function literal(v, a,b,c) {
a=int(v/16777216); b=int(v/65536)%256; c=int(v/256)%256
return a "." b "." c "." v%256
}
function emit(v,i, key) {
key=v SUBSEP dev[i]
if(!own[v] && !seen[key]++) print literal(v), dev[i]
}
$1=="N" {
split($2,a,"/"); v=number(a[1]); p=a[2]
if(v<0 || p!~/^[0-9]+$/ || p>32 || count>=64) next
count++; size[count]=1; for(j=0;j<32-p;j++) size[count]*=2
lo[count]=int(v/size[count])*size[count]
hi[count]=lo[count]+size[count]-1; dev[count]=$3; own[v]=1
if(p<31) {lo[count]++; hi[count]--}
next
}
$1=="H" {
v=number($2); if(v<0) next
for(i=1;i<=count;i++) if(v>=lo[i] && v<=hi[i] && ($3=="" || $3==dev[i])) emit(v,i)
}
END {
for(i=1;i<=count;i++) if(size[i]<=4096) for(v=lo[i];v<=hi[i];v++) emit(v,i)
}'
)" || exit 2
[ -n "$targets" ] || exit 0
count="$(printf '%s\n' "$targets" | awk 'END {print NR}')"
[ "$cursor" -lt "$count" ] || cursor=0
targets="$(printf '%s\n' "$targets" | awk -v offset="$cursor" '
{ips[NR-1]=$0}
END {for (i=0; i<NR; i++) print ips[(offset+i)%NR]}
')"
processed=0; pending=0
while read -r address device; do
# At most 16 children, 128 addresses and about 8 seconds of probing.
# The cursor resumes the remaining addresses on a later eligible request.
[ "$processed" -lt 128 ] || break
if [ "$pending" -eq 0 ]; then
read -r uptime rest < /proc/uptime
[ "$((${uptime%%.*}-now))" -lt 8 ] || break
fi
ping -c 1 -w 1 -W 1 -q -I "$device" "$address" >/dev/null 2>&1 &
processed=$((processed+1))
pending=$((pending+1))
if [ "$pending" -ge 16 ]; then
wait 2>/dev/null
pending=0
fi
done <<EOF
$targets
EOF
wait 2>/dev/null
read -r uptime rest < /proc/uptime
printf '%s %s\n' "${uptime%%.*}" "$(((cursor+processed)%count))" > /tmp/luci-device-manager-probe.state
) 9>/tmp/luci-device-manager-probe.lock 2>/dev/null
ping_error() {
json_init
json_add_boolean ok 0
json_add_string error "$1"
json_dump
}
# Resolve the target on the router; callers provide a MAC, never a shell command
# or an arbitrary destination. One global lock bounds simultaneous detail probes.
ping_device() (
command -v flock >/dev/null 2>&1 || { ping_error 'Ping is unavailable'; exit 0; }
flock -n 9 || { ping_error 'Another device is being probed. Please retry.'; exit 0; }
json_load "$(cat)" || { ping_error 'Enter a valid MAC address'; exit 0; }
json_get_var mac mac
mac="$(printf '%s' "$mac" | tr 'A-F' 'a-f')"
if ! printf '%s\n' "$mac" | awk -F: '
NF != 6 {exit 1}
{for (i=1; i<=6; i++) if ($i !~ /^[0-9a-f][0-9a-f]$/) exit 1}
$0 == "00:00:00:00:00:00" || $0 == "ff:ff:ff:ff:ff:ff" {exit 1}
'; then
ping_error 'Enter a valid MAC address'; exit 0
fi
neighbors="$(ip neigh show 2>/dev/null)" || { ping_error 'Unable to read the kernel neighbor table'; exit 0; }
candidates="$(
{
printf '%s\n' "$neighbors" | awk -v mac="$mac" '
{
owner=""; dev=""; active=0
for (i=2; i<=NF; i++) {
if ($i=="lladdr") owner=tolower($(i+1))
if ($i=="dev") dev=$(i+1)
if ($i=="REACHABLE") active=1
}
if (owner==mac) {
priority=($1 ~ /:/) ? (tolower($1) ~ /^fe80:/ ? 4 : 3) : (active ? 0 : 2)
print priority, $1, dev
}
}'
awk -v mac="$mac" 'tolower($2)==mac {print 1, $3}' /tmp/dhcp.leases /var/dhcp.leases 2>/dev/null
} | sort -n -k1,1
)"
address=""; iface=""
while read -r priority candidate candidate_iface; do
# Only numeric IP literals and kernel interface names reach ping.
if printf '%s\n' "$candidate" | awk '
/^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/ {split($0,a,"."); for(i=1;i<=4;i++) if(a[i]>255) exit 1; exit 0}
/^[0-9a-fA-F:]+$/ && /:/ {exit 0}
{exit 1}'; then
case "$candidate_iface" in *[!a-zA-Z0-9_.:-]*) continue ;; esac
# A stale lease must not cause another device at a reassigned IP to
# be reported as this MAC. Scope link-local ownership by interface.
if ! printf '%s\n' "$neighbors" | awk -v address="$candidate" -v mac="$mac" -v iface="$candidate_iface" '
$1==address {
owner=""; dev=""
for(i=2;i<=NF;i++) {
if($i=="lladdr") owner=tolower($(i+1))
if($i=="dev") dev=$(i+1)
}
if(owner!="" && owner!=mac && (iface=="" || iface==dev)) exit 1
}'; then
continue
fi
address="$candidate"; iface="$candidate_iface"; break
fi
done <<EOF
$candidates
EOF
[ -n "$address" ] || { ping_error 'No known IP address for this device'; exit 0; }
set -- -c 1 -w 3 -W 2
[ -z "$iface" ] || set -- "$@" -I "$iface"
case "$address" in
*:*)
if command -v ping6 >/dev/null 2>&1; then
ping_command=ping6
else
ping_command=ping
set -- -6 "$@"
fi ;;
*) ping_command=ping ;;
esac
command -v "$ping_command" >/dev/null 2>&1 || { ping_error 'Ping is unavailable'; exit 0; }
output="$("$ping_command" "$@" "$address" 2>&1)"
result=$?
json_init
json_add_string ip "$address"
json_add_string interface "$iface"
json_add_string output "$output"
if [ "$result" -eq 0 ] || [ "$result" -eq 1 ]; then
json_add_boolean ok 1
json_add_boolean reachable "$((1-result))"
else
json_add_boolean ok 0
json_add_string error 'Ping could not be completed'
fi
json_dump
) 9>/tmp/luci-device-manager-single-ping.lock 2>/dev/null
case "$1:$2" in
list:*)
printf '{"get_online_status":{},"scan_devices":{},"ping_device":{"mac":"string"}}\n'
;;
call:ping_device)
. /usr/share/libubox/jshn.sh
ping_device
;;
call:scan_devices)
. /usr/share/libubox/jshn.sh
probe_neighbors
result=$?
json_init
if [ "$result" -eq 0 ]; then
json_add_boolean ok 1
else
json_add_boolean ok 0
json_add_string error 'LAN scanning is unavailable. Check the selected interfaces and probe dependencies.'
fi
json_dump
;;
call:get_online_status)
. /usr/share/libubox/jshn.sh
json_init
if ! neighbors="$(ip neigh show 2>/dev/null)"; then
json_add_boolean ok 0
json_add_string error 'Unable to read the kernel neighbor table'
json_dump
exit 0
fi
json_add_boolean ok 1
json_add_array neighbors
# A here document keeps parsing in this shell. jshn escapes all JSON strings.
while read -r address rest; do
[ -n "$address" ] || continue
mac=""; state=""; device=""; previous=""
set -f
for word in $rest; do
case "$previous" in
dev) device="$word" ;;
lladdr) mac="$word" ;;
esac
case "$word" in
REACHABLE|DELAY|PROBE|STALE|FAILED|INCOMPLETE|PERMANENT|NOARP|NONE) state="$word" ;;
esac
previous="$word"
done
[ -n "$device" ] || continue
# FAILED and INCOMPLETE commonly have no lladdr. Preserve their IP/state.
json_add_object ''
json_add_string ip "$address"
json_add_string mac "$mac"
json_add_string dev "$device"
json_add_string state "$state"
json_close_object
done <<EOF
$neighbors
EOF
json_close_array
json_dump
;;
*) exit 1 ;;
esac
@@ -0,0 +1,15 @@
{
"admin/network/device-manager": {
"title": "Device Manager",
"order": 60,
"action": {
"type": "view",
"path": "device-manager/devices"
},
"depends": {
"acl": [
"luci-app-device-manager"
]
}
}
}
@@ -0,0 +1,24 @@
{
"luci-app-device-manager": {
"description": "Grant access to LAN device manager",
"read": {
"ubus": {
"luci-rpc": [ "getHostHints", "getDHCPLeases", "getWirelessDevices" ],
"luci.device-manager": [ "get_online_status", "ping_device" ],
"iwinfo": [ "assoclist" ],
"file": [ "read" ]
},
"file": {
"/proc/net/arp": [ "read" ]
},
"uci": [ "device_manager" ]
},
"write": {
"ubus": {
"luci.device-manager": [ "scan_devices" ],
"uci": [ "add", "set", "delete", "commit", "revert" ]
},
"uci": [ "device_manager" ]
}
}
}
@@ -0,0 +1,23 @@
# Test double for the standard jshn API. Records typed arguments, not plugin logic.
json_init() { printf 'init\0\0\0' > "$AUDIT_JSON_LOG"; }
json_add_boolean() { printf 'boolean\0%s\0%s\0' "$1" "$2" >> "$AUDIT_JSON_LOG"; }
json_add_string() { printf 'string\0%s\0%s\0' "$1" "$2" >> "$AUDIT_JSON_LOG"; }
json_add_array() { printf 'array\0%s\0\0' "$1" >> "$AUDIT_JSON_LOG"; }
json_add_object() { printf 'object\0%s\0\0' "$1" >> "$AUDIT_JSON_LOG"; }
json_close_array() { printf 'close\0\0\0' >> "$AUDIT_JSON_LOG"; }
json_close_object() { printf 'close\0\0\0' >> "$AUDIT_JSON_LOG"; }
json_dump() { "$AUDIT_NODE" "$AUDIT_JSON_ENCODER" "$AUDIT_JSON_LOG"; }
json_load() {
AUDIT_JSON_INPUT="$1"
export AUDIT_JSON_INPUT
"$AUDIT_NODE" -e 'JSON.parse(process.env.AUDIT_JSON_INPUT)' >/dev/null 2>&1
}
json_get_var() {
# Match jshn assignments without losing embedded newlines or evaluating input.
eval "$("$AUDIT_NODE" -e '
const value=JSON.parse(process.env.AUDIT_JSON_INPUT)[process.argv[2]];
const text=typeof value==="string" ? value : "";
const quote=String.fromCharCode(39);
process.stdout.write(process.argv[1]+"="+quote+text.split(quote).join(quote+"\\"+quote+quote)+quote);
' "$1" "$2")"
}
@@ -0,0 +1,15 @@
'use strict';
const fs = require('node:fs');
const fields = fs.readFileSync(process.argv[2], 'utf8').split('\0');
const root = {}, stack = [root];
for (let i = 0; i + 2 < fields.length; i += 3) {
const [type, key, raw] = fields.slice(i, i + 3);
if (type === 'init') continue;
if (type === 'close') { stack.pop(); continue; }
const value = type === 'array' ? [] : type === 'object' ? {} : type === 'boolean' ? raw === '1' : raw;
const current = stack[stack.length - 1];
if (Array.isArray(current)) current.push(value);
else current[key] = value;
if (type === 'array' || type === 'object') stack.push(value);
}
process.stdout.write(JSON.stringify(root) + '\n');
@@ -0,0 +1,155 @@
'use strict';
const fs = require('fs');
const path = require('path');
const root = path.resolve(__dirname, '../..');
String.prototype.format = function(...values) {
let index = 0;
return this.replace(/%[sd]/g, () => String(values[index++]));
};
class Node extends EventTarget {
constructor(tag, attrs = {}) {
super();
this.tag = tag;
this.attrs = attrs;
this.children = [];
this.style = {};
// LuCI sets every non-null attribute; even disabled="false" disables a button.
this.disabled = attrs.disabled != null;
this.selected = false;
this._value = attrs.value;
this.classes = new Set((attrs.class || '').split(/\s+/));
this.classList = { add: name => this.classes.add(name), remove: name => this.classes.delete(name) };
for (const [name, value] of Object.entries(attrs)) {
if (typeof value === 'function') this.addEventListener(name, event => { this.operation = value(event); });
}
}
appendChild(child) { this.children.push(child); return child; }
get value() {
if (this._value != null) return this._value;
if (this.tag === 'select') {
const option = this.children.find(child => child.selected) || this.children[0];
return option ? option.attrs.value : '';
}
return '';
}
set value(value) { this._value = value; }
get textContent() { return this.children.map(child => child instanceof Node ? child.textContent : String(child)).join(''); }
set textContent(value) { this.children = [String(value)]; }
querySelectorAll(selector) {
return this.children.filter(child => child instanceof Node).flatMap(child => [child, ...child.querySelectorAll('*')])
.filter(child => selector === '*' || selector === child.tag ||
(selector.startsWith('#') && child.attrs.id === selector.slice(1)) ||
(selector.startsWith('.') && child.classes.has(selector.slice(1))));
}
querySelector(selector) { return this.querySelectorAll(selector)[0] || null; }
focus() { this.focused = true; }
getAttribute(name) { return this.attrs[name]; }
click() {
if (this.disabled) return Promise.resolve();
this.dispatchEvent(new Event('click'));
return Promise.resolve(this.operation);
}
}
function environment(extra = {}) {
const htmlSinks = [], notifications = [];
const E = (tag, attrs, children) => {
if (!attrs || typeof attrs !== 'object' || Array.isArray(attrs)) { children = attrs; attrs = {}; }
const node = new Node(tag, attrs);
if (Array.isArray(children)) children.forEach(child => node.appendChild(child));
else if (children instanceof Node) node.appendChild(children);
else if (children != null) {
// Match LuCI dom.append: scalar content uses innerHTML, array items use text nodes.
htmlSinks.push(String(children));
node.appendChild(String(children));
}
return node;
};
const dom = { content: (node, children) => {
node.children = [];
if (Array.isArray(children)) children.forEach(child => node.appendChild(child));
else if (children != null) node.appendChild(children);
} };
const ui = {
modal: null,
showModal(title, children) { this.modal = E('modal', { title }, children); },
hideModal() { ui.modal = null; },
addNotification(title, content, level) { notifications.push({ content, level }); }
};
return Object.assign({
baseclass: { extend: value => value }, view: { extend: value => value }, E, dom, ui,
rpc: { declare: () => () => Promise.resolve({ language: 'auto' }) },
_: value => value, htmlSinks, notifications,
L: { hasViewPermission: () => true, resource: value => '/resources/' + value },
window: { localStorage: { getItem: () => null, setItem() {} } }
}, extra);
}
function loadModule(name, env, cache = new Map()) {
if (cache.has(name)) return cache.get(name);
const file = path.join(root, 'htdocs/luci-static/resources', name.replace(/\./g, '/') + '.js');
const source = fs.readFileSync(file, 'utf8');
const dependencies = Object.assign({}, env);
for (const match of source.matchAll(/'require ([\w.-]+)(?: as (\w+))?';/g)) {
const [, dependency, alias] = match;
dependencies[alias || dependency] = env[dependency] || loadModule(dependency, env, cache);
}
const result = new Function(...Object.keys(dependencies), source)(...Object.values(dependencies));
cache.set(name, result);
return result;
}
function backend(initial = []) {
const clone = value => JSON.parse(JSON.stringify(value));
const state = { saved: clone(initial), staged: null, cached: null, reads: 0, commits: 0, reverts: 0, calls: [], declarations: [] };
const uci = {
unload() { state.cached = null; },
load() {
if (!state.cached) { state.reads++; state.cached = clone(state.staged || state.saved); }
return Promise.resolve();
},
sections(config, type) { return state.cached.filter(section => section['.type'] === type); },
get(config, id) { return state.cached.find(section => section['.name'] === id) || null; },
add(config, type, id) { state.cached.push({ '.type': type, '.name': id }); return id; },
set(config, id, key, value) { uci.get(config, id)[key] = value; },
unset(config, id, key) { delete uci.get(config, id)[key]; },
remove(config, id) { state.cached = state.cached.filter(section => section['.name'] !== id); },
save() {
state.staged = clone(state.cached);
return state.failSave ? Promise.reject(new Error('save failed')) : Promise.resolve();
}
};
const replies = {
'luci-rpc.getHostHints': {},
'luci-rpc.getDHCPLeases': { dhcp_leases: [], dhcp6_leases: [] },
'luci-rpc.getWirelessDevices': {},
'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) {
state.declarations.push(options);
return (...args) => {
const key = options.object + '.' + options.method;
state.calls.push({ key, args });
if (key === 'uci.commit') {
if (state.failCommit) return options.reject ? Promise.reject(new Error('commit failed')) : Promise.resolve(6);
state.saved = clone(state.staged || state.saved); state.staged = null; state.commits++;
return Promise.resolve({});
}
if (key === 'uci.revert') {
state.reverts++;
if (state.failRevert) return Promise.reject(new Error('revert failed'));
state.staged = null; return Promise.resolve({});
}
const result = typeof replies[key] === 'function' ? replies[key](...args) : replies[key];
return result instanceof Error ? Promise.reject(result) : Promise.resolve(clone(result));
};
} };
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 };
@@ -0,0 +1,116 @@
'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 attack = '<img src=x onerror="window.auditFlag=1">';
async function context() {
const mocks = backend([
{ '.type': 'group', '.name': 'home', name: 'Home' },
{ '.type': 'device', '.name': 'saved', mac, name: 'Living room', remark: attack, group: 'home', type: 'tv' }
]);
mocks.replies['luci-rpc.getHostHints'] = { [mac]: {
name: 'television', ipaddrs: ['192.168.1.20', '192.168.1.21'],
ip6addrs: ['fd00::20', 'fe80::20']
} };
mocks.replies['luci.device-manager.get_online_status'] = { ok: true, neighbors: [
{ mac, ip: '192.168.1.20', dev: 'br-lan', state: 'REACHABLE' },
{ mac, ip: 'fd00::20', dev: 'br-lan', state: 'STALE' }
] };
const env = environment(mocks);
const page = loadModule('view.device-manager.devices', env);
const node = page.render(await page.load());
return Object.assign({ env, page, node, dev: page.devices[0] }, mocks);
}
test('details retain all known addresses and evidence and open with exactly one independent Ping', async () => {
const ctx = await context();
const scans = ctx.state.calls.filter(call => call.key === 'luci.device-manager.get_online_status').length;
assert.deepEqual(ctx.dev.ipv4Addresses, ['192.168.1.20', '192.168.1.21']);
assert.deepEqual(ctx.dev.ipv6Addresses, ['fd00::20', 'fe80::20']);
assert.deepEqual(ctx.dev.interfaces, ['br-lan']);
await ctx.node.querySelector('.dm-action-detail').click();
const modal = ctx.env.ui.modal;
assert.equal(modal.attrs.title, 'Device details');
for (const value of ['Living room', 'television', '192.168.1.20', '192.168.1.21', 'fd00::20', 'fe80::20', mac, 'Home', attack, 'br-lan', 'REACHABLE', 'STALE']) {
assert.ok(modal.textContent.includes(value), value);
}
assert.equal(modal.querySelector('.dm-ping-status').textContent, 'Online · Ping replied');
const calls = ctx.state.calls.filter(call => call.key === 'luci.device-manager.ping_device');
assert.equal(calls.length, 1); assert.deepEqual(calls[0].args, [mac]);
assert.equal(ctx.state.calls.filter(call => call.key === 'luci.device-manager.get_online_status').length, scans);
assert.equal(ctx.state.commits, 0); assert.deepEqual(ctx.env.htmlSinks, []);
});
test('unanswered Ping is distinct from offline and retry does not overlap an in-flight request', async () => {
const ctx = await context();
let release;
ctx.page.handlePingDevice = () => new Promise(resolve => { release = resolve; });
const pending = ctx.page.showDetailModal(ctx.dev);
await Promise.resolve();
const modal = ctx.env.ui.modal;
const button = modal.querySelectorAll('button').find(node => node.textContent === 'Ping again');
assert.equal(button.disabled, true);
await button.click();
release({ reachable: false, ip: '192.168.1.20', output: attack });
await pending;
assert.equal(modal.querySelector('.dm-ping-status').textContent, 'No Ping reply');
assert.ok(modal.textContent.includes('does not confirm it is offline'));
assert.equal(modal.querySelector('pre').textContent, attack);
assert.equal(ctx.dev.status, 'online'); assert.equal(button.disabled, false);
assert.deepEqual(ctx.env.htmlSinks, []);
ctx.page.handlePingDevice = () => Promise.resolve({ reachable: true, ip: '192.168.1.20', output: 'reply' });
await button.click();
assert.equal(modal.querySelector('.dm-ping-status').textContent, 'Online · Ping replied');
});
test('missing addresses and RPC failures remain visible and restore the retry control', async () => {
const ctx = await context();
for (const response of [{ ok: false, error: 'No known IP address for this device' }, new Error('RPC unavailable'), { ok: true }]) {
ctx.replies['luci.device-manager.ping_device'] = response;
await ctx.page.showDetailModal(ctx.dev);
const modal = ctx.env.ui.modal;
assert.equal(modal.querySelector('.dm-ping-status').textContent, 'Probe unavailable');
assert.equal(modal.querySelectorAll('button').find(node => node.textContent === 'Ping again').disabled, false);
assert.equal(ctx.dev.status, 'online');
}
});
test('late probe completion updates only its own content and never replaces a newer dialog', async () => {
const ctx = await context();
let release;
ctx.page.handlePingDevice = () => new Promise(resolve => { release = resolve; });
const pending = ctx.page.showDetailModal(ctx.dev);
await Promise.resolve();
ctx.page.showEditModal(ctx.dev);
const editModal = ctx.env.ui.modal;
release({ reachable: true, ip: '192.168.1.20', output: 'reply' });
await pending;
assert.equal(ctx.env.ui.modal, editModal); assert.equal(editModal.attrs.title, 'Edit device');
});
test('read-only sessions can view details and probe without getting editing or clearing controls', async () => {
const ctx = await context();
ctx.env.L.hasViewPermission = () => false;
ctx.page.readonly = true;
ctx.page.updateView();
assert.equal(ctx.node.querySelector('.dm-action-edit'), null);
await ctx.node.querySelector('.dm-action-detail').click();
assert.equal(ctx.env.ui.modal.attrs.title, 'Device details');
assert.ok(!ctx.env.ui.modal.textContent.includes('Clear record'));
assert.equal(ctx.state.commits, 0);
});
test('clear record removes metadata after confirmation while discovered devices and groups remain', async () => {
const ctx = await context();
ctx.page.showEditModal(ctx.dev);
const clear = ctx.env.ui.modal.querySelectorAll('button').find(node => node.textContent === 'Clear record');
await clear.click();
assert.equal(ctx.state.saved.length, 2, 'opening confirmation must not clear anything');
await ctx.env.ui.modal.querySelectorAll('button').find(node => node.textContent === 'Confirm clear').click();
assert.equal(ctx.state.saved.length, 1); assert.equal(ctx.state.saved[0]['.type'], 'group');
const dev = ctx.page.devices.find(device => device.mac === mac);
assert.equal(dev.isSaved, false); assert.equal(dev.customName, ''); assert.equal(dev.remark, '');
assert.equal(dev.group, 'ungrouped'); assert.equal(dev.hostname, 'television'); assert.equal(dev.customType, undefined);
});
@@ -0,0 +1,177 @@
'use strict';
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 EXPECTED_ICONS = [
'computer.svg', 'laptop.svg', 'phone.svg', 'tablet.svg',
'tv.svg', 'tvbox.svg', 'nas.svg', 'printer.svg',
'camera.svg', 'speaker.svg', 'game.svg', 'router.svg',
'switch.svg', 'ap.svg', 'server.svg', 'plug.svg',
'light.svg', 'sensor.svg', 'home.svg', 'watch.svg',
'car.svg', 'vr.svg', 'network.svg',
'airconditioner.svg', 'washer.svg', 'fridge.svg',
'waterpurifier.svg', 'airpurifier.svg',
'unknown.svg'
];
function pageContext(initial = []) {
const mocks = backend(initial);
const env = environment(mocks);
const cache = new Map();
const model = loadModule('device-manager.model', env, cache);
const service = loadModule('device-manager.service', env, cache);
const page = loadModule('view.device-manager.devices', env, cache);
return Object.assign({ env, page, model, service }, mocks);
}
test('all 29 distributed icons have one source and pinned attribution', () => {
const directory = path.join(root, 'htdocs/luci-static/resources/device-manager/device-icons');
const manifest = JSON.parse(fs.readFileSync(path.join(directory, 'sources.json')));
assert.match(manifest.revision, /^[a-f0-9]{40}$/);
for (const icon of EXPECTED_ICONS) {
const content = fs.readFileSync(path.join(directory, icon), 'utf8');
assert.match(content, /<svg[^>]+xmlns="http:\/\/www\.w3\.org\/2000\/svg"/);
assert.match(content, /<\/svg>/);
assert.ok(manifest.icons[icon].source.includes(manifest.revision));
}
assert.ok(fs.existsSync(path.join(directory, 'LICENSE')));
assert.ok(fs.existsSync(path.join(directory, 'NOTICE')));
});
test('vendor hints remain conservative and randomized MACs do not imply a vendor', () => {
const env = environment();
const model = loadModule('device-manager.model', env);
const cases = [
{ mac: '00:11:32:11:22:33', expected: 'nas' },
{ mac: '4C:FC:AA:11:22:33', expected: 'car' },
{ mac: '2C:26:17:11:22:33', expected: 'vr' },
{ mac: '00:1B:78:11:22:33', expected: 'unknown' },
{ mac: '10:12:FB:11:22:33', expected: 'camera' },
{ mac: '00:09:BF:11:22:33', expected: 'game' },
{ mac: '00:0E:58:11:22:33', expected: 'speaker' },
{ mac: 'F4:F5:E8:11:22:33', expected: 'unknown' },
{ mac: '00:1A:9A:11:22:33', expected: 'unknown' },
{ mac: '18:FE:34:11:22:33', expected: 'home' },
{ mac: '00:17:88:11:22:33', expected: 'light' },
{ mac: '54:EF:44:11:22:33', expected: 'home' },
{ mac: '10:2C:6B:11:22:33', expected: 'unknown' },
{ mac: '52:54:00:11:22:33', expected: 'unknown' },
{ mac: '00:15:6D:11:22:33', expected: 'network' },
{ mac: '00:0F:E2:11:22:33', expected: 'network' },
{ mac: '00:0C:42:11:22:33', expected: 'router' },
{ mac: 'B8:27:EB:11:22:33', expected: 'computer' },
{ mac: 'F0:18:98:11:22:33', expected: 'unknown' },
{ mac: '12:34:56:78:9A:BC', expected: 'unknown' }
];
for (const c of cases) {
const detected = model.detectDeviceType(c.mac, '', '', '');
assert.equal(detected, c.expected, `MAC ${c.mac} should be ${c.expected}, got ${detected}`);
const info = model.getTypeInfo(detected);
assert.equal(info.icon, detected + '.svg');
}
});
test('detectDeviceType refines classification using hostname or custom name clues', () => {
const env = environment();
const model = loadModule('device-manager.model', env);
const appleMac = 'F0:18:98:11:22:33';
assert.equal(model.detectDeviceType(appleMac, 'My-iPhone', '', ''), 'phone');
assert.equal(model.detectDeviceType(appleMac, 'MacBook-Pro', '', ''), 'laptop');
assert.equal(model.detectDeviceType(appleMac, 'iPad-Mini', '', ''), 'tablet');
assert.equal(model.detectDeviceType(appleMac, 'Apple-Watch-Ultra', '', ''), 'watch');
assert.equal(model.detectDeviceType(appleMac, 'Apple-TV-4K', '', ''), 'tvbox');
assert.equal(model.detectDeviceType(appleMac, 'HomePod-LivingRoom', '', ''), 'speaker');
const genericMac = '12:34:56:78:9A:BC';
assert.equal(model.detectDeviceType(genericMac, 'DiskStation', '', ''), 'nas');
assert.equal(model.detectDeviceType(genericMac, 'Tesla-Model-Y', '', ''), 'car');
assert.equal(model.detectDeviceType(genericMac, 'Oculus-Quest-3', '', ''), 'vr');
assert.equal(model.detectDeviceType(genericMac, 'HP-LaserJet', '', ''), 'printer');
assert.equal(model.detectDeviceType(genericMac, 'Hikvision-IPC', '', ''), 'camera');
assert.equal(model.detectDeviceType(genericMac, 'PlayStation-5', '', ''), 'game');
assert.equal(model.detectDeviceType(genericMac, 'USW-Lite-16-PoE', '', ''), 'switch');
assert.equal(model.detectDeviceType(genericMac, 'UniFi-U6-Pro', '', ''), 'ap');
assert.equal(model.detectDeviceType(genericMac, 'Gree-AirConditioner', '', ''), 'airconditioner');
assert.equal(model.detectDeviceType(genericMac, 'LivingRoom-空调', '', ''), 'airconditioner');
assert.equal(model.detectDeviceType(genericMac, 'LG-Washer', '', ''), 'washer');
assert.equal(model.detectDeviceType(genericMac, 'Haier-洗衣机', '', ''), 'washer');
assert.equal(model.detectDeviceType(genericMac, 'Samsung-Fridge', '', ''), 'fridge');
assert.equal(model.detectDeviceType(genericMac, '厨房冰箱', '', ''), 'fridge');
assert.equal(model.detectDeviceType(genericMac, 'Xiaomi-WaterPurifier', '', ''), 'waterpurifier');
assert.equal(model.detectDeviceType(genericMac, '小米净水器', '', ''), 'waterpurifier');
assert.equal(model.detectDeviceType(genericMac, 'Mi-AirPurifier-Pro', '', ''), 'airpurifier');
assert.equal(model.detectDeviceType(genericMac, '卧室空气净化器', '', ''), 'airpurifier');
});
test('device table renders the device icon after the row number', async () => {
const ctx = pageContext();
const nasMac = '00:11:32:11:22:33';
ctx.replies['luci.device-manager.get_online_status'] = {
ok: true,
neighbors: [{ mac: nasMac, state: 'REACHABLE' }]
};
ctx.replies['luci-rpc.getHostHints'] = {
[nasMac]: { name: 'Synology-NAS', ipaddrs: ['192.168.1.50'] }
};
const data = await ctx.page.load();
const viewNode = ctx.page.render(data);
// Check table thead has type column
const table = viewNode.querySelector('#device_manager_table');
const thList = table.querySelectorAll('th');
assert.equal(thList[1].classes.has('dm-type-th'), true);
// Check tbody row has the icon after the row number
const tbody = viewNode.querySelector('#device_manager_tbody');
const rows = tbody.querySelectorAll('tr');
assert.equal(rows.length, 1);
const row = rows[0];
const tdList = row.querySelectorAll('td');
assert.equal(tdList[1].classes.has('dm-type-cell'), true);
const iconImg = tdList[1].querySelector('img');
assert.ok(iconImg);
assert.equal(iconImg.classes.has('dm-device-icon'), true);
assert.match(iconImg.attrs.src, /device-icons\/nas\.svg/);
});
test('custom device type override can be saved and displayed', async () => {
const customMac = '00:11:32:55:66:77';
const initial = [
{ '.type': 'device', '.name': 'dev_001132556677', mac: customMac, name: 'Backup Server', type: 'server' }
];
const ctx = pageContext(initial);
ctx.replies['luci.device-manager.get_online_status'] = {
ok: true,
neighbors: [{ mac: customMac, state: 'REACHABLE' }]
};
const data = await ctx.page.load();
const viewNode = ctx.page.render(data);
const row = viewNode.querySelector('#device_manager_tbody').querySelectorAll('tr')[0];
const iconImg = row.querySelectorAll('td')[1].querySelector('img');
assert.match(iconImg.attrs.src, /device-icons\/server\.svg/);
// Test saving a new type via service
await ctx.service.saveDevice(customMac, 'Backup Server', 'Rack 1', 'ungrouped', 'nas');
assert.equal(ctx.state.saved[0].type, 'nas');
});
test('ambiguous short tokens and general vendors do not override meaningful device names', () => {
const model = loadModule('device-manager.model', environment());
for (const [name, expected] of [
['switch', 'switch'], ['gateway', 'router'], ['Cambridge-PC', 'computer'],
['spring', 'unknown'], ['bookmark', 'unknown'], ['canvas', 'unknown'],
['nintendo-switch', 'game'], ['switch-PC', 'computer']
]) assert.equal(model.detectDeviceType('12:34:56:78:9A:BC', name), expected, name);
assert.equal(model.detectDeviceType('02:11:32:11:22:33', ''), 'unknown');
assert.equal(model.detectDeviceType('02:11:32:11:22:33', 'synology'), 'nas');
});
@@ -0,0 +1,372 @@
'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', 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' };
const group = { '.type': 'group', '.name': 'home', name: 'Home' };
const data = overrides => Object.assign({ hints: {}, leases: {}, neighbors: { neighbors: [] }, wifi: [], arp: [], devices: [], discoveryComplete: true }, overrides);
const parse = input => model.parseDevices(data(input), model.parseGroups(input.groups || []));
function service(initial) {
const mocks = backend(initial);
const env = environment(mocks);
return Object.assign({ env, service: loadModule('device-manager.service', env) }, mocks);
}
test('MAC normalization, stable section ID and control-character removal', () => {
for (const value of ['aa:bb:cc:11:22:33', 'aa-bb-cc-11-22-33', 'aabbcc112233']) assert.equal(model.normalizeMac(value), mac);
for (const value of [null, 123, 'not-a-mac', '00:00:00:00:00:00', 'FF:FF:FF:FF:FF:FF']) assert.equal(model.normalizeMac(value), null);
assert.equal(model.getSectionId(mac), 'dev_aabbcc112233');
assert.equal(model.sanitizeInput(' hi\nthere\x01 '), 'hi there');
});
test('empty group configuration remains empty', () => assert.deepEqual(model.parseGroups([]), []));
test('Wi-Fi-only, neighbor-only and ARP-only devices are discovered', () => {
assert.equal(parse({ wifi: [mac] })[0].status, 'online');
const neighbor = parse({ neighbors: { neighbors: [{ mac, ip: '192.168.1.10', state: 'REACHABLE' }] } });
assert.equal(neighbor[0].status, 'online'); assert.equal(neighbor[0].ipv4, '192.168.1.10');
assert.equal(parse({ arp: [{ mac, ip: '192.168.1.10', flags: '0x2' }] })[0].status, 'unknown');
});
test('multiple evidence sources deduplicate a MAC and preserve user metadata', () => {
const result = parse({ hints: { [mac.toLowerCase()]: { name: 'hostname', ipaddrs: ['192.168.1.20'] } },
wifi: [mac], devices: [Object.assign({}, device, { group: 'deleted' })] });
assert.equal(result.length, 1); assert.equal(result[0].customName, 'TV');
assert.equal(result[0].remark, 'Living room'); assert.equal(result[0].group, 'ungrouped');
});
test('active neighbor evidence outranks FAILED and STALE in every input order', () => {
for (const active of ['REACHABLE', 'DELAY', 'PROBE']) {
for (const states of [['FAILED', active], [active, 'FAILED'], ['STALE', active], [active, 'STALE']]) {
assert.equal(parse({ neighbors: { neighbors: states.map(state => ({ mac, state })) } })[0].status, 'online');
}
}
});
test('FAILED neighbors without MAC are associated to a unique known IP', () => {
const result = parse({ hints: { [mac]: { ipaddrs: ['192.168.1.20'] } },
neighbors: { neighbors: [{ ip: '192.168.1.20', dev: 'br-lan', state: 'FAILED' }] } });
assert.equal(result[0].status, 'offline');
});
test('ambiguous IP ownership never assigns a failure to either device', () => {
const result = parse({ hints: { [mac]: { ipaddrs: ['192.168.1.20'] }, [other]: { ipaddrs: ['192.168.1.20'] } },
neighbors: { neighbors: [{ ip: '192.168.1.20', state: 'FAILED' }] } });
assert.ok(result.every(item => item.status === 'unknown'));
});
test('STALE, PERMANENT and unresolved discovery failures are unknown', () => {
for (const state of ['STALE', 'PERMANENT', 'INCOMPLETE']) assert.equal(parse({ neighbors: { neighbors: [{ mac, state }] } })[0].status, 'unknown');
assert.equal(parse({ devices: [device], discoveryComplete: false })[0].status, 'unknown');
assert.equal(parse({ devices: [device] })[0].status, 'offline');
assert.equal(parse({ devices: [device], discoveryComplete: false, wifi: [mac] })[0].status, 'online');
});
test('current active IP and DHCP leases take precedence over stale hints', () => {
const result = parse({ hints: { [mac]: { ipaddrs: ['192.168.1.20'] } }, leases: { dhcp_leases: [{ macaddr: mac, ipaddr: '192.168.1.90' }] } });
assert.equal(result[0].ipv4, '192.168.1.90');
assert.equal(parse({ hints: { [mac]: { ipaddrs: ['192.168.1.20'] } }, neighbors: { neighbors: [{ mac, ip: '192.168.1.91', state: 'REACHABLE' }] } })[0].ipv4, '192.168.1.91');
});
test('IPv6 leases and all addresses participate in failed neighbor matching', () => {
const result = parse({ leases: { dhcp6_leases: [{ macaddr: mac, ip6addrs: ['fd00::2/64', 'fd00::3'] }] },
neighbors: { neighbors: [{ ip: 'fd00::3', state: 'FAILED' }] } });
assert.equal(result[0].ipv6, 'fd00::2'); assert.equal(result[0].status, 'offline');
});
test('combined group and text filtering uses names, remarks and addresses', () => {
const result = parse({ devices: [Object.assign({}, device, { group: 'home' })], groups: [group] });
assert.ok(model.matchesDevice(result[0], model.parseGroups([group]), 'home', 'living'));
assert.ok(model.matchesDevice(result[0], model.parseGroups([group]), 'home', 'home'));
assert.equal(model.matchesDevice(result[0], [], 'ungrouped', ''), false);
});
test('legacy sections recover their MAC from the section ID', () => {
assert.equal(parse({ devices: [{ '.name': 'dev_aabbcc112233', name: 'Legacy' }] })[0].mac, mac);
});
test('real service saves a MAC-only record and clearing metadata preserves it', async () => {
const ctx = service([]);
await ctx.service.saveDevice(mac, '', '', 'ungrouped');
assert.equal(ctx.state.saved.length, 1); assert.equal(ctx.state.saved[0].mac, mac);
await ctx.service.saveDevice(mac, 'TV', 'Room', 'ungrouped');
await ctx.service.saveDevice(mac, '', '', 'ungrouped');
assert.equal(ctx.state.saved.length, 1); assert.equal(ctx.state.saved[0].name, undefined);
});
test('group create, rename and delete preserve actual device name and remarks', async () => {
const ctx = service([device]);
await ctx.service.addGroup('Home');
const created = ctx.state.saved.find(section => section['.type'] === 'group');
await ctx.service.saveDevice(mac, 'TV', 'Living room', created['.name']);
await ctx.service.renameGroup(created['.name'], 'Media');
assert.equal(ctx.state.saved.find(section => section.mac === mac).group, created['.name']);
await ctx.service.deleteGroup(created['.name']);
assert.deepEqual(ctx.state.saved, [device]);
assert.deepEqual((await ctx.service.load()).groups, []);
});
test('duplicate device sections are consolidated and deletion removes all matches', async () => {
const ctx = service([device, Object.assign({}, device, { '.name': 'legacy' })]);
await ctx.service.saveDevice(mac, 'Edited', '', 'ungrouped');
assert.equal(ctx.state.saved.length, 1);
await ctx.service.deleteDevice({ mac }); assert.equal(ctx.state.saved.length, 0);
});
test('commit failure rejects, reverts staged changes and permits a clean retry', async () => {
const ctx = service([device]); ctx.state.failCommit = true;
await assert.rejects(ctx.service.saveDevice(mac, 'Failed edit', '', 'ungrouped'), /commit failed/);
assert.deepEqual(ctx.state.saved, [device]); assert.equal(ctx.state.staged, null); assert.equal(ctx.state.reverts, 1);
assert.equal(ctx.state.declarations.find(options => options.method === 'commit').reject, true);
ctx.state.failCommit = false;
await ctx.service.saveDevice(other, 'Phone', '', 'ungrouped');
assert.equal(ctx.state.saved.find(section => section.mac === mac).name, 'TV');
});
test('partial save failure also discards staged edits', async () => {
const ctx = service([device]); ctx.state.failSave = true;
await assert.rejects(ctx.service.saveDevice(mac, 'Failed edit', '', 'ungrouped'), /save failed/);
assert.equal(ctx.state.staged, null); assert.equal(ctx.state.reverts, 1);
});
test('rollback failure is visible instead of reporting a successful save', async () => {
const ctx = service([device]); ctx.state.failCommit = true; ctx.state.failRevert = true;
await assert.rejects(ctx.service.saveDevice(mac, 'Failed edit', '', 'ungrouped'), /failed to discard pending changes/);
});
test('fresh UCI load observes external edits and validation rejects deleted groups', async () => {
const ctx = service([group, device]); await ctx.service.load();
ctx.state.saved[1].name = 'External edit';
assert.equal((await ctx.service.load()).devices[0].name, 'External edit');
await assert.rejects(ctx.service.saveDevice(mac, 'TV', '', 'missing'), /no longer exists/);
assert.equal(ctx.state.commits, 0);
});
test('renaming into an existing group name and overlong inputs are rejected', async () => {
const ctx = service([group, { '.type': 'group', '.name': 'other', name: 'Other' }]);
await assert.rejects(ctx.service.renameGroup('other', 'Home'), /already exists/);
await assert.rejects(ctx.service.saveDevice(mac, 'a'.repeat(65), '', 'ungrouped'), /too long/);
});
test('service refuses writes for read-only sessions', async () => {
const ctx = service([device]); ctx.env.L.hasViewPermission = () => false;
await assert.rejects(ctx.service.saveDevice(mac, 'Forbidden', '', 'ungrouped'), /Read-only/);
assert.equal(ctx.state.reads, 0); assert.equal(ctx.state.commits, 0);
});
test('refresh is serialized after an in-flight write and does not unload it', async () => {
const ctx = service([device]); let release, started;
const signal = new Promise(resolve => { started = resolve; });
const blocker = new Promise(resolve => { release = resolve; });
const save = ctx.uci.save;
ctx.uci.save = () => { started(); return blocker.then(save); };
const writing = ctx.service.saveDevice(mac, 'New name', '', 'ungrouped');
await signal;
const reading = ctx.service.load();
assert.equal(ctx.state.cached[0].name, 'New name');
release(); await writing;
assert.equal((await reading).devices[0].name, 'New name');
});
test('every refresh fetches current host hints without network library cache', async () => {
const ctx = service([]);
ctx.replies['luci-rpc.getHostHints'] = { [mac]: { ipaddrs: ['192.168.1.20'] } };
await ctx.service.load();
ctx.replies['luci-rpc.getHostHints'] = { [mac]: { ipaddrs: ['192.168.1.90'] } };
assert.equal(model.parseDevices(await ctx.service.load(), [])[0].ipv4, '192.168.1.90');
});
test('discovery errors are reported and do not turn saved devices offline', async () => {
const ctx = service([device]); ctx.replies['luci-rpc.getHostHints'] = new Error('access denied');
const snapshot = await ctx.service.load();
assert.equal(snapshot.discoveryComplete, false); assert.match(snapshot.errors.join(), /access denied/);
assert.equal(model.parseDevices(snapshot, [])[0].status, 'unknown');
ctx.replies['luci.device-manager.get_online_status'] = { ok: false, error: 'ip unavailable' };
assert.match((await ctx.service.load()).errors.join(), /ip unavailable/);
});
test('partial Wi-Fi RPC failure preserves active stations and VLAN discovery', async () => {
const ctx = service([]);
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' } }
] } };
ctx.replies['iwinfo.assoclist'] = name => name === 'wlan1' ? new Error('radio unavailable') : { results: [{ mac }] };
const snapshot = await ctx.service.load();
assert.equal(snapshot.discoveryComplete, false); assert.equal(model.parseDevices(snapshot, [])[0].status, 'online');
assert.ok(ctx.state.calls.some(call => call.key === 'iwinfo.assoclist' && call.args[0] === 'wlan0.10'));
assert.ok(!ctx.state.calls.some(call => call.args[0] === 'uplink'));
});
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 } });
const preferences = loadModule('device-manager.preferences', env);
assert.deepEqual(await preferences.load(), { tab: 'online', group: 'all' });
await preferences.save('online', 'home');
}
});
test('preference storage contains only tab and group', async () => {
let raw;
const env = environment({ window: { localStorage: { getItem: () => raw, setItem: (key, value) => { raw = value; } } } });
const preferences = loadModule('device-manager.preferences', env);
await preferences.save('online', 'home');
assert.deepEqual(JSON.parse(raw), { tab: 'online', group: 'home' });
assert.deepEqual(await preferences.load(), { tab: 'online', group: 'home' });
});
function pageContext(initial = []) {
const mocks = backend(initial);
const env = environment(mocks);
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());
return node;
}
function button(node, label) { return node.querySelectorAll('button').find(item => item.textContent === label); }
const attack = '<img src=x onerror="window.auditFlag=1">';
test('untrusted device/group text renders as text in rows, forms and confirmations', async () => {
const ctx = pageContext([Object.assign({}, group, { name: attack }), Object.assign({}, device, { name: attack, remark: attack, group: 'home' })]);
ctx.replies['luci-rpc.getHostHints'] = { [mac]: { name: attack, ipaddrs: ['192.168.1.20'] } };
const node = await renderPage(ctx);
assert.ok(node.textContent.includes(attack));
ctx.page.showEditModal(ctx.page.devices[0]);
assert.equal(ctx.env.ui.modal.attrs.title, 'Edit device');
ctx.page.confirmDelete(ctx.page.devices[0]);
ctx.page.showGroupModal(); ctx.page.promptRenameGroup(ctx.page.groups[0]);
ctx.page.confirmDeleteGroup(ctx.page.groups[0], 1);
assert.deepEqual(ctx.env.htmlSinks, []);
});
test('search empty-state messages safely display markup-like search text', async () => {
const ctx = pageContext([]); const node = await renderPage(ctx);
ctx.page.filterText = attack; ctx.page.updateView();
assert.ok(node.textContent.includes(attack)); assert.deepEqual(ctx.env.htmlSinks, []);
});
test('actual save button recovers after event.currentTarget is cleared', async () => {
const ctx = pageContext([]); await renderPage(ctx); ctx.page.showEditModal(null);
const modal = ctx.env.ui.modal; modal.querySelectorAll('input')[0].value = mac;
const save = button(modal, 'Save');
await save.click();
assert.equal(save.disabled, false); assert.equal(save.classes.has('spinning'), false);
assert.equal(ctx.state.saved[0].mac, mac); assert.equal(ctx.env.notifications[0].level, 'info');
});
test('save failure leaves the modal open, restores button and never notifies success', async () => {
const ctx = pageContext([]); await renderPage(ctx); ctx.page.showEditModal(null);
const modal = ctx.env.ui.modal; modal.querySelectorAll('input')[0].value = mac;
ctx.state.failCommit = true;
const save = button(modal, 'Save'); await save.click();
assert.equal(ctx.env.ui.modal, modal); assert.equal(save.disabled, false);
assert.equal(ctx.env.notifications.length, 0); assert.match(modal.textContent, /commit failed/);
assert.equal(ctx.state.saved.length, 0);
});
test('actual add, rename and delete group buttons restore their state', async () => {
const ctx = pageContext([]); await renderPage(ctx); ctx.page.showGroupModal();
const modal = ctx.env.ui.modal; modal.querySelector('input').value = 'Home';
const add = button(modal, 'Add group'); await add.click(); assert.equal(add.disabled, false);
ctx.page.promptRenameGroup(ctx.page.groups[0]);
ctx.env.ui.modal.querySelector('input').value = 'Media';
const rename = button(ctx.env.ui.modal, 'Save changes'); await rename.click(); assert.equal(rename.disabled, false);
ctx.page.confirmDeleteGroup(ctx.page.groups[0], 0);
const remove = button(ctx.env.ui.modal, 'Confirm delete'); await remove.click(); assert.equal(remove.disabled, false);
assert.deepEqual(ctx.page.groups, []);
});
test('actual clear record button restores its state on failure and retry', async () => {
const ctx = pageContext([device]); await renderPage(ctx); ctx.page.confirmDelete(ctx.page.devices[0]);
const remove = button(ctx.env.ui.modal, 'Confirm clear'); ctx.state.failCommit = true;
await remove.click(); assert.equal(remove.disabled, false); assert.equal(ctx.state.saved.length, 1);
ctx.state.failCommit = false; ctx.page.confirmDelete(ctx.page.devices[0]);
const retry = button(ctx.env.ui.modal, 'Confirm clear'); await retry.click();
assert.equal(retry.disabled, false); assert.equal(ctx.state.saved.length, 0);
});
test('refresh button restores state and external group deletion resets the active filter', async () => {
const ctx = pageContext([group]); const node = await renderPage(ctx);
ctx.page.activeGroup = 'home'; ctx.state.saved = [];
const refresh = button(node, 'Refresh list'); await refresh.click();
assert.equal(refresh.disabled, false); assert.equal(ctx.page.activeGroup, 'all');
assert.deepEqual(ctx.page.groups, []);
});
test('failed refresh preserves the last displayed snapshot and reports refresh failure', async () => {
const ctx = pageContext([device]); const node = await renderPage(ctx);
ctx.uci.load = () => Promise.reject(new Error('configuration unavailable'));
await button(node, 'Refresh list').click();
assert.equal(ctx.page.devices[0].customName, 'TV');
assert.match(ctx.env.notifications[0].content.textContent, /Could not refresh/);
});
test('read-only views have no edit actions or writable dialogs and disable the toolbar', async () => {
const ctx = pageContext([device]); ctx.env.L.hasViewPermission = () => false;
const node = await renderPage(ctx);
ctx.page.activeTab = 'all'; ctx.page.updateView();
assert.equal(button(node, 'Edit'), undefined); assert.equal(button(node, 'Delete'), undefined);
assert.equal(node.querySelectorAll('.dm-action-edit').length, 0);
assert.equal(node.querySelectorAll('.dm-action-detail').length, 1);
assert.equal(button(node, 'Add device').disabled, true); assert.equal(button(node, 'Manage groups').disabled, true);
ctx.page.showEditModal(null); ctx.page.showGroupModal(); assert.equal(ctx.env.ui.modal, null);
assert.equal(ctx.page.handleSave, null); assert.equal(ctx.page.handleSaveApply, null); assert.equal(ctx.page.handleReset, null);
});
test('rows offer edit/details links and saved records can only be cleared from the edit dialog', async () => {
const ctx = pageContext([device]);
ctx.replies['luci-rpc.getHostHints'] = { [other]: { name: 'New device' } };
const node = await renderPage(ctx);
ctx.page.activeTab = 'all'; ctx.page.updateView();
const rows = node.querySelector('#device_manager_tbody').querySelectorAll('tr');
const savedRow = rows.find(row => row.textContent.includes('TV'));
const unsavedRow = rows.find(row => row.textContent.includes('New device'));
assert.equal(node.querySelectorAll('.dm-action-delete').length, 0);
assert.equal(unsavedRow.querySelectorAll('a').length, 2);
assert.equal(savedRow.querySelectorAll('a').length, 2);
const edit = savedRow.querySelector('.dm-action-edit');
const detail = savedRow.querySelector('.dm-action-detail');
assert.equal(edit.attrs.href, '#'); assert.equal(edit.attrs.role, 'button');
const click = new Event('click', { cancelable: true });
edit.dispatchEvent(click);
assert.equal(click.defaultPrevented, true);
assert.equal(ctx.env.ui.modal.attrs.title, 'Edit device');
assert.equal(ctx.env.ui.modal.querySelector('input').value, 'TV');
await button(ctx.env.ui.modal, 'Clear record').click();
assert.equal(ctx.env.ui.modal.attrs.title, 'Clear record');
await detail.click();
assert.equal(ctx.env.ui.modal.attrs.title, 'Device details');
const space = new Event('keydown', { cancelable: true });
Object.defineProperty(space, 'key', { value: ' ' });
edit.dispatchEvent(space);
assert.equal(space.defaultPrevented, true);
assert.equal(ctx.env.ui.modal.attrs.title, 'Edit device');
await unsavedRow.querySelector('.dm-action-edit').click();
assert.equal(button(ctx.env.ui.modal, 'Clear record'), undefined);
});
test('tab counts match group/search filters and tabs support keyboard activation', async () => {
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' }] } };
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');
assert.ok(tabs.every(tab => tab.tag === 'button' && tab.attrs.role === 'tab'));
ctx.page.activeGroup = 'home'; ctx.page.filterText = 'tv'; ctx.page.updateView();
assert.equal(node.querySelector('#dm-tabs-container').children[0].textContent, 'Online devices0');
assert.equal(node.querySelector('#dm-tabs-container').children[1].textContent, 'All devices1');
});
test('a failed rollback must recover before a later operation can commit', async () => {
const ctx = service([device]); ctx.state.failCommit = true; ctx.state.failRevert = true;
await assert.rejects(ctx.service.saveDevice(mac, 'Failed edit', '', 'ungrouped'));
ctx.state.failCommit = false;
await assert.rejects(ctx.service.saveDevice(other, 'Phone', '', 'ungrouped'), /revert failed/);
assert.deepEqual(ctx.state.saved, [device]);
ctx.state.failRevert = false;
await ctx.service.saveDevice(other, 'Phone', '', 'ungrouped');
assert.equal(ctx.state.saved.find(section => section.mac === mac).name, 'TV');
});
test('a write waits for the entire earlier refresh to finish, including slow discovery', async () => {
const ctx = service([device]); let release, started;
const signal = new Promise(resolve => { started = resolve; });
const blocker = new Promise(resolve => { release = resolve; });
ctx.fs.read = () => { started(); return blocker.then(() => ''); };
const reading = ctx.service.load(); await signal;
const writing = ctx.service.saveDevice(mac, 'New name', '', 'ungrouped');
await Promise.resolve(); assert.equal(ctx.state.commits, 0);
release(); assert.equal((await reading).devices[0].name, 'TV');
await writing; assert.equal(ctx.state.saved[0].name, 'New name');
});
test('MAC-less failures on a different interface do not mark an ARP-known device offline', () => {
const result = parse({ arp: [{ mac, ip: '192.168.1.20', flags: '0x2', dev: 'br-lan' }],
neighbors: { neighbors: [{ ip: '192.168.1.20', dev: 'wan', state: 'FAILED' }] } });
assert.equal(result[0].status, 'unknown');
});
test('switching a status tab preserves focus on the newly rendered tab', async () => {
const ctx = pageContext([]); const node = await renderPage(ctx);
await node.querySelector('#dm-tabs-container').children[1].click();
assert.equal(ctx.page.activeTab, 'all');
assert.equal(node.querySelector('#dm-tabs-container').children[1].focused, true);
});
test('writable toolbar actions remain enabled under LuCI boolean attribute semantics', async () => {
const ctx = pageContext([]); const node = await renderPage(ctx);
assert.equal(button(node, 'Add device').disabled, false);
assert.equal(button(node, 'Manage groups').disabled, false);
await button(node, 'Add device').click();
assert.equal(ctx.env.ui.modal.attrs.title, 'Add device');
});
+98
View File
@@ -0,0 +1,98 @@
'use strict';
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');
function catalogue() {
const messages = {};
let key = '', value = '', field = '';
for (const line of (fs.readFileSync(path.join(root, 'po/zh_Hans/device-manager.po'), 'utf8') + '\n\n').split('\n')) {
if (line.startsWith('msgid ')) { key = JSON.parse(line.slice(6)); field = 'key'; }
else if (line.startsWith('msgstr ')) { value = JSON.parse(line.slice(7)); field = 'value'; }
else if (line.startsWith('"')) {
if (field === 'key') key += JSON.parse(line);
else if (field === 'value') value += JSON.parse(line);
} else if (!line.trim()) {
if (key && value) messages[key] = value;
key = ''; value = ''; field = '';
}
}
return messages;
}
function context(translate = text => text, initial = []) {
const mocks = backend(initial);
const env = environment(Object.assign({}, mocks, {
_: translate,
window: { navigator: { languages: ['zh-CN'] }, localStorage: { getItem: () => null, setItem() {} } }
}));
const page = loadModule('view.device-manager.devices', env);
return { mocks, env, page };
}
async function render(ctx) { return ctx.page.render(await ctx.page.load()); }
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 () => {
const messages = catalogue();
const ctx = context(text => messages[text] || text, [
{ '.type': 'device', '.name': 'saved', mac: 'AA:BB:CC:11:22:33', name: '客厅电视', type: 'tv' }
]);
const node = await render(ctx);
assert.ok(button(node, messages['Refresh list']));
assert.ok(button(node, messages['Add device']));
assert.ok(button(node, messages['Hide info']));
assert.ok(button(node, messages['Scan LAN']));
const title = node.querySelectorAll('th').find(th => th.getAttribute('data-sort') === 'name').querySelector('button').attrs.title;
assert.equal(title, messages['Click to sort by %s'].format(messages['Device name']));
ctx.page.showEditModal(ctx.page.devices[0]);
assert.equal(ctx.env.ui.modal.attrs.title, messages['Edit device']);
assert.ok(button(ctx.env.ui.modal, messages.Save));
assert.ok(ctx.env.ui.modal.querySelectorAll('option').some(option => option.textContent === messages.TV));
assert.deepEqual(ctx.env.htmlSinks, []);
});
test('LuCI decides language without a browser override or custom language RPC', async () => {
for (const translate of [text => text, text => 'DE:' + text, text => '繁體:' + text]) {
const ctx = context(translate);
const node = await render(ctx);
assert.ok(button(node, translate('Refresh list')));
assert.ok(node.textContent.includes(translate('LAN Device Management')));
assert.ok(!ctx.mocks.state.calls.some(call => call.key.includes('get_language')));
}
});
test('legacy default group labels translate while custom metadata remains literal', async () => {
const initial = [
{ '.type': 'group', '.name': 'smart_home', name: '智能家居' },
{ '.type': 'group', '.name': 'phone', name: 'My phones' },
{ '.type': 'group', '.name': 'grp_custom', name: '办公设备' }
];
const ctx = context(text => 'DE:' + text, initial);
await render(ctx);
assert.deepEqual(ctx.page.groups.map(group => group.name), ['DE:Smart home', 'My phones', '办公设备']);
assert.deepEqual(ctx.mocks.state.saved, initial);
});
test('loading and refreshing never start active scans; the explicit button scans then refreshes', async () => {
const ctx = context(); const node = await render(ctx);
await ctx.page.refresh();
assert.equal(ctx.mocks.state.calls.filter(call => call.key.endsWith('.scan_devices')).length, 0);
await button(node, 'Scan LAN').click();
const keys = ctx.mocks.state.calls.map(call => call.key);
assert.equal(keys.filter(key => key.endsWith('.scan_devices')).length, 1);
assert.ok(keys.lastIndexOf('luci.device-manager.get_online_status') > keys.indexOf('luci.device-manager.scan_devices'));
assert.equal(button(node, 'Scan LAN').disabled, false);
});
test('read-only users cannot start scans and scan failures restore the control', async () => {
const ctx = context();
ctx.env.L.hasViewPermission = () => false;
const node = await render(ctx);
assert.equal(button(node, 'Scan LAN').disabled, true);
await ctx.page.scan();
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());
ctx.mocks.replies['luci.device-manager.scan_devices'] = { ok: false };
await button(writable, 'Scan LAN').click();
assert.equal(button(writable, 'Scan LAN').disabled, false);
assert.match(ctx.env.notifications.at(-1).content.textContent, /Could not scan LAN devices/);
});
+105
View File
@@ -0,0 +1,105 @@
'use strict';
const assert = require('node:assert/strict');
const test = require('node:test');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawnSync, spawn } = require('node:child_process');
const { root } = require('./helpers/runtime');
const mac = 'AA:BB:CC:11:22:33';
function fixture(t, command, prefix) {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'device-manager-single-ping-'));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const leases = path.join(directory, 'leases'); fs.writeFileSync(leases, '');
const source = fs.readFileSync(path.join(root, 'root/usr/libexec/rpcd/luci.device-manager'), 'utf8')
.replaceAll('. /usr/share/libubox/jshn.sh', '. "$AUDIT_JSHN"')
.replaceAll('/tmp/luci-device-manager-single-ping', path.join(directory, 'single-ping'))
.replaceAll('/tmp/dhcp.leases', leases).replaceAll('/var/dhcp.leases', leases);
const wrapper = `
ip() { printf '%s\\n' "$AUDIT_NEIGH"; return "$AUDIT_IP_EXIT"; }
ping() { printf '%s\\n' "$@" >> "$AUDIT_PING_LOG"; sleep "$AUDIT_DELAY"; printf '%s\\n' "$AUDIT_OUTPUT"; return "$AUDIT_PING_EXIT"; }
ping6() { ping "$@"; }
` + source;
let sequence = 0;
function options(extra) {
const id = sequence++;
const log = path.join(directory, 'pings-' + id);
return { log, options: { encoding: 'utf8', timeout: 10000, env: Object.assign({}, process.env, {
AUDIT_JSHN: process.env.TEST_JSHN_PATH || path.join(root, 'test/helpers/jshn.sh'), AUDIT_NODE: process.execPath,
AUDIT_JSON_ENCODER: path.join(root, 'test/helpers/json-encoder.js'),
AUDIT_JSON_LOG: path.join(directory, 'json-' + id), AUDIT_PING_LOG: log,
AUDIT_NEIGH: '192.168.1.20 dev br-lan lladdr aa:bb:cc:11:22:33 REACHABLE',
AUDIT_IP_EXIT: '0', AUDIT_PING_EXIT: '0', AUDIT_DELAY: '0', AUDIT_OUTPUT: '1 packet received, time=0.5 ms'
}, extra) } };
}
return {
directory, leases,
run(request = { mac }, extra = {}) {
const cfg = options(extra);
const result = spawnSync(command, [...prefix, '-c', wrapper, 'ping-test', 'call', 'ping_device'],
Object.assign({}, cfg.options, { input: JSON.stringify(request) }));
assert.equal(result.status, 0, result.stderr);
return { reply: JSON.parse(result.stdout), args: fs.existsSync(cfg.log) ? fs.readFileSync(cfg.log, 'utf8').trim().split('\n') : [] };
},
start(extra = {}) {
const cfg = options(extra);
const child = spawn(command, [...prefix, '-c', wrapper, 'ping-test', 'call', 'ping_device'], cfg.options);
child.stdin.end(JSON.stringify({ mac }));
let stdout = '', stderr = '';
child.stdout.on('data', data => { stdout += data; }); child.stderr.on('data', data => { stderr += data; });
const done = new Promise((resolve, reject) => {
child.on('error', reject);
child.on('close', code => code === 0 ? resolve(JSON.parse(stdout)) : reject(new Error(stderr)));
});
return { done, log: cfg.log };
}
};
}
for (const [command, prefix] of [['sh', []], ['busybox', ['sh']]]) {
const available = spawnSync(command, [...prefix, '-c', 'command -v flock >/dev/null']).status === 0;
test(`${command}: single-device Ping resolves the MAC, ignores caller targets and reports reply versus timeout`, { skip: !available }, t => {
const f = fixture(t, command, prefix);
const result = f.run({ mac, ip: '8.8.8.8', command: 'touch /tmp/unsafe' });
assert.equal(result.reply.reachable, true); assert.equal(result.reply.ip, '192.168.1.20');
assert.equal(result.args.at(-1), '192.168.1.20'); assert.ok(result.args.includes('br-lan'));
assert.deepEqual(result.args.slice(0, 6), ['-c', '1', '-w', '3', '-W', '2']);
const timeout = f.run({ mac }, { AUDIT_PING_EXIT: '1' });
assert.equal(timeout.reply.ok, true); assert.equal(timeout.reply.reachable, false);
const error = f.run({ mac }, { AUDIT_PING_EXIT: '2', AUDIT_OUTPUT: 'invalid option' });
assert.equal(error.reply.ok, false); assert.equal(error.reply.error, 'Ping could not be completed');
});
test(`${command}: invalid and unknown MACs never launch Ping`, { skip: !available }, t => {
const f = fixture(t, command, prefix);
for (const value of ['', null, 'aa:bb:cc:11:22:33; touch /tmp/unsafe', 'AA:BB:CC:11:22:33\ninvalid', "AA:BB:CC:11:22:33'$(touch /tmp/unsafe)'", 'FF:FF:FF:FF:FF:FF', '00:00:00:00:00:00', '11:22:33:44:55:66']) {
const result = f.run({ mac: value });
assert.equal(result.reply.ok, false); assert.deepEqual(result.args, []);
}
assert.deepEqual(f.run({ mac }, { AUDIT_IP_EXIT: '127' }).args, []);
});
test(`${command}: DHCP fallback and IPv6-only link-local neighbors use a single numeric destination`, { skip: !available }, t => {
const f = fixture(t, command, prefix);
fs.writeFileSync(f.leases, '123 aa:bb:cc:11:22:33 192.168.1.50 device *\n');
const lease = f.run({ mac }, { AUDIT_NEIGH: '' });
assert.equal(lease.reply.ip, '192.168.1.50'); assert.equal(lease.reply.interface, '');
const reassigned = f.run({ mac }, { AUDIT_NEIGH: '192.168.1.50 dev br-lan lladdr aa:bb:cc:44:55:66 REACHABLE' });
assert.equal(reassigned.reply.ok, false); assert.deepEqual(reassigned.args, []);
fs.writeFileSync(f.leases, '');
const v6 = f.run({ mac }, { AUDIT_NEIGH: 'fe80::1234 dev br-lan lladdr aa:bb:cc:11:22:33 STALE' });
assert.equal(v6.reply.ip, 'fe80::1234'); assert.equal(v6.reply.interface, 'br-lan');
assert.equal(v6.args.at(-1), 'fe80::1234'); assert.ok(v6.args.includes('-I'));
fs.writeFileSync(f.leases, '123 aa:bb:cc:11:22:33 999.1.2.3 invalid *\n');
assert.equal(f.run({ mac }, { AUDIT_NEIGH: '' }).reply.ok, false);
});
test(`${command}: a global nonblocking lock prevents concurrent detail probes`, { skip: !available }, async t => {
const f = fixture(t, command, prefix);
const first = f.start({ AUDIT_DELAY: '0.3' });
for (let attempt = 0; attempt < 100 && !fs.existsSync(first.log); attempt++) await new Promise(resolve => setTimeout(resolve, 10));
assert.ok(fs.existsSync(first.log));
const second = f.run();
assert.equal(second.reply.ok, false); assert.match(second.reply.error, /Another device/); assert.deepEqual(second.args, []);
await first.done;
assert.equal(f.run().reply.reachable, true, 'lock must be released after the probe');
});
}
+209
View File
@@ -0,0 +1,209 @@
'use strict';
const assert = require('node:assert/strict');
const test = require('node:test');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawn, spawnSync } = require('node:child_process');
const { root } = require('./helpers/runtime');
function fixture(t, command, prefix) {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'device-manager-probe-'));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const clock = path.join(directory, 'uptime');
fs.writeFileSync(clock, '100.00 0.00\n');
fs.writeFileSync(path.join(directory, 'arp'), '192.168.1.200 0x1 0x2 aa:bb:cc:11:22:33 * br-lan\n');
fs.writeFileSync(path.join(directory, 'leases'), '123 aa:bb:cc:11:22:33 192.168.1.200 known *\n123 aa:bb:cc:11:22:44 192.168.3.20 other *\n');
const source = fs.readFileSync(path.join(root, 'root/usr/libexec/rpcd/luci.device-manager'), 'utf8')
.replaceAll('. /usr/share/libubox/jshn.sh', '. "$AUDIT_JSHN"')
.replaceAll('/tmp/luci-device-manager-probe', path.join(directory, 'probe'))
.replaceAll('/proc/uptime', clock)
.replaceAll('/proc/net/arp', path.join(directory, 'arp'))
.replaceAll('/tmp/dhcp.leases', path.join(directory, 'leases'))
.replaceAll('/var/dhcp.leases', path.join(directory, 'leases'));
const wrapper = `
ip() {
if [ "$1" = -o ]; then
case "$6" in
br-lan) printf '%s\\n' "2: br-lan inet $AUDIT_LAN_CIDR scope global br-lan" ;;
br-guest) printf '%s\\n' "3: br-guest inet $AUDIT_GUEST_CIDR scope global br-guest" ;;
esac
elif [ "$1" = -4 ]; then
printf '%s\\n' '198.51.100.0/24 dev eth0 scope link'
else
printf '%s\\n' '192.168.1.200 dev br-lan lladdr aa:bb:cc:11:22:33 REACHABLE'
fi
}
uci() { printf '%s\\n' "$AUDIT_INTERFACES"; }
ubus() {
[ "$AUDIT_NO_INTERFACE" = 1 ] && return 1
case "$2" in
network.interface.lan) printf '%s\\n' '{"l3_device":"br-lan"}' ;;
network.interface.guest) printf '%s\\n' '{"l3_device":"br-guest"}' ;;
*) return 1 ;;
esac
}
ping() {
previous=""; interface=""
for argument in "$@"; do
[ "$previous" = -I ] && interface="$argument"
previous="$argument"; target="$argument"
done
case "$interface" in br-lan|br-guest) ;; *) return 2 ;; esac
case "$target" in
192.168.1.200|192.168.3.20) sleep "\${AUDIT_KNOWN_START_DELAY:-0}" ;;
esac
printf 'start %s\\n' "$target" >> "$AUDIT_PING_LOG"
sleep "$AUDIT_PING_DELAY"
if [ -n "$AUDIT_ADVANCE" ]; then printf '%s.00 0.00\\n' "$AUDIT_ADVANCE" > "$AUDIT_CLOCK"; fi
printf 'end %s\\n' "$target" >> "$AUDIT_PING_LOG"
return 1
}
` + source;
let sequence = 0;
return {
clock,
directory,
run(extra = {}, method = 'scan_devices') {
const id = sequence++;
const log = path.join(directory, 'pings-' + id);
const child = spawn(command, [...prefix, '-c', wrapper, 'probe-test', 'call', method], {
env: Object.assign({}, process.env, {
AUDIT_JSHN: process.env.TEST_JSHN_PATH || path.join(root, 'test/helpers/jshn.sh'),
AUDIT_NODE: process.execPath,
AUDIT_JSON_ENCODER: path.join(root, 'test/helpers/json-encoder.js'),
AUDIT_JSON_LOG: path.join(directory, 'json-' + id),
AUDIT_PING_LOG: log, AUDIT_PING_DELAY: '0.02', AUDIT_KNOWN_START_DELAY: '0',
AUDIT_ADVANCE: '', AUDIT_CLOCK: clock, AUDIT_INTERFACES: 'lan guest', AUDIT_NO_INTERFACE: '0',
AUDIT_LAN_CIDR: '192.168.1.1/24', AUDIT_GUEST_CIDR: '192.168.2.1/24'
}, extra)
});
let stdout = '', stderr = '';
child.stdout.on('data', data => { stdout += data; });
child.stderr.on('data', data => { stderr += data; });
const timer = setTimeout(() => child.kill('SIGKILL'), 10000);
const done = new Promise((resolve, reject) => {
child.on('error', reject);
child.on('close', code => {
clearTimeout(timer);
if (code !== 0) { reject(new Error(stderr || 'RPC exit: ' + code)); return; }
resolve({ reply: JSON.parse(stdout), log });
});
});
return { done, log };
}
};
}
function starts(log) {
if (!fs.existsSync(log)) return [];
return fs.readFileSync(log, 'utf8').trim().split('\n')
.filter(line => line.startsWith('start ')).map(line => line.slice(6));
}
for (const [command, prefix] of [['sh', []], ['busybox', ['sh']]]) {
const available = spawnSync(command, [...prefix, '-c', 'command -v flock >/dev/null']).status === 0;
test(`${command}: active probes are bounded, deduplicated, cooled down and resume the sweep`, { skip: !available }, async t => {
const f = fixture(t, command, prefix);
const seen = new Set();
for (let pass = 0; pass < 4; pass++) {
fs.writeFileSync(f.clock, `${100 + pass * 30}.00 0.00\n`);
// Delay known-host children to expose assertions that depend on scheduling order.
const result = await f.run({ AUDIT_KNOWN_START_DELAY: '0.08' }).done;
assert.equal(result.reply.ok, true); // Failed ICMP must still return the neighbor snapshot.
const addresses = starts(result.log);
assert.equal(addresses.length, 128);
assert.equal(new Set(addresses).size, 128);
if (pass === 0) {
// Children may log in any order; the batch barrier preserves batch membership.
const firstBatch = ['192.168.1.200',
...Array.from({ length: 15 }, (_, i) => '192.168.1.' + (i + 2))];
assert.deepEqual(addresses.slice(0, 16).sort(), firstBatch.sort(),
'known hosts must be included in the first batch');
}
if (pass === 1) assert.ok(addresses.every(ip => !seen.has(ip)), 'second pass must resume, not restart');
addresses.forEach(ip => seen.add(ip));
let active = 0, peak = 0;
for (const line of fs.readFileSync(result.log, 'utf8').trim().split('\n')) {
active += line.startsWith('start ') ? 1 : -1;
peak = Math.max(peak, active);
}
assert.ok(peak <= 16, `peak concurrency was ${peak}`);
assert.equal(active, 0, 'all probes must finish before releasing the lock');
const cooldown = await f.run().done;
assert.equal(cooldown.reply.ok, true);
assert.deepEqual(starts(cooldown.log), []);
}
assert.equal(seen.size, 506, 'selected /24 subnets exclude router addresses and out-of-scope known hosts');
});
test(`${command}: overlapping RPCs share one scan and the lock is released afterwards`, { skip: !available }, async t => {
const f = fixture(t, command, prefix);
const first = f.run({ AUDIT_PING_DELAY: '0.05' });
for (let attempt = 0; attempt < 100 && starts(first.log).length === 0; attempt++) {
await new Promise(resolve => setTimeout(resolve, 10));
}
assert.ok(starts(first.log).length > 0, 'first request must have acquired the scan lock');
const second = await f.run().done;
assert.equal(second.reply.ok, true);
assert.deepEqual(starts(second.log), []);
await first.done;
fs.writeFileSync(f.clock, '130.00 0.00\n');
const next = await f.run().done;
assert.equal(starts(next.log).length, 128, 'next eligible request must acquire the released lock');
});
test(`${command}: elapsed-time budget stops a scan before its address limit`, { skip: !available }, async t => {
const f = fixture(t, command, prefix);
const result = await f.run({ AUDIT_ADVANCE: '108' }).done;
assert.equal(result.reply.ok, true);
assert.equal(starts(result.log).length, 16);
assert.equal(fs.readFileSync(path.join(f.directory, 'probe.state'), 'utf8'), '108 16\n');
fs.writeFileSync(f.clock, '130.00 0.00\n');
assert.deepEqual(starts((await f.run().done).log), [], 'cooldown starts after the scan finishes');
});
test(command + ': passive reads never probe and unavailable LANs never fall back to WAN', { skip: !available }, async t => {
const f = fixture(t, command, prefix);
const passive = await f.run({}, 'get_online_status').done;
assert.equal(passive.reply.ok, true);
assert.deepEqual(starts(passive.log), []);
const unavailable = await f.run({ AUDIT_NO_INTERFACE: '1' }).done;
assert.equal(unavailable.reply.ok, false);
assert.deepEqual(starts(unavailable.log), []);
});
test(command + ': /25 and /23 probes respect subnet boundaries and exclude router addresses', { skip: !available }, async t => {
for (const cidr of ['192.168.1.130/25', '192.168.1.1/23']) {
const f = fixture(t, command, prefix);
const result = await f.run({ AUDIT_INTERFACES: 'lan', AUDIT_LAN_CIDR: cidr }).done;
assert.equal(result.reply.ok, true);
const addresses = starts(result.log);
assert.ok(addresses.length > 0);
assert.ok(!addresses.includes(cidr.split('/')[0]));
assert.ok(!addresses.some(ip => ip.startsWith('198.51.100.') || ip.startsWith('192.168.3.')));
if (cidr.endsWith('/25')) {
assert.equal(addresses.length, 125);
assert.ok(addresses.every(ip => ip.startsWith('192.168.1.') && Number(ip.split('.')[3]) >= 129 && Number(ip.split('.')[3]) <= 254));
} else {
assert.ok(addresses.every(ip => ip.startsWith('192.168.0.') || ip.startsWith('192.168.1.')));
}
}
});
test(command + ': a /16 probes known LAN hosts only and ignores invalid interface names', { skip: !available }, async t => {
const f = fixture(t, command, prefix);
const result = await f.run({ AUDIT_INTERFACES: 'lan ../wan', AUDIT_LAN_CIDR: '192.168.1.1/16' }).done;
assert.equal(result.reply.ok, true);
assert.deepEqual(starts(result.log).sort(), ['192.168.1.200', '192.168.3.20']);
});
test(command + ': /31 addresses and disabled scanning do not expand to a /24', { skip: !available }, async t => {
const f = fixture(t, command, prefix);
const result = await f.run({ AUDIT_INTERFACES: 'lan', AUDIT_LAN_CIDR: '192.168.1.130/31' }).done;
assert.equal(result.reply.ok, true);
assert.deepEqual(starts(result.log), ['192.168.1.131']);
fs.writeFileSync(f.clock, '130.00 0.00\n');
const disabled = await f.run({ AUDIT_INTERFACES: '' }).done;
assert.equal(disabled.reply.ok, false);
assert.deepEqual(starts(disabled.log), []);
});
}
+141
View File
@@ -0,0 +1,141 @@
"""Offline checks for SDK selection and the actual translation catalogue."""
import importlib.util
import io
import json
import pathlib
import re
import tempfile
import subprocess
import unittest
from unittest.mock import patch
ROOT = pathlib.Path(__file__).resolve().parents[1]
SPEC = importlib.util.spec_from_file_location("resolve_sdk", ROOT / "tools/resolve-sdk.py")
SDK = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(SDK)
class SDKTests(unittest.TestCase):
def test_requested_version_and_target_select_the_corresponding_sdk(self):
for version, target in [("24.10.0", "x86/64"), ("23.05.5", "mediatek/filogic")]:
filename = f"openwrt-sdk-{version}-test_gcc.Linux-x86_64.tar.zst"
checksum = "a" * 64
listing = f'<a href="{filename}">{filename}</a><a href="../">Parent</a>'
with patch.object(SDK, "urlopen", side_effect=[io.BytesIO(listing.encode()), io.BytesIO(f"{checksum} *{filename}\n".encode())]) as fetch:
url, sha = SDK.resolve(version, target)
self.assertEqual(url, f"https://downloads.openwrt.org/releases/{version}/targets/{target}/{filename}")
self.assertEqual(sha, checksum)
self.assertEqual(fetch.call_args_list[0].args[0], f"https://downloads.openwrt.org/releases/{version}/targets/{target}/")
def test_invalid_inputs_fail_before_any_network_request(self):
for version, target in [("$(id)", "x86/64"), ("24.10.0", "../x86"), ("24.10.0", "x86/64\nextra")]:
with patch.object(SDK, "urlopen") as fetch:
with self.assertRaises(ValueError):
SDK.resolve(version, target)
fetch.assert_not_called()
def test_missing_sdk_or_checksum_is_rejected(self):
with patch.object(SDK, "urlopen", return_value=io.BytesIO(b"<html></html>")):
with self.assertRaisesRegex(ValueError, "found 0"):
SDK.resolve("24.10.0", "x86/64")
filename = "openwrt-sdk-24.10.0-test.Linux-x86_64.tar.xz"
with patch.object(SDK, "urlopen", side_effect=[io.BytesIO(f'<a href="{filename}">SDK</a>'.encode()), io.BytesIO(b"invalid checksum")]):
with self.assertRaisesRegex(ValueError, "No checksum"):
SDK.resolve("24.10.0", "x86/64")
def test_catalogues_cover_native_extracted_messages_including_menu_and_acl(self):
def ids(content):
result = set()
key = None
for line in content.splitlines():
if line.startswith("msgid "):
key = json.loads(line[6:])
elif key is not None and line.startswith('"'):
key += json.loads(line)
elif line.startswith("msgstr "):
if key:
result.add(re.sub(r" +", " ", key.strip()))
key = None
return result
strings = set()
for file in (ROOT / "htdocs").rglob("*.js"):
extracted = subprocess.run(["xgettext", "--from-code=UTF-8", "--language=JavaScript",
"--keyword=_:1", "--keyword=N_:2,3", "--no-wrap", "-o", "-", "-"],
input=file.read_text(), capture_output=True, text=True, check=True).stdout
strings.update(ids(extracted))
for directory in ["root/usr/share/luci/menu.d", "root/usr/share/rpcd/acl.d"]:
for file in (ROOT / directory).glob("*.json"):
strings.update(re.findall(r'"(?:title|description)"\s*:\s*"([^"]+)"', file.read_text()))
for file in [ROOT / "po/templates/device-manager.pot", ROOT / "po/zh_Hans/device-manager.po"]:
self.assertEqual(ids(file.read_text()), strings)
def test_snapshot_sdk_uses_snapshot_directory_and_unversioned_sdk_name(self):
filename = "openwrt-sdk-x86-64_gcc-14_musl.Linux-x86_64.tar.zst"
checksum = "b" * 64
with patch.object(SDK, "urlopen", side_effect=[
io.BytesIO(f'<a href="{filename}">SDK</a>'.encode()),
io.BytesIO(f"{checksum} *{filename}\n".encode())
]) as fetch:
url, sha = SDK.resolve("SNAPSHOT", "x86/64")
self.assertEqual(url, f"https://downloads.openwrt.org/snapshots/targets/x86/64/{filename}")
self.assertEqual(sha, checksum)
self.assertEqual(fetch.call_args_list[0].args[0],
"https://downloads.openwrt.org/snapshots/targets/x86/64/")
def test_selected_configuration_preserves_target_and_disables_sdk_all_defaults(self):
spec = importlib.util.spec_from_file_location("configure_sdk", ROOT / "tools/configure-sdk.py")
configure = importlib.util.module_from_spec(spec)
spec.loader.exec_module(configure)
with tempfile.TemporaryDirectory() as tmp:
directory = pathlib.Path(tmp)
(directory / "rules.mk").touch()
(directory / "Makefile").touch()
(directory / ".config").write_text("CONFIG_TARGET_x86=y\nCONFIG_ALL=y\nCONFIG_ALL_KMODS=y\nCONFIG_ALL_NONSHARED=y\nCONFIG_PACKAGE_unrelated=y\nCONFIG_LUCI_LANG_de=y\n")
configure.configure(directory)
result = (directory / ".config").read_text()
self.assertIn("CONFIG_TARGET_x86=y", result)
self.assertIn("# CONFIG_ALL_KMODS is not set", result)
self.assertIn("CONFIG_PACKAGE_luci-app-device-manager=m", result)
self.assertIn("CONFIG_PACKAGE_luci-i18n-device-manager-zh-cn=m", result)
self.assertNotIn("CONFIG_PACKAGE_unrelated", result)
self.assertNotIn("CONFIG_LUCI_LANG_de", result)
configure.configure(directory)
self.assertEqual((directory / ".config").read_text(), result)
with self.assertRaises(ValueError):
configure.configure(directory / "not-an-sdk")
def test_upstream_export_is_bounded_and_preserves_existing_checkout(self):
spec = importlib.util.spec_from_file_location("prepare_upstream", ROOT / "tools/prepare-upstream.py")
export = importlib.util.module_from_spec(spec)
spec.loader.exec_module(export)
with tempfile.TemporaryDirectory() as tmp:
checkout = pathlib.Path(tmp)
(checkout / "luci.mk").touch()
target = export.prepare(checkout)
self.assertTrue((target / "htdocs").is_dir())
self.assertIn("include ../../luci.mk", (target / "Makefile").read_text())
self.assertNotIn("feeds/luci/luci.mk", (target / "Makefile").read_text())
self.assertTrue((target / "root/etc/config/device_manager").is_file())
self.assertFalse((target / "root/etc/uci-defaults/80_device_manager").exists())
readme = (target / "README.md").read_text()
self.assertIn("opkg install luci-app-device-manager\n", readme)
self.assertIn("apk add luci-app-device-manager\n", readme)
for obsolete in ["尚未合并到官方源", "--allow-untrusted", "GitHub Actions", "workflow 默认"]:
self.assertNotIn(obsolete, readme)
for name in [".github", "tools", "test", "device-icons"]:
self.assertFalse((target / name).exists())
self.assertFalse((target / "docs/upstream-submission.md").exists())
(target / "user-sentinel").write_text("preserve")
with self.assertRaises(FileExistsError):
export.prepare(checkout)
self.assertEqual((target / "user-sentinel").read_text(), "preserve")
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("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"])
if __name__ == "__main__":
unittest.main()
+111
View File
@@ -0,0 +1,111 @@
'use strict';
const assert = require('node:assert/strict');
const test = require('node:test');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const { root } = require('./helpers/runtime');
function temporary(fn) {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'device-manager-test-'));
return Promise.resolve().then(() => fn(directory)).finally(() => fs.rmSync(directory, { recursive: true, force: true }));
}
function rpc(command, prefix, directory, neighbors, exit = '0', method = 'get_online_status', language = 'auto', uciExit = '0') {
const source = fs.readFileSync(path.join(root, 'root/usr/libexec/rpcd/luci.device-manager'), 'utf8')
.replaceAll('. /usr/share/libubox/jshn.sh', '. "$AUDIT_JSHN"')
.replaceAll('/tmp/luci-device-manager-probe', path.join(directory, 'probe'));
const wrapper = 'ip() { [ "$1" = -4 ] && return 0; printf "%s\\n" "$AUDIT_NEIGH"; return "$AUDIT_IP_EXIT"; }\n' +
'uci() { [ "$3" = network.lan.ipaddr ] && return 1; if [ "$AUDIT_UCI_EXIT" = 0 ]; then printf "%s\\n" "$AUDIT_LANGUAGE"; fi; return "$AUDIT_UCI_EXIT"; }\n' +
'ping() { return 1; }\n' + source;
return spawnSync(command, [...prefix, '-c', wrapper, 'luci.device-manager', 'call', method], { encoding: 'utf8', env: {
...process.env, AUDIT_NEIGH: neighbors, AUDIT_IP_EXIT: exit, AUDIT_LANGUAGE: language, AUDIT_UCI_EXIT: uciExit,
AUDIT_JSHN: process.env.TEST_JSHN_PATH || path.join(root, 'test/helpers/jshn.sh'), AUDIT_NODE: process.execPath,
AUDIT_JSON_ENCODER: path.join(root, 'test/helpers/json-encoder.js'), AUDIT_JSON_LOG: path.join(directory, 'json.log')
} });
}
for (const [command, prefix] of [['sh', []], ['busybox', ['sh']]]) {
const available = spawnSync(command, [...prefix, '-c', 'exit 0']).status === 0;
test(`${command}: RPC preserves MAC-less FAILED/INCOMPLETE and safely encodes strings`, { skip: !available }, () => temporary(directory => {
const result = rpc(command, prefix, directory,
'192.168.1.20 dev br-lan FAILED\n192.168.1.21 dev br-lan INCOMPLETE\nfe80::1 dev br-"lan lladdr aa:bb:cc:11:22:33 STALE proto kernel');
assert.equal(result.status, 0, result.stderr);
const reply = JSON.parse(result.stdout);
assert.equal(reply.ok, true); assert.equal(reply.neighbors.length, 3);
assert.equal(reply.neighbors[0].state, 'FAILED'); assert.equal(reply.neighbors[0].mac, '');
assert.equal(reply.neighbors[1].state, 'INCOMPLETE');
assert.equal(reply.neighbors[2].state, 'STALE'); assert.equal(reply.neighbors[2].dev, 'br-"lan');
}));
test(`${command}: RPC reports an unavailable ip command separately from an empty table`, { skip: !available }, () => temporary(directory => {
const failed = rpc(command, prefix, directory, '', '127');
assert.equal(JSON.parse(failed.stdout).ok, false);
const empty = rpc(command, prefix, directory, '');
assert.deepEqual(JSON.parse(empty.stdout), { ok: true, neighbors: [] });
}));
}
function initialize(directory, content, failure = false) {
const config = path.join(directory, 'config');
if (content !== null) fs.writeFileSync(config, content);
const source = fs.readFileSync(path.join(root, 'tools/initialize-config.sh'), 'utf8').replaceAll('/etc/config/device_manager', config);
const wrapper = 'uci() { printf "%s\\n" "$*" >> "$AUDIT_UCI_LOG"; if [ "$2" = batch ]; then cat >> "$AUDIT_UCI_LOG"; fi; return "$AUDIT_UCI_EXIT"; }\n' + source;
const result = spawnSync('busybox', ['sh', '-c', wrapper], { encoding: 'utf8', env: {
...process.env, AUDIT_UCI_LOG: path.join(directory, 'uci.log'), AUDIT_UCI_EXIT: failure ? '1' : '0'
} });
return { result, config, log: path.join(directory, 'uci.log') };
}
test('initializer preserves populated and intentionally empty configurations', () => temporary(directory => {
for (const content of ['', "config device 'saved'\n\toption name 'Existing TV'\n"]) {
const { result, config, log } = initialize(directory, content);
assert.equal(result.status, 0); assert.equal(fs.readFileSync(config, 'utf8'), content);
assert.equal(fs.existsSync(log), false);
}
}));
test('first installation creates default groups exactly once', () => temporary(directory => {
const { result, config, log } = initialize(directory, null);
assert.equal(result.status, 0, result.stderr); assert.equal(fs.existsSync(config), true);
const first = fs.readFileSync(log, 'utf8'); assert.match(first, /smart_home/); assert.match(first, /commit device_manager/);
initialize(directory, null); assert.equal(fs.readFileSync(log, 'utf8'), first);
}));
test('failed initialization removes the new incomplete configuration for retry', () => temporary(directory => {
const { result, config } = initialize(directory, null, true);
assert.notEqual(result.status, 0); assert.equal(fs.existsSync(config), false);
}));
function deploy(directory, args, failure = false) {
const bin = path.join(directory, 'bin'); fs.mkdirSync(bin, { recursive: true });
for (const command of ['ssh', 'scp']) {
fs.writeFileSync(path.join(bin, command), '#!/bin/sh\nprintf "%s: %s\\n" "'+command+'" "$*" >> "$AUDIT_COMMAND_LOG"\nexit "$AUDIT_REMOTE_EXIT"\n', { mode: 0o755 });
}
return spawnSync('bash', [path.join(root, 'tools/deploy.sh'), ...args], { encoding: 'utf8', env: {
...process.env, PATH: bin + ':' + process.env.PATH,
AUDIT_COMMAND_LOG: path.join(directory, 'commands.log'), AUDIT_REMOTE_EXIT: failure ? '1' : '0'
} });
}
test('deployment succeeds with mocked remote commands and copies all frontend modules', () => temporary(directory => {
const result = deploy(directory, ['-p', '2222', 'audit.invalid']);
assert.equal(result.status, 0, result.stderr);
const log = fs.readFileSync(path.join(directory, 'commands.log'), 'utf8');
assert.match(log, /resources\/device-manager /); assert.match(log, /view\/device-manager\/devices.js/);
assert.match(log, /-P 2222/); assert.match(log, /rm -f \/etc\/uci-defaults\/80_device_manager/);
assert.doesNotMatch(result.stderr, /EOF/);
}));
test('deployment rejects missing or invalid SSH ports before connecting', () => temporary(directory => {
for (const args of [['-p'], ['-p', 'bad', 'audit.invalid'], ['-p', '0', 'audit.invalid'], ['-p', '65536', 'audit.invalid']]) {
const result = deploy(directory, args);
assert.notEqual(result.status, 0); assert.match(result.stderr, /SSH port must/);
}
assert.equal(fs.existsSync(path.join(directory, 'commands.log')), false);
}));
test('failed SSH connectivity prevents deployment', () => temporary(directory => {
const result = deploy(directory, ['audit.invalid'], true);
assert.notEqual(result.status, 0); assert.match(result.stderr, /Failed to connect/);
assert.doesNotMatch(fs.readFileSync(path.join(directory, 'commands.log'), 'utf8'), /scp:/);
}));
test('development deployment uses native translations and removes legacy bundled resources', () => temporary(directory => {
const result = deploy(directory, ['audit.invalid']);
assert.equal(result.status, 0, result.stderr);
const log = fs.readFileSync(path.join(directory, 'commands.log'), 'utf8');
assert.match(log, /rm -f .*device-manager-builtin.zh-cn.lmo/);
assert.doesNotMatch(log, /scp:.*device-manager-builtin/);
assert.match(log, /i18n.js.*translations.js/);
}));
@@ -0,0 +1,309 @@
'use strict';
const assert = require('node:assert/strict');
const test = require('node:test');
const { environment, loadModule, backend } = require('./helpers/runtime');
const model = loadModule('device-manager.model', environment());
test('model.sortDevices sorts IPv4 addresses numerically in ascending and descending orders', () => {
const devices = [
{ mac: '00:00:00:00:00:03', ipv4: '192.168.1.100', hostname: 'PC-100' },
{ mac: '00:00:00:00:00:01', ipv4: '192.168.1.2', hostname: 'PC-2' },
{ mac: '00:00:00:00:00:04', ipv4: '', hostname: 'No-IP' },
{ mac: '00:00:00:00:00:02', ipv4: '192.168.1.10', hostname: 'PC-10' },
{ mac: '00:00:00:00:00:05', ipv4: '10.0.0.1', hostname: 'Router' }
];
// Ascending: 10.0.0.1 -> 192.168.1.2 -> 192.168.1.10 -> 192.168.1.100 -> No-IP
const asc = model.sortDevices(devices, 'ip', 'asc');
assert.deepEqual(asc.map(d => d.ipv4), [
'10.0.0.1',
'192.168.1.2',
'192.168.1.10',
'192.168.1.100',
''
]);
// Descending: 192.168.1.100 -> 192.168.1.10 -> 192.168.1.2 -> 10.0.0.1 -> No-IP (empty remains at bottom)
const desc = model.sortDevices(devices, 'ip', 'desc');
assert.deepEqual(desc.map(d => d.ipv4), [
'192.168.1.100',
'192.168.1.10',
'192.168.1.2',
'10.0.0.1',
''
]);
});
test('model.sortDevices handles IPv6-only devices and tie-breakers', () => {
const devices = [
{ mac: '00:00:00:00:00:02', ipv4: '', ipv6: 'fd00::2' },
{ mac: '00:00:00:00:00:01', ipv4: '', ipv6: 'fd00::1' },
{ mac: '00:00:00:00:00:03', ipv4: '', ipv6: '' }
];
const sorted = model.sortDevices(devices, 'ip', 'asc');
assert.equal(sorted[0].ipv6, 'fd00::1');
assert.equal(sorted[1].ipv6, 'fd00::2');
assert.equal(sorted[2].ipv6, '');
});
test('model.sortDevices sorts by name, MAC, group, and type', () => {
const devices = [
{ mac: '00:00:00:00:00:03', customName: 'Zebra', group: 'grp2', type: 'phone' },
{ mac: '00:00:00:00:00:01', customName: 'Apple', group: 'grp1', type: 'laptop' },
{ mac: '00:00:00:00:00:02', customName: 'Banana', group: 'grp3', type: 'tv' }
];
const byName = model.sortDevices(devices, 'name', 'asc');
assert.deepEqual(byName.map(d => d.customName), ['Apple', 'Banana', 'Zebra']);
const byMac = model.sortDevices(devices, 'mac', 'desc');
assert.deepEqual(byMac.map(d => d.mac), ['00:00:00:00:00:03', '00:00:00:00:00:02', '00:00:00:00:00:01']);
const byGroup = model.sortDevices(devices, 'group', 'asc', id => id);
assert.deepEqual(byGroup.map(d => d.group), ['grp1', 'grp2', 'grp3']);
const byType = model.sortDevices(devices, 'type', 'asc');
assert.deepEqual(byType.map(d => d.type), ['laptop', 'phone', 'tv']);
});
test('clicking the IP column header sorts devices in the table', async () => {
const initial = [
{ '.type': 'device', '.name': 'dev_1', mac: '00:11:22:33:44:01', name: 'Dev-1' },
{ '.type': 'device', '.name': 'dev_2', mac: '00:11:22:33:44:02', name: 'Dev-2' },
{ '.type': 'device', '.name': 'dev_3', mac: '00:11:22:33:44:03', name: 'Dev-3' }
];
const mocks = backend(initial);
mocks.replies['luci.device-manager.get_online_status'] = {
ok: true,
neighbors: [
{ mac: '00:11:22:33:44:01', state: 'REACHABLE' },
{ mac: '00:11:22:33:44:02', state: 'REACHABLE' },
{ mac: '00:11:22:33:44:03', state: 'REACHABLE' }
]
};
mocks.replies['luci-rpc.getHostHints'] = {
'00:11:22:33:44:01': { name: 'host1', ipaddrs: ['192.168.1.100'] },
'00:11:22:33:44:02': { name: 'host2', ipaddrs: ['192.168.1.5'] },
'00:11:22:33:44:03': { name: 'host3', ipaddrs: ['192.168.1.20'] }
};
const env = environment(mocks);
const page = loadModule('view.device-manager.devices', env);
const data = await page.load();
const viewNode = page.render(data);
const table = viewNode.querySelector('#device_manager_table');
const thList = table.querySelectorAll('th');
assert.equal(thList.length, 8);
assert.ok(thList[0].classes.has('dm-index-column'));
const ipTh = thList.find(th => th.getAttribute('data-sort') === 'ip');
assert.ok(ipTh);
assert.equal(ipTh.classes.has('dm-sortable'), true);
assert.equal(ipTh.querySelector('button').attrs.type, 'button');
// First click: Sort by IP ascending (192.168.1.5 -> 192.168.1.20 -> 192.168.1.100)
await ipTh.querySelector('button').click();
assert.equal(page.sortKey, 'ip');
assert.equal(page.sortDir, 'asc');
assert.equal(ipTh.classes.has('sorted-asc'), true);
assert.equal(ipTh.getAttribute('aria-sort'), 'ascending');
let tbody = viewNode.querySelector('#device_manager_tbody');
let rows = tbody.querySelectorAll('tr');
assert.equal(rows.length, 3);
assert.deepEqual(rows.map(row => row.querySelector('.dm-index-column').textContent), ['1', '2', '3']);
// 4th td in each row is IP address
assert.ok(rows[0].querySelectorAll('td')[3].textContent.includes('192.168.1.5'));
assert.ok(rows[1].querySelectorAll('td')[3].textContent.includes('192.168.1.20'));
assert.ok(rows[2].querySelectorAll('td')[3].textContent.includes('192.168.1.100'));
// Second click: Sort by IP descending (192.168.1.100 -> 192.168.1.20 -> 192.168.1.5)
await ipTh.querySelector('button').click();
assert.equal(page.sortKey, 'ip');
assert.equal(page.sortDir, 'desc');
assert.equal(ipTh.classes.has('sorted-desc'), true);
assert.equal(ipTh.getAttribute('aria-sort'), 'descending');
tbody = viewNode.querySelector('#device_manager_tbody');
rows = tbody.querySelectorAll('tr');
assert.ok(rows[0].querySelectorAll('td')[3].textContent.includes('192.168.1.100'));
assert.ok(rows[1].querySelectorAll('td')[3].textContent.includes('192.168.1.20'));
assert.ok(rows[2].querySelectorAll('td')[3].textContent.includes('192.168.1.5'));
assert.deepEqual(rows.map(row => row.querySelector('.dm-index-column').textContent), ['1', '2', '3']);
// Click Name header: switch sorting to Name
const nameTh = thList.find(th => th.getAttribute('data-sort') === 'name');
assert.ok(nameTh);
await nameTh.querySelector('button').click();
assert.equal(page.sortKey, 'name');
assert.equal(page.sortDir, 'asc');
assert.equal(ipTh.classes.has('sorted-asc'), false);
assert.equal(ipTh.classes.has('sorted-desc'), false);
assert.equal(ipTh.getAttribute('aria-sort'), 'none');
assert.equal(nameTh.classes.has('sorted-asc'), true);
assert.equal(nameTh.getAttribute('aria-sort'), 'ascending');
page.filterText = 'host2';
page.updateView();
rows = tbody.querySelectorAll('tr');
assert.equal(rows.length, 1);
assert.equal(rows[0].querySelector('.dm-index-column').textContent, '1');
assert.ok(rows[0].querySelectorAll('td')[3].textContent.includes('192.168.1.5'));
page.filterText = 'no matching device';
page.updateView();
assert.equal(tbody.querySelector('td').getAttribute('colspan'), thList.length);
});
test('status indicator renders as a small dot without text, and default tab is Online followed by All', async () => {
const initial = [
{ '.type': 'device', '.name': 'dev_1', mac: '00:11:22:33:44:01', name: 'Dev-Online' }
];
const mocks = backend(initial);
mocks.replies['luci.device-manager.get_online_status'] = {
ok: true,
neighbors: [{ mac: '00:11:22:33:44:01', state: 'REACHABLE' }]
};
mocks.replies['luci-rpc.getHostHints'] = {
'00:11:22:33:44:01': { name: 'host1', ipaddrs: ['192.168.1.10'] }
};
const env = environment(mocks);
const page = loadModule('view.device-manager.devices', env);
const data = await page.load();
const viewNode = page.render(data);
// Check tabs order: 0 is online, 1 is all
const tabs = viewNode.querySelector('#dm-tabs-container').children;
assert.equal(tabs[0].getAttribute('data-tab'), 'online');
assert.equal(tabs[1].getAttribute('data-tab'), 'all');
assert.equal(page.activeTab, 'online');
assert.equal(tabs[0].classes.has('active'), true);
// Check row has dot and no badge text
const row = viewNode.querySelector('#device_manager_tbody').querySelectorAll('tr')[0];
const dot = row.querySelector('.dm-status-dot');
assert.ok(dot);
assert.equal(dot.classes.has('dm-status-dot-online'), true);
assert.equal(dot.textContent, ''); // No text displayed inside the dot!
assert.ok(dot.attrs.title.includes('Online') || dot.attrs.title.includes('REACHABLE'));
});
test('table row display omits no-hostname subtitle, prefix from hostname subtitle, and leaves ungrouped and remark blank', async () => {
const initial = [
{ '.type': 'device', '.name': 'dev_1', mac: '00:11:22:33:44:01', name: 'My-Server', group: 'ungrouped', remark: '' },
{ '.type': 'device', '.name': 'dev_2', mac: '00:11:22:33:44:02', name: '', group: 'ungrouped', remark: '' }
];
const mocks = backend(initial);
mocks.replies['luci.device-manager.get_online_status'] = {
ok: true,
neighbors: [
{ mac: '00:11:22:33:44:01', state: 'REACHABLE' },
{ mac: '00:11:22:33:44:02', state: 'REACHABLE' }
]
};
mocks.replies['luci-rpc.getHostHints'] = {
'00:11:22:33:44:01': { name: 'NAS', ipaddrs: ['192.168.1.10'] },
'00:11:22:33:44:02': { name: '', ipaddrs: ['192.168.1.11'] }
};
const env = environment(mocks);
const page = loadModule('view.device-manager.devices', env);
const data = await page.load();
const viewNode = page.render(data);
const rows = viewNode.querySelector('#device_manager_tbody').querySelectorAll('tr');
assert.equal(rows.length, 2);
// dev_1 has customName 'My-Server' and hostname 'NAS'
const row1 = rows[0];
const row1Tds = row1.querySelectorAll('td');
const titleRow1 = row1Tds[2].querySelector('.dm-device-title-row');
assert.ok(titleRow1.textContent.includes('My-Server'));
const subtitle1 = row1Tds[2].querySelector('.dm-device-subtitle');
assert.ok(subtitle1);
assert.equal(subtitle1.textContent, 'NAS'); // Only NAS, no "Hostname: " or "主机名: " prefix
assert.equal(row1Tds[5].textContent, '');
assert.equal(row1Tds[6].textContent, '');
// dev_2 has no customName and no hostname
const row2 = rows[1];
const row2Tds = row2.querySelectorAll('td');
const subtitle2 = row2Tds[2].querySelector('.dm-device-subtitle');
assert.equal(subtitle2, null); // No "No hostname detected" or "无检测主机名"
assert.equal(row2Tds[5].textContent, '');
assert.equal(row2Tds[6].textContent, '');
});
test('mask/show info button toggles MAC and IPv6 masking with asterisks and remembers preference', async () => {
let stored = null;
const initial = [
{ '.type': 'device', '.name': 'dev_1', mac: '00:11:22:33:44:01', name: 'PC' }
];
const mocks = backend(initial);
mocks.replies['luci.device-manager.get_online_status'] = {
ok: true,
neighbors: [{ mac: '00:11:22:33:44:01', state: 'REACHABLE' }]
};
mocks.replies['luci-rpc.getHostHints'] = {
'00:11:22:33:44:01': { name: 'PC', ipaddrs: ['192.168.1.10'], ip6addrs: ['fe80::1234:5678:9abc:def0'] }
};
const env = environment(Object.assign({}, mocks, {
window: {
localStorage: {
getItem: () => stored,
setItem: (k, v) => { stored = v; }
}
}
}));
const page = loadModule('view.device-manager.devices', env);
const data = await page.load();
const viewNode = page.render(data);
// By default maskInfo is false: MAC and IPv6 are unmasked
let row = viewNode.querySelector('#device_manager_tbody').querySelectorAll('tr')[0];
let rowTds = row.querySelectorAll('td');
assert.ok(rowTds[3].textContent.includes('192.168.1.10')); // IPv4
assert.ok(rowTds[3].textContent.includes('fe80::1234:5678:9abc:def0')); // IPv6 unmasked
assert.ok(rowTds[4].textContent.includes('00:11:22:33:44:01')); // MAC unmasked
// Click mask toggle button
const maskBtn = viewNode.querySelector('#dm-btn-mask');
assert.ok(maskBtn);
await maskBtn.click();
// After clicking, maskInfo is true
assert.equal(page.maskInfo, true);
assert.deepEqual(JSON.parse(stored), { tab: 'online', group: 'all', maskInfo: true });
row = viewNode.querySelector('#device_manager_tbody').querySelectorAll('tr')[0];
rowTds = row.querySelectorAll('td');
// IPv4 remains completely unchanged
assert.ok(rowTds[3].textContent.includes('192.168.1.10'));
// IPv6 is masked with asterisks
assert.ok(rowTds[3].textContent.includes('****::****:****:****:****'));
assert.ok(!rowTds[3].textContent.includes('fe80'));
// MAC is masked with asterisks
assert.ok(rowTds[4].textContent.includes('**:**:**:**:**:**'));
assert.ok(!rowTds[4].textContent.includes('00:11:22'));
// Test persistence on page reload
const pageReload = loadModule('view.device-manager.devices', env);
const reloadedData = await pageReload.load();
const reloadedView = pageReload.render(reloadedData);
assert.equal(pageReload.maskInfo, true);
row = reloadedView.querySelector('#device_manager_tbody').querySelectorAll('tr')[0];
rowTds = row.querySelectorAll('td');
assert.ok(rowTds[3].textContent.includes('192.168.1.10'));
assert.ok(rowTds[3].textContent.includes('****::****:****:****:****'));
assert.ok(rowTds[4].textContent.includes('**:**:**:**:**:**'));
});
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
"""Select the plugin and Chinese translation without the SDK's all-package defaults."""
import pathlib
import re
import sys
def configure(directory):
directory = pathlib.Path(directory)
if not (directory / "rules.mk").is_file() or not (directory / "Makefile").is_file():
raise ValueError("Expected an OpenWrt SDK directory")
config = directory / ".config"
lines = config.read_text().splitlines() if config.exists() else []
pattern = r"(?:# )?CONFIG_(?:ALL(?:_NONSHARED|_KMODS)?\b|PACKAGE_|LUCI_LANG_)"
retained = [line for line in lines if not re.match(pattern, line)]
selected = [
"# CONFIG_ALL is not set", "# CONFIG_ALL_NONSHARED is not set", "# CONFIG_ALL_KMODS is not set",
"CONFIG_PACKAGE_luci-app-device-manager=m", "CONFIG_LUCI_LANG_zh_Hans=y",
"CONFIG_PACKAGE_luci-i18n-device-manager-zh-cn=m",
]
config.write_text("\n".join(retained + selected) + "\n")
if __name__ == "__main__":
if len(sys.argv) != 2:
sys.exit("Usage: configure-sdk.py <sdk-directory>")
configure(sys.argv[1])
+258
View File
@@ -0,0 +1,258 @@
#!/usr/bin/env bash
#
# deploy.sh - Deploy luci-app-device-manager directly to an OpenWrt router for development/testing.
# SSH authentication is performed once; all deployment commands reuse that connection.
#
# Usage:
# ./tools/deploy.sh [options] <target>
#
# Examples:
# ./tools/deploy.sh 192.168.1.1
# ./tools/deploy.sh root@192.168.1.1
# ./tools/deploy.sh -p 2222 root@192.168.1.1
#
set -euo pipefail
# Color formatting
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
print_info() {
printf "${BLUE}[INFO]${NC} %s\n" "$1"
}
print_success() {
printf "${GREEN}[SUCCESS]${NC} %s\n" "$1"
}
print_error() {
printf "${RED}[ERROR]${NC} %s\n" "$1" >&2
}
show_usage() {
cat <<EOF
Usage: $0 [options] <target>
Arguments:
<target> Router IP address or SSH user@host (e.g., 192.168.1.1 or root@192.168.1.1)
Options:
-p, --port <port> SSH port number (default: 22)
-h, --help Show this help message
Examples:
$0 192.168.1.1
$0 root@192.168.1.1
$0 -p 2222 root@192.168.31.1
EOF
}
# Parse options
SSH_PORT="22"
TARGET=""
while [[ $# -gt 0 ]]; do
case "$1" in
-p|--port)
if [[ $# -lt 2 || ! "$2" =~ ^[0-9]+$ || ${#2} -gt 5 ]] || (( 10#$2 < 1 || 10#$2 > 65535 )); then
print_error "SSH port must be an integer between 1 and 65535."
exit 1
fi
SSH_PORT="$2"
shift 2
;;
-h|--help)
show_usage
exit 0
;;
-*)
print_error "Unknown option: $1"
show_usage
exit 1
;;
*)
if [[ -z "$TARGET" ]]; then
TARGET="$1"
else
print_error "Unexpected argument: $1"
show_usage
exit 1
fi
shift
;;
esac
done
if [[ -z "$TARGET" ]]; then
print_error "Target router address is required."
show_usage
exit 1
fi
# Normalize SSH target
if [[ "$TARGET" != *"@"* ]]; then
SSH_TARGET="root@${TARGET}"
ROUTER_IP="${TARGET}"
else
SSH_TARGET="${TARGET}"
ROUTER_IP="${TARGET#*@}"
fi
# Check prerequisites
for cmd in ssh scp mktemp; do
if ! command -v "$cmd" &>/dev/null; then
print_error "Required local command '$cmd' is not found in PATH."
exit 1
fi
done
# Resolve workspace directory
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
# Verify essential project files exist
if [[ ! -f "${PROJECT_ROOT}/htdocs/luci-static/resources/view/device-manager/devices.js" ]]; then
print_error "Missing frontend view file: htdocs/.../devices.js"
exit 1
fi
if [[ ! -f "${PROJECT_ROOT}/root/usr/share/luci/menu.d/luci-app-device-manager.json" ]]; then
print_error "Missing menu definition file: root/.../menu.d/luci-app-device-manager.json"
exit 1
fi
if [[ ! -f "${PROJECT_ROOT}/root/usr/share/rpcd/acl.d/luci-app-device-manager.json" ]]; then
print_error "Missing ACL definition file: root/.../acl.d/luci-app-device-manager.json"
exit 1
fi
for resource in model.js service.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
fi
done
if [[ ! -f "${PROJECT_ROOT}/root/usr/libexec/rpcd/luci.device-manager" || ! -f "${PROJECT_ROOT}/tools/initialize-config.sh" ]]; then
print_error "Missing rpcd helper or configuration initializer."
exit 1
fi
# Keep the control socket private and its path short enough for Unix socket limits.
SSH_CONTROL_DIR="$(mktemp -d /tmp/luci-deploy.XXXXXX)"
SSH_CONTROL_PATH="${SSH_CONTROL_DIR}/ssh"
SSH_COMMON_OPTS=(-o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new -o "ControlPath=${SSH_CONTROL_PATH}")
SSH_OPTS=(-p "${SSH_PORT}" "${SSH_COMMON_OPTS[@]}" -o ControlMaster=no -o BatchMode=yes)
SCP_OPTS=(-P "${SSH_PORT}" "${SSH_COMMON_OPTS[@]}" -o ControlMaster=no -o BatchMode=yes)
cleanup() {
ssh "${SSH_OPTS[@]}" -O exit "${SSH_TARGET}" >/dev/null 2>&1 || true
rm -rf -- "${SSH_CONTROL_DIR}"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
print_info "Connecting to OpenWrt router at ${SSH_TARGET} (port: ${SSH_PORT})..."
# Authenticate once and keep the master connection open until cleanup.
if ! ssh -p "${SSH_PORT}" "${SSH_COMMON_OPTS[@]}" -o BatchMode=no -o ControlPersist=no -M -N -f "${SSH_TARGET}"; then
print_error "Failed to connect to ${SSH_TARGET} via SSH. Please check network, IP, port, and SSH key/password."
exit 1
fi
if ! ssh "${SSH_OPTS[@]}" "${SSH_TARGET}" "command -v ip >/dev/null && command -v flock >/dev/null && test -r /usr/share/libubox/jshn.sh"; then
print_error "Router is missing ip, flock or jshn. Standard OpenWrt BusyBox provides ip and flock; custom builds may need ip-tiny and flock. Install jshn if missing."
exit 1
fi
print_info "Connection verified. Preparing remote directories..."
ssh "${SSH_OPTS[@]}" "${SSH_TARGET}" "
mkdir -p /www/luci-static/resources/view/device-manager \
/usr/share/luci/menu.d \
/usr/share/rpcd/acl.d \
/usr/lib/lua/luci/i18n \
/usr/libexec/rpcd \
/etc/uci-defaults \
/etc/config
"
print_info "Deploying shared frontend modules and stylesheet..."
scp "${SCP_OPTS[@]}" -r \
"${PROJECT_ROOT}/htdocs/luci-static/resources/device-manager" \
"${SSH_TARGET}:/www/luci-static/resources/"
print_info "Deploying frontend view (devices.js)..."
scp "${SCP_OPTS[@]}" \
"${PROJECT_ROOT}/htdocs/luci-static/resources/view/device-manager/devices.js" \
"${SSH_TARGET}:/www/luci-static/resources/view/device-manager/devices.js"
print_info "Deploying LuCI menu and rpcd ACL definitions..."
scp "${SCP_OPTS[@]}" \
"${PROJECT_ROOT}/root/usr/share/luci/menu.d/luci-app-device-manager.json" \
"${SSH_TARGET}:/usr/share/luci/menu.d/luci-app-device-manager.json"
scp "${SCP_OPTS[@]}" \
"${PROJECT_ROOT}/root/usr/share/rpcd/acl.d/luci-app-device-manager.json" \
"${SSH_TARGET}:/usr/share/rpcd/acl.d/luci-app-device-manager.json"
# Native translations are installed as the matching luci-i18n package.
# Remove files left by older development deployments without changing LuCI language.
ssh "${SSH_OPTS[@]}" "${SSH_TARGET}" "
rm -f /www/luci-static/resources/device-manager/i18n.js /www/luci-static/resources/device-manager/translations.js
rm -f /usr/lib/lua/luci/i18n/device-manager-builtin.zh-cn.lmo /etc/uci-defaults/81_device_manager_i18n
"
if [[ -f "${PROJECT_ROOT}/root/usr/libexec/rpcd/luci.device-manager" ]]; then
print_info "Deploying rpcd helper backend script..."
scp "${SCP_OPTS[@]}" \
"${PROJECT_ROOT}/root/usr/libexec/rpcd/luci.device-manager" \
"${SSH_TARGET}:/usr/libexec/rpcd/luci.device-manager"
ssh "${SSH_OPTS[@]}" "${SSH_TARGET}" "chmod +x /usr/libexec/rpcd/luci.device-manager"
fi
if [[ -f "${PROJECT_ROOT}/tools/initialize-config.sh" ]]; then
print_info "Deploying development configuration initializer..."
scp "${SCP_OPTS[@]}" \
"${PROJECT_ROOT}/tools/initialize-config.sh" \
"${SSH_TARGET}:/etc/uci-defaults/80_device_manager"
fi
print_info "Ensuring configuration persistence (protecting existing user remarks)..."
ssh "${SSH_OPTS[@]}" "${SSH_TARGET}" "
set -e
if [ ! -f /etc/config/device_manager ]; then
echo 'Initializing default /etc/config/device_manager'
else
echo 'Existing /etc/config/device_manager preserved.'
fi
if [ -f /etc/uci-defaults/80_device_manager ]; then
chmod +x /etc/uci-defaults/80_device_manager
/etc/uci-defaults/80_device_manager
rm -f /etc/uci-defaults/80_device_manager
fi
"
print_info "Clearing LuCI cache and restarting rpcd..."
ssh "${SSH_OPTS[@]}" "${SSH_TARGET}" "
rm -f /tmp/luci-indexcache* /tmp/luci-modulecache*
/etc/init.d/rpcd restart
"
print_success "Deployment completed successfully!"
printf "\n"
printf "${CYAN}================================================================${NC}\n"
printf " ${GREEN}luci-app-device-manager${NC} is now active on your router.\n"
printf "\n"
printf " Access URL: ${YELLOW}http://%s/cgi-bin/luci/admin/network/device-manager${NC}\n" "${ROUTER_IP}"
printf " Menu path: ${YELLOW}网络 -> 设备管理${NC}\n"
printf "\n"
printf " ${YELLOW}Note:${NC} If you are already logged in, please refresh the page\n"
printf " with ${CYAN}Ctrl + Shift + R${NC} to clear browser JavaScript cache.\n"
printf "${CYAN}================================================================${NC}\n"
+24
View File
@@ -0,0 +1,24 @@
#!/bin/sh
# SPDX-License-Identifier: MIT
set -e
# Existing configuration, including an intentionally empty file, is authoritative.
# Development deployment only; packages ship /etc/config/device_manager directly.
[ -e /etc/config/device_manager ] && exit 0
touch /etc/config/device_manager
trap 'uci -q revert device_manager || :; rm -f /etc/config/device_manager' EXIT
uci -q batch <<'EOF'
set device_manager.smart_home=group
set device_manager.smart_home.name='Smart home'
set device_manager.phone=group
set device_manager.phone.name='Phones'
set device_manager.computer=group
set device_manager.computer.name='Computers'
set device_manager.network=group
set device_manager.network.name='Network devices'
set device_manager.other=group
set device_manager.other.name='Other devices'
commit device_manager
EOF
trap - EXIT
@@ -0,0 +1,19 @@
#!/usr/bin/env python3
"""Print a LuCI-style version from this source repository for standalone SDK builds."""
import datetime
import pathlib
import subprocess
ROOT = pathlib.Path(__file__).resolve().parents[1]
def version():
stamp, revision = subprocess.check_output(
["git", "log", "-1", "--format=%ct %h", "--abbrev=7", "--", "."],
cwd=ROOT, text=True).strip().split()
moment = datetime.datetime.fromtimestamp(int(stamp), datetime.timezone.utc)
return f"{moment:%y}.{moment.timetuple().tm_yday:03d}.{int(stamp) % 86400:05d}~{revision}"
if __name__ == "__main__":
print(version())
@@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""Export only package sources into a LuCI checkout without modifying Git history."""
import pathlib
import shutil
import sys
ROOT = pathlib.Path(__file__).resolve().parents[1]
def prepare(directory):
directory = pathlib.Path(directory).resolve()
if not (directory / "luci.mk").is_file():
raise ValueError("Expected a LuCI checkout containing luci.mk")
target = directory / "applications/luci-app-device-manager"
if target.exists():
raise FileExistsError(f"Refusing to overwrite existing package: {target}")
target.mkdir(parents=True)
for name in ["htdocs", "root", "po"]:
shutil.copytree(ROOT / name, target / name)
for name in ["LICENSE"]:
shutil.copyfile(ROOT / name, target / name)
readme = (ROOT / "README.md").read_text()
start = readme.index("## 安装 / Installation")
end = readme.index("## 扫描范围 / Scan scope", start)
readme = readme[:start] + (
"## Installation\n\n"
"On OpenWrt releases whose feeds include this application, install it\n"
"from the configured package repositories.\n\n"
"For systems using opkg:\n\n"
"```sh\n"
"opkg update\n"
"opkg install luci-app-device-manager\n"
"opkg install luci-i18n-device-manager-zh-cn\n"
"```\n\n"
"For systems using apk:\n\n"
"```sh\n"
"apk update\n"
"apk add luci-app-device-manager\n"
"apk add luci-i18n-device-manager-zh-cn\n"
"```\n\n"
"The Chinese language package is optional. Without a translation, LuCI\n"
"displays the English source messages. Reload LuCI after installation\n"
"and open **Network → Device Manager**.\n\n"
"Dependencies are resolved by the package manager. Standard OpenWrt\n"
"BusyBox supplies the required `ip` and `flock` applets. Custom firmware\n"
"that disables these applets must enable them or install `ip-tiny`\n"
"(or `ip-full`) and `flock` separately.\n\n"
) + readme[end:]
start = readme.index("## 构建和开发")
end = readme.index("## 真实设备验收", start)
readme = readme[:start] + (
"## Development\n\n"
"This application is built in the LuCI feed with the standard luci.mk.\n"
"Independent SDK workflows, tests and deployment tools are maintained in\n"
"[the source repository](https://github.com/xxi-arch/luci-app-device-manager).\n\n"
) + readme[end:]
(target / "README.md").write_text(readme)
# Package-specific attribution and usage documentation, excluding release tooling.
shutil.copytree(ROOT / "docs", target / "docs",
ignore=shutil.ignore_patterns("upstream-submission.md", "validation.md"))
makefile = (ROOT / "Makefile").read_text()
start = makefile.index("# In-tree LuCI applications")
end = makefile.index("# call BuildPackage", start)
(target / "Makefile").write_text(makefile[:start] + "include ../../luci.mk\n\n" + makefile[end:])
return target
if __name__ == "__main__":
if len(sys.argv) != 2:
sys.exit("Usage: prepare-upstream.py <luci-checkout>")
print(prepare(sys.argv[1]))
@@ -0,0 +1,50 @@
#!/usr/bin/env python3
"""Resolve an official release or snapshot SDK URL and checksum for the requested target."""
import re
import sys
from html.parser import HTMLParser
from urllib.request import urlopen
class Links(HTMLParser):
def __init__(self):
super().__init__()
self.files = set()
def handle_starttag(self, tag, attrs):
if tag == "a":
self.files.update(value for key, value in attrs if key == "href" and value)
def resolve(version, target):
if version != "SNAPSHOT" and not re.fullmatch(r"\d+\.\d+\.\d+(?:-rc\d+)?", version):
raise ValueError("Expected SNAPSHOT or an OpenWrt release version, e.g. 24.10.0")
if not re.fullmatch(r"[a-z0-9_-]+/[a-z0-9_-]+", target):
raise ValueError("Expected an OpenWrt target/subtarget, e.g. x86/64")
base = (f"https://downloads.openwrt.org/snapshots/targets/{target}/" if version == "SNAPSHOT"
else f"https://downloads.openwrt.org/releases/{version}/targets/{target}/")
with urlopen(base, timeout=30) as response:
listing = response.read().decode("utf-8")
links = Links()
links.feed(listing)
sdk_prefix = "openwrt-sdk-" if version == "SNAPSHOT" else f"openwrt-sdk-{re.escape(version)}-"
pattern = rf"{sdk_prefix}[A-Za-z0-9_.+-]+\.Linux-x86_64\.tar\.(?:xz|zst)"
matches = sorted(filename for filename in links.files if re.fullmatch(pattern, filename))
if len(matches) != 1:
raise ValueError(f"Expected one x86_64 host SDK at {base}; found {len(matches)}")
filename = matches[0]
with urlopen(base + "sha256sums", timeout=30) as response:
checksums = response.read().decode("utf-8")
for line in checksums.splitlines():
match = re.fullmatch(r"([a-fA-F0-9]{64})\s+\*?(.+)", line)
if match and match[2] == filename:
return base + filename, match[1].lower()
raise ValueError(f"No checksum found for {filename}")
if __name__ == "__main__":
if len(sys.argv) != 3:
sys.exit("Usage: resolve-sdk.py <release-version|SNAPSHOT> <target/subtarget>")
sdk_url, sdk_sha256 = resolve(sys.argv[1], sys.argv[2])
print(f"sdk_url={sdk_url}")
print(f"sdk_sha256={sdk_sha256}")
@@ -0,0 +1,43 @@
#!/usr/bin/env python3
"""Extract native LuCI messages and merge the PO catalogue using official tools."""
import argparse
import pathlib
import subprocess
import tempfile
ROOT = pathlib.Path(__file__).resolve().parents[1]
def extract(luci_dir):
scanner = pathlib.Path(luci_dir).resolve() / "build/i18n-scan.pl"
if not scanner.is_file():
raise ValueError("Expected a LuCI checkout containing build/i18n-scan.pl")
return subprocess.run(["perl", str(scanner), "htdocs", "root"], cwd=ROOT,
check=True, capture_output=True, text=True).stdout
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--luci-dir", required=True, help="Official LuCI checkout")
parser.add_argument("--check", action="store_true")
args = parser.parse_args()
template = ROOT / "po/templates/device-manager.pot"
catalogue = ROOT / "po/zh_Hans/device-manager.po"
content = extract(args.luci_dir)
with tempfile.TemporaryDirectory(prefix="device-manager-i18n-") as directory:
fresh = pathlib.Path(directory) / "device-manager.pot"
fresh.write_text(content)
if args.check:
if template.read_text() != content:
parser.error("Translation template is stale; regenerate with --luci-dir")
subprocess.run(["msgcmp", "--use-fuzzy", str(catalogue), str(fresh)], check=True)
subprocess.run(["msgfmt", "--check", "-o", str(pathlib.Path(directory) / "catalogue.mo"),
str(catalogue)], check=True)
else:
template.write_text(content)
subprocess.run(["msgmerge", "--update", "--no-fuzzy-matching", "--no-wrap",
"--backup=none", str(catalogue), str(fresh)], check=True)
if __name__ == "__main__":
main()
+1 -1
View File
@@ -7,7 +7,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-honk
PKG_VERSION:=1.1.4
PKG_RELEASE:=19
PKG_RELEASE:=20
LUCI_TITLE:=LuCI support for honk
LUCI_PKGARCH:=all
@@ -94,6 +94,23 @@ function isServiceEnabled() {
return ((sections[0] || {}).enabled === '1');
}
function updateStatus(data, targetEl) {
var el = targetEl || document.getElementById('honk_status');
if (!el) return;
if (data && data.running) {
var mem = data.memory ? ' (' + _('Memory Usage') + ': ' + data.memory + ')' : '';
dom.content(el, [
E('span', { 'style': 'color: var(--success, #22c55e); font-weight: bold;' }, _('HONK') + ' ' + _('RUNNING')),
' ',
E('span', { 'style': 'color: var(--text-muted, #888); font-size: 0.9em;' }, mem)
]);
} else {
dom.content(el, [
E('span', { 'style': 'color: var(--danger, #ef4444); font-weight: bold;' }, _('HONK') + ' ' + _('NOT RUNNING'))
]);
}
}
function waitForHonkState(wantRunning, attempts) {
attempts = attempts || 10;
@@ -104,15 +121,23 @@ function waitForHonkState(wantRunning, attempts) {
callHonkStatus().then(function(st) {
var running = !!(st && st.running);
if (running === wantRunning || ++tries >= attempts)
resolve({ running: running });
else
if (running === wantRunning || ++tries >= attempts) {
updateStatus(st);
if (poll && typeof(poll.start) === 'function')
poll.start();
resolve({ running: running, memory: st ? st.memory : null });
} else {
setTimeout(step, 1200);
}
}).catch(function() {
if (++tries >= attempts)
if (++tries >= attempts) {
updateStatus({ running: false });
if (poll && typeof(poll.start) === 'function')
poll.start();
resolve({ running: null });
else
} else {
setTimeout(step, 1200);
}
});
}
@@ -137,6 +162,25 @@ function switchDashboardAndWait(type) {
});
}
function applyChangesAndWait(mode) {
return new Promise(function(resolve) {
var timer = null;
var onApplied = function() {
if (timer) clearTimeout(timer);
document.removeEventListener('uci-applied', onApplied);
resolve();
};
var holdoff = (window.L && L.env && +L.env.apply_holdoff) || 4;
var timeout = (window.L && L.env && +L.env.apply_timeout) || 5;
timer = setTimeout(onApplied, (holdoff + timeout) * 1000);
document.addEventListener('uci-applied', onApplied);
ui.changes.apply(mode == '0');
});
}
var DOWNLOAD_MAX_TICKS = 900;
var DOWNLOAD_STALE_TICKS = 90;
var activeDownloadCancel = null;
@@ -621,6 +665,7 @@ function createConfigFileView(filePath, mapTitle, mapDesc, fieldTitle, successMs
return { skipped: true };
return needRestart ? waitForHonkState(true) : callHonkStatus().then(function(st) {
updateStatus(st);
return { running: !!(st && st.running) };
});
}).then(function(st) {
@@ -750,30 +795,18 @@ function renderStatusHeader() {
])
]);
function updateStatus(data) {
if (!statusEl) return;
if (data && data.running) {
var mem = data.memory ? ' (' + _('Memory Usage') + ': ' + data.memory + ')' : '';
dom.content(statusEl, [
E('span', { 'style': 'color: var(--success, #22c55e); font-weight: bold;' }, _('HONK') + ' ' + _('RUNNING')),
' ',
E('span', { 'style': 'color: var(--text-muted, #888); font-size: 0.9em;' }, mem)
]);
} else {
dom.content(statusEl, [
E('span', { 'style': 'color: var(--danger, #ef4444); font-weight: bold;' }, _('HONK') + ' ' + _('NOT RUNNING'))
]);
}
}
callHonkStatus().then(updateStatus);
callHonkStatus().then(function(st) {
updateStatus(st, statusEl);
});
var pollStatusFn = function() {
if (!document.body.contains(statusEl)) {
poll.remove(pollStatusFn);
return Promise.resolve();
}
return callHonkStatus().then(updateStatus).catch(function() { });
return callHonkStatus().then(function(st) {
updateStatus(st, statusEl);
}).catch(function() { });
};
poll.add(pollStatusFn, 5);
@@ -809,5 +842,7 @@ return baseclass.extend({
waitForHonkState: waitForHonkState,
switchDashboardAndWait: switchDashboardAndWait,
isServiceEnabled: isServiceEnabled,
showNotification: showNotification
showNotification: showNotification,
updateStatus: updateStatus,
applyChangesAndWait: applyChangesAndWait
});
@@ -62,30 +62,23 @@ return view.extend({
return this.handleSave(ev).then(function() {
var newDash = uci.get('honk', sid, 'dashboard') || 'none';
var enabled = honk.isServiceEnabled();
if (newDash !== oldDash) {
return honk.switchDashboardAndWait(newDash).then(function(res) {
if (res && res.pending)
honk.showNotification(null, E('p', _('Saved, but the panel files are not installed yet — download the panel first.')), 'warning');
else if (res.running === false)
throw new Error(_('HONK did not come back up; check the logs'));
});
}
var pre = (newDash !== oldDash) ? honk.switchDashboardAndWait(newDash).then(function(res) {
if (res && res.pending)
honk.showNotification(null, E('p', _('Saved, but the panel files are not installed yet — download the panel first.')), 'warning');
else if (res.running === false)
throw new Error(_('HONK did not come back up; check the logs'));
}) : Promise.resolve();
return honk.callHonkReload().then(function(resp) {
if (!resp || resp.success === false)
throw new Error((resp && resp.message) || _('Service did not accept the request'));
if (!honk.isServiceEnabled())
return;
return honk.waitForHonkState(true).then(function(st) {
if (st.running === false)
throw new Error(_('HONK did not come back up; check the logs'));
});
return pre.then(function() {
return honk.applyChangesAndWait(mode);
}).then(function() {
return honk.waitForHonkState(enabled);
}).then(function(st) {
if (enabled && st && st.running === false)
throw new Error(_('HONK did not come back up; check the logs'));
});
}).then(function() {
return ui.changes.apply(mode == '0');
}).catch(function(err) {
honk.showNotification(null, E('p', _('Failed to apply configuration:') + ' ' + (err.message || err)), 'error');
});
+2 -2
View File
@@ -1,8 +1,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-open-box
PKG_VERSION:=0.1.310
PKG_RELEASE:=22
PKG_VERSION:=0.1.311
PKG_RELEASE:=23
PKG_MAINTAINER:=liandu2024
PKG_LICENSE:=GPL-3.0-or-later
+13
View File
@@ -0,0 +1,13 @@
Copyright (C) 2008-2026 The OpenWrt Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://apache.org
Unless required by applicable law or agreed to in writing, Version 2.0
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+91
View File
@@ -0,0 +1,91 @@
# Copyright (C) 2008-2026 The OpenWrt Project
# Copyright (C) 2026 Manfred Jaider <info@masmbit.com>
#
# This is free software, licensed under the Apache License, Version 2.0.
# See /LICENSE for more information.
#
# luci-app-openvpn-plus : Makefile.standalone
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-openvpn-plus
PKG_VERSION:=1.0.0
PKG_RELEASE:=1
PKG_LICENSE:=Apache-2.0
PKG_MAINTAINER:=Manfred Jaider <info@masmbit.com>
include $(INCLUDE_DIR)/package.mk
define Package/luci-app-openvpn-plus
SECTION:=luci
CATEGORY:=LuCI
SUBMENU:=3. Applications
TITLE:=LuCI support for Ready-to-Use OpenVPN Setup (Plus Edition)
URL:=https://github.com/masmbit/luci-app-openvpn-plus
DEPENDS:=+luci-base +openvpn-openssl +openssl-util +firewall4 +uclient-fetch
PKGARCH:=all
endef
define Package/luci-app-openvpn-plus/description
This package provides an all-in-one web interface to configure OpenVPN servers
and clients quickly. It includes a setup wizard, a built-in key generator,
smart firewall protection, port and route checks, DDNS, SSL-VPN, and QR-code
import/export. With this app, the VPN setup is very simple and intuitive.
endef
# --- Build & Compilation Stage ---
define Build/Compile
$(INSTALL_DIR) $(PKG_BUILD_DIR)
endef
# --- Package Installation Stage ---
define Package/luci-app-openvpn-plus/install
$(INSTALL_DIR) $(1)/etc/hotplug.d/iface
$(INSTALL_DATA) ./root/etc/hotplug.d/iface/35-luci-app-openvpn-plus-bootup $(1)/etc/hotplug.d/iface/35-luci-app-openvpn-plus-bootup
$(INSTALL_DIR) $(1)/etc/hotplug.d/openvpn
$(INSTALL_DATA) ./root/etc/hotplug.d/openvpn/35-luci-app-openvpn-plus-lifecycle $(1)/etc/hotplug.d/openvpn/35-luci-app-openvpn-plus-lifecycle
$(INSTALL_DIR) $(1)/etc/openvpn/luci
$(INSTALL_DATA) ./root/etc/openvpn/luci/server.default.conf $(1)/etc/openvpn/luci/server.default.conf
$(INSTALL_DATA) ./root/etc/openvpn/luci/client.default.conf $(1)/etc/openvpn/luci/client.default.conf
$(INSTALL_DATA) ./root/etc/openvpn/luci/default.nft $(1)/etc/openvpn/luci/default.nft
$(INSTALL_DIR) $(1)/etc/uci-defaults
$(INSTALL_DATA) ./root/etc/uci-defaults/35_luci-app-openvpn-plus $(1)/etc/uci-defaults/35_luci-app-openvpn-plus
$(INSTALL_DIR) $(1)/usr/libexec
$(INSTALL_BIN) ./root/usr/libexec/luci-app-openvpn-plus $(1)/usr/libexec/luci-app-openvpn-plus
$(INSTALL_DIR) $(1)/usr/share/luci/menu.d
$(INSTALL_DATA) ./root/usr/share/luci/menu.d/luci-app-openvpn-plus.json $(1)/usr/share/luci/menu.d/luci-app-openvpn-plus.json
$(INSTALL_DIR) $(1)/usr/share/rpcd/acl.d
$(INSTALL_DATA) ./root/usr/share/rpcd/acl.d/luci-app-openvpn-plus.json $(1)/usr/share/rpcd/acl.d/luci-app-openvpn-plus.json
$(INSTALL_DATA) ./root/usr/share/rpcd/acl.d/luci-app-openvpn-plus-status.json $(1)/usr/share/rpcd/acl.d/luci-app-openvpn-plus-status.json
$(INSTALL_DIR) $(1)/www/luci-static/resources/view/vpn
$(INSTALL_DATA) ./root/www/luci-static/resources/view/vpn/openvpn.js $(1)/www/luci-static/resources/view/vpn/openvpn.js
$(INSTALL_DATA) ./root/www/luci-static/resources/view/vpn/openvpn-status.js $(1)/www/luci-static/resources/view/vpn/openvpn-status.js
$(INSTALL_DATA) ./root/www/luci-static/resources/view/vpn/openvpn-keygen.js $(1)/www/luci-static/resources/view/vpn/openvpn-keygen.js
$(INSTALL_DATA) ./root/www/luci-static/resources/view/vpn/openvpn-wizard.js $(1)/www/luci-static/resources/view/vpn/openvpn-wizard.js
$(INSTALL_DIR) $(1)/www/luci-static/resources/view/status/include
$(INSTALL_DATA) ./root/www/luci-static/resources/view/status/include/35_openvpn.js $(1)/www/luci-static/resources/view/status/include/35_openvpn.js
endef
# --- Package Uninstallation Stage ---
define Package/luci-app-openvpn-plus/prerm
#!/bin/sh
if [ -z "$${IPKG_INSTROOT}" ]; then
rm -rf /etc/openvpn/keys/*default*
rm -rf /etc/openvpn/luci/*default*
rm -rf /tmp/run/openvpn.instance*.status
fi
exit 0
endef
# Evaluate the main package into the OpenWrt Buildroot
$(eval $(call BuildPackage,luci-app-openvpn-plus))
+91
View File
@@ -0,0 +1,91 @@
# Copyright (C) 2008-2026 The OpenWrt Project
# Copyright (C) 2026 Manfred Jaider <info@masmbit.com>
#
# This is free software, licensed under the Apache License, Version 2.0.
# See /LICENSE for more information.
#
# luci-app-openvpn-plus : Makefile.standalone
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-openvpn-plus
PKG_VERSION:=1.0.0
PKG_RELEASE:=1
PKG_LICENSE:=Apache-2.0
PKG_MAINTAINER:=Manfred Jaider <info@masmbit.com>
include $(INCLUDE_DIR)/package.mk
define Package/luci-app-openvpn-plus
SECTION:=luci
CATEGORY:=LuCI
SUBMENU:=3. Applications
TITLE:=LuCI support for Ready-to-Use OpenVPN Setup (Plus Edition)
URL:=https://github.com/masmbit/luci-app-openvpn-plus
DEPENDS:=+luci-base +openvpn-openssl +openssl-util +firewall4 +uclient-fetch
PKGARCH:=all
endef
define Package/luci-app-openvpn-plus/description
This package provides an all-in-one web interface to configure OpenVPN servers
and clients quickly. It includes a setup wizard, a built-in key generator,
smart firewall protection, port and route checks, DDNS, SSL-VPN, and QR-code
import/export. With this app, the VPN setup is very simple and intuitive.
endef
# --- Build & Compilation Stage ---
define Build/Compile
$(INSTALL_DIR) $(PKG_BUILD_DIR)
endef
# --- Package Installation Stage ---
define Package/luci-app-openvpn-plus/install
$(INSTALL_DIR) $(1)/etc/hotplug.d/iface
$(INSTALL_DATA) ./root/etc/hotplug.d/iface/35-luci-app-openvpn-plus-bootup $(1)/etc/hotplug.d/iface/35-luci-app-openvpn-plus-bootup
$(INSTALL_DIR) $(1)/etc/hotplug.d/openvpn
$(INSTALL_DATA) ./root/etc/hotplug.d/openvpn/35-luci-app-openvpn-plus-lifecycle $(1)/etc/hotplug.d/openvpn/35-luci-app-openvpn-plus-lifecycle
$(INSTALL_DIR) $(1)/etc/openvpn/luci
$(INSTALL_DATA) ./root/etc/openvpn/luci/server.default.conf $(1)/etc/openvpn/luci/server.default.conf
$(INSTALL_DATA) ./root/etc/openvpn/luci/client.default.conf $(1)/etc/openvpn/luci/client.default.conf
$(INSTALL_DATA) ./root/etc/openvpn/luci/default.nft $(1)/etc/openvpn/luci/default.nft
$(INSTALL_DIR) $(1)/etc/uci-defaults
$(INSTALL_DATA) ./root/etc/uci-defaults/35_luci-app-openvpn-plus $(1)/etc/uci-defaults/35_luci-app-openvpn-plus
$(INSTALL_DIR) $(1)/usr/libexec
$(INSTALL_BIN) ./root/usr/libexec/luci-app-openvpn-plus $(1)/usr/libexec/luci-app-openvpn-plus
$(INSTALL_DIR) $(1)/usr/share/luci/menu.d
$(INSTALL_DATA) ./root/usr/share/luci/menu.d/luci-app-openvpn-plus.json $(1)/usr/share/luci/menu.d/luci-app-openvpn-plus.json
$(INSTALL_DIR) $(1)/usr/share/rpcd/acl.d
$(INSTALL_DATA) ./root/usr/share/rpcd/acl.d/luci-app-openvpn-plus.json $(1)/usr/share/rpcd/acl.d/luci-app-openvpn-plus.json
$(INSTALL_DATA) ./root/usr/share/rpcd/acl.d/luci-app-openvpn-plus-status.json $(1)/usr/share/rpcd/acl.d/luci-app-openvpn-plus-status.json
$(INSTALL_DIR) $(1)/www/luci-static/resources/view/vpn
$(INSTALL_DATA) ./root/www/luci-static/resources/view/vpn/openvpn.js $(1)/www/luci-static/resources/view/vpn/openvpn.js
$(INSTALL_DATA) ./root/www/luci-static/resources/view/vpn/openvpn-status.js $(1)/www/luci-static/resources/view/vpn/openvpn-status.js
$(INSTALL_DATA) ./root/www/luci-static/resources/view/vpn/openvpn-keygen.js $(1)/www/luci-static/resources/view/vpn/openvpn-keygen.js
$(INSTALL_DATA) ./root/www/luci-static/resources/view/vpn/openvpn-wizard.js $(1)/www/luci-static/resources/view/vpn/openvpn-wizard.js
$(INSTALL_DIR) $(1)/www/luci-static/resources/view/status/include
$(INSTALL_DATA) ./root/www/luci-static/resources/view/status/include/35_openvpn.js $(1)/www/luci-static/resources/view/status/include/35_openvpn.js
endef
# --- Package Uninstallation Stage ---
define Package/luci-app-openvpn-plus/prerm
#!/bin/sh
if [ -z "$${IPKG_INSTROOT}" ]; then
rm -rf /etc/openvpn/keys/*default*
rm -rf /etc/openvpn/luci/*default*
rm -rf /tmp/run/openvpn.instance*.status
fi
exit 0
endef
# Evaluate the main package into the OpenWrt Buildroot
$(eval $(call BuildPackage,luci-app-openvpn-plus))
+49
View File
@@ -0,0 +1,49 @@
# Copyright (C) 2008-2026 The OpenWrt Project
# Copyright (C) 2026 Manfred Jaider <info@masmbit.com>
#
# This is free software, licensed under the Apache License, Version 2.0.
# See /LICENSE for more information.
#
# luci-app-openvpn-plus : Makefile.upstream
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-openvpn-plus
PKG_VERSION:=1.0.0
PKG_RELEASE:=1
PKG_LICENSE:=Apache-2.0
PKG_MAINTAINER:=Manfred Jaider <info@masmbit.com>
LUCI_TITLE:=LuCI support for Ready-to-Use OpenVPN Setup (Plus Edition)
LUCI_DEPENDS:=+luci-base +openvpn-openssl +openssl-util +firewall4 +uclient-fetch
LUCI_PKGARCH:=all
define Package/luci-app-openvpn-plus/description
This package provides an all-in-one web interface to configure OpenVPN servers
and clients quickly. It includes a setup wizard, a built-in key generator,
smart firewall protection, port and route checks, DDNS, SSL-VPN, and QR-code
import/export. With this app, the VPN setup is very simple and intuitive.
endef
# --- LuCI custom installation hook for executable permissions ---
define LuCI/Install/Extra
$(INSTALL_DIR) $(1)/usr/libexec
$(INSTALL_BIN) ./root/usr/libexec/luci-app-openvpn-plus $(1)/usr/libexec/luci-app-openvpn-plus
endef
# --- Package uninstall cleanup ---
define Package/luci-app-openvpn-plus/prerm
#!/bin/sh
if [ -z "$${IPKG_INSTROOT}" ]; then
rm -rf /etc/openvpn/keys/*default*
rm -rf /etc/openvpn/luci/*default*
rm -rf /tmp/run/openvpn.instance*.status
fi
exit 0
endef
# 3. LuCI build engine
include ../../luci.mk
# call BuildPackage - OpenWrt buildroot signature
+114
View File
@@ -0,0 +1,114 @@
# luci-app-openvpn-plus
Modern and easy web interface to configure OpenVPN servers and clients on OpenWrt.
## Description
This package provides a modern, all-in-one web interface to configure OpenVPN servers and clients quickly. Featuring a built-in key generator, setup wizard, smart firewall protection, DDNS, SSL-VPN, and profile import/export via QR-code, your VPN is ready to use immediately.
Thanks to this simple application, **anyone can build a professional SSL/TLS-VPN network now**. You do not need to understand complex crypto keys or write config files. The app does all the hard background work for you. With the step-by-step wizard, you can create secure VPN connections for travel and mobile devices in seconds.
Setting up your mobile phone is incredibly easy—just scan the secure QR-code on your screen with your smartphone camera. In the past, running a VPN behind internet provider modems was very complex. To fix this, the app includes smart Double-NAT detection and automated live port checking. This makes the installation completely effortless and brings professional, enterprise-grade VPN features straight to standard OpenWrt routers.
**High Performance for Affordable Hardware:** You don't need expensive enterprise equipment. Thanks to native **OpenVPN Data Channel Offload (DCO)** integration and our automatic MTU engine, the app achieves outstanding throughput rates—matching the speed of Wireguard even on budget-friendly wireless access points.
## 📊 Real-World Performance Benchmarks (iperf3)
We tested the real-world performance of `luci-app-openvpn-plus` using two affordable **Zyxel NWA50AX Pro** wireless access points. Both devices run as pure APs behind standard internet provider modems.
Even though the traffic has to pass through **Double-NAT** and simple port forwardings, the performance matches expensive enterprise hardware:
### 🔹 Test A: Site-to-Site Tunnel (UDP Mode)
- **Setup:** Zyxel AP ↔️ WAN Router ↔️ Internet ↔️ WAN Router ↔️ Zyxel AP
- **Network Status:** Both routers are behind Double-NAT.
- **Benchmark Result:** **450+ Mbit/s** 🚀
- *The crypto load is perfectly split across multiple CPU cores thanks to OpenVPN DCO and our automatic MTU engine.*
### 🔸 Test B: Mobile Client / Roadwarrior (TCP / SSL-VPN Mode)
- **Setup:** Zyxel AP ↔️ WAN Router ↔️ Internet ↔️ Smartphone with OpenVPN App
- **Network Status:** The server AP is behind Double-NAT
- **Benchmark Result:** **200+ Mbit/s** 📱
- *This is the absolute maximum for a single CPU core under TCP. It bypasses strict public firewalls easily while keeping your smartphone connection incredibly fast.*
## Features
- **Easy Setup:** Build fully working OpenVPN servers or clients in seconds.
- **Connection Wizard:** Simple step-by-step setup for mobile phones (Roadwarrior), laptops, and router-to-router networks (**Site-to-Site with Multi-Clients Routing**).
- **Enterprise-Grade Speeds (DCO Support):** Out-of-the-box support for OpenVPN Data Channel Offload (DCO) to shift crypto processing directly into the Linux kernel, matching Wireguard performance.
- **Smart MTU Engine:** Automatically calculates and tunes packet sizes to eliminate fragmentation, splitting the crypto load perfectly across multiple CPU cores.
- **Secure Site-to-Site Routing:** Built-in `iroute()` hook dynamically maps matching subnets and includes dynamic Anti-Loop Prevention to automatically block internal routing loops and prevent kernel freezes.
- **Instant Mobile QR-Code Scan:** Shows a secure QR-code after setup. You can scan it with your phone camera to import the settings into the OpenVPN Mobile Connect app instantly.
- **Effortless Installation & Troubleshooting:** Built-in Double-NAT detection and automated live port-open checking make the first startup easy, even behind strict provider modems.
- **OpenVPN over TCP 443 (SSL-VPN):** Special travel-friendly presets to bypass strict public firewalls by hiding VPN traffic as standard HTTPS web traffic, achieving speeds over 200 Mbps on modern networks.
- **Built-in DDNS Daemon:** Automatically updates your VPN connection using built-in support for DuckDNS, IPv64, dynv6, Dynu, and No-IP.
- **Smart Firewall (DPI Defense):** Drops bad traffic and bans hackers in the Linux kernel using deep analysis for TCP and UDP ports.
- **Automated Firewall Zones:** Automatically creates all needed firewall zones, traffic rules, and port forwardings.
- **High-Security Keygen & Export:** Automatically builds RSA-2048/ECC-Prime256 keys, DH parameters, and TLS-crypt-v2, then packs them into one `.ovpn` file.
- **Hardware Crypto Optimization:** Smart cipher settings select fast hardware encryption (AES-GCM) for powerful chips, or optimized software encryption (CHACHA20-POLY1305) for older routers to keep speeds high.
- **Integrated Key Editor:** View, edit, download, and check key files and expiration dates directly inside your web browser.
- **Simple Profile Import & Export:** Upload existing `.ovpn` profiles or multi-key configuration files to start new client connections instantly.
- **Clear Status Table:** Shows a small overview of running tunnels, active process IDs (PIDs), encryption types, and live data transfer.
## Configuration Layout
The application manages settings inside `/etc/config/openvpn`:
```ini
config openvpn 'instance1'
option enabled '0'
option role 'server'
option config '/etc/openvpn/luci/instance1.conf'
```
## Directory Structure
```text
luci-app-openvpn-plus/
├── Makefile
├── LICENSE
├── README.md
├── po/
│ ├── de/
│ │ └── luci-app-openvpn-plus.po
│ ├── zh-cn/
│ │ └── luci-app-openvpn-plus.po
└── root/
├── etc/
│ ├── config/
│ └── hotplug.d/
│ │ ├── iface/
│ │ │ └── 35-luci-app-openvpn-plus-bootup
│ │ └── openvpn/
│ │ └── 35-luci-app-openvpn-plus-lifecycle
│ ├── openvpn/
│ │ ├── keys/
│ │ └── luci/
│ │ ├── default.nft
│ │ ├── client.default.conf
│ │ └── server.default.conf
│ └── uci-defaults/
│ └── 35_luci-app-openvpn-plus
├── usr/
│ ├── libexec/
│ │ └── luci-app-openvpn-plus
│ └── share/
│ ├── luci/menu.d/
│ │ └── luci-app-openvpn-plus.json
│ └── rpcd/acl.d/
│ ├── luci-app-openvpn-plus.json
│ └── luci-app-openvpn-plus-status.json
└── www/
└── luci-static/resources/view/
├── status/include/
│ └── 35_openvpn.js
└── vpn/
├── openvpn.js
├── openvpn-keygen.js
├── openvpn-status.js
└── openvpn-wizard.js
```
## License
Licensed under the Apache License 2.0.
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+1
View File
@@ -0,0 +1 @@
zh-cn
@@ -0,0 +1,73 @@
#!/bin/sh
# Copyright (C) 2008-2026 The OpenWrt Project
# Copyright (C) 2026 Manfred Jaider <info@masmbit.com>
#
# This is free software, licensed under the Apache License, Version 2.0.
# See /LICENSE for more information.
#
# luci-app-openvpn-plus : Network-ready bootup event handler with race-condition lock
# /etc/hotplug.d/iface/35-luci-app-openvpn-plus-bootup
# Only trigger this script when an interface finishes initializing
if [ "$ACTION" != "ifup" ]; then
exit 0
fi
# STRIP LOOPBACK: Ignore the internal system loopback to prevent a premature trigger
if [ "$INTERFACE" = "loopback" ]; then
exit 0
fi
LOCK_FILE="/var/run/luci-app-openvpn-plus-bootup.lock"
# Mutex implementation preventing parallel execution threads from racing each other
if [ -f "$LOCK_FILE" ]; then
# Clean verification via signal 0: check if the holding PID is still alive
if kill -0 $(cat "$LOCK_FILE" 2>/dev/null) 2>/dev/null; then
exit 0
fi
fi
# Register active PID into the lock file to serialize the bootup checks
echo "$$" > "$LOCK_FILE"
# Set traps to delete the lock file reliably on unexpected script exits
trap "rm -f $LOCK_FILE" EXIT INT TERM
logger -t openvpn-luci "Bootup: Interface '${INTERFACE}' is up. Checking active OpenVPN profile running state in 10 seconds..."
# Give the system firewall and routing tables enough time to init
sleep 10
# Loop through all configured openvpn sections inside the UCI configuration
uci -q show openvpn | grep "=openvpn" | while read -r line; do
# Extract the pure instance identity string from the current UCI line
inst="${line##*.}"
inst="${inst%%=*}"
# Process the instance only if it is explicitly enabled by the user
if [ "$(uci -q get openvpn.${inst}.enabled)" = "1" ]; then
# Bulletproof process check utilizing the immune syslog tag pattern
if ! ps | grep -v "grep" | grep -q "openvpn(${inst})"; then
logger -t openvpn-luci "Bootup: Detected unstarted instance '${inst}'. Initializing process."
# Restart the service cleanly to clear out any dead zombie states or locked ports
/etc/init.d/openvpn stop "$inst" >/dev/null 2>&1
sleep 1 >/dev/null 2>&1
/etc/init.d/openvpn restart "$inst" >/dev/null 2>&1
sleep 1 >/dev/null 2>&1
/etc/init.d/openvpn reload "$inst" >/dev/null 2>&1
else
logger -t openvpn-luci "Bootup: Instance '${inst}' is already running safely."
fi
fi
done
# Delete the lock file manually after successful execution
rm -f "$LOCK_FILE"
# Unregister and reset all traps to system default values before exiting
trap - EXIT INT TERM
exit 0
@@ -0,0 +1,111 @@
#!/bin/sh
# Copyright (C) 2008-2026 The OpenWrt Project
# Copyright (C) 2026 Manfred Jaider <info@masmbit.com>
#
# This is free software, licensed under the Apache License, Version 2.0.
# See /LICENSE for more information.
#
# luci-app-openvpn-plus : OpenWrt hotplug lifecycle manager for ddns server and client common name
# /etc/hotplug.d/openvpn/35-luci-app-openvpn-plus-lifecycle
# Ensure we catch the active OpenVPN name context across all OpenWrt variants
ovpn_instance="${INSTANCE:-$NAME}"
# Fallback to $1 if $dev is empty depending on the environment context
ovpn_dev="${dev:-$1}"
if [ -z "$ovpn_instance" ]; then
exit 0
fi
CONF_FILE="/etc/openvpn/luci/${ovpn_instance}.conf"
PID_FILE="/var/run/openvpn-ddns-${ovpn_instance}.pid"
case "$ACTION" in
up)
inst_role=$(uci -q get openvpn."$ovpn_instance".role)
# Save the active interface name into the OpenWrt UCI configuration
if [ "$inst_role" = "server" ] || [ "$inst_role" = "client" ]; then
if [ -n "$ovpn_dev" ]; then
current_devname=$(uci -q get openvpn."$ovpn_instance".devname)
if [ "$current_devname" != "$ovpn_dev" ]; then
uci -q set openvpn."$ovpn_instance".devname="$ovpn_dev"
uci commit openvpn
logger -t openvpn-luci "Hotplug (${ovpn_instance}): set devname ${ovpn_dev}"
fi
fi
fi
# SERVER: DDNS SERVICE START
if [ "$inst_role" = "server" ]; then
# Stop any old DDNS service processes if they are still running
if start-stop-daemon -K -q -p "$PID_FILE" -t 2>/dev/null; then
logger -t openvpn-luci "Hotplug: Stopping active DDNS service process for ${ovpn_instance} before configuration reload."
start-stop-daemon -K -q -p "$PID_FILE" 2>/dev/null
fi
rm -f "$PID_FILE"
# Process the new configuration only if the file exists
if [ -f "$CONF_FILE" ]; then
domain=$(grep -E "^setenv[[:space:]]+DDNS[[:space:]]+" "$CONF_FILE" | sed -E 's/^setenv[[:space:]]+DDNS[[:space:]]+"(.*)"/\1/')
url=$(grep -E "^setenv[[:space:]]+DDNS_URL[[:space:]]+" "$CONF_FILE" | sed -E 's/^setenv[[:space:]]+DDNS_URL[[:space:]]+"(.*)"/\1/')
if [ -n "$domain" ] && [ -n "$url" ]; then
logger -t openvpn-luci "Hotplug: Starting DDNS background service for ${ovpn_instance} (Domain: ${domain})"
start-stop-daemon -S -b -m -p "$PID_FILE" -x /usr/libexec/luci-app-openvpn-plus -- ddnsservice "$domain" "$url" "$ovpn_instance"
fi
fi
# CLIENT: EXTRACT COMMON NAME
elif [ "$inst_role" = "client" ]; then
cn_update="0"
# Extract the last connection initiation line from logread for this specific instance
log_line=$(logread | grep "openvpn(${ovpn_instance})" | grep "Peer Connection Initiated" | tail -n 1)
if [ -n "$log_line" ]; then
# Parse the pure CN string from inside the brackets (Example: [OfficeServer] -> OfficeServer)
server_cn=$(echo "$log_line" | sed -n 's/.*: \[\([^]]*\)\].*Peer Connection Initiated.*/\1/p')
if [ -n "$server_cn" ]; then
# Write the resolved Common Name safely into the OpenWrt UCI configuration
uci -q set openvpn."$ovpn_instance".server_common_name="$server_cn"
uci commit openvpn
cn_update="1"
fi
fi
if [ "$cn_update" = "1" ]; then
logger -t openvpn-luci "Hotplug (${ovpn_instance}): Server Common Name '${server_cn}' resolved"
else
logger -t openvpn-luci "Hotplug (${ovpn_instance}): Server Common Name not found in Peer Connection log entry"
fi
fi
;;
down)
inst_role=$(uci -q get openvpn."$ovpn_instance".role)
# Clear the mapped network device because the tunnel is offline
if [ "$inst_role" = "server" ] || [ "$inst_role" = "client" ]; then
uci -q delete openvpn."$ovpn_instance".devname
uci commit openvpn
fi
# SERVER: DDNS SERVICE STOP
if [ "$inst_role" = "server" ]; then
# Clean application stop via standard system manager signals
if start-stop-daemon -K -q -p "$PID_FILE" -t 2>/dev/null; then
logger -t openvpn-luci "Hotplug: Stopping DDNS service for ${ovpn_instance} because tunnel is down."
start-stop-daemon -K -q -p "$PID_FILE" 2>/dev/null
fi
rm -f "$PID_FILE"
fi
;;
esac
exit 0
@@ -0,0 +1,37 @@
# Modern OpenVPN Client Configuration Instance
# --- Connection Mode ---
client
dev tun
proto udp
# --- Network & IP Management ---
remote 127.0.0.1 1194
float
# --- Tuning & Optimization ---
explicit-exit-notify 1
mssfix 1360
tun-mtu 1420
# --- Certificates & Keys ---
ca /etc/openvpn/keys/ca_default.crt
cert /etc/openvpn/keys/client_default.crt
key /etc/openvpn/keys/client_default.key
# --- Hardening & Security ---
tls-crypt-v2 /etc/openvpn/keys/client_default.tls2.key
tls-groups secp256r1
cipher AES-256-GCM
data-ciphers AES-256-GCM:AES-128-GCM:CHACHA20-POLY1305
# --- Verification & Logging ---
status /tmp/run/openvpn.instance.status 5
status-version 1
setenv logfilter on
connect-retry 10 60
resolv-retry infinite
nobind
persist-tun
remote-cert-tls server
verb 3
+158
View File
@@ -0,0 +1,158 @@
#!/bin/sh
# Copyright (C) 2008-2026 The OpenWrt Project
# Copyright (C) 2026 Manfred Jaider <info@masmbit.com>
#
# This is free software, licensed under the Apache License, Version 2.0.
# See /LICENSE for more information.
#
# luci-app-openvpn : smart firewall rules
# USEFUL COMMANDS FOR FIREWALL MANAGEMENT & LIVE MONITORING
#
# * Core Firewall Controls
# - Restart entire firewall framework: /etc/init.d/firewall restart
# - Reload UCI configuration profiles: /etc/init.d/firewall reload
# - View permanent firewall settings: cat /etc/config/firewall
#
# * Monitoring Chains & Structure
# - View entire dynamic fw4 table: nft -a list table inet fw4
# - View main input chain with handles: nft -a list chain inet fw4 input
# - View main forward chain with handles: nft -a list chain inet fw4 forward
# - View your custom OpenVPN sub-chain: nft -a list chain inet fw4 ovpn_filter_instance0
# - Quick view of custom sub-chain: nft -a list chain inet fw4 ovpn_filter_instance0 | sed -E -n 's/.*(openvpn[^"]+).*counter (packets [0-9]+ bytes [0-9]+).*(# handle [0-9]+).*/\3 -> "\1" counter \2/p'
#
# * Live Security & Attacker Tracking (The most useful commands)
# - View active IPv4 blocked IPs & timers: nft list set inet fw4 ovpn_block_ipv4_instance0
# - Flush/Clear a specific ban list now: nft flush set inet fw4 ovpn_block_ipv4_instance0
fwPort=443
fwProto="tcp"
SUB_CHAIN="ovpn_filter_instance0"
# STEP 1: CLEAN UP OLD RULES AND SETS (Prevents duplicates and memory leaks)
# Find and delete the old jump rule handle inside the main input chain to unlock the sub-chain
oldJumpHandle=$(nft -a list chain inet fw4 input | grep "jump $SUB_CHAIN" | grep -oE 'handle [0-9]+' | tail -n 1 | awk '{print $2}')
if [ -n "$oldJumpHandle" ]; then
nft "delete rule inet fw4 input handle $oldJumpHandle"
fi
# Now the sub-chain is unlocked and can be safely deleted without resource busy errors
nft "flush chain inet fw4 $SUB_CHAIN" 2>/dev/null
nft "delete chain inet fw4 $SUB_CHAIN" 2>/dev/null
nft "flush set inet fw4 ovpn_block_ipv4_instance0" 2>/dev/null
nft "delete set inet fw4 ovpn_block_ipv4_instance0" 2>/dev/null
nft "flush set inet fw4 ovpn_block_ipv6_instance0" 2>/dev/null
nft "delete set inet fw4 ovpn_block_ipv6_instance0" 2>/dev/null
# STEP 2: BUILD FRESH NAMED SETS WITH AUTOMATIC TIMEOUT
nft "add set inet fw4 ovpn_block_ipv4_instance0 { type ipv4_addr ; flags timeout ; }"
nft "add set inet fw4 ovpn_block_ipv6_instance0 { type ipv6_addr ; flags timeout ; }"
# STEP 3: CREATE THE CLEAN SUB-CHAIN CONTAINER
nft "add chain inet fw4 $SUB_CHAIN"
# STEP 4: BUILD THE CASCADE RULES (based on protocol)
if [ "$fwProto" = "tcp" ]; then
# TCP-S1: Instantly rejects traffic from IPv4/IPv6 addresses listed in the block sets.
nft "add rule inet fw4 $SUB_CHAIN tcp dport $fwPort ct packets < 12 ip saddr @ovpn_block_ipv4_instance0 log prefix \"openvpn(instance0)[firewall]{ban}: \" counter reject with tcp reset"
nft "add rule inet fw4 $SUB_CHAIN tcp dport $fwPort ct packets < 12 ip6 saddr @ovpn_block_ipv6_instance0 log prefix \"openvpn(instance0)[firewall]{ban}: \" counter reject with tcp reset"
# TCP-S2: Blocks fragmented TCP packets before they can cause buffer overflows.
nft "add rule inet fw4 $SUB_CHAIN tcp dport $fwPort ct packets < 12 meta nfproto ipv4 ip frag-off & 0x3fff != 0 log prefix \"openvpn(instance0)[firewall]{fragment}: \" counter reject with tcp reset"
nft "add rule inet fw4 $SUB_CHAIN tcp dport $fwPort ct packets < 12 meta nfproto ipv6 exthdr frag exists log prefix \"openvpn(instance0)[firewall]{fragment}: \" counter reject with tcp reset"
# TCP-S3: Validates strict TCP headers and forces standard handshake behaviors.
# - TCP Timestamps is required (This forces the TCP header size to be exactly 32 bytes for every data packets)
# - SYN packets must have maxseg, window and sack-perm option.
# - Data packets are forbidden to have the maxseg option.
nft "add rule inet fw4 $SUB_CHAIN tcp dport $fwPort ct packets < 12 meta nfproto ipv4 tcp option timestamp missing log prefix \"openvpn(instance0)[firewall]{timestamp}: \" counter update @ovpn_block_ipv4_instance0 { ip saddr timeout 5m } reject with tcp reset"
nft "add rule inet fw4 $SUB_CHAIN tcp dport $fwPort ct packets < 12 meta nfproto ipv4 tcp flags & (syn) == syn tcp option maxseg missing log prefix \"openvpn(instance0)[firewall]{maxseg}: \" counter update @ovpn_block_ipv4_instance0 { ip saddr timeout 5m } reject with tcp reset"
nft "add rule inet fw4 $SUB_CHAIN tcp dport $fwPort ct packets < 12 meta nfproto ipv4 tcp flags & (syn) == 0 tcp option maxseg exists log prefix \"openvpn(instance0)[firewall]{maxseg}: \" counter update @ovpn_block_ipv4_instance0 { ip saddr timeout 5m } reject with tcp reset"
nft "add rule inet fw4 $SUB_CHAIN tcp dport $fwPort ct packets < 12 meta nfproto ipv4 tcp flags & (syn) == syn tcp option window missing log prefix \"openvpn(instance0)[firewall]{wscale}: \" counter update @ovpn_block_ipv4_instance0 { ip saddr timeout 5m } reject with tcp reset"
nft "add rule inet fw4 $SUB_CHAIN tcp dport $fwPort ct packets < 12 meta nfproto ipv4 tcp flags & (syn) == syn tcp option sack-perm missing log prefix \"openvpn(instance0)[firewall]{sack}: \" counter update @ovpn_block_ipv4_instance0 { ip saddr timeout 5m } reject with tcp reset"
nft "add rule inet fw4 $SUB_CHAIN tcp dport $fwPort ct packets < 12 meta nfproto ipv6 tcp option timestamp missing log prefix \"openvpn(instance0)[firewall]{timestamp}: \" counter update @ovpn_block_ipv6_instance0 { ip6 saddr timeout 5m } reject with tcp reset"
nft "add rule inet fw4 $SUB_CHAIN tcp dport $fwPort ct packets < 12 meta nfproto ipv6 tcp flags & (syn) == syn tcp option maxseg missing log prefix \"openvpn(instance0)[firewall]{maxseg}: \" counter update @ovpn_block_ipv6_instance0 { ip6 saddr timeout 5m } reject with tcp reset"
nft "add rule inet fw4 $SUB_CHAIN tcp dport $fwPort ct packets < 12 meta nfproto ipv6 tcp flags & (syn) == 0 tcp option maxseg exists log prefix \"openvpn(instance0)[firewall]{maxseg}: \" counter update @ovpn_block_ipv6_instance0 { ip6 saddr timeout 5m } reject with tcp reset"
nft "add rule inet fw4 $SUB_CHAIN tcp dport $fwPort ct packets < 12 meta nfproto ipv6 tcp flags & (syn) == syn tcp option window missing log prefix \"openvpn(instance0)[firewall]{wscale}: \" counter update @ovpn_block_ipv6_instance0 { ip6 saddr timeout 5m } reject with tcp reset"
nft "add rule inet fw4 $SUB_CHAIN tcp dport $fwPort ct packets < 12 meta nfproto ipv6 tcp flags & (syn) == syn tcp option sack-perm missing log prefix \"openvpn(instance0)[firewall]{sack}: \" counter update @ovpn_block_ipv6_instance0 { ip6 saddr timeout 5m } reject with tcp reset"
# TCP-S4: Catches smart bots that delay data transmission to bypass early scanners.
# * CRITICAL MINIMUM DATA SIZES (Worst-Case Real OpenVPN Client):
# - Smallest possible tls-crypt-v2 key payload from a real client: 264 Bytes.
# - IPv4: Handshake (Packets 1-3) + Key (Packet 4) = 488 Bytes minimum.
# If network reception is very bad up to Packet 10 (empty ACKs): 748 Bytes total min.
# - IPv6: Handshake (Packets 1-3) + Key (Packet 4) = 548 Bytes minimum.
# If network reception is very bad up to Packet 10 (empty ACKs): 928 Bytes total min.
# * SAFE LOCKOUT THRESHOLDS (How the firewall decides):
# - IPv4 limit is set to < 700 Bytes. Bots with no data are smaller and get killed.
# - IPv6 limit is set to < 900 Bytes. Bots with no data are smaller and get killed.
# - Real worst-case OpenVPN users always stay safely above this byte radar.
nft "add rule inet fw4 $SUB_CHAIN tcp dport $fwPort ct packets < 12 ct packets > 9 meta nfproto ipv4 ct bytes < 700 log prefix \"openvpn(instance0)[firewall]{stealth}: \" counter update @ovpn_block_ipv4_instance0 { ip saddr timeout 5m } reject with tcp reset"
nft "add rule inet fw4 $SUB_CHAIN tcp dport $fwPort ct packets < 12 ct packets > 9 meta nfproto ipv6 ct bytes < 900 log prefix \"openvpn(instance0)[firewall]{stealth}: \" counter update @ovpn_block_ipv6_instance0 { ip6 saddr timeout 5m } reject with tcp reset"
# TCP-S5: Blocks payload data hidden inside the very first SYN packet (80 bytes for IPv4 packets and max 100 bytes for IPv6)
nft "add rule inet fw4 $SUB_CHAIN tcp dport $fwPort ct packets < 12 meta nfproto ipv4 tcp flags & (syn) == syn tcp option maxseg exists meta length > 80 log prefix \"openvpn(instance0)[firewall]{fastopen}: \" counter update @ovpn_block_ipv4_instance0 { ip saddr timeout 5m } reject with tcp reset"
nft "add rule inet fw4 $SUB_CHAIN tcp dport $fwPort ct packets < 12 meta nfproto ipv6 tcp flags & (syn) == syn tcp option maxseg exists meta length > 100 log prefix \"openvpn(instance0)[firewall]{fastopen}: \" counter update @ovpn_block_ipv6_instance0 { ip6 saddr timeout 5m } reject with tcp reset"
# TCP-S6: Deep Packet Inspection for bad protocol lengths and fake small sizes.
# - Starts only when a data packet is bigger than 53 bytes (IPv4) or 73 bytes (IPv6).
# - Reads the 16-bit OpenVPN packet length field at the exact bit offset pointer 256.
# - Catches bad opcodes, web text (like GET/POST), and fake sizes (payload < 155, payload > 1499).
# - Real users with tls-crypt-v2 are safe because encrypted traffic never matches this bad matrix.
nft "add rule inet fw4 $SUB_CHAIN tcp dport $fwPort ct packets < 12 meta nfproto ipv4 tcp flags & (syn) == 0 meta length > 53 @th,256,16 < 0x009b log prefix \"openvpn(instance0)[firewall]{payload}: \" counter update @ovpn_block_ipv4_instance0 { ip saddr timeout 5m } reject with tcp reset"
nft "add rule inet fw4 $SUB_CHAIN tcp dport $fwPort ct packets < 12 meta nfproto ipv4 tcp flags & (syn) == 0 meta length > 53 @th,256,16 > 0x05db log prefix \"openvpn(instance0)[firewall]{payload}: \" counter update @ovpn_block_ipv4_instance0 { ip saddr timeout 5m } reject with tcp reset"
nft "add rule inet fw4 $SUB_CHAIN tcp dport $fwPort ct packets < 12 meta nfproto ipv6 tcp flags & (syn) == 0 meta length > 73 @th,256,16 < 0x009b log prefix \"openvpn(instance0)[firewall]{payload}: \" counter update @ovpn_block_ipv6_instance0 { ip6 saddr timeout 5m } reject with tcp reset"
nft "add rule inet fw4 $SUB_CHAIN tcp dport $fwPort ct packets < 12 meta nfproto ipv6 tcp flags & (syn) == 0 meta length > 73 @th,256,16 > 0x05db log prefix \"openvpn(instance0)[firewall]{payload}: \" counter update @ovpn_block_ipv6_instance0 { ip6 saddr timeout 5m } reject with tcp reset"
# TCP-S7: Packets that survived all previous checks are genuine.
nft "add rule inet fw4 $SUB_CHAIN tcp dport $fwPort accept"
else
# UDP-S1: Instantly drops traffic from IPv4/IPv6 addresses listed in the block sets.
# (Bleibt bei ct state new, damit bestehender established Tunnel-Traffic niemals blockiert wird!)
nft "add rule inet fw4 $SUB_CHAIN udp dport $fwPort ct state new ip saddr @ovpn_block_ipv4_instance0 log prefix \"openvpn(instance0)[firewall]{ban}: \" counter drop"
nft "add rule inet fw4 $SUB_CHAIN udp dport $fwPort ct state new ip6 saddr @ovpn_block_ipv6_instance0 log prefix \"openvpn(instance0)[firewall]{ban}: \" counter drop"
# UDP-S2: Blocks fragmented UDP packets before they can cause buffer overflows.
nft "add rule inet fw4 $SUB_CHAIN udp dport $fwPort ct state new meta nfproto ipv4 ip frag-off & 0x3fff != 0 log prefix \"openvpn(instance0)[firewall]{fragment}: \" counter drop"
nft "add rule inet fw4 $SUB_CHAIN udp dport $fwPort ct state new meta nfproto ipv6 exthdr frag exists log prefix \"openvpn(instance0)[firewall]{fragment}: \" counter drop"
# UDP-S3: Smallest valid OpenVPN packet is 48 bytes. Anything smaller is dropped.
nft "add rule inet fw4 $SUB_CHAIN udp dport $fwPort ct state new udp length < 48 log prefix \"openvpn(instance0)[firewall]{short}: \" counter drop"
# UDP-S4: Drops and bans new IPv4 bots that scan with a zero checksum to save time.
nft "add rule inet fw4 $SUB_CHAIN udp dport $fwPort ct state new meta nfproto ipv4 udp checksum 0 log prefix \"openvpn(instance0)[firewall]{chksum}: \" counter update @ovpn_block_ipv4_instance0 { ip saddr timeout 5m } drop"
# UDP-S5: Whitelist valid initial OpenVPN opcodes P_CONTROL_HARD_RESET_CLIENT_V1,2,3 (skipping the 64-bit UDP header)
nft "add rule inet fw4 $SUB_CHAIN udp dport $fwPort ct state new meta nfproto ipv4 @th,64,8 & 0xf8 == 0x08 udp length > 250 accept"
nft "add rule inet fw4 $SUB_CHAIN udp dport $fwPort ct state new meta nfproto ipv4 @th,64,8 & 0xf8 == 0x38 udp length > 250 accept"
nft "add rule inet fw4 $SUB_CHAIN udp dport $fwPort ct state new meta nfproto ipv4 @th,64,8 & 0xf8 == 0x50 udp length > 250 accept"
nft "add rule inet fw4 $SUB_CHAIN udp dport $fwPort ct state new meta nfproto ipv6 @th,64,8 & 0xf8 == 0x08 udp length > 250 accept"
nft "add rule inet fw4 $SUB_CHAIN udp dport $fwPort ct state new meta nfproto ipv6 @th,64,8 & 0xf8 == 0x38 udp length > 250 accept"
nft "add rule inet fw4 $SUB_CHAIN udp dport $fwPort ct state new meta nfproto ipv6 @th,64,8 & 0xf8 == 0x50 udp length > 250 accept"
# UDP-S6: Instantly ban and drop any other packet that tries to start a new connection
nft "add rule inet fw4 $SUB_CHAIN udp dport $fwPort ct state new meta nfproto ipv4 log prefix \"openvpn(instance0)[firewall]{opcode}: \" counter update @ovpn_block_ipv4_instance0 { ip saddr timeout 5m } drop"
nft "add rule inet fw4 $SUB_CHAIN udp dport $fwPort ct state new meta nfproto ipv6 log prefix \"openvpn(instance0)[firewall]{opcode}: \" counter update @ovpn_block_ipv6_instance0 { ip6 saddr timeout 5m } drop"
# UDP-S7: Packets that survived all previous checks are genuine.
nft "add rule inet fw4 $SUB_CHAIN udp dport $fwPort accept"
fi
# STEP 4: LINK THE SUB-CHAIN TO THE MAIN INPUT (single jump rule)
# Find the starting anchor in the main input chain (Loopback handle)
mainAnchorHandle=$(nft -a list chain inet fw4 input | grep -E 'iif ["'\'']?lo["'\'']? accept' | grep -oE 'handle [0-9]+' | tail -n 1 | awk '{print $2}')
if [ -z "$mainAnchorHandle" ]; then
mainAnchorHandle=35
fi
# Inject the dynamic jump trigger right below the loopback anchor rule
nft "add rule inet fw4 input handle $mainAnchorHandle $fwProto dport $fwPort jump $SUB_CHAIN"
@@ -0,0 +1,47 @@
# Modern OpenVPN Server Configuration Instance
# --- Connection Mode ---
dev tun
proto udp
# --- Network & IP Management ---
port 1194
setenv portextern 1194
server 10.8.0.0 255.255.255.0
server-ipv6 fd00:db8:0:1::/64
topology subnet
# --- Tuning & Optimization ---
tls-timeout 5
mssfix 1360
tun-mtu 1420
# Increased max-clients absorbs attacks and client roaming
max-clients 4096
# --- Certificates & Keys ---
ca /etc/openvpn/keys/ca_default.crt
cert /etc/openvpn/keys/server_default.crt
key /etc/openvpn/keys/server_default.key
dh /etc/openvpn/keys/dh_default.pem
# --- Hardening & Security ---
tls-crypt-v2 /etc/openvpn/keys/server_default.tls2.key
tls-groups secp256r1
cipher AES-256-GCM
data-ciphers AES-256-GCM:AES-128-GCM:CHACHA20-POLY1305
# --- Smart Firewall ---
setenv ovpnfirewall on
# --- Client Push Options ---
push "dhcp-option DNS 9.9.9.9"
push "dhcp-option DNS 149.112.112.112"
push "dhcp-option DNS6 2620:fe::fe"
push "dhcp-option DNS6 2620:fe::9"
# --- Verification & Logging ---
status /tmp/run/openvpn.instance.status 5
status-version 1
setenv logfilter on
keepalive 10 60
verb 3
@@ -0,0 +1,70 @@
#!/bin/sh
# Copyright (C) 2008-2026 The OpenWrt Project
# Copyright (C) 2026 Manfred Jaider <info@masmbit.com>
#
# This is free software, licensed under the Apache License, Version 2.0.
# See /LICENSE for more information.
#
# luci-app-openvpn-plus : clean default sample profiles and prepare empty configuration
# /etc/uci-defaults/35_luci-app-openvpn-plus
# Path to the UCI configuration file
CONFIG_FILE="${IPKG_INSTROOT}/etc/config/openvpn"
if [ -f "$CONFIG_FILE" ]; then
# 1. Verify that all three standard firmware sample entries exist exactly as delivered
if grep -q "config openvpn custom_config" "$CONFIG_FILE" 2>/dev/null && \
grep -q "config openvpn sample_server" "$CONFIG_FILE" 2>/dev/null && \
grep -q "config openvpn sample_client" "$CONFIG_FILE" 2>/dev/null; then
# 2. Check specifically for the UCI section pattern created by LuCI
if ! grep -q "config openvpn 'instance" "$CONFIG_FILE" 2>/dev/null; then
logger -t luci-app-openvpn-plus "Unmodified factory samples detected. Backing up to openvpn-def."
# Backup the cluttered firmware default file safely
cp -f "$CONFIG_FILE" "${IPKG_INSTROOT}/etc/config/openvpn-def" 2>/dev/null
# Create a clean, empty base configuration for your LuCI app
echo "# Clean OpenVPN base configuration" > "$CONFIG_FILE"
CLEAN_OPENVPN_SERVICE=1
fi
else
# If any check fails, the user has modified the file. DO NOT TOUCH IT!
logger -t luci-app-openvpn-plus "Modified or custom OpenVPN configuration detected. Keeping user data safe."
fi
# 3. Check if we are running on a live runtime system (opkg install / apk add)
if [ -z "${IPKG_INSTROOT}" ]; then
logger -t luci-app-openvpn-plus "Package installed at runtime. Clearing all cache and reloading services."
# Restart rpcd in the background after 3 seconds! This gives LuCI enough time to finish the installation in the browser.
(
# Restart the openvpn service only if we cleaned the config above
if [ "$CLEAN_OPENVPN_SERVICE" = "1" ] && [ -x "/etc/init.d/openvpn" ]; then
/etc/init.d/openvpn stop >/dev/null 2>&1
sleep 1
/etc/init.d/openvpn restart >/dev/null 2>&1
sleep 1
/etc/init.d/openvpn reload >/dev/null 2>&1
sleep 1
else
sleep 3
fi
# Delete ALL temporary LuCI files.
rm -rf /tmp/luci-* >/dev/null 2>&1
# Hard reset rpcd to finds the VPN menu and new ACL files.
if [ -x "/etc/init.d/rpcd" ]; then
/etc/init.d/rpcd restart >/dev/null 2>&1
fi
) &
else
logger -t luci-app-openvpn-plus "Image building state detected. Skipping live runtime triggers."
fi
fi
exit 0
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,9 @@
{
"admin/vpn/openvpn": {
"title": "OpenVPN",
"action": { "type": "view", "path": "vpn/openvpn" },
"order": 10,
"depends": { "acl": [ "luci-app-openvpn-plus" ] }
}
}
@@ -0,0 +1,19 @@
{
"luci-app-openvpn-plus-status": {
"description": "Status access for OpenVPN LuCI app",
"read": {
"cgi-bin": [ "luci" ],
"uci": [ "openvpn" ],
"ubus": {
"system": [ "info" ],
"service": [ "list" ],
"network": [ "status" ]
},
"file": {
"/etc/openvpn/luci/*": [ "read" ],
"/proc/uptime": [ "read" ],
"/usr/libexec/luci-app-openvpn-plus": [ "exec" ]
}
}
}
}
@@ -0,0 +1,39 @@
{
"luci-app-openvpn-plus": {
"description": "Configuration access for OpenVPN LuCI app",
"read": {
"cgi-bin": [ "luci" ],
"uci": [ "openvpn" ],
"ubus": {
"system": [ "info" ],
"service": [ "list" ],
"network": [ "status" ]
},
"file": {
"/etc/openvpn/luci/*": [ "read" ],
"/etc/openvpn/keys/*": [ "read" ],
"/var/run/openvpn.keygen.lock": [ "read" ],
"/proc/uptime": [ "read" ],
"/tmp/openvpn.keygen.log": [ "read" ],
"/tmp/openvpn.keygen.ca.tmp": [ "read" ],
"/tmp/openvpn.keygen.server-key.tmp": [ "read" ],
"/tmp/openvpn.keygen.client-key.tmp": [ "read" ],
"/tmp/openvpn.keygen.server-cert.tmp": [ "read" ],
"/tmp/openvpn.keygen.client-cert.tmp": [ "read" ],
"/tmp/openvpn.keygen.dh.tmp": [ "read" ],
"/tmp/openvpn.keygen.server-tls2.tmp": [ "read" ],
"/tmp/openvpn.keygen.client-tls2.tmp": [ "read" ],
"/sbin/logread": [ "exec" ],
"/usr/libexec/luci-app-openvpn-plus": [ "exec" ]
}
},
"write": {
"uci": [ "openvpn" ],
"file": {
"/etc/openvpn/luci/*": [ "write" ],
"/etc/openvpn/keys/*": [ "write" ],
"/etc/config/openvpn": [ "write" ]
}
}
}
}
Loaded 100 of 105 files, more files were not shown because too many files have changed in this diff. Show more