Fixes and improvements
This commit is contained in:
1 parent
7882bbbd37
commit
39fcfe50c6
49 files changed
+5585
-4450
No files matched your search
Regular → Executable
+1
-1
@@ -764,7 +764,7 @@ jobs:
|
||||
tar -xzf "inputs/frontend/nodeflare-frontend-$VERSION.tar.gz" -C inputs/frontend/unpacked
|
||||
cp -a inputs/frontend/unpacked/dist/. package/share/frontend/
|
||||
cp -a inputs/frontend/unpacked/admin-dist/. package/share/admin/
|
||||
cp deploy/nodeflare.service package/nodeflare.service
|
||||
cp docs/nodeflare.service package/nodeflare.service
|
||||
cp LICENSE package/LICENSE
|
||||
chmod 0755 "package/$PACKAGE_BINARY"
|
||||
chmod 0644 package/LICENSE package/nodeflare.service
|
||||
|
||||
@@ -1,14 +1,67 @@
|
||||
# NodeFlare
|
||||
|
||||
NodeFlare 是一款轻量级的自托管服务器监控面板,通过 Web 界面查看服务器状态,并由轻量级 Agent 采集数据上报。支持实时监控、TCP 拨测、Telegram 告警、远程执行、TOTP 两步验证、主题定制、SQLite/PostgreSQL 备份迁移等。
|
||||
NodeFlare 是一款轻量级、可自托管的服务器监控面板:通过 Web 界面查看服务器状态,由轻量 Agent 采集数据并主动上报。支持实时监控、TCP/ICMP 拨测、Telegram 告警、远程执行、TOTP 两步验证、主题定制,以及 SQLite / PostgreSQL 的备份恢复与在线迁移。
|
||||
|
||||
CPU、内存和网速每秒采样,默认每 3 秒压缩批量上传。服务器卡片每秒显示一个真实采样值,正常连接下有约 2~3 秒的显示延迟;断线重连后跳过积压的旧数据。磁盘容量、GPU 等较慢指标单独缓存,历史数据按节点设置的保存间隔聚合。增大实时上传间隔会降低卡片连续更新的频率。
|
||||
English: [docs/README.en.md](docs/README.en.md)
|
||||
|
||||
Linux 内存统计与 Komari 默认口径一致:已用内存为 `MemTotal - MemFree - Cached - SReclaimable - Buffers + Shmem`,Swap 已用量扣除 `SwapCached`。文件缓存不计入已用内存,共享内存计入;面板显示的是整机内存,不是 NodeFlare 进程本身的占用。
|
||||
- [特性](#特性)
|
||||
- [平台支持](#平台支持)
|
||||
- [安装服务端](#安装服务端)
|
||||
- [安装 Agent](#安装-agent)
|
||||
- [监控口径与采样](#监控口径与采样)
|
||||
- [配置](#配置)
|
||||
- [默认目录与日志](#默认目录与日志)
|
||||
- [反向代理](#反向代理)
|
||||
- [数据库与备份](#数据库与备份)
|
||||
- [开发](#开发)
|
||||
- [排障](#排障)
|
||||
|
||||
## 特性
|
||||
|
||||
**监控与展示**
|
||||
|
||||
- CPU、内存、网速每秒采样,默认每 3 秒压缩批量上传;磁盘、GPU、连接数等慢指标单独缓存
|
||||
- 延迟拨测:TCP 与 ICMP 任务按节点分配,支持电信 / 移动 / 联通分线展示
|
||||
- 公开看板双语(简体中文 / English),站点名称、公告、Logo、背景图与显示项均可配置
|
||||
- 主题商店:内置主题、仓库主题一键安装、本地 ZIP 上传、主题参数自定义
|
||||
|
||||
**告警**
|
||||
|
||||
- 资源阈值告警:CPU / 内存 / 磁盘 / 上行 / 下行,可选均值或持续超限,并可设置持续时长
|
||||
- 离线、到期、流量告警;Telegram 推送,消息模板可自定义
|
||||
|
||||
**管理**
|
||||
|
||||
- 节点分组、标签、地区、计费周期、价格、到期时间、流量额度与重置日
|
||||
- 远程执行命令:需启用 TOTP;单条命令最长执行 10 分钟,离开页面不会中断
|
||||
- 每日汇率快照,用于多币种价格折算
|
||||
|
||||
**安全**
|
||||
|
||||
- TOTP 两步验证;Cloudflare Turnstile 人机验证(可分别保护管理员登录与公开看板)
|
||||
- 登录限速、会话管理(查看登录设备并踢下线)、会话有效期可配置
|
||||
- 默认只监听 `127.0.0.1`;Agent 为出站连接,无需开放入站端口;Agent Token 支持文件或环境变量传递
|
||||
|
||||
**数据**
|
||||
|
||||
- SQLite 与 PostgreSQL 双支持,两种数据库之间可在线迁移
|
||||
- 一键备份 / 恢复(ZIP)、空间回收、历史数据按保留天数自动清理
|
||||
|
||||
## 平台支持
|
||||
|
||||
| 角色 | 平台 | 架构 |
|
||||
| --- | --- | --- |
|
||||
| 服务端 | Linux | x64 / ARM64 |
|
||||
| 服务端 | Windows | x64 |
|
||||
| 服务端 | macOS | ARM64 |
|
||||
| 服务端 | FreeBSD 13+ | x64 / ARM64 |
|
||||
| Agent | Linux / Windows / macOS / FreeBSD | 同上(macOS 仅 Apple Silicon) |
|
||||
|
||||
安装脚本会自动检测系统服务管理器(systemd / OpenRC / launchd / FreeBSD rc / Windows 计划任务)并注册为开机自启。
|
||||
|
||||
## 安装服务端
|
||||
|
||||
支持 Linux x64/ARM64、Windows x64、macOS ARM64、FreeBSD 13+ x64/ARM64。安装脚本自动下载最新 Release 并注册系统服务。
|
||||
安装脚本自动下载最新 Release 并注册系统服务。
|
||||
|
||||
Linux / macOS:
|
||||
|
||||
@@ -30,21 +83,28 @@ Unblock-File "$env:TEMP\nodeflare-install.ps1"
|
||||
& "$env:TEMP\nodeflare-install.ps1"
|
||||
```
|
||||
|
||||
首次安装需输入管理员用户名、密码、监听端口(默认 2206)和数据库地址(默认 SQLite)。服务端默认监听 `127.0.0.1:2206`,本机访问 `http://127.0.0.1:2206/admin/login`;对外使用需经 HTTPS 反向代理。
|
||||
首次安装会询问管理员用户名、密码、监听端口(默认 2206)和数据库地址(默认 SQLite)。服务端默认监听 `127.0.0.1:2206`,本机访问 `http://127.0.0.1:2206/admin/login`;对外使用需经 HTTPS 反向代理,见[反向代理](#反向代理)。
|
||||
|
||||
更新:重新运行安装脚本即可,配置数据保留。卸载加 `--purge` 删除配置数据。
|
||||
管理员密码仅首次初始化数据库时需要,初始化完成后会自动从配置文件中清除。
|
||||
|
||||
Linux systemd:
|
||||
### 安装脚本参数
|
||||
|
||||
```bash
|
||||
systemctl status nodeflare
|
||||
systemctl restart nodeflare
|
||||
journalctl -u nodeflare -f
|
||||
```
|
||||
| 命令 | 说明 |
|
||||
| --- | --- |
|
||||
| `sudo sh install.sh` | 交互菜单 |
|
||||
| `sudo sh install.sh --install` | 安装或更新 |
|
||||
| `sudo sh install.sh --status` | 查看服务状态 |
|
||||
| `sudo sh install.sh --restart` | 重启服务 |
|
||||
| `sudo sh install.sh --uninstall` | 卸载,保留配置和数据 |
|
||||
| `sudo sh install.sh --uninstall --purge` | 卸载并删除配置与数据 |
|
||||
|
||||
Windows 对应参数为 `-Install` / `-Status` / `-Restart` / `-Uninstall [-Purge]`。
|
||||
|
||||
更新:重新运行安装脚本即可,配置数据保留。安装与更新都会校验 Release 摘要,失败时自动回滚到上一版本。
|
||||
|
||||
## 安装 Agent
|
||||
|
||||
在管理后台“服务器”页面创建节点,执行弹窗中的安装命令。Agent 出站连接服务端,无需开放入站端口。替换面板地址和 Token。
|
||||
在管理后台「服务器」页面创建节点,执行弹窗中的安装命令。Agent 主动出站连接服务端,无需开放入站端口。
|
||||
|
||||
Linux:
|
||||
|
||||
@@ -53,50 +113,114 @@ curl -fsSL https://raw.githubusercontent.com/elysia62/NodeFlare/main/agent/agent
|
||||
| sudo sh -s -- -e 'https://nodeflare.example.com' -t 'Agent Token'
|
||||
```
|
||||
|
||||
macOS ARM64 / FreeBSD / Windows 脚本见仓库 `agent/` 目录,安装参数相同。
|
||||
各平台脚本:
|
||||
|
||||
更新:
|
||||
| 平台 | 脚本 | 服务方式 |
|
||||
| --- | --- | --- |
|
||||
| Linux | `agent/agent.sh` | systemd / OpenRC |
|
||||
| macOS(Apple Silicon) | `agent/install-macos.sh` | launchd |
|
||||
| FreeBSD | `agent/install-freebsd.sh` | rc.d |
|
||||
| Windows | `agent/install.ps1` | 计划任务 |
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/elysia62/NodeFlare/main/agent/agent.sh | sudo sh -s -- --update
|
||||
```
|
||||
### Agent 参数
|
||||
|
||||
Windows 用 `-Update` 参数。更新自动沿用已配置的地址和 Token,校验 Release 摘要,失败自动回滚。
|
||||
| 参数 | 说明 |
|
||||
| --- | --- |
|
||||
| `-e` | NodeFlare 服务地址(必填) |
|
||||
| `-t` | Agent Token(必填) |
|
||||
| `-i` | 初始历史保存间隔,15–3600 秒(默认 60) |
|
||||
| `-m` | GitHub 下载加速前缀,如 `https://ghproxy.net`(可选) |
|
||||
| `--update` | 更新 Agent,沿用已保存的地址与 Token,校验摘要,失败回滚 |
|
||||
| `--status` | 查看 Agent 状态 |
|
||||
| `--uninstall` | 卸载 Agent |
|
||||
|
||||
卸载:
|
||||
Windows 对应 `-Endpoint` / `-Token` / `-Interval` / `-Mirror` 与 `-Update` / `-Status` / `-Uninstall`。
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/elysia62/NodeFlare/main/agent/agent.sh | sudo sh -s -- --uninstall
|
||||
```
|
||||
> **关于 Token 传递**:`-t` 会出现在进程列表中。Agent 也支持环境变量 `NODEFLARE_AGENT_TOKEN`,以及 `--token-file <路径>` 从文件读取。Linux 安装脚本会把 Token 写入 `0600` 的 `agent.env` 供服务单元读取。
|
||||
|
||||
## 监控口径与采样
|
||||
|
||||
CPU、内存和网速每秒采样,默认每 3 秒压缩批量上传。服务器卡片每秒显示一个真实采样值,正常连接下有约 2~3 秒的显示延迟;断线重连后跳过积压的旧数据。磁盘容量、GPU 等较慢指标单独缓存,历史数据按节点设置的保存间隔聚合。增大实时上传间隔会降低卡片连续更新的频率。
|
||||
|
||||
Linux 内存统计与 Komari 默认口径一致:已用内存为 `MemTotal - MemFree - Cached - SReclaimable - Buffers + Shmem`,Swap 已用量扣除 `SwapCached`。文件缓存不计入已用内存,共享内存计入;面板显示的是整机内存,不是 NodeFlare 进程本身的占用。
|
||||
|
||||
## 配置
|
||||
|
||||
配置文件位置见[默认目录](#默认目录),示例见 [`backend/config.example.toml`](backend/config.example.toml)。
|
||||
配置文件位置见[默认目录与日志](#默认目录与日志),完整示例见 [`backend/config.example.toml`](backend/config.example.toml)。
|
||||
|
||||
| 配置项 | 说明 |
|
||||
| --- | --- |
|
||||
| `database_url` | SQLite `sqlite://nodeflare.db` 或 PostgreSQL 连接串 |
|
||||
| `database_url` | `sqlite://nodeflare.db` 或 PostgreSQL 连接串,如 `postgres://user:password@127.0.0.1:5432/nodeflare?sslmode=disable` |
|
||||
| `bind_addr` | 监听地址,默认 `127.0.0.1:2206` |
|
||||
| `trusted_proxies` | 可信反向代理 IP / CIDR 列表 |
|
||||
| `turnstile_site_key` / `turnstile_secret_key` | Turnstile 密钥,留空禁用 |
|
||||
| `admin_username` | 管理员用户名 |
|
||||
| `admin_password` | 管理员密码(8–128 字符),仅首次初始化数据库需要,成功后自动清空 |
|
||||
| `trusted_proxies` | 可信反向代理 IP / CIDR 列表;只有来自这些网段的 `X-Forwarded-For` 才会被信任 |
|
||||
| `turnstile_site_key` / `turnstile_secret_key` | Turnstile 密钥,留空则禁用 |
|
||||
| `session_ttl_hours` | 会话有效期(1–2160 小时),默认 168 |
|
||||
| `frontend_dir` / `admin_frontend_dir` | 前端静态资源目录,相对路径按配置文件所在目录解析 |
|
||||
| `theme_dir` | 主题解压目录,默认配置目录下的 `themes` |
|
||||
|
||||
管理员密码仅首次初始化数据库需要,之后自动清除。
|
||||
命令行可临时覆盖配置:`nodeflare --config <路径> --bind <地址> --database <URL>`。使用 `--database` 启动时,后台的在线迁移功能会被禁用。
|
||||
|
||||
## 默认目录
|
||||
## 默认目录与日志
|
||||
|
||||
| 平台 | 程序 | 配置和数据 |
|
||||
| ------- | ------------------------------ | ----------------------------------------------- |
|
||||
| Linux | `/opt/nodeflare` | `/etc/nodeflare` |
|
||||
| Windows | `%ProgramFiles%\NodeFlare` | `%ProgramData%\NodeFlare\Server` |
|
||||
| macOS | `/usr/local/libexec/nodeflare` | `/Library/Application Support/NodeFlare/Server` |
|
||||
| FreeBSD | `/usr/local/libexec/nodeflare` | `/var/db/nodeflare/server` |
|
||||
| 平台 | 程序 | 配置与数据 |
|
||||
| --- | --- | --- |
|
||||
| Linux | `/opt/nodeflare` | `/etc/nodeflare` |
|
||||
| Windows | `%ProgramFiles%\NodeFlare` | `%ProgramData%\NodeFlare\Server` |
|
||||
| macOS | `/usr/local/libexec/nodeflare` | `/Library/Application Support/NodeFlare/Server` |
|
||||
| FreeBSD | `/usr/local/libexec/nodeflare` | `/var/db/nodeflare/server` |
|
||||
|
||||
SQLite 文件位于配置目录下。
|
||||
Agent(Linux):程序 `/opt/nodeflare/agent`,配置与状态 `/etc/nodeflare/agent`。SQLite 文件位于配置目录下。
|
||||
|
||||
日志位置:
|
||||
|
||||
- Linux(systemd):`journalctl -u nodeflare -f`;Agent 为 `journalctl -u nodeflare-agent -f`
|
||||
- Linux(OpenRC):`rc-service nodeflare status`
|
||||
- macOS:`/var/log/nodeflare.log`
|
||||
- Windows:`Get-ScheduledTaskInfo -TaskName nodeflare`
|
||||
|
||||
## 反向代理
|
||||
|
||||
对外访问请使用 HTTPS 反向代理,并把代理地址加入 `trusted_proxies`,否则所有访客会被算作同一个 IP,且会话 Cookie 不会带上 `Secure`:
|
||||
|
||||
```toml
|
||||
trusted_proxies = ["127.0.0.1/32", "::1/128"]
|
||||
```
|
||||
|
||||
nginx:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name nodeflare.example.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/nodeflare.example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/nodeflare.example.com/privkey.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:2206;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade; # WebSocket 实时数据
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Caddy:
|
||||
|
||||
```
|
||||
nodeflare.example.com {
|
||||
reverse_proxy 127.0.0.1:2206
|
||||
}
|
||||
```
|
||||
|
||||
## 数据库与备份
|
||||
|
||||
后台“数据库”页面支持查看占用、回收空间、导出/恢复 ZIP 备份、SQLite ↔ PostgreSQL 迁移。备份包含设置、节点、历史、通知、主题、任务与安全配置,需 TOTP 或密码验证。迁移覆盖目标库并自动更新连接串,保留 Agent Token。备份上限 512 MiB / 解压 4 GiB。历史数据默认保留 30 天。
|
||||
后台「数据库」页面支持查看占用、回收空间、导出 / 恢复 ZIP 备份、SQLite ↔ PostgreSQL 迁移。备份包含设置、节点、历史、通知、主题、任务与安全配置,需要 TOTP 或密码验证。限制为 ZIP 512 MiB、解压后 4 GiB、最多 16384 个条目,单个主题文件 32 MiB。迁移会覆盖目标库并自动更新连接串,保留 Agent Token,重启服务后生效。历史数据默认保留 30 天。
|
||||
|
||||
## 开发
|
||||
|
||||
@@ -108,7 +232,20 @@ cp backend/config.example.toml backend/config.toml
|
||||
./dev.sh
|
||||
```
|
||||
|
||||
测试构建:
|
||||
`dev.sh` 会构建前端并以 `cargo run` 启动后端。`start.sh` 则构建 release 版本,优先使用 `/etc/nodeflare/config.toml`,也可用 `NODEFLARE_CONFIG` 指定。
|
||||
|
||||
仓库结构:
|
||||
|
||||
| 目录 | 内容 |
|
||||
| --- | --- |
|
||||
| `backend/` | 服务端(Rust / Axum / SQLx):`src/routes` 接口、`src/db` 数据层、`src/websocket` 实时通道、`migrations/` 建表脚本 |
|
||||
| `agent/` | Agent 源码(采集 / 上报 / 远程执行 / 自更新)与各平台安装脚本 |
|
||||
| `shared/` | Agent 与服务端共用的遥测协议(序列化 + 压缩) |
|
||||
| `frontend/` | 前端(React + Vite):`src/components` 组件、`src/styles` 样式 |
|
||||
| `scripts/` | 构建、版本解析与冒烟测试脚本 |
|
||||
| `docs/` | 英文 README 与 systemd 服务单元 |
|
||||
|
||||
测试与构建:
|
||||
|
||||
```bash
|
||||
bun test --cwd frontend
|
||||
@@ -117,6 +254,20 @@ cargo test --locked --manifest-path agent/Cargo.toml
|
||||
bun run build
|
||||
```
|
||||
|
||||
冒烟测试(需先启动面板):
|
||||
|
||||
```bash
|
||||
MONITOR_ADMIN_USERNAME=admin MONITOR_ADMIN_PASSWORD='你的密码' bun run test:smoke
|
||||
```
|
||||
|
||||
## 排障
|
||||
|
||||
- **服务启动失败**:先看日志(见[默认目录与日志](#默认目录与日志)),多为端口被占用或数据库连接串有误。
|
||||
- **不确定监听端口**:查看配置文件中的 `bind_addr`,默认 `127.0.0.1:2206`。
|
||||
- **Agent 显示离线**:确认 Agent 能出站访问面板地址(`curl -I https://你的面板地址`),并核对 Token;上报与延迟依赖时钟校准,系统时间偏差过大会有影响。
|
||||
- **公开看板看不到节点**:确认节点未启用「隐藏」,且站点设置中已开启「公开仪表盘」。
|
||||
- **导出备份报超限**:先缩短历史保留天数或清理历史数据,再重新导出。
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
Regular → Executable
+17
-2
@@ -8,6 +8,9 @@ STATE_ROOT="/etc/nodeflare"
|
||||
STATE_DIR="$STATE_ROOT/agent"
|
||||
SERVICE_FILE="/etc/systemd/system/$SERVICE_NAME.service"
|
||||
OPENRC_FILE="/etc/init.d/$SERVICE_NAME"
|
||||
# Holds NODEFLARE_AGENT_TOKEN for systemd; a 0600 file inside the 0750 state dir
|
||||
# is readable only by root/uninstall, unlike Environment= in the unit.
|
||||
TOKEN_ENV_FILE="$STATE_DIR/agent.env"
|
||||
temporary=""
|
||||
backup_agent=""
|
||||
backup_service=""
|
||||
@@ -347,6 +350,10 @@ install_agent() {
|
||||
mv "$temporary" "$AGENT_FILE"
|
||||
log "正在启动 $init_system 服务"
|
||||
if [ "$init_system" = "systemd" ]; then
|
||||
# The token goes into a 0600 file rather than Environment=, which any local
|
||||
# user can read back with `systemctl show`.
|
||||
printf 'NODEFLARE_AGENT_TOKEN=%s\n' "$token" > "$TOKEN_ENV_FILE"
|
||||
chmod 600 "$TOKEN_ENV_FILE"
|
||||
printf '%s\n' \
|
||||
'[Unit]' \
|
||||
'Description=NodeFlare Agent' \
|
||||
@@ -359,7 +366,7 @@ install_agent() {
|
||||
'Restart=always' \
|
||||
'RestartSec=10' \
|
||||
'NoNewPrivileges=true' \
|
||||
"Environment=NODEFLARE_AGENT_TOKEN=$token" \
|
||||
"EnvironmentFile=$TOKEN_ENV_FILE" \
|
||||
"Environment=NODEFLARE_STATE_DIR=$STATE_DIR" \
|
||||
'' \
|
||||
'[Install]' \
|
||||
@@ -404,7 +411,15 @@ load_installed_agent_config() {
|
||||
service_exec=$(sed -n 's/^ExecStart=//p' "$SERVICE_FILE" | head -n 1)
|
||||
endpoint=$(printf '%s\n' "$service_exec" | sed -n 's/.* -e \([^[:space:]]*\) -i \([0-9][0-9]*\)$/\1/p')
|
||||
interval=$(printf '%s\n' "$service_exec" | sed -n 's/.* -e \([^[:space:]]*\) -i \([0-9][0-9]*\)$/\2/p')
|
||||
token=$(sed -n 's/^Environment=NODEFLARE_AGENT_TOKEN=//p' "$SERVICE_FILE" | head -n 1)
|
||||
token=""
|
||||
if [ -f "$TOKEN_ENV_FILE" ]; then
|
||||
token=$(sed -n 's/^NODEFLARE_AGENT_TOKEN=//p' "$TOKEN_ENV_FILE" | head -n 1)
|
||||
fi
|
||||
# Fall back to the unit file so installs created before the token moved
|
||||
# into an EnvironmentFile still update cleanly.
|
||||
if [ -z "$token" ]; then
|
||||
token=$(sed -n 's/^Environment=NODEFLARE_AGENT_TOKEN=//p' "$SERVICE_FILE" | head -n 1)
|
||||
fi
|
||||
;;
|
||||
openrc)
|
||||
[ -f "$OPENRC_FILE" ] || fail "未找到 Agent 服务配置,请先完成安装"
|
||||
|
||||
Regular → Executable
+1
-1
@@ -1,4 +1,4 @@
|
||||
<#
|
||||
<#
|
||||
.EXAMPLE
|
||||
.\install.ps1 -Update
|
||||
Update using the saved endpoint, token and history interval.
|
||||
|
||||
Executable
+698
@@ -0,0 +1,698 @@
|
||||
use super::*;
|
||||
|
||||
pub(crate) struct LiveSender {
|
||||
pub(crate) pending: Arc<(Mutex<VecDeque<Report>>, Condvar)>,
|
||||
pub(crate) send_interval: Arc<Mutex<Duration>>,
|
||||
pub(crate) persisted_through: Arc<AtomicI64>,
|
||||
pub(crate) remote_config: Arc<Mutex<Option<RemoteConfig>>>,
|
||||
pub(crate) stats: Arc<runtime_stats::RuntimeStats>,
|
||||
}
|
||||
|
||||
pub(crate) struct LiveSenderWorker {
|
||||
pub(crate) pending: Arc<(Mutex<VecDeque<Report>>, Condvar)>,
|
||||
pub(crate) configured_interval: Arc<Mutex<Duration>>,
|
||||
pub(crate) persisted_through: Arc<AtomicI64>,
|
||||
pub(crate) remote_config: Arc<Mutex<Option<RemoteConfig>>>,
|
||||
pub(crate) clock: SharedClock,
|
||||
pub(crate) stats: Arc<runtime_stats::RuntimeStats>,
|
||||
}
|
||||
|
||||
impl LiveSender {
|
||||
pub(crate) fn start(
|
||||
config: &RuntimeConfig,
|
||||
clock: SharedClock,
|
||||
stats: Arc<runtime_stats::RuntimeStats>,
|
||||
) -> Result<Self> {
|
||||
let pending = Arc::new((Mutex::new(VecDeque::new()), Condvar::new()));
|
||||
let send_interval = Arc::new(Mutex::new(live_batch_interval(config.collect_interval)));
|
||||
let persisted_through = Arc::new(AtomicI64::new(0));
|
||||
let remote_config = Arc::new(Mutex::new(None));
|
||||
let endpoint = live_endpoint(&config.endpoint)?;
|
||||
let token = config.token.clone();
|
||||
let sender_state = Arc::clone(&pending);
|
||||
let sender_interval = Arc::clone(&send_interval);
|
||||
let sender_persisted_through = Arc::clone(&persisted_through);
|
||||
let sender_remote_config = Arc::clone(&remote_config);
|
||||
let sender_stats = Arc::clone(&stats);
|
||||
thread::Builder::new()
|
||||
.name("nodeflare-live".to_string())
|
||||
.spawn(move || {
|
||||
live_sender_loop(
|
||||
&endpoint,
|
||||
&token,
|
||||
LiveSenderWorker {
|
||||
pending: sender_state,
|
||||
configured_interval: sender_interval,
|
||||
persisted_through: sender_persisted_through,
|
||||
remote_config: sender_remote_config,
|
||||
clock,
|
||||
stats: sender_stats,
|
||||
},
|
||||
)
|
||||
})?;
|
||||
Ok(Self {
|
||||
pending,
|
||||
send_interval,
|
||||
persisted_through,
|
||||
remote_config,
|
||||
stats,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn send(&self, report: &Report) {
|
||||
let (pending, ready) = &*self.pending;
|
||||
if let Ok(mut pending) = pending.lock() {
|
||||
if pending.len() >= LIVE_QUEUE_CAPACITY {
|
||||
pending.pop_front();
|
||||
self.stats.live_queue_dropped(1);
|
||||
}
|
||||
pending.push_back(report.clone());
|
||||
ready.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_send_interval(&self, collect_interval: u64) {
|
||||
if let Ok(mut interval) = self.send_interval.lock() {
|
||||
*interval = live_batch_interval(collect_interval);
|
||||
}
|
||||
self.pending.1.notify_one();
|
||||
}
|
||||
|
||||
pub(crate) fn persisted_through(&self) -> i64 {
|
||||
self.persisted_through.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
pub(crate) fn take_remote_config(&self) -> Option<RemoteConfig> {
|
||||
self.remote_config
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|mut config| config.take())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn live_batch_interval(collect_interval: u64) -> Duration {
|
||||
Duration::from_secs(collect_interval.clamp(telemetry::MIN_UPLOAD_INTERVAL, 60))
|
||||
}
|
||||
|
||||
pub(crate) fn live_endpoint(endpoint: &str) -> Result<String> {
|
||||
let mut url = url::Url::parse(endpoint)?;
|
||||
url.set_scheme(if url.scheme() == "https" { "wss" } else { "ws" })
|
||||
.map_err(|_| "unsupported live endpoint scheme")?;
|
||||
let path = format!("{}/api/agent/ws", url.path().trim_end_matches('/'));
|
||||
url.set_path(&path);
|
||||
Ok(url.to_string())
|
||||
}
|
||||
|
||||
pub(crate) type LiveSocket = tungstenite::WebSocket<tungstenite::stream::MaybeTlsStream<TcpStream>>;
|
||||
|
||||
pub(crate) fn connect_live(endpoint: &str, token: &str) -> Result<(LiveSocket, Option<i64>)> {
|
||||
let started_ms = unix_timestamp_millis();
|
||||
let deadline = Instant::now() + LIVE_CONNECT_TIMEOUT;
|
||||
let mut url = url::Url::parse(endpoint)?;
|
||||
let mut redirects = 0;
|
||||
let (socket, response) = loop {
|
||||
let mut request = url.as_str().into_client_request()?;
|
||||
request
|
||||
.headers_mut()
|
||||
.insert("Authorization", format!("Bearer {token}").parse()?);
|
||||
request
|
||||
.headers_mut()
|
||||
.insert("User-Agent", format!("nodeflare-agent/{VERSION}").parse()?);
|
||||
request
|
||||
.headers_mut()
|
||||
.insert(AGENT_PROTOCOL_HEADER, AGENT_PROTOCOL_VERSION.parse()?);
|
||||
request.headers_mut().insert(
|
||||
AGENT_CAPABILITIES_HEADER,
|
||||
REQUIRED_AGENT_CAPABILITIES.join(",").parse()?,
|
||||
);
|
||||
let stream = connect_live_stream(&url, deadline)?;
|
||||
match client_tls(request, stream) {
|
||||
Ok(connected) => break connected,
|
||||
Err(tungstenite::HandshakeError::Failure(WebSocketError::Http(response)))
|
||||
if response.status().is_redirection() && redirects < 3 =>
|
||||
{
|
||||
let location = response
|
||||
.headers()
|
||||
.get("location")
|
||||
.ok_or("WebSocket redirect has no location")?
|
||||
.to_str()?;
|
||||
let redirected = url.join(location)?;
|
||||
// Never forward the Agent token to a different origin.
|
||||
if redirected.origin() != url.origin() {
|
||||
return Err("WebSocket redirect must stay on the configured origin".into());
|
||||
}
|
||||
url = redirected;
|
||||
redirects += 1;
|
||||
}
|
||||
Err(error) => return Err(format!("WebSocket handshake failed: {error}").into()),
|
||||
}
|
||||
};
|
||||
let ended_ms = unix_timestamp_millis();
|
||||
let clock_offset_ms = response
|
||||
.headers()
|
||||
.get("date")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| clock_offset_from_http_date(value, started_ms, ended_ms));
|
||||
if !response
|
||||
.headers()
|
||||
.get(AGENT_PROTOCOL_HEADER)
|
||||
.is_some_and(|value| value == AGENT_PROTOCOL_VERSION)
|
||||
{
|
||||
return Err(format!(
|
||||
"backend protocol mismatch; update the backend to protocol {AGENT_PROTOCOL_VERSION}"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
Ok((socket, clock_offset_ms))
|
||||
}
|
||||
|
||||
pub(crate) fn connect_live_stream(url: &url::Url, deadline: Instant) -> Result<TcpStream> {
|
||||
let host = url
|
||||
.host_str()
|
||||
.ok_or("WebSocket endpoint has no hostname")?
|
||||
.trim_start_matches('[')
|
||||
.trim_end_matches(']');
|
||||
let port = url
|
||||
.port_or_known_default()
|
||||
.ok_or("WebSocket endpoint has no port")?;
|
||||
let addresses = (host, port).to_socket_addrs()?;
|
||||
let mut connected = None;
|
||||
let mut last_error = io::Error::new(
|
||||
ErrorKind::NotFound,
|
||||
"WebSocket hostname resolved to no addresses",
|
||||
);
|
||||
for address in addresses {
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
if remaining.is_zero() {
|
||||
return Err(
|
||||
io::Error::new(ErrorKind::TimedOut, "WebSocket connection timed out").into(),
|
||||
);
|
||||
}
|
||||
match TcpStream::connect_timeout(&address, remaining.min(Duration::from_secs(3))) {
|
||||
Ok(stream) => {
|
||||
connected = Some(stream);
|
||||
break;
|
||||
}
|
||||
Err(error) => last_error = error,
|
||||
}
|
||||
}
|
||||
let stream = connected.ok_or(last_error)?;
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
if remaining.is_zero() {
|
||||
return Err(io::Error::new(ErrorKind::TimedOut, "WebSocket connection timed out").into());
|
||||
}
|
||||
stream.set_nodelay(true)?;
|
||||
stream.set_read_timeout(Some(remaining))?;
|
||||
stream.set_write_timeout(Some(remaining))?;
|
||||
Ok(stream)
|
||||
}
|
||||
|
||||
pub(crate) fn set_live_read_timeout(socket: &mut LiveSocket, timeout: Option<Duration>) -> io::Result<()> {
|
||||
match socket.get_mut() {
|
||||
tungstenite::stream::MaybeTlsStream::Plain(stream) => stream.set_read_timeout(timeout),
|
||||
tungstenite::stream::MaybeTlsStream::Rustls(stream) => {
|
||||
stream.sock.set_read_timeout(timeout)
|
||||
}
|
||||
// An unmatched TLS backend keeps running without a read timeout rather
|
||||
// than failing outright; the rustls-only feature set always matches an
|
||||
// arm above, so this is defensive.
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct LiveAck {
|
||||
#[serde(rename = "type")]
|
||||
pub(crate) message_type: String,
|
||||
pub(crate) ts: i64,
|
||||
pub(crate) persisted: bool,
|
||||
#[serde(rename = "persistenceError")]
|
||||
pub(crate) persistence_error: bool,
|
||||
#[serde(rename = "persistedThroughTs")]
|
||||
pub(crate) persisted_through_ts: i64,
|
||||
#[serde(rename = "nextPersistAfterMs")]
|
||||
pub(crate) next_persist_after_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct LiveConfigMessage {
|
||||
#[serde(rename = "type")]
|
||||
pub(crate) message_type: String,
|
||||
pub(crate) ts: i64,
|
||||
pub(crate) config: RemoteConfig,
|
||||
}
|
||||
|
||||
pub(crate) fn ack_persist_interval(ack: &LiveAck) -> Duration {
|
||||
Duration::from_millis(ack.next_persist_after_ms)
|
||||
.clamp(Duration::from_secs(1), Duration::from_secs(3600))
|
||||
}
|
||||
|
||||
pub(crate) enum LiveRead {
|
||||
Ack(LiveAck),
|
||||
Config(RemoteConfig),
|
||||
RemoteTask(RemoteTaskMessage),
|
||||
TaskResultAck(String),
|
||||
Closed,
|
||||
Pending,
|
||||
}
|
||||
|
||||
pub(crate) fn read_live_ack(socket: &mut LiveSocket) -> Result<LiveRead> {
|
||||
match socket.read() {
|
||||
Ok(Message::Text(text)) => {
|
||||
let Ok(value) = serde_json::from_str::<serde_json::Value>(text.as_ref()) else {
|
||||
return Ok(LiveRead::Pending);
|
||||
};
|
||||
match value.get("type").and_then(serde_json::Value::as_str) {
|
||||
Some("ack") => {
|
||||
let Ok(ack) = serde_json::from_value::<LiveAck>(value) else {
|
||||
return Ok(LiveRead::Pending);
|
||||
};
|
||||
if ack.message_type != "ack" || ack.ts <= 0 {
|
||||
return Ok(LiveRead::Pending);
|
||||
}
|
||||
Ok(LiveRead::Ack(ack))
|
||||
}
|
||||
Some("config") => {
|
||||
let Ok(message) = serde_json::from_value::<LiveConfigMessage>(value) else {
|
||||
return Ok(LiveRead::Pending);
|
||||
};
|
||||
if message.message_type != "config" || message.ts <= 0 {
|
||||
return Ok(LiveRead::Pending);
|
||||
}
|
||||
Ok(LiveRead::Config(message.config))
|
||||
}
|
||||
Some("remote_task") => {
|
||||
let Ok(task) = serde_json::from_value::<RemoteTaskMessage>(value) else {
|
||||
return Ok(LiveRead::Pending);
|
||||
};
|
||||
if task.message_type != "remote_task"
|
||||
|| !valid_remote_task_id(&task.task_id)
|
||||
|| task.command.trim().is_empty()
|
||||
|| task.command.len() > 16_384
|
||||
{
|
||||
return Ok(LiveRead::Pending);
|
||||
}
|
||||
Ok(LiveRead::RemoteTask(task))
|
||||
}
|
||||
Some("task_result_ack") => {
|
||||
let Ok(ack) = serde_json::from_value::<TaskResultAckMessage>(value) else {
|
||||
return Ok(LiveRead::Pending);
|
||||
};
|
||||
if ack.message_type != "task_result_ack" || !valid_remote_task_id(&ack.task_id)
|
||||
{
|
||||
return Ok(LiveRead::Pending);
|
||||
}
|
||||
Ok(LiveRead::TaskResultAck(ack.task_id))
|
||||
}
|
||||
_ => Ok(LiveRead::Pending),
|
||||
}
|
||||
}
|
||||
Ok(Message::Ping(payload)) => {
|
||||
socket.send(Message::Pong(payload))?;
|
||||
Ok(LiveRead::Pending)
|
||||
}
|
||||
Ok(Message::Close(_)) => Ok(LiveRead::Closed),
|
||||
Ok(_) => Ok(LiveRead::Pending),
|
||||
Err(WebSocketError::Io(error))
|
||||
if matches!(error.kind(), ErrorKind::TimedOut | ErrorKind::WouldBlock) =>
|
||||
{
|
||||
Ok(LiveRead::Pending)
|
||||
}
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn accept_remote_task(
|
||||
pub(crate) socket: &mut LiveSocket,
|
||||
pub(crate) executor: &mut RemoteExecutor,
|
||||
pub(crate) task: RemoteTaskMessage,
|
||||
) -> Result<()> {
|
||||
let task_id = task.task_id.clone();
|
||||
let rejected = executor.enqueue(task);
|
||||
socket.send(Message::Text(
|
||||
serde_json::json!({
|
||||
"type": "task_received",
|
||||
"task_id": task_id,
|
||||
})
|
||||
.to_string()
|
||||
.into(),
|
||||
))?;
|
||||
if let Some(result) = rejected {
|
||||
socket.send(Message::Text(serde_json::to_string(&result)?.into()))?;
|
||||
}
|
||||
flush_remote_results(socket, executor)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn wait_for_live_ack(
|
||||
pub(crate) socket: &mut LiveSocket,
|
||||
pub(crate) remote_config: &Arc<Mutex<Option<RemoteConfig>>>,
|
||||
pub(crate) remote_executor: &mut RemoteExecutor,
|
||||
) -> Result<LiveRead> {
|
||||
let deadline = Instant::now() + LIVE_ACK_READ_TIMEOUT;
|
||||
loop {
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
if remaining.is_zero() {
|
||||
return Ok(LiveRead::Pending);
|
||||
}
|
||||
set_live_read_timeout(socket, Some(remaining))?;
|
||||
match read_live_ack(socket)? {
|
||||
LiveRead::Ack(ack) => {
|
||||
return Ok(LiveRead::Ack(ack));
|
||||
}
|
||||
LiveRead::Config(config) => {
|
||||
if let Ok(mut target) = remote_config.lock() {
|
||||
*target = Some(config);
|
||||
}
|
||||
}
|
||||
LiveRead::RemoteTask(task) => {
|
||||
accept_remote_task(socket, remote_executor, task)?;
|
||||
}
|
||||
LiveRead::TaskResultAck(task_id) => {
|
||||
remote_executor.acknowledge(&task_id);
|
||||
}
|
||||
LiveRead::Closed => return Ok(LiveRead::Closed),
|
||||
LiveRead::Pending => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn live_update_payload(
|
||||
pub(crate) reports: Vec<Report>,
|
||||
pub(crate) persist: bool,
|
||||
pub(crate) info: &mut Option<telemetry::Info>,
|
||||
) -> Result<Vec<u8>> {
|
||||
let mut next_info = info.clone();
|
||||
let payload = telemetry::encode(&telemetry::Update::from_reports(
|
||||
reports,
|
||||
persist,
|
||||
&mut next_info,
|
||||
))?;
|
||||
*info = next_info;
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
pub(crate) fn observe_persisted_through(target: &AtomicI64, ack: &LiveAck) {
|
||||
if ack.persisted && !ack.persistence_error {
|
||||
target.fetch_max(ack.persisted_through_ts.max(0), Ordering::AcqRel);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn prune_live_queue(pending: &Arc<(Mutex<VecDeque<Report>>, Condvar)>, persisted_through: i64) {
|
||||
if persisted_through <= 0 {
|
||||
return;
|
||||
}
|
||||
if let Ok(mut queue) = pending.0.lock() {
|
||||
queue.retain(|report| report.timestamp > persisted_through);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn live_batch_after(queue: &VecDeque<Report>, timestamp: i64) -> Vec<Report> {
|
||||
live_batch::batch_from(queue.iter().filter(|report| report.timestamp > timestamp))
|
||||
}
|
||||
|
||||
/// Bounded exponential backoff for the live connection, with jitter derived
|
||||
/// from the token so a fleet of agents that dropped together does not retry in
|
||||
/// lockstep and hammer an unreachable server.
|
||||
pub(crate) fn reconnect_delay(attempt: u32, token: &str) -> Duration {
|
||||
let base_ms = LIVE_RECONNECT_DELAY.as_millis() as u64;
|
||||
let backoff_ms = base_ms
|
||||
.saturating_mul(1_u64 << attempt.min(5))
|
||||
.min(LIVE_RECONNECT_MAX_DELAY.as_millis() as u64);
|
||||
let digest = Sha256::digest(token.as_bytes());
|
||||
let seed = u64::from(digest[0]) ^ (u64::from(attempt) << 8);
|
||||
let jitter_ms = seed % (backoff_ms / 4 + 1);
|
||||
Duration::from_millis(backoff_ms + jitter_ms)
|
||||
}
|
||||
|
||||
pub(crate) struct ReconnectBackoff {
|
||||
pub(crate) attempt: u32,
|
||||
pub(crate) token: String,
|
||||
}
|
||||
|
||||
impl ReconnectBackoff {
|
||||
pub(crate) fn new(token: &str) -> Self {
|
||||
Self {
|
||||
attempt: 0,
|
||||
token: token.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset after a confirmed round trip, not merely after a TCP connect, so a
|
||||
/// server that accepts then immediately drops connections cannot keep us at
|
||||
/// the shortest delay.
|
||||
pub(crate) fn reset(&mut self) {
|
||||
self.attempt = 0;
|
||||
}
|
||||
|
||||
pub(crate) fn next_delay(&mut self) -> Duration {
|
||||
self.attempt = self.attempt.saturating_add(1);
|
||||
reconnect_delay(self.attempt, &self.token)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn live_sender_loop(endpoint: &str, token: &str, worker: LiveSenderWorker) {
|
||||
let LiveSenderWorker {
|
||||
pending,
|
||||
configured_interval,
|
||||
persisted_through,
|
||||
remote_config,
|
||||
clock,
|
||||
stats,
|
||||
} = worker;
|
||||
let mut socket: Option<LiveSocket> = None;
|
||||
let mut backoff = ReconnectBackoff::new(token);
|
||||
let mut wss_interval = configured_interval.lock().map_or(
|
||||
Duration::from_secs(telemetry::MIN_UPLOAD_INTERVAL),
|
||||
|interval| *interval,
|
||||
);
|
||||
let mut info = None;
|
||||
let mut accepted_through = persisted_through.load(Ordering::Acquire);
|
||||
let mut next_send_at = Instant::now();
|
||||
let mut next_probe_at = Instant::now();
|
||||
let mut remote_executor = match RemoteExecutor::new() {
|
||||
Ok(executor) => executor,
|
||||
Err(error) => {
|
||||
eprintln!("remote executor disabled: {error}");
|
||||
RemoteExecutor::disabled()
|
||||
}
|
||||
};
|
||||
'sender: loop {
|
||||
if socket.is_none() {
|
||||
match connect_live(endpoint, token) {
|
||||
Ok((mut connected, offset_ms)) => {
|
||||
observe_clock(&clock, offset_ms);
|
||||
if set_live_read_timeout(&mut connected, Some(LIVE_HINT_READ_TIMEOUT)).is_err()
|
||||
{
|
||||
thread::sleep(backoff.next_delay());
|
||||
continue;
|
||||
}
|
||||
socket = Some(connected);
|
||||
info = None;
|
||||
remote_executor.reset_delivery();
|
||||
accepted_through = persisted_through.load(Ordering::Acquire);
|
||||
next_send_at = Instant::now();
|
||||
next_probe_at = Instant::now();
|
||||
}
|
||||
Err(error) => {
|
||||
stats.live_connect_failed();
|
||||
eprintln!("live connection failed: {error}");
|
||||
thread::sleep(backoff.next_delay());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(connected) = socket.as_mut()
|
||||
&& flush_remote_results(connected, &mut remote_executor).is_err()
|
||||
{
|
||||
socket = None;
|
||||
thread::sleep(backoff.next_delay());
|
||||
continue;
|
||||
}
|
||||
|
||||
let (queue_lock, ready) = &*pending;
|
||||
let Ok(mut queue) = queue_lock.lock() else {
|
||||
return;
|
||||
};
|
||||
let (batch, persist, more_pending) = loop {
|
||||
let now = Instant::now();
|
||||
let persisted = persisted_through.load(Ordering::Acquire);
|
||||
let has_unsent = queue.iter().any(|report| report.timestamp > accepted_through);
|
||||
let persist =
|
||||
now >= next_probe_at && queue.iter().any(|report| report.timestamp > persisted);
|
||||
if persist || (has_unsent && now >= next_send_at) {
|
||||
// Build (and serialize) the batch only once it is actually due;
|
||||
// the wait below re-runs several times per second while idle.
|
||||
let unsent = live_batch_after(&queue, accepted_through);
|
||||
let last = unsent
|
||||
.last()
|
||||
.map_or(accepted_through, |report| report.timestamp);
|
||||
let more_pending = queue.iter().any(|report| report.timestamp > last);
|
||||
// A commit can be empty: samples already sent remain buffered by the server.
|
||||
break (unsent, persist || more_pending, more_pending);
|
||||
}
|
||||
|
||||
let wake_at = if has_unsent {
|
||||
next_send_at.min(next_probe_at)
|
||||
} else if queue.iter().any(|report| report.timestamp > persisted) {
|
||||
next_probe_at
|
||||
} else {
|
||||
now + Duration::from_millis(250)
|
||||
};
|
||||
let wait = wake_at
|
||||
.saturating_duration_since(now)
|
||||
.min(Duration::from_millis(250));
|
||||
queue = match ready.wait_timeout(queue, wait) {
|
||||
Ok((queue, _)) => queue,
|
||||
Err(_) => return,
|
||||
};
|
||||
drop(queue);
|
||||
|
||||
let mut drop_socket = false;
|
||||
if let Some(connected) = socket.as_mut() {
|
||||
if flush_remote_results(connected, &mut remote_executor).is_err()
|
||||
|| set_live_read_timeout(connected, Some(LIVE_HINT_READ_TIMEOUT)).is_err()
|
||||
{
|
||||
drop_socket = true;
|
||||
} else {
|
||||
match read_live_ack(connected) {
|
||||
Ok(LiveRead::Closed) => drop_socket = true,
|
||||
Ok(LiveRead::Ack(ack)) => {
|
||||
observe_persisted_through(&persisted_through, &ack);
|
||||
if ack.persistence_error {
|
||||
stats.live_persistence_failed();
|
||||
drop_socket = true;
|
||||
} else {
|
||||
prune_live_queue(&pending, ack.persisted_through_ts);
|
||||
accepted_through = accepted_through.max(ack.persisted_through_ts);
|
||||
next_probe_at = Instant::now() + ack_persist_interval(&ack);
|
||||
}
|
||||
}
|
||||
Ok(LiveRead::Config(config)) => {
|
||||
if let Ok(mut target) = remote_config.lock() {
|
||||
*target = Some(config);
|
||||
}
|
||||
}
|
||||
Ok(LiveRead::RemoteTask(task)) => {
|
||||
if accept_remote_task(connected, &mut remote_executor, task).is_err() {
|
||||
drop_socket = true;
|
||||
}
|
||||
}
|
||||
Ok(LiveRead::TaskResultAck(task_id)) => {
|
||||
remote_executor.acknowledge(&task_id);
|
||||
}
|
||||
Ok(LiveRead::Pending) => {}
|
||||
Err(error) => {
|
||||
eprintln!("live hint read failed: {error}");
|
||||
drop_socket = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if drop_socket {
|
||||
socket = None;
|
||||
accepted_through = persisted_through.load(Ordering::Acquire);
|
||||
next_send_at = Instant::now();
|
||||
next_probe_at = Instant::now();
|
||||
thread::sleep(backoff.next_delay());
|
||||
continue 'sender;
|
||||
}
|
||||
queue = match queue_lock.lock() {
|
||||
Ok(queue) => queue,
|
||||
Err(_) => return,
|
||||
};
|
||||
};
|
||||
drop(queue);
|
||||
|
||||
if let Ok(interval) = configured_interval.lock() {
|
||||
wss_interval = (*interval).clamp(
|
||||
Duration::from_secs(telemetry::MIN_UPLOAD_INTERVAL),
|
||||
Duration::from_secs(60),
|
||||
);
|
||||
}
|
||||
|
||||
let batch_last = batch.last().map(|report| report.timestamp);
|
||||
let batch_len = batch.len();
|
||||
let payload = match live_update_payload(batch, persist, &mut info) {
|
||||
Ok(payload) => payload,
|
||||
Err(error) => {
|
||||
eprintln!("live payload encode failed: {error}");
|
||||
next_send_at = Instant::now() + wss_interval;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let sent = socket
|
||||
.as_mut()
|
||||
.is_some_and(|socket| socket.send(Message::Binary(payload.into())).is_ok());
|
||||
if !sent {
|
||||
socket = None;
|
||||
accepted_through = persisted_through.load(Ordering::Acquire);
|
||||
next_send_at = Instant::now();
|
||||
next_probe_at = Instant::now();
|
||||
thread::sleep(backoff.next_delay());
|
||||
continue;
|
||||
}
|
||||
stats.live_batch_sent(batch_len);
|
||||
backoff.reset();
|
||||
if let Some(timestamp) = batch_last {
|
||||
accepted_through = accepted_through.max(timestamp);
|
||||
}
|
||||
next_send_at = Instant::now() + wss_interval;
|
||||
if !persist {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut drop_socket = false;
|
||||
if let Some(socket) = socket.as_mut() {
|
||||
match wait_for_live_ack(socket, &remote_config, &mut remote_executor) {
|
||||
Ok(LiveRead::Closed) => drop_socket = true,
|
||||
Ok(LiveRead::Ack(ack)) => {
|
||||
observe_persisted_through(&persisted_through, &ack);
|
||||
if ack.persistence_error {
|
||||
stats.live_persistence_failed();
|
||||
drop_socket = true;
|
||||
} else {
|
||||
prune_live_queue(&pending, ack.persisted_through_ts);
|
||||
accepted_through = accepted_through.max(ack.persisted_through_ts);
|
||||
next_probe_at = Instant::now() + ack_persist_interval(&ack);
|
||||
if more_pending
|
||||
&& batch_last
|
||||
.is_some_and(|timestamp| ack.persisted_through_ts >= timestamp)
|
||||
{
|
||||
next_probe_at = Instant::now();
|
||||
}
|
||||
}
|
||||
}
|
||||
// wait_for_live_ack consumes config and remote-task messages
|
||||
// internally, so only Ack/Closed/Pending reach this match; these
|
||||
// arms are kept defensively in case that contract changes.
|
||||
Ok(LiveRead::Config(config)) => {
|
||||
if let Ok(mut target) = remote_config.lock() {
|
||||
*target = Some(config);
|
||||
}
|
||||
drop_socket = true;
|
||||
}
|
||||
Ok(LiveRead::RemoteTask(_) | LiveRead::TaskResultAck(_)) => {}
|
||||
Ok(LiveRead::Pending) => {
|
||||
eprintln!("live ACK timed out");
|
||||
drop_socket = true;
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!("live ACK read failed: {error}");
|
||||
drop_socket = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if drop_socket {
|
||||
socket = None;
|
||||
accepted_through = persisted_through.load(Ordering::Acquire);
|
||||
next_send_at = Instant::now();
|
||||
next_probe_at = Instant::now();
|
||||
thread::sleep(backoff.next_delay());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Regular → Executable
+13
-9
@@ -1,13 +1,17 @@
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use super::Report;
|
||||
|
||||
pub(crate) const MAX_LIVE_BATCH_BYTES: usize = nodeflare_telemetry::MAX_BATCH_BYTES;
|
||||
|
||||
pub(crate) fn batch_len(queue: &VecDeque<Report>) -> usize {
|
||||
/// Collects the longest prefix of `queue` that fits one live batch, cloning only
|
||||
/// the reports that are actually selected and serializing each candidate once
|
||||
/// (the size check needs the encoded length).
|
||||
pub(crate) fn batch_from<'a>(queue: impl Iterator<Item = &'a Report>) -> Vec<Report> {
|
||||
let mut encoded_bytes = 192_usize;
|
||||
let mut count = 0_usize;
|
||||
let mut batch = Vec::new();
|
||||
for report in queue {
|
||||
if batch.len() >= super::LIVE_QUEUE_CAPACITY {
|
||||
break;
|
||||
}
|
||||
let Ok(report_bytes) = serde_json::to_vec(report) else {
|
||||
break;
|
||||
};
|
||||
@@ -18,19 +22,19 @@ pub(crate) fn batch_len(queue: &VecDeque<Report>) -> usize {
|
||||
break;
|
||||
}
|
||||
encoded_bytes = next_bytes;
|
||||
count += 1;
|
||||
batch.push(report.clone());
|
||||
if encoded_bytes >= MAX_LIVE_BATCH_BYTES {
|
||||
break;
|
||||
}
|
||||
}
|
||||
count.min(super::LIVE_QUEUE_CAPACITY)
|
||||
batch
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use super::{MAX_LIVE_BATCH_BYTES, batch_len};
|
||||
use super::{MAX_LIVE_BATCH_BYTES, batch_from};
|
||||
use crate::Report;
|
||||
|
||||
#[test]
|
||||
@@ -40,7 +44,7 @@ mod tests {
|
||||
..Report::default()
|
||||
};
|
||||
let queue = std::iter::repeat_n(report, 10).collect::<VecDeque<_>>();
|
||||
let count = batch_len(&queue);
|
||||
let count = batch_from(queue.iter()).len();
|
||||
assert!((1..10).contains(&count));
|
||||
let payload = crate::live_update_payload(
|
||||
queue.iter().take(count).cloned().collect::<Vec<_>>(),
|
||||
@@ -92,7 +96,7 @@ mod tests {
|
||||
..Report::default()
|
||||
})
|
||||
.collect::<VecDeque<_>>();
|
||||
assert!(batch_len(&queue) <= crate::LIVE_QUEUE_CAPACITY);
|
||||
assert!(batch_from(queue.iter()).len() <= crate::LIVE_QUEUE_CAPACITY);
|
||||
let reports = crate::live_batch_after(&queue, 100);
|
||||
assert_eq!(reports.first().unwrap().timestamp, 101);
|
||||
for persist in [true, false] {
|
||||
|
||||
Regular → Executable
+64
-3654
File diff suppressed because it is too large.
Load diff
Executable
+1398
File diff suppressed because it is too large.
Load diff
Executable
+587
@@ -0,0 +1,587 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct RemoteTaskMessage {
|
||||
#[serde(rename = "type")]
|
||||
pub(crate) message_type: String,
|
||||
pub(crate) task_id: String,
|
||||
pub(crate) command: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub(crate) struct TaskResultMessage {
|
||||
#[serde(rename = "type")]
|
||||
pub(crate) message_type: String,
|
||||
pub(crate) task_id: String,
|
||||
pub(crate) status: String,
|
||||
pub(crate) result: String,
|
||||
pub(crate) exit_code: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct TaskResultAckMessage {
|
||||
#[serde(rename = "type")]
|
||||
pub(crate) message_type: String,
|
||||
pub(crate) task_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct RemoteTaskJournal {
|
||||
pub(crate) version: u32,
|
||||
pub(crate) entries: Vec<RemoteTaskJournalEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(tag = "state", rename_all = "snake_case")]
|
||||
pub(crate) enum RemoteTaskJournalEntry {
|
||||
Active { task_id: String },
|
||||
Completed { result: TaskResultMessage },
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct CapturedOutput {
|
||||
pub(crate) bytes: Vec<u8>,
|
||||
pub(crate) truncated: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn capture_remote_output<R>(mut reader: R) -> mpsc::Receiver<CapturedOutput>
|
||||
where
|
||||
R: Read + Send + 'static,
|
||||
{
|
||||
let (sender, receiver) = mpsc::sync_channel(1);
|
||||
thread::spawn(move || {
|
||||
let mut bytes = Vec::new();
|
||||
let mut truncated = false;
|
||||
let mut buffer = [0_u8; 8192];
|
||||
// Keep draining after the limit so verbose commands do not receive SIGPIPE.
|
||||
loop {
|
||||
match reader.read(&mut buffer) {
|
||||
Ok(0) => break,
|
||||
Ok(read) => {
|
||||
let retained = read.min(REMOTE_STREAM_OUTPUT_BYTES as usize - bytes.len());
|
||||
bytes.extend_from_slice(&buffer[..retained]);
|
||||
truncated |= retained < read;
|
||||
}
|
||||
Err(error) if error.kind() == ErrorKind::Interrupted => continue,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
let _ = sender.send(CapturedOutput { bytes, truncated });
|
||||
});
|
||||
receiver
|
||||
}
|
||||
|
||||
pub(crate) fn terminate_remote_process_tree(child: &mut Child) {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let process_group = format!("-{}", child.id());
|
||||
let _ = Command::new("kill")
|
||||
.args(["-KILL", "--", process_group.as_str()])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status();
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let _ = Command::new("taskkill")
|
||||
.args(["/PID", &child.id().to_string(), "/T", "/F"])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status();
|
||||
}
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
pub(crate) fn receive_remote_output(
|
||||
pub(crate) receiver: &mpsc::Receiver<CapturedOutput>,
|
||||
pub(crate) child: &mut Child,
|
||||
) -> CapturedOutput {
|
||||
match receiver.recv_timeout(REMOTE_OUTPUT_DRAIN_TIMEOUT) {
|
||||
Ok(output) => output,
|
||||
Err(_) => {
|
||||
// Descendants may keep the pipe open after the shell exits.
|
||||
terminate_remote_process_tree(child);
|
||||
receiver
|
||||
.recv_timeout(REMOTE_OUTPUT_DRAIN_TIMEOUT)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn remote_result_text(stdout: &CapturedOutput, stderr: &CapturedOutput) -> String {
|
||||
let truncated = stdout.truncated || stderr.truncated;
|
||||
let stdout = String::from_utf8_lossy(&stdout.bytes);
|
||||
let stderr = String::from_utf8_lossy(&stderr.bytes);
|
||||
let mut combined = if stderr.is_empty() {
|
||||
stdout.trim().to_string()
|
||||
} else if stdout.is_empty() {
|
||||
stderr.trim().to_string()
|
||||
} else {
|
||||
format!("{}\n{}", stdout.trim(), stderr.trim())
|
||||
};
|
||||
let result_truncated = combined.len() > REMOTE_RESULT_OUTPUT_BYTES;
|
||||
if result_truncated {
|
||||
let mut end = REMOTE_RESULT_OUTPUT_BYTES;
|
||||
while !combined.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
combined.truncate(end);
|
||||
}
|
||||
if truncated || result_truncated {
|
||||
combined.push_str("\n[输出已截断]");
|
||||
}
|
||||
combined
|
||||
}
|
||||
|
||||
pub(crate) fn wait_for_remote_child(
|
||||
pub(crate) child: &mut Child,
|
||||
pub(crate) timeout: Duration,
|
||||
) -> io::Result<Option<std::process::ExitStatus>> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
if let Some(status) = child.try_wait()? {
|
||||
return Ok(Some(status));
|
||||
}
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
if remaining.is_zero() {
|
||||
return Ok(None);
|
||||
}
|
||||
thread::sleep(remaining.min(Duration::from_millis(50)));
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn execute_remote_task_with_timeout(
|
||||
pub(crate) task: &RemoteTaskMessage,
|
||||
pub(crate) timeout: Duration,
|
||||
) -> TaskResultMessage {
|
||||
let task_id = task.task_id.clone();
|
||||
|
||||
if task.command.trim().is_empty() || task.command.len() > 16_384 {
|
||||
return TaskResultMessage {
|
||||
message_type: "task_result".to_string(),
|
||||
task_id,
|
||||
status: "failed".to_string(),
|
||||
result: "命令为空或长度超出限制".to_string(),
|
||||
exit_code: Some(-1),
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
let mut command = {
|
||||
let mut command = Command::new("sh");
|
||||
command.args(["-c", task.command.as_str()]);
|
||||
command.process_group(0);
|
||||
command
|
||||
};
|
||||
#[cfg(target_os = "windows")]
|
||||
let mut command = {
|
||||
let mut command = Command::new("cmd.exe");
|
||||
command.args(["/C", task.command.as_str()]);
|
||||
command
|
||||
};
|
||||
command.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
|
||||
let mut child = match command.spawn() {
|
||||
Ok(child) => child,
|
||||
Err(error) => {
|
||||
return TaskResultMessage {
|
||||
message_type: "task_result".to_string(),
|
||||
task_id,
|
||||
status: "failed".to_string(),
|
||||
result: format!("无法启动命令:{error}"),
|
||||
exit_code: Some(-1),
|
||||
};
|
||||
}
|
||||
};
|
||||
let stdout = capture_remote_output(child.stdout.take().expect("stdout is piped"));
|
||||
let stderr = capture_remote_output(child.stderr.take().expect("stderr is piped"));
|
||||
|
||||
let status = match wait_for_remote_child(&mut child, timeout) {
|
||||
Ok(Some(status)) => Some(status),
|
||||
Ok(None) => {
|
||||
terminate_remote_process_tree(&mut child);
|
||||
None
|
||||
}
|
||||
Err(error) => {
|
||||
terminate_remote_process_tree(&mut child);
|
||||
let stdout = receive_remote_output(&stdout, &mut child);
|
||||
let stderr = receive_remote_output(&stderr, &mut child);
|
||||
let output = remote_result_text(&stdout, &stderr);
|
||||
return TaskResultMessage {
|
||||
message_type: "task_result".to_string(),
|
||||
task_id,
|
||||
status: "failed".to_string(),
|
||||
result: if output.is_empty() {
|
||||
format!("等待命令结束失败:{error}")
|
||||
} else {
|
||||
format!("{output}\n等待命令结束失败:{error}")
|
||||
},
|
||||
exit_code: Some(-1),
|
||||
};
|
||||
}
|
||||
};
|
||||
let stdout = receive_remote_output(&stdout, &mut child);
|
||||
let stderr = receive_remote_output(&stderr, &mut child);
|
||||
let output = remote_result_text(&stdout, &stderr);
|
||||
|
||||
let Some(status) = status else {
|
||||
let timeout_seconds = timeout.as_secs().max(1);
|
||||
return TaskResultMessage {
|
||||
message_type: "task_result".to_string(),
|
||||
task_id,
|
||||
status: "failed".to_string(),
|
||||
result: if output.is_empty() {
|
||||
format!("命令执行超过 {timeout_seconds} 秒,已终止")
|
||||
} else {
|
||||
format!("{output}\n命令执行超过 {timeout_seconds} 秒,已终止")
|
||||
},
|
||||
exit_code: Some(-1),
|
||||
};
|
||||
};
|
||||
|
||||
TaskResultMessage {
|
||||
message_type: "task_result".to_string(),
|
||||
task_id,
|
||||
status: if status.success() {
|
||||
"success"
|
||||
} else {
|
||||
"failed"
|
||||
}
|
||||
.to_string(),
|
||||
result: output,
|
||||
exit_code: status.code().map(i64::from),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn execute_remote_task(task: &RemoteTaskMessage) -> TaskResultMessage {
|
||||
execute_remote_task_with_timeout(task, REMOTE_TASK_TIMEOUT)
|
||||
}
|
||||
|
||||
pub(crate) struct RemoteExecutor {
|
||||
pub(crate) task_tx: mpsc::SyncSender<RemoteTaskMessage>,
|
||||
pub(crate) result_rx: mpsc::Receiver<TaskResultMessage>,
|
||||
pub(crate) active: HashSet<String>,
|
||||
pub(crate) completed: HashMap<String, TaskResultMessage>,
|
||||
pub(crate) task_order: VecDeque<String>,
|
||||
pub(crate) last_sent: HashMap<String, Instant>,
|
||||
pub(crate) journal_path: PathBuf,
|
||||
pub(crate) disabled: bool,
|
||||
}
|
||||
|
||||
impl RemoteExecutor {
|
||||
pub(crate) fn new() -> Result<Self> {
|
||||
Self::new_at(remote_task_journal_path()?)
|
||||
}
|
||||
|
||||
pub(crate) fn new_at(journal_path: PathBuf) -> Result<Self> {
|
||||
let journal = load_remote_task_journal(&journal_path)?;
|
||||
let mut active = HashSet::new();
|
||||
let mut completed = HashMap::new();
|
||||
let mut task_order = VecDeque::new();
|
||||
for entry in journal.entries {
|
||||
match entry {
|
||||
RemoteTaskJournalEntry::Active { task_id } => {
|
||||
active.insert(task_id.clone());
|
||||
task_order.push_back(task_id);
|
||||
}
|
||||
RemoteTaskJournalEntry::Completed { result } => {
|
||||
task_order.push_back(result.task_id.clone());
|
||||
completed.insert(result.task_id.clone(), result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (task_tx, task_rx) = mpsc::sync_channel::<RemoteTaskMessage>(16);
|
||||
let (result_tx, result_rx) = mpsc::channel::<TaskResultMessage>();
|
||||
thread::Builder::new()
|
||||
.name("nodeflare-remote".to_string())
|
||||
.spawn(move || {
|
||||
while let Ok(task) = task_rx.recv() {
|
||||
if result_tx.send(execute_remote_task(&task)).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
})?;
|
||||
let interrupted = active.drain().collect::<Vec<_>>();
|
||||
for task_id in interrupted {
|
||||
completed.insert(
|
||||
task_id.clone(),
|
||||
TaskResultMessage {
|
||||
message_type: "task_result".to_string(),
|
||||
task_id,
|
||||
status: "failed".to_string(),
|
||||
result: "Agent 在命令执行期间重启,无法确认原命令状态;为避免重复操作,任务不会再次执行"
|
||||
.to_string(),
|
||||
exit_code: Some(-1),
|
||||
},
|
||||
);
|
||||
}
|
||||
let executor = Self {
|
||||
task_tx,
|
||||
result_rx,
|
||||
active,
|
||||
completed,
|
||||
task_order,
|
||||
last_sent: HashMap::new(),
|
||||
journal_path,
|
||||
disabled: false,
|
||||
};
|
||||
if !executor.completed.is_empty() {
|
||||
executor.persist_snapshot(None)?;
|
||||
}
|
||||
Ok(executor)
|
||||
}
|
||||
|
||||
pub(crate) fn disabled() -> Self {
|
||||
let (task_tx, task_rx) = mpsc::sync_channel(1);
|
||||
let (result_tx, result_rx) = mpsc::channel();
|
||||
drop(task_rx);
|
||||
drop(result_tx);
|
||||
Self {
|
||||
task_tx,
|
||||
result_rx,
|
||||
active: HashSet::new(),
|
||||
completed: HashMap::new(),
|
||||
task_order: VecDeque::new(),
|
||||
last_sent: HashMap::new(),
|
||||
journal_path: PathBuf::new(),
|
||||
disabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn enqueue(&mut self, task: RemoteTaskMessage) -> Option<TaskResultMessage> {
|
||||
if self.disabled {
|
||||
return Some(remote_task_rejected(
|
||||
task.task_id,
|
||||
"Agent 任务日志异常,远程执行已禁用",
|
||||
));
|
||||
}
|
||||
if self.completed.contains_key(&task.task_id) {
|
||||
self.last_sent.remove(&task.task_id);
|
||||
return None;
|
||||
}
|
||||
if self.active.contains(&task.task_id) {
|
||||
return None;
|
||||
}
|
||||
let task_id = task.task_id.clone();
|
||||
if self.task_order.len() >= MAX_REMOTE_TASK_JOURNAL_ENTRIES {
|
||||
return Some(remote_task_rejected(
|
||||
task_id,
|
||||
"待确认的远程任务过多,已拒绝执行",
|
||||
));
|
||||
}
|
||||
self.active.insert(task_id.clone());
|
||||
self.task_order.push_back(task_id.clone());
|
||||
if let Err(error) = self.persist_snapshot(None) {
|
||||
self.active.remove(&task_id);
|
||||
self.task_order.retain(|id| id != &task_id);
|
||||
eprintln!("remote task journal write failed: {error}");
|
||||
return Some(remote_task_rejected(
|
||||
task_id,
|
||||
"Agent 无法持久化任务状态,已拒绝执行",
|
||||
));
|
||||
}
|
||||
match self.task_tx.try_send(task) {
|
||||
Ok(()) => None,
|
||||
Err(error) => {
|
||||
let message = match error {
|
||||
mpsc::TrySendError::Full(_) => "远程命令队列已满,请稍后重试",
|
||||
mpsc::TrySendError::Disconnected(_) => "远程命令执行线程不可用",
|
||||
};
|
||||
let result = remote_task_rejected(task_id, message);
|
||||
self.remember_completed(result.clone());
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn drain_completed(&mut self) {
|
||||
let results = self.result_rx.try_iter().collect::<Vec<_>>();
|
||||
for result in results {
|
||||
self.remember_completed(result);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn acknowledge(&mut self, task_id: &str) {
|
||||
if !self.completed.contains_key(task_id) {
|
||||
return;
|
||||
}
|
||||
match self.persist_snapshot(Some(task_id)) {
|
||||
Ok(()) => {
|
||||
self.completed.remove(task_id);
|
||||
self.task_order.retain(|id| id != task_id);
|
||||
self.last_sent.remove(task_id);
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!("remote task ACK persistence failed: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn remember_completed(&mut self, result: TaskResultMessage) {
|
||||
let task_id = result.task_id.clone();
|
||||
let was_active = self.active.remove(&task_id);
|
||||
if !was_active && !self.completed.contains_key(&task_id) {
|
||||
self.task_order.push_back(task_id.clone());
|
||||
}
|
||||
self.completed.insert(task_id.clone(), result);
|
||||
self.last_sent.remove(&task_id);
|
||||
if let Err(error) = self.persist_snapshot(None) {
|
||||
eprintln!("remote task result persistence failed: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn due_results(&mut self) -> Vec<TaskResultMessage> {
|
||||
self.drain_completed();
|
||||
let now = Instant::now();
|
||||
self.task_order
|
||||
.iter()
|
||||
.filter_map(|task_id| {
|
||||
let result = self.completed.get(task_id)?;
|
||||
let due = self.last_sent.get(task_id).is_none_or(|sent_at| {
|
||||
now.duration_since(*sent_at) >= REMOTE_RESULT_RETRY_INTERVAL
|
||||
});
|
||||
due.then(|| result.clone())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn mark_sent(&mut self, task_id: &str) {
|
||||
self.last_sent.insert(task_id.to_string(), Instant::now());
|
||||
}
|
||||
|
||||
pub(crate) fn reset_delivery(&mut self) {
|
||||
self.last_sent.clear();
|
||||
}
|
||||
|
||||
pub(crate) fn persist_snapshot(&self, excluded_task_id: Option<&str>) -> Result<()> {
|
||||
let entries = self
|
||||
.task_order
|
||||
.iter()
|
||||
.filter(|task_id| excluded_task_id != Some(task_id.as_str()))
|
||||
.filter_map(|task_id| {
|
||||
if self.active.contains(task_id) {
|
||||
Some(RemoteTaskJournalEntry::Active {
|
||||
task_id: task_id.clone(),
|
||||
})
|
||||
} else {
|
||||
self.completed
|
||||
.get(task_id)
|
||||
.cloned()
|
||||
.map(|result| RemoteTaskJournalEntry::Completed { result })
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
write_remote_task_journal(&self.journal_path, entries)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn remote_task_rejected(task_id: String, result: &str) -> TaskResultMessage {
|
||||
TaskResultMessage {
|
||||
message_type: "task_result".to_string(),
|
||||
task_id,
|
||||
status: "failed".to_string(),
|
||||
result: result.to_string(),
|
||||
exit_code: Some(-1),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn valid_remote_task_id(value: &str) -> bool {
|
||||
value.len() == 36
|
||||
&& value.bytes().enumerate().all(|(index, byte)| {
|
||||
if matches!(index, 8 | 13 | 18 | 23) {
|
||||
byte == b'-'
|
||||
} else {
|
||||
byte.is_ascii_hexdigit()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn load_remote_task_journal(path: &Path) -> Result<RemoteTaskJournal> {
|
||||
let bytes = match fs::read(path) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => {
|
||||
return Ok(RemoteTaskJournal {
|
||||
version: REMOTE_TASK_JOURNAL_VERSION,
|
||||
entries: Vec::new(),
|
||||
});
|
||||
}
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
if bytes.len() as u64 > MAX_REMOTE_TASK_JOURNAL_BYTES {
|
||||
return Err("remote task journal is too large".into());
|
||||
}
|
||||
let journal = serde_json::from_slice::<RemoteTaskJournal>(&bytes)?;
|
||||
if journal.version != REMOTE_TASK_JOURNAL_VERSION {
|
||||
return Err("unsupported remote task journal version".into());
|
||||
}
|
||||
if journal.entries.len() > MAX_REMOTE_TASK_JOURNAL_ENTRIES {
|
||||
return Err("remote task journal has too many entries".into());
|
||||
}
|
||||
let mut task_ids = HashSet::new();
|
||||
for entry in &journal.entries {
|
||||
let (task_id, valid_result) = match entry {
|
||||
RemoteTaskJournalEntry::Active { task_id } => (task_id, true),
|
||||
RemoteTaskJournalEntry::Completed { result } => (
|
||||
&result.task_id,
|
||||
result.message_type == "task_result"
|
||||
&& matches!(result.status.as_str(), "success" | "failed")
|
||||
&& result.result.len() <= REMOTE_RESULT_OUTPUT_BYTES + 64,
|
||||
),
|
||||
};
|
||||
if !valid_remote_task_id(task_id) || !valid_result || !task_ids.insert(task_id) {
|
||||
return Err("remote task journal contains an invalid entry".into());
|
||||
}
|
||||
}
|
||||
Ok(journal)
|
||||
}
|
||||
|
||||
pub(crate) fn write_remote_task_journal(path: &Path, entries: Vec<RemoteTaskJournalEntry>) -> Result<()> {
|
||||
if entries.is_empty() {
|
||||
return match fs::remove_file(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(error.into()),
|
||||
};
|
||||
}
|
||||
let journal = RemoteTaskJournal {
|
||||
version: REMOTE_TASK_JOURNAL_VERSION,
|
||||
entries,
|
||||
};
|
||||
let mut encoded = serde_json::to_vec(&journal)?;
|
||||
encoded.push(b'\n');
|
||||
if encoded.len() as u64 > MAX_REMOTE_TASK_JOURNAL_BYTES {
|
||||
return Err("remote task journal is too large".into());
|
||||
}
|
||||
let temporary = path.with_file_name(format!(
|
||||
".{}.{}.tmp",
|
||||
path.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("remote-tasks"),
|
||||
std::process::id()
|
||||
));
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.write(true)
|
||||
.open(&temporary)?;
|
||||
#[cfg(unix)]
|
||||
fs::set_permissions(&temporary, fs::Permissions::from_mode(0o600))?;
|
||||
file.write_all(&encoded)?;
|
||||
file.sync_all()?;
|
||||
drop(file);
|
||||
// fs::rename maps to MoveFileEx(MOVEFILE_REPLACE_EXISTING) on Windows, so it
|
||||
// replaces the destination atomically; deleting it first would open a window
|
||||
// where a crash loses the whole journal.
|
||||
if let Err(error) = fs::rename(&temporary, path) {
|
||||
let _ = fs::remove_file(&temporary);
|
||||
return Err(error.into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Executable
+811
@@ -0,0 +1,811 @@
|
||||
use super::*;
|
||||
|
||||
use clap::Parser;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use super::{
|
||||
CLOCK_CALIBRATION_MAX_AGE, CapturedOutput, CliOptions, ClockCalibration, DiskMetric,
|
||||
GithubReleaseAsset, LatencyResult, LatencyTask, LiveAck, MAX_PENDING_LATENCY_RESULTS,
|
||||
PUBLIC_IP_STALE_AFTER, PublicIpValue, REMOTE_RESULT_OUTPUT_BYTES,
|
||||
REMOTE_STREAM_OUTPUT_BYTES, RemoteExecutor, RemoteTaskJournalEntry, RemoteTaskMessage,
|
||||
Report, TaskResultMessage, UPDATE_CHECK_JITTER_MAX_SECONDS, ack_persist_interval,
|
||||
advance_deadline, clock_offset_from_http_date, corrected_timestamp, dedupe_disks,
|
||||
execute_remote_task_with_timeout, gpu_name_from_uevent, is_public_probe_ip, live_endpoint,
|
||||
live_update_payload, monotonic_report_timestamp, normalized_version, parse_lspci_gpu_names,
|
||||
parse_pciconf_gpu_names, parse_probe_target, parse_public_ip,
|
||||
parse_system_profiler_gpu_names, ping_latencies, ping_latency, prune_report_samples,
|
||||
release_asset_sha256, remote_result_text, sanitize_latency_tasks, selected_interface,
|
||||
update_check_jitter, valid_endpoint, version_triplet, wildcard_match,
|
||||
write_remote_task_journal,
|
||||
};
|
||||
|
||||
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "freebsd"))]
|
||||
use super::connection_counts_from_netstat;
|
||||
#[cfg(target_os = "linux")]
|
||||
use super::disk_device;
|
||||
|
||||
#[test]
|
||||
fn parses_family_matched_public_ips() {
|
||||
let v4: std::net::IpAddr = "203.0.113.7".parse().unwrap();
|
||||
let v6: std::net::IpAddr = "2001:db8::1".parse().unwrap();
|
||||
assert_eq!(parse_public_ip("203.0.113.7\n", false), Some(v4));
|
||||
assert_eq!(parse_public_ip("2001:db8::1", true), Some(v6));
|
||||
assert_eq!(parse_public_ip("2001:db8::1", false), None);
|
||||
assert_eq!(parse_public_ip("203.0.113.7", true), None);
|
||||
assert_eq!(parse_public_ip("not an ip", false), None);
|
||||
assert_eq!(parse_public_ip("", false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expires_public_ip_after_sustained_probe_failures() {
|
||||
let started_at = Instant::now();
|
||||
let address = "203.0.113.7".parse().unwrap();
|
||||
let mut value = PublicIpValue::default();
|
||||
|
||||
value.observe(Some(address), started_at);
|
||||
value.observe(
|
||||
None,
|
||||
started_at + PUBLIC_IP_STALE_AFTER - Duration::from_secs(1),
|
||||
);
|
||||
assert_eq!(value.address, Some(address));
|
||||
|
||||
value.observe(None, started_at + PUBLIC_IP_STALE_AFTER);
|
||||
assert_eq!(value.address, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_platform_gpu_names() {
|
||||
let lspci = "00:02.0 VGA compatible controller: Intel Corporation CometLake-S GT2 [UHD Graphics 630] (rev 05)\n\
|
||||
01:00.0 Audio device: NVIDIA Corporation HDMI Audio\n\
|
||||
02:00.0 3D controller: NVIDIA Corporation GA102 [GeForce RTX 3090] (rev a1)";
|
||||
assert_eq!(
|
||||
parse_lspci_gpu_names(lspci),
|
||||
[
|
||||
"Intel Corporation CometLake-S GT2 [UHD Graphics 630] (rev 05)",
|
||||
"NVIDIA Corporation GA102 [GeForce RTX 3090] (rev a1)",
|
||||
]
|
||||
);
|
||||
|
||||
let profiler = "Graphics/Displays:\n\n Apple M3 Max:\n\n Chipset Model: Apple M3 Max\n Metal Support: Metal 3";
|
||||
assert_eq!(parse_system_profiler_gpu_names(profiler), ["Apple M3 Max"]);
|
||||
|
||||
let pciconf = "vgapci0@pci0:0:2:0:\tclass=0x030000 rev=0x02 hdr=0x00 vendor=0x8086\n\
|
||||
\tvendor = 'Intel Corporation'\n\
|
||||
\tdevice = 'UHD Graphics 630'\n\
|
||||
\tclass = display\n\
|
||||
em0@pci0:0:25:0:\tclass=0x020000 rev=0x05 hdr=0x00 vendor=0x8086\n\
|
||||
\tvendor = 'Intel Corporation'\n\
|
||||
\tdevice = 'Ethernet Connection'\n\
|
||||
\tclass = network";
|
||||
assert_eq!(
|
||||
parse_pciconf_gpu_names(pciconf),
|
||||
["Intel Corporation UHD Graphics 630"]
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
gpu_name_from_uevent("DRIVER=i915\nPCI_CLASS=30000\nPCI_ID=8086:591B"),
|
||||
Some("Intel Integrated Graphics".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
gpu_name_from_uevent("DRIVER=virtio_gpu\nPCI_CLASS=30000"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn selects_whole_disk_devices() {
|
||||
assert!(disk_device("sda"));
|
||||
assert!(disk_device("nvme0n1"));
|
||||
assert!(!disk_device("sda1"));
|
||||
assert!(!disk_device("nvme0n1p1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_virtual_interfaces_and_supports_include_exclude_patterns() {
|
||||
assert!(!selected_interface("lo", ""));
|
||||
assert!(!selected_interface("docker0", ""));
|
||||
assert!(!selected_interface("veth123", ""));
|
||||
assert!(!selected_interface("br0", "!eth*"));
|
||||
assert!(selected_interface("br0", "br0"));
|
||||
assert!(!selected_interface("br0", "br*,!br0"));
|
||||
assert!(!selected_interface("br0", "!br0,br*"));
|
||||
assert!(!selected_interface("Software Loopback Interface 1", ""));
|
||||
assert!(selected_interface("eth0", ""));
|
||||
assert!(selected_interface("ens3", "eth*,ens*"));
|
||||
assert!(!selected_interface("wlan0", "eth*,ens*"));
|
||||
assert!(!selected_interface("eth0", "eth*,!eth0"));
|
||||
assert!(selected_interface("ens3", "!eth*"));
|
||||
assert!(wildcard_match("en?3", "ens3"));
|
||||
assert!(!wildcard_match("en?3", "enp4s0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deduplicates_mounts_for_the_same_device() {
|
||||
let disks = dedupe_disks([
|
||||
DiskMetric {
|
||||
name: "/dev/vg/root".into(),
|
||||
mount_point: "/var".into(),
|
||||
total: 10,
|
||||
used: 4,
|
||||
..DiskMetric::default()
|
||||
},
|
||||
DiskMetric {
|
||||
name: "/dev/vg/root".into(),
|
||||
mount_point: "/".into(),
|
||||
total: 20,
|
||||
used: 8,
|
||||
..DiskMetric::default()
|
||||
},
|
||||
DiskMetric {
|
||||
name: "tank/data".into(),
|
||||
mount_point: "/data".into(),
|
||||
total: 30,
|
||||
used: 9,
|
||||
..DiskMetric::default()
|
||||
},
|
||||
DiskMetric {
|
||||
name: "tank/archive".into(),
|
||||
mount_point: "/archive".into(),
|
||||
total: 25,
|
||||
used: 7,
|
||||
..DiskMetric::default()
|
||||
},
|
||||
DiskMetric {
|
||||
name: "/var/lib/data".into(),
|
||||
mount_point: "/var/lib/data".into(),
|
||||
total: 40,
|
||||
used: 12,
|
||||
..DiskMetric::default()
|
||||
},
|
||||
]);
|
||||
assert_eq!(disks.len(), 3);
|
||||
assert_eq!(disks[0].mount_point, "/");
|
||||
assert_eq!(disks[0].total, 20);
|
||||
assert_eq!(disks[1].name, "tank/data");
|
||||
assert_eq!(disks[2].name, "/var/lib/data");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn keeps_root_filesystem_but_filters_virtual_mounts() {
|
||||
assert!(!super::excluded_filesystem("overlay", "/"));
|
||||
assert!(super::excluded_filesystem("tmpfs", "/tmp"));
|
||||
assert!(super::excluded_filesystem("proc", "/proc"));
|
||||
assert!(!super::excluded_filesystem("btrfs", "/var"));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn memory_matches_komari_default_including_shared_memory_and_swap_cache() {
|
||||
let memory = super::memory_sample(
|
||||
"MemTotal: 1000000 kB\nMemFree: 100000 kB\nMemAvailable: 750000 kB\n\
|
||||
Cached: 300000 kB\nSReclaimable: 50000 kB\nBuffers: 25000 kB\n\
|
||||
Shmem: 25000 kB\nSwapTotal: 100000 kB\nSwapFree: 60000 kB\nSwapCached: 20000 kB\n",
|
||||
);
|
||||
assert_eq!(memory.total, 1_000_000 * 1024);
|
||||
assert_eq!(memory.used, 550_000 * 1024);
|
||||
assert_eq!(memory.swap_total, 100_000 * 1024);
|
||||
assert_eq!(memory.swap_used, 20_000 * 1024);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn filling_file_cache_does_not_inflate_reported_memory() {
|
||||
let before = super::memory_sample(
|
||||
"MemTotal: 1000 kB\nMemFree: 400 kB\nCached: 300 kB\n\
|
||||
Buffers: 50 kB\nSReclaimable: 50 kB\nShmem: 100 kB\n",
|
||||
);
|
||||
let after = super::memory_sample(
|
||||
"MemTotal: 1000 kB\nMemFree: 200 kB\nCached: 500 kB\n\
|
||||
Buffers: 50 kB\nSReclaimable: 50 kB\nShmem: 100 kB\n",
|
||||
);
|
||||
assert_eq!(before.used, 300 * 1024);
|
||||
assert_eq!(after.used, before.used);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn memory_handles_missing_and_inconsistent_kernel_counters() {
|
||||
let memory = super::memory_sample(
|
||||
"MemTotal: 1000 kB\nMemFree: 200 kB\nCached: 1000 kB\n\
|
||||
Shmem: 100 kB\nSwapTotal: 100 kB\nSwapFree: 20 kB\nSwapCached: 100 kB\n",
|
||||
);
|
||||
assert_eq!(memory.used, 900 * 1024);
|
||||
assert_eq!(memory.swap_used, 80 * 1024);
|
||||
for contents in ["", "MemTotal: invalid kB\n", "MemTotal: -1 kB\n"] {
|
||||
let memory = super::memory_sample(contents);
|
||||
assert_eq!(
|
||||
(
|
||||
memory.total,
|
||||
memory.used,
|
||||
memory.swap_total,
|
||||
memory.swap_used
|
||||
),
|
||||
(0, 0, 0, 0)
|
||||
);
|
||||
}
|
||||
let memory = super::memory_sample(
|
||||
"MemTotal: 100 kB\nMemFree: 1000 kB\nSwapTotal: 100 kB\nSwapFree: 1000 kB\n",
|
||||
);
|
||||
assert_eq!((memory.used, memory.swap_used), (0, 0));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn reads_local_mounts_and_decodes_escaped_paths_once() {
|
||||
let mounts = super::disk_mounts(
|
||||
r"overlay / overlay rw 0 0
|
||||
proc /proc proc rw 0 0
|
||||
tmpfs /run tmpfs rw 0 0
|
||||
/dev/vda1 /data\040volume ext4 rw 0 0
|
||||
/dev/vda2 /literal\134040path ext4 rw 0 0
|
||||
/dev/vda3 /tab\011dir ext4 rw 0 0
|
||||
server:/export /remote nfs4 rw 0 0
|
||||
//server/share /remote-share cifs rw 0 0
|
||||
none /auto autofs rw 0 0
|
||||
invalid",
|
||||
)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
mounts,
|
||||
vec![
|
||||
("overlay".into(), "/".into()),
|
||||
("/dev/vda1".into(), "/data volume".into()),
|
||||
("/dev/vda2".into(), "/literal\\040path".into()),
|
||||
("/dev/vda3".into(), "/tab\tdir".into()),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn reads_root_disk_capacity_without_external_commands() {
|
||||
let (total, used) = super::filesystem_space("/").unwrap();
|
||||
assert!(total > 0);
|
||||
assert!((0..=total).contains(&used));
|
||||
assert!(
|
||||
super::disk_usage()
|
||||
.iter()
|
||||
.any(|disk| disk.mount_point == "/")
|
||||
);
|
||||
assert!(super::filesystem_space("/proc/self/not-a-mount").is_none());
|
||||
assert!(super::filesystem_space("/nul\0path").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overdue_latency_tasks_wait_and_resume_without_duplicate_probes() {
|
||||
let (task_tx, task_rx) = std::sync::mpsc::channel();
|
||||
let (result_tx, result_rx) = std::sync::mpsc::channel();
|
||||
let mut executor = super::LatencyExecutor {
|
||||
task_tx,
|
||||
result_rx,
|
||||
in_flight: Default::default(),
|
||||
stats: Default::default(),
|
||||
};
|
||||
let task = LatencyTask {
|
||||
id: "queued-probe".into(),
|
||||
name: "probe".into(),
|
||||
task_type: "tcp".into(),
|
||||
target: "example.com".into(),
|
||||
port: Some(443),
|
||||
interval_seconds: 30,
|
||||
};
|
||||
let mut deadlines = std::collections::HashMap::new();
|
||||
let started = Instant::now();
|
||||
executor.schedule(std::slice::from_ref(&task), &mut deadlines, started);
|
||||
assert_eq!(task_rx.try_recv().unwrap().id, task.id);
|
||||
assert_eq!(deadlines[&task.id], started + Duration::from_secs(30));
|
||||
|
||||
let overdue = started + Duration::from_secs(30);
|
||||
executor.schedule(std::slice::from_ref(&task), &mut deadlines, overdue);
|
||||
assert!(task_rx.try_recv().is_err());
|
||||
assert_eq!(deadlines[&task.id], overdue + Duration::from_secs(1));
|
||||
|
||||
result_tx
|
||||
.send(LatencyResult {
|
||||
task_id: task.id.clone(),
|
||||
..LatencyResult::default()
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(executor.drain().len(), 1);
|
||||
let retry_at = deadlines[&task.id];
|
||||
executor.schedule(std::slice::from_ref(&task), &mut deadlines, retry_at);
|
||||
executor.schedule(std::slice::from_ref(&task), &mut deadlines, retry_at);
|
||||
assert_eq!(task_rx.try_recv().unwrap().id, task.id);
|
||||
assert!(task_rx.try_recv().is_err());
|
||||
assert_eq!(deadlines[&task.id], retry_at + Duration::from_secs(30));
|
||||
|
||||
drop(task_rx);
|
||||
executor.in_flight.clear();
|
||||
let retry_at = deadlines[&task.id];
|
||||
executor.schedule(std::slice::from_ref(&task), &mut deadlines, retry_at);
|
||||
assert_eq!(deadlines[&task.id], retry_at + Duration::from_secs(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_release_versions() {
|
||||
assert_eq!(normalized_version("v1.2.3"), "1.2.3");
|
||||
assert_eq!(version_triplet("v1.2.3"), Some((1, 2, 3)));
|
||||
assert_eq!(version_triplet("1.2"), None);
|
||||
assert_eq!(version_triplet("1.2.3.4"), None);
|
||||
assert_eq!(version_triplet("rust-3"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_release_asset_digests() {
|
||||
let hash = "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF";
|
||||
let mut asset = GithubReleaseAsset {
|
||||
name: "agent-linux-x64-glibc".to_string(),
|
||||
browser_download_url: "https://example.com/agent".to_string(),
|
||||
digest: Some(format!("sha256:{hash}")),
|
||||
};
|
||||
let expected = hash.to_ascii_lowercase();
|
||||
assert_eq!(
|
||||
release_asset_sha256(&asset).as_deref(),
|
||||
Some(expected.as_str())
|
||||
);
|
||||
asset.digest = Some("sha512:0123".to_string());
|
||||
assert_eq!(release_asset_sha256(&asset), None);
|
||||
asset.digest = None;
|
||||
assert_eq!(release_asset_sha256(&asset), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_endpoints() {
|
||||
assert!(valid_endpoint("https://monitor.example.com/"));
|
||||
assert!(valid_endpoint("http://127.0.0.1:8787"));
|
||||
assert!(valid_endpoint("http://localhost:8787"));
|
||||
assert!(valid_endpoint("http://[::1]:8787"));
|
||||
assert!(!valid_endpoint("http://monitor.example.com"));
|
||||
assert!(!valid_endpoint("http://127.0.0.2:8787"));
|
||||
assert!(!valid_endpoint("monitor.example.com"));
|
||||
assert!(!valid_endpoint("https://"));
|
||||
assert!(!valid_endpoint("https://user@example.com"));
|
||||
assert!(!valid_endpoint("https://monitor.example.com/?token=abc"));
|
||||
assert!(!valid_endpoint("https://bad host.example"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_live_websocket_endpoints() {
|
||||
assert_eq!(
|
||||
live_endpoint("https://monitor.example.com").unwrap(),
|
||||
"wss://monitor.example.com/api/agent/ws"
|
||||
);
|
||||
assert_eq!(
|
||||
live_endpoint("https://monitor.example.com/base/").unwrap(),
|
||||
"wss://monitor.example.com/base/api/agent/ws"
|
||||
);
|
||||
assert_eq!(
|
||||
live_endpoint("http://127.0.0.1:8787").unwrap(),
|
||||
"ws://127.0.0.1:8787/api/agent/ws"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_durable_ack_interval_and_limits_upload_frequency() {
|
||||
let ack: LiveAck = serde_json::from_str(
|
||||
r#"{"type":"ack","ts":100,"persisted":true,"persistenceError":false,"persistedThroughTs":100,"nextPersistAfterMs":60000}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(ack_persist_interval(&ack), Duration::from_secs(60));
|
||||
let idle: LiveAck = serde_json::from_str(
|
||||
r#"{"type":"ack","ts":100,"persisted":true,"persistenceError":false,"persistedThroughTs":100,"nextPersistAfterMs":120000}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(ack_persist_interval(&idle), Duration::from_secs(120));
|
||||
assert_eq!(super::live_batch_interval(1), Duration::from_secs(3));
|
||||
assert_eq!(super::live_batch_interval(3), Duration::from_secs(3));
|
||||
assert_eq!(super::live_batch_interval(15), Duration::from_secs(15));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn remote_commands_capture_output_and_exit_status() {
|
||||
let result = execute_remote_task_with_timeout(
|
||||
&RemoteTaskMessage {
|
||||
message_type: "remote_task".to_string(),
|
||||
task_id: "task-output".to_string(),
|
||||
command: "printf stdout; printf stderr >&2; exit 7".to_string(),
|
||||
},
|
||||
Duration::from_secs(2),
|
||||
);
|
||||
assert_eq!(result.status, "failed");
|
||||
assert_eq!(result.exit_code, Some(7));
|
||||
assert_eq!(result.result, "stdout\nstderr");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn remote_commands_are_terminated_after_the_deadline() {
|
||||
let result = execute_remote_task_with_timeout(
|
||||
&RemoteTaskMessage {
|
||||
message_type: "remote_task".to_string(),
|
||||
task_id: "task-timeout".to_string(),
|
||||
command: "sleep 5".to_string(),
|
||||
},
|
||||
Duration::from_millis(50),
|
||||
);
|
||||
assert_eq!(result.status, "failed");
|
||||
assert_eq!(result.exit_code, Some(-1));
|
||||
assert!(result.result.contains("已终止"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combined_remote_output_is_bounded_at_utf8_boundaries() {
|
||||
let result = remote_result_text(
|
||||
&CapturedOutput {
|
||||
bytes: vec![b'x'; REMOTE_STREAM_OUTPUT_BYTES as usize],
|
||||
truncated: true,
|
||||
},
|
||||
&CapturedOutput {
|
||||
bytes: "输出"
|
||||
.repeat(REMOTE_STREAM_OUTPUT_BYTES as usize / 6)
|
||||
.into_bytes(),
|
||||
truncated: false,
|
||||
},
|
||||
);
|
||||
assert!(result.len() < REMOTE_RESULT_OUTPUT_BYTES + 64);
|
||||
assert!(result.ends_with("[输出已截断]"));
|
||||
assert!(!result.contains('\u{fffd}'));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn verbose_remote_commands_finish_after_output_is_truncated() {
|
||||
for command in [
|
||||
"set -e; head -c 1048576 /dev/zero; printf completed >&2",
|
||||
"set -e; head -c 1048576 /dev/zero >&2; printf completed",
|
||||
] {
|
||||
let result = execute_remote_task_with_timeout(
|
||||
&RemoteTaskMessage {
|
||||
message_type: "remote_task".to_string(),
|
||||
task_id: "verbose-output".to_string(),
|
||||
command: command.to_string(),
|
||||
},
|
||||
Duration::from_secs(5),
|
||||
);
|
||||
assert_eq!(result.status, "success");
|
||||
assert_eq!(result.exit_code, Some(0));
|
||||
assert!(result.result.contains("completed"));
|
||||
assert!(result.result.ends_with("[输出已截断]"));
|
||||
assert!(result.result.len() < REMOTE_RESULT_OUTPUT_BYTES + 64);
|
||||
}
|
||||
}
|
||||
|
||||
fn temporary_remote_task_journal(label: &str) -> PathBuf {
|
||||
let nonce = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let directory = std::env::temp_dir().join(format!(
|
||||
"nodeflare-agent-{label}-{}-{nonce}",
|
||||
std::process::id()
|
||||
));
|
||||
fs::create_dir_all(&directory).unwrap();
|
||||
directory.join("remote-tasks.json")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interrupted_remote_tasks_are_failed_without_reexecution() {
|
||||
let path = temporary_remote_task_journal("interrupted-task");
|
||||
let task_id = "8dd70536-f721-4d47-af63-e2f17c83de25";
|
||||
write_remote_task_journal(
|
||||
&path,
|
||||
vec![RemoteTaskJournalEntry::Active {
|
||||
task_id: task_id.to_string(),
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut executor = RemoteExecutor::new_at(path.clone()).unwrap();
|
||||
assert!(executor.active.is_empty());
|
||||
let due = executor.due_results();
|
||||
assert_eq!(due.len(), 1);
|
||||
assert_eq!(due[0].task_id, task_id);
|
||||
assert_eq!(due[0].status, "failed");
|
||||
assert!(due[0].result.contains("不会再次执行"));
|
||||
|
||||
executor.mark_sent(task_id);
|
||||
assert!(executor.due_results().is_empty());
|
||||
executor.reset_delivery();
|
||||
assert_eq!(executor.due_results().len(), 1);
|
||||
executor.acknowledge(task_id);
|
||||
assert!(!path.exists());
|
||||
|
||||
let directory = path.parent().unwrap().to_path_buf();
|
||||
drop(executor);
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_remote_tasks_return_the_cached_result() {
|
||||
let path = temporary_remote_task_journal("completed-task");
|
||||
let task_id = "6cbf4d36-f33a-40ef-b952-6af1b8491820";
|
||||
let result = TaskResultMessage {
|
||||
message_type: "task_result".to_string(),
|
||||
task_id: task_id.to_string(),
|
||||
status: "success".to_string(),
|
||||
result: "already finished".to_string(),
|
||||
exit_code: Some(0),
|
||||
};
|
||||
write_remote_task_journal(
|
||||
&path,
|
||||
vec![RemoteTaskJournalEntry::Completed {
|
||||
result: result.clone(),
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut executor = RemoteExecutor::new_at(path.clone()).unwrap();
|
||||
assert!(
|
||||
executor
|
||||
.enqueue(RemoteTaskMessage {
|
||||
message_type: "remote_task".to_string(),
|
||||
task_id: task_id.to_string(),
|
||||
command: "this command must not run".to_string(),
|
||||
})
|
||||
.is_none()
|
||||
);
|
||||
assert!(executor.active.is_empty());
|
||||
assert_eq!(executor.due_results(), [result]);
|
||||
executor.acknowledge(task_id);
|
||||
|
||||
let directory = path.parent().unwrap().to_path_buf();
|
||||
drop(executor);
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encodes_realtime_samples_as_a_batch() {
|
||||
let payload = live_update_payload(
|
||||
vec![
|
||||
Report {
|
||||
timestamp: 10,
|
||||
cpu: 20.0,
|
||||
..Report::default()
|
||||
},
|
||||
Report {
|
||||
timestamp: 15,
|
||||
cpu: 30.0,
|
||||
..Report::default()
|
||||
},
|
||||
],
|
||||
false,
|
||||
&mut None,
|
||||
)
|
||||
.unwrap();
|
||||
let value: serde_json::Value = nodeflare_telemetry::decode(&payload).unwrap();
|
||||
assert_eq!(value["persist"], false);
|
||||
assert_eq!(value["samples"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(value["samples"][1]["metrics"]["cpu"], 30.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calibrates_timestamps_from_http_date() {
|
||||
let server_ms = 784_111_777_000_i64;
|
||||
assert_eq!(
|
||||
clock_offset_from_http_date(
|
||||
"Sun, 06 Nov 1994 08:49:37 GMT",
|
||||
server_ms - 1_000,
|
||||
server_ms + 1_000,
|
||||
),
|
||||
Some(0),
|
||||
);
|
||||
assert_eq!(corrected_timestamp(100, 2_500), 102);
|
||||
assert_eq!(corrected_timestamp(100, -2_500), 97);
|
||||
assert_eq!(monotonic_report_timestamp(97, 100, 90), 101);
|
||||
assert_eq!(monotonic_report_timestamp(110, 100, 120), 121);
|
||||
assert_eq!(monotonic_report_timestamp(130, 120, 110), 130);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_small_clock_jitter_until_the_calibration_expires() {
|
||||
let mut calibration = ClockCalibration::default();
|
||||
assert!(calibration.observe(1_000));
|
||||
assert!(!calibration.observe(2_000));
|
||||
assert_eq!(calibration.offset_ms, 1_000);
|
||||
assert!(calibration.observe(25_000));
|
||||
calibration.calibrated_at = Some(Instant::now() - CLOCK_CALIBRATION_MAX_AGE);
|
||||
assert!(calibration.observe(25_001));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_cli_options() {
|
||||
let parsed = CliOptions::try_parse_from([
|
||||
"agent",
|
||||
"-e",
|
||||
"https://monitor.example.com",
|
||||
"-t",
|
||||
"agent-token",
|
||||
"-i",
|
||||
"60",
|
||||
"--once",
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(parsed.endpoint, "https://monitor.example.com");
|
||||
assert_eq!(parsed.token.as_deref(), Some("agent-token"));
|
||||
assert_eq!(parsed.interval, 60);
|
||||
assert!(parsed.once);
|
||||
assert!(CliOptions::try_parse_from(["agent", "-t"]).is_err());
|
||||
assert!(CliOptions::try_parse_from(["agent", "-t", "first", "-t", "second"]).is_err());
|
||||
assert!(
|
||||
CliOptions::try_parse_from([
|
||||
"agent",
|
||||
"-e",
|
||||
"https://monitor.example.com",
|
||||
"-t",
|
||||
"agent-token",
|
||||
"--token-file",
|
||||
"/run/nodeflare/token",
|
||||
])
|
||||
.is_err()
|
||||
);
|
||||
assert!(CliOptions::try_parse_from(["agent", "--once", "--collect"]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_and_deduplicates_remote_latency_tasks_without_a_count_limit() {
|
||||
let valid = LatencyTask {
|
||||
id: "task-1".to_string(),
|
||||
name: "Cloudflare".to_string(),
|
||||
task_type: "tcp".to_string(),
|
||||
target: "1.1.1.1".to_string(),
|
||||
port: Some(443),
|
||||
interval_seconds: 60,
|
||||
};
|
||||
let mut tasks = vec![valid.clone(), valid];
|
||||
tasks.push(LatencyTask {
|
||||
id: "bad".to_string(),
|
||||
name: "Bad".to_string(),
|
||||
task_type: "http".to_string(),
|
||||
target: "https://example.com".to_string(),
|
||||
port: Some(443),
|
||||
interval_seconds: 1,
|
||||
});
|
||||
for index in 2..=300 {
|
||||
tasks.push(LatencyTask {
|
||||
id: format!("task-{index}"),
|
||||
name: format!("Task {index}"),
|
||||
task_type: "icmp".to_string(),
|
||||
target: "1.1.1.1".to_string(),
|
||||
port: None,
|
||||
interval_seconds: 60,
|
||||
});
|
||||
}
|
||||
let sanitized = sanitize_latency_tasks(&tasks);
|
||||
assert_eq!(sanitized.len(), 300);
|
||||
assert_eq!(sanitized[0].id, "task-1");
|
||||
assert!(!sanitized.iter().any(|task| task.id == "bad"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advances_collection_deadlines_without_drift() {
|
||||
let start = Instant::now();
|
||||
let interval = Duration::from_secs(1);
|
||||
assert_eq!(
|
||||
advance_deadline(start, interval, start + Duration::from_millis(2500)),
|
||||
start + Duration::from_secs(3)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drops_samples_the_server_would_reject_as_expired() {
|
||||
let mut samples = vec![
|
||||
Report {
|
||||
timestamp: 1_000,
|
||||
..Report::default()
|
||||
},
|
||||
Report {
|
||||
timestamp: 8_500,
|
||||
..Report::default()
|
||||
},
|
||||
];
|
||||
prune_report_samples(&mut samples, 9_000);
|
||||
assert_eq!(samples.len(), 1);
|
||||
assert_eq!(samples[0].timestamp, 8_500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_pending_latency_results() {
|
||||
let mut samples = (0..MAX_PENDING_LATENCY_RESULTS + 10)
|
||||
.map(|index| Report {
|
||||
timestamp: index as i64 + 1,
|
||||
latency_results: vec![LatencyResult {
|
||||
task_id: format!("task-{index}"),
|
||||
timestamp: index as i64 + 1,
|
||||
latency_ms: 10.0,
|
||||
packet_loss: 0.0,
|
||||
}],
|
||||
..Report::default()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
prune_report_samples(&mut samples, MAX_PENDING_LATENCY_RESULTS as i64 + 10);
|
||||
assert_eq!(
|
||||
samples
|
||||
.iter()
|
||||
.map(|sample| sample.latency_results.len())
|
||||
.sum::<usize>(),
|
||||
MAX_PENDING_LATENCY_RESULTS
|
||||
);
|
||||
assert!(samples.first().unwrap().latency_results.is_empty());
|
||||
assert_eq!(samples.last().unwrap().latency_results.len(), 1);
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "freebsd"))]
|
||||
#[test]
|
||||
fn parses_netstat_connection_counts() {
|
||||
let output = "tcp4 0 0 host.443 peer.1 ESTABLISHED\nudp4 0 0 *.5353 *.*\nTCP host peer ESTABLISHED\n";
|
||||
assert_eq!(connection_counts_from_netstat(output), (2, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_probe_targets() {
|
||||
assert_eq!(
|
||||
parse_probe_target("Example.COM", None),
|
||||
Some(("example.com".to_string(), 443))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_probe_target("1.1.1.1", Some(8080)),
|
||||
Some(("1.1.1.1".to_string(), 8080))
|
||||
);
|
||||
for target in [
|
||||
"",
|
||||
"https://example.com",
|
||||
"example.com:0",
|
||||
"example.com:65536",
|
||||
"999.1.1.1",
|
||||
"127.0.0.1:8080",
|
||||
"169.254.169.254",
|
||||
"192.168.1.1",
|
||||
"router.local",
|
||||
"localhost",
|
||||
"[::1]:443",
|
||||
"bad host",
|
||||
] {
|
||||
assert_eq!(parse_probe_target(target, None), None, "target: {target}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_only_public_probe_addresses() {
|
||||
for address in ["1.1.1.1", "8.8.8.8", "2606:4700:4700::1111"] {
|
||||
assert!(is_public_probe_ip(address.parse().expect("IP address")));
|
||||
}
|
||||
for address in [
|
||||
"0.0.0.0",
|
||||
"10.0.0.1",
|
||||
"100.64.0.1",
|
||||
"127.0.0.1",
|
||||
"169.254.169.254",
|
||||
"172.16.0.1",
|
||||
"192.168.1.1",
|
||||
"198.18.0.1",
|
||||
"224.0.0.1",
|
||||
"::1",
|
||||
"fe80::1",
|
||||
"fd00:ec2::254",
|
||||
] {
|
||||
assert!(!is_public_probe_ip(address.parse().expect("IP address")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_ping_latency() {
|
||||
assert_eq!(ping_latency("64 bytes time=12.34 ms"), Some(12.34));
|
||||
assert_eq!(ping_latency("64 bytes time<1 ms"), Some(0.5));
|
||||
assert_eq!(ping_latency("unreachable"), None);
|
||||
assert_eq!(
|
||||
ping_latencies("reply time=12.34 ms\nrequest timeout\nreply time=8.5 ms"),
|
||||
vec![12.34, 8.5]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_check_jitter_is_stable_and_bounded() {
|
||||
let first = update_check_jitter("agent-token");
|
||||
assert_eq!(first, update_check_jitter("agent-token"));
|
||||
assert!(first.as_secs() <= UPDATE_CHECK_JITTER_MAX_SECONDS);
|
||||
}
|
||||
Executable
+240
@@ -0,0 +1,240 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct GithubRelease {
|
||||
pub(crate) tag_name: String,
|
||||
pub(crate) assets: Vec<GithubReleaseAsset>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct GithubReleaseAsset {
|
||||
pub(crate) name: String,
|
||||
pub(crate) browser_download_url: String,
|
||||
pub(crate) digest: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) fn normalized_version(value: &str) -> &str {
|
||||
let value = value.trim();
|
||||
value.strip_prefix('v').unwrap_or(value)
|
||||
}
|
||||
|
||||
pub(crate) fn version_triplet(value: &str) -> Option<(u64, u64, u64)> {
|
||||
let mut parts = normalized_version(value).split('.');
|
||||
let version = (
|
||||
parts.next()?.parse().ok()?,
|
||||
parts.next()?.parse().ok()?,
|
||||
parts.next()?.parse().ok()?,
|
||||
);
|
||||
parts.next().is_none().then_some(version)
|
||||
}
|
||||
|
||||
pub(crate) fn agent_artifact_name() -> Option<&'static str> {
|
||||
match (env::consts::OS, env::consts::ARCH) {
|
||||
("linux", "x86_64") => Some(if cfg!(target_env = "musl") {
|
||||
"agent-linux-x64-musl"
|
||||
} else {
|
||||
"agent-linux-x64-glibc"
|
||||
}),
|
||||
("linux", "aarch64") => Some(if cfg!(target_env = "musl") {
|
||||
"agent-linux-aarch64-musl"
|
||||
} else {
|
||||
"agent-linux-aarch64-glibc"
|
||||
}),
|
||||
("windows", "x86_64") => Some("agent-windows-x64.exe"),
|
||||
("macos", "aarch64") => Some("agent-macos-aarch64"),
|
||||
("freebsd", "x86_64") => Some("agent-freebsd-x64"),
|
||||
("freebsd", "aarch64") => Some("agent-freebsd-aarch64"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn executable_format_valid(path: &Path) -> bool {
|
||||
let mut magic = [0_u8; 4];
|
||||
if fs::File::open(path)
|
||||
.and_then(|mut file| file.read_exact(&mut magic))
|
||||
.is_err()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
match env::consts::OS {
|
||||
"linux" | "freebsd" => magic == *b"\x7fELF",
|
||||
"windows" => magic.starts_with(b"MZ"),
|
||||
"macos" => matches!(
|
||||
magic,
|
||||
[0xcf, 0xfa, 0xed, 0xfe]
|
||||
| [0xfe, 0xed, 0xfa, 0xcf]
|
||||
| [0xca, 0xfe, 0xba, 0xbe]
|
||||
| [0xbe, 0xba, 0xfe, 0xca]
|
||||
),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn sha256_file(path: &Path) -> Result<String> {
|
||||
let mut file = fs::File::open(path)?;
|
||||
let mut digest = Sha256::new();
|
||||
let mut buffer = [0_u8; 64 * 1024];
|
||||
loop {
|
||||
let read = file.read(&mut buffer)?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
digest.update(&buffer[..read]);
|
||||
}
|
||||
Ok(digest
|
||||
.finalize()
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub(crate) fn release_asset_sha256(asset: &GithubReleaseAsset) -> Option<String> {
|
||||
let digest = asset.digest.as_deref()?.strip_prefix("sha256:")?;
|
||||
(digest.len() == 64
|
||||
&& digest
|
||||
.chars()
|
||||
.all(|character| character.is_ascii_hexdigit()))
|
||||
.then(|| digest.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
pub(crate) fn download_agent(
|
||||
pub(crate) agent: &ureq::Agent,
|
||||
pub(crate) url: &str,
|
||||
pub(crate) destination: &Path,
|
||||
pub(crate) expected_version: &str,
|
||||
pub(crate) expected_sha256: &str,
|
||||
) -> Result<bool> {
|
||||
let Ok(response) = agent.get(url).call() else {
|
||||
return Ok(false);
|
||||
};
|
||||
let response = response.into_body().into_reader();
|
||||
let mut file = fs::File::create(destination)?;
|
||||
let copied = io::copy(&mut response.take(MAX_AGENT_BINARY_BYTES + 1), &mut file)?;
|
||||
file.flush()?;
|
||||
drop(file);
|
||||
if copied > MAX_AGENT_BINARY_BYTES
|
||||
|| !executable_format_valid(destination)
|
||||
|| sha256_file(destination)? != expected_sha256
|
||||
{
|
||||
let _ = fs::remove_file(destination);
|
||||
return Ok(false);
|
||||
}
|
||||
#[cfg(unix)]
|
||||
fs::set_permissions(destination, fs::Permissions::from_mode(0o755))?;
|
||||
|
||||
let downloaded_version = Command::new(destination)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|output| output.status.success())
|
||||
.and_then(|output| {
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
.split_whitespace()
|
||||
.next_back()
|
||||
.map(str::to_string)
|
||||
});
|
||||
let valid = downloaded_version.as_deref().map(normalized_version)
|
||||
== Some(normalized_version(expected_version));
|
||||
if !valid {
|
||||
let _ = fs::remove_file(destination);
|
||||
}
|
||||
Ok(valid)
|
||||
}
|
||||
|
||||
pub(crate) fn mirrored_download_url(mirror: &str, url: &str) -> String {
|
||||
let mirror = mirror.trim().trim_end_matches('/');
|
||||
if mirror.is_empty() {
|
||||
url.to_string()
|
||||
} else {
|
||||
format!("{mirror}/{url}")
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn update(agent: &ureq::Agent, mirror: &str) -> Result<bool> {
|
||||
let mut response = agent
|
||||
.get(LATEST_RELEASE_API)
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.header("User-Agent", format!("nodeflare-agent/{VERSION}"))
|
||||
.call()?;
|
||||
let release = response.body_mut().read_json::<GithubRelease>()?;
|
||||
let Some(remote_version) = version_triplet(&release.tag_name) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let Some(current_version) = version_triplet(VERSION) else {
|
||||
return Ok(false);
|
||||
};
|
||||
if remote_version <= current_version {
|
||||
return Ok(false);
|
||||
}
|
||||
let Some(artifact) = agent_artifact_name() else {
|
||||
return Ok(false);
|
||||
};
|
||||
let current = env::current_exe()?;
|
||||
let temporary = current.with_file_name(format!(
|
||||
".nodeflare-agent.{}.download{}",
|
||||
std::process::id(),
|
||||
if cfg!(target_os = "windows") {
|
||||
".exe"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
));
|
||||
let Some(release_asset) = release.assets.iter().find(|asset| asset.name == artifact) else {
|
||||
return Err(format!("latest release does not contain {artifact}").into());
|
||||
};
|
||||
let Some(expected_sha256) = release_asset_sha256(release_asset) else {
|
||||
return Err(
|
||||
format!("latest release does not contain a SHA-256 digest for {artifact}").into(),
|
||||
);
|
||||
};
|
||||
if !download_agent(
|
||||
agent,
|
||||
&mirrored_download_url(mirror, &release_asset.browser_download_url),
|
||||
&temporary,
|
||||
&release.tag_name,
|
||||
&expected_sha256,
|
||||
)? {
|
||||
return Err("downloaded agent version does not match the configured version".into());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
fs::rename(&temporary, ¤t)?;
|
||||
let error = Command::new(¤t).args(env::args_os().skip(1)).exec();
|
||||
Err(error.into())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let script = concat!(
|
||||
"$ErrorActionPreference='Stop'; ",
|
||||
"$targetPid=[int]$env:NODEFLARE_UPDATE_PID; ",
|
||||
"Wait-Process -Id $targetPid; ",
|
||||
"Move-Item -LiteralPath $env:NODEFLARE_UPDATE_NEW ",
|
||||
"-Destination $env:NODEFLARE_UPDATE_CURRENT -Force; ",
|
||||
"try { Start-ScheduledTask -TaskName 'nodeflare-agent' -ErrorAction Stop } ",
|
||||
"catch { $restartArgs=@($env:NODEFLARE_UPDATE_ARGS | ConvertFrom-Json); ",
|
||||
"Start-Process -FilePath $env:NODEFLARE_UPDATE_CURRENT -ArgumentList $restartArgs }"
|
||||
);
|
||||
Command::new("powershell.exe")
|
||||
.args([
|
||||
"-NoLogo",
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
script,
|
||||
])
|
||||
.env("NODEFLARE_UPDATE_PID", std::process::id().to_string())
|
||||
.env("NODEFLARE_UPDATE_NEW", &temporary)
|
||||
.env("NODEFLARE_UPDATE_CURRENT", ¤t)
|
||||
.env(
|
||||
"NODEFLARE_UPDATE_ARGS",
|
||||
serde_json::to_string(&env::args().skip(1).collect::<Vec<_>>())?,
|
||||
)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()?;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
Regular → Executable
+80
-14
@@ -56,6 +56,50 @@ pub struct SessionIdentity {
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
/// Rewrites `?` placeholders to `$1`, `$2`, … while skipping quoted spans.
|
||||
fn translate_placeholders(statement: &str) -> String {
|
||||
let mut output = String::with_capacity(statement.len() + 16);
|
||||
let mut index = 0_u32;
|
||||
let mut characters = statement.chars().peekable();
|
||||
let mut single_quoted = false;
|
||||
let mut double_quoted = false;
|
||||
while let Some(character) = characters.next() {
|
||||
if single_quoted || double_quoted {
|
||||
output.push(character);
|
||||
let delimiter = if single_quoted { '\'' } else { '"' };
|
||||
if character == delimiter {
|
||||
// A doubled delimiter is an escape, not the end of the span.
|
||||
if characters.peek() == Some(&delimiter) {
|
||||
if let Some(escaped) = characters.next() {
|
||||
output.push(escaped);
|
||||
}
|
||||
} else {
|
||||
single_quoted = false;
|
||||
double_quoted = false;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match character {
|
||||
'\'' => {
|
||||
single_quoted = true;
|
||||
output.push(character);
|
||||
}
|
||||
'"' => {
|
||||
double_quoted = true;
|
||||
output.push(character);
|
||||
}
|
||||
'?' => {
|
||||
index += 1;
|
||||
output.push('$');
|
||||
output.push_str(&index.to_string());
|
||||
}
|
||||
_ => output.push(character),
|
||||
}
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
impl Database {
|
||||
pub fn pool(&self) -> &AnyPool {
|
||||
&self.pool
|
||||
@@ -98,6 +142,8 @@ impl Database {
|
||||
kind: self.kind.as_str().to_string(),
|
||||
size_bytes,
|
||||
reclaimable_bytes,
|
||||
// The database layer cannot see pending restarts; routes fill this
|
||||
// in from the activity state (see routes/admin.rs database_stats).
|
||||
restart_required: false,
|
||||
})
|
||||
}
|
||||
@@ -164,6 +210,11 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Translates SQLite-style `?` placeholders to PostgreSQL `$N`.
|
||||
///
|
||||
/// Placeholders inside single-quoted literals or double-quoted identifiers
|
||||
/// are left untouched, so a statement such as `WHERE label='?'` cannot
|
||||
/// silently shift the numbering of the real parameters.
|
||||
pub fn sql(&self, statement: &'static str) -> &'static str {
|
||||
if self.kind == DatabaseKind::Sqlite {
|
||||
return statement;
|
||||
@@ -174,18 +225,7 @@ impl Database {
|
||||
if let Some(value) = translated.get(statement) {
|
||||
return value;
|
||||
}
|
||||
let mut index = 0;
|
||||
let mut output = String::with_capacity(statement.len() + 16);
|
||||
for character in statement.chars() {
|
||||
if character == '?' {
|
||||
index += 1;
|
||||
output.push('$');
|
||||
output.push_str(&index.to_string());
|
||||
} else {
|
||||
output.push(character);
|
||||
}
|
||||
}
|
||||
let output = Box::leak(output.into_boxed_str());
|
||||
let output = Box::leak(translate_placeholders(statement).into_boxed_str());
|
||||
translated.insert(statement, output);
|
||||
output
|
||||
}
|
||||
@@ -553,6 +593,7 @@ pub async fn update_settings(
|
||||
pool: &Database,
|
||||
input: &SettingsInput,
|
||||
password_hash: Option<&str>,
|
||||
totp_rename: Option<(&str, &str)>,
|
||||
) -> Result<()> {
|
||||
let mut updates = Vec::<(&str, String)>::new();
|
||||
macro_rules! text {
|
||||
@@ -648,6 +689,18 @@ pub async fn update_settings(
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
// The TOTP record must follow the admin rename atomically; otherwise a
|
||||
// failure between the two writes would leave login unable to find the
|
||||
// secret and silently skip two-factor verification.
|
||||
if let Some((previous, next)) = totp_rename
|
||||
&& previous != next
|
||||
{
|
||||
sqlx::query(pool.sql("UPDATE admin_2fa SET username=? WHERE username=?"))
|
||||
.bind(next)
|
||||
.bind(previous)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
transaction.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -885,7 +938,7 @@ mod tests {
|
||||
"turnstile_login_enabled": false
|
||||
}))
|
||||
.unwrap();
|
||||
update_settings(&db, &input, None).await.unwrap();
|
||||
update_settings(&db, &input, None, None).await.unwrap();
|
||||
assert_eq!(
|
||||
get_setting(&db, "turnstile_site_key")
|
||||
.await
|
||||
@@ -905,7 +958,7 @@ mod tests {
|
||||
"turnstile_site_key": "new-site", "turnstile_secret_key": "new-secret"
|
||||
}))
|
||||
.unwrap();
|
||||
update_settings(&db, &input, None).await.unwrap();
|
||||
update_settings(&db, &input, None, None).await.unwrap();
|
||||
assert_eq!(
|
||||
get_setting(&db, "turnstile_secret_key")
|
||||
.await
|
||||
@@ -915,6 +968,19 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placeholder_translation_skips_quoted_spans() {
|
||||
assert_eq!(
|
||||
translate_placeholders("SELECT '?' , ? FROM t WHERE a=? AND b='it''s ?'"),
|
||||
"SELECT '?' , $1 FROM t WHERE a=$2 AND b='it''s ?'"
|
||||
);
|
||||
assert_eq!(
|
||||
translate_placeholders("SELECT \"weird?col\" FROM t WHERE a=?"),
|
||||
"SELECT \"weird?col\" FROM t WHERE a=$1"
|
||||
);
|
||||
assert_eq!(translate_placeholders("SELECT 1"), "SELECT 1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_file_pool_configures_every_connection() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
|
||||
Regular → Executable
+129
-38
@@ -18,6 +18,9 @@ const AGENT_REPORT_MAX_GPUS: usize = 32;
|
||||
const AGENT_REPORT_MAX_LATENCY_RESULTS: usize = 2048;
|
||||
const HISTORY_INSERT_BATCH_ROWS: usize = 500;
|
||||
const LATENCY_INSERT_BATCH_ROWS: usize = 500;
|
||||
// Kept small enough that a 3-column batch stays under the legacy SQLite
|
||||
// 999-variable limit as well as PostgreSQL's 65535-parameter limit.
|
||||
const ASSIGNMENT_INSERT_BATCH_ROWS: usize = 200;
|
||||
const CLEANUP_DELETE_BATCH_ROWS: i64 = 1000;
|
||||
const CLEANUP_MAX_PASSES: usize = 10;
|
||||
const CLEANUP_TIME_BUDGET: std::time::Duration = std::time::Duration::from_secs(2);
|
||||
@@ -532,11 +535,19 @@ pub async fn reorder_servers(db: &Database, ids: &[String]) -> Result<()> {
|
||||
|
||||
pub async fn agent_install_token(db: &Database, id: &str) -> Result<Option<String>> {
|
||||
let token = auth::random_token(32);
|
||||
// The existence check and the insert share one transaction so a server
|
||||
// deleted in between cannot turn a missing node into a foreign-key 500.
|
||||
let mut transaction = if db.is_postgres() {
|
||||
db.pool().begin().await?
|
||||
} else {
|
||||
db.pool().begin_with("BEGIN IMMEDIATE").await?
|
||||
};
|
||||
let exists = sqlx::query_scalar::<_, i64>(db.sql("SELECT COUNT(*) FROM servers WHERE id=?"))
|
||||
.bind(id)
|
||||
.fetch_one(db.pool())
|
||||
.fetch_one(&mut *transaction)
|
||||
.await?;
|
||||
if exists == 0 {
|
||||
transaction.rollback().await?;
|
||||
return Ok(None);
|
||||
}
|
||||
sqlx::query(db.sql(
|
||||
@@ -545,8 +556,9 @@ pub async fn agent_install_token(db: &Database, id: &str) -> Result<Option<Strin
|
||||
.bind(auth::token_hash(&token))
|
||||
.bind(id)
|
||||
.bind(now())
|
||||
.execute(db.pool())
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
transaction.commit().await?;
|
||||
Ok(Some(token))
|
||||
}
|
||||
|
||||
@@ -912,12 +924,30 @@ async fn save_traffic_state(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Builds one row's `($1,$2,…)` value group, advancing the shared counter.
|
||||
///
|
||||
/// PostgreSQL needs numbered placeholders while SQLite (and the `Any` driver
|
||||
/// on it) uses `?`, so batch inserts cannot use a single static statement.
|
||||
fn placeholder_group(db: &Database, parameter_index: &mut usize, columns: usize) -> String {
|
||||
let mut placeholders = Vec::with_capacity(columns);
|
||||
for _ in 0..columns {
|
||||
if db.is_postgres() {
|
||||
placeholders.push(format!("${}", *parameter_index));
|
||||
*parameter_index += 1;
|
||||
} else {
|
||||
placeholders.push("?".to_string());
|
||||
}
|
||||
}
|
||||
format!("({})", placeholders.join(","))
|
||||
}
|
||||
|
||||
async fn save_history_rows(
|
||||
db: &Database,
|
||||
transaction: &mut Transaction<'_, Any>,
|
||||
server_id: &str,
|
||||
rows: &[HistoryRow],
|
||||
) -> Result<()> {
|
||||
let merge_sql = history_merge_sql();
|
||||
for batch in rows.chunks(HISTORY_INSERT_BATCH_ROWS) {
|
||||
let mut arguments = AnyArguments::default();
|
||||
let mut parameter_index = 1_usize;
|
||||
@@ -993,7 +1023,7 @@ async fn save_history_rows(
|
||||
ON CONFLICT(server_id, timestamp) DO UPDATE SET {}",
|
||||
HISTORY_AGGREGATE_COLUMNS.join(","),
|
||||
value_groups.join(","),
|
||||
history_merge_sql(),
|
||||
merge_sql,
|
||||
);
|
||||
sqlx::query_with(AssertSqlSafe(sql), arguments)
|
||||
.execute(&mut **transaction)
|
||||
@@ -1542,15 +1572,30 @@ async fn replace_task_servers(
|
||||
.bind(task_id)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
for server_id in server_ids {
|
||||
sqlx::query(db.sql(
|
||||
"INSERT INTO latency_task_servers(task_id, server_id, assigned_at) VALUES (?, ?, ?)",
|
||||
))
|
||||
.bind(task_id)
|
||||
.bind(server_id)
|
||||
.bind(existing.get(server_id).copied().unwrap_or(timestamp))
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
for batch in server_ids.chunks(ASSIGNMENT_INSERT_BATCH_ROWS) {
|
||||
let mut arguments = AnyArguments::default();
|
||||
let mut parameter_index = 1_usize;
|
||||
let mut groups = Vec::with_capacity(batch.len());
|
||||
for server_id in batch {
|
||||
groups.push(placeholder_group(db, &mut parameter_index, 3));
|
||||
let assigned_at = existing.get(server_id).copied().unwrap_or(timestamp);
|
||||
arguments
|
||||
.add(task_id.to_string())
|
||||
.map_err(|error| anyhow::anyhow!("无法编码拨测任务节点:{error}"))?;
|
||||
arguments
|
||||
.add(server_id.clone())
|
||||
.map_err(|error| anyhow::anyhow!("无法编码拨测任务节点:{error}"))?;
|
||||
arguments
|
||||
.add(assigned_at)
|
||||
.map_err(|error| anyhow::anyhow!("无法编码拨测任务节点:{error}"))?;
|
||||
}
|
||||
let sql = format!(
|
||||
"INSERT INTO latency_task_servers(task_id, server_id, assigned_at) VALUES {}",
|
||||
groups.join(",")
|
||||
);
|
||||
sqlx::query_with(AssertSqlSafe(sql), arguments)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1740,10 +1785,24 @@ async fn replace_alert_servers(
|
||||
.bind(rule_id)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
for server_id in server_ids {
|
||||
sqlx::query(db.sql("INSERT INTO alert_rule_servers(rule_id, server_id) VALUES (?, ?)"))
|
||||
.bind(rule_id)
|
||||
.bind(server_id)
|
||||
for batch in server_ids.chunks(ASSIGNMENT_INSERT_BATCH_ROWS) {
|
||||
let mut arguments = AnyArguments::default();
|
||||
let mut parameter_index = 1_usize;
|
||||
let mut groups = Vec::with_capacity(batch.len());
|
||||
for server_id in batch {
|
||||
groups.push(placeholder_group(db, &mut parameter_index, 2));
|
||||
arguments
|
||||
.add(rule_id.to_string())
|
||||
.map_err(|error| anyhow::anyhow!("无法编码告警规则节点:{error}"))?;
|
||||
arguments
|
||||
.add(server_id.clone())
|
||||
.map_err(|error| anyhow::anyhow!("无法编码告警规则节点:{error}"))?;
|
||||
}
|
||||
let sql = format!(
|
||||
"INSERT INTO alert_rule_servers(rule_id, server_id) VALUES {}",
|
||||
groups.join(",")
|
||||
);
|
||||
sqlx::query_with(AssertSqlSafe(sql), arguments)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
}
|
||||
@@ -1769,17 +1828,37 @@ pub async fn evaluate_resource_rules(
|
||||
db: &Database,
|
||||
server_id: &str,
|
||||
) -> Result<Vec<ResourceAlertEvaluation>> {
|
||||
let rules = list_alert_rules(db).await?;
|
||||
let report_interval =
|
||||
sqlx::query_scalar::<_, i64>(db.sql("SELECT report_interval FROM servers WHERE id=?"))
|
||||
.bind(server_id)
|
||||
.fetch_optional(db.pool())
|
||||
.await?
|
||||
.unwrap_or(60);
|
||||
// Load only the rules that apply to this server instead of the full rule
|
||||
// table plus every assignment row on every evaluation pass; this runs per
|
||||
// persisted batch, so the previous full scan scaled badly with fleet size.
|
||||
let rows = sqlx::query(db.sql(
|
||||
"SELECT id, name, metric, threshold, duration_minutes, aggregation, all_servers \
|
||||
FROM alert_rules WHERE enabled=1 \
|
||||
AND (all_servers=1 OR id IN (SELECT rule_id FROM alert_rule_servers WHERE server_id=?)) \
|
||||
ORDER BY created_at",
|
||||
))
|
||||
.bind(server_id)
|
||||
.fetch_all(db.pool())
|
||||
.await?;
|
||||
let mut evaluations = Vec::new();
|
||||
for rule in rules.into_iter().filter(|rule| {
|
||||
rule.enabled && (rule.all_servers || rule.server_ids.iter().any(|id| id == server_id))
|
||||
}) {
|
||||
for row in rows {
|
||||
let rule = AlertRuleView {
|
||||
server_ids: Vec::new(),
|
||||
id: row.try_get("id")?,
|
||||
name: row.try_get("name")?,
|
||||
metric: row.try_get("metric")?,
|
||||
threshold: row.try_get("threshold")?,
|
||||
duration_minutes: row.try_get("duration_minutes")?,
|
||||
aggregation: row.try_get("aggregation")?,
|
||||
all_servers: row.try_get::<i64, _>("all_servers")? != 0,
|
||||
enabled: true,
|
||||
};
|
||||
let (average_expression, minimum_expression) = match rule.metric.as_str() {
|
||||
"cpu" => ("cpu", "cpu_min"),
|
||||
"memory" => ("memory_avg", "memory_min"),
|
||||
@@ -2006,18 +2085,6 @@ pub async fn set_totp_enabled(db: &Database, username: &str, enabled: bool) -> R
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
pub async fn rename_totp_user(db: &Database, old: &str, new: &str) -> Result<()> {
|
||||
if old == new {
|
||||
return Ok(());
|
||||
}
|
||||
sqlx::query(db.sql("UPDATE admin_2fa SET username=? WHERE username=?"))
|
||||
.bind(new)
|
||||
.bind(old)
|
||||
.execute(db.pool())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn create_remote_task(
|
||||
db: &Database,
|
||||
server_id: &str,
|
||||
@@ -2290,16 +2357,40 @@ pub async fn set_active_theme(db: &Database, id: &str) -> Result<bool> {
|
||||
}
|
||||
|
||||
pub async fn delete_theme(db: &Database, id: &str) -> Result<bool> {
|
||||
// Deleting the theme and resetting active_theme_id must be atomic; a failure
|
||||
// between them would leave the active theme pointing at a deleted row.
|
||||
let mut transaction = if db.is_postgres() {
|
||||
db.pool().begin().await?
|
||||
} else {
|
||||
db.pool().begin_with("BEGIN IMMEDIATE").await?
|
||||
};
|
||||
let result = sqlx::query(db.sql("DELETE FROM themes WHERE id=?"))
|
||||
.bind(id)
|
||||
.execute(db.pool())
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
if result.rows_affected() > 0
|
||||
&& super::get_setting(db, "active_theme_id").await?.as_deref() == Some(id)
|
||||
{
|
||||
super::set_setting(db, "active_theme_id", "builtin-nodeflare-glass").await?;
|
||||
if result.rows_affected() == 0 {
|
||||
transaction.rollback().await?;
|
||||
return Ok(false);
|
||||
}
|
||||
Ok(result.rows_affected() > 0)
|
||||
let active = sqlx::query_scalar::<_, String>(
|
||||
db.sql("SELECT value FROM settings WHERE key=? AND value=?"),
|
||||
)
|
||||
.bind("active_theme_id")
|
||||
.bind(id)
|
||||
.fetch_optional(&mut *transaction)
|
||||
.await?;
|
||||
if active.is_some() {
|
||||
sqlx::query(db.sql(
|
||||
"INSERT INTO settings(key, value) VALUES (?, ?) \
|
||||
ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
||||
))
|
||||
.bind("active_theme_id")
|
||||
.bind("builtin-nodeflare-glass")
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
transaction.commit().await?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub async fn create_theme_preview(db: &Database, theme_id: &str) -> Result<String> {
|
||||
|
||||
Regular → Executable
+6
@@ -45,6 +45,7 @@ pub struct AppState {
|
||||
pub restart_tx: watch::Sender<bool>,
|
||||
pub login_attempts: security::AttemptLimiter,
|
||||
pub sensitive_attempts: security::AttemptLimiter,
|
||||
pub turnstile_attempts: security::AttemptLimiter,
|
||||
pub password_verifications: Arc<Semaphore>,
|
||||
}
|
||||
|
||||
@@ -186,6 +187,11 @@ async fn main() -> Result<()> {
|
||||
std::time::Duration::from_secs(5 * 60),
|
||||
std::time::Duration::from_secs(10 * 60),
|
||||
),
|
||||
turnstile_attempts: security::AttemptLimiter::new(
|
||||
10,
|
||||
std::time::Duration::from_secs(5 * 60),
|
||||
std::time::Duration::from_secs(5 * 60),
|
||||
),
|
||||
password_verifications: Arc::new(Semaphore::new(4)),
|
||||
});
|
||||
|
||||
|
||||
Regular → Executable
+11
-1
@@ -299,7 +299,17 @@ async fn send(
|
||||
}
|
||||
let value: Value = serde_json::from_slice(&body)?;
|
||||
if value.get("ok").and_then(Value::as_bool) != Some(true) {
|
||||
anyhow::bail!("Telegram rejected the message");
|
||||
// Surface Telegram's error_code/description (e.g. "chat not found",
|
||||
// "bot was blocked by the user") so admins can act on the failure;
|
||||
// neither field contains the bot token.
|
||||
let description = value
|
||||
.get("description")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("no description");
|
||||
match value.get("error_code").and_then(Value::as_i64) {
|
||||
Some(code) => anyhow::bail!("Telegram rejected the message (error {code}): {description}"),
|
||||
None => anyhow::bail!("Telegram rejected the message: {description}"),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Regular → Executable
+22
-8
@@ -192,12 +192,17 @@ pub async fn settings_patch(
|
||||
if !username_changed {
|
||||
input.admin_username = None;
|
||||
}
|
||||
crate::db::update_settings(&state.db, &input, password_hash.as_deref())
|
||||
.await
|
||||
.map_err(ApiResponse::internal)?;
|
||||
crate::db::queries::rename_totp_user(&state.db, &user.username, &next_username)
|
||||
.await
|
||||
.map_err(ApiResponse::internal)?;
|
||||
// The TOTP rename is folded into the same transaction as the settings
|
||||
// update so a mid-way failure cannot leave 2FA looking up a stale username.
|
||||
let totp_rename = username_changed.then(|| (user.username.as_str(), next_username.as_str()));
|
||||
crate::db::update_settings(
|
||||
&state.db,
|
||||
&input,
|
||||
password_hash.as_deref(),
|
||||
totp_rename,
|
||||
)
|
||||
.await
|
||||
.map_err(ApiResponse::internal)?;
|
||||
let credentials_changed = password_hash.is_some() || username_changed;
|
||||
let token = if credentials_changed {
|
||||
Some(
|
||||
@@ -707,8 +712,17 @@ pub async fn database_migrate(
|
||||
.await
|
||||
.map_err(|error| ApiResponse::unprocessable(format!("迁移失败:{error}")))?;
|
||||
let database = target.stats().await.map_err(ApiResponse::internal)?;
|
||||
crate::config::update_database_url(&state.config_path, &target_url)
|
||||
.map_err(ApiResponse::internal)?;
|
||||
crate::config::update_database_url(&state.config_path, &target_url).map_err(|error| {
|
||||
// The copy already succeeded, so a bare 500 would make the admin think
|
||||
// nothing happened and possibly retry against a half-switched setup.
|
||||
tracing::error!(%error, "database migration copied data but the configuration update failed");
|
||||
ApiResponse::error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!(
|
||||
"目标数据库已写入数据,但配置更新失败({error});请手动修改 database_url 后重启面板"
|
||||
),
|
||||
)
|
||||
})?;
|
||||
state.database_activity.require_restart();
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
|
||||
Regular → Executable
+11
-1
@@ -174,6 +174,15 @@ pub async fn verify_turnstile(
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<TurnstileVerifyRequest>,
|
||||
) -> Result<Response, ApiResponse> {
|
||||
// This endpoint is public and each call triggers an outbound request to
|
||||
// Cloudflare, so rate-limit per IP to stop it being used as an amplifier.
|
||||
let client_ip = client_ip(&headers, peer, &state.config.trusted_proxies);
|
||||
if let Some(seconds) = state.turnstile_attempts.retry_after(&client_ip) {
|
||||
return Err(ApiResponse::error(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
format!("人机验证尝试过多,请在 {seconds} 秒后重试"),
|
||||
));
|
||||
}
|
||||
let settings = crate::db::load_settings(&state.db)
|
||||
.await
|
||||
.map_err(ApiResponse::internal)?;
|
||||
@@ -181,7 +190,6 @@ pub async fn verify_turnstile(
|
||||
return Err(ApiResponse::bad_request("公开仪表盘未启用人机验证"));
|
||||
}
|
||||
let host = hostname(&headers).ok_or_else(|| ApiResponse::bad_request("请求主机名无效"))?;
|
||||
let client_ip = client_ip(&headers, peer, &state.config.trusted_proxies);
|
||||
if !crate::turnstile::verify(
|
||||
&state.http,
|
||||
&input.token,
|
||||
@@ -192,8 +200,10 @@ pub async fn verify_turnstile(
|
||||
)
|
||||
.await
|
||||
{
|
||||
state.turnstile_attempts.record_failure(&client_ip);
|
||||
return Err(ApiResponse::forbidden("Cloudflare 人机验证失败,请重试"));
|
||||
}
|
||||
state.turnstile_attempts.clear(&client_ip);
|
||||
let proof = crate::db::create_dashboard_proof(&state.db)
|
||||
.await
|
||||
.map_err(ApiResponse::internal)?;
|
||||
|
||||
Regular → Executable
+22
-15
@@ -199,24 +199,31 @@ async fn require_public_server(state: &AppState, server_id: &str) -> Result<(),
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fields that must never reach the unauthenticated dashboard.
|
||||
///
|
||||
/// This is a deny list, so a new `ServerView` field is public until added here;
|
||||
/// prefer inverting it into an explicit allow list when the public projection is
|
||||
/// next reworked (it needs the frontend's field usage verified first).
|
||||
const PRIVATE_SERVER_FIELDS: [&str; 13] = [
|
||||
"hidden",
|
||||
"last_ip",
|
||||
"ip_v4",
|
||||
"ip_v6",
|
||||
"network_interface",
|
||||
"reset_day",
|
||||
"report_interval",
|
||||
"collect_interval",
|
||||
"rx_correction",
|
||||
"tx_correction",
|
||||
"agent_mirror",
|
||||
"offline_notify_disabled",
|
||||
"auto_update",
|
||||
];
|
||||
|
||||
fn public_server(server: crate::models::ServerView) -> serde_json::Value {
|
||||
let mut value = serde_json::to_value(server).unwrap_or_else(|_| serde_json::json!({}));
|
||||
if let Some(object) = value.as_object_mut() {
|
||||
for key in [
|
||||
"hidden",
|
||||
"last_ip",
|
||||
"ip_v4",
|
||||
"ip_v6",
|
||||
"network_interface",
|
||||
"reset_day",
|
||||
"report_interval",
|
||||
"collect_interval",
|
||||
"rx_correction",
|
||||
"tx_correction",
|
||||
"agent_mirror",
|
||||
"offline_notify_disabled",
|
||||
"auto_update",
|
||||
] {
|
||||
for key in PRIVATE_SERVER_FIELDS {
|
||||
object.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
Regular → Executable
+5
@@ -139,6 +139,11 @@ pub async fn get_task(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Response, ApiResponse> {
|
||||
// Remote task IDs are UUIDs; validate the path parameter for consistency
|
||||
// with the other ID endpoints instead of sending arbitrary input to the DB.
|
||||
if id.len() > 80 || uuid::Uuid::parse_str(&id).is_err() {
|
||||
return Err(ApiResponse::bad_request("任务 ID 无效"));
|
||||
}
|
||||
let task = crate::db::queries::remote_task(&state.db, &id)
|
||||
.await
|
||||
.map_err(ApiResponse::internal)?
|
||||
|
||||
Regular → Executable
+19
-12
@@ -20,6 +20,24 @@ use tokio::sync::mpsc;
|
||||
|
||||
const MAX_AGENT_MESSAGE_BYTES: usize = 2 * 1024 * 1024;
|
||||
const MAX_TASK_RESULT_BYTES: usize = 1024 * 1024;
|
||||
/// Static identity fields the public dashboard already receives through
|
||||
/// `/api/bootstrap`, so the live stream omits them.
|
||||
///
|
||||
/// This is a deny list, so a new `Report` field is broadcast until added here;
|
||||
/// prefer inverting it into an explicit allow list when the sample shape is next
|
||||
/// reworked (verify the frontend's usage first).
|
||||
const LIVE_SAMPLE_PRIVATE_FIELDS: [&str; 10] = [
|
||||
"timestamp",
|
||||
"cpu_model",
|
||||
"os",
|
||||
"kernel",
|
||||
"arch",
|
||||
"virtualization",
|
||||
"gpu_model",
|
||||
"agent_version",
|
||||
"ip_v4",
|
||||
"ip_v6",
|
||||
];
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -406,18 +424,7 @@ fn broadcast_reports(state: &AppState, identity: &AgentIdentity, reports: &[Agen
|
||||
.map(|report| {
|
||||
let mut data = serde_json::to_value(report).unwrap_or_else(|_| serde_json::json!({}));
|
||||
if let Some(object) = data.as_object_mut() {
|
||||
for key in [
|
||||
"timestamp",
|
||||
"cpu_model",
|
||||
"os",
|
||||
"kernel",
|
||||
"arch",
|
||||
"virtualization",
|
||||
"gpu_model",
|
||||
"agent_version",
|
||||
"ip_v4",
|
||||
"ip_v6",
|
||||
] {
|
||||
for key in LIVE_SAMPLE_PRIVATE_FIELDS {
|
||||
object.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
Executable
+273
@@ -0,0 +1,273 @@
|
||||
# NodeFlare
|
||||
|
||||
NodeFlare is a lightweight, self-hosted server monitoring panel: view server status in a web UI while a small agent collects and pushes telemetry. It supports live metrics, TCP/ICMP probing, Telegram alerts, remote command execution, TOTP two-factor authentication, theme customization, and SQLite / PostgreSQL backup, restore, and online migration.
|
||||
|
||||
Chinese: [README.md](../README.md)
|
||||
|
||||
- [Features](#features)
|
||||
- [Supported platforms](#supported-platforms)
|
||||
- [Install the server](#install-the-server)
|
||||
- [Install the agent](#install-the-agent)
|
||||
- [Metrics and sampling](#metrics-and-sampling)
|
||||
- [Configuration](#configuration)
|
||||
- [Default paths and logs](#default-paths-and-logs)
|
||||
- [Reverse proxy](#reverse-proxy)
|
||||
- [Database and backups](#database-and-backups)
|
||||
- [Development](#development)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
## Features
|
||||
|
||||
**Monitoring**
|
||||
|
||||
- CPU, memory, and network speed are sampled every second and uploaded in compressed batches every 3 seconds by default; slower metrics such as disks, GPU, and connection counts are cached separately
|
||||
- Latency probing: TCP and ICMP tasks assigned per node, with per-carrier (China Telecom / Mobile / Unicom) display
|
||||
- Public dashboard in Simplified Chinese and English; site name, announcement, logo, background image, and visible sections are configurable
|
||||
- Theme store: built-in themes, one-click install from the repository, local ZIP upload, and per-theme settings
|
||||
|
||||
**Alerts**
|
||||
|
||||
- Resource threshold rules for CPU / memory / disk / inbound / outbound, using averages or continuous breach with a configurable duration
|
||||
- Offline, expiry, and traffic alerts delivered through Telegram with a customizable message template
|
||||
|
||||
**Management**
|
||||
|
||||
- Node grouping, tags, region, billing cycle, price, expiry date, traffic quota, and reset day
|
||||
- Remote command execution: requires TOTP; a command runs for up to 10 minutes and is not cancelled when you leave the page
|
||||
- Daily exchange-rate snapshot for multi-currency pricing
|
||||
|
||||
**Security**
|
||||
|
||||
- TOTP two-factor authentication; Cloudflare Turnstile (can protect admin login and the public dashboard independently)
|
||||
- Login rate limiting, session management (list login devices and revoke them), configurable session lifetime
|
||||
- Listens on `127.0.0.1` by default; the agent connects outbound, so no inbound port is required; the agent token can be passed as a file or an environment variable
|
||||
|
||||
**Data**
|
||||
|
||||
- SQLite and PostgreSQL, with online migration between them
|
||||
- One-click ZIP backup / restore, space reclamation, and automatic cleanup of history by retention days
|
||||
|
||||
## Supported platforms
|
||||
|
||||
| Role | Platform | Architecture |
|
||||
| --- | --- | --- |
|
||||
| Server | Linux | x64 / ARM64 |
|
||||
| Server | Windows | x64 |
|
||||
| Server | macOS | ARM64 |
|
||||
| Server | FreeBSD 13+ | x64 / ARM64 |
|
||||
| Agent | Linux / Windows / macOS / FreeBSD | Same as above (macOS is Apple Silicon only) |
|
||||
|
||||
The installer detects the service manager (systemd / OpenRC / launchd / FreeBSD rc / Windows scheduled task) and registers the panel to start on boot.
|
||||
|
||||
## Install the server
|
||||
|
||||
The installer downloads the latest release and registers the system service.
|
||||
|
||||
Linux / macOS:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/elysia62/NodeFlare/main/install.sh | sudo sh
|
||||
```
|
||||
|
||||
FreeBSD:
|
||||
|
||||
```sh
|
||||
fetch -qo - https://raw.githubusercontent.com/elysia62/NodeFlare/main/install.sh | sudo sh
|
||||
```
|
||||
|
||||
Windows PowerShell (administrator):
|
||||
|
||||
```powershell
|
||||
Invoke-WebRequest -UseBasicParsing https://raw.githubusercontent.com/elysia62/NodeFlare/main/install.ps1 -OutFile "$env:TEMP\nodeflare-install.ps1"
|
||||
Unblock-File "$env:TEMP\nodeflare-install.ps1"
|
||||
& "$env:TEMP\nodeflare-install.ps1"
|
||||
```
|
||||
|
||||
The first install asks for the admin username, password, listening port (default 2206), and database URL (SQLite by default). The panel listens on `127.0.0.1:2206`, so open `http://127.0.0.1:2206/admin/login` locally; public access requires an HTTPS reverse proxy, see [Reverse proxy](#reverse-proxy).
|
||||
|
||||
The admin password is only needed to initialize the database for the first time; it is cleared from the configuration afterwards.
|
||||
|
||||
### Installer options
|
||||
|
||||
| Command | Description |
|
||||
| --- | --- |
|
||||
| `sudo sh install.sh` | Interactive menu |
|
||||
| `sudo sh install.sh --install` | Install or update |
|
||||
| `sudo sh install.sh --status` | Show service status |
|
||||
| `sudo sh install.sh --restart` | Restart the service |
|
||||
| `sudo sh install.sh --uninstall` | Uninstall, keeping configuration and data |
|
||||
| `sudo sh install.sh --uninstall --purge` | Uninstall and delete configuration and data |
|
||||
|
||||
On Windows the equivalent options are `-Install` / `-Status` / `-Restart` / `-Uninstall [-Purge]`.
|
||||
|
||||
To update, run the installer again; configuration and data are preserved. Installs and updates verify the release digest and roll back to the previous version on failure.
|
||||
|
||||
## Install the agent
|
||||
|
||||
Create a node on the "Servers" page in the admin panel and run the command shown in the dialog. The agent connects outbound to the panel, so no inbound port is required.
|
||||
|
||||
Linux:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/elysia62/NodeFlare/main/agent/agent.sh \
|
||||
| sudo sh -s -- -e 'https://nodeflare.example.com' -t 'Agent Token'
|
||||
```
|
||||
|
||||
Per-platform scripts:
|
||||
|
||||
| Platform | Script | Service |
|
||||
| --- | --- | --- |
|
||||
| Linux | `agent/agent.sh` | systemd / OpenRC |
|
||||
| macOS (Apple Silicon) | `agent/install-macos.sh` | launchd |
|
||||
| FreeBSD | `agent/install-freebsd.sh` | rc.d |
|
||||
| Windows | `agent/install.ps1` | Scheduled task |
|
||||
|
||||
### Agent options
|
||||
|
||||
| Option | Description |
|
||||
| --- | --- |
|
||||
| `-e` | NodeFlare endpoint (required) |
|
||||
| `-t` | Agent token (required) |
|
||||
| `-i` | Initial history interval in seconds, 15–3600 (default 60) |
|
||||
| `-m` | Optional GitHub download mirror prefix, e.g. `https://ghproxy.net` |
|
||||
| `--update` | Update the agent, reusing the saved endpoint and token, verifying the digest and rolling back on failure |
|
||||
| `--status` | Show agent status |
|
||||
| `--uninstall` | Uninstall the agent |
|
||||
|
||||
On Windows the equivalents are `-Endpoint` / `-Token` / `-Interval` / `-Mirror` and `-Update` / `-Status` / `-Uninstall`.
|
||||
|
||||
> **About the token**: `-t` is visible in the process list. The agent also accepts the `NODEFLARE_AGENT_TOKEN` environment variable and `--token-file <path>`. The Linux installer writes the token to a `0600` `agent.env` file that the service unit reads.
|
||||
|
||||
## Metrics and sampling
|
||||
|
||||
CPU, memory, and network speed are sampled every second and uploaded in compressed batches every 3 seconds by default. Server cards show one real sample per second, giving roughly 2–3 seconds of display latency on a healthy connection; after a reconnect, backlogged samples are skipped. Slower metrics such as disk capacity and GPU are cached separately, and history is aggregated by each node's configured interval. A longer real-time upload interval reduces how often cards update.
|
||||
|
||||
Linux memory accounting follows the Komari convention: used memory is `MemTotal - MemFree - Cached - SReclaimable - Buffers + Shmem`, and used swap subtracts `SwapCached`. File cache is not counted as used memory and shared memory is; the panel reports whole-machine memory, not NodeFlare's own usage.
|
||||
|
||||
## Configuration
|
||||
|
||||
The configuration file location is listed in [Default paths and logs](#default-paths-and-logs); a full example is in [`backend/config.example.toml`](../backend/config.example.toml).
|
||||
|
||||
| Key | Description |
|
||||
| --- | --- |
|
||||
| `database_url` | `sqlite://nodeflare.db` or a PostgreSQL URL such as `postgres://user:password@127.0.0.1:5432/nodeflare?sslmode=disable` |
|
||||
| `bind_addr` | Listen address, `127.0.0.1:2206` by default |
|
||||
| `admin_username` | Admin username |
|
||||
| `admin_password` | Admin password (8–128 characters), only needed for the first database initialization and cleared afterwards |
|
||||
| `trusted_proxies` | Trusted reverse proxy IP / CIDR list; only `X-Forwarded-For` from these networks is trusted |
|
||||
| `turnstile_site_key` / `turnstile_secret_key` | Turnstile keys; leave empty to disable |
|
||||
| `session_ttl_hours` | Session lifetime in hours (1–2160), 168 by default |
|
||||
| `frontend_dir` / `admin_frontend_dir` | Frontend asset directories; relative paths resolve against the configuration file |
|
||||
| `theme_dir` | Theme extraction directory, `themes` under the configuration directory by default |
|
||||
|
||||
A few settings can be overridden on the command line: `nodeflare --config <path> --bind <address> --database <url>`. When started with `--database`, the panel's online migration feature is disabled.
|
||||
|
||||
## Default paths and logs
|
||||
|
||||
| Platform | Program | Configuration and data |
|
||||
| --- | --- | --- |
|
||||
| Linux | `/opt/nodeflare` | `/etc/nodeflare` |
|
||||
| Windows | `%ProgramFiles%\NodeFlare` | `%ProgramData%\NodeFlare\Server` |
|
||||
| macOS | `/usr/local/libexec/nodeflare` | `/Library/Application Support/NodeFlare/Server` |
|
||||
| FreeBSD | `/usr/local/libexec/nodeflare` | `/var/db/nodeflare/server` |
|
||||
|
||||
Agent on Linux: program at `/opt/nodeflare/agent`, configuration and state in `/etc/nodeflare/agent`. The SQLite file lives in the configuration directory.
|
||||
|
||||
Logs:
|
||||
|
||||
- Linux (systemd): `journalctl -u nodeflare -f`; the agent is `journalctl -u nodeflare-agent -f`
|
||||
- Linux (OpenRC): `rc-service nodeflare status`
|
||||
- macOS: `/var/log/nodeflare.log`
|
||||
- Windows: `Get-ScheduledTaskInfo -TaskName nodeflare`
|
||||
|
||||
## Reverse proxy
|
||||
|
||||
Expose the panel through an HTTPS reverse proxy and add the proxy address to `trusted_proxies`, otherwise every visitor is treated as the same IP and the session cookie is not marked `Secure`:
|
||||
|
||||
```toml
|
||||
trusted_proxies = ["127.0.0.1/32", "::1/128"]
|
||||
```
|
||||
|
||||
nginx:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name nodeflare.example.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/nodeflare.example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/nodeflare.example.com/privkey.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:2206;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade; # WebSocket live data
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Caddy:
|
||||
|
||||
```
|
||||
nodeflare.example.com {
|
||||
reverse_proxy 127.0.0.1:2206
|
||||
}
|
||||
```
|
||||
|
||||
## Database and backups
|
||||
|
||||
The "Database" page shows usage, reclaims space, exports and restores ZIP backups, and migrates between SQLite and PostgreSQL. A backup contains settings, nodes, history, notifications, themes, tasks, and security configuration, and requires TOTP or the password. Limits are a 512 MiB ZIP, 4 GiB extracted, 16,384 entries, and 32 MiB per theme file. Migration overwrites the target database, updates the connection string automatically, keeps agent tokens, and takes effect after a restart. History is kept for 30 days by default.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
git clone https://github.com/elysia62/NodeFlare.git
|
||||
cd NodeFlare
|
||||
bun install --frozen-lockfile
|
||||
cp backend/config.example.toml backend/config.toml
|
||||
./dev.sh
|
||||
```
|
||||
|
||||
`dev.sh` builds the frontend and starts the backend with `cargo run`. `start.sh` builds release binaries and prefers `/etc/nodeflare/config.toml`, which can be overridden with `NODEFLARE_CONFIG`.
|
||||
|
||||
Repository layout:
|
||||
|
||||
| Directory | Contents |
|
||||
| --- | --- |
|
||||
| `backend/` | Server (Rust / Axum / SQLx): `src/routes` endpoints, `src/db` data layer, `src/websocket` live channels, `migrations/` schema |
|
||||
| `agent/` | Agent source (collection / upload / remote execution / self-update) and per-platform install scripts |
|
||||
| `shared/` | Telemetry protocol shared by the agent and the server (serialization + compression) |
|
||||
| `frontend/` | Frontend (React + Vite): `src/components`, `src/styles` |
|
||||
| `scripts/` | Build, version resolution, and smoke-test scripts |
|
||||
| `docs/` | English README and systemd service unit |
|
||||
|
||||
Tests and builds:
|
||||
|
||||
```bash
|
||||
bun test --cwd frontend
|
||||
cargo test --locked --manifest-path backend/Cargo.toml
|
||||
cargo test --locked --manifest-path agent/Cargo.toml
|
||||
bun run build
|
||||
```
|
||||
|
||||
Smoke test (start the panel first):
|
||||
|
||||
```bash
|
||||
MONITOR_ADMIN_USERNAME=admin MONITOR_ADMIN_PASSWORD='your-password' bun run test:smoke
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **The service fails to start**: check the logs (see [Default paths and logs](#default-paths-and-logs)); usually the port is in use or the database URL is wrong.
|
||||
- **Unknown listening port**: check `bind_addr` in the configuration file, `127.0.0.1:2206` by default.
|
||||
- **The agent shows offline**: verify the agent can reach the panel (`curl -I https://your-panel`), check the token, and check the system clock, since reporting and latency rely on clock calibration.
|
||||
- **Nodes are missing from the public dashboard**: make sure the node is not hidden and that "Public dashboard" is enabled in site settings.
|
||||
- **Backup export reports the size limit**: shorten the history retention or clear history, then export again.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
File renamed without changes.
Regular → Executable
+24
-46
@@ -1,47 +1,18 @@
|
||||
import { KeyRound, Megaphone, Moon, Search, Sun, UserCircle } from "lucide-react";
|
||||
import { lazy, Suspense, useMemo, useState, type FormEvent } from "react";
|
||||
import { Megaphone, Moon, Search, Sun, UserCircle } from "lucide-react";
|
||||
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
|
||||
import { demoMode, useApp } from "./AppProvider";
|
||||
import { LoginForm } from "./components/LoginForm";
|
||||
import { NodeCard } from "./components/NodeCard";
|
||||
import { SiteLogo } from "./components/SiteLogo";
|
||||
import { StatsBar } from "./components/StatsBar";
|
||||
import { TurnstileWidget } from "./components/TurnstileWidget";
|
||||
import { ApiError } from "./api";
|
||||
import { ui } from "./locale";
|
||||
|
||||
const NodeDetails = lazy(() => import("./components/NodeDetails").then((module) => ({ default: module.NodeDetails })));
|
||||
|
||||
function LoginGate() {
|
||||
const { config, dark, error, setError, login } = useApp();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [turnstileToken, setTurnstileToken] = useState("");
|
||||
const [turnstileReset, setTurnstileReset] = useState(0);
|
||||
const [totpCode, setTotpCode] = useState("");
|
||||
const [totpChallenge, setTotpChallenge] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const locale = config.locale;
|
||||
const totpRequired = config.totp_login_enabled || totpChallenge;
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await login(username, password, turnstileToken, totpCode);
|
||||
setPassword("");
|
||||
setTurnstileToken("");
|
||||
setTotpCode("");
|
||||
setTotpChallenge(false);
|
||||
} catch (reason) {
|
||||
if (reason instanceof ApiError && reason.status === 428) setTotpChallenge(true);
|
||||
setError(reason instanceof Error ? reason.message : ui(locale, "登录失败", "Unable to sign in"));
|
||||
setTurnstileToken("");
|
||||
setTurnstileReset((value) => value + 1);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="dashboard-login-gate glass-panel">
|
||||
@@ -50,14 +21,17 @@ function LoginGate() {
|
||||
<h1>{ui(locale, "登录仪表盘", "Sign in to dashboard")}</h1>
|
||||
<p>{ui(locale, "此仪表盘仅限登录后访问", "This dashboard requires an administrator sign-in")}</p>
|
||||
</div>
|
||||
<form className="dashboard-login-form" onSubmit={(event) => void submit(event)}>
|
||||
<label><span>{ui(locale, "用户名", "Username")}</span><input autoFocus autoComplete="username" value={username} onChange={(event) => setUsername(event.target.value)} required /></label>
|
||||
<label><span>{ui(locale, "密码", "Password")}</span><input type="password" autoComplete="current-password" value={password} onChange={(event) => setPassword(event.target.value)} required /></label>
|
||||
{totpRequired ? <label><span>{ui(locale, "两步验证码", "Two-factor code")}</span><input autoFocus inputMode="numeric" autoComplete="one-time-code" pattern="[0-9]{6}" maxLength={6} value={totpCode} onChange={(event) => setTotpCode(event.target.value.replace(/\D/g, "").slice(0, 6))} required /></label> : null}
|
||||
{config.turnstile_login_enabled ? <div className="dashboard-login-turnstile"><TurnstileWidget siteKey={config.turnstile_site_key} action="admin_login" theme={dark ? "dark" : "light"} resetKey={turnstileReset} onVerify={setTurnstileToken} onError={setError} /></div> : null}
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
<button className="primary-btn dashboard-login-submit" disabled={busy || (totpRequired && !/^\d{6}$/.test(totpCode)) || (config.turnstile_login_enabled && !turnstileToken)} type="submit"><KeyRound size={16} />{busy ? ui(locale, "登录中", "Signing in") : ui(locale, "登录", "Sign in")}</button>
|
||||
</form>
|
||||
<LoginForm
|
||||
config={config}
|
||||
dark={dark}
|
||||
error={error}
|
||||
setError={setError}
|
||||
onSubmit={login}
|
||||
className="dashboard-login-form"
|
||||
turnstileClassName="dashboard-login-turnstile"
|
||||
errorClassName="form-error"
|
||||
submitClassName="primary-btn dashboard-login-submit"
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -86,7 +60,7 @@ function VerificationGate() {
|
||||
<SiteLogo src={config.logo_url} alt="" width="52" height="52" />
|
||||
<div><h1>{ui(locale, "访问验证", "Access verification")}</h1><p>{config.site_name}</p></div>
|
||||
{config.turnstile_site_key
|
||||
? <TurnstileWidget siteKey={config.turnstile_site_key} action="public_dashboard" theme={dark ? "dark" : "light"} resetKey={resetKey} onVerify={(token) => void run(token)} onError={setError} />
|
||||
? <TurnstileWidget siteKey={config.turnstile_site_key} action="public_dashboard" theme={dark ? "dark" : "light"} resetKey={resetKey} onVerify={(token) => void run(token)} onError={setError} locale={locale} />
|
||||
: <p className="form-error">{ui(locale, "Turnstile 尚未正确配置", "Turnstile is not configured")}</p>}
|
||||
{busy ? <span className="verification-status">{ui(locale, "正在验证", "Verifying")}</span> : null}
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
@@ -100,17 +74,21 @@ function HomeView() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [group, setGroup] = useState("__all__");
|
||||
const locale = config.locale;
|
||||
const fallbackGroup = ui(locale, "默认", "Default");
|
||||
|
||||
const groups = useMemo(
|
||||
() => ["__all__", ...Array.from(new Set(servers.map((server) => server.group_name || "默认")))],
|
||||
[servers],
|
||||
() => ["__all__", ...Array.from(new Set(servers.map((server) => server.group_name || fallbackGroup)))],
|
||||
[fallbackGroup, servers],
|
||||
);
|
||||
const visible = useMemo(() => servers.filter((server) => {
|
||||
const text = `${server.name} ${server.region} ${server.tags} ${server.group_name}`.toLowerCase();
|
||||
const groupMatches = !config.show_groups || group === "__all__" || (server.group_name || "默认") === group;
|
||||
const groupMatches = !config.show_groups || group === "__all__" || (server.group_name || fallbackGroup) === group;
|
||||
const queryMatches = !config.show_search || text.includes(query.trim().toLowerCase());
|
||||
return groupMatches && queryMatches;
|
||||
}), [config.show_groups, config.show_search, group, query, servers]);
|
||||
}), [config.show_groups, config.show_search, fallbackGroup, group, query, servers]);
|
||||
|
||||
// A locale change renames the fallback group; drop the stale selection.
|
||||
useEffect(() => setGroup("__all__"), [fallbackGroup]);
|
||||
|
||||
return (
|
||||
<div className="home-content">
|
||||
@@ -125,7 +103,7 @@ function HomeView() {
|
||||
<div className="dashboard-loading"><span className="loading-ring" aria-hidden="true" /><p>{ui(locale, "加载中…", "Loading…")}</p></div>
|
||||
) : visible.length ? (
|
||||
<section className={`node-grid ${carrierLatency ? "carrier-latency" : ""}`}>
|
||||
{visible.map((server) => <NodeCard key={server.id} server={server} config={config} liveConnected={liveConnected} liveLatencyResults={liveMetrics[server.id]?.latencyResults} onOpen={() => openServer(server)} />)}
|
||||
{visible.map((server) => <NodeCard key={server.id} server={server} config={config} liveConnected={liveConnected} liveLatencyResults={liveMetrics[server.id]?.latencyResults} onOpen={openServer} />)}
|
||||
</section>
|
||||
) : !error ? (
|
||||
<div className="empty-state"><strong>{servers.length ? ui(locale, "没有匹配的节点", "No matching servers") : ui(locale, "尚未添加节点", "No servers added")}</strong></div>
|
||||
|
||||
Regular → Executable
+46
-9
@@ -6,8 +6,10 @@ import {
|
||||
createLivePlayback,
|
||||
mergeServerLive,
|
||||
pruneLiveMetrics,
|
||||
type LiveMetrics,
|
||||
type LiveMetricsMap,
|
||||
} from "./live";
|
||||
import { isOnline } from "./format";
|
||||
import { ui } from "./locale";
|
||||
import { useFavicon, useStoredAppearance, useSystemDark } from "./hooks/useBrowserAppearance";
|
||||
import { resolveBackground, themeToggle } from "./theme";
|
||||
@@ -85,6 +87,13 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||
const systemDark = useSystemDark();
|
||||
const liveConnectedRef = useRef(false);
|
||||
const serversRef = useRef<Server[]>([]);
|
||||
const cardCacheRef = useRef(new Map<string, {
|
||||
server: Server;
|
||||
live: LiveMetrics | undefined;
|
||||
online: boolean;
|
||||
result: Server;
|
||||
}>());
|
||||
const liveServersRef = useRef<Server[]>([]);
|
||||
const localeRef = useRef(config.locale);
|
||||
const reloadQueueRef = useRef<ReturnType<typeof createRefreshQueue> | null>(null);
|
||||
|
||||
@@ -142,7 +151,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (username: string, password: string, turnstileToken: string, totpCode: string) => {
|
||||
const derived = await derivePassword(password, config.password_client_salt);
|
||||
const derived = await derivePassword(password, config.password_client_salt, config.locale);
|
||||
await api.login(username.trim(), derived, turnstileToken, totpCode);
|
||||
setAccess("ok");
|
||||
await reload();
|
||||
@@ -286,14 +295,42 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||
return () => window.removeEventListener("popstate", onPop);
|
||||
}, []);
|
||||
|
||||
const metricServers = useMemo(
|
||||
() => servers.map((server) => mergeServerLive(server, liveMetrics[server.id], 0, config.offline_threshold_seconds)),
|
||||
[config.offline_threshold_seconds, liveMetrics, servers],
|
||||
);
|
||||
const liveServers = useMemo(
|
||||
() => metricServers.map((server) => mergeServerLive(server, undefined, clockNow, config.offline_threshold_seconds)),
|
||||
[clockNow, config.offline_threshold_seconds, metricServers],
|
||||
);
|
||||
// Merge live metrics into servers with identity preservation: a server's object
|
||||
// only changes when its own data or online status changes, and the array itself
|
||||
// stays referentially stable on quiet ticks. Memoized cards then skip re-renders
|
||||
// for nodes that reported nothing new, while a silent node still flips to
|
||||
// offline within a second of crossing the threshold.
|
||||
const liveServers = useMemo(() => {
|
||||
const nowSec = clockNow / 1000;
|
||||
const cache = cardCacheRef.current;
|
||||
const next = servers.map((server) => {
|
||||
const live = liveMetrics[server.id];
|
||||
const merged = mergeServerLive(server, live);
|
||||
const online = isOnline(merged, config.offline_threshold_seconds, nowSec);
|
||||
const cached = cache.get(server.id);
|
||||
if (cached && cached.server === server && cached.live === live) {
|
||||
if (cached.online === online) return cached.result;
|
||||
// Online status flipped without new data: mint a fresh object so the
|
||||
// card re-renders into the offline/online state.
|
||||
const result = { ...merged };
|
||||
cache.set(server.id, { server, live, online, result });
|
||||
return result;
|
||||
}
|
||||
cache.set(server.id, { server, live, online, result: merged });
|
||||
return merged;
|
||||
});
|
||||
if (cache.size > next.length) {
|
||||
const ids = new Set(next.map((server) => server.id));
|
||||
for (const id of cache.keys()) {
|
||||
if (!ids.has(id)) cache.delete(id);
|
||||
}
|
||||
}
|
||||
const previous = liveServersRef.current;
|
||||
const stable = previous.length === next.length
|
||||
&& next.every((server, index) => server === previous[index]);
|
||||
liveServersRef.current = stable ? previous : next;
|
||||
return liveServersRef.current;
|
||||
}, [clockNow, config.offline_threshold_seconds, liveMetrics, servers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!configReady) return;
|
||||
|
||||
Regular → Executable
+9
-5
@@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client";
|
||||
import { api } from "./api";
|
||||
import { AdminPanel } from "./components/AdminPanel";
|
||||
import { useFavicon, useStoredAppearance, useSystemDark } from "./hooks/useBrowserAppearance";
|
||||
import { ui } from "./locale";
|
||||
import type { Config } from "./types";
|
||||
import "./styles/admin.css";
|
||||
|
||||
@@ -13,6 +14,8 @@ function AdminApp() {
|
||||
const systemDark = useSystemDark();
|
||||
const dark = appearance ? appearance === "dark" : config?.default_theme === "system" || !config ? systemDark : config.default_theme === "dark";
|
||||
useFavicon(config?.logo_url);
|
||||
// Before the config arrives there is no configured locale; fall back to the browser.
|
||||
const locale = config?.locale ?? (navigator.language.toLowerCase().startsWith("zh") ? "zh-CN" : "en");
|
||||
|
||||
async function loadConfig() {
|
||||
try {
|
||||
@@ -20,7 +23,7 @@ function AdminApp() {
|
||||
setConfig(next.config);
|
||||
setError("");
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "无法加载管理面板");
|
||||
setError(reason instanceof Error ? reason.message : ui(locale, "无法加载管理面板", "Unable to load the admin panel"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,11 +33,12 @@ function AdminApp() {
|
||||
document.documentElement.style.colorScheme = dark ? "dark" : "light";
|
||||
}, [dark]);
|
||||
useEffect(() => {
|
||||
document.title = config ? `管理面板 · ${config.site_name}` : "管理面板";
|
||||
}, [config]);
|
||||
document.documentElement.lang = locale;
|
||||
document.title = config ? ui(locale, `管理面板 · ${config.site_name}`, `Admin · ${config.site_name}`) : ui(locale, "管理面板", "Admin");
|
||||
}, [config, locale]);
|
||||
|
||||
if (error) return <div className={`admin-loading ${dark ? "admin-dark" : ""}`}><span>{error}</span><button className="secondary-btn" onClick={() => void loadConfig()}>重试</button></div>;
|
||||
if (!config) return <div className={`admin-loading ${dark ? "admin-dark" : ""}`}>正在加载管理面板</div>;
|
||||
if (error) return <div className={`admin-loading ${dark ? "admin-dark" : ""}`}><span>{error}</span><button className="secondary-btn" onClick={() => void loadConfig()}>{ui(locale, "重试", "Retry")}</button></div>;
|
||||
if (!config) return <div className={`admin-loading ${dark ? "admin-dark" : ""}`}>{ui(locale, "正在加载管理面板", "Loading the admin panel")}</div>;
|
||||
return <AdminPanel config={config} dark={dark} onToggleTheme={() => {
|
||||
const next = dark ? "light" : "dark";
|
||||
setAppearance(next);
|
||||
|
||||
Regular → Executable
+255
-484
File diff suppressed because it is too large.
Load diff
Regular → Executable
+29
-27
@@ -1,6 +1,7 @@
|
||||
import { Pencil, Plus, Save, Trash2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api } from "../api";
|
||||
import { ui, type UiLocale } from "../locale";
|
||||
import type { AdminServer, AlertRule, AlertRuleInput } from "../types";
|
||||
import { Checkbox } from "./Checkbox";
|
||||
import { useDialog } from "./useDialog";
|
||||
@@ -16,15 +17,16 @@ const emptyRule: AlertRuleInput = {
|
||||
server_ids: [],
|
||||
};
|
||||
|
||||
const metricLabels = {
|
||||
cpu: "CPU 使用率",
|
||||
memory: "内存使用率",
|
||||
disk: "磁盘使用率",
|
||||
net_in: "下行速度",
|
||||
net_out: "上行速度",
|
||||
};
|
||||
const metricLabels = (locale: UiLocale | string | undefined) => ({
|
||||
cpu: ui(locale, "CPU 使用率", "CPU usage"),
|
||||
memory: ui(locale, "内存使用率", "Memory usage"),
|
||||
disk: ui(locale, "磁盘使用率", "Disk usage"),
|
||||
net_in: ui(locale, "下行速度", "Download speed"),
|
||||
net_out: ui(locale, "上行速度", "Upload speed"),
|
||||
});
|
||||
|
||||
export function AlertRuleManager({ servers, onError, onNotice }: {
|
||||
export function AlertRuleManager({ locale, servers, onError, onNotice }: {
|
||||
locale: UiLocale | string | undefined;
|
||||
servers: AdminServer[];
|
||||
onError: (message: string) => void;
|
||||
onNotice: (message: string) => void;
|
||||
@@ -37,7 +39,7 @@ export function AlertRuleManager({ servers, onError, onNotice }: {
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try { setRules((await api.alertRules()).rules); }
|
||||
catch (reason) { onError(reason instanceof Error ? reason.message : "读取告警规则失败"); }
|
||||
catch (reason) { onError(reason instanceof Error ? reason.message : ui(locale, "读取告警规则失败", "Failed to load alert rules")); }
|
||||
}, [onError]);
|
||||
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
@@ -65,15 +67,15 @@ export function AlertRuleManager({ servers, onError, onNotice }: {
|
||||
else if (editing) await api.updateAlertRule(editing.id, input);
|
||||
setEditing(null);
|
||||
await load();
|
||||
onNotice("告警规则已保存");
|
||||
} catch (reason) { onError(reason instanceof Error ? reason.message : "保存告警规则失败"); }
|
||||
onNotice(ui(locale, "告警规则已保存", "Alert rule saved"));
|
||||
} catch (reason) { onError(reason instanceof Error ? reason.message : ui(locale, "保存告警规则失败", "Failed to save the alert rule")); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function remove(rule: AlertRule) {
|
||||
if (!window.confirm(`确认删除告警规则“${rule.name}”?`)) return;
|
||||
try { await api.deleteAlertRule(rule.id); await load(); onNotice("告警规则已删除"); }
|
||||
catch (reason) { onError(reason instanceof Error ? reason.message : "删除告警规则失败"); }
|
||||
if (!window.confirm(ui(locale, `确认删除告警规则“${rule.name}”?`, `Delete the alert rule "${rule.name}"?`))) return;
|
||||
try { await api.deleteAlertRule(rule.id); await load(); onNotice(ui(locale, "告警规则已删除", "Alert rule deleted")); }
|
||||
catch (reason) { onError(reason instanceof Error ? reason.message : ui(locale, "删除告警规则失败", "Failed to delete the alert rule")); }
|
||||
}
|
||||
|
||||
async function toggle(rule: AlertRule) {
|
||||
@@ -89,31 +91,31 @@ export function AlertRuleManager({ servers, onError, onNotice }: {
|
||||
server_ids: rule.server_ids,
|
||||
});
|
||||
await load();
|
||||
} catch (reason) { onError(reason instanceof Error ? reason.message : "更新告警规则失败"); }
|
||||
} catch (reason) { onError(reason instanceof Error ? reason.message : ui(locale, "更新告警规则失败", "Failed to update the alert rule")); }
|
||||
}
|
||||
|
||||
const unit = form.metric === "net_in" || form.metric === "net_out" ? "MiB/s" : "%";
|
||||
|
||||
return <div className="alert-rule-manager">
|
||||
<div className="section-head"><div><h3>资源告警规则</h3><span>最多 20 条,可指定服务器和统计窗口</span></div><button type="button" className="primary-btn compact" onClick={() => open()}><Plus size={15} />新建规则</button></div>
|
||||
<div className="section-head"><div><h3>{ui(locale, "资源告警规则", "Resource alert rules")}</h3><span>{ui(locale, "最多 20 条,可指定服务器和统计窗口", "Up to 20 rules; can target specific servers and windows")}</span></div><button type="button" className="primary-btn compact" onClick={() => open()}><Plus size={15} />{ui(locale, "新建规则", "New rule")}</button></div>
|
||||
<div className="alert-rule-list">
|
||||
{rules.map((rule) => <div className="alert-rule-row" key={rule.id}>
|
||||
<Checkbox checked={rule.enabled} onChange={() => void toggle(rule)} ariaLabel={`${rule.name}启用状态`} />
|
||||
<div><strong>{rule.name}</strong><small>{metricLabels[rule.metric]} ≥ {rule.threshold} {rule.metric.startsWith("net_") ? "MiB/s" : "%"} · {rule.duration_minutes} 分钟{rule.aggregation === "continuous" ? "持续" : "平均"} · {rule.all_servers ? "全部服务器" : `${rule.server_ids.length} 台服务器`}</small></div>
|
||||
<button type="button" className="icon-btn" title="编辑规则" onClick={() => open(rule)}><Pencil size={15} /></button>
|
||||
<button type="button" className="icon-btn danger" title="删除规则" onClick={() => void remove(rule)}><Trash2 size={15} /></button>
|
||||
<Checkbox checked={rule.enabled} onChange={() => void toggle(rule)} ariaLabel={ui(locale, `${rule.name}启用状态`, `${rule.name} enabled state`)} />
|
||||
<div><strong>{rule.name}</strong><small>{metricLabels(locale)[rule.metric]} ≥ {rule.threshold} {rule.metric.startsWith("net_") ? "MiB/s" : "%"} · {ui(locale, `${rule.duration_minutes} 分钟`, `${rule.duration_minutes} min`)}{rule.aggregation === "continuous" ? ui(locale, "持续", " continuous") : ui(locale, "平均", " average")} · {rule.all_servers ? ui(locale, "全部服务器", "All servers") : ui(locale, `${rule.server_ids.length} 台服务器`, `${rule.server_ids.length} server(s)`)}</small></div>
|
||||
<button type="button" className="icon-btn" title={ui(locale, "编辑规则", "Edit rule")} onClick={() => open(rule)}><Pencil size={15} /></button>
|
||||
<button type="button" className="icon-btn danger" title={ui(locale, "删除规则", "Delete rule")} onClick={() => void remove(rule)}><Trash2 size={15} /></button>
|
||||
</div>)}
|
||||
{!rules.length ? <div className="list-empty">尚未配置资源告警规则</div> : null}
|
||||
{!rules.length ? <div className="list-empty">{ui(locale, "尚未配置资源告警规则", "No resource alert rules yet")}</div> : null}
|
||||
</div>
|
||||
|
||||
{editing ? <div className="submodal-backdrop" role="presentation" onMouseDown={editorDialog.onBackdropMouseDown}><section ref={editorDialog.dialogRef} className="alert-rule-editor glass-panel" role="dialog" aria-modal="true" aria-labelledby="alert-rule-editor-title" tabIndex={-1}>
|
||||
<header><div><span className="eyebrow">通知规则</span><h3 id="alert-rule-editor-title">{editing === "new" ? "新建资源告警" : "编辑资源告警"}</h3></div></header>
|
||||
<label><span>规则名称</span><input required maxLength={80} value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} /></label>
|
||||
<div className="form-grid"><label><span>监控指标</span><select value={form.metric} onChange={(event) => setForm((current) => ({ ...current, metric: event.target.value as AlertRuleInput["metric"] }))}>{Object.entries(metricLabels).map(([value, label]) => <option value={value} key={value}>{label}</option>)}</select></label><label><span>阈值({unit})</span><input required type="number" min="0.01" max={unit === "%" ? 100 : 1000000} step="0.01" value={form.threshold} onChange={(event) => setForm((current) => ({ ...current, threshold: Number(event.target.value) }))} /></label></div>
|
||||
<div className="form-grid"><label><span>时间窗口(分钟)</span><input required type="number" min="1" max="1440" value={form.duration_minutes} onChange={(event) => setForm((current) => ({ ...current, duration_minutes: Number(event.target.value) }))} /></label><label><span>判断方式</span><select value={form.aggregation} onChange={(event) => setForm((current) => ({ ...current, aggregation: event.target.value as AlertRuleInput["aggregation"] }))}><option value="average">窗口平均值</option><option value="continuous">窗口内持续超限</option></select></label></div>
|
||||
<div className="settings-toggles"><label className="toggle-row"><b>启用规则</b><Checkbox checked={form.enabled} onChange={(value) => setForm((current) => ({ ...current, enabled: value }))} /></label><label className="toggle-row"><b>全部服务器</b><Checkbox checked={form.all_servers} onChange={(value) => setForm((current) => ({ ...current, all_servers: value }))} /></label></div>
|
||||
<header><div><span className="eyebrow">{ui(locale, "通知规则", "Notification rule")}</span><h3 id="alert-rule-editor-title">{editing === "new" ? ui(locale, "新建资源告警", "New resource alert") : ui(locale, "编辑资源告警", "Edit resource alert")}</h3></div></header>
|
||||
<label><span>{ui(locale, "规则名称", "Rule name")}</span><input required maxLength={80} value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} /></label>
|
||||
<div className="form-grid"><label><span>{ui(locale, "监控指标", "Metric")}</span><select value={form.metric} onChange={(event) => setForm((current) => ({ ...current, metric: event.target.value as AlertRuleInput["metric"] }))}>{Object.entries(metricLabels(locale)).map(([value, label]) => <option value={value} key={value}>{label}</option>)}</select></label><label><span>{ui(locale, `阈值(${unit})`, `Threshold (${unit})`)}</span><input required type="number" min="0.01" max={unit === "%" ? 100 : 1000000} step="0.01" value={form.threshold} onChange={(event) => setForm((current) => ({ ...current, threshold: Number(event.target.value) }))} /></label></div>
|
||||
<div className="form-grid"><label><span>{ui(locale, "时间窗口(分钟)", "Time window (min)")}</span><input required type="number" min="1" max="1440" value={form.duration_minutes} onChange={(event) => setForm((current) => ({ ...current, duration_minutes: Number(event.target.value) }))} /></label><label><span>{ui(locale, "判断方式", "Evaluation")}</span><select value={form.aggregation} onChange={(event) => setForm((current) => ({ ...current, aggregation: event.target.value as AlertRuleInput["aggregation"] }))}><option value="average">{ui(locale, "窗口平均值", "Window average")}</option><option value="continuous">{ui(locale, "窗口内持续超限", "Continuously above threshold")}</option></select></label></div>
|
||||
<div className="settings-toggles"><label className="toggle-row"><b>{ui(locale, "启用规则", "Enable rule")}</b><Checkbox checked={form.enabled} onChange={(value) => setForm((current) => ({ ...current, enabled: value }))} /></label><label className="toggle-row"><b>{ui(locale, "全部服务器", "All servers")}</b><Checkbox checked={form.all_servers} onChange={(value) => setForm((current) => ({ ...current, all_servers: value }))} /></label></div>
|
||||
{!form.all_servers ? <div className="alert-server-picker">{servers.map((server) => <label key={server.id}><Checkbox checked={form.server_ids.includes(server.id)} onChange={(checked) => setForm((current) => ({ ...current, server_ids: checked ? [...current.server_ids, server.id] : current.server_ids.filter((id) => id !== server.id) }))} /><span>{server.name}</span></label>)}</div> : null}
|
||||
<div className="form-actions"><button type="button" className="secondary-btn" onClick={() => setEditing(null)}>取消</button><button type="button" className="primary-btn" onClick={() => void save()} disabled={busy || !form.name.trim() || (!form.all_servers && !form.server_ids.length)}><Save size={15} />保存规则</button></div>
|
||||
<div className="form-actions"><button type="button" className="secondary-btn" onClick={() => setEditing(null)}>{ui(locale, "取消", "Cancel")}</button><button type="button" className="primary-btn" onClick={() => void save()} disabled={busy || !form.name.trim() || (!form.all_servers && !form.server_ids.length)}><Save size={15} />{ui(locale, "保存规则", "Save rule")}</button></div>
|
||||
</section></div> : null}
|
||||
</div>;
|
||||
}
|
||||
Regular → Executable
+24
-21
@@ -1,6 +1,7 @@
|
||||
import { Pencil, Plus, RadioTower, Save, Search, Trash2 } from "lucide-react";
|
||||
import { type FormEvent, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { api } from "../api";
|
||||
import { ui, type UiLocale } from "../locale";
|
||||
import type { AdminServer, LatencyTask, LatencyTaskInput } from "../types";
|
||||
import { Checkbox } from "./Checkbox";
|
||||
import { useDialog } from "./useDialog";
|
||||
@@ -46,10 +47,12 @@ function parseTarget(value: string, type: LatencyTask["task_type"]): Pick<Latenc
|
||||
}
|
||||
|
||||
export function LatencyManager({
|
||||
locale,
|
||||
servers,
|
||||
onError,
|
||||
onNotice,
|
||||
}: {
|
||||
locale: UiLocale | string | undefined;
|
||||
servers: AdminServer[];
|
||||
onError: (message: string) => void;
|
||||
onNotice: (message: string) => void;
|
||||
@@ -67,7 +70,7 @@ export function LatencyManager({
|
||||
try {
|
||||
setTasks((await api.latencyTasks()).tasks);
|
||||
} catch (reason) {
|
||||
onError(reason instanceof Error ? reason.message : "读取延迟任务失败");
|
||||
onError(reason instanceof Error ? reason.message : ui(locale, "读取延迟任务失败", "Failed to load latency tasks"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -108,7 +111,7 @@ export function LatencyManager({
|
||||
event.preventDefault();
|
||||
const target = parseTarget(form.target, form.task_type);
|
||||
if (!target) {
|
||||
onError(form.task_type === "icmp" ? "ICMP 节点应为公网域名或公网 IPv4,不使用端口" : "TCP 节点格式应为域名:端口或公网 IPv4:端口,端口范围为 1 至 65535");
|
||||
onError(form.task_type === "icmp" ? ui(locale, "ICMP 节点应为公网域名或公网 IPv4,不使用端口", "An ICMP target must be a public hostname or public IPv4 address without a port") : ui(locale, "TCP 节点格式应为域名:端口或公网 IPv4:端口,端口范围为 1 至 65535", "A TCP target must be host:port or public IPv4:port with port 1-65535"));
|
||||
return;
|
||||
}
|
||||
const input: LatencyTaskInput = { ...form, ...target };
|
||||
@@ -119,24 +122,24 @@ export function LatencyManager({
|
||||
else if (editing) await api.updateLatencyTask(editing.id, input);
|
||||
setEditing(null);
|
||||
await load();
|
||||
onNotice(editing === "new" ? "延迟任务已添加" : "延迟任务已更新");
|
||||
onNotice(editing === "new" ? ui(locale, "延迟任务已添加", "Latency task added") : ui(locale, "延迟任务已更新", "Latency task updated"));
|
||||
} catch (reason) {
|
||||
onError(reason instanceof Error ? reason.message : "保存延迟任务失败");
|
||||
onError(reason instanceof Error ? reason.message : ui(locale, "保存延迟任务失败", "Failed to save the latency task"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(task: LatencyTask) {
|
||||
if (!window.confirm(`确认删除延迟任务“${task.name}”及其历史结果?`)) return;
|
||||
if (!window.confirm(ui(locale, `确认删除延迟任务“${task.name}”及其历史结果?`, `Delete the latency task "${task.name}" and its history?`))) return;
|
||||
setBusy(true);
|
||||
onError("");
|
||||
try {
|
||||
await api.deleteLatencyTask(task.id);
|
||||
await load();
|
||||
onNotice("延迟任务已删除");
|
||||
onNotice(ui(locale, "延迟任务已删除", "Latency task deleted"));
|
||||
} catch (reason) {
|
||||
onError(reason instanceof Error ? reason.message : "删除延迟任务失败");
|
||||
onError(reason instanceof Error ? reason.message : ui(locale, "删除延迟任务失败", "Failed to delete the latency task"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -146,35 +149,35 @@ export function LatencyManager({
|
||||
|
||||
return <div className="admin-section latency-section">
|
||||
<div className="section-head">
|
||||
<div><h3>延迟任务</h3><span>{tasks.length} 个任务 · TCP / ICMP</span></div>
|
||||
<button className="primary-btn compact" type="button" onClick={() => open()}><Plus size={15} />添加</button>
|
||||
<div><h3>{ui(locale, "延迟任务", "Latency tasks")}</h3><span>{ui(locale, `${tasks.length} 个任务 · TCP / ICMP`, `${tasks.length} task(s) · TCP / ICMP`)}</span></div>
|
||||
<button className="primary-btn compact" type="button" onClick={() => open()}><Plus size={15} />{ui(locale, "添加", "Add")}</button>
|
||||
</div>
|
||||
<div className="latency-task-list">
|
||||
{tasks.map((task) => <div className="latency-task-row" key={task.id}>
|
||||
<span className={`latency-type ${task.task_type}`}><RadioTower size={13} />{task.task_type.toUpperCase()}</span>
|
||||
<div className="latency-task-name"><strong>{task.name}</strong><small>{task.target}{task.port ? `:${task.port}` : ""}</small></div>
|
||||
<span className="latency-task-meta">{task.interval_seconds}s</span>
|
||||
<span className="latency-task-meta">{task.server_ids.length} 个节点</span>
|
||||
<div className="row-actions"><button className="icon-btn" type="button" onClick={() => open(task)} title="编辑延迟任务"><Pencil size={15} /></button><button className="icon-btn danger" type="button" onClick={() => void remove(task)} title="删除延迟任务"><Trash2 size={15} /></button></div>
|
||||
<span className="latency-task-meta">{ui(locale, `${task.server_ids.length} 个节点`, `${task.server_ids.length} server(s)`)}</span>
|
||||
<div className="row-actions"><button className="icon-btn" type="button" onClick={() => open(task)} title={ui(locale, "编辑延迟任务", "Edit latency task")}><Pencil size={15} /></button><button className="icon-btn danger" type="button" onClick={() => void remove(task)} title={ui(locale, "删除延迟任务", "Delete latency task")}><Trash2 size={15} /></button></div>
|
||||
</div>)}
|
||||
{!tasks.length && !busy ? <div className="list-empty">暂无延迟任务</div> : null}
|
||||
{busy && !tasks.length ? <div className="list-empty">正在读取延迟任务</div> : null}
|
||||
{!tasks.length && !busy ? <div className="list-empty">{ui(locale, "暂无延迟任务", "No latency tasks")}</div> : null}
|
||||
{busy && !tasks.length ? <div className="list-empty">{ui(locale, "正在读取延迟任务", "Loading latency tasks")}</div> : null}
|
||||
</div>
|
||||
|
||||
{editing ? <div className="submodal-backdrop" role="presentation" onMouseDown={editorDialog.onBackdropMouseDown}><form ref={editorDialog.dialogRef} className="latency-editor glass-panel" role="dialog" aria-modal="true" aria-labelledby="latency-editor-title" tabIndex={-1} onSubmit={save}>
|
||||
<header><div><span className="eyebrow">延迟检测</span><h3 id="latency-editor-title">{editing === "new" ? "添加任务" : `编辑 · ${editing.name}`}</h3></div></header>
|
||||
<div className="form-grid"><label><span>名称</span><input autoFocus required maxLength={80} value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} /></label><label><span>类型</span><div className="segmented task-type-control"><button type="button" className={form.task_type === "icmp" ? "active" : ""} onClick={() => setForm((current) => ({ ...current, task_type: "icmp", target: parseTarget(current.target, current.task_type)?.target ?? current.target }))}>ICMP</button><button type="button" className={form.task_type === "tcp" ? "active" : ""} onClick={() => setForm((current) => ({ ...current, task_type: "tcp" }))}>TCP</button></div></label></div>
|
||||
<div className="form-grid latency-target-row"><label><span>节点</span><input required maxLength={form.task_type === "tcp" ? 56 : 50} value={form.target} onChange={(event) => setForm((current) => ({ ...current, target: event.target.value }))} placeholder={form.task_type === "tcp" ? "hb-cm-dualstack.ip.zstaticcdn.com:80" : "1.1.1.1"} /></label><label><span>检测间隔(秒)</span><input type="number" min="30" max="3600" required value={form.interval_seconds} onChange={(event) => setForm((current) => ({ ...current, interval_seconds: Number(event.target.value) }))} /></label></div>
|
||||
<header><div><span className="eyebrow">{ui(locale, "延迟检测", "Latency check")}</span><h3 id="latency-editor-title">{editing === "new" ? ui(locale, "添加任务", "Add task") : ui(locale, `编辑 · ${editing.name}`, `Edit · ${editing.name}`)}</h3></div></header>
|
||||
<div className="form-grid"><label><span>{ui(locale, "名称", "Name")}</span><input autoFocus required maxLength={80} value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} /></label><label><span>{ui(locale, "类型", "Type")}</span><div className="segmented task-type-control"><button type="button" className={form.task_type === "icmp" ? "active" : ""} onClick={() => setForm((current) => ({ ...current, task_type: "icmp", target: parseTarget(current.target, current.task_type)?.target ?? current.target }))}>ICMP</button><button type="button" className={form.task_type === "tcp" ? "active" : ""} onClick={() => setForm((current) => ({ ...current, task_type: "tcp" }))}>TCP</button></div></label></div>
|
||||
<div className="form-grid latency-target-row"><label><span>{ui(locale, "节点", "Target")}</span><input required maxLength={form.task_type === "tcp" ? 56 : 50} value={form.target} onChange={(event) => setForm((current) => ({ ...current, target: event.target.value }))} placeholder={form.task_type === "tcp" ? "hb-cm-dualstack.ip.zstaticcdn.com:80" : "1.1.1.1"} /></label><label><span>{ui(locale, "检测间隔(秒)", "Interval (s)")}</span><input type="number" min="30" max="3600" required value={form.interval_seconds} onChange={(event) => setForm((current) => ({ ...current, interval_seconds: Number(event.target.value) }))} /></label></div>
|
||||
<div className="server-picker">
|
||||
<div className="server-picker-head"><strong>服务器</strong><span>已选 {form.server_ids.length} / 共 {servers.length}</span><button type="button" onClick={() => setForm((current) => ({ ...current, server_ids: allSelected ? [] : servers.map((server) => server.id) }))}>{allSelected ? "取消全选" : "全选"}</button></div>
|
||||
<div className="server-picker-search"><Search size={16} /><input aria-label="搜索服务器" placeholder="搜索" value={query} onChange={(event) => setQuery(event.target.value)} /></div>
|
||||
<div className="server-picker-head"><strong>{ui(locale, "服务器", "Servers")}</strong><span>{ui(locale, `已选 ${form.server_ids.length} / 共 ${servers.length}`, `Selected ${form.server_ids.length} / ${servers.length}`)}</span><button type="button" onClick={() => setForm((current) => ({ ...current, server_ids: allSelected ? [] : servers.map((server) => server.id) }))}>{allSelected ? ui(locale, "取消全选", "Deselect all") : ui(locale, "全选", "Select all")}</button></div>
|
||||
<div className="server-picker-search"><Search size={16} /><input aria-label={ui(locale, "搜索服务器", "Search servers")} placeholder={ui(locale, "搜索", "Search")} value={query} onChange={(event) => setQuery(event.target.value)} /></div>
|
||||
<div className="server-picker-list">
|
||||
{visibleServers.map((server) => <label className="server-picker-row" key={server.id}><Checkbox checked={form.server_ids.includes(server.id)} onChange={() => toggleServer(server.id)} /><span><strong>{server.name}</strong></span></label>)}
|
||||
{!visibleServers.length ? <div className="server-picker-empty">没有匹配的服务器</div> : null}
|
||||
{!visibleServers.length ? <div className="server-picker-empty">{ui(locale, "没有匹配的服务器", "No matching servers")}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
<label className="toggle-row"><span><b>默认分配给新服务器</b></span><Checkbox checked={form.default_enabled} onChange={(checked) => setForm((current) => ({ ...current, default_enabled: checked }))} /></label>
|
||||
<div className="form-actions"><button type="button" className="secondary-btn" onClick={() => setEditing(null)}>取消</button><button className="primary-btn" disabled={busy}><Save size={15} />{busy ? "保存中" : "保存任务"}</button></div>
|
||||
<label className="toggle-row"><span><b>{ui(locale, "默认分配给新服务器", "Assign to new servers by default")}</b></span><Checkbox checked={form.default_enabled} onChange={(checked) => setForm((current) => ({ ...current, default_enabled: checked }))} /></label>
|
||||
<div className="form-actions"><button type="button" className="secondary-btn" onClick={() => setEditing(null)}>{ui(locale, "取消", "Cancel")}</button><button className="primary-btn" disabled={busy}><Save size={15} />{busy ? ui(locale, "保存中", "Saving") : ui(locale, "保存任务", "Save task")}</button></div>
|
||||
</form></div> : null}
|
||||
</div>;
|
||||
}
|
||||
Executable
+70
@@ -0,0 +1,70 @@
|
||||
import { CircleAlert, KeyRound } from "lucide-react";
|
||||
import { useState, type FormEvent, type ReactNode } from "react";
|
||||
import { ApiError } from "../api";
|
||||
import { ui } from "../locale";
|
||||
import type { Config } from "../types";
|
||||
import { PasswordInput } from "./PasswordInput";
|
||||
import { TurnstileWidget } from "./TurnstileWidget";
|
||||
|
||||
// Shared sign-in form for the public dashboard gate and the admin panel.
|
||||
// Owns the credential state machine: busy guard against duplicate submits,
|
||||
// 428 TOTP challenge escalation, Turnstile reset on failure. The submit
|
||||
// callback receives the raw credentials; password derivation stays with the
|
||||
// caller. Errors are controlled by the caller so unrelated load failures can
|
||||
// surface in the same place.
|
||||
export function LoginForm({ config, dark, error, setError, onSubmit, className, turnstileClassName, errorClassName, submitClassName, children }: {
|
||||
config: Config;
|
||||
dark: boolean;
|
||||
error: string;
|
||||
setError: (message: string) => void;
|
||||
onSubmit: (username: string, password: string, turnstileToken: string, totpCode: string) => Promise<void>;
|
||||
className: string;
|
||||
turnstileClassName: string;
|
||||
errorClassName: string;
|
||||
submitClassName: string;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [turnstileToken, setTurnstileToken] = useState("");
|
||||
const [turnstileReset, setTurnstileReset] = useState(0);
|
||||
const [totpCode, setTotpCode] = useState("");
|
||||
const [totpChallenge, setTotpChallenge] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const locale = config.locale;
|
||||
const totpRequired = config.totp_login_enabled || totpChallenge;
|
||||
const describedBy = error ? "login-form-error" : undefined;
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await onSubmit(username, password, turnstileToken, totpCode);
|
||||
setPassword("");
|
||||
setTurnstileToken("");
|
||||
setTotpCode("");
|
||||
setTotpChallenge(false);
|
||||
} catch (reason) {
|
||||
if (reason instanceof ApiError && reason.status === 428) setTotpChallenge(true);
|
||||
setError(reason instanceof Error ? reason.message : ui(locale, "登录失败", "Unable to sign in"));
|
||||
setTurnstileToken("");
|
||||
setTurnstileReset((value) => value + 1);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className={className} onSubmit={(event) => void submit(event)}>
|
||||
{children}
|
||||
<label><span>{ui(locale, "用户名", "Username")}</span><input autoFocus autoComplete="username" value={username} onChange={(event) => setUsername(event.target.value)} required aria-invalid={error ? true : undefined} aria-describedby={describedBy} /></label>
|
||||
<label><span>{ui(locale, "密码", "Password")}</span><PasswordInput locale={locale} autoComplete="current-password" value={password} onChange={(event) => setPassword(event.target.value)} required aria-invalid={error ? true : undefined} aria-describedby={describedBy} /></label>
|
||||
{totpRequired ? <label><span>{ui(locale, "两步验证码", "Two-factor code")}</span><input autoFocus inputMode="numeric" autoComplete="one-time-code" pattern="[0-9]{6}" maxLength={6} value={totpCode} onChange={(event) => setTotpCode(event.target.value.replace(/\D/g, "").slice(0, 6))} required aria-invalid={error ? true : undefined} aria-describedby={describedBy} /></label> : null}
|
||||
{config.turnstile_login_enabled ? <div className={turnstileClassName}><TurnstileWidget siteKey={config.turnstile_site_key} action="admin_login" theme={dark ? "dark" : "light"} resetKey={turnstileReset} onVerify={setTurnstileToken} onError={setError} locale={locale} /></div> : null}
|
||||
{error ? <p className={errorClassName} id="login-form-error" role="alert"><CircleAlert size={15} aria-hidden="true" />{error}</p> : null}
|
||||
<button className={submitClassName} disabled={busy || (totpRequired && !/^\d{6}$/.test(totpCode)) || (config.turnstile_login_enabled && !turnstileToken)} type="submit"><KeyRound size={15} />{busy ? ui(locale, "登录中", "Signing in") : ui(locale, "登录", "Sign in")}</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
Regular → Executable
+5
-4
@@ -6,6 +6,7 @@ import {
|
||||
Download,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { memo } from "react";
|
||||
import {
|
||||
formatBytes,
|
||||
formatCurrency,
|
||||
@@ -94,12 +95,12 @@ function CarrierPanel({ label, rows, kind, empty }: {
|
||||
);
|
||||
}
|
||||
|
||||
export function NodeCard({ server, config, liveLatencyResults, liveConnected, onOpen }: {
|
||||
export const NodeCard = memo(function NodeCard({ server, config, liveLatencyResults, liveConnected, onOpen }: {
|
||||
server: Server;
|
||||
config: Config;
|
||||
liveLatencyResults?: LiveLatencyResult[];
|
||||
liveConnected: boolean;
|
||||
onOpen: () => void;
|
||||
onOpen: (server: Server) => void;
|
||||
}) {
|
||||
const threshold = config.offline_threshold_seconds;
|
||||
const online = isOnline(server, threshold);
|
||||
@@ -119,7 +120,7 @@ export function NodeCard({ server, config, liveLatencyResults, liveConnected, on
|
||||
const lastUpdated = new Date(number(server.timestamp) * 1000).toLocaleString(locale, { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false });
|
||||
|
||||
return (
|
||||
<button className={`node-card glass-panel ${online ? "" : "offline"}`} onClick={onOpen} type="button" aria-label={ui(locale, `${server.name},${online ? "在线" : "离线"},查看详情`, `${server.name}, ${online ? "online" : "offline"}, view details`)}>
|
||||
<button className={`node-card glass-panel ${online ? "" : "offline"}`} onClick={() => onOpen(server)} type="button" aria-label={ui(locale, `${server.name},${online ? "在线" : "离线"},查看详情`, `${server.name}, ${online ? "online" : "offline"}, view details`)}>
|
||||
<header className="node-header">
|
||||
<span className={`status-dot ${online ? "online" : ""}`} />
|
||||
<strong title={server.name}>{server.name}</strong>
|
||||
@@ -176,4 +177,4 @@ export function NodeCard({ server, config, liveLatencyResults, liveConnected, on
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
});
|
||||
Regular → Executable
+3
-3
@@ -407,13 +407,13 @@ export function NodeDetails({ server, liveLatencyResults, threshold, retentionDa
|
||||
<ResponsiveContainer width="100%" height="100%"><AreaChart data={data}><CartesianGrid stroke="var(--chart-grid)" strokeDasharray="3 3" /><XAxis dataKey="timestamp" type="number" domain={["dataMin", "dataMax"]} tick={{ fontSize: 10 }} minTickGap={30} tickFormatter={(value) => chartTimeLabel(Number(value), loadChartHours, locale)} /><YAxis domain={[0, 100]} tick={{ fontSize: 10 }} width={36} /><Tooltip contentStyle={tooltipStyle} labelFormatter={(value) => chartTimeLabel(Number(value), loadChartHours, locale)} formatter={(value) => [`${Number(value).toFixed(1)}%`, "CPU"]} /><Area type="monotone" dataKey="cpu" stroke="var(--danger-bar)" fill="var(--danger-bar)" fillOpacity={0.15} strokeWidth={2} dot={false} isAnimationActive={false} /></AreaChart></ResponsiveContainer>
|
||||
</ChartCard>
|
||||
<ChartCard title={ui(locale, "内存", "Memory")} value={`${formatBytes(last.mem_used)} / ${formatBytes(last.mem_total)}`}>
|
||||
<ResponsiveContainer width="100%" height="100%"><AreaChart data={data}><CartesianGrid stroke="var(--chart-grid)" strokeDasharray="3 3" /><XAxis dataKey="timestamp" type="number" domain={["dataMin", "dataMax"]} tick={{ fontSize: 10 }} minTickGap={30} tickFormatter={(value) => chartTimeLabel(Number(value), loadChartHours, locale)} /><YAxis domain={[0, last.mem_total]} ticks={resourceTicks(last.mem_total)} tick={{ fontSize: 10 }} width={56} tickFormatter={(value) => formatBytes(Number(value), 0)} /><Tooltip contentStyle={tooltipStyle} labelFormatter={(value) => chartTimeLabel(Number(value), loadChartHours, locale)} formatter={(value) => [formatBytes(Number(value)), "内存"]} /><Area type="monotone" dataKey="mem_used" stroke="var(--primary)" fill="var(--primary)" fillOpacity={0.15} strokeWidth={2} dot={false} isAnimationActive={false} /></AreaChart></ResponsiveContainer>
|
||||
<ResponsiveContainer width="100%" height="100%"><AreaChart data={data}><CartesianGrid stroke="var(--chart-grid)" strokeDasharray="3 3" /><XAxis dataKey="timestamp" type="number" domain={["dataMin", "dataMax"]} tick={{ fontSize: 10 }} minTickGap={30} tickFormatter={(value) => chartTimeLabel(Number(value), loadChartHours, locale)} /><YAxis domain={[0, last.mem_total]} ticks={resourceTicks(last.mem_total)} tick={{ fontSize: 10 }} width={56} tickFormatter={(value) => formatBytes(Number(value), 0)} /><Tooltip contentStyle={tooltipStyle} labelFormatter={(value) => chartTimeLabel(Number(value), loadChartHours, locale)} formatter={(value) => [formatBytes(Number(value)), ui(locale, "内存", "Memory")]} /><Area type="monotone" dataKey="mem_used" stroke="var(--primary)" fill="var(--primary)" fillOpacity={0.15} strokeWidth={2} dot={false} isAnimationActive={false} /></AreaChart></ResponsiveContainer>
|
||||
</ChartCard>
|
||||
<ChartCard title={ui(locale, "硬盘", "Disk")} value={`${formatBytes(last.disk_used)} / ${formatBytes(last.disk_total)}`}>
|
||||
<ResponsiveContainer width="100%" height="100%"><AreaChart data={data}><CartesianGrid stroke="var(--chart-grid)" strokeDasharray="3 3" /><XAxis dataKey="timestamp" type="number" domain={["dataMin", "dataMax"]} tick={{ fontSize: 10 }} minTickGap={30} tickFormatter={(value) => chartTimeLabel(Number(value), loadChartHours, locale)} /><YAxis domain={[0, last.disk_total]} ticks={resourceTicks(last.disk_total)} tick={{ fontSize: 10 }} width={56} tickFormatter={(value) => formatBytes(Number(value), 0)} /><Tooltip contentStyle={tooltipStyle} labelFormatter={(value) => chartTimeLabel(Number(value), loadChartHours, locale)} formatter={(value) => [formatBytes(Number(value)), "硬盘"]} /><Area type="monotone" dataKey="disk_used" stroke="var(--warning-bar)" fill="var(--warning-bar)" fillOpacity={0.15} strokeWidth={2} dot={false} isAnimationActive={false} /></AreaChart></ResponsiveContainer>
|
||||
<ResponsiveContainer width="100%" height="100%"><AreaChart data={data}><CartesianGrid stroke="var(--chart-grid)" strokeDasharray="3 3" /><XAxis dataKey="timestamp" type="number" domain={["dataMin", "dataMax"]} tick={{ fontSize: 10 }} minTickGap={30} tickFormatter={(value) => chartTimeLabel(Number(value), loadChartHours, locale)} /><YAxis domain={[0, last.disk_total]} ticks={resourceTicks(last.disk_total)} tick={{ fontSize: 10 }} width={56} tickFormatter={(value) => formatBytes(Number(value), 0)} /><Tooltip contentStyle={tooltipStyle} labelFormatter={(value) => chartTimeLabel(Number(value), loadChartHours, locale)} formatter={(value) => [formatBytes(Number(value)), ui(locale, "硬盘", "Disk")]} /><Area type="monotone" dataKey="disk_used" stroke="var(--warning-bar)" fill="var(--warning-bar)" fillOpacity={0.15} strokeWidth={2} dot={false} isAnimationActive={false} /></AreaChart></ResponsiveContainer>
|
||||
</ChartCard>
|
||||
<ChartCard title={ui(locale, "网络", "Network")} value={<span className="network-live-values"><b className="upload">↑ {formatSpeed(last.net_out)}</b><b className="download">↓ {formatSpeed(last.net_in)}</b></span>}>
|
||||
<ResponsiveContainer width="100%" height="100%"><AreaChart data={data} margin={{ top: 14, right: 8, bottom: 2, left: 2 }}><defs><linearGradient id="network-upload-fill" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stopColor="var(--success-bar)" stopOpacity={0.28} /><stop offset="100%" stopColor="var(--success-bar)" stopOpacity={0.02} /></linearGradient><linearGradient id="network-download-fill" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stopColor="var(--info)" stopOpacity={0.24} /><stop offset="100%" stopColor="var(--info)" stopOpacity={0.02} /></linearGradient></defs><CartesianGrid vertical={false} stroke="var(--chart-grid)" strokeDasharray="3 3" /><XAxis dataKey="timestamp" type="number" domain={["dataMin", "dataMax"]} axisLine={false} tickLine={false} tickMargin={8} tick={{ fontSize: 10 }} minTickGap={30} tickFormatter={(value) => chartTimeLabel(Number(value), loadChartHours, locale)} /><YAxis domain={[0, networkMaximum]} ticks={networkTicks} axisLine={false} tickLine={false} tickMargin={6} tick={{ fontSize: 10 }} width={76} tickFormatter={(value) => formatNetworkAxis(Number(value))} /><Tooltip contentStyle={tooltipStyle} labelFormatter={(value) => chartTimeLabel(Number(value), loadChartHours, locale)} formatter={(value, name) => [formatSpeed(Number(value)), name === "net_out" ? "上行" : "下行"]} /><Area type="monotone" dataKey="net_out" name="net_out" stroke="var(--success-bar)" fill="url(#network-upload-fill)" strokeWidth={2} dot={false} connectNulls={false} isAnimationActive={false} /><Area type="monotone" dataKey="net_in" name="net_in" stroke="var(--info)" fill="url(#network-download-fill)" strokeWidth={2} dot={false} connectNulls={false} isAnimationActive={false} /></AreaChart></ResponsiveContainer>
|
||||
<ResponsiveContainer width="100%" height="100%"><AreaChart data={data} margin={{ top: 14, right: 8, bottom: 2, left: 2 }}><defs><linearGradient id="network-upload-fill" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stopColor="var(--success-bar)" stopOpacity={0.28} /><stop offset="100%" stopColor="var(--success-bar)" stopOpacity={0.02} /></linearGradient><linearGradient id="network-download-fill" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stopColor="var(--info)" stopOpacity={0.24} /><stop offset="100%" stopColor="var(--info)" stopOpacity={0.02} /></linearGradient></defs><CartesianGrid vertical={false} stroke="var(--chart-grid)" strokeDasharray="3 3" /><XAxis dataKey="timestamp" type="number" domain={["dataMin", "dataMax"]} axisLine={false} tickLine={false} tickMargin={8} tick={{ fontSize: 10 }} minTickGap={30} tickFormatter={(value) => chartTimeLabel(Number(value), loadChartHours, locale)} /><YAxis domain={[0, networkMaximum]} ticks={networkTicks} axisLine={false} tickLine={false} tickMargin={6} tick={{ fontSize: 10 }} width={76} tickFormatter={(value) => formatNetworkAxis(Number(value))} /><Tooltip contentStyle={tooltipStyle} labelFormatter={(value) => chartTimeLabel(Number(value), loadChartHours, locale)} formatter={(value, name) => [formatSpeed(Number(value)), name === "net_out" ? ui(locale, "上行", "Upload") : ui(locale, "下行", "Download")]} /><Area type="monotone" dataKey="net_out" name="net_out" stroke="var(--success-bar)" fill="url(#network-upload-fill)" strokeWidth={2} dot={false} connectNulls={false} isAnimationActive={false} /><Area type="monotone" dataKey="net_in" name="net_in" stroke="var(--info)" fill="url(#network-download-fill)" strokeWidth={2} dot={false} connectNulls={false} isAnimationActive={false} /></AreaChart></ResponsiveContainer>
|
||||
</ChartCard>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
Regular → Executable
+5
-2
@@ -1,10 +1,13 @@
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { useState, type InputHTMLAttributes } from "react";
|
||||
import { ui, type UiLocale } from "../locale";
|
||||
|
||||
export function PasswordInput(props: Omit<InputHTMLAttributes<HTMLInputElement>, "type">) {
|
||||
export function PasswordInput({ locale, ...props }: Omit<InputHTMLAttributes<HTMLInputElement>, "type"> & { locale?: UiLocale | string }) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const showLabel = ui(locale, "显示密码", "Show password");
|
||||
const hideLabel = ui(locale, "隐藏密码", "Hide password");
|
||||
return <div className="password-field">
|
||||
<input {...props} type={visible ? "text" : "password"} />
|
||||
<button type="button" className="icon-btn" title={visible ? "隐藏密码" : "显示密码"} aria-label={visible ? "隐藏密码" : "显示密码"} aria-pressed={visible} onClick={() => setVisible(!visible)}>{visible ? <EyeOff size={16} /> : <Eye size={16} />}</button>
|
||||
<button type="button" className="icon-btn" title={visible ? hideLabel : showLabel} aria-label={visible ? hideLabel : showLabel} aria-pressed={visible} onClick={() => setVisible(!visible)}>{visible ? <EyeOff size={16} /> : <Eye size={16} />}</button>
|
||||
</div>;
|
||||
}
|
||||
Regular → Executable
+13
-11
@@ -1,6 +1,7 @@
|
||||
import { CheckCircle2, Send, Save } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api } from "../api";
|
||||
import { ui, type UiLocale } from "../locale";
|
||||
import type { TelegramSettingsInput } from "../types";
|
||||
|
||||
const defaultTemplate = "{{title}}\n\n服务器:{{server}}\n{{message}}\n时间:{{time}}";
|
||||
@@ -11,7 +12,8 @@ const emptySettings: TelegramSettingsInput = {
|
||||
template: defaultTemplate,
|
||||
};
|
||||
|
||||
export function TelegramSettings({ onError, onNotice }: {
|
||||
export function TelegramSettings({ locale, onError, onNotice }: {
|
||||
locale: UiLocale | string | undefined;
|
||||
onError: (message: string) => void;
|
||||
onNotice: (message: string) => void;
|
||||
}) {
|
||||
@@ -35,7 +37,7 @@ export function TelegramSettings({ onError, onNotice }: {
|
||||
setConfigured(false);
|
||||
}
|
||||
} catch (reason) {
|
||||
onError(reason instanceof Error ? reason.message : "读取 Telegram 配置失败");
|
||||
onError(reason instanceof Error ? reason.message : ui(locale, "读取 Telegram 配置失败", "Failed to load Telegram settings"));
|
||||
}
|
||||
}, [onError]);
|
||||
|
||||
@@ -56,9 +58,9 @@ export function TelegramSettings({ onError, onNotice }: {
|
||||
template: settings.template.trim(),
|
||||
});
|
||||
await load();
|
||||
onNotice("Telegram 配置已保存");
|
||||
onNotice(ui(locale, "Telegram 配置已保存", "Telegram settings saved"));
|
||||
} catch (reason) {
|
||||
onError(reason instanceof Error ? reason.message : "保存 Telegram 配置失败");
|
||||
onError(reason instanceof Error ? reason.message : ui(locale, "保存 Telegram 配置失败", "Failed to save Telegram settings"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -69,9 +71,9 @@ export function TelegramSettings({ onError, onNotice }: {
|
||||
onError("");
|
||||
try {
|
||||
await api.testTelegram();
|
||||
onNotice("Telegram 测试消息已发送");
|
||||
onNotice(ui(locale, "Telegram 测试消息已发送", "Telegram test message sent"));
|
||||
} catch (reason) {
|
||||
onError(reason instanceof Error ? reason.message : "Telegram 测试消息发送失败");
|
||||
onError(reason instanceof Error ? reason.message : ui(locale, "Telegram 测试消息发送失败", "Failed to send the Telegram test message"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -83,18 +85,18 @@ export function TelegramSettings({ onError, onNotice }: {
|
||||
<header className="telegram-settings-header">
|
||||
<div className="telegram-settings-title">
|
||||
<span className="telegram-mark"><Send size={18} /></span>
|
||||
<div><h3 id="telegram-settings-title">Telegram</h3><span className={`telegram-state ${configured ? "configured" : ""}`}>{configured ? <CheckCircle2 size={13} /> : null}{configured ? "已配置" : "未配置"}</span></div>
|
||||
<div><h3 id="telegram-settings-title">Telegram</h3><span className={`telegram-state ${configured ? "configured" : ""}`}>{configured ? <CheckCircle2 size={13} /> : null}{configured ? ui(locale, "已配置", "Configured") : ui(locale, "未配置", "Not configured")}</span></div>
|
||||
</div>
|
||||
</header>
|
||||
<div className="telegram-fields">
|
||||
<label className="telegram-token-field"><span>Bot Token</span><input type="password" autoComplete="off" value={settings.bot_token} onChange={(event) => update("bot_token", event.target.value)} placeholder="123456789:AA..." /></label>
|
||||
<label><span>Chat ID</span><input type="password" autoComplete="off" value={settings.chat_id} onChange={(event) => update("chat_id", event.target.value)} placeholder="-1001234567890" /></label>
|
||||
<label><span>话题 ID(可选)</span><input type="number" min="1" value={settings.message_thread_id ?? ""} onChange={(event) => update("message_thread_id", event.target.value ? Number(event.target.value) : null)} /></label>
|
||||
<label><span>{ui(locale, "话题 ID(可选)", "Topic ID (optional)")}</span><input type="number" min="1" value={settings.message_thread_id ?? ""} onChange={(event) => update("message_thread_id", event.target.value ? Number(event.target.value) : null)} /></label>
|
||||
</div>
|
||||
<label className="telegram-template"><span>消息模板</span><textarea rows={5} maxLength={4000} value={settings.template} onChange={(event) => update("template", event.target.value)} /></label>
|
||||
<label className="telegram-template"><span>{ui(locale, "消息模板", "Message template")}</span><textarea rows={5} maxLength={4000} value={settings.template} onChange={(event) => update("template", event.target.value)} /></label>
|
||||
<div className="telegram-actions">
|
||||
<button type="button" className="secondary-btn" disabled={busy || !configured} onClick={() => void test()}><Send size={15} />发送测试</button>
|
||||
<button type="button" className="primary-btn" disabled={busy || !ready} onClick={() => void save()}><Save size={15} />保存 Telegram</button>
|
||||
<button type="button" className="secondary-btn" disabled={busy || !configured} onClick={() => void test()}><Send size={15} />{ui(locale, "发送测试", "Send test")}</button>
|
||||
<button type="button" className="primary-btn" disabled={busy || !ready} onClick={() => void save()}><Save size={15} />{ui(locale, "保存 Telegram", "Save Telegram")}</button>
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
Regular → Executable
+6
-2
@@ -39,6 +39,7 @@ export function TurnstileWidget({
|
||||
resetKey,
|
||||
onVerify,
|
||||
onError,
|
||||
locale,
|
||||
}: {
|
||||
siteKey: string;
|
||||
action: "admin_login" | "public_dashboard";
|
||||
@@ -46,6 +47,7 @@ export function TurnstileWidget({
|
||||
resetKey?: number;
|
||||
onVerify: (token: string) => void;
|
||||
onError: (message: string) => void;
|
||||
locale?: string;
|
||||
}) {
|
||||
const target = useRef<HTMLDivElement>(null);
|
||||
const widgetId = useRef("");
|
||||
@@ -78,10 +80,12 @@ export function TurnstileWidget({
|
||||
"error-callback": () => {
|
||||
setVerified(false);
|
||||
verifyCallback.current("");
|
||||
errorCallback.current("Cloudflare 验证暂时不可用,请刷新重试");
|
||||
errorCallback.current(locale === "en"
|
||||
? "Cloudflare verification is temporarily unavailable; refresh and try again"
|
||||
: "Cloudflare 验证暂时不可用,请刷新重试");
|
||||
},
|
||||
});
|
||||
}).catch((reason) => errorCallback.current(reason instanceof Error ? reason.message : "验证组件加载失败"));
|
||||
}).catch(() => errorCallback.current(locale === "en" ? "Failed to load the verification widget" : "验证组件加载失败"));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (widgetId.current && window.turnstile) window.turnstile.remove(widgetId.current);
|
||||
|
||||
Executable
+17
@@ -0,0 +1,17 @@
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { ui, type UiLocale } from "../locale";
|
||||
import { SiteLogo } from "../SiteLogo";
|
||||
import { VERSION } from "./shared";
|
||||
|
||||
export function AboutTab({ locale }: { locale: UiLocale | string | undefined }) {
|
||||
return (
|
||||
<div className="admin-section about-page">
|
||||
<div className="about-brand"><SiteLogo alt="" width="52" height="52" /><div><strong>NodeFlare</strong><small>{ui(locale, "基于 Rust、Axum、SQLite / PostgreSQL 与 WebSocket 的服务器监控", "Server monitoring built with Rust, Axum, SQLite / PostgreSQL and WebSocket")}</small></div></div>
|
||||
<div className="about-rows">
|
||||
<div className="about-row"><span>{ui(locale, "版本", "Version")}</span><strong>v{VERSION}</strong></div>
|
||||
<div className="about-row"><span>{ui(locale, "项目地址", "Repository")}</span><a href="https://github.com/elysia62/NodeFlare" target="_blank" rel="noreferrer">github.com/elysia62/NodeFlare<ExternalLink size={13} /></a></div>
|
||||
<div className="about-row"><span>{ui(locale, "开源协议", "License")}</span><strong>MIT License</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Executable
+74
@@ -0,0 +1,74 @@
|
||||
import { RotateCw, Search, ShieldCheck, Terminal } from "lucide-react";
|
||||
import type { FormEvent } from "react";
|
||||
import type { AdminTab } from "../adminRoutes";
|
||||
import { REMOTE_TASK_POLL_TIMEOUT_MS } from "../refresh";
|
||||
import type { AdminServer, RemoteTask, TotpStatus } from "../types";
|
||||
import { ui, type UiLocale } from "../locale";
|
||||
import { Checkbox } from "../Checkbox";
|
||||
import { remoteTaskStatusLabels } from "./shared";
|
||||
|
||||
export interface RemoteTabProps {
|
||||
locale: UiLocale | string | undefined;
|
||||
servers: AdminServer[];
|
||||
busy: boolean;
|
||||
selectTab: (next: AdminTab) => void;
|
||||
createRemoteTask: (event: FormEvent) => void;
|
||||
remoteCommand: string;
|
||||
setRemoteCommand: (value: string) => void;
|
||||
remoteSelectedIds: string[];
|
||||
setRemoteSelectedIds: (ids: string[]) => void;
|
||||
remoteAllSelected: boolean;
|
||||
remoteQuery: string;
|
||||
setRemoteQuery: (value: string) => void;
|
||||
remoteVisibleServers: AdminServer[];
|
||||
toggleRemoteServer: (serverId: string) => void;
|
||||
remotePayloadReady: boolean;
|
||||
remoteTasks: RemoteTask[];
|
||||
remoteTasksActive: boolean;
|
||||
remotePollingUntil: number;
|
||||
setRemotePollingUntil: (value: number) => void;
|
||||
refreshRemoteTasks: (quiet?: boolean, includeCompleted?: boolean) => void;
|
||||
remoteServerById: Map<string, AdminServer>;
|
||||
twoFactorStatus: TotpStatus | null;
|
||||
}
|
||||
|
||||
export function RemoteTab({ locale, servers, busy, selectTab, createRemoteTask, remoteCommand, setRemoteCommand, remoteSelectedIds, setRemoteSelectedIds, remoteAllSelected, remoteQuery, setRemoteQuery, remoteVisibleServers, toggleRemoteServer, remotePayloadReady, remoteTasks, remoteTasksActive, remotePollingUntil, setRemotePollingUntil, refreshRemoteTasks, remoteServerById, twoFactorStatus }: RemoteTabProps) {
|
||||
return (
|
||||
<div className="admin-section remote-section">
|
||||
<form className="remote-execution-form" onSubmit={(event) => void createRemoteTask(event)}>
|
||||
<label className="remote-command-field">
|
||||
<span>{ui(locale, "执行命令", "Command")}</span>
|
||||
<textarea autoFocus required rows={5} maxLength={16_384} spellCheck={false} value={remoteCommand} onChange={(event) => setRemoteCommand(event.target.value)} />
|
||||
<small>{ui(locale, "多行内容按一个脚本执行,完成后返回输出,单条命令最长执行 10 分钟。离开页面不会终止已下发的命令。", "Multiple lines run as one script; output is returned when finished. A command may run up to 10 minutes. Leaving this page does not stop dispatched commands.")}</small>
|
||||
</label>
|
||||
|
||||
<div className="server-picker remote-server-picker">
|
||||
<div className="server-picker-head"><strong>{ui(locale, "选择服务器", "Select servers")}</strong><span>{ui(locale, `已选 ${remoteSelectedIds.length} / 共 ${servers.length}`, `Selected ${remoteSelectedIds.length} / ${servers.length}`)}</span><button type="button" onClick={() => setRemoteSelectedIds(remoteAllSelected ? [] : servers.map((server) => server.id))}>{remoteAllSelected ? ui(locale, "取消全选", "Deselect all") : ui(locale, "全选", "Select all")}</button></div>
|
||||
<div className="server-picker-search"><Search size={16} /><input aria-label={ui(locale, "搜索远程执行服务器", "Search servers for remote execution")} placeholder={ui(locale, "搜索服务器", "Search servers")} value={remoteQuery} onChange={(event) => setRemoteQuery(event.target.value)} /></div>
|
||||
<div className="server-picker-list">
|
||||
{remoteVisibleServers.map((server) => <label className="server-picker-row" key={server.id}><Checkbox checked={remoteSelectedIds.includes(server.id)} onChange={() => toggleRemoteServer(server.id)} ariaLabel={ui(locale, `选择 ${server.name}`, `Select ${server.name}`)} /><span><strong>{server.name}</strong><small>{server.group_name || "默认"}</small></span></label>)}
|
||||
{!remoteVisibleServers.length ? <div className="server-picker-empty">{servers.length ? ui(locale, "没有匹配的服务器", "No matching servers") : ui(locale, "暂无服务器", "No servers yet")}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{twoFactorStatus === null ? <p className="settings-hint">{ui(locale, "正在确认 TOTP 两步验证状态…", "Checking TOTP two-factor status…")}</p> : twoFactorStatus.enabled ? <div className="remote-confirm-row"><button className="primary-btn remote-submit" disabled={busy || !remoteSelectedIds.length || !remotePayloadReady}><Terminal size={15} />{busy ? ui(locale, "下发中", "Dispatching") : ui(locale, `确认执行${remoteSelectedIds.length ? ` (${remoteSelectedIds.length})` : ""}`, `Run now${remoteSelectedIds.length ? ` (${remoteSelectedIds.length})` : ""}`)}</button></div> : <div className="remote-2fa-required"><ShieldCheck size={18} /><div><strong>{ui(locale, "远程执行需要 TOTP 两步验证", "Remote execution requires TOTP two-factor authentication")}</strong><span>{ui(locale, "启用后才能向 Agent 发送命令。", "Enable it before sending commands to agents.")}</span></div><button type="button" className="secondary-btn compact" onClick={() => selectTab("security")}>{ui(locale, "前往启用", "Enable now")}</button></div>}
|
||||
</form>
|
||||
|
||||
{remoteTasks.length > 0 ? <div className="remote-results">
|
||||
<div className="section-head"><div><h3>{ui(locale, "执行结果", "Results")}</h3>{remoteTasksActive ? <span className="remote-auto-refresh">{remotePollingUntil ? <RotateCw size={12} /> : null}{remotePollingUntil ? ui(locale, "等待结果,每 2 秒自动刷新", "Waiting for results; auto-refreshing every 2s") : ui(locale, "自动刷新已暂停", "Auto-refresh paused")}</span> : <span>{ui(locale, "本次命令已结束", "This run has finished")}</span>}</div><button type="button" className="secondary-btn compact" disabled={busy} onClick={() => { setRemotePollingUntil(Date.now() + REMOTE_TASK_POLL_TIMEOUT_MS); void refreshRemoteTasks(false, true); }}><RotateCw size={14} />{ui(locale, "刷新结果", "Refresh results")}</button></div>
|
||||
{remoteTasksActive && !remotePollingUntil ? <p className="settings-hint" role="status">ui(locale, "已等待 1 分钟,命令可能仍在执行。点击“刷新结果”可继续查询,暂停刷新不会停止命令。", "Waited 1 minute; the command may still be running. Click \"Refresh results\" to keep polling; pausing refresh does not stop the command.")</p> : null}
|
||||
<div className="remote-command-summary"><span>{ui(locale, "本次命令", "Command")}</span><code>{remoteTasks[0]?.command}</code></div>
|
||||
<div className="task-list">
|
||||
{remoteTasks.map((task) => {
|
||||
const server = remoteServerById.get(task.server_id);
|
||||
return <div key={task.id} className="task-item remote-result-item">
|
||||
<div className="task-header"><strong className="remote-result-server">{server?.name ?? task.server_id}</strong><span className={`task-status ${task.status}`}>{remoteTaskStatusLabels(locale)[task.status]}</span><span className="task-time">{new Date(task.requested_at * 1000).toLocaleString()}</span></div>
|
||||
{task.status === "success" || task.status === "failed" ? <pre className={`task-result ${task.status}`}>{task.result || ui(locale, "(命令没有输出)", "(no output)")}</pre> : <p className="task-progress">{task.status === "pending" ? ui(locale, "等待 Agent 确认接收;断线后不会自动重发命令。", "Waiting for the agent to acknowledge; it will not be resent after a disconnect.") : ui(locale, "Agent 已接收,正在等待执行结果…", "Received by the agent; waiting for the result…")}</p>}
|
||||
</div>;
|
||||
})}
|
||||
</div>
|
||||
</div> : null}
|
||||
</div>
|
||||
|
||||
);
|
||||
}
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
import { AlertTriangle, ArrowRightLeft, Check, ChevronDown, ChevronUp, CircleCheck, Coins, Copy, Download, Eye, LogOut, MonitorSmartphone, Power, RotateCw, Save, ShieldCheck, SlidersHorizontal, Upload } from "lucide-react";
|
||||
import type { Dispatch, FormEvent, RefObject, SetStateAction } from "react";
|
||||
import type { AdminTab } from "../adminRoutes";
|
||||
import { formatBytes } from "../format";
|
||||
import { ui, type UiLocale } from "../locale";
|
||||
import { ASSET_CURRENCIES, type AdminServer, type DatabaseMigrationResult, type DatabaseStats, type ExchangeRates, type LoginSession, type Settings, type ThemeSettingsSchema, type ThemeSettingValue, type TotpSetup, type TotpStatus } from "../types";
|
||||
import { AlertRuleManager } from "../AlertRuleManager";
|
||||
import { PasswordInput } from "../PasswordInput";
|
||||
import { TelegramSettings } from "../TelegramSettings";
|
||||
import { ThemeOption, Toggle } from "./controls";
|
||||
import { describeLoginDevice, formatSessionTime } from "./shared";
|
||||
|
||||
export interface SettingsTabsProps {
|
||||
tab: AdminTab;
|
||||
locale: UiLocale | string | undefined;
|
||||
settings: Settings;
|
||||
servers: AdminServer[];
|
||||
busy: boolean;
|
||||
saveSite: (event: FormEvent) => void;
|
||||
migrateDatabase: () => void;
|
||||
updateSettings: <K extends keyof Settings>(key: K, value: Settings[K]) => void;
|
||||
themeSettingsSchema: ThemeSettingsSchema | null;
|
||||
updateThemeOption: (key: string, value: ThemeSettingValue) => void;
|
||||
setError: (message: string) => void;
|
||||
setNotice: (message: string) => void;
|
||||
newPasswordConfirmation: string;
|
||||
setNewPasswordConfirmation: (value: string) => void;
|
||||
twoFactorStatus: TotpStatus | null;
|
||||
twoFactorSetup: TotpSetup | null;
|
||||
twoFactorSecretCopied: boolean;
|
||||
copyTwoFactorSecret: () => void;
|
||||
setupTwoFactor: () => void;
|
||||
enableTwoFactor: () => void;
|
||||
disableTwoFactor: () => void;
|
||||
loginSessions: LoginSession[];
|
||||
loginSessionsLoaded: boolean;
|
||||
loadLoginSessions: () => void;
|
||||
revokeLoginSession: (session: LoginSession) => void;
|
||||
revokingSessionId: string;
|
||||
database: DatabaseStats | null;
|
||||
databaseRestoreInputRef: RefObject<HTMLInputElement | null>;
|
||||
databaseMigrationUrl: string;
|
||||
setDatabaseMigrationUrl: (value: string) => void;
|
||||
databaseMigrationResult: DatabaseMigrationResult | null;
|
||||
exportDatabaseBackup: () => void;
|
||||
restoreDatabaseBackup: (file: File) => void;
|
||||
reclaimDatabase: () => void;
|
||||
restarting: boolean;
|
||||
restartAfterDatabaseMigration: () => void;
|
||||
exchangeRates: ExchangeRates | null;
|
||||
exchangeRatesExpanded: boolean;
|
||||
setExchangeRatesExpanded: Dispatch<SetStateAction<boolean>>;
|
||||
refreshExchangeRates: () => void;
|
||||
}
|
||||
|
||||
export function SettingsTabs({ tab, locale, settings, servers, busy, saveSite, migrateDatabase, updateSettings, themeSettingsSchema, updateThemeOption, setError, setNotice, newPasswordConfirmation, setNewPasswordConfirmation, twoFactorStatus, twoFactorSetup, twoFactorSecretCopied, copyTwoFactorSecret, setupTwoFactor, enableTwoFactor, disableTwoFactor, loginSessions, loginSessionsLoaded, loadLoginSessions, revokeLoginSession, revokingSessionId, database, databaseRestoreInputRef, databaseMigrationUrl, setDatabaseMigrationUrl, databaseMigrationResult, exportDatabaseBackup, restoreDatabaseBackup, reclaimDatabase, restarting, restartAfterDatabaseMigration, exchangeRates, exchangeRatesExpanded, setExchangeRatesExpanded, refreshExchangeRates }: SettingsTabsProps) {
|
||||
return (
|
||||
<form className="settings-form" onSubmit={tab === "data" ? (event) => { event.preventDefault(); void migrateDatabase(); } : saveSite}>
|
||||
{tab === "appearance" ? <>
|
||||
<div className="section-title"><Eye size={15} />{ui(locale, "外观与展示", "Appearance")}</div>
|
||||
<div className="form-grid"><label><span>{ui(locale, "站点名称", "Site name")}</span><input required value={settings.site_name} onChange={(event) => updateSettings("site_name", event.target.value)} /></label><label><span>{ui(locale, "站点描述", "Site description")}</span><input value={settings.site_description} onChange={(event) => updateSettings("site_description", event.target.value)} /></label></div>
|
||||
<label><span>{ui(locale, "站点公告", "Site announcement")}</span><textarea rows={3} maxLength={1000} value={settings.site_announcement} onChange={(event) => updateSettings("site_announcement", event.target.value)} /></label>
|
||||
<div className="form-grid"><label><span>{ui(locale, "界面语言", "Language")}</span><select value={settings.locale} onChange={(event) => updateSettings("locale", event.target.value as Settings["locale"])}><option value="zh-CN">简体中文</option><option value="en">English</option></select></label><label><span>{ui(locale, "站点 Logo / 浏览器图标地址", "Site logo / favicon URL")}</span><input type="url" maxLength={1000} value={settings.logo_url} onChange={(event) => updateSettings("logo_url", event.target.value)} placeholder="https://example.com/logo.svg" /></label></div>
|
||||
<div className="form-grid"><label><span>{ui(locale, "离线判定(秒)", "Offline threshold (s)")}</span><input type="number" min="30" max="3600" value={settings.offline_threshold_seconds} onChange={(event) => updateSettings("offline_threshold_seconds", Number(event.target.value))} /></label><label><span>{ui(locale, "历史保留(天)", "History retention (days)")}</span><input type="number" min="1" max="30" value={settings.history_retention_days} onChange={(event) => updateSettings("history_retention_days", Number(event.target.value))} /></label></div>
|
||||
</> : null}
|
||||
|
||||
{tab === "themeSettings" ? <>
|
||||
<div className="section-title"><SlidersHorizontal size={15} />{ui(locale, "通用主题设置", "General theme settings")}</div>
|
||||
<div className="form-grid"><label><span>{ui(locale, "默认主题", "Default theme")}</span><select value={settings.default_theme} onChange={(event) => updateSettings("default_theme", event.target.value as Settings["default_theme"])}><option value="system">{ui(locale, "跟随系统", "System")}</option><option value="light">{ui(locale, "浅色", "Light")}</option><option value="dark">{ui(locale, "深色", "Dark")}</option></select></label><label><span>{ui(locale, "背景图地址", "Background image URL")}</span><input type="text" maxLength={1000} value={settings.background_url} onChange={(event) => updateSettings("background_url", event.target.value)} placeholder="https://example.com/light.webp | https://example.com/dark.webp" /></label></div>
|
||||
<p className="settings-hint">{ui(locale, "仅支持 HTTPS 地址;浅色和深色背景可用 | 分隔。", "HTTPS only; separate light and dark backgrounds with |.")}</p>
|
||||
<div className="section-subtitle">{ui(locale, "当前主题选项", "Active theme options")}</div><div className="theme-option-grid"><Toggle label={ui(locale, "公开仪表盘", "Public dashboard")} checked={settings.public_dashboard} onChange={(value) => updateSettings("public_dashboard", value)} />{themeSettingsSchema?.settings.map((field) => <ThemeOption key={field.key} field={field} value={settings.theme_options[field.key] ?? field.default} onChange={(value) => updateThemeOption(field.key, value)} />)}</div>
|
||||
<div className="section-subtitle">{ui(locale, "公开界面元素", "Public UI elements")}</div>
|
||||
<div className="settings-toggles"><Toggle label={ui(locale, "显示搜索", "Show search")} checked={settings.show_search} onChange={(value) => updateSettings("show_search", value)} /><Toggle label={ui(locale, "显示分组", "Show groups")} checked={settings.show_groups} onChange={(value) => updateSettings("show_groups", value)} /><Toggle label={ui(locale, "总览统计", "Overview stats")} checked={settings.show_stats} onChange={(value) => updateSettings("show_stats", value)} /><Toggle label={ui(locale, "资产统计", "Asset stats")} checked={settings.show_assets} onChange={(value) => updateSettings("show_assets", value)} /><Toggle label={ui(locale, "累计流量", "Total traffic")} checked={settings.show_traffic} onChange={(value) => updateSettings("show_traffic", value)} /><Toggle label={ui(locale, "实时网速", "Live speed")} checked={settings.show_speed} onChange={(value) => updateSettings("show_speed", value)} /><Toggle label={ui(locale, "价格信息", "Price info")} checked={settings.show_price} onChange={(value) => updateSettings("show_price", value)} /><Toggle label={ui(locale, "到期信息", "Expiry info")} checked={settings.show_expiry} onChange={(value) => updateSettings("show_expiry", value)} /><Toggle label={ui(locale, "延迟与丢包", "Latency & loss")} checked={settings.show_latency} onChange={(value) => updateSettings("show_latency", value)} /><Toggle label={ui(locale, "在线时长", "Uptime")} checked={settings.show_uptime} onChange={(value) => updateSettings("show_uptime", value)} /></div>
|
||||
</> : null}
|
||||
|
||||
{tab === "alerts" ? <>
|
||||
<div className="section-title"><AlertTriangle size={15} />{ui(locale, "通知与告警", "Notifications & alerts")}</div>
|
||||
<Toggle label={ui(locale, "启用通知与告警", "Enable notifications & alerts")} checked={settings.notification_enabled} onChange={(value) => updateSettings("notification_enabled", value)} />
|
||||
<div className="form-grid three"><label><span>{ui(locale, "离线告警延迟(分钟)", "Offline alert delay (min)")}</span><input type="number" min="2" max="1440" value={settings.offline_alert_minutes} onChange={(event) => updateSettings("offline_alert_minutes", Number(event.target.value))} /></label><label><span>{ui(locale, "到期提醒(天)", "Expiry reminder (days)")}</span><input type="number" min="0" max="365" value={settings.expiry_alert_days} onChange={(event) => updateSettings("expiry_alert_days", Number(event.target.value))} /></label><label><span>{ui(locale, "流量提醒起始阈值(%)", "Traffic alert threshold (%)")}</span><input type="number" min="50" max="100" value={settings.traffic_alert_percentage} onChange={(event) => updateSettings("traffic_alert_percentage", Number(event.target.value))} /></label></div>
|
||||
<p className="settings-hint">{ui(locale, "流量达到起始阈值后,每增加 5 个百分点生成一次事件,最多提醒到 100%。", "After the threshold is reached, an event is created for every additional 5 percentage points, up to 100%.")}</p>
|
||||
<TelegramSettings locale={locale} onError={setError} onNotice={setNotice} />
|
||||
<AlertRuleManager locale={locale} servers={servers} onError={setError} onNotice={setNotice} />
|
||||
</> : null}
|
||||
|
||||
{tab === "security" ? <>
|
||||
<div className="section-title"><ShieldCheck size={15} />{ui(locale, "账号与 Cloudflare 防护", "Account & Cloudflare protection")}</div>
|
||||
<div className="form-grid three"><label><span>{ui(locale, "管理员用户名", "Admin username")}</span><input autoComplete="username" value={settings.admin_username} onChange={(event) => updateSettings("admin_username", event.target.value)} /></label><label><span>{ui(locale, "新密码(留空不修改)", "New password (leave empty to keep)")}</span><PasswordInput locale={locale} autoComplete="new-password" minLength={8} maxLength={128} value={settings.new_password || ""} onChange={(event) => { updateSettings("new_password", event.target.value); setNewPasswordConfirmation(""); }} placeholder={ui(locale, "至少 8 个字符", "At least 8 characters")} /></label><label><span>{ui(locale, "确认新密码", "Confirm new password")}</span><PasswordInput locale={locale} autoComplete="new-password" minLength={8} maxLength={128} value={newPasswordConfirmation} onChange={(event) => setNewPasswordConfirmation(event.target.value)} placeholder={ui(locale, "再次输入新密码", "Re-enter the new password")} /></label></div>
|
||||
<div className="two-factor-panel">
|
||||
<div className="two-factor-head"><div><div className="section-subtitle">{ui(locale, "TOTP 两步验证", "TOTP two-factor authentication")}</div><p className="settings-hint">{ui(locale, "启用后管理员登录和每次远程执行都必须提交验证器生成的 6 位动态码。", "Once enabled, admin sign-in and every remote execution require the 6-digit code from your authenticator.")}</p></div><span className={`two-factor-status ${twoFactorStatus?.enabled ? "enabled" : ""}`}>{twoFactorStatus?.enabled ? ui(locale, "已启用", "Enabled") : twoFactorStatus ? ui(locale, "未启用", "Not enabled") : ui(locale, "读取中", "Loading")}</span></div>
|
||||
{twoFactorSetup ? <div className="two-factor-setup">
|
||||
<label><span>{ui(locale, "验证器密钥", "Authenticator secret")}</span><div className="copy-field"><input readOnly value={twoFactorSetup.secret} onFocus={(event) => event.currentTarget.select()} onClick={(event) => event.currentTarget.select()} /><button type="button" className={`secondary-btn compact copy-secret-btn ${twoFactorSecretCopied ? "copied" : ""}`} aria-live="polite" onClick={() => void copyTwoFactorSecret()}>{twoFactorSecretCopied ? <Check size={14} /> : <Copy size={14} />}{twoFactorSecretCopied ? ui(locale, "已复制", "Copied") : ui(locale, "复制密钥", "Copy secret")}</button></div></label>
|
||||
<p className="settings-hint">{ui(locale, "在 Google Authenticator、Aegis、2FAS 等验证器中手动输入该密钥,再填写当前 6 位验证码确认。", "Enter this secret manually in an authenticator such as Google Authenticator, Aegis or 2FAS, then confirm with the current 6-digit code.")}</p>
|
||||
</div> : null}
|
||||
{twoFactorStatus?.enabled ? <div className="two-factor-actions"><button type="button" className="danger-btn two-factor-disable-btn" disabled={busy} onClick={() => void disableTwoFactor()}>{ui(locale, "禁用两步验证", "Disable two-factor authentication")}</button></div> : <div className="two-factor-actions">
|
||||
{!twoFactorSetup ? <p className="settings-hint">{twoFactorStatus?.has_secret ? ui(locale, "已有未启用的密钥;重新生成后,旧密钥会失效。", "A disabled secret exists; regenerating invalidates the old one.") : ui(locale, "尚未生成两步验证密钥。", "No two-factor secret has been generated yet.")}</p> : null}
|
||||
<button type="button" className="secondary-btn two-factor-generate-btn" disabled={busy} onClick={() => void setupTwoFactor()}>{twoFactorStatus?.has_secret ? ui(locale, "重新生成密钥", "Regenerate secret") : ui(locale, "生成密钥", "Generate secret")}</button>
|
||||
{twoFactorSetup ? <button type="button" className="primary-btn" disabled={busy} onClick={() => void enableTwoFactor()}>{ui(locale, "启用两步验证", "Enable two-factor authentication")}</button> : null}
|
||||
</div>}
|
||||
</div>
|
||||
<div className="login-devices-panel">
|
||||
<div className="login-devices-head"><div><div className="section-subtitle">{ui(locale, "登录设备", "Signed-in devices")}</div><p className="settings-hint">{ui(locale, "踢下线后,对应设备的登录状态会立即失效。", "Signing out a device invalidates its session immediately.")}</p></div><button type="button" className="secondary-btn compact" disabled={Boolean(revokingSessionId)} onClick={() => void loadLoginSessions()}><RotateCw size={14} />{ui(locale, "刷新", "Refresh")}</button></div>
|
||||
{!loginSessionsLoaded ? <p className="settings-hint">{ui(locale, "正在读取登录设备...", "Loading devices...")}</p> : loginSessions.length ? <div className="login-device-list">{loginSessions.map((session) => <div className="login-device-row" key={session.id}><span className="login-device-icon"><MonitorSmartphone size={18} /></span><div className="login-device-copy"><div className="login-device-title"><strong>{describeLoginDevice(session.user_agent, locale)}</strong>{session.current ? <span className="login-device-current">{ui(locale, "当前设备", "Current device")}</span> : null}</div><span>{session.ip_address || ui(locale, "未知 IP", "Unknown IP")}</span><small title={session.user_agent}>{ui(locale, `最近活动 ${formatSessionTime(session.last_seen_at)} · 登录于 ${formatSessionTime(session.created_at)} · 到期 ${formatSessionTime(session.expires_at)}`, `Last active ${formatSessionTime(session.last_seen_at)} · Signed in ${formatSessionTime(session.created_at)} · Expires ${formatSessionTime(session.expires_at)}`)}</small></div>{!session.current ? <button type="button" className="danger-btn compact login-device-revoke" disabled={Boolean(revokingSessionId)} onClick={() => void revokeLoginSession(session)}><LogOut size={14} />{revokingSessionId === session.id ? ui(locale, "下线中", "Signing out") : ui(locale, "踢下线", "Sign out")}</button> : null}</div>)}</div> : <p className="settings-hint">{ui(locale, "暂无有效登录设备。", "No active devices.")}</p>}
|
||||
</div>
|
||||
<div className="form-grid"><Toggle label={ui(locale, "保护公开仪表盘", "Protect public dashboard")} checked={settings.turnstile_enabled} onChange={(value) => updateSettings("turnstile_enabled", value)} /><Toggle label={ui(locale, "保护管理员登录", "Protect admin sign-in")} checked={settings.turnstile_login_enabled} onChange={(value) => updateSettings("turnstile_login_enabled", value)} /></div>
|
||||
<div className="form-grid"><label><span>Turnstile Site Key</span><input autoComplete="off" type="password" value={settings.turnstile_site_key} onChange={(event) => updateSettings("turnstile_site_key", event.target.value)} /></label><label><span>Turnstile Secret Key</span><input autoComplete="off" type="password" value={settings.turnstile_secret_key} onChange={(event) => updateSettings("turnstile_secret_key", event.target.value)} /></label></div>
|
||||
</> : null}
|
||||
|
||||
{tab === "data" ? <>
|
||||
<div className="section-head database-section-head"><div><h3>{ui(locale, "数据库维护", "Database maintenance")}</h3><span>{ui(locale, "备份包含节点、设置、历史、主题文件、任务及安全配置,请妥善保管。", "Backups include servers, settings, history, theme files, tasks and security configuration. Keep them safe.")}</span></div><div className="section-actions"><button type="button" className="secondary-btn compact" disabled={busy || database?.restart_required} onClick={() => void exportDatabaseBackup()}><Download size={15} />{ui(locale, "导出备份", "Export backup")}</button><button type="button" className="secondary-btn compact" disabled={busy || database?.restart_required} onClick={() => databaseRestoreInputRef.current?.click()}><Upload size={15} />{ui(locale, "恢复备份", "Restore backup")}</button><input ref={databaseRestoreInputRef} hidden type="file" accept=".zip,application/zip" onChange={(event) => { const file = event.currentTarget.files?.[0]; event.currentTarget.value = ""; if (file) void restoreDatabaseBackup(file); }} /></div></div>
|
||||
<div className="database-storage"><div><span>{ui(locale, "数据库大小", "Database size")}</span><strong>{database ? formatBytes(database.size_bytes) : ui(locale, "读取中...", "Loading...")}</strong>{database ? <small>{database.kind === "postgresql" ? "PostgreSQL" : "SQLite"}{database.reclaimable_bytes ? ui(locale, ` · 可回收 ${formatBytes(database.reclaimable_bytes)}`, ` · ${formatBytes(database.reclaimable_bytes)} reclaimable`) : ""}</small> : null}</div><button type="button" className="secondary-btn" disabled={busy || !database || database.restart_required} onClick={() => void reclaimDatabase()}><RotateCw size={15} />{ui(locale, "回收空间", "Reclaim space")}</button></div>
|
||||
<div className="database-migration">
|
||||
<div><div className="section-title"><ArrowRightLeft size={15} />{ui(locale, "数据库迁移", "Database migration")}</div><p className="settings-hint">{ui(locale, `将当前 ${database?.kind === "postgresql" ? "PostgreSQL" : "SQLite"} 数据复制到 ${database?.kind === "postgresql" ? "SQLite" : "PostgreSQL"},完成后自动更新配置,重启服务后生效。`, `Copy data from the current ${database?.kind === "postgresql" ? "PostgreSQL" : "SQLite"} database to ${database?.kind === "postgresql" ? "SQLite" : "PostgreSQL"}; the config is updated automatically and takes effect after a restart.`)}</p></div>
|
||||
{database?.restart_required ? <div className="database-migration-ready" role="status" aria-live="polite"><CircleCheck size={20} /><div><strong>{ui(locale, "迁移完成,业务写入已暂停", "Migration complete; writes are paused")}</strong><span>{databaseMigrationResult ? ui(locale, `已复制 ${databaseMigrationResult.migrated_rows.toLocaleString()} 行,新数据库大小 ${formatBytes(databaseMigrationResult.size_bytes)},请重启服务`, `Copied ${databaseMigrationResult.migrated_rows.toLocaleString()} rows; new database size ${formatBytes(databaseMigrationResult.size_bytes)}. Restart the service`) : ui(locale, "配置已更新,重启后切换到新数据库并恢复写入", "Config updated; restart to switch to the new database and resume writes")}</span></div><button type="button" className="primary-btn" disabled={busy} onClick={() => void restartAfterDatabaseMigration()}>{restarting ? <RotateCw className="spin" size={15} /> : <Power size={15} />}{restarting ? ui(locale, "正在重启", "Restarting") : ui(locale, "立即重启", "Restart now")}</button></div> : <div className="database-migration-form"><label><span>{ui(locale, "目标数据库 URL", "Target database URL")}</span><input required type="password" autoComplete="off" maxLength={2048} spellCheck={false} value={databaseMigrationUrl} onChange={(event) => setDatabaseMigrationUrl(event.target.value)} placeholder={database?.kind === "postgresql" ? "sqlite://nodeflare-migrated.db" : "postgres://user:password@127.0.0.1:5432/nodeflare?sslmode=prefer"} /></label><button className="primary-btn" disabled={busy || !databaseMigrationUrl.trim()}><ArrowRightLeft size={15} />{busy ? ui(locale, "迁移中", "Migrating") : ui(locale, "开始迁移", "Start migration")}</button></div>}
|
||||
</div>
|
||||
<div className="usage-section">
|
||||
<div className="usage-head"><div><div className="section-title"><Coins size={15} />{ui(locale, "每日汇率", "Daily exchange rates")}</div><p className="settings-hint">{exchangeRates ? `${exchangeRates.source} · ${exchangeRates.date || ui(locale, "等待首次更新", "awaiting first update")}${exchangeRates.stale ? ui(locale, " · 数据待更新", " · stale") : ""}` : ui(locale, "正在读取汇率快照", "Loading rate snapshot")}</p></div><div className="usage-head-actions">{exchangeRatesExpanded ? <button type="button" className="secondary-btn compact" disabled={busy || database?.restart_required} onClick={() => void refreshExchangeRates()}><RotateCw size={15} />{ui(locale, "立即更新", "Refresh now")}</button> : null}<button type="button" className="secondary-btn compact usage-toggle" aria-expanded={exchangeRatesExpanded} onClick={() => setExchangeRatesExpanded((expanded) => !expanded)}>{exchangeRatesExpanded ? <ChevronUp size={15} /> : <ChevronDown size={15} />}{exchangeRatesExpanded ? ui(locale, "收起", "Collapse") : ui(locale, "展开", "Expand")}</button></div></div>
|
||||
{exchangeRatesExpanded ? exchangeRates ? <div className="usage-table-wrap"><table className="usage-table"><thead><tr><th>{ui(locale, "币种", "Currency")}</th><th>{ui(locale, "1 CNY 可兑换", "1 CNY equals")}</th></tr></thead><tbody>{ASSET_CURRENCIES.filter((currency) => currency !== "CNY").map((currency) => <tr key={currency}><th scope="row">{currency}</th><td>{exchangeRates.rates[currency]?.toLocaleString(undefined, { maximumFractionDigits: 6 }) ?? "--"}</td></tr>)}</tbody></table></div> : <div className="usage-empty">{ui(locale, "尚未读取", "Not loaded")}</div> : null}
|
||||
</div>
|
||||
</> : null}
|
||||
|
||||
{tab !== "data" ? <div className="form-actions"><button className="primary-btn" disabled={busy}><Save size={15} />{ui(locale, "保存设置", "Save settings")}</button></div> : null}
|
||||
</form>
|
||||
|
||||
);
|
||||
}
|
||||
Executable
+22
@@ -0,0 +1,22 @@
|
||||
import { Checkbox } from "../Checkbox";
|
||||
import { type ThemeSettingField, type ThemeSettingValue } from "../types";
|
||||
|
||||
export function Toggle({ label, checked, onChange }: { label: string; checked: boolean; onChange: (value: boolean) => void }) {
|
||||
return <label className="toggle-row"><span><b>{label}</b></span><Checkbox checked={checked} onChange={onChange} /></label>;
|
||||
}
|
||||
|
||||
export function ThemeOption({ field, value, onChange }: { field: ThemeSettingField; value: ThemeSettingValue | undefined; onChange: (value: ThemeSettingValue) => void }) {
|
||||
if (field.type === "toggle") {
|
||||
return <Toggle label={field.label} checked={typeof value === "boolean" ? value : false} onChange={onChange} />;
|
||||
}
|
||||
if (field.type === "textarea") {
|
||||
return <label className="theme-option"><span>{field.label}</span><textarea rows={3} maxLength={500} placeholder={field.placeholder} value={typeof value === "string" ? value : ""} onChange={(event) => onChange(event.target.value)} /></label>;
|
||||
}
|
||||
if (field.type === "select") {
|
||||
return <label className="theme-option"><span>{field.label}</span><select value={typeof value === "string" ? value : ""} onChange={(event) => onChange(event.target.value)}>{field.options?.map((option) => <option value={option.value} key={option.value}>{option.label}</option>)}</select></label>;
|
||||
}
|
||||
if (field.type === "number") {
|
||||
return <label className="theme-option"><span>{field.label}</span><input type="number" min={field.min} max={field.max} step={field.step} value={typeof value === "number" ? value : ""} onChange={(event) => onChange(Number(event.target.value))} /></label>;
|
||||
}
|
||||
return <label className={`theme-option ${field.type === "color" ? "theme-color-option" : ""}`}><span>{field.label}</span><input type={field.type} maxLength={field.type === "color" ? undefined : 500} placeholder={field.placeholder} value={typeof value === "string" ? value : field.type === "color" ? "#0f766e" : ""} onChange={(event) => onChange(event.target.value)} /></label>;
|
||||
}
|
||||
Executable
+192
@@ -0,0 +1,192 @@
|
||||
import { AlertTriangle, Database, Eye, Info, Palette, RadioTower, ServerCog, ShieldCheck, SlidersHorizontal, Terminal } from "lucide-react";
|
||||
import { type AdminTab } from "../adminRoutes";
|
||||
import { ui, type UiLocale } from "../locale";
|
||||
import { type AdminServer, type DatabaseStats, type RemoteTask, type ServerInput, type Settings } from "../types";
|
||||
import pkg from "../../package.json";
|
||||
|
||||
export const VERSION = import.meta.env.VITE_NODEFLARE_VERSION || pkg.version;
|
||||
export const AGENT_SCRIPT_BASE = "https://raw.githubusercontent.com/elysia62/NodeFlare/main/agent";
|
||||
|
||||
export type AgentPlatform = "linux" | "windows" | "macos" | "freebsd";
|
||||
export type ThemeSourceMode = "repository" | "upload";
|
||||
|
||||
export interface AgentInstallInfo {
|
||||
agent_token: string;
|
||||
agent_mirror: string;
|
||||
}
|
||||
|
||||
export function adminPages(locale: UiLocale | string | undefined): Record<AdminTab, { title: string; description: string }> {
|
||||
return {
|
||||
servers: { title: ui(locale, "服务器", "Servers"), description: ui(locale, "管理监控节点和运行参数", "Manage monitored nodes and runtime parameters") },
|
||||
latency: { title: ui(locale, "延迟检测", "Latency checks"), description: ui(locale, "配置分配给各服务器的 TCP 与 ICMP 延迟任务", "Configure TCP and ICMP latency tasks assigned to each server") },
|
||||
appearance: { title: ui(locale, "站点设置", "Site settings"), description: ui(locale, "调整站点信息、公开内容和前台显示项目", "Adjust site info, public content and dashboard display options") },
|
||||
themes: { title: ui(locale, "主题商店", "Theme store"), description: ui(locale, "选择内置主题或安装本地主题包", "Choose the built-in theme or install a local theme package") },
|
||||
themeSettings: { title: ui(locale, "主题设置", "Theme settings"), description: ui(locale, "调整当前前端主题提供的显示选项", "Adjust display options provided by the active frontend theme") },
|
||||
alerts: { title: ui(locale, "通知", "Notifications"), description: ui(locale, "配置 Telegram 通知和资源告警阈值", "Configure Telegram notifications and resource alert thresholds") },
|
||||
security: { title: ui(locale, "登录与安全", "Sign-in & security"), description: ui(locale, "管理管理员账号、登录设备和安全验证", "Manage the admin account, signed-in devices and security checks") },
|
||||
data: { title: ui(locale, "数据库", "Database"), description: ui(locale, "查看空间、备份恢复和迁移数据库", "Inspect usage, back up, restore and migrate the database") },
|
||||
remote: { title: ui(locale, "远程执行", "Remote execution"), description: ui(locale, "输入命令并执行", "Enter a command and run it") },
|
||||
about: { title: ui(locale, "关于", "About"), description: ui(locale, "版本信息与项目地址", "Version info and project links") },
|
||||
};
|
||||
}
|
||||
|
||||
export function adminNavigation(locale: UiLocale | string | undefined) {
|
||||
return [
|
||||
{ tab: "servers" as const, label: ui(locale, "服务器", "Servers"), icon: ServerCog },
|
||||
{ tab: "latency" as const, label: ui(locale, "延迟检测", "Latency"), icon: RadioTower },
|
||||
{ tab: "remote" as const, label: ui(locale, "远程执行", "Remote"), icon: Terminal },
|
||||
{ tab: "alerts" as const, label: ui(locale, "通知", "Alerts"), icon: AlertTriangle },
|
||||
{ tab: "themes" as const, label: ui(locale, "主题商店", "Themes"), icon: Palette },
|
||||
{ tab: "themeSettings" as const, label: ui(locale, "主题设置", "Theme"), icon: SlidersHorizontal },
|
||||
{ tab: "appearance" as const, label: ui(locale, "站点设置", "Site"), icon: Eye },
|
||||
{ tab: "security" as const, label: ui(locale, "登录与安全", "Security"), icon: ShieldCheck },
|
||||
{ tab: "data" as const, label: ui(locale, "数据库", "Database"), icon: Database },
|
||||
{ tab: "about" as const, label: ui(locale, "关于", "About"), icon: Info },
|
||||
];
|
||||
}
|
||||
|
||||
export function remoteTaskStatusLabels(locale: UiLocale | string | undefined): Record<RemoteTask["status"], string> {
|
||||
return {
|
||||
pending: ui(locale, "等待接收", "Queued"),
|
||||
sent: ui(locale, "执行中", "Running"),
|
||||
success: ui(locale, "执行成功", "Succeeded"),
|
||||
failed: ui(locale, "执行失败", "Failed"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export const emptyServer: ServerInput = {
|
||||
name: "",
|
||||
region: "",
|
||||
group_name: "默认",
|
||||
tags: "",
|
||||
hidden: false,
|
||||
expires_at: null,
|
||||
traffic_limit: 0,
|
||||
traffic_limit_type: "sum",
|
||||
price: 0,
|
||||
billing_cycle: 30,
|
||||
currency: "CNY",
|
||||
auto_renewal: false,
|
||||
network_interface: "",
|
||||
reset_day: 1,
|
||||
report_interval: 60,
|
||||
collect_interval: 3,
|
||||
rx_correction: 0,
|
||||
tx_correction: 0,
|
||||
agent_mirror: "",
|
||||
offline_notify_disabled: false,
|
||||
auto_update: true,
|
||||
};
|
||||
|
||||
export function toInput(server: AdminServer): ServerInput {
|
||||
return {
|
||||
...emptyServer,
|
||||
name: server.name,
|
||||
region: server.region,
|
||||
group_name: server.group_name,
|
||||
tags: server.tags,
|
||||
hidden: server.hidden,
|
||||
expires_at: server.expires_at,
|
||||
traffic_limit: server.traffic_limit,
|
||||
traffic_limit_type: server.traffic_limit_type,
|
||||
price: server.price,
|
||||
billing_cycle: server.billing_cycle,
|
||||
currency: server.currency,
|
||||
auto_renewal: server.auto_renewal,
|
||||
network_interface: server.network_interface,
|
||||
reset_day: server.reset_day,
|
||||
report_interval: server.report_interval,
|
||||
collect_interval: server.collect_interval,
|
||||
rx_correction: server.rx_correction,
|
||||
tx_correction: server.tx_correction,
|
||||
agent_mirror: server.agent_mirror,
|
||||
offline_notify_disabled: server.offline_notify_disabled,
|
||||
auto_update: server.auto_update,
|
||||
};
|
||||
}
|
||||
|
||||
export function billingCycles(locale: UiLocale | string | undefined): Array<{ days: number; label: string }> {
|
||||
return [
|
||||
{ days: 30, label: ui(locale, "月", "Monthly") },
|
||||
{ days: 90, label: ui(locale, "季", "Quarterly") },
|
||||
{ days: 180, label: ui(locale, "半年", "Semi-annually") },
|
||||
{ days: 365, label: ui(locale, "年", "Yearly") },
|
||||
{ days: 0, label: ui(locale, "一次性", "One-time") },
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
export function formatDate(value: number | null) {
|
||||
return value ? new Date(value * 1000).toISOString().slice(0, 10) : "";
|
||||
}
|
||||
|
||||
export function formatSessionTime(value: number) {
|
||||
return new Date(value * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
export function describeLoginDevice(userAgent: string, locale: UiLocale | string | undefined) {
|
||||
const browser = userAgent.includes("Edg/") ? "Edge"
|
||||
: userAgent.includes("Firefox/") ? "Firefox"
|
||||
: userAgent.includes("Chrome/") ? "Chrome"
|
||||
: userAgent.includes("Safari/") ? "Safari"
|
||||
: ui(locale, "其他客户端", "Other client");
|
||||
const system = userAgent.includes("Android") ? "Android"
|
||||
: /iPhone|iPad/.test(userAgent) ? "iOS"
|
||||
: userAgent.includes("Windows") ? "Windows"
|
||||
: userAgent.includes("Mac OS") ? "macOS"
|
||||
: userAgent.includes("Linux") ? "Linux"
|
||||
: "";
|
||||
return system ? `${browser} · ${system}` : browser;
|
||||
}
|
||||
|
||||
export function settingPatch(settings: Settings, key: keyof Settings, value: unknown) {
|
||||
return { ...settings, [key]: value } as Settings;
|
||||
}
|
||||
|
||||
export function shellLiteral(value: string) {
|
||||
return `'${value.replaceAll("'", `'\\''`)}'`;
|
||||
}
|
||||
|
||||
export function powershellLiteral(value: string) {
|
||||
return `'${value.replaceAll("'", "''")}'`;
|
||||
}
|
||||
|
||||
export async function copyText(value: string) {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
return;
|
||||
} catch {}
|
||||
}
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = value;
|
||||
textarea.readOnly = true;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.append(textarea);
|
||||
textarea.select();
|
||||
const copied = document.execCommand("copy");
|
||||
textarea.remove();
|
||||
if (!copied) throw new Error("copy failed");
|
||||
}
|
||||
|
||||
export async function waitForDatabaseSwitch(targetKind: DatabaseStats["kind"], locale: UiLocale | string | undefined) {
|
||||
const deadline = Date.now() + 120_000;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 750));
|
||||
try {
|
||||
const response = await fetch(`/api/admin/database?restart=${Date.now()}`, {
|
||||
cache: "no-store",
|
||||
credentials: "same-origin",
|
||||
signal: AbortSignal.timeout(2_500),
|
||||
});
|
||||
if (response.status === 401) return;
|
||||
if (response.ok) {
|
||||
const database = await response.json() as DatabaseStats;
|
||||
if (database.kind === targetKind && !database.restart_required) return;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
throw new Error(ui(locale, "NodeFlare 未自动恢复,请在主机上检查服务状态", "NodeFlare did not come back automatically; check the service status on the host"));
|
||||
}
|
||||
Regular → Executable
+6
-5
@@ -1,9 +1,10 @@
|
||||
import { ShieldCheck, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ui, type UiLocale } from "../locale";
|
||||
import { PasswordInput } from "./PasswordInput";
|
||||
import { useDialog } from "./useDialog";
|
||||
|
||||
export function useVerification(active: boolean) {
|
||||
export function useVerification(active: boolean, locale: UiLocale | string | undefined) {
|
||||
const [request, setRequest] = useState<{ title: string; totp: boolean } | null>(null);
|
||||
const [value, setValue] = useState("");
|
||||
const resolver = useRef<((value: string | null) => void) | null>(null);
|
||||
@@ -27,11 +28,11 @@ export function useVerification(active: boolean) {
|
||||
ask,
|
||||
dialog: request ? <div className="submodal-backdrop verification-backdrop" onMouseDown={dialog.onBackdropMouseDown}>
|
||||
<form ref={dialog.dialogRef} className="editor-modal verification-modal" role="dialog" aria-modal="true" aria-labelledby="verification-title" tabIndex={-1} onSubmit={(event) => { event.preventDefault(); finish(value); }}>
|
||||
<header><h3 id="verification-title">{request.title}</h3><button type="button" className="icon-btn" title="关闭" aria-label="关闭" onClick={() => finish(null)}><X size={18} /></button></header>
|
||||
<label><span>{request.totp ? "两步验证码" : "当前管理员密码"}</span>{request.totp
|
||||
<header><h3 id="verification-title">{request.title}</h3><button type="button" className="icon-btn" title={ui(locale, "关闭", "Close")} aria-label={ui(locale, "关闭", "Close")} onClick={() => finish(null)}><X size={18} /></button></header>
|
||||
<label><span>{request.totp ? ui(locale, "两步验证码", "Two-factor code") : ui(locale, "当前管理员密码", "Current admin password")}</span>{request.totp
|
||||
? <input autoFocus data-dialog-autofocus required inputMode="numeric" autoComplete="one-time-code" pattern="[0-9]{6}" maxLength={6} value={value} onChange={(event) => setValue(event.target.value.replace(/\D/g, "").slice(0, 6))} />
|
||||
: <PasswordInput autoFocus data-dialog-autofocus required autoComplete="current-password" maxLength={128} value={value} onChange={(event) => setValue(event.target.value)} />}</label>
|
||||
<div className="form-actions"><button type="button" className="secondary-btn" onClick={() => finish(null)}>取消</button><button className="primary-btn" disabled={request.totp ? !/^\d{6}$/.test(value) : !value}><ShieldCheck size={15} />确认</button></div>
|
||||
: <PasswordInput locale={locale} autoFocus data-dialog-autofocus required autoComplete="current-password" maxLength={128} value={value} onChange={(event) => setValue(event.target.value)} />}</label>
|
||||
<div className="form-actions"><button type="button" className="secondary-btn" onClick={() => finish(null)}>{ui(locale, "取消", "Cancel")}</button><button className="primary-btn" disabled={request.totp ? !/^\d{6}$/.test(value) : !value}><ShieldCheck size={15} />{ui(locale, "确认", "Confirm")}</button></div>
|
||||
</form>
|
||||
</div> : null,
|
||||
};
|
||||
|
||||
Regular → Executable
+12
-1
@@ -54,6 +54,15 @@ function fetchHistory(id: string, signature: string) {
|
||||
return request;
|
||||
}
|
||||
|
||||
// Stale entries are never reused (load only reads a hit while it is fresher than
|
||||
// CACHE_TTL), so dropping expired ones keeps the shared map bounded and stops
|
||||
// deleted servers from lingering for the lifetime of the page.
|
||||
function pruneCache(now: number) {
|
||||
for (const [key, entry] of cache) {
|
||||
if (now - entry.at >= CACHE_TTL) cache.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
function mergePoints(...sources: LatencySample[][]): LatencySample[] {
|
||||
const points = new Map<string, LatencySample>();
|
||||
for (const source of sources) {
|
||||
@@ -127,7 +136,9 @@ export function useNodeLatency(
|
||||
const result = await fetchHistory(server.id, taskSignature);
|
||||
loaded = true;
|
||||
if (!stopped) {
|
||||
cache.set(server.id, { at: Date.now(), signature: taskSignature, points: result.points });
|
||||
const now = Date.now();
|
||||
pruneCache(now);
|
||||
cache.set(server.id, { at: now, signature: taskSignature, points: result.points });
|
||||
setFetched(result.points);
|
||||
}
|
||||
} catch {} finally {
|
||||
|
||||
Regular → Executable
+12
-13
@@ -149,7 +149,7 @@ describe("one-second card playback", () => {
|
||||
}], servers, now);
|
||||
}
|
||||
current = applyBatch(current, queue.take(servers, now), servers);
|
||||
const card = mergeServerLive(servers[0], current.s1, now, 180);
|
||||
const card = mergeServerLive(servers[0], current.s1);
|
||||
displayed.push([card.cpu, card.net_in, card.net_out]);
|
||||
}
|
||||
expect(displayed).toEqual(Array.from({ length: 9 }, (_, index) => [
|
||||
@@ -196,7 +196,7 @@ describe("one-second card playback", () => {
|
||||
}] }], [server()], 0);
|
||||
const servers = [server({ timestamp: 1_005, cpu: 10 })];
|
||||
const state = applyBatch({}, queue.take(servers, 0), servers);
|
||||
expect(mergeServerLive(servers[0], state.s1, 0, 180).cpu).toBe(10);
|
||||
expect(mergeServerLive(servers[0], state.s1).cpu).toBe(10);
|
||||
expect(state.s1.latencyResults).toHaveLength(1);
|
||||
});
|
||||
|
||||
@@ -216,30 +216,29 @@ describe("one-second card playback", () => {
|
||||
});
|
||||
|
||||
describe("mergeServerLive", () => {
|
||||
test("applies a newer live sample and ticks uptime", () => {
|
||||
test("applies a newer live sample without ticking uptime", () => {
|
||||
const merged = mergeServerLive(
|
||||
server({ timestamp: 1_000, uptime: 100 }),
|
||||
{ timestamp: 1_010, metrics: { cpu: 42 } },
|
||||
1_015_000,
|
||||
180,
|
||||
);
|
||||
expect(merged.cpu).toBe(42);
|
||||
expect(merged.uptime).toBe(105);
|
||||
expect(merged.uptime).toBe(100);
|
||||
expect(merged.timestamp).toBe(1_010);
|
||||
});
|
||||
|
||||
test("keeps persisted metrics when the live sample is older", () => {
|
||||
test("returns the same object when the live sample is older", () => {
|
||||
const persisted = server({ timestamp: 2_000, cpu: 10, uptime: null });
|
||||
const merged = mergeServerLive(
|
||||
server({ timestamp: 2_000, cpu: 10, uptime: null }),
|
||||
persisted,
|
||||
{ timestamp: 1_000, metrics: { cpu: 99 } },
|
||||
2_000_000,
|
||||
180,
|
||||
);
|
||||
expect(merged).toBe(persisted);
|
||||
expect(merged.cpu).toBe(10);
|
||||
});
|
||||
|
||||
test("does not tick uptime past the offline threshold", () => {
|
||||
const merged = mergeServerLive(server({ timestamp: 1_000, uptime: 100 }), undefined, 9_000_000, 180);
|
||||
expect(merged.uptime).toBe(100);
|
||||
test("returns the same object when there is no live sample", () => {
|
||||
const persisted = server({ timestamp: 1_000, uptime: 100 });
|
||||
expect(mergeServerLive(persisted, undefined)).toBe(persisted);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Regular → Executable
+9
-14
@@ -186,24 +186,19 @@ export function applyBatch(
|
||||
return next;
|
||||
}
|
||||
|
||||
// Returns the input object unchanged whenever nothing newer arrived, so memoized
|
||||
// subscribers can skip re-rendering. Uptime is shown as reported by the agent;
|
||||
// the card grid does not tick it between reports.
|
||||
export function mergeServerLive(
|
||||
server: Server,
|
||||
live: LiveMetrics | undefined,
|
||||
clockNow: number,
|
||||
offlineThresholdSeconds: number,
|
||||
): Server {
|
||||
const merged = !live ? server : (() => {
|
||||
const latency = live.latencyResults?.length ? mergeLiveLatency(server, live.latencyResults) : server.latency;
|
||||
if (live.timestamp <= (server.timestamp ?? 0)) {
|
||||
return latency === server.latency ? server : { ...server, latency };
|
||||
}
|
||||
return { ...server, ...live.metrics, latency, timestamp: live.timestamp };
|
||||
})();
|
||||
if (!merged.timestamp || !merged.uptime || !Number.isFinite(merged.uptime)) return merged;
|
||||
const elapsed = Math.max(0, Math.floor(clockNow / 1000 - merged.timestamp));
|
||||
return elapsed > 0 && elapsed <= offlineThresholdSeconds
|
||||
? { ...merged, uptime: merged.uptime + elapsed }
|
||||
: merged;
|
||||
if (!live) return server;
|
||||
const latency = live.latencyResults?.length ? mergeLiveLatency(server, live.latencyResults) : server.latency;
|
||||
if (live.timestamp <= (server.timestamp ?? 0)) {
|
||||
return latency === server.latency ? server : { ...server, latency };
|
||||
}
|
||||
return { ...server, ...live.metrics, latency, timestamp: live.timestamp };
|
||||
}
|
||||
|
||||
export function pruneLiveMetrics(current: LiveMetricsMap, servers: readonly Server[]): LiveMetricsMap {
|
||||
|
||||
Regular → Executable
+4
-3
@@ -5,9 +5,10 @@ function toHex(bytes: Uint8Array) {
|
||||
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
export async function derivePassword(password: string, deploymentSalt: string) {
|
||||
if (!deploymentSalt) throw new Error("登录密码派生参数缺失");
|
||||
if (!globalThis.crypto?.subtle) throw new Error("当前浏览器不支持安全密码派生");
|
||||
export async function derivePassword(password: string, deploymentSalt: string, locale?: string) {
|
||||
const english = locale === "en";
|
||||
if (!deploymentSalt) throw new Error(english ? "Password derivation parameters are missing" : "登录密码派生参数缺失");
|
||||
if (!globalThis.crypto?.subtle) throw new Error(english ? "This browser does not support secure password derivation" : "当前浏览器不支持安全密码派生");
|
||||
const key = await globalThis.crypto.subtle.importKey(
|
||||
"raw",
|
||||
encoder.encode(password),
|
||||
|
||||
Regular → Executable
+1
-3
@@ -251,9 +251,7 @@
|
||||
.latency-editor > * { flex-shrink: 0; }
|
||||
.latency-target-row { grid-template-columns: minmax(0, 1fr) 140px; }
|
||||
.latency-target-row > label { min-width: 0; }
|
||||
.password-field { position: relative; min-width: 0; }
|
||||
.password-field input { padding-right: 44px; }
|
||||
.password-field .icon-btn { position: absolute; right: 4px; top: 50%; transform: translateY(-50%); width: 32px; height: 32px; }
|
||||
/* .password-field 样式已上移到 base.css,登录页与后台共用 */
|
||||
.submodal-backdrop.verification-backdrop { z-index: 100; align-items: center; padding: 16px; }
|
||||
.verification-backdrop .verification-modal { width: min(400px, 100%); max-height: calc(100dvh - 32px); border: 1px solid var(--admin-border); border-radius: 8px; }
|
||||
.task-type-control { height: 36px; }
|
||||
|
||||
Regular → Executable
+6
-1
@@ -97,7 +97,12 @@ html[data-blur="off"] .glass-panel { background: color-mix(in srgb, var(--card-s
|
||||
|
||||
label { display: flex; min-width: 0; flex-direction: column; gap: 6px; }
|
||||
label > span { color: var(--muted-foreground); font-size: 11px; }
|
||||
.form-error { margin: 0; color: var(--danger); font-size: 11px; }
|
||||
.form-error { display: flex; margin: 0; align-items: flex-start; gap: 6px; color: var(--danger); font-size: 11px; line-height: 1.5; }
|
||||
.form-error svg { flex: 0 0 auto; margin-top: 1px; }
|
||||
|
||||
.password-field { position: relative; min-width: 0; }
|
||||
.password-field input { padding-right: 44px; }
|
||||
.password-field .icon-btn { position: absolute; right: 4px; top: 50%; transform: translateY(-50%); width: 32px; height: 32px; }
|
||||
|
||||
.segmented { display: inline-flex; min-width: 0; min-height: 36px; align-items: center; gap: 3px; overflow-x: auto; border: 1px solid var(--border); border-radius: 8px; padding: 3px; background: color-mix(in srgb, var(--card-solid) 78%, transparent); }
|
||||
.segmented button { display: inline-flex; min-height: 29px; flex: 0 0 auto; align-items: center; justify-content: center; gap: 5px; border-radius: 6px; padding: 4px 10px; color: var(--muted-foreground); font-size: 12px; white-space: nowrap; }
|
||||
|
||||
Regular → Executable
+1
-1
@@ -1,4 +1,4 @@
|
||||
[CmdletBinding(DefaultParameterSetName = "Menu")]
|
||||
[CmdletBinding(DefaultParameterSetName = "Menu")]
|
||||
param(
|
||||
[Parameter(ParameterSetName = "Menu")][switch]$Menu,
|
||||
[Parameter(ParameterSetName = "Install", Mandatory = $true)][switch]$Install,
|
||||
|
||||
@@ -17,6 +17,7 @@ backup_dir=""
|
||||
previous_install=false
|
||||
previous_share=false
|
||||
previous_service=false
|
||||
config_created=false
|
||||
server_port=2206
|
||||
|
||||
case "$(uname -s)" in
|
||||
@@ -316,6 +317,7 @@ write_config() {
|
||||
} > "$config_temp"
|
||||
chown root "$config_temp"
|
||||
chmod 0600 "$config_temp"
|
||||
[ -f "$config_file" ] || config_created=true
|
||||
mv -f "$config_temp" "$config_file"
|
||||
config_temp=""
|
||||
admin_password=""
|
||||
@@ -560,6 +562,13 @@ rollback_install() {
|
||||
if [ "$previous_service" = true ]; then
|
||||
cp -p "$backup_dir/service" "$service_path" || return 1
|
||||
fi
|
||||
if [ "$config_created" = true ]; then
|
||||
# A failed first install would otherwise leave a config file holding the
|
||||
# plaintext admin password; it also makes the next run look like an update
|
||||
# and skip credential setup.
|
||||
rm -f "$config_file" || return 1
|
||||
log "安装未完成,已删除本次写入的配置文件($config_file)"
|
||||
fi
|
||||
if [ "$previous_install" = true ] && [ -f "$config_file" ]; then
|
||||
if start_server; then
|
||||
log "已恢复并重新启动上一版本"
|
||||
|
||||
Regular → Executable
+9
-1
@@ -13,9 +13,17 @@ monitor_curl() {
|
||||
esac
|
||||
}
|
||||
|
||||
# --fail-with-body was added in curl 7.76; older builds (e.g. CentOS 7 ships
|
||||
# 7.29) would abort with an unknown-option error, so fall back to --fail.
|
||||
if curl --help 2>&1 | grep -q -- '--fail-with-body'; then
|
||||
CURL_FAIL_FLAG=--fail-with-body
|
||||
else
|
||||
CURL_FAIL_FLAG=--fail
|
||||
fi
|
||||
|
||||
request() {
|
||||
response_file=$(mktemp /tmp/nodeflare-response.XXXXXX)
|
||||
if monitor_curl --fail-with-body --location --silent --show-error \
|
||||
if monitor_curl "$CURL_FAIL_FLAG" --location --silent --show-error \
|
||||
--output "$response_file" "$@"; then
|
||||
cat "$response_file"
|
||||
rm -f -- "$response_file"
|
||||
|
||||
Reference in new issue
Block a user