⛅ Sync 2026-10-11 13:16:00
No files matched your search
@@ -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,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 {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
node_modules/
|
||||
*.ipk
|
||||
*.apk
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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 ""
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
@@ -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), []);
|
||||
});
|
||||
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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])
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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" ]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||