🚀 Sync 2026-11-14 15:02:09

This commit is contained in:
github-actions[bot] committed 2026-11-14 15:02:09 +08:00
1 parent 90f27ab347
commit 9f5777add1
44 files changed
+3970 -5

No files matched your search

+2 -2
View File
@@ -1,8 +1,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=filebrowser-q
PKG_VERSION:=2.0.2-stable
PKG_RELEASE:=17
PKG_VERSION:=2.1.5-beta
PKG_RELEASE:=18
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/gtsteffaniak/filebrowser/tar.gz/v$(PKG_VERSION)?
+3 -3
View File
@@ -5,11 +5,11 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=honk
PKG_VERSION:=2026.11.13~f8bbf4f
PKG_RELEASE:=36
PKG_VERSION:=2026.11.13~d9d401e
PKG_RELEASE:=37
HONK_SRC_URL:=https://github.com/daeuniverse/honk.git
HONK_SRC_VERSION:=f8bbf4f4b4ab1cdf673c440df316c3c642a31f9c
HONK_SRC_VERSION:=d9d401e8dd4cfcbed84cff147b29fb8b2bb0c9b4
PKG_LICENSE:=AGPL-3.0-only
PKG_LICENSE_FILES:=LICENSE
+3
View File
@@ -0,0 +1,3 @@
.DS_Store
*.swp
/tmp/
+202
View File
@@ -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.
+40
View File
@@ -0,0 +1,40 @@
# SPDX-License-Identifier: Apache-2.0
#
# 7Dollar — an elegant, lightweight LuCI theme.
# Drop this directory into feeds/luci/themes/ (or package/) of an OpenWrt
# buildroot / SDK and select it under LuCI -> Themes.
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-theme-7dollar
PKG_VERSION:=1.0.16
PKG_RELEASE:=1
LUCI_TITLE:=7Dollar — an elegant, lightweight LuCI theme
LUCI_DESCRIPTION:=A calm, card based LuCI theme with five palettes, light/dark mode, \
a friendly home dashboard and grouped navigation. Built for OpenWrt 23.05+ (ucode LuCI).
LUCI_DEPENDS:=+luci-base
LUCI_PKGARCH:=all
PKG_LICENSE:=Apache-2.0
PKG_MAINTAINER:=itsmaiGe <https://github.com/itsmaiGe>
define Package/luci-theme-7dollar/conffiles
/etc/config/7dollar
endef
define Package/luci-theme-7dollar/postrm
#!/bin/sh
[ "$$1" = upgrade ] && exit 0
[ -n "$${IPKG_INSTROOT}" ] || {
uci -q delete luci.themes.7Dollar
[ "$$(uci -q get luci.main.mediaurlbase)" = "/luci-static/7dollar" ] && \
uci -q set luci.main.mediaurlbase=/luci-static/bootstrap
uci commit luci
rm -rf /tmp/luci-indexcache* /tmp/luci-modulecache/
}
endef
include $(TOPDIR)/feeds/luci/luci.mk
# call BuildPackage - OpenWrt buildroot signature
+223
View File
@@ -0,0 +1,223 @@
<p align="center">
<img src="docs/logo.svg" width="72" alt="7Dollar">
</p>
<h1 align="center">7Dollar</h1>
<p align="center">
不像工程师后台的 OpenWrt 主题<br>
<sub>An OpenWrt theme that doesn't look like an engineer's console</sub>
</p>
<p align="center">
<img alt="OpenWrt" src="https://img.shields.io/badge/OpenWrt-23.05%20%7C%2024.10%20%7C%2025.12-D97757?style=flat-square">
<img alt="Size" src="https://img.shields.io/badge/size-48%20KB-8A7F72?style=flat-square">
<img alt="License" src="https://img.shields.io/badge/license-Apache--2.0-5B6F9A?style=flat-square">
</p>
<p align="center">
<a href="#安装">安装</a> · <a href="#使用">使用</a> · <a href="#卸载">卸载</a> · <a href="#english">English</a>
</p>
<p align="center">
<img src="docs/home-light.webp" alt="首页" width="100%">
</p>
## 简介
OpenWrt 的多数主题只调整了颜色,页面上仍是大量参数、表格和英文缩写,不熟悉网络的用户不容易看懂。7Dollar 重新组织了页面的呈现方式,让普通用户也能直接找到需要的信息和功能。
| | 常见主题 | 7Dollar |
| --- | --- | --- |
| 首页 | 参数表 | 网络状态、速率、在线设备集中显示 |
| 菜单 | 按技术模块分类:状态 / 系统 / 服务 / 网络 | 按使用场景分类:设备 / Wi‑Fi / 上网 / 安全 |
| 设备 | MAC 地址和 IP 列表 | 设备名称、类型、信号强度、连接时长 |
| 状态页 | 显示全部字段 | 默认显示常用信息,MAC、BSSID 等在「详情」中查看 |
| 用词 | 2g / 5g、DHCP 租约 | 2.4 GHz / 5 GHz、在线设备 |
| 查找功能 | 逐级打开菜单 | <kbd>⌘K</kbd> 搜索 |
LuCI 原有功能全部保留,只调整了显示方式。
## 名称由来
开发这个主题共花费了 **7 美元** 的 AI Token 费用,因此命名为 7Dollar。
## 功能
- **首页总览**:显示网络状态、实时速率、在线设备、Wi‑Fi、路由器状态、网口和存储空间。卡片可以单独开关,并自动分为两列排列
- **按用途分组的菜单**:总览 / 设备 / Wi‑Fi / 上网 / 安全 / 应用 / 系统,支持 <kbd>⌘K</kbd> / <kbd>Ctrl K</kbd> 搜索
- **5 套配色**:陶土、青瓷、雾蓝、暮紫、石墨,均有浅色和深色,可跟随系统设置
- **应用卡片(可选)**:默认关闭。开启后,首页列出正在运行的插件,适用于任意插件。常见插件会额外显示一项关键信息,例如 OpenClash 代理模式(可直接切换)、PassWall 当前节点、文件共享剩余空间。信息只从插件配置中读取,不调用插件接口
- **自适应布局**:在手机、平板和页面缩放时自动调整排版
- **轻量**:不加载外部字体,不发起外部请求;CSS 16 KB(gzip),安装包 48 KB
- **兼容原生页面**:LuCI 功能保持不变,原生图标统一为主题风格
- **信息分层**:Wi‑Fi、接口和状态页默认只显示常用信息,MAC、BSSID、数据包数等内容点击「详情」查看
## 截图
<p align="center"><img src="docs/palettes.webp" alt="五种配色" width="100%"></p>
<table>
<tr>
<td width="50%"><img src="docs/home-dark.webp" alt="深色模式"><p align="center"><sub>深色模式</sub></p></td>
<td width="50%"><img src="docs/mobile.webp" alt="手机"><p align="center"><sub>手机</sub></p></td>
</tr>
<tr>
<td width="50%"><img src="docs/login.webp" alt="登录页"><p align="center"><sub>登录页</sub></p></td>
<td width="50%"><img src="docs/search.webp" alt="搜索"><p align="center"><sub>搜索</sub></p></td>
</tr>
<tr>
<td width="50%"><img src="docs/appearance.webp" alt="外观设置"><p align="center"><sub>外观设置</sub></p></td>
<td width="50%"><img src="docs/native.webp" alt="原生页面"><p align="center"><sub>原生页面</sub></p></td>
</tr>
</table>
## 安装
安装前请确认路由器系统为 **OpenWrt 23.05 或更新版本**。版本号可在后台「状态 → 概览」中查看。
### 方法一:网页上传(推荐,无需命令行)
适用于 23.05 / 24.10。25.x 请使用方法二。
1. 下载安装包:[luci-theme-7dollar_1.0.16-r1_all.ipk](https://github.com/itsmaiGe/luci-theme-7dollar/releases/latest/download/luci-theme-7dollar_1.0.16-r1_all.ipk)(历史版本见 [Releases](https://github.com/itsmaiGe/luci-theme-7dollar/releases))
2. 在浏览器中打开路由器后台(通常为 `192.168.1.1`),输入密码登录
3. 打开 **系统 → 软件包 → 上传软件包…**,选择下载的文件,点击 **上传**,再点击 **安装**
4. 刷新页面(Windows 按 <kbd>F5</kbd>,Mac 按 <kbd>⌘ R</kbd>)
> 如果刷新后界面没有变化:打开 **系统 → 系统 → 语言和界面**,将 **主题** 设为 **7Dollar**,点击 **保存并应用**。
### 方法二:一行命令
适用于所有支持的版本。脚本会根据系统自动选择 ipk(23.05 / 24.10)或 apk(25.x)安装包。
1. 打开终端:Mac 上搜索「终端」,Windows 上搜索「PowerShell」
2. 登录路由器(将 IP 替换为你的路由器地址):
```sh
ssh root@192.168.1.1
```
首次连接出现 `yes/no` 提示时,输入 `yes` 并回车;然后输入路由器密码并回车。输入密码时屏幕不显示字符,这是正常现象。
3. 粘贴下面的命令并回车,出现「完成」后刷新浏览器:
```sh
wget -qO- https://raw.githubusercontent.com/itsmaiGe/luci-theme-7dollar/main/install.sh | sh
```
<details>
<summary><b>下载失败</b></summary>
<br>
如果访问 GitHub 不稳定,建议使用方法一:先在电脑上下载安装包,再上传到路由器。
也可以使用 GitHub 加速代理(将 `<代理地址>` 替换为实际地址):
```sh
wget -qO- <代理地址>/https://raw.githubusercontent.com/itsmaiGe/luci-theme-7dollar/main/install.sh | PROXY=<代理地址>/ sh
```
</details>
<details>
<summary><b>从源码编译</b></summary>
<br>
在 OpenWrt 源码或 SDK 中:
```sh
git clone https://github.com/itsmaiGe/luci-theme-7dollar.git feeds/luci/themes/luci-theme-7dollar
./scripts/feeds update -i && ./scripts/feeds install luci-theme-7dollar
make menuconfig # LuCI → Themes → luci-theme-7dollar
make package/luci-theme-7dollar/compile V=s
```
不使用 SDK 时,可在 macOS / Linux 上运行 `./scripts/build-ipk.sh` 生成 ipk(需要 Node.js,打包时用 esbuild 压缩 JS / CSS,源码不变);apk 由 GitHub Actions 在 OpenWrt 25.12 镜像中构建并测试安装(见 `.github/workflows/apk.yml`)。两者都输出到 `dist/`。
</details>
## 使用
| 操作 | 位置 |
| --- | --- |
| 切换配色 / 深浅色 | 系统 → 外观与首页(也可点击左下角 🌙 切换深浅色) |
| 调整首页卡片 | 系统 → 外观与首页 → 首页卡片 |
| 首页显示正在运行的插件 | 系统 → 外观与首页 → 首页卡片 → 应用 |
| 查看在线设备 | 侧边栏「设备」 |
| 搜索设置 | <kbd>⌘K</kbd> / <kbd>Ctrl K</kbd> / <kbd>/</kbd> |
| 打开已装插件 | 侧边栏「应用」 |
主题设置保存在 `/etc/config/7dollar`,只影响界面显示,不会修改网络配置。
## 卸载
- **切换为其他主题**:打开 系统 → 系统 → 语言和界面 → 主题,选择 Bootstrap 或其他主题,保存并应用
- **完全卸载**:打开 系统 → 软件包 → 已安装,搜索 `7dollar`,点击 **移除**。卸载后会自动切换为 Bootstrap
- **命令卸载**:
```sh
wget -qO- https://raw.githubusercontent.com/itsmaiGe/luci-theme-7dollar/main/uninstall.sh | sh
```
<details>
<summary><b>后台无法打开时</b></summary>
<br>
通过 SSH 登录路由器后执行以下命令,切换为默认主题:
```sh
uci set luci.main.mediaurlbase=/luci-static/bootstrap && uci commit luci
```
</details>
## 兼容性
| | |
| --- | --- |
| OpenWrt | 23.05、24.10(ipk),25.x(apk);不支持 22.03 及更早版本 |
| 浏览器 | Chrome / Edge 111+、Safari 16.4+、Firefox 113+(2023 年以后的版本) |
| 已测试 | Cudy TR3000 · OpenWrt 24.10.5 · MT7981B |
| 共存 | 可与 Bootstrap、Argon 等主题同时安装 |
## 目录结构
```
htdocs/luci-static/7dollar/ 样式、图标
htdocs/luci-static/resources/ 导航与页面脚本
ucode/ 页面框架、登录页
root/ 默认配置、菜单、权限
dist/ ipk / apk 安装包
install.sh / uninstall.sh 安装 / 卸载脚本
```
## 致谢
- [OpenWrt LuCI](https://github.com/openwrt/luci)
- [zashboard](https://github.com/Zephyruso/zashboard):多配色设计参考
- 默认配色参考 Claude 的橙色
## English
**7Dollar** is a LuCI theme for people who are not network engineers: it shows what matters (online or not, how fast, who's connected) instead of listing every field, groups menus by task (Devices, Wi‑Fi, Internet, Security), and keeps the technical details one *Details* click away. Nothing from LuCI is removed. It is a clean, card-based theme for OpenWrt 23.05+ (ucode LuCI), including apk-based 25.x. The name comes from its cost: **$7** in AI tokens.
- Home dashboard with connection status, live throughput, devices, Wi‑Fi and system health
- Menus grouped by purpose, with <kbd>⌘K</kbd> search
- 5 palettes (Clay, Celadon, Mist, Dusk, Graphite), each in light and dark
- Optional Apps card (off by default): lists whatever plugins are running. Popular ones get one line that matters (OpenClash proxy-mode switch, PassWall node, file-sharing free space…), read from their config, never from their API.
- Native LuCI pages restyled, icons included
- Status pages (Wi‑Fi, interfaces, overview) show the essentials; MACs, BSSIDs, packet counts and the like sit behind *Details*
- Responsive and zoom-friendly; no external fonts or requests; 48 KB package
**Install**: upload [the ipk](https://github.com/itsmaiGe/luci-theme-7dollar/releases/latest/download/luci-theme-7dollar_1.0.16-r1_all.ipk) via *System → Software → Upload Package* (23.05 / 24.10), or run over SSH on any version (picks the ipk or the [apk](https://github.com/itsmaiGe/luci-theme-7dollar/tree/main/dist) automatically):
```sh
wget -qO- https://raw.githubusercontent.com/itsmaiGe/luci-theme-7dollar/main/install.sh | sh
```
**Uninstall**: `opkg remove luci-theme-7dollar` / `apk del luci-theme-7dollar`, or the `uninstall.sh` one-liner.
## License
[Apache-2.0](LICENSE) © itsmaiGe
+1
View File
@@ -0,0 +1 @@
luci-theme-7dollar-1.0.16-r1.apk
+1
View File
@@ -0,0 +1 @@
luci-theme-7dollar_1.0.16-r1_all.ipk
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="b" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#2E2B27"/><stop offset="1" stop-color="#151413"/></linearGradient><linearGradient id="s" x1="20" y1="0" x2="80" y2="0" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#8E8BEA"/><stop offset=".45" stop-color="#E07A8E"/><stop offset="1" stop-color="#FFC9A0"/></linearGradient><radialGradient id="g"><stop offset="0" stop-color="#FFC9A0" stop-opacity=".55"/><stop offset="1" stop-color="#FFC9A0" stop-opacity="0"/></radialGradient></defs><rect width="100" height="100" rx="23" fill="url(#b)"/><path d="M22 60c10-26 20-26 28-8s18 18 28-12" fill="none" stroke="url(#s)" stroke-width="10" stroke-linecap="round"/><path d="M23.5 55c8-17 15-19 21-12" fill="none" stroke="#fff" stroke-opacity=".35" stroke-width="2.4" stroke-linecap="round"/><circle cx="78" cy="40" r="15" fill="url(#g)"/><circle cx="78" cy="40" r="5" fill="#FFF3E6"/></svg>

After

Width:  |  Height:  |  Size: 993 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

@@ -0,0 +1,842 @@
/*!
* 7Dollar — an elegant LuCI theme for OpenWrt
* Licensed under the Apache License 2.0
*/
/* ============================== tokens ============================== */
:root{
--serif:"Iowan Old Style","Songti SC","Noto Serif SC","Source Han Serif SC",Georgia,serif;
--sans:-apple-system,BlinkMacSystemFont,"PingFang SC","HarmonyOS Sans SC","Microsoft YaHei UI","Noto Sans SC","Segoe UI",Roboto,sans-serif;
--mono:ui-monospace,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace;
--ease:cubic-bezier(.2,.7,.2,1);--fast:.18s;--base:.28s;--slow:.45s;--data:.8s; /* motion: feedback / state change / entrance / data */
--chevron:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23958F83' stroke-width='1.6' stroke-linecap='round'%3E%3Cpath d='m3 4.5 3 3 3-3'/%3E%3C/svg%3E");
--r-lg:20px;--r-md:14px;--r-sm:10px;
--ok:#5F8A55;--ok-soft:#E1EAD8;--warn:#B98330;--warn-soft:#F4E7CF;--bad:#C0533E;--bad-soft:#F6DDD5;
--shadow:0 1px 1px rgba(20,20,20,.03),0 10px 30px -18px rgba(20,20,20,.18);
--shadow-pop:0 24px 60px -20px rgba(20,20,20,.35),0 2px 6px rgba(20,20,20,.06);
color-scheme:light;
}
[data-theme=dark]{--ok:#93AE82;--ok-soft:#2B3427;--warn:#D6A55A;--warn-soft:#3A3122;--bad:#E3826E;--bad-soft:#3E2621;--shadow:0 1px 1px rgba(0,0,0,.2),0 10px 30px -18px rgba(0,0,0,.6);--shadow-pop:0 24px 60px -20px rgba(0,0,0,.8);color-scheme:dark}
/* 1 陶土 clay */
:root,[data-palette=clay]{--canvas:#F3F1EA;--surface:#FBFAF6;--surface-2:#EFECE3;--hover:#E9E5DA;--line:#E2DDD1;--line-soft:#ECE8DE;--ink:#1F1E1B;--ink-2:#5B5850;--ink-3:#726D64;--accent:#D97757;--accent-2:#B74C29;--on-accent:#fff;--accent-soft:#F6E2D7;--tint:rgba(217,119,87,.10);--second:#8094A8}
[data-theme=dark][data-palette=clay],[data-theme=dark] [data-palette=clay]{--canvas:#1D1C1A;--surface:#262522;--surface-2:#2E2D29;--hover:#34322E;--line:#383631;--line-soft:#302F2B;--ink:#EDEAE2;--ink-2:#B4AFA4;--ink-3:#908B81;--accent:#E0886A;--accent-2:#EA9A7E;--on-accent:#1D1C1A;--accent-soft:#3D2C25;--tint:rgba(224,136,106,.12);--second:#93A6B9}
/* 2 青瓷 celadon */
[data-palette=celadon]{--canvas:#EFF2EE;--surface:#FAFBF8;--surface-2:#E8ECE6;--hover:#E1E6DE;--line:#DAE0D7;--line-soft:#E5E9E2;--ink:#1B201D;--ink-2:#4F5A53;--ink-3:#67706A;--accent:#3F8A78;--accent-2:#2F6F60;--on-accent:#fff;--accent-soft:#DCEBE5;--tint:rgba(63,138,120,.10);--second:#B08A5A}
[data-theme=dark][data-palette=celadon],[data-theme=dark] [data-palette=celadon]{--canvas:#191D1B;--surface:#222724;--surface-2:#2A302C;--hover:#303733;--line:#353D38;--line-soft:#2C332F;--ink:#E6ECE7;--ink-2:#A9B5AD;--ink-3:#848F88;--accent:#63B19E;--accent-2:#7CC3B1;--on-accent:#0F1A16;--accent-soft:#24382F;--tint:rgba(99,177,158,.13);--second:#C9A777}
/* 3 雾蓝 mist */
[data-palette=mist]{--canvas:#EFF1F5;--surface:#FAFBFD;--surface-2:#E7EAF0;--hover:#E0E4EC;--line:#D9DEE7;--line-soft:#E4E8EF;--ink:#1A1D24;--ink-2:#4E5563;--ink-3:#686E7B;--accent:#4D6FAE;--accent-2:#3A5A96;--on-accent:#fff;--accent-soft:#DEE6F4;--tint:rgba(77,111,174,.10);--second:#C08457}
[data-theme=dark][data-palette=mist],[data-theme=dark] [data-palette=mist]{--canvas:#181A20;--surface:#21242B;--surface-2:#292D35;--hover:#2F343D;--line:#353A45;--line-soft:#2B2F38;--ink:#E7EAF0;--ink-2:#A8AFBD;--ink-3:#848B9A;--accent:#7F9DD8;--accent-2:#98B2E3;--on-accent:#0F1626;--accent-soft:#26324A;--tint:rgba(127,157,216,.14);--second:#D9A27A}
/* 4 暮紫 dusk */
[data-palette=dusk]{--canvas:#F3F0F2;--surface:#FCFAFB;--surface-2:#ECE7EB;--hover:#E6E0E5;--line:#E0D9DF;--line-soft:#EAE4E9;--ink:#211C20;--ink-2:#5A5058;--ink-3:#756B73;--accent:#9A5F8E;--accent-2:#7F4B75;--on-accent:#fff;--accent-soft:#F0E0EC;--tint:rgba(154,95,142,.10);--second:#6F8FA0}
[data-theme=dark][data-palette=dusk],[data-theme=dark] [data-palette=dusk]{--canvas:#1C181B;--surface:#262124;--surface-2:#2E282C;--hover:#352E33;--line:#3C343A;--line-soft:#322B30;--ink:#EEE7EC;--ink-2:#B6AAB3;--ink-3:#90868E;--accent:#CC8FBE;--accent-2:#DBA6CF;--on-accent:#24121F;--accent-soft:#3D2937;--tint:rgba(204,143,190,.13);--second:#8FB0C1}
/* 5 石墨 graphite */
[data-palette=graphite]{--canvas:#F1F1EF;--surface:#FBFBFA;--surface-2:#E9E9E6;--hover:#E2E2DF;--line:#DCDCD8;--line-soft:#E7E7E4;--ink:#1C1C1B;--ink-2:#575754;--ink-3:#6E6E6A;--accent:#2B2B29;--accent-2:#000;--on-accent:#fff;--accent-soft:#E4E4E0;--tint:rgba(43,43,41,.08);--second:#9A9A94}
[data-theme=dark][data-palette=graphite],[data-theme=dark] [data-palette=graphite]{--canvas:#171717;--surface:#202020;--surface-2:#282828;--hover:#2E2E2E;--line:#343434;--line-soft:#2A2A2A;--ink:#EDEDEB;--ink-2:#ABABA7;--ink-3:#878784;--accent:#EDEDEB;--accent-2:#fff;--on-accent:#171717;--accent-soft:#333;--tint:rgba(237,237,235,.10);--second:#8A8A86}
/* ============================== base ============================== */
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;border:0}
*{scroll-margin-top:40px}
html{height:100%;-webkit-text-size-adjust:100%}
body{min-height:100%;background:var(--canvas);color:var(--ink);font:14px/1.6 var(--sans);-webkit-font-smoothing:antialiased;font-variant-numeric:tabular-nums}
a{color:var(--accent-2);text-decoration:none}
a:hover{text-decoration:underline;text-underline-offset:3px}
img,svg{vertical-align:middle}
table{border-collapse:collapse;border-spacing:0}
ul,ol{margin:0 0 14px 22px}
li{line-height:1.6}
p{margin-bottom:10px}
strong,b{font-weight:600}
small{font-size:.9em}
hr{height:1px;background:var(--line-soft);margin:18px 0}
code,pre,kbd,samp{font-family:var(--mono);font-size:12.5px}
code{background:var(--surface-2);border-radius:6px;padding:1px 6px}
pre{background:var(--surface-2);border:1px solid var(--line-soft);border-radius:var(--r-sm);padding:12px 14px;white-space:pre-wrap;word-wrap:break-word;margin-bottom:16px;line-height:1.55}
abbr[title]{text-decoration:underline dotted;cursor:help}
::selection{background:var(--accent-soft);color:var(--ink)}
::placeholder{color:var(--ink-3);opacity:1}
:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.hidden,[hidden]{display:none!important}
.left{text-align:left!important}.right{text-align:right!important}.center{text-align:center!important}
.top{vertical-align:top!important}.middle{vertical-align:middle!important}.bottom{vertical-align:bottom!important}
.nowrap{white-space:nowrap}
.pull-right{float:right}.pull-left{float:left}
svg.i{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round;flex:none}
.td-serif{font-family:var(--serif);font-weight:500;letter-spacing:-.01em}
.td-muted{color:var(--ink-3)}
::view-transition-old(root),::view-transition-new(root){animation-duration:var(--base);animation-timing-function:var(--ease)}
@media (prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.01ms!important;transition-duration:.01ms!important}}
/* headings: LuCI views put the page title in h2 */
h1,h2,h3,h4,h5,h6,legend{color:var(--ink);font-weight:500;line-height:1.3}
h1{font:500 clamp(26px,3.2vw,34px)/1.18 var(--serif);letter-spacing:-.015em;margin-bottom:12px}
h2{font:500 clamp(24px,3vw,32px)/1.2 var(--serif);letter-spacing:-.015em;margin:0 0 10px}
.cbi-section h2,.modal h2,.td-card h2{font:500 20px/1.3 var(--serif)}
h3,legend{font:500 18px/1.3 var(--serif);margin:0 0 12px;display:block}
h4{font-size:15px;margin:6px 0 8px}
h5{font-size:14px}
h6{font-size:12px;color:var(--ink-3);text-transform:uppercase;letter-spacing:.06em}
.cbi-map-descr,.cbi-tab-descr{color:var(--ink-2);font-size:14px;margin:0 0 20px;max-width:76ch}
.cbi-section-descr{color:var(--ink-3);font-size:13px;margin:-4px 0 12px;max-width:76ch}
.cbi-section-descr:empty,.cbi-map-descr:empty{display:none}
.td-desc{margin:-4px 0 12px}.td-desc>summary{display:inline-flex;align-items:center;gap:2px;list-style:none;cursor:pointer;color:var(--ink-3);font-size:13px;transition:color var(--fast) var(--ease)}
.td-desc>summary::-webkit-details-marker{display:none}.td-desc>summary:hover{color:var(--ink)}
.td-desc>summary .i{width:14px;height:14px;transform:rotate(90deg);transition:transform var(--base) var(--ease)}.td-desc[open]>summary .i{transform:rotate(-90deg)}
.td-desc>*:not(summary){margin:6px 0 0;animation:td-fade var(--base) var(--ease)}
/* ============================== app shell ============================== */
.td-app{display:grid;grid-template-columns:248px minmax(0,1fr);min-height:100vh}
.td-side{position:sticky;top:0;height:100vh;padding:22px 16px;display:flex;flex-direction:column;gap:18px;min-width:0;z-index:20}
.td-brand{display:block;padding:4px 8px;color:var(--ink)!important;text-decoration:none!important;min-width:0}
.td-brand b{display:block;font:600 17px/1.15 var(--serif);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.td-brand small{display:block;color:var(--ink-3);font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.td-search{display:flex;align-items:center;gap:8px;width:100%;padding:9px 12px;border:1px solid var(--line);border-radius:12px;background:var(--surface);color:var(--ink-3);text-align:left;cursor:pointer;font:inherit;transition:border-color var(--fast) var(--ease)}
.td-search:hover{border-color:var(--ink-3)}
.td-search span{flex:1}
kbd{font:11px var(--sans);padding:1px 6px;border:1px solid var(--line);border-radius:6px;color:var(--ink-3)}
.td-nav{display:flex;flex-direction:column;gap:2px;margin:0;min-height:300px}
.td-nav a{display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:11px;color:var(--ink-2);text-decoration:none;transition:background var(--fast) var(--ease),color var(--fast) var(--ease);white-space:nowrap;animation:td-fade var(--base) var(--ease) both}
.td-nav a:hover{background:var(--hover);color:var(--ink);text-decoration:none}
.td-nav a.on{background:var(--surface);color:var(--ink);box-shadow:var(--shadow)}
.td-nav a.on svg{color:var(--accent)}
.td-nav a em{margin-left:auto;font-style:normal;font-size:12px;color:var(--ink-3)}
.td-nav .short{display:none}
.td-side-foot{margin-top:auto;display:flex;gap:6px;padding:0 4px}
.td-icon-btn{width:36px;height:36px;display:grid;place-items:center;border-radius:10px;color:var(--ink-2)!important;background:none;cursor:pointer;text-decoration:none!important}
.td-icon-btn:hover{background:var(--hover);color:var(--ink)!important}
.td-main{padding:34px 40px 60px 24px;min-width:0;container:main/inline-size;position:relative}
#maincontent{max-width:1440px;min-width:0}
@keyframes td-fade{from{opacity:0}to{opacity:1}}
@keyframes td-rise{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
#view{animation:td-rise var(--slow) var(--ease)}
/* indicators (unsaved changes, polling) */
#indicators{display:flex;flex:none;flex-wrap:wrap;justify-content:flex-end;gap:6px;margin-left:auto;max-width:60%}
#indicators:empty{display:none}
.td-top{display:flex;align-items:center;gap:12px;min-width:0;margin:-4px 0 24px}
.td-top>.td-pills{flex:1 1 auto;min-width:0;margin:0}
.td-top:has(>.td-pills:empty){margin:0}
.td-top:has(>.td-pills:empty)>#indicators{position:absolute;top:22px;right:40px;z-index:30}
[data-indicator]{display:inline-flex;align-items:center;gap:6px;height:28px;padding:0 12px;border-radius:99px;font-size:12.5px;font-weight:500;background:var(--surface);border:1px solid var(--line);color:var(--ink-2);box-shadow:var(--shadow);white-space:nowrap}
[data-indicator][data-clickable]{cursor:pointer}
[data-indicator][data-style=active]{background:var(--accent-2);border-color:var(--accent-2);color:var(--on-accent)}
[data-indicator=poll-status]{height:26px;padding:0 10px;box-shadow:none;background:transparent;border-color:transparent;color:var(--ink-3);font-weight:400}
[data-indicator=poll-status]::before{content:"";width:6px;height:6px;border-radius:50%;background:var(--ink-3)}
[data-indicator=poll-status][data-style=active]{background:transparent;border-color:transparent;color:var(--ink-3)}
[data-indicator=poll-status][data-style=active]::before{background:var(--ok);animation:td-breathe 2.4s ease-in-out infinite}
[data-indicator=poll-status]:hover{background:var(--hover)}
@keyframes td-breathe{50%{opacity:.35}}
/* group pills (built by menu-7dollar.js) */
.td-pills{display:flex;gap:6px;overflow-x:auto;padding:4px 0;scrollbar-width:none}
.td-pills::-webkit-scrollbar{display:none}
.td-pills.scrolls{-webkit-mask-image:linear-gradient(90deg,#000 calc(100% - 36px),transparent);mask-image:linear-gradient(90deg,#000 calc(100% - 36px),transparent)}
.td-pills.scrolls.end{-webkit-mask-image:none;mask-image:none}
.td-pills:empty{display:none}
.td-pills a{padding:7px 14px;border-radius:99px;color:var(--ink-2);text-decoration:none;white-space:nowrap;font-size:13.5px;transition:background var(--fast) var(--ease),color var(--fast) var(--ease)}
.td-pills a:hover{background:var(--hover);color:var(--ink);text-decoration:none}
.td-pills a.on{background:var(--ink);color:var(--canvas)}
.td-pills :is(.back,.fold){display:inline-flex;align-items:center;gap:4px;color:var(--ink-3)}
.td-pills :is(.back,.fold) svg{width:15px;height:15px;transform:rotate(180deg)}
.td-pills .fold svg{transform:rotate(90deg)}
.td-pills .td-in{animation:td-fade var(--base) var(--ease) both}
/* rail: narrow windows or zoomed in */
@media (max-width:1100px){
.td-app{grid-template-columns:76px minmax(0,1fr)}
.td-side{padding:22px 12px;align-items:center}
.td-brand,.td-search span,.td-search kbd,.td-nav .lbl,.td-nav a em{display:none}
.td-search{width:44px;height:44px;justify-content:center;padding:0}
.td-nav a{padding:12px;justify-content:center}
.td-side-foot{flex-direction:column}
.td-top:has(>.td-pills:empty)>#indicators{right:24px}
}
/* phone: bottom tab bar */
@media (max-width:760px){
.td-app{grid-template-columns:minmax(0,1fr)}
.td-side{position:fixed;inset:auto 0 0 0;top:auto;height:auto;flex-direction:row;padding:6px 8px calc(6px + env(safe-area-inset-bottom));background:color-mix(in srgb,var(--surface) 92%,transparent);-webkit-backdrop-filter:blur(16px);backdrop-filter:blur(16px);border-top:1px solid var(--line);z-index:600}
.td-brand,.td-search,.td-side-foot{display:none}
.td-nav{flex-direction:row;justify-content:space-around;width:100%;min-height:0}
.td-nav a{flex-direction:column;gap:3px;padding:6px 4px;font-size:11px;box-shadow:none!important;background:none!important;flex:1;justify-content:center}
.td-nav a.mob-hide{display:none}
.td-nav .lbl{display:inline}.td-nav .short{display:inline}.td-nav .lbl.has-short{display:none}
.td-main{padding:22px 16px 100px}
.td-top:has(>.td-pills:empty)>#indicators{position:static;margin:0 0 12px auto}
}
/* command palette / mobile sheet */
.td-pop{position:fixed;inset:0;z-index:1000;background:color-mix(in srgb,#141413 30%,transparent);-webkit-backdrop-filter:blur(3px);backdrop-filter:blur(3px);display:flex;align-items:flex-start;justify-content:center;padding:12vh 16px 16px;animation:td-fade var(--base) var(--ease)}
.td-pop-box{width:min(560px,100%);max-height:70vh;display:flex;flex-direction:column;background:var(--surface);border:1px solid var(--line-soft);border-radius:18px;box-shadow:var(--shadow-pop);overflow:hidden;animation:td-rise var(--base) var(--ease)}
/* exits get their own keyframes: reusing td-fade/td-rise would not restart the finished entrance */
.td-pop.td-out,.td-toast.td-out{animation:td-out var(--fast) var(--ease) both}
.td-out .td-pop-box{animation:td-sink var(--fast) var(--ease) both}
@keyframes td-out{to{opacity:0}}
@keyframes td-sink{to{opacity:0;transform:translateY(6px)}}
.td-pop-in{display:flex;align-items:center;gap:10px;padding:14px 18px;border-bottom:1px solid var(--line-soft);color:var(--ink-3)}
.td-pop-in input{flex:1;border:0!important;background:none!important;box-shadow:none!important;outline:0;font-size:16px!important;height:auto!important;padding:4px 0!important;color:var(--ink);width:auto!important;min-width:0}
.td-pop-list{overflow-y:auto;padding:8px;margin:0;list-style:none}
.td-pop-list a{display:flex;align-items:center;gap:12px;padding:10px 12px;border-radius:11px;color:var(--ink);text-decoration:none}
.td-pop-list a small{margin-left:auto;color:var(--ink-3);font-size:12px;white-space:nowrap}
.td-pop-list a svg{color:var(--ink-3)}
.td-pop-list a.on,.td-pop-list a:hover{background:var(--hover);text-decoration:none}
.td-pop-list a.on svg{color:var(--accent)}
.td-pop-list .grp{padding:12px 12px 4px;font-size:12px;color:var(--ink-3)}
.td-pop-empty{padding:28px;text-align:center;color:var(--ink-3)}
.td-sheet-tools{display:flex;gap:8px;padding:10px 12px 14px;border-top:1px solid var(--line-soft)}
.td-sheet-tools .btn{flex:1}
@media (max-width:760px){
.td-pop{align-items:flex-end;padding:0}
.td-pop-box{width:100%;max-height:82vh;border-radius:20px 20px 0 0;padding-bottom:env(safe-area-inset-bottom)}
}
/* ============================== buttons ============================== */
button,input,select,textarea{font:inherit;color:inherit}
.btn,.cbi-button,:where(input)[type=button],:where(input)[type=submit],:where(input)[type=reset]{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:36px;padding:0 15px;border-radius:11px;border:1px solid var(--line);background:var(--surface);color:var(--ink);font-weight:500;font-size:13.5px;line-height:1.2;white-space:nowrap;cursor:pointer;text-decoration:none!important;transition:background var(--fast) var(--ease),border-color var(--fast) var(--ease),color var(--fast) var(--ease),box-shadow var(--fast) var(--ease);vertical-align:middle;width:auto;height:auto}
.btn:hover,.cbi-button:hover,:where(input)[type=button]:hover,:where(input)[type=submit]:hover,:where(input)[type=reset]:hover{border-color:var(--ink-3)}
.btn:focus-visible,.cbi-button:focus-visible{outline:0;box-shadow:0 0 0 4px var(--tint);border-color:var(--accent)}
.btn:active,.cbi-button:active{transform:translateY(1px)}
.btn[disabled],.cbi-button[disabled],button[disabled],input[disabled][type=button],input[disabled][type=submit]{opacity:.45;pointer-events:none;cursor:default}
.btn.primary,.btn.important,.cbi-button-apply,.cbi-button-action.important,.cbi-button-positive.important,.cbi-section-actions .cbi-button-edit{background:var(--accent-2);border-color:var(--accent-2);color:var(--on-accent)!important}
.btn.primary:hover,.btn.important:hover,.cbi-button-apply:hover,.cbi-button-action.important:hover,.cbi-button-positive.important:hover,.cbi-section-actions .cbi-button-edit:hover{background:color-mix(in srgb,var(--accent-2) 86%,var(--ink));border-color:transparent}
.cbi-button-positive,.cbi-button-save,.cbi-button-add,.cbi-button-fieldadd,.btn.success{color:var(--accent-2);border-color:color-mix(in srgb,var(--accent) 40%,var(--line));background:var(--surface)}
.cbi-button-positive:hover,.cbi-button-save:hover,.cbi-button-add:hover,.cbi-button-fieldadd:hover{background:var(--tint);border-color:var(--accent)}
.cbi-button-negative,.cbi-button-remove,.cbi-button-reset,.cbi-section-remove .cbi-button,.btn.danger,.btn.error{color:var(--bad);border-color:color-mix(in srgb,var(--bad) 30%,var(--line));background:var(--surface)}
.cbi-button-negative:hover,.cbi-button-remove:hover,.cbi-button-reset:hover,.cbi-section-remove .cbi-button:hover,.btn.danger:hover{background:var(--bad-soft);border-color:var(--bad)}
.cbi-button-negative.important,.btn.danger.important{background:var(--bad);border-color:var(--bad);color:#fff}
.cbi-button-up,.cbi-button-down{min-width:36px;padding:0 8px}
.btn.large{min-height:46px;padding:0 20px;font-size:15px;border-radius:14px}
.btn.small,.cbi-button.small{min-height:28px;padding:0 10px;font-size:12px;border-radius:8px}
.btn.quiet{border-color:transparent;background:none}
.btn.quiet:hover{background:var(--hover);border-color:transparent}
.btn.block{width:100%}
.close{float:right;font-size:20px;line-height:1;color:var(--ink-3);cursor:pointer;opacity:.7}
.close:hover{opacity:1;text-decoration:none}
.control-group{display:inline-flex;flex-wrap:wrap;gap:6px;align-items:center}
/* LuCI stacks controls with a bare <br> (e.g. System → Local time); give that line break room */
br+:is(.control-group,input,select,textarea,.cbi-button,.btn,.cbi-dropdown,.cbi-select){margin-top:8px}
/* ============================== form controls ============================== */
input,textarea,select,.cbi-select,.cbi-dropdown:not(.btn):not(.cbi-button){display:inline-block;width:280px;max-width:100%;background:var(--canvas);color:var(--ink);font-size:14px;line-height:1.4;border:1px solid var(--line);border-radius:var(--r-sm);padding:7px 11px;transition:border-color var(--fast) var(--ease),box-shadow var(--fast) var(--ease);vertical-align:middle}
input,select,.cbi-select{height:38px}
textarea{min-height:90px;font-family:var(--mono);font-size:13px;line-height:1.55;padding:10px 12px;resize:vertical;width:100%}
input:hover,select:hover,textarea:hover,.cbi-dropdown:not(.btn):not(.cbi-button):hover{border-color:color-mix(in srgb,var(--ink-3) 70%,var(--line))}
input:focus,select:focus,textarea:focus,.cbi-select.focus,.cbi-select:focus-within,.cbi-dropdown:not(.btn):not(.cbi-button):focus,.cbi-dropdown[open]:not(.btn):not(.cbi-button){outline:0;border-color:var(--accent);box-shadow:0 0 0 4px var(--tint)}
input[readonly],textarea[readonly]{background:var(--surface-2);color:var(--ink-2)}
input[disabled],select[disabled],textarea[disabled],.cbi-select[disabled],.cbi-dropdown[disabled]:not(.btn):not(.cbi-button){opacity:.5;pointer-events:none;cursor:default}
input[type=file]{height:auto;border:0;background:none;padding:6px 0;width:auto!important}
input[type=file]::file-selector-button{font:inherit;font-weight:500;margin-right:10px;padding:7px 14px;border-radius:10px;border:1px solid var(--line);background:var(--surface);color:var(--ink);cursor:pointer}
input[type=search]{-webkit-appearance:none;appearance:none}
select{-webkit-appearance:none;appearance:none;padding-right:30px;cursor:pointer;background:var(--canvas) var(--chevron) no-repeat right 11px center}
select[multiple]{height:auto;background-image:none;padding-right:11px}
select option{background:var(--surface);color:var(--ink)}
.cbi-select{position:relative;padding:0}
.cbi-select select,.cbi-select select:focus{border:0;box-shadow:none;width:100%;height:100%;background-color:transparent}
input.cbi-input-invalid,textarea.cbi-input-invalid,select.cbi-input-invalid,.cbi-dropdown.cbi-input-invalid,.cbi-select.cbi-input-invalid{border-color:var(--bad)!important;color:var(--bad)}
input.cbi-input-invalid:focus{box-shadow:0 0 0 4px color-mix(in srgb,var(--bad) 15%,transparent)!important}
input.cbi-input-password{width:calc(280px - 46px)}
.control-group>input+.cbi-button,.cbi-input-password+.cbi-button{min-width:38px;padding:0 10px}
/* checkbox & radio */
input[type=checkbox],input[type=radio]{-webkit-appearance:none;appearance:none;width:18px;height:18px;min-width:18px;padding:0;border:1.5px solid color-mix(in srgb,var(--ink-3) 70%,var(--line));border-radius:6px;background:var(--surface);display:inline-grid;place-items:center;cursor:pointer;vertical-align:-4px;margin:0 6px 0 0;transition:background var(--fast) var(--ease),border-color var(--fast) var(--ease),box-shadow var(--fast) var(--ease)}
input[type=radio]{border-radius:50%}
input[type=checkbox]::after{content:"";width:10px;height:6px;border:2px solid var(--on-accent);border-top:0;border-right:0;transform:translateY(-1px) rotate(-45deg) scale(0);transition:transform var(--fast) var(--ease)}
input[type=radio]::after{content:"";width:8px;height:8px;border-radius:50%;background:var(--on-accent);transform:scale(0);transition:transform var(--fast) var(--ease)}
input[type=checkbox]:checked,input[type=radio]:checked{background:var(--accent);border-color:var(--accent)}
input[type=checkbox]:checked::after{transform:translateY(-1px) rotate(-45deg) scale(1)}
input[type=radio]:checked::after{transform:scale(1)}
input[type=checkbox]:focus-visible,input[type=radio]:focus-visible{box-shadow:0 0 0 4px var(--tint);outline:0}
input[type=checkbox].cbi-input-invalid,input[type=radio].cbi-input-invalid{border-color:var(--bad)}
.cbi-checkbox,.cbi-radio{display:inline-flex;align-items:center;gap:2px;min-height:38px;cursor:pointer}
.cbi-radio{margin-right:14px}
.cbi-checkbox>label,.cbi-radio>label{cursor:pointer}
/* ============================== map / section / value ============================== */
.cbi-map{display:block;margin-bottom:22px}
.cbi-section,.cbi-tblsection{background:var(--surface);border:1px solid var(--line-soft);border-radius:var(--r-lg);box-shadow:var(--shadow);padding:22px 24px;margin:0 0 18px;min-width:0;position:relative}
.cbi-section .cbi-section,.cbi-value .cbi-section,.cbi-value .cbi-tblsection,.cbi-section .cbi-tblsection,.modal .cbi-section,.modal .cbi-tblsection,.cbi-section-node .cbi-section{background:none;border:0;border-radius:0;box-shadow:none;padding:0;margin:0 0 10px}
.cbi-section-node{min-width:0}
.cbi-section-node+.cbi-section-node{margin-top:12px}
.cbi-section-error{border:1px solid var(--bad);background:var(--bad-soft);border-radius:var(--r-sm);padding:10px 14px;margin-bottom:14px;color:var(--bad)}
.cbi-section-error ul{margin:0 0 0 18px}
.cbi-value{display:flex;flex-wrap:wrap;align-items:flex-start;gap:6px 24px;padding:14px 0;border-top:1px solid var(--line-soft);min-width:0}
.cbi-section-node>.cbi-value:first-child,.cbi-section-node>[data-tab]>.cbi-value:first-child,.cbi-section-node>.cbi-tabcontainer>.cbi-value:first-child,.cbi-value.hidden+.cbi-value{border-top:0}
.cbi-value>.cbi-value-title{flex:0 0 220px;max-width:100%;padding-top:8px;font-weight:500;color:var(--ink);font-size:14px;line-height:1.45;text-align:left;overflow-wrap:anywhere}
.cbi-value-field{flex:1 1 300px;min-width:0;color:var(--ink-2);line-height:1.45}
.cbi-value-field>span:only-child,.cbi-value-field>div:not([class]):first-child{display:inline-block;padding-top:8px}
.cbi-value-description{display:block;margin-top:6px;color:var(--ink-3);font-size:12.5px;line-height:1.5;max-width:68ch}
.cbi-value-description:empty{display:none}
.cbi-value-helpicon,.cbi-value-helpicon img{display:none}
.cbi-value>.cbi-section,.cbi-value>.cbi-tblsection{flex:1 1 100%}
div.cbi-value var,.td.cbi-value-field var{font-style:normal;color:var(--accent-2);font-family:var(--mono);font-size:.92em}
div.cbi-value var[data-tooltip],.td.cbi-value-field var[data-tooltip],var.cbi-tooltip-container{cursor:help;border-bottom:1px dotted currentColor}
var.cbi-tooltip-container .cbi-tooltip{white-space:normal;font-family:var(--sans)} /* a tooltip inside <var> (firewall rules) must not inherit its mono font */
.cbi-value-field>input+.cbi-button,.cbi-value-field>select+.cbi-button{margin-left:6px}
.cbi-optionals,.cbi-section-create{display:flex;flex-wrap:wrap;align-items:center;gap:8px;padding:12px 0 0}
.cbi-section-create>*{flex:0 1 auto;margin:0}
.cbi-section-create>div>input{width:220px}
.cbi-section-remove{text-align:right;margin:0 0 8px}
.cbi-title-ref{color:var(--accent-2)}
.cbi-title-ref::after{content:" →"}
fieldset{min-width:0}
.cbi-progressbar{position:relative;min-width:160px;height:8px;border-radius:99px;background:var(--surface-2);margin:22px 0 6px}
.cbi-progressbar>div{height:100%;width:0;border-radius:99px;background:var(--accent);transition:width var(--data) var(--ease)}
.cbi-progressbar::before{content:attr(title);position:absolute;bottom:13px;left:0;right:0;white-space:pre;overflow:hidden;text-overflow:ellipsis;font-size:13px;color:var(--ink-2);line-height:1.3}
/* page actions → floating save bar */
.cbi-page-actions,.actions{position:sticky;bottom:18px;z-index:40;display:flex;flex-wrap:wrap;justify-content:flex-end;align-items:center;gap:8px;margin:18px 0 0;padding:12px 14px;border-radius:16px;background:color-mix(in srgb,var(--surface) 88%,transparent);-webkit-backdrop-filter:blur(14px);backdrop-filter:blur(14px);border:1px solid var(--line-soft);box-shadow:var(--shadow-pop)}
.cbi-page-actions>form{display:inline-flex;margin:0}
.cbi-page-actions:empty{display:none}
.cbi-page-actions>*{margin:0!important;float:none!important}
.cbi-page-actions .cbi-button-apply{order:3}
.cbi-page-actions .cbi-button-save{order:2}
.cbi-page-actions .cbi-button-reset{order:1;margin-right:auto!important}
@media (max-width:760px){.cbi-page-actions,.actions{bottom:84px}.cbi-page-actions>*{flex:1 1 auto}}
/* ============================== tabs ============================== */
.cbi-tabmenu,.tabs{display:flex;flex-wrap:nowrap;gap:4px;list-style:none;margin:0 0 20px;padding:0;overflow-x:auto;scrollbar-width:none;border-bottom:1px solid var(--line-soft);background:none}
.cbi-tabmenu::-webkit-scrollbar,.tabs::-webkit-scrollbar{display:none}
.cbi-tabmenu>li,.tabs>li{flex:none;display:flex;margin:0;padding:0;background:none!important;border:0;border-radius:0;height:auto;max-width:none;color:inherit}
.cbi-tabmenu>li>a,.tabs>li>a{display:block;padding:9px 12px 11px;color:var(--ink-3);font-size:13.5px;font-weight:500;text-decoration:none!important;white-space:nowrap;border-bottom:2px solid transparent;margin-bottom:-1px;transition:color var(--fast) var(--ease),border-color var(--fast) var(--ease);line-height:1.3}
.cbi-tabmenu>li>a:hover,.tabs>li>a:hover{color:var(--ink)}
.cbi-tabmenu>li.cbi-tab>a,.tabs>li.active>a{color:var(--ink);border-bottom-color:var(--accent)}
.cbi-tab-disabled[data-errors]::after{content:attr(data-errors);align-self:center;min-width:18px;height:18px;border-radius:9px;background:var(--bad);color:#fff;font-size:11px;font-weight:600;display:inline-grid;place-items:center;padding:0 5px;margin:0 6px 0 -4px}
#tabmenu .tabs{margin-bottom:22px}
.cbi-tabmenu.map{margin:0 0 18px}
.cbi-tabmenu.map>li>a{font:500 16px var(--serif)}
.cbi-section>.cbi-tabmenu:first-child{margin-top:-8px}
/* a mouse can't scroll sideways: there, tab rows and pills wrap instead of hiding what doesn't fit */
@media (hover:hover) and (pointer:fine){.cbi-tabmenu,.tabs,.td-pills{flex-wrap:wrap;overflow-x:visible}}
.cbi-tabcontainer{min-width:0}
[data-tab-title]:not([data-tab-active=true]){display:none}
[data-tab-active=true]{animation:td-fade var(--base) var(--ease)}
.tab-content>.tab-pane,.tab-content>div{display:none}
.tab-content>.active{display:block}
/* ============================== tables ============================== */
.table{display:table;width:100%;border-collapse:collapse;margin:0 0 14px;font-size:13.5px;position:relative}
.tr{display:table-row}
.th,.td{display:table-cell;vertical-align:middle;text-align:left;padding:11px 12px;border-top:1px solid var(--line-soft);line-height:1.45;overflow-wrap:break-word}
.table .tr:first-child>.th,.table .tr:first-child>.td{border-top:0}
.th,.tr.table-titles .th,.tr.cbi-section-table-titles .th{font-weight:500;font-size:12.5px;color:var(--ink-3);vertical-align:bottom}
.td>.th{color:inherit}
.tr.table-titles,.tr.cbi-section-table-titles,.cbi-rowstyle-2{background:none}
.table .tr:not(.table-titles):not(.cbi-section-table-titles):not(.placeholder):hover>.td{background:var(--tint)}
.table[width="33%"],.th[width="33%"],.td[width="33%"]{width:33%}
.table[width="100%"],.th[width="100%"],.td[width="100%"]{width:100%}
.tr.placeholder{height:calc(3em + 22px)}
.tr.placeholder>.td{position:absolute;left:0;right:0;bottom:0;text-align:center;line-height:3em;color:var(--ink-3);border:0;font-style:normal}
.tr.drag-over-above{box-shadow:inset 0 2px 0 var(--accent)}
.tr.drag-over-below{box-shadow:inset 0 -2px 0 var(--accent)}
.cbi-section-table .tr.cbi-section-table-descr .th{font-weight:400;color:var(--ink-3)}
.cbi-section-table-titles.named::before,.cbi-section-table-descr.named::before,.cbi-section-table-row[data-title]::before{content:attr(data-title) " ";display:table-cell;font-weight:500;vertical-align:middle;border-top:1px solid var(--line-soft);padding:11px 12px;color:var(--ink)}
.cbi-section-table-titles.named::before{border-top:0;color:var(--ink-3);font-size:12.5px}
/* editable grids (one control per cell): tighter gutters, controls shrink to a usable minimum */
.table.cbi-section-table .th,.table.cbi-section-table .td{padding-left:8px;padding-right:8px}
.table.cbi-section-table input[type=password],.table.cbi-section-table input[type=text],.table.cbi-section-table textarea,.table.cbi-section-table select,.table.cbi-section-table .cbi-select{width:100%;min-width:64px}
/* action buttons keep to one row while there is room and wrap instead of pushing out of the card */
:is(.th,.td).cbi-section-actions{text-align:right;white-space:normal}
.td.cbi-section-actions>*{display:inline-flex;justify-content:flex-end;align-items:center;gap:6px;white-space:nowrap}
.td.cbi-section-actions>*>form{display:inline-flex;gap:6px;margin:0}
.td.cbi-section-actions .cbi-button{min-height:32px;padding:0 12px;font-size:13px}
.td.cbi-section-actions .drag-handle{width:24px;padding:0;border:0;background:none;box-shadow:none;color:var(--ink-3);cursor:grab}
.td.cbi-section-actions .drag-handle:hover{background:var(--hover);color:var(--ink)}
.td>input[type=text],.td>input[type=password],.td>select,.td>.cbi-dropdown:not(.btn):not(.cbi-button){width:100%}
th[data-sortable-row]{cursor:pointer}
th[data-sort-direction=asc]::after{content:"\a0\25B4"}
th[data-sort-direction=desc]::after{content:"\a0\25BE"}
table table td,.cbi-value-field table td{border:0}
.cbi-section>.table:last-child,.cbi-section-node>.table:last-child{margin-bottom:0}
/* ============================== dropdown (ui.Dropdown) ============================== */
.cbi-dropdown{display:inline-flex!important;position:relative;cursor:pointer;height:auto;min-height:38px;padding:0!important;align-items:stretch}
.cbi-dropdown:not(.btn):not(.cbi-button){min-width:220px;height:38px}
.cbi-dropdown:not(.btn):not(.cbi-button)>ul:not(.dropdown)>li{align-self:stretch;padding-block:0}
.cbi-dropdown:not(.btn):not(.cbi-button)>.open{width:30px;padding:0;font-size:0;background:var(--chevron) no-repeat center}
.cbi-dropdown:not(.btn):not(.cbi-button)>.more{text-align:left;padding:0 11px}
.cbi-dropdown>ul{list-style:none;display:flex;overflow:hidden;width:100%;margin:0!important;padding:0}
.cbi-dropdown.btn>ul:not(.dropdown),.cbi-dropdown.cbi-button>ul:not(.dropdown){margin:0 0 0 2px!important}
.cbi-dropdown.btn.spinning>ul:not(.dropdown),.cbi-dropdown.cbi-button.spinning>ul:not(.dropdown){margin:0!important}
.cbi-dropdown>.open,.cbi-dropdown>.more{flex:0 0 auto;display:flex;flex-direction:column;justify-content:center;text-align:center;padding:0 10px;color:var(--ink-3);font-size:12px}
.cbi-dropdown.btn>.open,.cbi-dropdown.cbi-button>.open{margin-left:8px;border-left:1px solid color-mix(in srgb,currentColor 30%,transparent);padding:0 0 0 10px;color:inherit}
.cbi-dropdown>.more,.cbi-dropdown:not(.btn):not(.cbi-button)>ul>li[placeholder]{color:var(--ink-3);display:none}
.cbi-dropdown>ul>li{display:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1 1 auto;align-items:center;align-self:center;color:inherit;gap:6px}
.cbi-dropdown>ul.dropdown>li,.cbi-dropdown:not(.btn):not(.cbi-button)>ul>li{min-height:22px;color:var(--ink);padding:7px 11px}
.cbi-dropdown>ul>li[display]:not([display="0"]){border-left:1px solid var(--line-soft)}
.cbi-dropdown[empty]>ul{max-width:1px}
.cbi-dropdown>ul>li>form{display:none;pointer-events:none;margin:0;padding:0}
.cbi-dropdown>ul>li img{vertical-align:middle;margin-right:4px;max-height:1.4em;width:auto}
.cbi-dropdown>ul>li>form>input[type=checkbox]{margin:0 6px 0 0}
.cbi-dropdown>ul>li input[type=text]{height:30px}
.cbi-dropdown[open]{position:relative}
.cbi-dropdown[open]>ul.dropdown{display:block;position:absolute;z-index:1100;min-width:100%;width:auto;max-width:none;overflow-y:auto;background:var(--surface);border:1px solid var(--line);border-radius:12px;box-shadow:var(--shadow-pop);padding:4px!important;transition:max-height var(--fast) var(--ease)}
.cbi-dropdown>ul>li[display],.cbi-dropdown[open]>ul.preview,.cbi-dropdown[open]>ul.dropdown>li,.cbi-dropdown[multiple]>ul>li>label,.cbi-dropdown[multiple][open]>ul.dropdown>li,.cbi-dropdown[multiple][more]>.more,.cbi-dropdown[multiple][empty]>.more{flex-grow:1;display:flex!important}
.cbi-dropdown[empty]>ul>li,.cbi-dropdown[optional][open]>ul.dropdown>li[placeholder]{display:block!important}
.cbi-dropdown[multiple][open]>ul.dropdown>li>form{display:flex!important}
.cbi-dropdown[open]>ul.dropdown>li{border-radius:8px;border:0}
.cbi-dropdown[open]>ul.dropdown>li.focus,.cbi-dropdown[open]>ul.dropdown>li:hover{background:var(--hover);color:var(--ink)}
.cbi-dropdown[open]>ul.dropdown>li[selected]{background:var(--accent-soft);color:var(--ink)}
.cbi-dropdown[open]>ul.dropdown>li[unselectable]{opacity:.6}
.cbi-dropdown>ul.preview,.cbi-dropdown>ul>li .hide-close,.cbi-dropdown[open]>ul.dropdown>li .hide-open{display:none}
.cbi-dropdown>ul>li .hide-open,.cbi-dropdown[open]>ul.dropdown>li .hide-close{display:initial}
.cbi-dropdown[open]>ul.dropdown>li>input.create-item-input:first-child:last-child{width:100%}
.cbi-dropdown.btn,.cbi-dropdown.cbi-button{padding:0 12px!important;min-height:36px}
.cbi-dropdown.btn>ul>li,.cbi-dropdown.cbi-button>ul>li{padding:0}
.cbi-dropdown.btn[open]>ul.dropdown>li,.cbi-dropdown.cbi-button[open]>ul.dropdown>li{padding:7px 11px;color:var(--ink)}
/* dynamic list */
.cbi-dynlist{display:inline-flex;flex-direction:column;gap:6px;min-width:220px;width:280px;max-width:100%;height:auto;min-height:38px;line-height:1.4}
.cbi-dynlist>.item{position:relative;display:flex;align-items:center;min-height:36px;padding:7px 38px 7px 11px;border:1px solid var(--line);border-radius:var(--r-sm);background:var(--surface);color:var(--ink);word-break:break-all;cursor:move;user-select:text;font-size:13.5px}
.cbi-dynlist>.item::after{content:"×";position:absolute;top:0;right:0;bottom:0;width:34px;display:grid;place-items:center;color:var(--ink-3);font-size:17px;cursor:pointer;border-left:1px solid var(--line-soft);pointer-events:auto}
.cbi-dynlist>.item:hover::after{color:var(--bad)}
.cbi-dynlist>.item.dragging{opacity:.5}
.cbi-dynlist>.item>span{pointer-events:none}
.cbi-dynlist>.add-item{display:flex;gap:6px}
.cbi-dynlist>.add-item>input,.cbi-dynlist>.add-item>.cbi-dropdown{flex:1 1 auto;min-width:0;width:auto}
.cbi-dynlist>.add-item>.cbi-button{flex:0 0 auto}
.cbi-dynlist>.item.drag-over,.cbi-dynlist>.add-item>.cbi-input-text.drag-over,.cbi-dynlist>.add-item>.cbi-button-add.drag-over{border-top:2px solid var(--accent)}
[data-page=admin-system-admin-sshkeys] .cbi-dynlist{width:100%}
/* file browser */
.cbi-filebrowser{min-width:220px;max-width:100%;border:1px solid var(--line);border-radius:var(--r-sm);display:flex;flex-direction:column;opacity:0;height:0;overflow:hidden;background:var(--surface);margin-top:6px}
.cbi-filebrowser.open{height:auto;opacity:1;overflow:visible;transition:opacity var(--base) var(--ease)}
.cbi-filebrowser>*{max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;border-bottom:1px solid var(--line-soft);margin:6px 8px 0;padding:0 0 6px}
.cbi-filebrowser>div{border-bottom:0}
.cbi-filebrowser>ul{list-style:none}
.cbi-filebrowser>ul>li{display:flex;align-items:center;padding:3px 4px;border-radius:6px}
.cbi-filebrowser>ul>li:hover{background:var(--hover)}
.cbi-filebrowser>ul>li>div:first-child{flex:10;overflow:hidden;text-overflow:ellipsis}
.cbi-filebrowser>ul>li>div:last-child{flex:3;text-align:right}
.cbi-filebrowser>ul>li>div:last-child>button{margin-left:4px;min-height:26px;padding:0 8px;font-size:12px}
.cbi-filebrowser .upload{display:flex;flex-wrap:wrap;gap:6px}
.cbi-filebrowser .upload>*{flex:1}
.cbi-filebrowser .upload>div{flex:10;min-width:150px}
.cbi-filebrowser .upload>div>input{width:100%}
/* ============================== modal ============================== */
#modal_overlay{position:fixed;inset:0;z-index:900;overflow:auto;background:rgba(20,20,19,.45);-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);opacity:0;visibility:hidden;transition:opacity var(--fast) var(--ease),visibility 0s var(--fast)}
body.modal-overlay-active{overflow:hidden;height:100vh}
body.modal-overlay-active #modal_overlay{opacity:1;visibility:visible;transition:opacity var(--base) var(--ease)}
.modal{width:92%;max-width:600px;min-width:270px;margin:9vh auto 5vh;padding:26px 28px 22px;background:var(--surface);color:var(--ink);border:1px solid var(--line-soft);border-radius:22px;box-shadow:var(--shadow-pop);transform:translateY(6px);transition:transform var(--fast) var(--ease);line-height:1.6;container:main/inline-size}
/* the dialog stays in the DOM, so it eases in and out with transitions rather than a one-shot animation */
body.modal-overlay-active .modal{transform:none;transition-duration:var(--base)}
.modal>*{margin-bottom:12px;max-width:100%}
.modal>*:last-child{margin-bottom:0}
.modal>h4{font:500 22px/1.25 var(--serif);margin:0 0 14px}
.modal>pre,.modal>textarea{white-space:pre-wrap;overflow:auto;width:100%;max-height:50vh}
.modal .button-row,.modal>.right{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:8px;margin-top:20px}
.modal .button-row>button:first-of-type{margin-right:auto}
.modal>.right>*{margin:0!important}
.modal .cbi-value>.cbi-value-title{flex-basis:160px}
#modal_overlay>.modal.uci-dialog,#modal_overlay>.modal.cbi-modal{max-width:900px}
#modal_overlay>.modal.login>button.important{width:100%;min-height:46px;font-size:15px;margin-top:10px}
.modal.alert-message{color:var(--ink)}
/* uci changes dialog */
.uci-change-legend{display:flex;flex-wrap:wrap;gap:8px 18px;margin-bottom:14px}
.uci-change-legend-label{display:flex;align-items:center;gap:6px;font-size:12.5px;color:var(--ink-2)}
.uci-change-legend-label>ins,.uci-change-legend-label>del,.uci-change-legend-label>var{width:14px;height:14px;border-radius:4px;display:inline-block}
.uci-change-list{line-height:1.7;white-space:pre;overflow:auto;max-height:55vh;font-family:var(--mono);font-size:12.5px}
.uci-change-list>var>*{overflow:hidden;text-overflow:ellipsis}
.uci-dialog del,.uci-dialog ins,.uci-dialog var{text-decoration:none;font-family:var(--mono);font-style:normal;color:var(--ink);display:block}
.uci-dialog div>del,.uci-dialog div>ins,.uci-dialog div>var{margin-bottom:3px;border:1px solid var(--line);border-radius:6px;padding:3px 8px;background:var(--canvas);overflow:hidden;text-overflow:ellipsis}
.uci-dialog div>ins,.uci-change-legend-label>ins{background:var(--ok-soft);border-color:color-mix(in srgb,var(--ok) 40%,transparent)}
.uci-dialog div>del,.uci-change-legend-label>del{background:var(--bad-soft);border-color:color-mix(in srgb,var(--bad) 40%,transparent)}
.uci-change-legend-label>var{background:var(--surface-2);border:1px solid var(--line)}
.uci-dialog var>ins{background:var(--ok-soft)}
.uci-dialog var>del{background:var(--bad-soft)}
/* ============================== alerts, labels, tooltips ============================== */
.alert-message{position:relative;margin:0 0 16px;padding:14px 18px;border-radius:var(--r-md);background:var(--warn-soft);color:var(--ink);border:1px solid color-mix(in srgb,var(--warn) 35%,transparent);line-height:1.55;white-space:unset;animation:td-rise var(--base) var(--ease)}
.alert-message h4,.alert-message h5,.alert-message p,.alert-message pre,.alert-message ul,.alert-message li{color:inherit;background:transparent;border:0;margin:4px 0;padding:0}
.alert-message h4{font:500 16px var(--serif)}
.alert-message pre{white-space:pre-wrap;font-size:12px}
.alert-message .right{display:flex;justify-content:flex-end;gap:8px;margin-top:8px}
.alert-message.success{background:var(--ok-soft);border-color:color-mix(in srgb,var(--ok) 35%,transparent)}
.alert-message.info,.alert-message.notice{background:var(--accent-soft);border-color:color-mix(in srgb,var(--accent) 30%,transparent)}
.alert-message.error,.alert-message.danger{background:var(--bad-soft);border-color:color-mix(in srgb,var(--bad) 35%,transparent)}
.label{display:inline-flex;align-items:center;gap:4px;height:22px;padding:0 9px;border-radius:99px;font-size:11.5px;font-weight:500;background:var(--surface-2);color:var(--ink-2);white-space:nowrap;margin:2px 0 2px 4px;vertical-align:middle;text-transform:none;text-decoration:none!important}
.label.important,.label.notice{background:var(--accent-soft);color:var(--accent-2)}
.label.warning{background:var(--warn-soft);color:var(--warn)}
.label.success{background:var(--ok-soft);color:var(--ok)}
.label.error,.label.danger{background:var(--bad-soft);color:var(--bad)}
.cbi-tooltip-container{cursor:help}
.cbi-tooltip{position:absolute;z-index:1000;left:-10000px;padding:8px 12px;border-radius:10px;background:var(--ink);color:var(--canvas);font-size:12.5px;line-height:1.5;white-space:pre;box-shadow:var(--shadow-pop);opacity:0;transition:opacity var(--fast) var(--ease);max-width:min(420px,90vw)}
.cbi-tooltip-container:hover .cbi-tooltip:not(:empty){left:auto;opacity:1}
.cbi-tooltip.error{background:var(--bad);color:#fff}
.cbi-tooltip.success{background:var(--ok);color:#fff}
.cbi-tooltip.info,.cbi-tooltip.notice{background:var(--accent-2);color:var(--on-accent)}
/* spinner */
.spinning{position:relative;padding-left:32px!important}
.spinning::before{content:"";position:absolute;top:calc(50% - 9px);left:7px;width:18px;height:18px;border-radius:50%;border:2px solid color-mix(in srgb,currentColor 25%,transparent);border-top-color:currentColor;animation:td-spin .8s linear infinite}
#view>div.spinning:first-child{display:table;margin:16vh auto;color:var(--ink-3)}
@keyframes td-spin{to{transform:rotate(360deg)}}
.flash{animation:td-flash var(--slow) var(--ease)}
@keyframes td-flash{50%{opacity:.5}}
/* LuCI drops a dismissed notice on transitionend, so this must be a transition */
.fade-in,.fade-out{transition:opacity var(--base) var(--ease)}
.fade-out{opacity:0}
/* ============================== network badges ============================== */
.ifacebox{display:inline-flex;flex-direction:column;min-width:110px;margin:0 8px;text-align:center;white-space:nowrap;line-height:1.3;background:var(--surface);border:1px solid var(--line);border-radius:12px;overflow:hidden}
.ifacebox .ifacebox-head{padding:5px 8px;background:var(--surface-2);color:var(--ink);font-weight:500;border-bottom:1px solid var(--line-soft)}
.ifacebox .ifacebox-head.active{background:var(--accent-2);color:var(--on-accent)}
.ifacebox .ifacebox-body{padding:8px;font-size:12.5px;color:var(--ink-2)}
.ifacebox-body img{width:20px;height:20px;vertical-align:middle;margin:0 1px}
#cbi-network-interface .ifacebox-body{white-space:normal}
.ifacebadge{display:inline-flex;align-items:center;gap:6px;padding:4px 9px;border-radius:9px;border:1px solid var(--line);background:var(--surface);color:var(--ink);white-space:nowrap;cursor:default;line-height:1.3;font-size:12.5px}
.ifacebadge img{width:18px!important;height:18px!important;flex:none} /* LuCI sets 32px inline on some */
.ifacebadge-active{border-color:var(--accent);font-weight:500}
.network-status-table{display:flex;flex-wrap:wrap;gap:12px}
.network-status-table .ifacebox{flex:1 1 260px;margin:0;text-align:left}
.network-status-table .ifacebox-body{display:flex;flex-direction:column;height:100%;text-align:left;gap:6px;padding:10px 12px}
.network-status-table .ifacebox-body>span{flex:10 10 auto;height:100%;white-space:normal}
.network-status-table .ifacebox-body>div{display:flex;flex-wrap:wrap;gap:4px}
.network-status-table .ifacebox-body .ifacebadge{display:flex;flex:1;min-width:200px;padding:6px 8px}
.network-status-table .ifacebox-body .ifacebadge>span{overflow:hidden;text-overflow:ellipsis}
.ifacebadge.large{display:inline-flex;flex:1;min-width:200px;padding:6px 8px}
#dsl_status_table .ifacebox-body span>strong{display:inline-block;min-width:35%}
.zonebadge{display:inline-flex;align-items:center;gap:4px;padding:3px 9px;border-radius:9px;white-space:nowrap;color:var(--ink);font-size:12.5px;line-height:1.4}
.zonebadge>em,.zonebadge>strong{display:inline-block;margin:0 2px}
.zonebadge input{width:6em}
.zonebadge>.ifacebadge{margin-left:2px;padding:1px 6px}
.zonebadge-empty{border:1px dashed var(--line);color:var(--ink-3);font-style:italic}
.zonebadge .cbi-tooltip{background:inherit;color:inherit;padding:2px;margin:-1.6em 0 0 -5px;pointer-events:none}
.zonebadge .cbi-tooltip>*{position:relative;z-index:2;margin:1px}
/* firewall zone colours are LuCI's saturated pastels; tone them into the palette in both modes */
.zonebadge[style*="--zone-color-rgb"],.ifacebox-head[style*="--zone-color-rgb"]{background:rgba(var(--zone-color-rgb),.4)!important;color:var(--ink)}
.zone-forwards{display:flex;flex-wrap:nowrap;align-items:center;gap:4px}
.zone-forwards>*{flex:1 1 40%;text-wrap:wrap}
.zone-forwards>span{flex-basis:10%;text-align:center;color:var(--ink-3)}
.zone-forwards .zone-src,.zone-forwards .zone-dest{display:flex;flex-direction:column;gap:3px}
/* status text LuCI writes as "<strong>Label: </strong>value", "<big>title</big>", "<em>note</em>" and " | " */
:is(.td[data-name=_stat],.td[data-name=_ifacestat],.ifacebox-body,.ifacebadge) strong{font-weight:400;color:var(--ink-3)}
:is(.td[data-name=_stat],.td[data-name=_ifacestat],.ifacebox-body,.ifacebadge,.assoclist) em{font-style:normal;color:var(--ink-3)}
.td[data-name=_stat]{font-size:13px;line-height:1.7}
.td[data-name=_stat]>div{color:color-mix(in srgb,var(--ink-3) 45%,transparent)}
.td[data-name=_stat] .nowrap{color:var(--ink-2);margin-right:4px}
.td[data-name=_stat] big{display:block;font-size:14.5px;line-height:1.5}
.td[data-name=_stat] big strong{font-weight:500;color:var(--ink)}
.td[data-name=_badge] .ifacebadge{min-width:96px;justify-content:center}
/* associated stations: badge text wraps only between words */
.assoclist .ifacebadge{white-space:normal;word-break:keep-all;text-align:left;max-width:100%}
.assoclist .ifacebadge small{display:none} /* interface name; the badge title still has it */
.assoclist .th,.assoclist .td{padding-left:8px;padding-right:8px}
.assoclist .td:nth-of-type(5){width:28%}
.assoclist .td:nth-of-type(2){white-space:nowrap;font-variant-numeric:tabular-nums}
/* details folding (7dollar/tidy.js): engineering fields stay hidden until the section is opened */
.td-sec:not(.td-open) .td-x{display:none!important}
.td-y,.td-sec.td-open .td-y{display:none}.td-sec:not(.td-open) .td-y{display:inline}
.td-sec:not(.td-open) .ifacebadge[data-ssid]>span{display:none}
.td-sec:not(.td-open) .ifacebadge[data-ssid]::after{content:attr(data-ssid)}
.td-sec-h{display:flex;align-items:center;gap:8px}
.td-detail{margin-left:auto;display:inline-flex;align-items:center;gap:2px;height:28px;padding:0 6px 0 10px;border:0;border-radius:99px;background:none;color:var(--ink-3);font:500 12.5px/1 var(--sans);cursor:pointer;transition:background var(--fast) var(--ease),color var(--fast) var(--ease)}
.td-detail:hover{background:var(--surface-2);color:var(--ink)}
.td-detail .i{width:14px;height:14px;transform:rotate(90deg);transition:transform var(--base) var(--ease)}
.td-detail[aria-expanded=true] .i{transform:rotate(-90deg)}
/* tidy.js: details fade out before the section shrinks; whatever appears as it resizes fades in */
.td-showing :is(.td-x,.td-y){animation:td-fade var(--base) var(--ease) both}
.td-hiding .td-x{animation:td-out var(--fast) var(--ease) both}
/* native LuCI icons (<img src=".../resources/icons/*.svg">): hide the bitmap, paint a palette-tinted line icon */
@supports (mask-size:contain) or (-webkit-mask-size:contain){
img[src*="/resources/icons/"]{object-position:-99em 0;background:var(--ink-2);-webkit-mask:var(--ic) center/contain no-repeat;mask:var(--ic) center/contain no-repeat}
img[src*="/icons/ethernet"],img[src*="/icons/port_"]{--ic:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M5 5h14a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1h-3v3H8v-3H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1z'/%3E%3Cpath d='M8.5 9v2.5M12 9v2.5M15.5 9v2.5'/%3E%3C/svg%3E")}
img[src*="/icons/wifi"]{--ic:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M2.5 9.5a14 14 0 0 1 19 0M5.8 13a9.3 9.3 0 0 1 12.4 0M9.2 16.4a4.6 4.6 0 0 1 5.6 0'/%3E%3Ccircle cx='12' cy='19.6' r='.6' fill='%23000'/%3E%3C/svg%3E")}
img[src*="/icons/bridge"]{--ic:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 18h18M3 10c3 0 6-3.5 9-3.5s6 3.5 9 3.5M6 9.4V18M12 6.5V18M18 9.4V18'/%3E%3C/svg%3E")}
img[src*="/icons/switch"]{--ic:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='7' width='18' height='10' rx='2.5'/%3E%3Cpath d='M7 12h0M10.3 12h0M13.7 12h0M17 12h0' stroke-width='2.4'/%3E%3C/svg%3E")}
img[src*="/icons/vlan"]{--ic:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3.5 12.2V5a1.5 1.5 0 0 1 1.5-1.5h7.2L20.5 12 12 20.5z'/%3E%3Ccircle cx='8.2' cy='8.2' r='1.3'/%3E%3C/svg%3E")}
img[src*="/icons/alias"]{--ic:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 14a4 4 0 0 0 5.7 0l3-3A4 4 0 0 0 13 5.3l-1 1M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1'/%3E%3C/svg%3E")}
img[src*="/icons/tunnel"]{--ic:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 20v-8a9 9 0 0 1 18 0v8M7.5 20v-7.5a4.5 4.5 0 0 1 9 0V20M2 20h20'/%3E%3C/svg%3E")}
img[src*="/icons/wireguard"]{--ic:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 3l7.5 3v5.5c0 4.6-3.2 8-7.5 9.5-4.3-1.5-7.5-4.9-7.5-9.5V6z'/%3E%3Cpath d='M9.2 12.2h5.6v4H9.2zM10.4 12.2v-1.4a1.6 1.6 0 0 1 3.2 0v1.4'/%3E%3C/svg%3E")}
img[src*="/icons/vrf"]{--ic:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 3.5l8.5 4.5L12 12.5 3.5 8zM3.5 12.5 12 17l8.5-4.5M3.5 16.5 12 21l8.5-4.5'/%3E%3C/svg%3E")}
img[src*="/icons/loading"]{--ic:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 3a9 9 0 1 0 9 9' stroke-width='2.2'/%3E%3C/svg%3E")}
img[src*="/icons/signal-"]{--ic:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cg fill='%23000' stroke='none'%3E%3Crect x='3' y='15' width='3.4' height='5' rx='1'/%3E%3Crect x='8.2' y='11.5' width='3.4' height='8.5' rx='1'/%3E%3Crect x='13.4' y='8' width='3.4' height='12' rx='1'/%3E%3Crect x='18.6' y='4' width='3.4' height='16' rx='1'/%3E%3C/g%3E%3C/svg%3E")}
img[src*="/icons/wifi."],img[src*="/icons/port_up"]{background:var(--accent)}
img[src*="_disabled."],img[src*="/icons/port_down"]{background:var(--ink-3);opacity:.55}
img[src*="/icons/signal-"]{background:linear-gradient(90deg,var(--accent) var(--lv,0%),color-mix(in srgb,var(--ink-3) 35%,transparent) 0)}
img[src*="signal-000-025"]{--lv:30%}img[src*="signal-025-050"]{--lv:52%}img[src*="signal-050-075"]{--lv:74%}img[src*="signal-075-100"]{--lv:100%}
img[src*="/icons/loading"]{background:var(--accent);animation:td-spin .8s linear infinite}
}
#view>.table{display:table;border-collapse:separate;border-spacing:0;background:var(--surface);border:1px solid var(--line-soft);border-radius:var(--r-lg);box-shadow:var(--shadow);padding:10px 14px;margin-bottom:18px}
/* logs, graphs, misc */
#syslog{width:100%;min-height:60vh;font-size:12.5px;background:var(--surface);border:1px solid var(--line-soft);color:var(--ink);border-radius:var(--r-md)}
[data-theme=dark] [data-page=admin-statistics-graphs] [data-plugin] img{filter:invert(100%) hue-rotate(150deg)}
[data-page^=admin-status-realtime] #view svg line[style],[data-page=admin-status-channel_analysis] #view svg line[style]{stroke:var(--line)!important}
[data-page^=admin-status-realtime] #view div[style*="background"],[data-page=admin-status-channel_analysis] #view div[style*="background"]{background:var(--surface)!important;border-radius:var(--r-md)}
[data-page^=admin-status-realtime] #view svg text,[data-page=admin-status-channel_analysis] #view svg text{fill:var(--ink-3)}
#applyreboot-section{line-height:300%}
/* diagnostics: LuCI spaces host + action with an inline 5px margin sized for its own, shorter controls */
[data-page=admin-network-diagnostics] .td>input{display:block;width:100%;margin:0 0 8px!important}
.breadcrumb,#modemenu{display:none!important}
.td-footer{margin-top:40px;padding-top:18px;border-top:1px solid var(--line-soft);font-size:12px;color:var(--ink-3);display:flex;flex-wrap:wrap;gap:6px 14px;justify-content:space-between}
.td-footer a{color:var(--ink-3)}
/* ============================== narrow content: LuCI tables & values ==============================
Keyed to the width of the content area, not the viewport: the icon rail at 761–1100px leaves
less room than a phone, so tables turn into cards wherever they would not fit. Modals are
containers too, so dialogs follow their own width. */
@container main (max-width:700px){
.cbi-section,.cbi-tblsection{padding:18px 16px;border-radius:18px}
.table,#view>.table{display:flex;flex-direction:column}
.tr{display:flex;flex-wrap:wrap;align-items:flex-end;border-top:1px solid var(--line-soft);padding:8px 0}
.table .tr:first-child{border-top:0}
.table .th,.table .td,.table .tr::before{flex:1 1 50%;align-self:flex-start;display:inline-block;border-top:0!important;padding:4px 6px;overflow:hidden;text-overflow:ellipsis}
.table .td.cbi-dropdown-open{overflow:visible}
.tr.table-titles,.tr.cbi-section-table-titles,.cbi-section-table-descr{display:none}
.tr[data-title]::before{display:block;flex:1 1 100%;font-size:15px;font-weight:500;padding:4px 6px}
.td[data-title]::before{content:attr(data-title);display:block;font-size:12px;color:var(--ink-3);font-weight:400;margin-bottom:2px;white-space:pre-line}
.td.cbi-section-actions{flex-basis:100%;max-width:100%;width:auto;text-align:left;padding:6px}
.td.cbi-section-actions>*{display:flex;justify-content:flex-start}
.td.cbi-section-actions .cbi-button:not(.drag-handle){flex:1 1 auto}
.table .tr:hover>.td{background:none!important}
.col-1{flex:1 1 30px!important}.col-2{flex:2 2 60px!important}.col-3{flex:3 3 90px!important}.col-4{flex:4 4 120px!important}.col-5{flex:5 5 150px!important}.col-6{flex:6 6 180px!important}.col-7{flex:7 7 210px!important}.col-8{flex:8 8 240px!important}.col-9{flex:9 9 270px!important}.col-10{flex:10 10 300px!important}
.hide-sm,.hide-xs{display:none!important}
#wifi_assoclist_table .td,[data-page=admin-status-processes] .td{flex-basis:50%!important}
[data-page=admin-network-network] .td[data-name=_ifacebox]{flex-basis:40%}
[data-page=admin-network-network] .td[data-name=_ifacestat]{flex-basis:60%}
[data-page=admin-status-overview] .td[width="33%"]{flex-basis:33%}
}
@container main (max-width:560px){
.table .td.cbi-value-field{flex-basis:100%;width:auto} /* flex-basis sizes cells; not the form-row width below */
.td.cbi-value-field[data-widget="CBI.DummyValue"],.td.cbi-value-field[data-widget="CBI.Button"],.td.cbi-value-field[data-widget="CBI.FlagValue"],.td[data-widget=dvalue],.td[data-widget=button],.td[data-widget=fvalue]{flex-basis:50%}
.table .td[data-name=_badge]{flex:0 0 auto}
.table .td[data-name=_stat]{flex:1 1 0}
.cbi-value{display:block;padding:12px 0}
.cbi-value>.cbi-value-title{display:block;padding:0 0 6px}
.cbi-value-field,input,select,textarea,.cbi-select,.cbi-dynlist,.cbi-dropdown:not(.btn):not(.cbi-button){width:100%}
input[type=checkbox],input[type=radio]{width:18px}
input.cbi-input-password{width:calc(100% - 46px)}
}
@media (max-width:760px){
input,select,textarea,.cbi-dropdown:not(.btn):not(.cbi-button)>ul>li{font-size:16px}
.modal{margin:4vh auto;padding:22px 18px 18px;border-radius:20px;width:94%}
.modal .button-row>button:first-of-type{margin-right:0}
.modal .button-row>*{flex:1 1 auto}
}
/* ============================== 7Dollar pages ============================== */
.td-head{display:flex;flex-wrap:wrap;align-items:flex-end;justify-content:space-between;gap:16px 24px;margin-bottom:26px}
.td-head>div{min-width:0;flex:1 1 320px}
.td-eyebrow{color:var(--ink-3);font-size:13px;margin:0 0 6px}
.td-head h1{margin:0}
.td-sub{color:var(--ink-2);margin:8px 0 0;font-size:15px}
/* Home: connection and live traffic share the top row (same height: the chart stretches).
Below, two columns stack cards at their own height (home.js balances them), so no card is stretched
to match a neighbour and left half empty. Phones get one column in reading order (--i). */
.td-grid{display:flex;flex-direction:column;gap:18px}
.td-pair,.td-cols{display:grid;gap:18px;min-width:0}
.td-col{display:flex;flex-direction:column;gap:18px;min-width:0}
@container main (min-width:640px){.td-cols{grid-template-columns:1fr 1fr;align-items:start}.td-col:only-child{grid-column:1/-1}}
@container main (min-width:980px){.td-pair,.td-cols{grid-template-columns:7fr 5fr}.td-pair>:only-child{grid-column:1/-1}}
@container main (max-width:639.98px){
.td-cols{display:flex;flex-direction:column}.td-col{display:contents}.td-col>.td-card{order:var(--i)}
}
.td-card{background:var(--surface);border:1px solid var(--line-soft);border-radius:var(--r-lg);box-shadow:var(--shadow);padding:22px 24px;position:relative;overflow:hidden;min-width:0;container-type:inline-size;animation:td-rise var(--slow) var(--ease) both}
.td-grid .td-card{animation-delay:min(calc(var(--i,0) * 40ms),.24s)}
.td-card-h{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:18px;min-width:0}
.td-card-h h3{font:500 13.5px/1.3 var(--sans);color:var(--ink-2);margin:0}
.td-card-h a{color:var(--ink-3)!important;font-size:13px;text-decoration:none!important;display:flex;align-items:center;gap:2px;white-space:nowrap}
.td-card-h a:hover{color:var(--accent)!important}
.td-card-h a svg{width:14px;height:14px}
.td-card-h .td-muted{font-size:12.5px}
.td-chip{display:inline-flex;align-items:center;gap:6px;height:24px;padding:0 10px;border-radius:99px;font-size:12px;font-weight:500;background:var(--ok-soft);color:var(--ok);white-space:nowrap}
.td-chip.mute{background:var(--surface-2);color:var(--ink-3)}
.td-chip.acc{background:var(--accent-soft);color:var(--accent-2)}
.td-chip.warn{background:var(--warn-soft);color:var(--warn)}
.td-chip.bad{background:var(--bad-soft);color:var(--bad)}
.td-dot{width:7px;height:7px;border-radius:50%;background:currentColor;flex:none}
.td-dot.live{animation:td-pulse 2.4s infinite}
@keyframes td-pulse{0%{box-shadow:0 0 0 0 color-mix(in srgb,var(--ok) 45%,transparent)}70%,100%{box-shadow:0 0 0 7px transparent}}
.td-big{font:500 clamp(30px,9cqi,40px)/1 var(--serif);letter-spacing:-.02em}
.td-unit{font-size:14px;color:var(--ink-3);margin-left:6px}
/* topology: pure flex, reflows on zoom */
.td-topo{display:flex;align-items:flex-start;gap:4px}
.td-node{flex:0 0 auto;width:clamp(64px,22cqi,120px);display:flex;flex-direction:column;align-items:center;text-align:center;min-width:0}
.td-node .ic{--s:clamp(46px,13cqi,58px);width:var(--s);height:var(--s);border-radius:30%;display:grid;place-items:center;background:var(--surface-2);color:var(--ink-2)}
.td-node.me .ic{background:var(--accent);color:var(--on-accent);box-shadow:0 10px 24px -12px var(--accent)}
.td-node.bad .ic{background:var(--bad-soft);color:var(--bad)}
.td-node .ic svg{width:24px;height:24px}
.td-node b{font-weight:500;margin-top:10px;font-size:14px;max-width:100%;overflow-wrap:anywhere}
.td-node small{color:var(--ink-3);font-size:12.5px}
.td-link{flex:1 1 0;min-width:18px;display:flex;flex-direction:column;align-items:center;gap:6px;padding-top:clamp(6px,2.6cqi,12px)}
.td-link .t{font-size:12px;color:var(--ink-3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%;line-height:17px}
.td-link .ln{position:relative;width:100%;height:2px;background:repeating-linear-gradient(90deg,var(--line) 0 6px,transparent 6px 11px)}
.td-link .ln i{position:absolute;top:-3px;width:8px;height:8px;border-radius:50%;background:var(--accent);animation:td-flow 2.6s linear infinite}
.td-link .ln i+i{animation-delay:1.3s;background:var(--second)}
.td-link.down .ln i{display:none}
.td-link.down .ln{background:repeating-linear-gradient(90deg,var(--bad) 0 6px,transparent 6px 11px);opacity:.6}
@keyframes td-flow{from{left:0;opacity:0}15%,85%{opacity:1}to{left:calc(100% - 8px);opacity:0}}
@container (max-width:430px){.td-link .t{visibility:hidden}.td-node small{display:none}}
.td-facts{display:grid;grid-template-columns:repeat(auto-fit,minmax(110px,1fr));gap:14px 8px;margin:22px 0 0;padding-top:18px;border-top:1px solid var(--line-soft)}
.td-facts dt{font-size:12.5px;color:var(--ink-3);font-weight:400}
.td-facts dd{font-size:14.5px;margin:3px 0 0;overflow-wrap:anywhere;color:var(--ink)}
.td-lines{display:grid;gap:6px;margin-top:14px}.td-lines:empty{display:none}
.td-lines p{display:flex;align-items:center;gap:8px;margin:0;font-size:13px;color:var(--ink-3);min-width:0}
.td-lines .td-dot{color:var(--ok)}.td-lines b{font-weight:500;color:var(--ink-2)}
.td-lines span:last-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* rate */
.k-rate{display:flex;flex-direction:column}
.td-rates{display:flex;flex-wrap:wrap;gap:12px 34px;align-items:flex-start}
.td-rate small{display:flex;align-items:center;gap:6px;color:var(--ink-3);font-size:12.5px;margin-bottom:8px}
.td-rate small i{width:8px;height:8px;border-radius:3px;background:var(--accent)}
.td-rate.up small i{background:var(--second)}
.td-rate.boot{margin-left:auto;text-align:right}.td-rate.boot small{justify-content:flex-end}.td-rate.boot span{font-size:14px;color:var(--ink-2);white-space:pre}
/* the chart takes whatever height the card has, so a taller neighbour never leaves a gap above it */
.td-chart{display:block;margin:0 -24px -22px;padding-top:18px;width:calc(100% + 48px);flex:1 0 136px}
/* device rows */
.td-dev{display:flex;align-items:center;gap:13px;padding:10px 0;border-top:1px solid var(--line-soft);min-width:0}
.td-dev:first-child{border-top:0;padding-top:0}
.td-av{width:36px;height:36px;border-radius:11px;background:var(--surface-2);display:grid;place-items:center;color:var(--ink-2);flex:none}
.td-dev .nm{flex:1;min-width:0}
.td-dev .nm b{font-weight:500;display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.td-dev .nm small{color:var(--ink-3);font-size:12.5px;display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.td-dev .rt{text-align:right;font-size:13px;width:clamp(72px,24cqi,120px);flex:none;color:var(--ink);white-space:nowrap}
.td-dev .rt.idle{color:var(--ink-3)}
.td-run .nm a{color:inherit;text-decoration:none}.td-run .nm a:hover b{color:var(--accent)}
.td-run .rt{width:auto;color:var(--ink-2)}a.rt{display:inline-flex;align-items:center;gap:4px;text-decoration:none!important}a.rt:hover{color:var(--accent)}
.td-run .td-seg{padding:2px;border-radius:9px}.td-run .td-seg button{min-height:26px;padding:0 10px;font-size:12px;border-radius:7px}
@container (max-width:420px){.td-run{flex-wrap:wrap}.td-run :is(.rt,.td-seg){flex-basis:calc(100% - 49px);margin-left:49px;text-align:left}.td-run .td-seg button{flex:1}}
/* A wide device card lists two per row instead of stretching each row across the screen. */
@container (min-width:760px){#td-devs{display:grid;grid-template-columns:1fr 1fr;column-gap:40px}#td-devs>:not(.td-dev){grid-column:1/-1}#td-devs>.td-dev:nth-child(2){border-top:0;padding-top:0}}
.td-bar{height:4px;border-radius:4px;background:var(--surface-2);margin-top:5px;overflow:hidden}
.td-bar i{display:block;height:100%;border-radius:4px;background:var(--accent);transition:width var(--data) var(--ease)}
.td-empty-row{padding:18px 0;color:var(--ink-3);text-align:center;font-size:13px}
.td-more{display:block;text-align:center;font-size:13px;color:var(--ink-3)!important;padding-top:12px;border-top:1px solid var(--line-soft);margin-top:4px;text-decoration:none!important}
.td-more:hover{color:var(--accent)!important}
/* wifi */
.td-ssid{font:500 34px/1.05 var(--serif);margin:2px 0 10px;overflow-wrap:anywhere}
.td-ssid-s{font-size:13px;margin:0 0 16px}
/* plugin card */
.td-seg{display:inline-flex;flex-wrap:wrap;padding:3px;border-radius:11px;background:var(--surface-2);gap:2px;max-width:100%}
.td-seg button{min-height:30px;padding:0 14px;border-radius:9px;color:var(--ink-2);font-size:13px;white-space:nowrap;background:none;border:0;cursor:pointer}
.td-seg button:hover{color:var(--ink)}
.td-seg button.on{background:var(--surface);color:var(--ink);box-shadow:0 1px 3px rgba(0,0,0,.08);font-weight:500}
.td-kv{display:grid;grid-template-columns:repeat(auto-fit,minmax(90px,1fr));gap:10px;margin:0 0 16px}
.td-kv>div{background:var(--canvas);border-radius:12px;padding:10px 12px;min-width:0}
.td-kv dt{font-size:12px;color:var(--ink-3)}
.td-kv dd{margin:2px 0 0;font-size:15px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.td-kv dt{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.td-kv dd+dd{font-size:12px;color:var(--ink-3);margin-top:1px}
.td-kv .off dd{color:var(--ink-3)}
.td-card>.td-kv:last-child,#td-wifi>.td-kv,#td-ports{margin-bottom:0}
/* storage */
.td-meter+.td-meter{margin-top:16px}
.td-meter p{display:flex;justify-content:space-between;align-items:baseline;gap:10px;margin:0 0 7px;font-size:13.5px}
.td-meter p b{font-weight:500}
.td-meter p span{color:var(--ink-3);font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.td-meter .td-bar{height:6px}
.td-bar.full i{background:var(--warn)}
/* system rings */
.td-rings{display:grid;grid-auto-flow:column;grid-auto-columns:minmax(0,1fr);gap:14px}
.td-ring{display:flex;align-items:center;gap:12px;min-width:0}
.td-ring svg{width:58px;height:58px;transform:rotate(-90deg);flex:none}
.td-ring circle{fill:none;stroke-width:5}
.td-ring .bg{stroke:var(--surface-2)}
.td-ring .fg{stroke:var(--accent);stroke-linecap:round;transition:stroke-dashoffset var(--data) var(--ease)}
.td-ring .fg.warm{stroke:var(--warn)}
.td-ring .fg.hot{stroke:var(--bad)}
.td-ring b{font:500 20px/1 var(--serif);display:block}
.td-ring small{color:var(--ink-3);font-size:12.5px;white-space:nowrap}
@container (max-width:470px){.td-ring{flex-direction:column;text-align:center;gap:6px}}
.td-tags{display:flex;flex-wrap:wrap;gap:8px;margin-top:20px}.td-tags:empty{display:none}
.td-tag{display:inline-flex;align-items:center;gap:6px;padding:5px 11px;border-radius:99px;border:1px solid var(--line);font-size:12.5px;color:var(--ink-2)}
.td-tag svg{width:14px;height:14px;color:var(--ok)}
.td-acts{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}
@container (min-width:620px){.td-acts{grid-template-columns:repeat(4,minmax(0,1fr))}}
@container (max-width:300px){.td-acts{grid-template-columns:1fr}}
.td-act{display:flex;gap:12px;align-items:center;padding:14px;border-radius:var(--r-md);background:var(--canvas);text-align:left;transition:background var(--fast) var(--ease),transform var(--fast) var(--ease);min-width:0;color:var(--ink)!important;text-decoration:none!important}
.td-act:hover{background:var(--hover);transform:translateY(-1px)}
.td-act svg{color:var(--accent);flex:none}
.td-act b{font-weight:500;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* settings rows */
.td-sect{padding:6px 24px;margin-bottom:18px}
.td-sect-t{font:500 18px var(--serif);padding:18px 0 2px;margin:0}
.td-sect-d{color:var(--ink-3);font-size:13px;padding-bottom:8px;margin:0}
.td-row{display:flex;flex-wrap:wrap;align-items:center;gap:12px 24px;padding:16px 0;border-top:1px solid var(--line-soft)}
.td-sect-t+.td-row,.td-sect-d+.td-row,.td-rows>.td-row:first-child{border-top:0}
.td-row .lb{flex:1 1 220px;min-width:0}
.td-row .lb b{font-weight:500;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.td-row .lb small{color:var(--ink-3);font-size:12.5px;display:block}
.td-row.off{opacity:.55}
.td-switch{width:44px;height:26px;border-radius:99px;background:var(--line);position:relative;transition:background var(--base) var(--ease);flex:none;border:0;cursor:pointer;padding:0}
.td-switch::after{content:"";position:absolute;top:3px;left:3px;width:20px;height:20px;border-radius:50%;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.2);transition:transform var(--base) var(--ease)}
.td-switch.on{background:var(--accent)}
.td-switch.on::after{transform:translateX(18px);background:var(--on-accent)}
[data-palette=graphite] .td-switch.on::after{background:var(--surface)}
.td-switch[disabled]{cursor:not-allowed}
.td-palettes{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:12px;padding:6px 0 22px}
.td-pal{border-radius:16px;padding:8px;border:1.5px solid var(--line-soft);background:var(--surface);text-align:left;transition:border-color var(--fast) var(--ease),transform var(--fast) var(--ease);color:var(--ink);cursor:pointer;font:inherit}
.td-pal:hover{transform:translateY(-2px)}
.td-pal.on{border-color:var(--accent)}
.td-pal .pv{height:86px;border-radius:11px;background:var(--canvas);padding:10px;display:flex;gap:8px;overflow:hidden}
.td-pal .pv .sb{width:22%;display:flex;flex-direction:column;gap:5px}
.td-pal .pv .sb i{height:6px;border-radius:3px;background:var(--line)}
.td-pal .pv .sb i:first-child{background:var(--accent)}
.td-pal .pv .cd{flex:1;border-radius:8px;background:var(--surface);border:1px solid var(--line-soft);padding:8px;display:flex;flex-direction:column;gap:6px}
.td-pal .pv .cd i{height:5px;border-radius:3px;background:var(--surface-2)}
.td-pal .pv .cd i:first-child{width:45%;height:9px;background:var(--ink);opacity:.85}
.td-pal .pv .cd u{margin-top:auto;align-self:flex-start;width:42%;height:12px;border-radius:5px;background:var(--accent)}
.td-pal .nm{display:flex;align-items:center;justify-content:space-between;padding:10px 6px 4px;font-weight:500}
.td-pal .ck{width:20px;height:20px;border-radius:50%;background:var(--accent);color:var(--on-accent);display:grid;place-items:center;opacity:0;transition:opacity var(--fast) var(--ease)}
.td-pal.on .ck{opacity:1}
.td-pal .ck svg{width:12px;height:12px;stroke-width:2.4}
.td-hint{display:flex;gap:10px;padding:14px 16px;margin:4px 0 20px;border-radius:var(--r-md);background:var(--canvas);color:var(--ink-2);font-size:13px}
.td-hint svg{color:var(--ink-3);margin-top:1px}
.td-toast{position:fixed;left:50%;bottom:28px;transform:translateX(-50%);z-index:1200;padding:10px 18px;border-radius:99px;background:var(--ink);color:var(--canvas);font-size:13px;box-shadow:var(--shadow-pop);animation:td-fade var(--base) var(--ease)}
@media (max-width:760px){.td-toast{bottom:92px}}
/* apps */
.td-apps{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:14px}
.td-app-t{display:flex;gap:14px;align-items:center;padding:18px;border-radius:var(--r-lg);background:var(--surface);border:1px solid var(--line-soft);box-shadow:var(--shadow);text-decoration:none!important;min-width:0;color:var(--ink)!important;transition:transform var(--fast) var(--ease),border-color var(--fast) var(--ease);animation:td-rise var(--slow) var(--ease) both}
.td-app-t:hover{transform:translateY(-2px);border-color:var(--line)}
.td-app-t .td-av{width:44px;height:44px;border-radius:13px}
.td-app-t:hover .td-av{color:var(--accent)}
.td-app-t div{min-width:0}
.td-app-t b{font-weight:500;display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.td-app-t small{color:var(--ink-3);font-size:12.5px;display:block}
.td-app-t.add{border-style:dashed;box-shadow:none;background:none;color:var(--ink-3)!important}
.td-app-t.add .td-av{background:none;border:1px dashed var(--line)}
/* devices page */
.td-devlist .td-dev{padding:14px 0}
.td-devlist .td-dev:first-child{padding-top:0}
.td-dev .meta{display:flex;flex-wrap:wrap;gap:4px 16px;font-size:12.5px;color:var(--ink-3);justify-content:flex-end;flex:0 1 auto;min-width:0}
.td-dev .meta span{white-space:nowrap}
.td-dev .meta b{font-weight:500;color:var(--ink-2)}
@container (max-width:560px){.td-devlist .td-dev{flex-wrap:wrap}.td-dev .meta{flex-basis:100%;justify-content:flex-start;padding-left:49px}}
.td-stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;margin-bottom:18px}
.td-stat{background:var(--surface);border:1px solid var(--line-soft);border-radius:var(--r-md);padding:14px 16px;box-shadow:var(--shadow);width:100%;margin:0;text-align:left;font:inherit;color:inherit;cursor:pointer;transition:background var(--fast) var(--ease),border-color var(--fast) var(--ease)}
.td-stat:hover{border-color:var(--line)}
.td-stat[aria-pressed=true]{background:var(--accent-soft);border-color:color-mix(in srgb,var(--accent) 45%,var(--line))}.td-stat[aria-pressed=true] small{color:var(--accent-2)}
.td-stat small{display:block;color:var(--ink-3);font-size:12.5px}
.td-stat b{font:500 26px/1.2 var(--serif)}
/* ============================== login ============================== */
.td-login{position:relative;min-height:100vh;display:grid;place-items:center;overflow:hidden;background:var(--canvas);padding:24px}
.td-login .glow{position:absolute;width:900px;height:900px;right:-260px;bottom:-420px;border-radius:50%;background:radial-gradient(closest-side,color-mix(in srgb,var(--accent) 22%,transparent),transparent);pointer-events:none}
.td-login .arcs{position:absolute;right:-120px;bottom:-120px;width:620px;height:620px;color:var(--accent);opacity:.22;pointer-events:none}
.td-lbox{position:relative;z-index:1;width:min(360px,100%);text-align:center;animation:td-rise var(--slow) var(--ease)}
.td-lbox h1{font-size:40px;margin:0 0 8px}
.td-lbox .td-sub{margin:0 0 36px}
.td-field{display:flex;align-items:center;gap:10px;width:100%;height:50px;padding:0 16px;border:1px solid var(--line);border-radius:14px;background:var(--surface);transition:border-color var(--fast) var(--ease),box-shadow var(--fast) var(--ease);margin-bottom:10px;color:var(--ink-3);text-align:left;cursor:text}
.td-field:focus-within{border-color:var(--accent);box-shadow:0 0 0 4px var(--tint)}
.td-field input{flex:1;border:0!important;background:none!important;box-shadow:none!important;outline:0;height:100%!important;padding:0!important;font-size:15px;min-width:0;width:auto;color:var(--ink)}
.td-field .eye{background:none;border:0;color:var(--ink-3);cursor:pointer;display:grid;place-items:center;padding:4px;border-radius:8px}
.td-field .eye:hover{color:var(--ink)}
.td-lbox .btn{width:100%;height:50px;border-radius:14px;margin-top:4px;font-size:15px}
.td-lerr{margin:0 0 12px;padding:10px 14px;border-radius:12px;background:var(--bad-soft);color:var(--bad);font-size:13px;text-align:left;animation:td-shake var(--slow) var(--ease)}
@keyframes td-shake{20%,60%{transform:translateX(-5px)}40%,80%{transform:translateX(5px)}}
.td-luser{font-size:12.5px;color:var(--ink-3);margin-top:16px}
.td-luser button{background:none;border:0;color:var(--ink-2);text-decoration:underline;text-underline-offset:3px;cursor:pointer;font:inherit}
.td-lfoot{position:absolute;bottom:28px;left:0;right:0;text-align:center;color:var(--ink-3);font-size:12.5px;z-index:1;padding:0 16px}
@@ -0,0 +1,41 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="home" viewBox="0 0 24 24"><path d="M3.5 10.5 12 3.8l8.5 6.7V19a1.5 1.5 0 0 1-1.5 1.5h-4.2v-5.8H9.2v5.8H5A1.5 1.5 0 0 1 3.5 19z"/></symbol>
<symbol id="devices" viewBox="0 0 24 24"><rect x="2.5" y="5" width="13.5" height="10" rx="1.6"/><path d="M1.5 18.5h16"/><rect x="18" y="8.5" width="4.5" height="10" rx="1.2"/></symbol>
<symbol id="wifi" viewBox="0 0 24 24"><path d="M2.5 9a14 14 0 0 1 19 0"/><path d="M5.8 12.6a9.4 9.4 0 0 1 12.4 0"/><path d="M9.1 16.1a4.6 4.6 0 0 1 5.8 0"/><circle cx="12" cy="19.4" r=".9" fill="currentColor"/></symbol>
<symbol id="globe" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3c2.6 2.6 3.8 5.6 3.8 9s-1.2 6.4-3.8 9M12 3C9.4 5.6 8.2 8.6 8.2 12s1.2 6.4 3.8 9"/></symbol>
<symbol id="grid" viewBox="0 0 24 24"><rect x="4" y="4" width="6.5" height="6.5" rx="1.8"/><rect x="13.5" y="4" width="6.5" height="6.5" rx="1.8"/><rect x="4" y="13.5" width="6.5" height="6.5" rx="1.8"/><path d="M16.75 13.5v6.5M13.5 16.75H20"/></symbol>
<symbol id="route" viewBox="0 0 24 24"><circle cx="6" cy="18.5" r="2.5"/><circle cx="18" cy="5.5" r="2.5"/><path d="M8.5 18.5H15a3.2 3.2 0 0 0 0-6.4H9a3.2 3.2 0 0 1 0-6.4h6.5"/></symbol>
<symbol id="folder" viewBox="0 0 24 24"><path d="M3.5 7.5A1.5 1.5 0 0 1 5 6h4.5l2 2.2H19a1.5 1.5 0 0 1 1.5 1.5V17A1.5 1.5 0 0 1 19 18.5H5A1.5 1.5 0 0 1 3.5 17z"/></symbol>
<symbol id="plus" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></symbol>
<symbol id="shield" viewBox="0 0 24 24"><path d="M12 3 4.8 5.8v5.6c0 4.4 3 8.2 7.2 9.6 4.2-1.4 7.2-5.2 7.2-9.6V5.8z"/><path d="m9 12 2.2 2.2L15.5 10"/></symbol>
<symbol id="gear" viewBox="0 0 24 24"><path d="M4 7h9M17 7h3M4 17h3M11 17h9"/><circle cx="15" cy="7" r="2.2"/><circle cx="9" cy="17" r="2.2"/></symbol>
<symbol id="search" viewBox="0 0 24 24"><circle cx="11" cy="11" r="6.5"/><path d="m20 20-4.2-4.2"/></symbol>
<symbol id="moon" viewBox="0 0 24 24"><path d="M20 14.5A8 8 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5z"/></symbol>
<symbol id="sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5.3 5.3l1.4 1.4M17.3 17.3l1.4 1.4M5.3 18.7l1.4-1.4M17.3 6.7l1.4-1.4"/></symbol>
<symbol id="logout" viewBox="0 0 24 24"><path d="M14 4h4.5A1.5 1.5 0 0 1 20 5.5v13a1.5 1.5 0 0 1-1.5 1.5H14M10 16l-4-4 4-4M6 12h10"/></symbol>
<symbol id="arrow" viewBox="0 0 24 24"><path d="M5 12h14M13 6l6 6-6 6"/></symbol>
<symbol id="chev" viewBox="0 0 24 24"><path d="m9 6 6 6-6 6"/></symbol>
<symbol id="modem" viewBox="0 0 24 24"><rect x="3" y="12" width="18" height="7" rx="2"/><path d="M7 15.5h.01M10.5 15.5h.01M8 12 6 5M16 12l2-7"/></symbol>
<symbol id="router" viewBox="0 0 24 24"><rect x="3" y="8" width="18" height="9" rx="2.2"/><path d="M7 12.5h.01M10 12.5h.01M14 12.5h4M7 17v2M17 17v2"/></symbol>
<symbol id="desktop" viewBox="0 0 24 24"><rect x="5" y="5" width="14" height="10" rx="2"/><path d="M9 19h6M12 15v4"/></symbol>
<symbol id="laptop" viewBox="0 0 24 24"><rect x="5" y="5" width="14" height="10" rx="1.6"/><path d="M2.5 18.5h19"/></symbol>
<symbol id="phone" viewBox="0 0 24 24"><rect x="6.5" y="2.8" width="11" height="18.4" rx="2.6"/><path d="M10.5 18h3"/></symbol>
<symbol id="watch" viewBox="0 0 24 24"><rect x="6.5" y="6.5" width="11" height="11" rx="3"/><path d="M9 6.5 9.6 3h4.8l.6 3.5M9 17.5l.6 3.5h4.8l.6-3.5"/></symbol>
<symbol id="tablet" viewBox="0 0 24 24"><rect x="4.5" y="3" width="15" height="18" rx="2.4"/><path d="M10.5 18h3"/></symbol>
<symbol id="tv" viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="12" rx="2"/><path d="M8 20.5h8"/></symbol>
<symbol id="check" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="m8.5 12 2.4 2.4 4.6-4.8"/></symbol>
<symbol id="tick" viewBox="0 0 24 24"><path d="m5 12.5 4.5 4.5L19 7.5"/></symbol>
<symbol id="info" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 11v5.5M12 7.5h.01"/></symbol>
<symbol id="backup" viewBox="0 0 24 24"><path d="M12 4v11M7.5 10.5 12 15l4.5-4.5M4.5 16v2.5A1.5 1.5 0 0 0 6 20h12a1.5 1.5 0 0 0 1.5-1.5V16"/></symbol>
<symbol id="refresh" viewBox="0 0 24 24"><path d="M19.5 12A7.5 7.5 0 1 1 17 6.4M19.5 4v4h-4"/></symbol>
<symbol id="power" viewBox="0 0 24 24"><path d="M12 3.5v8M7.2 6.3a7.5 7.5 0 1 0 9.6 0"/></symbol>
<symbol id="pulse" viewBox="0 0 24 24"><path d="M3 12h4l2.5-6 5 12 2.5-6h4"/></symbol>
<symbol id="doc" viewBox="0 0 24 24"><path d="M6.5 3.5h7l4 4v13h-11z"/><path d="M13.5 3.5v4h4M9 12.5h6M9 16h4"/></symbol>
<symbol id="eye" viewBox="0 0 24 24"><path d="M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12z"/><circle cx="12" cy="12" r="2.8"/></symbol>
<symbol id="lock" viewBox="0 0 24 24"><rect x="5" y="10.5" width="14" height="10" rx="2.2"/><path d="M8.5 10.5V8a3.5 3.5 0 0 1 7 0v2.5"/></symbol>
<symbol id="sliders" viewBox="0 0 24 24"><path d="M5 20v-6M5 10V4M12 20v-8M12 8V4M19 20v-4M19 12V4M3 14h4M10 8h4M17 16h4"/></symbol>
<symbol id="box" viewBox="0 0 24 24"><path d="M12 3 4 7v10l8 4 8-4V7z"/><path d="m4 7 8 4 8-4M12 11v10"/></symbol>
<symbol id="clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3.5 2"/></symbol>
<symbol id="vpn" viewBox="0 0 24 24"><rect x="5" y="10.5" width="14" height="10" rx="2.2"/><path d="M8.5 10.5V8a3.5 3.5 0 0 1 7 0v2.5M12 14.5v2"/></symbol>
<symbol id="palette" viewBox="0 0 24 24"><path d="M12 3a9 9 0 1 0 0 18c1.2 0 1.8-.8 1.8-1.7 0-1.3-1-1.6-1-2.6 0-.9.7-1.5 1.6-1.5H17a4 4 0 0 0 4-4C21 6.6 17 3 12 3z"/><circle cx="7.5" cy="11.5" r="1" fill="currentColor"/><circle cx="10.5" cy="7.5" r="1" fill="currentColor"/><circle cx="15" cy="7.8" r="1" fill="currentColor"/></symbol>
</svg>

After

Width:  |  Height:  |  Size: 5.5 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="b" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#2E2B27"/><stop offset="1" stop-color="#151413"/></linearGradient><linearGradient id="s" x1="20" y1="0" x2="80" y2="0" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#8E8BEA"/><stop offset=".45" stop-color="#E07A8E"/><stop offset="1" stop-color="#FFC9A0"/></linearGradient><radialGradient id="g"><stop offset="0" stop-color="#FFC9A0" stop-opacity=".55"/><stop offset="1" stop-color="#FFC9A0" stop-opacity="0"/></radialGradient></defs><rect width="100" height="100" rx="23" fill="url(#b)"/><path d="M22 60c10-26 20-26 28-8s18 18 28-12" fill="none" stroke="url(#s)" stroke-width="10" stroke-linecap="round"/><path d="M23.5 55c8-17 15-19 21-12" fill="none" stroke="#fff" stroke-opacity=".35" stroke-width="2.4" stroke-linecap="round"/><circle cx="78" cy="40" r="15" fill="url(#g)"/><circle cx="78" cy="40" r="5" fill="#FFF3E6"/></svg>

After

Width:  |  Height:  |  Size: 993 B

@@ -0,0 +1,695 @@
'use strict';
'require baseclass';
'require rpc';
'require fs';
'require ui';
/*
* Shared helpers for the 7Dollar theme: menu grouping, settings storage,
* formatting and the device list used by the home and devices pages.
*/
const ZH = /^zh/i.test(document.documentElement.getAttribute('lang') || '');
const T = (zh, en) => ZH ? zh : en;
const SPRITE = '/luci-static/7dollar/icons.svg';
const ACTIVE = /\/7dollar$/.test(L.env.media || '');
const callUciGet = rpc.declare({ object: 'uci', method: 'get', params: [ 'config', 'section' ], expect: { values: {} } });
const callUciGetType = rpc.declare({ object: 'uci', method: 'get', params: [ 'config', 'type' ], expect: { values: {} } });
const callUciSet = rpc.declare({ object: 'uci', method: 'set', params: [ 'config', 'section', 'values' ] });
const callUciAdd = rpc.declare({ object: 'uci', method: 'add', params: [ 'config', 'type', 'name', 'values' ] });
const callUciDelete = rpc.declare({ object: 'uci', method: 'delete', params: [ 'config', 'section', 'options' ] });
const callUciCommit = rpc.declare({ object: 'uci', method: 'commit', params: [ 'config' ] });
const callFileRead64 = rpc.declare({ object: 'file', method: 'read', params: [ 'path', 'base64' ], expect: { data: '' } });
const callLeases = rpc.declare({ object: 'luci-rpc', method: 'getDHCPLeases', expect: { '': {} } });
const callHints = rpc.declare({ object: 'luci-rpc', method: 'getHostHints', expect: { '': {} } });
const callWireless = rpc.declare({ object: 'luci-rpc', method: 'getWirelessDevices', expect: { '': {} } });
const callAssoc = rpc.declare({ object: 'iwinfo', method: 'assoclist', params: [ 'device' ], expect: { results: [] } });
const callDevices = rpc.declare({ object: 'luci-rpc', method: 'getNetworkDevices', expect: { '': {} } });
const callIfDump = rpc.declare({ object: 'network.interface', method: 'dump', expect: { interface: [] } });
const callBoard = rpc.declare({ object: 'system', method: 'board' });
const callInfo = rpc.declare({ object: 'system', method: 'info' });
const callServices = rpc.declare({ object: 'service', method: 'list', expect: { '': {} } });
const callMounts = rpc.declare({ object: 'luci', method: 'getMountPoints', expect: { result: [] } });
const DEFAULTS = { palette: 'clay', mode: 'auto', hidden_cards: '', shown_cards: '' };
const OPT_IN = [ 'apps' ]; // cards that are off until switched on (they show plugin information)
const words = s => String(s || '').split(/[\s,]+/).filter(Boolean);
/* Whether a home card is on: normal cards unless hidden, opt-in cards only when shown. */
function cardOn(conf, key) {
return OPT_IN.indexOf(key) > -1 ? words(conf.shown_cards).indexOf(key) > -1 : words(conf.hidden_cards).indexOf(key) < 0;
}
const PALETTES = [
[ 'clay', T('陶土', 'Clay') ],
[ 'celadon', T('青瓷', 'Celadon') ],
[ 'mist', T('雾蓝', 'Mist') ],
[ 'dusk', T('暮紫', 'Dusk') ],
[ 'graphite', T('石墨', 'Graphite') ]
];
/* Menu regrouping: native LuCI pages -> seven plain-language sections. `pages` are the everyday
* ones, shown as pills; `more` and anything matched by `prefix` sit behind the group's "More" pill.
* Plugins also put themselves under System or Status (other themes' settings, file transfer, disk
* tools ...); those are apps. So System's prefixes only take LuCI's own pages, told apart by the
* view they open (`own`), which also covers native pages added in newer LuCI versions. */
const GROUPS = [
{ id: 'home', icon: 'home', label: T('总览', 'Overview'), pages: [ 'admin/home' ] },
{ id: 'devices', icon: 'devices', label: T('设备', 'Devices'), pages: [ 'admin/clients' ] },
{ id: 'wifi', icon: 'wifi', label: 'Wi‑Fi', pages: [ 'admin/network/wireless', 'admin/status/channel_analysis' ] },
{ id: 'internet', icon: 'globe', label: T('上网', 'Internet'),
pages: [ 'admin/network/network', 'admin/network/dhcp', 'admin/network/dns', 'admin/network/diagnostics' ],
more: [ 'admin/network/routes', 'admin/status/routes', 'admin/status/routesj', 'admin/network/switch' ],
prefix: [ 'admin/network' ] },
{ id: 'security', icon: 'shield', label: T('安全', 'Security'), mob: false,
pages: [ 'admin/network/firewall', 'admin/status/nftables', 'admin/status/iptables', 'admin/system/admin' ] },
{ id: 'apps', icon: 'grid', label: T('应用', 'Apps'), mob: false, pages: [ 'admin/apps' ],
prefix: [ 'admin/services', 'admin/vpn', 'admin/nas', 'admin/docker', 'admin/statistics' ] },
{ id: 'system', icon: 'gear', label: T('系统', 'System'), short: T('更多', 'More'),
pages: [ 'admin/system/system', 'admin/system/appearance', 'admin/status/overview', 'admin/status/logs',
'admin/system/package-manager', 'admin/system/opkg', 'admin/system/flash', 'admin/system/reboot' ],
more: [ 'admin/dashboard', 'admin/status/realtime', 'admin/system/crontab', 'admin/system/startup', 'admin/status/processes', 'admin/system/mounts',
'admin/system/leds', 'admin/status/syslog', 'admin/status/dmesg', 'admin/status/dashboard' ],
prefix: [ 'admin/system', 'admin/status' ], own: true }
];
/* A page LuCI itself ships: it opens one of LuCI's status/system views, or points into them.
* (The patterns sit in constants: the buildroot's jsmin misreads a regex that follows `return`.) */
const OWN_VIEW = /^(status|system|admin_status)\//, OWN_ALIAS = /^admin\/(status|system)\//;
function own(node) {
const a = (node && node.action) || {}, path = a.path || '';
return OWN_VIEW.test(path) || (a.type == 'alias' && OWN_ALIAS.test(path));
}
const LABELS = {
'admin/home': T('总览', 'Overview'),
'admin/dashboard': T('仪表盘', 'Dashboard'),
'admin/clients': T('设备', 'Devices'),
'admin/apps': T('全部应用', 'All apps'),
'admin/network/wireless': T('无线网络', 'Wireless'),
'admin/status/channel_analysis': T('信道分析', 'Channel analysis'),
'admin/network/network': T('网络接口', 'Interfaces'),
'admin/network/diagnostics': T('网络诊断', 'Diagnostics'),
'admin/network/routes': T('静态路由', 'Static routes'),
'admin/status/routes': T('路由表', 'Routing table'),
'admin/status/routesj': T('路由表', 'Routing table'),
'admin/network/switch': T('交换机', 'Switch'),
'admin/network/firewall': T('防火墙', 'Firewall'),
'admin/status/nftables': T('防火墙规则', 'Firewall status'),
'admin/status/iptables': T('防火墙规则', 'Firewall status'),
'admin/system/admin': T('管理员', 'Administration'),
'admin/system/system': T('通用', 'General'),
'admin/system/appearance': T('外观与首页', 'Appearance'),
'admin/status/overview': T('运行状态', 'Status'),
'admin/status/realtime': T('实时监控', 'Realtime'),
'admin/status/logs': T('日志', 'Logs'),
'admin/status/processes': T('进程', 'Processes'),
'admin/system/package-manager': T('软件包', 'Software'),
'admin/system/opkg': T('软件包', 'Software'),
'admin/system/flash': T('备份与升级', 'Backup & upgrade'),
'admin/system/startup': T('启动项', 'Startup'),
'admin/system/crontab': T('计划任务', 'Scheduled tasks'),
'admin/system/mounts': T('挂载点', 'Mount points'),
'admin/system/leds': T('指示灯', 'LEDs'),
'admin/system/reboot': T('重启', 'Reboot'),
'admin/services/openclash': 'OpenClash',
'admin/services/ksmbd': T('文件共享', 'File sharing')
};
const APP_ICONS = [
[ /clash|passwall|homeproxy|ssr|v2ray|xray|sing|mihomo|proxy|nikki|daed/i, 'route' ],
[ /smb|samba|ksmbd|nfs|ftp|disk|share|alist|aria|transmission|qbit|minidlna/i, 'folder' ],
[ /vpn|wireguard|openvpn|zerotier|tailscale|frp|ipsec/i, 'vpn' ],
[ /ddns|dns|adguard|mosdns|smartdns/i, 'globe' ],
[ /docker|dockerman/i, 'box' ],
[ /stat|nlbw|bandwidth|netdata/i, 'pulse' ],
[ /upnp|qos|sqm|eqos/i, 'sliders' ],
[ /wol|wake/i, 'power' ],
[ /cron|sched|time/i, 'clock' ]
];
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
}
function icon(id, cls) {
return `<svg class="i${cls ? ' ' + cls : ''}" aria-hidden="true"><use href="${SPRITE}#${id}"></use></svg>`;
}
function html(s) {
const t = document.createElement('template');
t.innerHTML = s.trim();
return t.content.childNodes.length == 1 ? t.content.firstChild : t.content;
}
function title(path, node) {
if (LABELS[path])
return LABELS[path];
return node && node.title ? _(node.title) : path.split('/').pop();
}
/* ui.menu.getChildren() replaces alias nodes (e.g. Firewall → Zones) with a copy of
* their target, which drops the alias's own sub-pages. Always keep the real node. */
function realNode(parent, child) {
return (parent.children && parent.children[child.name]) || child;
}
/* True if the menu has this page. LuCI only lists pages whose dependencies are met, so this
* doubles as a free feature check (e.g. Wireless needs a wifi-device, OpenClash needs the app). */
function menuHas(tree, path) {
let n = tree;
for (const k of ('admin/' + path).split('/')) n = n && n.children && n.children[k];
return !!n;
}
/* The software page: package-manager since 24.10, opkg on 23.05; null when neither app is installed. */
function software(tree) {
return [ 'system/package-manager', 'system/opkg' ].find(p => menuHas(tree, p)) || null;
}
let menuPromise;
/* A menu cached before switching themes can omit our home page. Refresh it once and share the
* result between navigation and views; never invent entries that the server did not authorize. */
function loadMenu() {
if (!menuPromise)
menuPromise = ui.menu.load().then(tree => {
const home = tree && tree.children && tree.children.admin && tree.children.admin.children && tree.children.admin.children.home;
if (!ACTIVE || (home && home.satisfied && home.action && home.action.path == '7dollar/home'))
return tree;
ui.menu.flushCache();
ui.menu.menu = null;
return ui.menu.load();
});
return menuPromise;
}
function flatten(tree) {
/* Returns [{ path, node, title, group }] for every navigable page. */
const admin = tree && tree.children && tree.children.admin;
const out = [];
if (!admin)
return out;
ui.menu.getChildren(admin).forEach(t => {
const top = realNode(admin, t);
if (top.name == 'logout')
return;
const tpath = 'admin/' + top.name;
const kids = ui.menu.getChildren(top);
if (!kids.length)
out.push({ path: tpath, node: top });
else
kids.forEach(k => out.push({ path: tpath + '/' + k.name, node: realNode(top, k) }));
});
out.forEach(p => {
p.title = title(p.path, p.node);
p.group = groupOf(p.path, p.node);
});
return out;
}
function groupOf(path, node) {
for (const g of GROUPS)
if (g.pages.concat(g.more || []).indexOf(path) > -1)
return g.id;
for (const g of GROUPS)
for (const pre of (g.prefix || []))
if ((path == pre || path.indexOf(pre + '/') == 0) && (!g.own || own(node)))
return g.id;
return 'apps';
}
function grouped(pages) {
return GROUPS.map(g => {
const order = g.pages.concat(g.more || []), list = pages.filter(p => p.group == g.id);
list.sort((a, b) => {
const ia = order.indexOf(a.path), ib = order.indexOf(b.path);
return (ia < 0 ? 999 : ia) - (ib < 0 ? 999 : ib);
});
return Object.assign({}, g, { list: list });
}).filter(g => g.list.length);
}
function appIcon(path) {
for (const [ re, id ] of APP_ICONS)
if (re.test(path))
return id;
return 'grid';
}
/* The procd service behind an app page, matched by name (services/openclash ↔ openclash,
* nas/ksmbd ↔ ksmbd, services/upnp ↔ miniupnpd): { name, running }, or null if none fits. */
function service(services, path) {
const leaf = path.split('/').pop().toLowerCase().replace(/[^a-z0-9]/g, '');
const name = Object.keys(services || {}).find(k => {
const s = k.toLowerCase().replace(/[^a-z0-9]/g, '');
return s == leaf || (leaf.length > 3 && s.indexOf(leaf) > -1) || (s.length > 3 && leaf.indexOf(s) > -1);
});
if (!name) return null;
const inst = services[name].instances || {};
return { name: name, running: Object.keys(inst).some(k => inst[k].running) };
}
/* ---------------- settings (stored in /etc/config/7dollar) ---------------- */
function loadSettings() {
return L.resolveDefault(callUciGet('7dollar', 'main'), {}).then(v => Object.assign({}, DEFAULTS, v || {}));
}
function saveSettings(values) {
const set = {}, del = [];
for (const k in values) {
if (values[k] === '' || values[k] == null)
del.push(k);
else
set[k] = String(values[k]);
}
return L.resolveDefault(callUciGet('7dollar', 'main'), null).then(exists => {
const steps = [];
if (exists == null || !Object.keys(exists).length)
steps.push(() => callUciAdd('7dollar', 'theme', 'main', set));
else if (Object.keys(set).length)
steps.push(() => callUciSet('7dollar', 'main', set));
if (del.length)
steps.push(() => L.resolveDefault(callUciDelete('7dollar', 'main', del), null));
steps.push(() => callUciCommit('7dollar'));
return steps.reduce((p, fn) => p.then(fn), Promise.resolve());
});
}
/* ---------------- motion ---------------- */
/* Durations live in cascade.css (--fast / --base / --slow); scripts read them so CSS and JS move alike. */
const calm = matchMedia('(prefers-reduced-motion: reduce)');
function motion(name) {
return calm.matches ? 0 : parseFloat(getComputedStyle(document.documentElement).getPropertyValue(`--${name}`)) * 1000 || 0;
}
/* Exits mirror entrances: fade (and sink) out, then go. */
function leave(el) {
if (el.classList.contains('td-out')) return;
el.classList.add('td-out');
setTimeout(() => el.remove(), motion('fast'));
}
function applyLook(s) {
const r = document.documentElement;
const dark = s.mode == 'auto' ? matchMedia('(prefers-color-scheme: dark)').matches : s.mode == 'dark';
const paint = () => {
r.setAttribute('data-palette', s.palette);
r.setAttribute('data-mode', s.mode);
r.setAttribute('data-theme', dark ? 'dark' : 'light');
r.setAttribute('data-darkmode', dark ? 'true' : 'false');
document.dispatchEvent(new CustomEvent('td-look', { detail: s }));
};
/* one cross-fade of the whole page instead of every colour easing on its own */
if (document.startViewTransition && motion('base')) document.startViewTransition(paint);
else paint();
}
let toastTimer;
function toast(msg) {
let t = document.querySelector('.td-toast:not(.td-out)');
if (!t) document.body.appendChild(t = E('div', { 'class': 'td-toast', 'role': 'status' }));
t.textContent = msg;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => leave(t), 1800);
}
/* ---------------- formatting ---------------- */
function rate(bytesPerSec) {
const bits = Math.max(0, bytesPerSec * 8);
if (bits >= 1e9) return [ (bits / 1e9).toFixed(2), 'Gbps' ];
if (bits >= 1e6) return [ (bits / 1e6).toFixed(bits >= 1e8 ? 0 : 1), 'Mbps' ];
if (bits >= 1e3) return [ (bits / 1e3).toFixed(0), 'Kbps' ];
return [ bits.toFixed(0), 'bps' ];
}
function bytes(n) {
n = +n || 0;
if (n >= 1099511627776) return (n / 1099511627776).toFixed(1) + ' TB';
if (n >= 1073741824) return (n / 1073741824).toFixed(1) + ' GB';
if (n >= 1048576) return (n / 1048576).toFixed(0) + ' MB';
if (n >= 1024) return (n / 1024).toFixed(0) + ' KB';
return n + ' B';
}
function duration(sec) {
sec = Math.max(0, Math.floor(sec || 0));
const d = Math.floor(sec / 86400), h = Math.floor(sec % 86400 / 3600), m = Math.floor(sec % 3600 / 60);
if (ZH) {
if (d) return `${d} 天 ${h} 小时`;
if (h) return `${h} 小时 ${m} 分钟`;
return `${Math.max(m, 1)} 分钟`;
}
if (d) return `${d}d ${h}h`;
if (h) return `${h}h ${m}m`;
return `${Math.max(m, 1)}m`;
}
function signal(dbm) {
if (dbm == null || dbm == 0) return [ T('未知', 'Unknown'), 0 ];
if (dbm >= -55) return [ T('信号很好', 'Excellent'), 4 ];
if (dbm >= -65) return [ T('信号良好', 'Good'), 3 ];
if (dbm >= -75) return [ T('信号一般', 'Fair'), 2 ];
return [ T('信号较弱', 'Weak'), 1 ];
}
function quality(dbm) {
if (!dbm) return 0;
return Math.max(4, Math.min(100, Math.round((dbm + 92) / 50 * 100)));
}
function bandName(b) {
return ({ '2g': '2.4 GHz', '5g': '5 GHz', '6g': '6 GHz', '60g': '60 GHz' })[b] || b || 'Wi‑Fi';
}
function kind(name) {
name = name || '';
if (/watch/i.test(name)) return 'watch';
if (/ipad|pad|tab|kindle/i.test(name)) return 'tablet';
if (/book\d*\b|laptop|thinkpad|surface/i.test(name)) return 'laptop'; // MacBook, MateBook, Vivobook, Galaxy Book3
if (/iphone|pixel|android|phone|oppo|vivo|xiaomi|redmi|huawei|honor|galaxy|oneplus|realme|meizu|nokia|mi-/i.test(name)) return 'phone';
if (/tv\b|roku|chromecast|fire-?stick|\bshield\b|x?box\b/i.test(name)) return 'tv';
if (/\b(mac|imac|mini|pc|desktop|win|ubuntu|linux|nas|server)\b/i.test(name)) return 'desktop';
return 'devices';
}
function pretty(name) {
if (!name) return '';
return name.replace(/[-_]+/g, ' ').replace(/\s+/g, ' ').trim();
}
/* ---------------- data ---------------- */
function readFile(path) {
return L.resolveDefault(fs.read(path), null);
}
/* MACs a bridge has heard from in the last few minutes: its forwarding table forgets a port after
* 300 s of silence, while an ARP entry can outlive a device by hours. Entries are 16 bytes
* (6 of MAC) in the binary brforward file. null when none of the devices is a bridge. */
function heard(bridges) {
return Promise.all(bridges.map(b => L.resolveDefault(callFileRead64(`/sys/class/net/${b}/brforward`, true), null))).then(datas => {
let macs = null;
datas.forEach(d => {
if (d == null) return;
const bin = atob(d);
macs = macs || {};
for (let i = 0; i + 16 <= bin.length; i += 16)
macs[Array.from(bin.slice(i, i + 6), c => ('0' + c.charCodeAt(0).toString(16)).slice(-2)).join(':').toUpperCase()] = true;
});
return macs;
});
}
/* CPU temperature lives in different places per platform (thermal_zone0 on most SoCs,
* coretemp/k10temp hwmon on x86). Pick the best sensor once; later reads cost one file. */
const TEMP_PREF = [ /^coretemp/, /^k10temp/, /^zenpower/, /^x86_pkg_temp/, /^cpu[-_]?thermal/, /^soc[-_]?thermal/, /^acpitz/ ];
let tempPath;
function tempSensor() {
if (tempPath !== undefined)
return Promise.resolve(tempPath);
const list = dir => L.resolveDefault(fs.list(dir), []).then(l => l.map(e => dir + '/' + e.name));
const rank = name => { const i = TEMP_PREF.findIndex(r => r.test(name)); return i < 0 ? TEMP_PREF.length : i };
return Promise.all([ list('/sys/class/thermal'), list('/sys/class/hwmon') ]).then(([ zones, hwmons ]) => Promise.all([].concat(
zones.filter(d => /thermal_zone\d+$/.test(d)).map(d => readFile(d + '/type').then(n => ({ name: n, path: d + '/temp' }))),
hwmons.map(d => readFile(d + '/name').then(n => ({ name: n, path: d + '/temp1_input' })))
))).then(found => {
/* Known CPU sensors first, then any thermal zone; never radio/PHY/disk sensors. */
const cands = found.filter(c => c.name).map(c => ({ path: c.path, r: rank(c.name.trim()) }))
.filter(c => c.r < TEMP_PREF.length || /thermal_zone/.test(c.path))
.sort((a, b) => a.r - b.r || (a.path < b.path ? -1 : 1));
const next = i => i >= cands.length ? null
: readFile(cands[i].path).then(v => +v > 0 ? cands[i].path : next(i + 1));
return next(0);
}).then(p => (tempPath = p));
}
function temperature() {
return tempSensor().then(p => p ? readFile(p) : null).then(v => +v > 0 ? +v / 1000 : null);
}
function wifiInfo() {
return L.resolveDefault(callWireless(), {}).then(radios => {
const aps = [];
Object.keys(radios || {}).sort().forEach(rname => {
const r = radios[rname] || {};
(r.interfaces || []).forEach(i => {
if ((i.config || {}).mode == 'ap' && i.ifname)
aps.push({ radio: rname, band: (r.config || {}).band, ifname: i.ifname, ssid: (i.iwinfo || {}).ssid || (i.config || {}).ssid,
network: (i.config || {}).network || [], up: !!r.up && !(i.config || {}).disabled });
});
});
return Promise.all(aps.map(ap => ap.up ? L.resolveDefault(callAssoc(ap.ifname), []) : Promise.resolve([])))
.then(lists => {
aps.forEach((ap, i) => ap.stations = lists[i] || []);
return { radios: radios || {}, aps: aps };
});
});
}
function clients(wifi, uplink) {
return Promise.all([
L.resolveDefault(callLeases(), {}),
L.resolveDefault(callHints(), {}),
wifi ? Promise.resolve(wifi) : wifiInfo(),
readFile('/proc/net/arp'),
uplink !== undefined ? uplink : wan()
]).then(([ leases, hints, wi, arp, up ]) => {
up = up || {};
/* neighbours on the uplink side (a main router's WAN port, a repeater's Wi-Fi link) are not
* our devices; a side router's uplink is the LAN itself, so there only the gateway is skipped.
* What a side router sees on its LAN port may well be a phone on the main router's Wi-Fi,
* so those count as "lan" rather than "wired". The same goes for a box with no Wi-Fi of its
* own (x86, radios off): its Wi-Fi devices arrive through an access point on a LAN port. */
const side = up.mode == 'side', skip = side ? [] : [ up.device, up.port ];
const lan = side || !(wi.aps || []).some(ap => ap.up);
const byMac = {};
const get = mac => {
mac = String(mac).toUpperCase();
return byMac[mac] || (byMac[mac] = { mac: mac, online: false });
};
(leases.dhcp_leases || []).forEach(l => {
if (!l.macaddr) return;
const c = get(l.macaddr);
c.name = c.name || l.hostname;
c.ip = l.ipaddr;
c.expires = l.expires;
c.lease = true;
});
const wifiMacs = {};
(wi.aps || []).forEach(ap => ap.stations.forEach(s => {
const c = get(s.mac);
const h = (hints || {})[c.mac] || {};
c.online = true;
c.conn = 'wifi';
c.band = ap.band;
c.ssid = ap.ssid;
c.signal = s.signal;
c.link = Math.max((s.tx || {}).rate || 0, (s.rx || {}).rate || 0) / 1000;
c.since = s.connected_time;
c.name = c.name || h.name;
c.ip = c.ip || (h.ipaddrs || [])[0];
wifiMacs[c.mac] = true;
}));
/* Wired devices are whatever answers ARP on our side, leased or not (static IPs, or a side
* router whose DHCP is off), whatever the LAN is called (br-lan, eth0, br-lan.10 ...).
* The gateway is the upstream router; containers and loopback are not devices either.
* The bridge's own table then drops whoever has gone quiet (a Wi-Fi device that left). */
const rows = String(arp || '').split('\n').slice(1).map(line => line.trim().split(/\s+/)).filter(f =>
f.length >= 6 && f[2] == '0x2' && f[0] != up.gw && !skip.includes(f[5]) && !/^(lo|docker|veth)|^br-[0-9a-f]{12}$/.test(f[5]) && !wifiMacs[f[3].toUpperCase()]);
const bridges = [ ...new Set(rows.map(f => f[5].replace(/\.\d+$/, ''))) ];
return heard(bridges).then(macs => {
rows.forEach(f => {
if (macs && !macs[f[3].toUpperCase()]) return;
const c = get(f[3]);
c.online = true;
c.conn = lan ? 'lan' : 'wired';
c.ip = c.ip || f[0];
});
for (const mac in (hints || {})) {
const h = hints[mac], c = byMac[mac.toUpperCase()];
if (c) {
c.name = c.name || h.name;
c.ip = c.ip || (h.ipaddrs || h.ipv4 || [])[0];
}
}
const list = Object.values(byMac).map(c => {
c.display = pretty(c.name) || c.ip || c.mac;
c.kind = kind(c.name);
return c;
});
list.sort((a, b) => (b.online - a.online) || ((b.link || 0) - (a.link || 0)) || a.display.localeCompare(b.display));
return list;
});
});
}
/* The uplink is whichever device carries the kernel's default route. Asking the kernel instead of
* netifd covers every layout (main router, bypass/side router, routes added by scripts). */
function defaultRoute(text) {
let best = null;
String(text || '').split('\n').slice(1).forEach(line => {
const f = line.trim().split(/\s+/);
if (f[1] != '00000000' || f[7] != '00000000' || !(parseInt(f[3], 16) & 1)) return;
if (!best || +f[6] < best.metric)
best = { dev: f[0], metric: +f[6], gw: f[2].match(/../g).reverse().map(h => parseInt(h, 16)).join('.') };
});
return best;
}
function wan() {
return Promise.all([ L.resolveDefault(callIfDump(), []), readFile('/proc/net/route') ]).then(([ ifs, rt ]) => {
const def = defaultRoute(rt);
const dev = i => i.l3_device || i.device;
const list = ifs || [];
const lan = list.find(i => i.interface == 'lan');
const v4 = i => ((i['ipv4-address'] || [])[0] || {}).address;
const v6 = i => ((i['ipv6-address'] || []).find(a => !/^fe80:/i.test(a.address)) || {}).address; // never the link-local one
if (!def) {
const w = list.find(i => i.interface == 'wan');
return w ? { up: false, mode: 'wan', proto: w.proto, device: dev(w) } : null;
}
const i = list.filter(i => i.up && dev(i) == def.dev)
.sort((a, b) => !!(b['ipv4-address'] || []).length - !!(a['ipv4-address'] || []).length)[0] || {};
/* Any other interface with a default route of its own (IPTV, a backup WAN) is another line.
* The IPv6 half of the WAN (wan6) is not: it rides on the WAN's device or port with no IPv4 route. */
const deflt = (x, re) => (x.route || []).some(r => r.mask == 0 && re.test(r.target));
const port = n => String(n || '').split('.')[0];
const seen = new Set([ def.dev, lan && dev(lan) ]);
const more = list.filter(x => x.up && x.interface != 'loopback' && deflt(x, /^(0\.0\.0\.0|::)$/) && !seen.has(dev(x))
&& (deflt(x, /^0\.0\.0\.0$/) || port(x.device) != port(i.device)) && seen.add(dev(x)))
.map(x => ({ name: x.interface.toUpperCase(), proto: x.proto, device: dev(x), port: x.device, ip: v4(x) || v6(x), uptime: x.uptime }));
return {
up: true,
/* side: uplink shares the LAN (bypass router / AP); sta: Wi-Fi uplink (repeater) */
mode: lan && dev(lan) == def.dev ? 'side' : /-sta\d+$/.test(def.dev) ? 'sta' : 'wan',
proto: i.proto,
device: def.dev,
port: i.device,
ip: v4(i),
ip6: v6(i),
gw: def.gw != '0.0.0.0' ? def.gw : null,
dns: i['dns-server'] || [],
uptime: i.uptime,
more: more
};
});
}
/* Physical Ethernet ports in use: the uplink ports, named after their line (WAN, IPTV, ...), then
* whatever sits in a bridge (lan1…lan4, eth1, …). Switch ports are "dsa" devices, a plain NIC is
* "ethernet". A VLAN on top (eth0.7, wan.10) counts as its port. Empty on swconfig boxes, where
* the kernel only sees the CPU port. */
function ports(up) {
const base = n => String(n || '').split('.')[0];
const line = new Map();
const tag = (n, name) => (n = base(n)) && !line.has(n) && line.set(n, name);
if (up && up.mode != 'side') { tag(up.port, 'WAN'); tag(up.device, 'WAN'); }
((up && up.more) || []).forEach(u => { tag(u.port, u.name); tag(u.device, u.name); });
const order = [ ...line.keys() ], rank = n => { const k = order.indexOf(n); return k < 0 ? order.length : k };
return L.resolveDefault(callDevices(), {}).then(devs => Object.values(devs || {})
.filter(d => /^(ethernet|dsa)$/.test(d.devtype) && !d.wireless && d.name.indexOf('.') < 0 && !/^(docker|veth|br-)/.test(d.name)
&& (d.master || line.has(d.name)))
.map(d => ({ name: d.name, line: line.get(d.name), up: !!(d.link || {}).carrier, speed: +(d.link || {}).speed || 0 }))
.sort((a, b) => (rank(a.name) - rank(b.name)) || a.name.localeCompare(b.name, 'en', { numeric: true })));
}
function connName(c) {
return c.conn == 'lan' ? T('局域网', 'LAN') : T('有线连接', 'Wired');
}
const DNS_NAMES = {
'223.5.5.5': T('阿里', 'AliDNS'), '223.6.6.6': T('阿里', 'AliDNS'),
'119.29.29.29': T('腾讯', 'DNSPod'), '119.28.28.28': T('腾讯', 'DNSPod'), '182.254.116.116': T('腾讯', 'DNSPod'),
'114.114.114.114': '114DNS', '114.114.115.115': '114DNS',
'180.76.76.76': T('百度', 'Baidu'), '1.1.1.1': 'Cloudflare', '1.0.0.1': 'Cloudflare',
'8.8.8.8': 'Google', '8.8.4.4': 'Google', '9.9.9.9': 'Quad9'
};
function dnsName(list, gw, gwName) {
const names = [];
(list || []).forEach(ip => {
const n = DNS_NAMES[ip] || (ip == gw ? gwName || T('上级路由', 'Upstream') : ip);
if (names.indexOf(n) < 0) names.push(n);
});
return names.slice(0, 2).join(' · ') || '—';
}
function netdev(text) {
const out = {};
String(text || '').split('\n').slice(2).forEach(line => {
const m = line.match(/^\s*([^:]+):\s*(.*)$/);
if (!m) return;
const f = m[2].trim().split(/\s+/).map(Number);
out[m[1]] = { rx: f[0], tx: f[8] };
});
return out;
}
function cpuStat(text) {
const f = String(text || '').split('\n')[0].trim().split(/\s+/).slice(1).map(Number);
if (f.length < 4) return null;
const idle = f[3] + (f[4] || 0);
return { idle: idle, total: f.reduce((a, b) => a + b, 0) };
}
const uciType = (config, type) => L.resolveDefault(callUciGetType(config, type), {});
const uciSection = (config, section) => L.resolveDefault(callUciGet(config, section), {});
return baseclass.extend({
ZH: ZH, T: T,
active: ACTIVE,
PALETTES: PALETTES, OPT_IN: OPT_IN, words: words, cardOn: cardOn,
esc: esc, icon: icon, html: html,
loadMenu: loadMenu, flatten: flatten, realNode: realNode, menuHas: menuHas, software: software, grouped: grouped, appIcon: appIcon, service: service,
loadSettings: loadSettings, saveSettings: saveSettings, applyLook: applyLook, toast: toast, motion: motion, leave: leave,
rate: rate, bytes: bytes, duration: duration, signal: signal, quality: quality, bandName: bandName, connName: connName,
readFile: readFile, temperature: temperature, wifiInfo: wifiInfo, clients: clients, wan: wan, ports: ports, dnsName: dnsName, netdev: netdev, cpuStat: cpuStat,
board: () => L.resolveDefault(callBoard(), {}),
sysinfo: () => L.resolveDefault(callInfo(), {}),
services: () => L.resolveDefault(callServices(), {}), mounts: () => L.resolveDefault(callMounts(), []),
uciType: uciType, uciSection: uciSection,
uciSet: (config, section, values) => callUciSet(config, section, values).then(() => callUciCommit(config))
});
@@ -0,0 +1,221 @@
'use strict';
'require baseclass';
'require uci';
'require 7dollar.common as td';
/*
* Plain status by default. Native LuCI status blocks (interfaces, Wi-Fi, overview) list every
* field they know; this marks the engineering ones (MAC, BSSID, packet counts, MCS, noise, ...)
* as .td-x and gives their section a "Details" button. Nothing is removed: one click shows all.
* Labels are matched through LuCI's own translations, so it works in every language.
* Runs in the browser only, after each re-render of #view; the router does no extra work.
*/
const T = td.T, icon = td.icon;
const BAND = { '2g': '2.4 GHz', '5g': '5 GHz', '6g': '6 GHz', '60g': '60 GHz' };
let W;
function words(list) { return new Set(list.map(s => _(s))); }
function dict() {
return W ??= {
item: words([ 'MAC', 'MAC address', 'BSSID', 'Mode', 'RX', 'TX', 'Gateway', 'DNS', 'Expires', 'Device', 'Type',
'Tx-Power', 'Noise', 'Country', 'Prefix Delegated' ]),
v6: words([ 'IPv6', 'IPv6-PD' ]), // hidden only when the same list already has an IPv4 address
v4: words([ 'IPv4', 'Address' ]),
row: words([ 'Architecture', 'Target Platform', 'Kernel Version', 'Load Average', 'Buffered', 'Cached' ]),
col: words([ 'MAC address', 'DUID' ]),
host: words([ 'Host', 'Hostname' ]),
fw: _('Firmware Version'),
noise: new RegExp(`\\s*/\\s*${_('Noise')}\\s*$`) // the "Signal / Noise" header
};
}
/* Wrap the part of a direct text child that matches re (or its first group) in .td-x. */
function fold(el, re) {
for (const n of el.childNodes) {
const m = n.nodeType == 3 && re.exec(n.data);
if (!m) continue;
const part = m[1] ?? m[0];
const mid = n.splitText(m.index + m[0].indexOf(part));
mid.splitText(part.length);
const s = E('span', { class: 'td-x' });
mid.replaceWith(s);
s.append(mid);
return s;
}
}
function hide(el) {
el.classList.add('td-x');
return el;
}
const shown = n => n.nodeType == 1 && n.matches('span.nowrap') && !n.classList.contains('td-x');
/* L.itemlist() output: <span.nowrap><strong>Label: </strong>value</span>, separated by " | " or <br>. */
function list(box, w) {
const nodes = [ ...box.childNodes ];
const items = nodes.filter(n => n.nodeType == 1 && n.matches('span.nowrap'));
const label = e => e.firstChild?.nodeName == 'STRONG' ? e.firstChild.textContent.replace(/:\s*$/, '') : '';
const has4 = items.some(e => w.v4.has(label(e)));
items.forEach(e => {
const l = label(e);
if (w.item.has(l) || (has4 && w.v6.has(l))) hide(e);
else if (l) fold(e, /\s+\([^()]*\)\s*$/); // "149 (5.745 GHz)", "WPA2 PSK (CCMP)"
});
/* A separator goes with the item before it, and also when nothing visible follows. */
let prev = null;
nodes.forEach((n, i) => {
if (items.includes(n)) return void (prev = n);
if (!prev || (shown(prev) && nodes.slice(i + 1).some(shown))) return;
if (n.nodeType == 3) { const s = E('span', { class: 'td-x' }); n.replaceWith(s); s.append(n); }
else if (n.nodeType == 1) hide(n);
});
const badge = box.closest('.ifacebadge');
if (badge && items.length && !items.some(shown)) hide(badge);
}
function scan(view) {
const w = dict(), out = [];
const mark = el => el && out.push(el);
new Set([ ...view.querySelectorAll('span.nowrap') ].map(e => e.parentNode)).forEach(box => box.closest('.cbi-tooltip') || list(box, w));
view.querySelectorAll('span.nowrap.td-x, .ifacebadge.td-x').forEach(mark);
/* two-column status tables (overview): fold whole rows */
view.querySelectorAll('.tr').forEach(tr => {
const c = tr.children;
if (c.length != 2) return;
const k = c[0].textContent.trim();
if (w.row.has(k)) mark(hide(tr));
else if (k == w.fw) mark(fold(c[1], /^\S+\s+\S+(\s.+)$/s)); // keep "OpenWrt 24.10.5"
});
/* device tables (clients, leases): MAC / DUID columns, host suffixes and addresses, Wi-Fi rate details, noise.
* Tables without a host column (neighbours, device config) are about those addresses, so they stay. */
const named = new Set([ ...view.querySelectorAll('.th') ].filter(th => w.host.has(th.textContent.trim())).map(th => th.closest('.table')));
view.querySelectorAll('.th').forEach(th => named.has(th.closest('.table')) && w.col.has(th.textContent.trim()) ? mark(hide(th)) : mark(fold(th, w.noise)));
view.querySelectorAll('.td[data-title]').forEach(c => {
const t = c.getAttribute('data-title');
if (w.col.has(t)) return named.has(c.closest('.table')) && mark(hide(c));
if (w.noise.test(t)) c.setAttribute('data-title', t.replace(w.noise, '')); // label of the phone layout
if (!w.host.has(t)) return;
c.querySelectorAll('.hide-xs').forEach(e => mark(hide(e)));
mark(fold(c, /\s+\([^()]*\)\s*$/));
mark(fold(c, /^[^\s.:]*[a-z][^\s.:]*(\.[a-z][\w.-]*)/i)); // "Pixel.lan" -> "Pixel"; IPs stay
});
view.querySelectorAll('.assoclist .td > span:not(.ifacebadge) > span').forEach(s => mark(fold(s, /,\s.*$/s)));
view.querySelectorAll('[data-noise] > span').forEach(s => mark(fold(s, /\/-?\d+/)));
view.querySelectorAll('.assoclist .ifacebadge[data-ssid]').forEach(mark);
/* an empty secondary device table (e.g. no DHCPv6 leases) folds away with its title */
view.querySelectorAll('.table .placeholder').forEach(p => {
const t = p.closest('.table'), h = t.previousElementSibling;
if (named.has(t) && h?.matches('h3') && h != head(t)[1]) { mark(hide(t)); hide(h); }
});
/* radio rows on the Wi-Fi page: "5 GHz" instead of the chipset name */
view.querySelectorAll('[data-sid] [data-name=_stat] big > strong').forEach(s => {
if (s.querySelector('.td-y')) return;
const b = BAND[uci.get('wireless', s.closest('[data-sid]').getAttribute('data-sid'), 'band')];
if (!b) return;
s.replaceChildren(E('span', { class: 'td-x' }, s.textContent), E('span', { class: 'td-y' }, b));
mark(s);
});
/* long section help (DHCP, firewall, ...) folds behind "说明" instead of a wall of text above the form */
view.querySelectorAll('.cbi-section-descr').forEach(d => {
if (d.parentNode.matches('details') || d.textContent.trim().length <= 160) return;
const box = E('details', { class: 'td-desc' }, E('summary', {}, [ T('说明', 'Help'), td.html(icon('chev')) ]));
d.replaceWith(box);
box.append(d);
});
out.forEach(arm);
}
/* The section an element belongs to, and the heading that carries its Details button. */
function head(el) {
let sec = el.closest('.cbi-section');
if (sec)
return [ sec, sec.querySelector(':scope > h3') ?? sec.querySelector('h3') ];
sec = el.closest('table');
return [ sec, sec?.previousElementSibling?.matches('h3') ? sec.previousElementSibling : null ];
}
function arm(el) {
const [ sec, h ] = head(el);
if (!h) return;
sec.classList.add('td-sec');
if (h.querySelector('.td-detail')) return;
const btn = E('button', { type: 'button', class: 'td-detail' });
const paint = open => {
btn.setAttribute('aria-expanded', open);
btn.innerHTML = `${T(open ? '收起' : '详情', open ? 'Less' : 'Details')}${icon('chev')}`;
};
btn.addEventListener('click', () => paint(flip(sec)));
paint(sec.classList.contains('td-open'));
h.classList.add('td-sec-h');
h.append(btn);
}
/* Open or close a section. Opening: the box eases to its new height while the details fade in.
* Closing mirrors it: the details fade out first, then the box settles, so nothing jumps at the end.
* Returns the new state. */
function flip(sec) {
sec._end?.();
const open = !sec.classList.contains('td-open'), ms = td.motion('base');
if (!ms) return sec.classList.toggle('td-open', open);
let timer, anim;
const end = sec._end = () => {
sec._end = null;
clearTimeout(timer);
anim?.cancel();
sec.style.overflow = '';
sec.classList.remove('td-showing', 'td-hiding');
sec.classList.toggle('td-open', open);
};
/* Cells that stay, and whatever sits below the tables, glide from where they were instead of jumping
* when the lines and columns around them come or go. A cell is anchored where its content sits:
* left, and middle in a table row or top when stacked on phones. */
const settle = () => {
const easing = getComputedStyle(sec).getPropertyValue('--ease').trim(), from = sec.offsetHeight;
const rows = [ ...sec.querySelectorAll('.th,.td'), ...[ ...sec.children ].filter(c => !c.querySelector('.td')) ];
const at = r => { const b = r.getBoundingClientRect(); return b.height ? [ b.left, b.top + (getComputedStyle(r).display == 'table-cell' ? b.height / 2 : 0) ] : null };
const was = rows.map(at);
sec.classList.remove('td-hiding');
sec.classList.toggle('td-open', open);
sec.classList.add('td-showing');
anim = sec.animate({ height: [ `${from}px`, `${sec.offsetHeight}px` ] }, { duration: ms, fill: 'forwards', easing });
if (getComputedStyle(sec).display != 'table') sec.style.overflow = 'hidden'; // a table only ever shrinks: its rows ease shorter
rows.forEach((r, i) => {
const now = at(r), dx = was[i] && now && was[i][0] - now[0], dy = was[i] && now && was[i][1] - now[1];
if (Math.abs(dx) + Math.abs(dy) > 1) r.animate({ transform: [ `translate(${dx}px,${dy}px)`, 'none' ] }, { duration: ms, easing });
});
timer = setTimeout(end, ms);
};
if (open) settle();
else { sec.classList.add('td-hiding'); timer = setTimeout(settle, td.motion('fast')); }
return open;
}
return baseclass.extend({
__init__() {
const view = document.getElementById('view');
/* the theme's own pages are already plain; the home page repaints every 2 s and need not be rescanned */
if (!view || /^admin-(home|clients|apps|system-appearance)$/.test(document.body.dataset.page || '')) return;
let queued = false;
const run = () => { queued = false; scan(view); };
new MutationObserver(() => queued || (queued = true, requestAnimationFrame(run)))
.observe(view, { childList: true, subtree: true });
run();
}
});
@@ -0,0 +1,279 @@
'use strict';
'require baseclass';
'require ui';
'require poll';
'require 7dollar.common as td';
'require 7dollar.tidy as tidy';
/*
* Navigation for the 7Dollar theme.
* Native LuCI pages are regrouped into seven plain-language sections;
* anything unknown falls back to "Apps" so third-party plugins always show up.
*/
const T = td.T, icon = td.icon, esc = td.esc;
const mqPhone = window.matchMedia('(max-width:760px)');
return baseclass.extend({
__init__() {
td.loadMenu().then(L.bind(this.render, this));
this.bindKeys();
this.bindMode();
this.bindVisibility();
},
/* Every LuCI page refreshes through one shared poll loop; stop it while the tab is hidden so a
* forgotten tab costs the router nothing. Coming back restarts it, which refreshes at once. */
bindVisibility() {
let paused = false;
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
if (poll.active()) { poll.stop(); paused = true; }
}
else if (paused) {
paused = false;
poll.start();
}
});
},
render(tree) {
const pages = td.flatten(tree);
const groups = td.grouped(pages);
const here = L.env.dispatchpath.join('/');
let current = null;
pages.forEach(p => {
if ((here == p.path || here.indexOf(p.path + '/') == 0) && (!current || p.path.length > current.path.length))
current = p;
});
this.pages = pages;
this.groups = groups;
this.current = current;
this.renderNav(groups, current);
this.renderPills(groups, current);
if (current)
this.renderTabs(current.node, current.path, current.path.split('/').length);
const btn = document.getElementById('td-search');
if (btn) btn.addEventListener('click', () => this.openSearch());
},
href(group) {
const first = group.list[0];
return L.url(first.path);
},
renderNav(groups, current) {
const nav = document.getElementById('td-nav');
if (!nav) return;
nav.innerHTML = groups.map((g, i) => {
const on = current && current.group == g.id;
const short = g.short ? `<span class="lbl has-short">${esc(g.label)}</span><span class="short">${esc(g.short)}</span>`
: `<span class="lbl">${esc(g.label)}</span>`;
return `<a href="${this.href(g)}" data-g="${g.id}" title="${esc(g.label)}" class="${on ? 'on' : ''}${g.mob === false ? ' mob-hide' : ''}" style="animation-delay:${i * 25}ms">${icon(g.icon)}${short}</a>`;
}).join('');
const sys = nav.querySelector('[data-g="system"]');
if (sys)
sys.addEventListener('click', ev => {
if (!mqPhone.matches) return;
ev.preventDefault();
this.openSheet();
});
},
renderPills(groups, current) {
const box = document.getElementById('td-pills');
if (!box || !current) return;
const g = groups.find(x => x.id == current.group);
let items = [];
if (g.id == 'apps') {
if (current.path == 'admin/apps') return;
const all = g.list.find(p => p.path == 'admin/apps');
if (all)
box.innerHTML = `<a class="back" href="${L.url(all.path)}">${icon('chev')}${esc(all.title)}</a>`;
box.innerHTML += `<a class="on" href="${L.url(current.path)}">${esc(current.title)}</a>`;
return;
}
items = g.list;
if (items.length < 2) return;
/* everyday pages first; the rest wait behind "More" unless one of them is open */
const pill = (p, cls) => `<a href="${L.url(p.path)}" class="${p === current ? 'on' : cls || ''}">${esc(p.title)}</a>`;
const rest = items.filter(p => g.pages.indexOf(p.path) < 0);
const fold = rest.length > 1 && rest.indexOf(current) < 0;
box.innerHTML = (fold ? items.filter(p => rest.indexOf(p) < 0) : items).map(p => pill(p)).join('')
+ (fold ? `<a href="#" role="button" class="fold">${esc(T('更多', 'More'))}${icon('chev')}</a>` : '');
if (fold)
box.lastChild.addEventListener('click', ev => {
ev.preventDefault();
ev.currentTarget.outerHTML = rest.map(p => pill(p, 'td-in')).join('');
edge();
});
const on = box.querySelector('.on');
if (on && on.scrollIntoView) box.scrollLeft = Math.max(0, on.offsetLeft - 40);
const edge = () => {
box.classList.toggle('scrolls', box.scrollWidth > box.clientWidth + 2);
box.classList.toggle('end', box.scrollLeft + box.clientWidth >= box.scrollWidth - 2);
};
box.addEventListener('scroll', edge, { passive: true });
window.addEventListener('resize', edge);
edge();
},
renderTabs(node, url, level) {
const container = document.getElementById('tabmenu');
const children = ui.menu.getChildren(node);
if (!container || !children.length) return;
const ul = E('ul', { 'class': 'tabs' });
let active = null;
children.forEach(child => {
const isActive = (L.env.dispatchpath[level] == child.name);
ul.appendChild(E('li', { 'class': 'tabmenu-item-%s%s'.format(child.name, isActive ? ' active' : '') }, [
E('a', { 'href': L.url(url, child.name) }, [ _(child.title) ])
]));
if (isActive) active = child;
});
container.appendChild(ul);
container.style.display = '';
if (active)
this.renderTabs(td.realNode(node, active), url + '/' + active.name, level + 1);
},
/* ---------- search (⌘K) ---------- */
entries() {
if (this._entries) return this._entries;
const out = [];
(this.groups || []).forEach(g => g.list.forEach(p => {
out.push({ path: p.path, title: p.title, group: g.label, icon: g.icon, en: p.node.title || '' });
ui.menu.getChildren(p.node).forEach(c => out.push({
path: p.path + '/' + c.name, title: p.title + ' · ' + _(c.title), group: g.label, icon: g.icon, en: c.title || ''
}));
}));
return (this._entries = out);
},
openSearch() {
if (document.querySelector('.td-pop:not(.td-out)')) return;
const pop = td.html(`<div class="td-pop" role="dialog" aria-modal="true">
<div class="td-pop-box">
<label class="td-pop-in">${icon('search')}<input type="search" placeholder="${esc(T('搜索功能或设置,例如“端口转发”', 'Search pages and settings'))}" autocomplete="off"><kbd>Esc</kbd></label>
<div class="td-pop-list"></div>
</div></div>`);
document.body.appendChild(pop);
const input = pop.querySelector('input'), list = pop.querySelector('.td-pop-list');
const all = this.entries();
let sel = 0, shown = [];
const draw = () => {
const q = input.value.trim().toLowerCase();
shown = !q ? all.filter(e => e.path.split('/').length <= 3).slice(0, 40)
: all.filter(e => (e.title + ' ' + e.en + ' ' + e.group + ' ' + e.path).toLowerCase().indexOf(q) > -1).slice(0, 40);
sel = Math.min(sel, Math.max(0, shown.length - 1));
list.innerHTML = shown.length ? shown.map((e, i) =>
`<a href="${L.url(e.path)}" class="${i == sel ? 'on' : ''}" data-i="${i}">${icon(e.icon)}<span>${esc(e.title)}</span><small>${esc(e.group)}</small></a>`).join('')
: `<div class="td-pop-empty">${esc(T('没有找到相关功能', 'Nothing found'))}</div>`;
const on = list.querySelector('.on');
if (on) on.scrollIntoView({ block: 'nearest' });
};
const close = () => td.leave(pop);
input.addEventListener('input', () => { sel = 0; draw() });
input.addEventListener('keydown', ev => {
if (ev.key == 'ArrowDown') { sel = Math.min(sel + 1, shown.length - 1); draw(); ev.preventDefault() }
else if (ev.key == 'ArrowUp') { sel = Math.max(sel - 1, 0); draw(); ev.preventDefault() }
else if (ev.key == 'Enter' && shown[sel]) { location.href = L.url(shown[sel].path) }
else if (ev.key == 'Escape') close();
});
list.addEventListener('mousemove', ev => {
const a = ev.target.closest('a[data-i]');
if (a && +a.dataset.i != sel) { sel = +a.dataset.i; list.querySelectorAll('a').forEach(x => x.classList.toggle('on', x === a)) }
});
pop.addEventListener('click', ev => { if (ev.target === pop) close() });
draw();
input.focus();
},
/* ---------- phone: "More" sheet ---------- */
openSheet() {
if (document.querySelector('.td-pop:not(.td-out)')) return;
const rest = (this.groups || []).filter(g => g.mob === false || g.id == 'system');
const body = rest.map(g => `<div class="grp">${esc(g.label)}</div>` +
g.list.map(p => `<a href="${L.url(p.path)}" class="${this.current === p ? 'on' : ''}">${icon(g.id == 'apps' ? td.appIcon(p.path) : g.icon)}<span>${esc(p.title)}</span></a>`).join('')).join('');
const pop = td.html(`<div class="td-pop" role="dialog" aria-modal="true"><div class="td-pop-box">
<div class="td-pop-list">${body}</div>
<div class="td-sheet-tools">
<button class="btn" type="button" data-act="search">${icon('search')}${esc(T('搜索', 'Search'))}</button>
<button class="btn" type="button" data-act="mode">${icon('moon')}${esc(T('深浅色', 'Theme'))}</button>
<a class="btn" href="${L.url('admin/logout')}">${icon('logout')}${esc(T('退出', 'Log out'))}</a>
</div></div></div>`);
document.body.appendChild(pop);
pop.addEventListener('click', ev => {
const b = ev.target.closest('[data-act]');
if (ev.target === pop) td.leave(pop);
else if (b && b.dataset.act == 'search') { pop.remove(); this.openSearch() }
else if (b && b.dataset.act == 'mode') this.toggleMode();
});
},
/* ---------- keyboard & dark mode ---------- */
bindKeys() {
const kbd = document.querySelector('.td-search kbd');
if (kbd && !/Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent)) kbd.textContent = 'Ctrl K';
document.addEventListener('keydown', ev => {
const typing = /^(INPUT|TEXTAREA|SELECT)$/.test((ev.target || {}).tagName || '') || (ev.target || {}).isContentEditable;
if ((ev.key == 'k' || ev.key == 'K') && (ev.metaKey || ev.ctrlKey)) { ev.preventDefault(); this.openSearch() }
else if (ev.key == '/' && !typing && !document.body.classList.contains('modal-overlay-active')) { ev.preventDefault(); this.openSearch() }
});
},
bindMode() {
const btn = document.getElementById('td-mode');
const sync = () => {
if (!btn) return;
const dark = document.documentElement.getAttribute('data-theme') == 'dark';
btn.innerHTML = icon(dark ? 'sun' : 'moon');
btn.title = dark ? T('切换到浅色', 'Light mode') : T('切换到深色', 'Dark mode');
};
if (btn) btn.addEventListener('click', () => this.toggleMode());
document.addEventListener('td-look', sync);
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (document.documentElement.getAttribute('data-mode') == 'auto')
td.applyLook({ palette: document.documentElement.getAttribute('data-palette'), mode: 'auto' });
});
sync();
},
toggleMode() {
const r = document.documentElement;
const mode = r.getAttribute('data-theme') == 'dark' ? 'light' : 'dark';
td.applyLook({ palette: r.getAttribute('data-palette') || 'clay', mode: mode });
td.saveSettings({ mode: mode }).catch(() => {});
}
});
@@ -0,0 +1,110 @@
'use strict';
'require view';
'require ui';
'require 7dollar.common as td';
/* Appearance & home preferences. Saved to /etc/config/7dollar immediately; never touches networking. */
const T = td.T, icon = td.icon, esc = td.esc;
const CARDS = [
[ 'rate', T('实时速率', 'Live traffic'), T('上传和下载速度曲线', 'Upload and download chart') ],
[ 'dev', T('在线设备', 'Online devices'), T('正在使用网络的设备', 'Devices currently online') ],
[ 'wifi', 'Wi‑Fi', T('名称、频段和连接数', 'Name, bands and clients'), t => td.menuHas(t, 'network/wireless'), T('这台设备没有无线网卡', 'This device has no wireless hardware') ],
[ 'sys', T('路由器状态', 'Router status'), T('处理器、内存、温度', 'CPU, memory, temperature') ],
[ 'ports', T('网口', 'Ports'), T('哪些网口接了网线、速率多少', 'Which ports are plugged in, and how fast') ],
[ 'disk', T('存储空间', 'Storage'), T('还剩多少空间装插件', 'Space left for apps and settings') ],
[ 'actions', T('常用操作', 'Quick actions'), T('备份、软件包、诊断、日志', 'Backup, software, diagnostics, logs') ],
[ 'apps', T('应用', 'Apps'), T('正在运行的插件', 'Plugins that are running'), t => td.flatten(t).some(p => p.group == 'apps' && p.path != 'admin/apps'), T('还没有安装插件', 'No plugins installed yet') ]
];
return view.extend({
load() {
return Promise.all([ td.loadSettings(), td.loadMenu(), td.uciSection('luci', 'main') ]);
},
render([ conf, tree, luci ]) {
this.conf = conf;
const usingUs = /\/7dollar$/.test((luci || {}).mediaurlbase || L.env.media || '');
const pal = td.PALETTES.map(([ id, name ]) => `<button type="button" class="td-pal" data-palette="${id}" data-pick="${id}">
<div class="pv"><div class="sb"><i></i><i></i><i></i><i></i></div><div class="cd"><i></i><i></i><i style="width:70%"></i><u></u></div></div>
<div class="nm"><span>${esc(name)}</span><span class="ck">${icon('tick')}</span></div></button>`).join('');
const cards = CARDS.map(([ k, n, d, need, why ]) => need && !need(tree)
? `<div class="td-row off"><div class="lb"><b>${esc(n)} <span class="td-chip mute">${esc(T('未检测到', 'Not found'))}</span></b><small>${esc(why)}</small></div><button type="button" class="td-switch" disabled aria-label="${esc(n)}"></button></div>`
: `<div class="td-row"><div class="lb"><b>${esc(n)}</b><small>${esc(d)}</small></div><button type="button" class="td-switch" data-card="${k}" aria-label="${esc(n)}"></button></div>`).join('');
const root = td.html(`<div class="td-look">
<div class="td-head"><div>
<p class="td-eyebrow">${esc(T('系统', 'System'))}</p>
<h1 class="td-serif">${esc(T('外观与首页', 'Appearance'))}</h1>
<p class="td-sub">${esc(T('只影响界面显示,立即生效,不会改动网络设置。', 'Only changes how the interface looks. Applies instantly and never touches your network.'))}</p>
</div></div>
${usingUs ? '' : `<div class="alert-message info"><h4>${esc(T('当前没有使用 7Dollar 主题', '7Dollar is not the active theme'))}</h4><p>${esc(T('下面的设置只在 7Dollar 主题下生效。', 'The settings below only apply to the 7Dollar theme.'))}</p><div class="right"><button type="button" class="btn primary" id="td-use">${esc(T('切换到 7Dollar', 'Use 7Dollar'))}</button></div></div>`}
<section class="td-card td-sect">
<h3 class="td-sect-t">${esc(T('配色', 'Palette'))}</h3>
<div class="td-palettes">${pal}</div>
<div class="td-row"><div class="lb"><b>${esc(T('显示模式', 'Mode'))}</b></div>
<div class="td-seg" id="td-modes"><button type="button" data-m="light">${esc(T('浅色', 'Light'))}</button><button type="button" data-m="dark">${esc(T('深色', 'Dark'))}</button><button type="button" data-m="auto">${esc(T('跟随系统', 'Auto'))}</button></div></div>
</section>
<section class="td-card td-sect">
<h3 class="td-sect-t">${esc(T('首页卡片', 'Home cards'))}</h3>
<p class="td-sect-d">${esc(T('“网络连接”卡片始终显示。', 'The Connection card is always shown.'))}</p>
<div class="td-rows">${cards}</div>
</section>
<div class="td-hint">${icon('info')}<span>${esc(T('换回原主题:系统 → 通用 → 语言和界面', 'Switch back: System → General → Language and Style'))}</span></div>
</div>`);
this.root = root;
root.querySelectorAll('[data-pick]').forEach(b => b.addEventListener('click', () => this.update({ palette: b.dataset.pick })));
root.querySelectorAll('#td-modes button').forEach(b => b.addEventListener('click', () => this.update({ mode: b.dataset.m })));
root.querySelectorAll('[data-card]').forEach(b => b.addEventListener('click', () => {
const k = b.dataset.card, field = td.OPT_IN.indexOf(k) > -1 ? 'shown_cards' : 'hidden_cards';
const list = td.words(this.conf[field]), i = list.indexOf(k);
i > -1 ? list.splice(i, 1) : list.push(k);
this.update({ [field]: list.join(' ') });
}));
const use = root.querySelector('#td-use');
if (use) use.addEventListener('click', () => {
use.disabled = true;
td.uciSet('luci', 'main', { mediaurlbase: '/luci-static/7dollar' }).then(() => location.reload())
.catch(e => { use.disabled = false; ui.addNotification(null, E('p', e.message)) });
});
this.sync();
return root;
},
sync() {
const c = this.conf;
this.root.querySelectorAll('.td-pal').forEach(b => b.classList.toggle('on', b.dataset.pick == c.palette));
this.root.querySelectorAll('#td-modes button').forEach(b => b.classList.toggle('on', b.dataset.m == c.mode));
this.root.querySelectorAll('[data-card]').forEach(b => b.classList.toggle('on', td.cardOn(c, b.dataset.card)));
},
update(patch) {
Object.assign(this.conf, patch);
this.sync();
if (td.active && ('palette' in patch || 'mode' in patch))
td.applyLook(this.conf);
const save = {};
for (const k in patch) save[k] = patch[k];
return td.saveSettings(save)
.then(() => td.toast(T('已保存', 'Saved')))
.catch(e => ui.addNotification(null, E('p', T('保存失败:', 'Could not save: ') + (e.message || e)), 'error'));
},
handleSave: null,
handleSaveApply: null,
handleReset: null
});
@@ -0,0 +1,46 @@
'use strict';
'require view';
'require ui';
'require 7dollar.common as td';
/* Every plugin page the router has, as tiles. The theme depends on none of them. */
const T = td.T, icon = td.icon, esc = td.esc;
return view.extend({
load() {
if (!td.active) {
location.replace(L.url('admin/services'));
return new Promise(() => {});
}
return td.loadMenu();
},
render(tree) {
const pages = td.flatten(tree).filter(p => p.group == 'apps' && p.path != 'admin/apps');
const CAT = { services: T('服务', 'Service'), vpn: 'VPN', nas: T('存储', 'Storage'), docker: T('容器', 'Container'), statistics: T('统计', 'Statistics') };
const tiles = pages.map((p, i) => {
const cat = CAT[p.path.split('/')[1]] || T('插件', 'Plugin');
return `<a class="td-app-t" href="${L.url(p.path)}" style="animation-delay:${Math.min(i, 10) * 30}ms"><div class="td-av">${icon(td.appIcon(p.path))}</div><div><b>${esc(p.title)}</b><small>${esc(cat)}</small></div></a>`;
}).join('');
const add = td.software(tree);
return td.html(`<div class="td-appsview">
<div class="td-head"><div>
<p class="td-eyebrow">${esc(T('应用', 'Apps'))}</p>
<h1 class="td-serif">${esc(T('已安装的应用', 'Installed apps'))}</h1>
<p class="td-sub">${esc(pages.length ? T(`共 ${pages.length} 个`, `${pages.length} installed`)
: T('还没有安装插件。', 'No plugins installed yet.'))}</p>
</div></div>
<div class="td-apps">${tiles}
${add ? `<a class="td-app-t add" href="${L.url('admin/' + add)}"><div class="td-av">${icon('plus')}</div><div><b style="color:var(--ink-2)">${esc(T('安装更多应用', 'Get more apps'))}</b><small>${esc(T('前往软件包', 'Open Software'))}</small></div></a>` : ''}
</div>
</div>`);
},
handleSave: null,
handleSaveApply: null,
handleReset: null
});
@@ -0,0 +1,92 @@
'use strict';
'require view';
'require poll';
'require 7dollar.common as td';
/* All devices: online (Wi-Fi + wired) and recently seen DHCP clients. The summary tiles filter the list. */
const T = td.T, icon = td.icon, esc = td.esc;
const VIEWS = [
[ 'on', T('在线', 'Online'), c => c.online ],
[ 'wifi', 'Wi‑Fi', c => c.online && c.conn == 'wifi' ],
[ 'wired', T('有线', 'Wired'), c => c.online && c.conn != 'wifi', T('局域网', 'LAN') ], // a side router sees the whole LAN here
[ 'off', T('最近连接过', 'Recently seen'), c => !c.online ]
];
return view.extend({
load() {
if (!td.active) {
location.replace(L.url('admin/status/overview'));
return new Promise(() => {});
}
return td.clients();
},
render(list) {
const root = td.html(`<div class="td-devices">
<div class="td-head"><div>
<p class="td-eyebrow">${esc(T('设备', 'Devices'))}</p>
<h1 class="td-serif">${esc(T('谁在用网络', 'Who is online'))}</h1>
<p class="td-sub" id="td-dsum">&nbsp;</p>
</div>
<a class="btn" href="${L.url('admin/network/dhcp')}">${icon('lock')}${esc(T('给设备固定 IP', 'Reserve an IP'))}</a></div>
<div class="td-stats" id="td-dstats">${VIEWS.map(([ k, n ]) => `<button type="button" class="td-stat" data-v="${k}"><small>${esc(n)}</small><b>0</b></button>`).join('')}</div>
<section class="td-card"><div class="td-devlist" id="td-dlist"></div></section>
</div>`);
this.root = root;
this.view = 'on';
root.querySelector('#td-dstats').addEventListener('click', ev => {
const b = ev.target.closest('[data-v]');
if (b) { this.view = b.dataset.v; this.paint(this.list); }
});
this.paint(list);
poll.add(() => td.clients().then(l => this.paint(l)), 5);
return root;
},
row(c) {
const meta = [];
if (c.ip) meta.push(`<span>${esc(c.ip)}</span>`);
meta.push(`<span>${esc(c.mac)}</span>`);
let sub;
if (!c.online) sub = c.expires > 0 ? T('租约剩余 ', 'Lease ') + td.duration(c.expires) : T('离线', 'Offline');
else if (c.conn == 'wifi') {
const [ s ] = td.signal(c.signal);
sub = `Wi‑Fi ${td.bandName(c.band)} · ${s}${c.signal ? ` (${c.signal} dBm)` : ''}`;
if (c.link) meta.unshift(`<span><b>${Math.round(c.link)} Mbps</b></span>`);
if (c.since) meta.push(`<span>${esc(T('已连接 ', 'Connected ') + td.duration(c.since))}</span>`);
}
else sub = td.connName(c);
return `<div class="td-dev"><div class="td-av">${icon(c.kind)}</div><div class="nm"><b>${esc(c.display)}</b><small>${esc(sub)}</small></div><div class="meta">${meta.join('')}</div></div>`;
},
paint(list) {
this.list = list;
const on = list.filter(c => c.online);
const $ = id => this.root.querySelector('#' + id);
$('td-dsum').textContent = on.length
? T(`现在有 ${on.length} 台设备在线。名字来自设备自己上报的主机名。`, `${on.length} device${on.length == 1 ? '' : 's'} online right now.`)
: T('现在没有设备在线。', 'No devices online right now.');
let rows = [];
const lan = list.some(c => c.conn == 'lan');
VIEWS.forEach(([ k, n, match, alt ]) => {
const b = $('td-dstats').querySelector(`[data-v="${k}"]`), sel = list.filter(match);
b.firstChild.textContent = lan && alt || n;
b.lastChild.textContent = sel.length;
b.hidden = lan && k == 'wifi' && !sel.length; // no Wi-Fi of our own to count
b.setAttribute('aria-pressed', k == this.view);
if (k == this.view) rows = sel;
});
const box = $('td-dlist'), html = rows.length ? rows.map(c => this.row(c)).join('') : `<div class="td-empty-row">${esc(T('这里暂时没有设备', 'No devices here'))}</div>`;
if (box._html !== html) box.innerHTML = box._html = html; // every 5 s, but the DOM only moves when something changed
},
handleSave: null,
handleSaveApply: null,
handleReset: null
});
@@ -0,0 +1,556 @@
'use strict';
'require view';
'require fs';
'require poll';
'require request';
'require ui';
'require 7dollar.common as td';
/*
* 7Dollar home dashboard. Only router data by default. The opt-in apps card lists whatever
* plugins are running: name from the menu, state from procd, no polling of the plugins.
*/
const T = td.T, icon = td.icon, esc = td.esc;
const N = 60;
const RING = 2 * Math.PI * 24;
/* One line of what matters most for the popular apps, read from their config (never their API);
* every other app shows name and state only. Keyed by procd service name. */
const MODE = {
rule: T('按规则', 'Rule'), global: T('全局', 'Global'), direct: T('直连', 'Direct'), all: T('全局', 'Global'), custom: T('自定义', 'Custom'),
gfw: T('GFW 列表', 'GFW list'), gfwlist: T('GFW 列表', 'GFW list'), router: T('绕过大陆', 'Bypass mainland'), bypass_mainland_china: T('绕过大陆', 'Bypass mainland'),
proxy_mainland_china: T('只代理大陆', 'Mainland only'), oversea: T('海外模式', 'Overseas'),
none: T('未接管 DNS', 'DNS not taken over'), redirect: T('接管 53 端口', 'Takes over port 53'), 'dnsmasq-upstream': T('dnsmasq 上游', 'dnsmasq upstream'), exchange: T('替换 dnsmasq', 'Replaces dnsmasq')
};
const sec = (cfg, s) => s[0] == '@' ? td.uciType(cfg, s.slice(1)).then(a => Object.values(a)[0] || {}) : td.uciSection(cfg, s);
const plain = (cfg, s, f) => () => sec(cfg, s).then(c => ({ sub: f(c) }));
/* current node: the section named in `key`, shown by its `label` field */
const node = (cfg, s, key, label, more) => () => sec(cfg, s).then(c => (c[key] && c[key] != 'nil' ? td.uciSection(cfg, c[key]) : Promise.resolve({}))
.then(n => ({ sub: [ n[label] || (c[key] != 'nil' && c[key]) || T('未选节点', 'No node'), more && more(c) ].filter(Boolean).join(' · ') })));
const panel = port => ({ link: `http://${location.hostname}:${port}/` });
const shares = (cfg, type) => () => Promise.all([ td.uciType(cfg, type), td.mounts() ]).then(([ s, m ]) => {
const list = Object.values(s).filter(x => x.path);
const disk = p => m.filter(x => x.mount == '/' || x.mount == p || p.indexOf(x.mount + '/') == 0).sort((a, b) => b.mount.length - a.mount.length)[0];
const d = list.map(x => disk(x.path)).find(Boolean);
return { sub: list.map(x => x.name).join(' · '), rt: d && d.size ? T(`剩余 ${td.bytes(d.avail)} / 共 ${td.bytes(d.size)}`, `${td.bytes(d.avail)} free of ${td.bytes(d.size)}`) : '' };
});
const APPS = {
openclash: () => td.uciSection('openclash', 'config').then(c => ({
sub: String(c.en_mode || c.operation_mode || '').replace(/^fake-ip/, 'Fake-IP').replace(/^redir-host/, 'Redir-Host').replace(/-(tun|mix)$/, s => ' · ' + s.slice(1).toUpperCase()),
seg: c.proxy_mode || 'rule' })),
passwall: node('passwall', '@global', 'tcp_node', 'remarks'),
passwall2: node('passwall2', '@global', 'node', 'remarks'),
shadowsocksr: node('shadowsocksr', '@global', 'global_server', 'alias', c => MODE[c.run_mode]),
homeproxy: node('homeproxy', 'config', 'main_node', 'label', c => MODE[c.routing_mode]),
nikki: plain('nikki', 'mixin', c => MODE[c.mode]),
adguardhome: () => td.uciSection('AdGuardHome', 'AdGuardHome').then(c => Object.assign({ sub: MODE[c.redirect] }, panel(c.httpport || 3000))),
openbox: () => td.readFile('/opt/open-box/data/panel-port').then(p => panel(+String(p || '').trim() || 2026)),
mosdns: plain('mosdns', 'config', c => c.listen_port && T('端口 ', 'Port ') + c.listen_port),
smartdns: plain('smartdns', '@smartdns', c => c.port && T('端口 ', 'Port ') + c.port),
bandix: plain('bandix', 'general', c => c.iface && T('监控 ', 'Watching ') + c.iface),
ksmbd: shares('ksmbd', 'share'),
samba4: shares('samba4', 'sambashare')
};
const PROTO = { dhcp: T('自动获取', 'DHCP'), pppoe: T('宽带拨号', 'PPPoE'), static: T('静态地址', 'Static'), dhcpv6: 'DHCPv6', qmi: T('移动网络', 'Cellular'), ncm: T('移动网络', 'Cellular'), modemmanager: T('移动网络', 'Cellular') };
return view.extend({
load() {
if (!td.active) {
location.replace(L.url('admin/status/overview'));
return new Promise(() => {});
}
return Promise.all([
td.loadSettings(),
td.board(),
td.sysinfo(),
td.wan(),
td.loadMenu().then(menu => Promise.all([ menu, td.menuHas(menu, 'network/wireless') ? td.wifiInfo() : null ])),
td.uciType('firewall', 'defaults'),
Promise.all([ 'mt7915e', 'mt7996e' ].map(m => td.readFile(`/sys/module/${m}/parameters/wed_enable`))).then(l => l.find(Boolean)), // Wi-Fi 6 / Wi-Fi 7 MediaTek
td.readFile('/proc/sys/net/ipv4/tcp_congestion_control'),
td.readFile('/proc/stat'),
td.readFile('/proc/net/dev')
]);
},
render([ conf, board, info, wan, [ menu, wifi ], fw, wed, cc, stat, dev ]) {
const apps = td.flatten(menu).filter(p => p.group == 'apps' && p.path != 'admin/apps');
const mode = wan && wan.up ? wan.mode : 'wan';
const software = td.software(menu);
this.s = {
down: new Array(N).fill(0), up: new Array(N).fill(0),
last: null, cpu: td.cpuStat(stat), cpuPct: null, wan: wan, wifi: wifi || { radios: {}, aps: [] }, info: info, board: board,
clients: [], ping: null, apps: apps, hasWifi: !!(wifi && Object.keys(wifi.radios).length), mode: mode
};
const fwd = Object.values(fw || {})[0] || {};
const tags = [];
if (fwd.flow_offloading_hw == '1') tags.push(T('硬件流量卸载', 'Hardware offload'));
else if (fwd.flow_offloading == '1') tags.push(T('软件流量卸载', 'Software offload'));
if (/^Y/.test(wed || '')) tags.push(T('无线加速 WED', 'Wireless offload (WED)'));
if (/bbr/.test(cc || '')) tags.push(T('BBR 拥塞控制', 'BBR congestion control'));
this.s.tags = tags;
let n = 1;
const card = (key, cls, inner) => !td.cardOn(conf, key) ? '' : `<section class="td-card ${cls}" data-card="${key}" style="--i:${n++}">${inner}</section>`;
const col = c => c.length ? `<div class="td-col">${c.join('')}</div>` : '';
/* Topology follows how this box is wired: main router (upstream → this → devices) or
* side router / AP, which hangs off the main router next to the other devices. */
const UP = { wan: [ 'modem', T('上级网络', 'Upstream') ], sta: [ 'wifi', T('上级 Wi‑Fi', 'Upstream Wi‑Fi') ], side: [ 'router', T('主路由', 'Main router') ] }[mode];
const link = (attr, tid) => `<div class="td-link"${attr}><span class="t" id="${tid}">&nbsp;</span><div class="ln"><i></i><i></i></div></div>`;
const nUp = `<div class="td-node" id="td-up-node"><div class="ic" id="td-up-icon">${icon(UP[0])}</div><b id="td-up-title">${esc(UP[1])}</b><small id="td-proto">—</small></div>`;
const nMe = `<div class="td-node me"><div class="ic">${icon('router')}</div><b>${esc(board.hostname || 'OpenWrt')}</b><small id="td-router-role">${esc(mode == 'side' ? T('旁路由', 'Side router') : T('这台路由器', 'This router'))}</small></div>`;
const nDev = `<div class="td-node"><div class="ic">${icon('devices')}</div><b id="td-ndev">—</b><small id="td-ndev-s">&nbsp;</small></div>`;
const lUp = link(' id="td-link-up"', 'td-lat'), lLan = link('', 'td-lan-t');
const topo = mode == 'side' ? nMe + lUp + nUp + lLan + nDev : nUp + lUp + nMe + lLan + nDev;
/* A side router receives and resends the same packets on one LAN port, so "down/up" would just
* be the same number twice: show one "forwarded" figure instead. */
const rate = (id, label, cls) => `<div class="td-rate${cls || ''}"><small><i></i>${esc(label)}</small><span class="td-big" id="td-${id}">0</span><span class="td-unit" id="td-${id}-u">Kbps</span></div>`;
const rates = (mode == 'side' ? rate('down', T('经过本机', 'Through this box'))
: rate('down', T('下载', 'Download')) + rate('up', T('上传', 'Upload'), ' up'))
+ `<div class="td-rate boot"><small>${esc(T('本次开机', 'Since boot'))}</small><span id="td-boot">—</span></div>`;
const os = board.release ? `${board.release.distribution} ${board.release.version}` : 'OpenWrt';
const rateCard = card('rate', 'k-rate', `
<div class="td-card-h"><h3>${esc(T('实时速率', 'Live traffic'))}</h3><span class="td-muted">${esc(T('过去 2 分钟', 'Last 2 minutes'))}</span></div>
<div class="td-rates">${rates}</div>
<svg class="td-chart" id="td-chart" viewBox="0 0 600 118" preserveAspectRatio="none" aria-hidden="true">
<defs><linearGradient id="td-gd" x1="0" y1="0" x2="0" y2="1"><stop offset="0" style="stop-color:var(--accent);stop-opacity:.26"/><stop offset="1" style="stop-color:var(--accent);stop-opacity:0"/></linearGradient></defs>
<path fill="url(#td-gd)"/><path fill="none" style="stroke:var(--accent)" stroke-width="2" vector-effect="non-scaling-stroke"/>
${mode == 'side' ? '' : '<path fill="none" style="stroke:var(--second)" stroke-width="1.6" stroke-dasharray="3 4" vector-effect="non-scaling-stroke"/>'}
</svg>`);
/* Cards below the top row, in reading order. Each goes into whichever column is shorter so far
* (rough heights), so the two columns stay balanced whatever is hidden or missing. */
const H = { dev: 6, wifi: 4, sys: 3.5, apps: 3, ports: 2, disk: 2, actions: 3 }, cols = [ [], [] ], h = [ 0, 0 ];
for (const [ k, html ] of [
[ 'dev', card('dev', 'k-dev', `
<div class="td-card-h"><h3>${esc(T('在线设备', 'Online devices'))}</h3><a href="${L.url('admin/clients')}">${esc(T('全部', 'All'))}${icon('chev')}</a></div>
<div id="td-devs"><div class="td-empty-row">${esc(T('正在读取…', 'Loading…'))}</div></div>`) ],
[ 'wifi', this.s.hasWifi ? card('wifi', 'k-wifi', `
<div class="td-card-h"><h3>Wi‑Fi</h3><a href="${L.url('admin/network/wireless')}">${esc(T('设置', 'Settings'))}${icon('chev')}</a></div>
<div id="td-wifi"></div>`) : '' ],
[ 'sys', card('sys', 'k-sys', `
<div class="td-card-h"><h3>${esc(T('路由器状态', 'Router status'))}</h3><span class="td-muted">${esc(os)}</span></div>
<div class="td-rings">
${this.ring('cpu', T('处理器', 'CPU'))}
${this.ring('mem', T('内存', 'Memory'))}
${this.ring('temp', T('温度', 'Temperature'), 'warm')}
</div>
<div class="td-tags" id="td-tags"></div>`) ],
[ 'apps', apps.length ? card('apps', 'k-apps', `
<div class="td-card-h"><h3>${esc(T('应用', 'Apps'))}</h3><a href="${L.url('admin/apps')}">${esc(T('全部', 'All'))}${icon('chev')}</a></div>
<div id="td-apps"><div class="td-empty-row">${esc(T('正在读取…', 'Loading…'))}</div></div>`) : '' ],
[ 'ports', card('ports', 'k-ports', `
<div class="td-card-h"><h3>${esc(T('网口', 'Ports'))}</h3><span class="td-muted" id="td-ports-n"></span></div>
<dl class="td-kv" id="td-ports"></dl>`) ],
[ 'disk', card('disk', 'k-disk', `
<div class="td-card-h"><h3>${esc(T('存储空间', 'Storage'))}</h3></div>
<div id="td-disk"></div>`) ],
[ 'actions', card('actions', 'k-actions', `
<div class="td-card-h"><h3>${esc(T('常用操作', 'Quick actions'))}</h3></div>
<div class="td-acts">
${this.act('admin/system/flash', 'backup', T('备份配置', 'Back up'))}
${software ? this.act('admin/' + software, 'box', T('软件包', 'Software')) : ''}
${this.act('admin/network/diagnostics', 'pulse', T('网络诊断', 'Diagnostics'))}
${this.act('admin/status/logs', 'doc', T('查看日志', 'Logs'))}
</div>`) ]
]) if (html) { const c = h[1] < h[0] ? 1 : 0; cols[c].push(html); h[c] += H[k]; }
const root = td.html(`<div class="td-home">
<div class="td-head">
<div>
<p class="td-eyebrow" id="td-today"></p>
<h1 class="td-serif" id="td-greet">${esc(T('你好', 'Hello'))}</h1>
<p class="td-sub" id="td-summary">&nbsp;</p>
</div>
<a class="btn" href="${L.url('admin/system/reboot')}">${icon('refresh')}${esc(T('重启路由器', 'Reboot'))}</a>
</div>
<div class="td-grid"><div class="td-pair">
<section class="td-card k-conn" data-card="conn">
<div class="td-card-h"><h3>${esc(T('网络连接', 'Connection'))}</h3><span class="td-chip" id="td-conn-chip"></span></div>
<div class="td-topo">${topo}</div>
<dl class="td-facts">
<div><dt>${esc(mode == 'side' ? T('本机地址', 'Address') : T('上网地址', 'WAN address'))}</dt><dd id="td-wanip">—</dd></div>
<div><dt>${esc(T('网关延迟', 'Gateway latency'))}</dt><dd id="td-ping">—</dd></div>
<div><dt>DNS</dt><dd id="td-dns">—</dd></div>
<div><dt>${esc(T('已连接', 'Connected for'))}</dt><dd id="td-wanup">—</dd></div>
</dl>
<div class="td-lines" id="td-lines"></div>
</section>
${rateCard}
</div><div class="td-cols">${col(cols[0])}${col(cols[1])}</div></div>
</div>`);
this.root = root;
this.$ = id => root.querySelector('#' + id);
this.greet();
this.paintStatic();
this.paintWifi(this.s.wifi);
this.paintDisk();
this.refreshPorts();
this.ticks = 0;
this.sample(dev, info);
this.refreshClients();
this.pingGateway();
poll.add(L.bind(this.tick, this), 2);
poll.add(L.bind(this.slow, this), 10);
this.paintApps();
requestAnimationFrame(() => requestAnimationFrame(() => this.paintRings()));
return root;
},
ring(key, label, cls) {
return `<div class="td-ring"><svg viewBox="0 0 58 58"><circle class="bg" cx="29" cy="29" r="24"/><circle class="fg ${cls || ''}" id="td-ring-${key}" cx="29" cy="29" r="24" style="stroke-dasharray:${RING};stroke-dashoffset:${RING}"/></svg><div><b id="td-ring-${key}-v">—</b><small id="td-ring-${key}-l">${esc(label)}</small></div></div>`;
},
act(path, ic, title) {
return `<a class="td-act" href="${L.url(path)}">${icon(ic)}<b>${esc(title)}</b></a>`;
},
set(id, text) {
const el = this.$(id);
if (el && el.textContent !== String(text)) el.textContent = text;
},
/* innerHTML only when it changed: polled cards keep their DOM (and hover state) between ticks */
put(el, html) {
if (el && el._html !== html) el.innerHTML = el._html = html;
},
greet() {
const d = new Date(), h = d.getHours();
const hi = T(h < 5 ? '夜深了' : h < 11 ? '早上好' : h < 14 ? '中午好' : h < 18 ? '下午好' : '晚上好',
h < 12 ? 'Good morning' : h < 18 ? 'Good afternoon' : 'Good evening');
const ok = this.s.wan && this.s.wan.up;
this.set('td-greet', ok ? hi + T(',一切都好', ', all good') : T('网络好像断开了', 'You seem to be offline'));
this.set('td-today', td.ZH ? `星期${'日一二三四五六'[d.getDay()]} · ${d.getMonth() + 1} 月 ${d.getDate()} 日`
: d.toLocaleDateString('en', { weekday: 'long', month: 'long', day: 'numeric' }));
},
paintStatic() {
const w = this.s.wan;
const bands = [...new Set((this.s.wifi.aps || []).filter(a => a.up && a.stations.length).map(a => td.bandName(a.band)))];
/* PPPoE is terminated by this router, not by the upstream network. Keep the labels in sync
* when the WAN reconnects or fails over to a different protocol. */
const dial = this.s.mode == 'wan' && w && w.proto == 'pppoe';
if (this.s.mode == 'wan') {
this.put(this.$('td-up-icon'), icon(dial ? 'globe' : 'modem'));
this.set('td-up-title', dial ? T('互联网', 'Internet') : T('上级网络', 'Upstream'));
}
this.set('td-router-role', this.s.mode == 'side' ? T('旁路由', 'Side router') : dial ? T('本机拨号', 'PPPoE dial-up') : T('这台路由器', 'This router'));
this.set('td-lan-t', this.s.mode == 'side' ? T('局域网', 'LAN') : bands.length ? 'Wi‑Fi ' + bands.join(' / ') : T('有线', 'Wired'));
const ok = !!(w && w.up), chip = this.$('td-conn-chip');
this.$('td-up-node').classList.toggle('bad', !ok);
this.$('td-link-up').classList.toggle('down', !ok);
if (chip.dataset.ok != String(ok)) {
chip.dataset.ok = ok;
chip.className = ok ? 'td-chip' : 'td-chip bad';
chip.innerHTML = ok ? `<span class="td-dot live"></span>${esc(T('畅通', 'Online'))}` : `<span class="td-dot"></span>${esc(T('未连接', 'Offline'))}`;
}
/* further lines (IPTV, a backup WAN): one quiet row each under the main one */
const lines = ((w && w.more) || []).map(u => `<p><span class="td-dot"></span><b>${esc(u.name)}</b><span>${esc([ PROTO[u.proto] || u.proto, u.ip, u.uptime && td.duration(u.uptime) ].filter(Boolean).join(' · '))}</span></p>`).join('');
this.put(this.$('td-lines'), lines);
if (!ok) {
this.set('td-proto', T('没有连接', 'Not connected'));
return;
}
this.set('td-proto', this.s.mode == 'side' ? w.gw || '—' : this.s.mode == 'sta' ? T('无线中继', 'Wi‑Fi uplink') : dial ? T('运营商网络', 'ISP network') : PROTO[w.proto] || w.proto || '—');
this.set('td-wanip', w.ip || w.ip6 || '—');
this.set('td-dns', td.dnsName(w.dns, w.gw, this.s.mode == 'side' ? T('主路由', 'Main router') : null));
this.set('td-wanup', w.uptime ? td.duration(w.uptime) : '—');
this.paintTags();
},
paintTags() {
const box = this.$('td-tags');
this.put(box, this.s.tags.map(t => `<span class="td-tag">${icon('check')}${esc(t)}</span>`).join(''));
},
paintWifi(wifi) {
const box = this.$('td-wifi');
this.s.wifi = wifi;
if (!box) return;
const radios = wifi.radios || {}, aps = wifi.aps || [];
const devs = k => k + ' ' + T('台', k == 1 ? 'client' : 'clients');
const n = aps.reduce((a, ap) => a + ap.stations.length, 0);
const guest = aps.find(a => (a.network || []).some(x => /guest/i.test(x)));
/* The headline is the network people mostly join: 5 GHz first (2.4 GHz is often the
* smart-home one), never the guest network. A band with another name shows it on its tile. */
const rank = ap => (ap.up ? 10 : 0) + (ap == guest ? -5 : 0) + ({ '5g': 3, '6g': 2, '2g': 1 }[ap.band] || 0);
const main = aps.filter(ap => ap.up).sort((a, b) => rank(b) - rank(a))[0];
const tile = (label, value, sub, off) => `<div${off ? ' class="off"' : ''}><dt>${esc(label)}</dt><dd>${esc(value)}</dd>${sub ? `<dd>${esc(sub)}</dd>` : ''}</div>`;
const tiles = Object.keys(radios).sort((a, b) => String((radios[a].config || {}).band).localeCompare(String((radios[b].config || {}).band))).map(r => {
const c = radios[r].config || {}, iw = (radios[r].iwinfo || {});
const on = radios[r].up && !c.disabled;
const width = String(c.htmode || '').replace(/^[A-Z]+/, '');
const mine = aps.filter(a => a.radio == r && a != guest);
const other = [ ...new Set(mine.filter(a => a.ssid && a.ssid != (main || {}).ssid).map(a => a.ssid)) ].join(' / ');
return tile(td.bandName(c.band) + (other ? ' · ' + other : ''),
on ? devs(mine.reduce((a, ap) => a + ap.stations.length, 0)) : T('已关闭', 'Off'),
on ? `${T('信道', 'Ch')} ${iw.channel || c.channel || 'auto'}${width ? ` · ${width} MHz` : ''}` : '', !on);
});
if (guest)
tiles.push(tile(T('访客网络', 'Guest network'), guest.up ? devs(guest.stations.length) : T('未开启', 'Off'), guest.ssid, !guest.up));
this.put(box, (main
? `<div class="td-ssid">${esc(main.ssid || '—')}</div><p class="td-muted td-ssid-s">${esc(n + ' ' + T('台设备已连接', n == 1 ? 'device connected' : 'devices connected'))}</p>`
: `<div class="td-ssid td-muted">${esc(T('未开启', 'Off'))}</div><p class="td-muted td-ssid-s">${esc(T('这台路由器还没有开启无线网络', 'No wireless network is enabled'))}</p>`)
+ (tiles.length ? `<dl class="td-kv">${tiles.join('')}</dl>` : ''));
},
/* ---------- ports and storage: ports, apps and the gateway ping every 30 s; storage comes with the system info we already poll ---------- */
refreshPorts() {
const box = this.$('td-ports');
if (!box) return Promise.resolve();
return td.ports(this.s.wan).then(list => {
if (!list.length) return box.closest('.td-card').remove(); // nothing the kernel can tell us (swconfig)
const lan = list.filter(p => !p.line);
let k = 0;
this.set('td-ports-n', T(`${list.filter(p => p.up).length} / ${list.length} 已接网线`, `${list.filter(p => p.up).length} of ${list.length} in use`));
this.put(box, list.map(p => {
const m = /^lan(\d+)$/.exec(p.name);
const name = p.line || (m ? 'LAN ' + m[1] : lan.length > 1 ? 'LAN ' + ++k : 'LAN');
const speed = !p.up ? T('未接网线', 'Unplugged') : p.speed > 0 ? (p.speed >= 1000 ? p.speed / 1000 + ' Gbps' : p.speed + ' Mbps') : T('已连接', 'Connected');
return `<div${p.up ? '' : ' class="off"'}><dt>${esc(name)}</dt><dd>${esc(speed)}</dd></div>`;
}).join(''));
});
},
paintDisk() {
const box = this.$('td-disk');
if (!box) return;
const info = this.s.info || {};
const row = (label, d) => {
if (!d || !d.total) return '';
const used = d.used != null ? d.used : d.total - d.free, pct = Math.round(used / d.total * 100);
return `<div class="td-meter"><p><b>${esc(label)}</b><span>${esc(T(`剩余 ${td.bytes((d.avail != null ? d.avail : d.free) * 1024)} / 共 ${td.bytes(d.total * 1024)}`,
`${td.bytes((d.avail != null ? d.avail : d.free) * 1024)} free of ${td.bytes(d.total * 1024)}`))}</span></p><div class="td-bar${pct >= 90 ? ' full' : ''}"><i style="width:${pct}%"></i></div></div>`;
};
const html = row(T('系统空间', 'System'), info.root) + row(T('临时文件', 'Temporary files'), info.tmp) + row(T('虚拟内存', 'Swap'), info.swap);
this.put(box, html);
},
refreshClients() {
return td.clients(this.s.wifi, this.s.wan).then(list => {
const online = list.filter(c => c.online);
this.s.clients = online;
this.set('td-ndev', online.length + ' ' + T('台设备', online.length == 1 ? 'device' : 'devices'));
this.set('td-ndev-s', online.length ? T('正在上网', 'online') : T('暂无设备', 'none yet'));
this.paintSummary();
const box = this.$('td-devs');
if (!box) return;
const top = online.slice(0, 6);
this.put(box, top.length ? top.map(c => {
const [ sig ] = td.signal(c.signal);
const sub = c.conn == 'wifi' ? `Wi‑Fi ${td.bandName(c.band)} · ${sig}` : td.connName(c) + (c.ip && c.ip != c.display ? ' · ' + c.ip : '');
const rt = c.conn == 'wifi' && c.link
? `${Math.round(c.link)} Mbps<div class="td-bar"><i style="width:${td.quality(c.signal)}%"></i></div>`
: `${esc(c.conn == 'lan' ? T('局域网', 'LAN') : T('有线', 'Wired'))}<div class="td-bar"><i style="width:100%"></i></div>`;
return `<div class="td-dev"><div class="td-av">${icon(c.kind)}</div><div class="nm"><b>${esc(c.display)}</b><small>${esc(sub)}</small></div><div class="rt" title="${esc(T('连接速率 · 信号强度', 'Link rate · signal'))}">${rt}</div></div>`;
}).join('') + (online.length > 6 ? `<a class="td-more" href="${L.url('admin/clients')}">${esc(T(`还有 ${online.length - 6} 台设备`, `${online.length - 6} more`))}</a>` : '')
: `<div class="td-empty-row">${esc(T('现在没有设备在线', 'No devices online'))}</div>`);
});
},
paintSummary() {
const info = this.s.info || {}, n = this.s.clients.length;
const host = (this.s.board || {}).hostname || T('路由器', 'Router');
if (!this.s.wan || !this.s.wan.up) {
this.set('td-summary', T('上级网络没有响应,检查一下光猫或网线是否接好。', 'The upstream network is not responding. Check the modem and cables.'));
return;
}
this.set('td-summary', td.ZH
? `${host} 已平稳运行 ${td.duration(info.uptime)},${n} 台设备在线。`
: `${host} has been up for ${td.duration(info.uptime)} with ${n} device${n == 1 ? '' : 's'} online.`);
},
sample(devText, info) {
const now = Date.now();
const w = this.s.wan;
const nd = td.netdev(devText);
const d = w && nd[w.device];
if (info) this.s.info = info;
if (d && this.s.last && now - this.s.last.t < 10000) {
const dt = Math.max(0.5, (now - this.s.last.t) / 1000);
const rx = Math.max(0, (d.rx - this.s.last.rx) / dt), tx = Math.max(0, (d.tx - this.s.last.tx) / dt);
this.s.down.push(rx); this.s.down.shift();
this.s.up.push(tx); this.s.up.shift();
const [ dv, du ] = td.rate(rx), [ uv, uu ] = td.rate(tx);
this.set('td-down', dv); this.set('td-down-u', du);
this.set('td-up', uv); this.set('td-up-u', uu);
this.drawChart();
}
if (d) {
this.s.last = { t: now, rx: d.rx, tx: d.tx };
this.set('td-boot', this.s.mode == 'side' ? td.bytes(d.rx) : `↓ ${td.bytes(d.rx)} ↑ ${td.bytes(d.tx)}`);
}
this.paintRings();
},
drawChart() {
const svg = this.$('td-chart');
if (!svg) return;
const down = this.s.down, up = this.s.up;
const max = Math.max(1, ...down, ...up) * 1.15;
const W = 600, H = 118;
const path = (a, close) => {
const p = a.map((v, i) => [ i * W / (a.length - 1), H - 6 - (v / max) * (H - 22) ]);
let s = `M${p[0]}`;
for (let i = 0; i < p.length - 1; i++) {
const p0 = p[i - 1] || p[i], p1 = p[i], p2 = p[i + 1], p3 = p[i + 2] || p2;
s += `C${p1[0] + (p2[0] - p0[0]) / 6},${p1[1] + (p2[1] - p0[1]) / 6} ${p2[0] - (p3[0] - p1[0]) / 6},${p2[1] - (p3[1] - p1[1]) / 6} ${p2}`;
}
return close ? s + `L${W},${H}L0,${H}Z` : s;
};
const [ fill, line, upLine ] = svg.querySelectorAll('path');
fill.setAttribute('d', path(down, true));
line.setAttribute('d', path(down));
if (upLine) upLine.setAttribute('d', path(up));
},
paintRings() {
const setRing = (key, pct, text, label, cls) => {
const c = this.$('td-ring-' + key);
if (!c) return;
c.style.strokeDashoffset = RING * (1 - Math.max(0, Math.min(100, pct || 0)) / 100);
if (cls != null) c.setAttribute('class', 'fg ' + cls);
this.set('td-ring-' + key + '-v', text);
if (label) this.set('td-ring-' + key + '-l', label);
};
const info = this.s.info || {}, m = info.memory || {};
if (this.s.cpuPct != null) setRing('cpu', this.s.cpuPct, Math.round(this.s.cpuPct) + '%');
else if (info.load) setRing('cpu', Math.min(100, info.load[0] / 65536 * 50), (info.load[0] / 65536).toFixed(2));
if (m.total) {
const used = (m.total - (m.available || (m.free + (m.buffered || 0) + (m.cached || 0)))) / m.total * 100;
setRing('mem', used, Math.round(used) + '%');
}
if (this.s.temp != null) {
const t = this.s.temp;
setRing('temp', t, Math.round(t) + '°C',
T('温度', 'Temp') + ' · ' + (t < 70 ? T('正常', 'normal') : t < 85 ? T('偏高', 'warm') : T('过热', 'hot')),
t < 85 ? 'warm' : 'hot');
}
else setRing('temp', 0, '—');
},
tick() {
return Promise.all([ td.readFile('/proc/net/dev'), td.readFile('/proc/stat'), td.temperature() ]).then(([ dev, stat, temp ]) => {
const c = td.cpuStat(stat);
if (c && this.s.cpu && c.total > this.s.cpu.total)
this.s.cpuPct = 100 * (1 - (c.idle - this.s.cpu.idle) / (c.total - this.s.cpu.total));
if (c) this.s.cpu = c;
if (temp != null) this.s.temp = temp;
else if (this.s.temp == null) this.$('td-ring-temp')?.closest('.td-ring').remove(); // no usable sensor (VMs, some x86)
this.sample(dev);
});
},
slow() {
return Promise.all([ td.sysinfo(), td.wan(), this.s.hasWifi ? td.wifiInfo() : this.s.wifi ]).then(([ info, wan, wifi ]) => {
this.s.info = info;
this.s.wan = wan;
this.greet(); // the hour, the date and the online state all change over a long session; it only writes what differs
this.paintWifi(wifi);
this.paintStatic();
this.paintDisk();
return Promise.all([ this.refreshClients(), ++this.ticks % 3 ? null : Promise.all([ this.refreshPorts(), this.paintApps(), this.pingGateway() ]) ]);
});
},
pingGateway() {
const w = this.s.wan;
if (!w || !w.gw || !/^[0-9.]+$/.test(w.gw)) {
this.set('td-lat', w && w.up ? T('已连接', 'Connected') : T('未连接', 'Offline'));
return Promise.resolve();
}
/* a gateway that never answers ping (many ISP modems) is retried every few minutes, not every 30 s */
if (this.s.miss >= 3 && this.ticks % 18) return Promise.resolve();
return L.resolveDefault(fs.exec('/bin/ping', [ '-c', '1', '-W', '1', w.gw ]), {}).then(r => {
const m = String((r || {}).stdout || '').match(/time[=<]([\d.]+)/);
const v = m ? (+m[1] < 1 ? '<1' : Math.round(+m[1])) + ' ms' : null;
this.s.miss = v ? 0 : (this.s.miss || 0) + 1;
this.set('td-ping', v || T('无响应', 'No reply'));
this.set('td-lat', v ? T('延迟 ', 'Latency ') + v : T('已连接', 'Connected'));
});
},
/* ---------- apps card: one procd query plus a config read per shown app, every 30 s; the DOM is touched only when something changed.
* Like the devices card it shows a few rows (apps with something to say first) and counts the rest. ---------- */
paintApps() {
const box = this.$('td-apps');
if (!box) return Promise.resolve();
return td.services().then(all => {
const fn = s => APPS[s.name.toLowerCase()];
const live = this.s.apps.map(p => [ p, td.service(all, p.path) ]).filter(([ , s ]) => s && s.running)
.sort(([ , a ], [ , b ]) => !fn(a) - !fn(b));
const top = live.slice(0, live.length > 5 ? 4 : 5), more = live.length - top.length;
return Promise.all(top.map(([ p, s ]) => (fn(s) || (() => Promise.resolve({})))().then(x => [ p, x ]))).then(rows => {
const html = rows.length ? rows.map(([ p, x ]) => `<div class="td-dev td-run"><div class="td-av">${icon(td.appIcon(p.path))}</div>
<div class="nm"><a href="${L.url(p.path)}"><b>${esc(p.title)}</b></a><small>${esc([ T('运行中', 'Running'), x.sub ].filter(Boolean).join(' · '))}</small></div>
${x.seg ? `<div class="td-seg" data-oc="${esc(x.seg)}"><button type="button" data-m="rule">${esc(T('按规则', 'Rule'))}</button><button type="button" data-m="global">${esc(T('全局', 'Global'))}</button><button type="button" data-m="direct">${esc(T('直连', 'Direct'))}</button></div>`
: x.link ? `<a class="rt" href="${esc(x.link)}" target="_blank" rel="noopener">${esc(T('控制台', 'Console'))}${icon('arrow')}</a>`
: x.rt ? `<div class="rt">${esc(x.rt)}</div>` : ''}</div>`).join('')
+ (more ? `<a class="td-more" href="${L.url('admin/apps')}">${esc(T(`还有 ${more} 个在运行`, `${more} more running`))}</a>` : '')
: `<div class="td-empty-row">${esc(T('没有正在运行的应用', 'No apps running'))}</div>`;
if (box._html === html) return;
this.put(box, html);
box.querySelectorAll('[data-oc]').forEach(seg => this.ocSwitch(seg));
});
});
},
/* OpenClash's proxy mode, switched through its own switch_rule_mode call */
ocSwitch(seg) {
let mode = seg.dataset.oc;
const mark = m => seg.querySelectorAll('button').forEach(b => b.classList.toggle('on', b.dataset.m == m));
mark(mode);
seg.addEventListener('click', ev => {
const b = ev.target.closest('button[data-m]');
if (!b || b.dataset.m == mode) return;
const prev = mode;
mark(mode = b.dataset.m);
request.get(L.url('admin/services/openclash/switch_rule_mode'), { query: { rule_mode: mode } })
.then(r => { if (!r.ok) throw r; td.toast(T('已切换代理模式', 'Proxy mode switched')); })
.catch(() => { mark(mode = prev); td.toast(T('切换失败', 'Switch failed')); });
});
},
handleSave: null,
handleSaveApply: null,
handleReset: null
});
+45
View File
@@ -0,0 +1,45 @@
#!/bin/sh
# 7Dollar — one line installer for OpenWrt
# wget -qO- https://raw.githubusercontent.com/itsmaiGe/luci-theme-7dollar/main/install.sh | sh
# Behind the GFW? put a GitHub proxy in front:
# wget -qO- https://<proxy>/https://raw.githubusercontent.com/itsmaiGe/luci-theme-7dollar/main/install.sh | PROXY=https://<proxy>/ sh
REPO=itsmaiGe/luci-theme-7dollar
BRANCH=${BRANCH:-main}
P=${PROXY:-}
RAW="${P}https://raw.githubusercontent.com/$REPO/$BRANCH"
TMP=/tmp/7dollar.$$
say() { printf '\033[38;5;173m●\033[0m %s\n' "$*"; }
die() { printf '\033[31m✕ %s\033[0m\n' "$*"; rm -rf "$TMP"; exit 1; }
get() {
if command -v uclient-fetch >/dev/null 2>&1; then uclient-fetch -q -O "$2" "$1"
elif command -v wget >/dev/null 2>&1; then wget -q -O "$2" "$1"
elif command -v curl >/dev/null 2>&1; then curl -fsSL -o "$2" "$1"
else return 1; fi
}
[ -f /etc/openwrt_release ] || die "这不是 OpenWrt 系统 / not OpenWrt"
[ -d /usr/share/ucode/luci ] || die "需要 OpenWrt 23.05 或更新版本 (ucode LuCI) / needs OpenWrt 23.05+"
mkdir -p "$TMP" || die "无法创建临时目录"
say "7Dollar 主题安装中…"
# opkg (OpenWrt 23.05 / 24.10) installs the .ipk, apk (OpenWrt 25.x) the .apk
if command -v opkg >/dev/null 2>&1; then LIST=latest.txt; else LIST=latest-apk.txt; fi
get "$RAW/dist/$LIST" "$TMP/latest" || die "下载失败,请检查网络,或设置 PROXY 后重试"
PKG=$(head -n1 "$TMP/latest" | tr -d '\r\n ')
VER=$(echo "$PKG" | sed -n 's/^luci-theme-7dollar[_-]\([0-9.]*\)-r.*/\1/p')
say "下载 $PKG"
# from the Release (counted as a download on GitHub); the copy in the repo is the fallback
get "${P}https://github.com/$REPO/releases/download/v$VER/$PKG" "$TMP/$PKG" || get "$RAW/dist/$PKG" "$TMP/$PKG" \
|| die "下载失败,请检查网络,或设置 PROXY 后重试"
if [ "$LIST" = latest.txt ]; then
opkg install --force-reinstall "$TMP/$PKG" || die "opkg 安装失败"
else
apk add --allow-untrusted "$TMP/$PKG" || die "apk 安装失败"
fi
rm -rf "$TMP" /tmp/luci-indexcache* /tmp/luci-modulecache/
say "完成!刷新浏览器里的路由器后台即可看到 7Dollar。"
say "Done! Reload the router page in your browser."
@@ -0,0 +1,4 @@
config theme 'main'
option palette 'clay'
option mode 'auto'
@@ -0,0 +1,19 @@
#!/bin/sh
# Register the 7Dollar theme and make it the active one, except on an upgrade where it was
# already registered: then the user's own theme choice stays. (Packages up to 1.0.7 unregister
# the theme when upgraded, so upgrading from those still lands on 7Dollar, as before.)
[ -f /etc/config/7dollar ] || printf "\nconfig theme 'main'\n\toption palette 'clay'\n\toption mode 'auto'\n" > /etc/config/7dollar
uci -q delete 7dollar.main.plugin_openclash && uci commit 7dollar # replaced by the apps card in 1.0.12
[ "$PKG_UPGRADE" = 1 ] && uci -q get luci.themes.7Dollar >/dev/null || uci -q set luci.main.mediaurlbase=/luci-static/7dollar
uci -q set luci.themes.7Dollar=/luci-static/7dollar
uci -q commit luci
# LuCI caches the menu, and our pages depend on the active theme: when the theme is switched
# (Language and Style), touching our menu file makes LuCI rebuild it. 24.10+ (ucitrack).
[ -x /etc/init.d/ucitrack ] && /etc/init.d/ucitrack reload >/dev/null 2>&1
rm -rf /tmp/luci-indexcache* /tmp/luci-modulecache/ 2>/dev/null
exit 0
@@ -0,0 +1,67 @@
{
"admin/home": {
"title": "Home",
"order": 0,
"action": {
"type": "view",
"path": "7dollar/home"
},
"depends": {
"acl": [ "luci-theme-7dollar" ],
"uci": {
"luci": {
"main": {
"mediaurlbase": "/luci-static/7dollar"
}
}
}
}
},
"admin/clients": {
"title": "Devices",
"order": 2,
"action": {
"type": "view",
"path": "7dollar/devices"
},
"depends": {
"acl": [ "luci-theme-7dollar" ],
"uci": {
"luci": {
"main": {
"mediaurlbase": "/luci-static/7dollar"
}
}
}
}
},
"admin/apps": {
"title": "Apps",
"order": 39,
"action": {
"type": "view",
"path": "7dollar/apps"
},
"depends": {
"acl": [ "luci-theme-7dollar" ],
"uci": {
"luci": {
"main": {
"mediaurlbase": "/luci-static/7dollar"
}
}
}
}
},
"admin/system/appearance": {
"title": "Appearance",
"order": 3,
"action": {
"type": "view",
"path": "7dollar/appearance"
},
"depends": {
"acl": [ "luci-theme-7dollar" ]
}
}
}
@@ -0,0 +1,49 @@
{
"luci-theme-7dollar": {
"description": "Grant access to the 7Dollar theme dashboard and appearance settings",
"read": {
"ubus": {
"luci-rpc": [ "getDHCPLeases", "getHostHints", "getWirelessDevices", "getNetworkDevices" ],
"iwinfo": [ "assoclist" ],
"network.interface": [ "dump" ],
"system": [ "board", "info" ],
"service": [ "list" ],
"luci": [ "getMountPoints" ],
"uci": [ "get" ],
"file": [ "read", "exec" ]
},
"file": {
"/proc/stat": [ "read" ],
"/proc/net/dev": [ "read" ],
"/proc/net/route": [ "read" ],
"/proc/net/arp": [ "read" ],
"/sys/class/net/*/brforward": [ "read" ],
"/sys/devices/virtual/net/*/brforward": [ "read" ],
"/proc/sys/net/ipv4/tcp_congestion_control": [ "read" ],
"/sys/class/thermal": [ "list" ],
"/sys/class/hwmon": [ "list" ],
"/sys/class/thermal/thermal_zone*/type": [ "read" ],
"/sys/class/thermal/thermal_zone*/temp": [ "read" ],
"/sys/class/hwmon/hwmon*/name": [ "read" ],
"/sys/class/hwmon/hwmon*/temp1_input": [ "read" ],
"/proc/[0-9]*/net/dev": [ "read" ],
"/proc/[0-9]*/net/route": [ "read" ],
"/proc/[0-9]*/net/arp": [ "read" ],
"/sys/devices/*/thermal_zone*/type": [ "read" ],
"/sys/devices/*/thermal_zone*/temp": [ "read" ],
"/sys/devices/*/hwmon*/name": [ "read" ],
"/sys/devices/*/hwmon*/temp1_input": [ "read" ],
"/sys/module/mt79*e/parameters/wed_enable": [ "read" ],
"/opt/open-box/data/panel-port": [ "read" ],
"/bin/ping": [ "exec" ]
},
"uci": [ "7dollar", "firewall", "luci", "AdGuardHome", "bandix", "homeproxy", "ksmbd", "mosdns", "nikki", "openclash", "passwall", "passwall2", "samba4", "shadowsocksr", "smartdns" ]
},
"write": {
"ubus": {
"uci": [ "add", "set", "delete", "commit" ]
},
"uci": [ "7dollar", "luci" ]
}
}
}
@@ -0,0 +1,4 @@
{
"config": "luci",
"exec": "/bin/touch -c /usr/share/luci/menu.d/luci-theme-7dollar.json"
}
+82
View File
@@ -0,0 +1,82 @@
#!/bin/sh
# Build dist/luci-theme-7dollar-<version>.apk for OpenWrt 25.x (apk-tools v3).
# Needs apk-tools v3 (`apk mkpkg`, set APK=/path/to/apk.static) and runs as root so files are
# owned by root; .github/workflows/apk.yml does both.
set -eu
cd "$(dirname "$0")/.."
NAME=luci-theme-7dollar
VER=$(sed -n 's/^PKG_VERSION:=//p' Makefile)-r$(sed -n 's/^PKG_RELEASE:=//p' Makefile)
OUT=dist/$NAME-$VER.apk
# Stamp files with the last commit that touched the package, so rebuilding unchanged sources
# gives a byte-identical apk (CI then has nothing to commit).
export SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH:-$(git -c safe.directory="$PWD" log -1 --format=%ct -- Makefile htdocs ucode root scripts/stage.sh scripts/build-apk.sh)}
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
D=$TMP/data S=$TMP/scripts
sh scripts/stage.sh "$D"
mkdir -p "$D/lib/apk/packages" "$S"
( cd "$D" && find . \( -type f -o -type l \) | sed 's/^\.//' | sort ) > "$TMP/list"
mv "$TMP/list" "$D/lib/apk/packages/$NAME.list"
echo /etc/config/7dollar > "$D/lib/apk/packages/$NAME.conffiles"
echo "/etc/config/7dollar $(sha256sum "$D/etc/config/7dollar" | cut -d' ' -f1)" > "$D/lib/apk/packages/$NAME.conffiles_static"
# Same maintainer scripts OpenWrt's package-pack.mk generates for apk packages.
cat > "$S/post-install" <<SH
#!/bin/sh
[ "\${IPKG_NO_SCRIPT}" = "1" ] && exit 0
[ -s "\${IPKG_INSTROOT}/lib/functions.sh" ] || exit 0
. \${IPKG_INSTROOT}/lib/functions.sh
export root="\${IPKG_INSTROOT}"
export pkgname="$NAME"
default_postinst
[ -n "\${IPKG_INSTROOT}" ] || {
rm -rf /tmp/luci-indexcache* /tmp/luci-modulecache/
/etc/init.d/rpcd reload >/dev/null 2>&1
}
exit 0
SH
{ echo '#!/bin/sh'; echo 'export PKG_UPGRADE=1'; sed 1d "$S/post-install"; } > "$S/post-upgrade"
cat > "$S/pre-deinstall" <<SH
#!/bin/sh
[ -s "\${IPKG_INSTROOT}/lib/functions.sh" ] || exit 0
. \${IPKG_INSTROOT}/lib/functions.sh
export root="\${IPKG_INSTROOT}"
export pkgname="$NAME"
default_prerm
SH
cat > "$S/post-deinstall" <<'SH'
#!/bin/sh
[ -n "${IPKG_INSTROOT}" ] || {
uci -q delete luci.themes.7Dollar
[ "$(uci -q get luci.main.mediaurlbase)" = "/luci-static/7dollar" ] && \
uci -q set luci.main.mediaurlbase=/luci-static/bootstrap
uci commit luci
rm -rf /tmp/luci-indexcache* /tmp/luci-modulecache/
}
exit 0
SH
chmod 755 "$S"/*
find "$D" "$S" -exec touch -h -d "@$SOURCE_DATE_EPOCH" {} +
mkdir -p dist
rm -f dist/$NAME-*.apk
${APK:-apk} mkpkg \
--info "name:$NAME" \
--info "version:$VER" \
--info "description:7Dollar, an elegant, lightweight LuCI theme" \
--info "arch:noarch" \
--info "license:Apache-2.0" \
--info "origin:$NAME" \
--info "url:https://github.com/itsmaiGe/luci-theme-7dollar" \
--info "maintainer:itsmaiGe" \
--info "depends:libc luci-base" \
--script "post-install:$S/post-install" \
--script "post-upgrade:$S/post-upgrade" \
--script "pre-deinstall:$S/pre-deinstall" \
--script "post-deinstall:$S/post-deinstall" \
--files "$D" \
--output "$OUT"
basename "$OUT" > dist/latest-apk.txt
echo "$OUT ($(wc -c < "$OUT" | tr -d ' ') bytes)"
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# Build dist/luci-theme-7dollar_<version>_all.ipk without an OpenWrt SDK.
# The result is a standard opkg package (tar.gz flavour, like ipkg-build).
set -euo pipefail
cd "$(dirname "$0")/.."
VER=$(sed -n 's/^PKG_VERSION:=//p' Makefile)-r$(sed -n 's/^PKG_RELEASE:=//p' Makefile)
NAME=luci-theme-7dollar
OUT=dist/${NAME}_${VER}_all.ipk
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
export COPYFILE_DISABLE=1 # no macOS ._ files
TAR=tar; command -v gtar >/dev/null && TAR=gtar
own() { if $TAR --version | grep -q GNU; then echo "--format=gnu --owner=0 --group=0 --numeric-owner"; else echo "--format ustar --no-xattrs --no-mac-metadata --uid 0 --gid 0 --uname root --gname root"; fi; }
# ---- data ----
D=$TMP/data
sh scripts/check-jsmin.sh
sh scripts/stage.sh "$D"
SIZE=$(du -sk "$D" | cut -f1)
# ---- control ----
C=$TMP/control
mkdir -p "$C"
cat > "$C/control" <<CTL
Package: $NAME
Version: $VER
Depends: libc, luci-base
Source: https://github.com/itsmaiGe/luci-theme-7dollar
SourceName: $NAME
License: Apache-2.0
Section: luci
URL: https://github.com/itsmaiGe/luci-theme-7dollar
Maintainer: itsmaiGe
Architecture: all
Installed-Size: $((SIZE * 1024))
Description: 7Dollar — an elegant, lightweight LuCI theme with five palettes,
light/dark mode, a friendly home dashboard and grouped navigation.
CTL
echo /etc/config/7dollar > "$C/conffiles"
cat > "$C/postinst" <<'SH'
#!/bin/sh
[ "${IPKG_NO_SCRIPT}" = "1" ] && exit 0
[ -s ${IPKG_INSTROOT}/lib/functions.sh ] || exit 0
. ${IPKG_INSTROOT}/lib/functions.sh
default_postinst $0 $@
[ -n "${IPKG_INSTROOT}" ] || {
rm -rf /tmp/luci-indexcache* /tmp/luci-modulecache/
/etc/init.d/rpcd reload >/dev/null 2>&1
}
exit 0
SH
cat > "$C/prerm" <<'SH'
#!/bin/sh
[ -s ${IPKG_INSTROOT}/lib/functions.sh ] || exit 0
. ${IPKG_INSTROOT}/lib/functions.sh
default_prerm $0 $@
SH
cat > "$C/postrm" <<'SH'
#!/bin/sh
[ "$1" = upgrade ] && exit 0 # opkg runs this on upgrades too; only a real removal unregisters
[ -n "${IPKG_INSTROOT}" ] || {
uci -q delete luci.themes.7Dollar
[ "$(uci -q get luci.main.mediaurlbase)" = "/luci-static/7dollar" ] && \
uci -q set luci.main.mediaurlbase=/luci-static/bootstrap
uci commit luci
rm -rf /tmp/luci-indexcache* /tmp/luci-modulecache/
}
exit 0
SH
chmod 644 "$C/control" "$C/conffiles"; chmod 755 "$C/postinst" "$C/prerm" "$C/postrm"
echo "2.0" > "$TMP/debian-binary"
( cd "$D" && $TAR $(own) -czf "$TMP/data.tar.gz" . )
( cd "$C" && $TAR $(own) -czf "$TMP/control.tar.gz" . )
mkdir -p dist
( cd "$TMP" && $TAR $(own) -czf pkg.tar.gz ./debian-binary ./data.tar.gz ./control.tar.gz )
mv "$TMP/pkg.tar.gz" "$OUT"
basename "$OUT" > dist/latest.txt
echo "$OUT ($(wc -c < "$OUT" | tr -d ' ') bytes)"
+18
View File
@@ -0,0 +1,18 @@
#!/bin/sh
# Source builds (feeds / SDK) minify our JS with LuCI's jsmin, which is stricter than a browser:
# for one, it misreads a regex literal that follows `return`. Run every script through the same
# jsmin and make sure the result still parses. Needs cc, curl and node; skipped without them.
set -eu
cd "$(dirname "$0")/.."
T=${TMPDIR:-/tmp}/7dollar-jsmin
mkdir -p "$T"
if [ ! -x "$T/jsmin" ]; then
curl -sfL -o "$T/jsmin.c" https://raw.githubusercontent.com/openwrt/luci/master/modules/luci-base/src/jsmin.c \
&& cc -o "$T/jsmin" "$T/jsmin.c" 2>/dev/null || { echo "check-jsmin: skipped (no cc or network)"; exit 0; }
fi
bad=0
for f in $(find htdocs -name '*.js'); do
"$T/jsmin" < "$f" > "$T/out.js" 2>/dev/null || continue # jsmin gave up: the buildroot then ships the file as it is
node -e "new Function(require('fs').readFileSync('$T/out.js','utf8'))" 2>/dev/null || { echo "check-jsmin: $f breaks after jsmin"; bad=1; }
done
exit $bad
+17
View File
@@ -0,0 +1,17 @@
#!/bin/sh
# Lay out the installed file tree of luci-theme-7dollar in $1 (shared by build-ipk.sh and build-apk.sh).
# JS and CSS are minified on the way (what the OpenWrt buildroot does for LuCI itself); the sources in
# the repo stay readable. MINIFY=0 skips it.
set -eu
cd "$(dirname "$0")/.."
D=$1
mkdir -p "$D/www" "$D/usr/share/ucode/luci"
cp -R htdocs/. "$D/www/"
cp -R ucode/. "$D/usr/share/ucode/luci/"
cp -R root/. "$D/"
find "$D" -name .DS_Store -exec rm {} +
[ "${MINIFY:-1}" = 0 ] || npx -y esbuild@0.28.2 $(find "$D/www" -name '*.js' -o -name '*.css') \
--minify --charset=utf8 --target=esnext --log-level=warning --allow-overwrite --outbase="$D/www" --outdir="$D/www"
find "$D" -type d -exec chmod 755 {} +
find "$D" -type f -exec chmod 644 {} +
chmod 755 "$D"/etc/uci-defaults/*
+11
View File
@@ -0,0 +1,11 @@
// 7Dollar: helpers shared by the theme templates (sidebar and login page).
/* What to call this box. x86 boards often keep a BIOS placeholder ("Default string",
* "To be filled by O.E.M.") as their model: name them by CPU instead, e.g. "Intel Celeron N5105". */
export function model(board) {
const m = board?.model;
if (m && !match(m, /default string|to be filled|o\.e\.m|system product name|not (specified|applicable)/i))
return m;
return trim(replace(replace(board?.system ?? '', /\((R|TM)\)|\bCPU\b|@.*$/g, ''), /\s+/g, ' '))
|| board?.release?.distribution || 'OpenWrt';
};
@@ -0,0 +1,17 @@
{#
7Dollar — an elegant LuCI theme
Licensed to the public under the Apache License 2.0.
-#}
{% if (!blank_page): %}
<footer class="td-footer">
<span>{{ version.distname }} {{ version.distversion }} · <a href="https://github.com/itsmaiGe/luci-theme-7dollar" target="_blank" rel="noreferrer">7Dollar</a></span>
<span><a href="https://github.com/openwrt/luci" target="_blank" rel="noreferrer">{{ version.luciname }} ({{ version.luciversion }})</a>{% if (lua_active): %} · {{ _('Lua compatibility mode active') }}{% endif %}</span>
</footer>
</div>
</main>
</div>
<ul id="modemenu" style="display:none"></ul>
<script>L.require('menu-7dollar')</script>
{% endif %}
</body>
</html>
@@ -0,0 +1,96 @@
{#
7Dollar — an elegant LuCI theme
Licensed to the public under the Apache License 2.0.
-#}
{%
import { getuid, getspnam } from 'luci.core';
import { model as boardModel } from 'luci.7dollar';
const boardinfo = ubus.call('system', 'board') ?? {};
const conf = uci.get_all('7dollar', 'main') ?? {};
const palette = (conf.palette in [ 'clay', 'celadon', 'mist', 'dusk', 'graphite' ]) ? conf.palette : 'clay';
const mode = (conf.mode in [ 'light', 'dark', 'auto' ]) ? conf.mode : 'auto';
const zh = !!match(dispatcher.lang ?? '', /^zh/);
const T = (z, e) => zh ? z : e;
const ver = '1.0.16';
const hostname = striptags(boardinfo.hostname ?? 'OpenWrt');
const model = striptags(boardModel(boardinfo));
const sprite = `${media}/icons.svg`;
http.prepare_content('text/html; charset=UTF-8');
-%}
<!DOCTYPE html>
<html lang="{{ dispatcher.lang }}" data-palette="{{ palette }}" data-mode="{{ mode }}"{% if (mode != 'auto'): %} data-theme="{{ mode }}" data-darkmode="{{ mode == 'dark' ? 'true' : 'false' }}"{% endif %}>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>{{ striptags(`${hostname}${node ? ` · ${node.title}` : ''}`) }}</title>
<script>
(function(r){if(r.getAttribute('data-mode')!=='auto')return;var d=window.matchMedia('(prefers-color-scheme: dark)').matches;r.setAttribute('data-theme',d?'dark':'light');r.setAttribute('data-darkmode',d)})(document.documentElement);
</script>
<link rel="stylesheet" href="{{ media }}/cascade.css?v={{ ver }}">
<link rel="icon" href="{{ media }}/logo.svg?v={{ ver }}" type="image/svg+xml">
<link rel="apple-touch-icon" href="{{ media }}/logo.svg?v={{ ver }}">
{% if (node?.css): %}
<link rel="stylesheet" href="{{ resource }}/{{ node.css }}">
{% endif %}
{% if (css): %}
<style title="text/css">{{ css }}</style>
{% endif %}
<script src="{{ dispatcher.build_url('admin/translations', dispatcher.lang) }}"></script>
<script src="{{ resource }}/cbi.js?v={{ version.luciversion }}"></script>
</head>
<body class="lang_{{ dispatcher.lang }} {{ entityencode(striptags(node?.title ?? ''), true) }}" data-page="{{ entityencode(join('-', ctx.request_path), true) }}">
{% if (!blank_page): %}
<div class="td-app">
<aside class="td-side">
<a class="td-brand" href="{{ dispatcher.build_url('admin') }}">
<b>{{ hostname }}</b><small>{{ model }}</small>
</a>
<button class="td-search" type="button" id="td-search" title="{{ T('搜索', 'Search') }}">
<svg class="i" aria-hidden="true"><use href="{{ sprite }}#search"></use></svg>
<span>{{ T('搜索功能或设置', 'Search settings') }}</span><kbd>⌘K</kbd>
</button>
<nav class="td-nav" id="td-nav" aria-label="{{ T('主菜单', 'Main menu') }}"></nav>
<div class="td-side-foot">
<button class="td-icon-btn" type="button" id="td-mode" title="{{ T('切换深浅色', 'Toggle dark mode') }}"><svg class="i" aria-hidden="true"><use href="{{ sprite }}#moon"></use></svg></button>
<a class="td-icon-btn" href="{{ dispatcher.build_url('admin/logout') }}" title="{{ T('退出登录', 'Log out') }}"><svg class="i" aria-hidden="true"><use href="{{ sprite }}#logout"></use></svg></a>
</div>
</aside>
<main class="td-main">
<div id="maincontent">
<div class="td-top"><div class="td-pills" id="td-pills"></div><div id="indicators"></div></div>
{% if (getuid() == 0 && getspnam('root')?.pwdp === ''): %}
<div class="alert-message warning">
<h4>{{ _('No password set!') }}</h4>
<p>{{ _('There is no password set on this router. Please configure a root password to protect the web interface.') }}</p>
{% if (dispatcher.lookup("admin/system/admin")): %}
<div class="right"><a class="btn" href="{{ dispatcher.build_url("admin/system/admin") }}">{{ _('Go to password configuration...') }}</a></div>
{% endif %}
</div>
{% endif %}
{% if (boardinfo.rootfs_type == "initramfs"): %}
<div class="alert-message warning">
<h4>{{ _('System running in recovery (initramfs) mode.') }}</h4>
<p>{{ _('No changes to settings will be stored and are lost after rebooting. This mode should only be used to install a firmware upgrade') }}</p>
{% if (dispatcher.lookup("admin/system/flash")): %}
<div class="right"><a class="btn" href="{{ dispatcher.build_url("admin/system/flash") }}">{{ _('Go to firmware upgrade...') }}</a></div>
{% endif %}
</div>
{% endif %}
<noscript>
<div class="alert-message warning">
<h4>{{ _('JavaScript required!') }}</h4>
<p>{{ _('You must enable JavaScript in your browser or LuCI will not work properly.') }}</p>
</div>
</noscript>
<div id="tabmenu" style="display:none"></div>
{% endif %}
@@ -0,0 +1,79 @@
{#
7Dollar — login page
Licensed to the public under the Apache License 2.0.
-#}
{% include('header', { blank_page: true }) %}
{%
import { model } from 'luci.7dollar';
const board = ubus.call('system', 'board') ?? {};
const zh = !!match(dispatcher.lang ?? '', /^zh/);
const T = (z, e) => zh ? z : e;
const sprite = `${media}/icons.svg`;
const user = duser ?? 'root';
const custom = fuser && fuser != user;
-%}
<div class="td-login">
<div class="glow"></div>
<svg class="arcs" viewBox="0 0 600 600" fill="none" stroke="currentColor" stroke-width="1.2" aria-hidden="true"><circle cx="600" cy="600" r="160"/><circle cx="600" cy="600" r="250"/><circle cx="600" cy="600" r="340"/><circle cx="600" cy="600" r="430"/><circle cx="600" cy="600" r="520"/></svg>
<form class="td-lbox" method="post" id="td-login">
<h1 class="td-serif">{{ T('欢迎回来', 'Welcome back') }}</h1>
<p class="td-sub">{{ striptags(board.hostname ?? 'OpenWrt') }} · {{ striptags(model(board)) }}</p>
{% if (fuser): %}
<div class="td-lerr" role="alert">{{ T('密码不正确,请再试一次。', 'Wrong password, please try again.') }}</div>
{% endif %}
<label class="td-field" id="td-ufield"{% if (!custom): %} hidden{% endif %}>
<svg class="i" aria-hidden="true"><use href="{{ sprite }}#devices"></use></svg>
<input name="luci_username" id="luci_username" type="text" autocomplete="username" value="{{ entityencode(custom ? fuser : user, true) }}" placeholder="{{ T('用户名', 'Username') }}" autocapitalize="off" spellcheck="false">
</label>
<label class="td-field">
<svg class="i" aria-hidden="true"><use href="{{ sprite }}#lock"></use></svg>
<input name="luci_password" id="luci_password" type="password" autocomplete="current-password" placeholder="{{ T('管理密码', 'Password') }}" autofocus>
<button class="eye" type="button" id="td-eye" title="{{ T('显示密码', 'Show password') }}"><svg class="i" aria-hidden="true"><use href="{{ sprite }}#eye"></use></svg></button>
</label>
<button class="btn primary" type="submit" id="td-go">{{ T('进入', 'Sign in') }} <svg class="i" aria-hidden="true"><use href="{{ sprite }}#arrow"></use></svg></button>
<p class="td-luser" id="td-luser"{% if (custom): %} hidden{% endif %}>{{ T('将以', 'Signing in as') }} <b>{{ entityencode(user) }}</b> {{ T('身份登录', '') }} · <button type="button" id="td-switch">{{ T('换个用户', 'Other user') }}</button></p>
</form>
<div class="td-lfoot">{{ version.distname }} {{ version.distversion }} · {{ T('仅限局域网访问', 'Local network only') }}</div>
</div>
<script>
(function() {
var pw = document.getElementById('luci_password'),
eye = document.getElementById('td-eye'),
form = document.getElementById('td-login'),
go = document.getElementById('td-go');
eye.addEventListener('click', function() {
pw.type = (pw.type == 'password') ? 'text' : 'password';
pw.focus();
});
document.getElementById('td-switch').addEventListener('click', function() {
document.getElementById('td-ufield').hidden = false;
document.getElementById('td-luser').hidden = true;
var u = document.getElementById('luci_username');
u.value = '';
u.focus();
});
form.addEventListener('submit', function() {
go.disabled = true;
go.classList.add('spinning');
});
pw.focus();
})();
</script>
{% include('footer', { blank_page: true }) %}
+22
View File
@@ -0,0 +1,22 @@
#!/bin/sh
# Remove 7Dollar and switch LuCI back to Bootstrap.
# wget -qO- https://raw.githubusercontent.com/itsmaiGe/luci-theme-7dollar/main/uninstall.sh | sh
if command -v opkg >/dev/null 2>&1 && opkg status luci-theme-7dollar | grep -q '^Status: .* installed'; then
opkg remove luci-theme-7dollar
elif command -v apk >/dev/null 2>&1 && apk info -e luci-theme-7dollar >/dev/null 2>&1; then
apk del luci-theme-7dollar
else
# copied in by the 1.0.x installer on OpenWrt 25.x
rm -rf /www/luci-static/7dollar /www/luci-static/resources/7dollar /www/luci-static/resources/view/7dollar \
/usr/share/ucode/luci/template/themes/7dollar
rm -f /www/luci-static/resources/menu-7dollar.js /usr/share/luci/menu.d/luci-theme-7dollar.json \
/usr/share/rpcd/acl.d/luci-theme-7dollar.json /etc/uci-defaults/30_luci-theme-7dollar
uci -q delete luci.themes.7Dollar
[ "$(uci -q get luci.main.mediaurlbase)" = "/luci-static/7dollar" ] && uci set luci.main.mediaurlbase=/luci-static/bootstrap
uci commit luci
/etc/init.d/rpcd reload >/dev/null 2>&1
fi
rm -rf /tmp/luci-indexcache* /tmp/luci-modulecache/
echo "7Dollar 已卸载,界面已切回 Bootstrap。设置文件 /etc/config/7dollar 保留,可手动删除。"