🚀 Sync 2026-11-14 15:02:09
No files matched your search
@@ -0,0 +1,3 @@
|
||||
.DS_Store
|
||||
*.swp
|
||||
/tmp/
|
||||
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
luci-theme-7dollar-1.0.16-r1.apk
|
||||
@@ -0,0 +1 @@
|
||||
luci-theme-7dollar_1.0.16-r1_all.ipk
|
||||
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 29 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 |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 204 KiB |
|
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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"> </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}"> </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"> </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"> </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
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)"
|
||||
@@ -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)"
|
||||
@@ -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
|
||||
@@ -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/*
|
||||
@@ -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 }) %}
|
||||
@@ -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 保留,可手动删除。"
|
||||