From 59dbc9abebc9d2e1cd859ac73b74e3dc12f15499 Mon Sep 17 00:00:00 2001 From: imengying Date: Mon, 7 Sep 2026 21:14:21 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=89=8D=E5=90=8E=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 105 ++++++++++++------ agent/agent.sh | 4 +- agent/install-freebsd.sh | 74 ++++++++---- agent/install-macos.sh | 81 ++++++++++---- agent/install.ps1 | 34 +++++- .../postgres/03_agent_install_token.sql | 8 ++ .../postgres/04_nonnegative_price.sql | 2 + .../sqlite/03_agent_install_token.sql | 8 ++ .../sqlite/04_nonnegative_price.sql | 14 +++ backend/src/backup.rs | 27 ++++- backend/src/db/mod.rs | 42 +++++++ backend/src/db/queries.rs | 80 ++++++++++++- backend/src/main.rs | 4 + backend/src/routes/admin.rs | 44 +++++++- frontend/src/AppProvider.tsx | 24 ++-- frontend/src/agentInstall.test.ts | 94 ++++++++++++++++ frontend/src/api.ts | 2 + frontend/src/components/AdminPanel.tsx | 18 +-- frontend/src/components/NodeCard.tsx | 16 +-- frontend/src/components/NodeDetails.tsx | 4 +- frontend/src/components/ProgressBar.tsx | 12 +- frontend/src/demo.ts | 4 +- frontend/src/format.test.ts | 18 ++- frontend/src/format.ts | 6 +- frontend/src/install.test.ts | 11 +- frontend/src/refresh.test.ts | 65 ++++++++++- frontend/src/refresh.ts | 28 +++++ frontend/src/styles/dashboard.css | 6 +- install.ps1 | 29 ++++- install.sh | 33 +++++- 30 files changed, 752 insertions(+), 145 deletions(-) create mode 100644 backend/migrations/postgres/03_agent_install_token.sql create mode 100644 backend/migrations/postgres/04_nonnegative_price.sql create mode 100644 backend/migrations/sqlite/03_agent_install_token.sql create mode 100644 backend/migrations/sqlite/04_nonnegative_price.sql create mode 100644 frontend/src/agentInstall.test.ts diff --git a/README.md b/README.md index c46ade3..32565a9 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ 自部署的服务器监控面板:服务端提供状态页与管理后台,Agent 部署在被监控服务器上采集并上报数据,两者通过 WebSocket 通信。 +项目地址: + ## 功能 - **实时监控**:CPU、内存、负载、磁盘、网络流量、TCP/UDP 连接数 @@ -16,7 +18,7 @@ ## 安装服务端 -服务端与 Agent 支持 Linux x64/ARM64、Windows x64、macOS ARM64、FreeBSD 13+ x64/ARM64。安装脚本从 latest Release 下载预编译包、校验 SHA-256 并注册系统服务(Linux 为 systemd / OpenRC,macOS 为 launchd)。 +支持 Linux x64/ARM64、Windows x64、macOS ARM64、FreeBSD 13+ x64/ARM64。安装脚本从 latest Release 下载预编译包并注册系统服务。 Linux / macOS: @@ -38,27 +40,19 @@ Unblock-File "$env:TEMP\nodeflare-install.ps1" & "$env:TEMP\nodeflare-install.ps1" ``` -运行脚本后选择“安装 / 更新”。首次安装依次输入管理员用户名、密码、监听端口(默认 2206)和数据库地址(默认 SQLite),连接串格式见[配置](#配置)。更新时保留已有配置和端口;菜单还可查看状态、重启或卸载服务。 +首次安装依次输入管理员用户名、密码、监听端口(默认 2206)和数据库地址(默认 SQLite),连接串格式见[配置](#配置)。菜单还可查看状态、重启或卸载服务。 -服务端默认监听 `127.0.0.1:2206`,本机访问 `http://127.0.0.1:2206/admin/login`。需经 HTTPS 反向代理对外暴露,例如 Caddy(使用自选端口时同步修改): +服务端默认监听 `127.0.0.1:2206`,本机访问 `http://127.0.0.1:2206/admin/login`。对外暴露需经 HTTPS 反向代理,例如 Caddy(域名替换为你的实际地址): ```caddy -monitor.example.com { +nodeflare.example.com { reverse_proxy 127.0.0.1:2206 } ``` -只有 `trusted_proxies` 中列出的代理写入的 `X-Forwarded-For` 会被信任(默认含本机回环),代理不在本机时将其 IP 或 CIDR 加入 `config.toml`。 +只有 `trusted_proxies` 中列出的代理写入的 `X-Forwarded-For` 会被信任,代理不在本机时将其 IP 或 CIDR 加入 `config.toml`。 -重新运行脚本并选择“安装 / 更新”即可更新;直接更新可用 `install.sh --install`(Windows 为 `install.ps1 -Install`)。Linux 常用 systemd 命令: - -```bash -systemctl status nodeflare -systemctl restart nodeflare -journalctl -u nodeflare -f -``` - -卸载默认保留配置和数据,追加 `--purge` 一并删除(Windows 为 `install.ps1 -Uninstall`,加 `-Purge`): +更新:重新运行安装脚本即可,已安装的配置和数据会保留;也可直接执行 `install.sh --install`(Windows 为 `install.ps1 -Install`)。卸载默认保留配置和数据,追加 `--purge` 一并删除: ```bash # Linux / macOS @@ -67,29 +61,74 @@ curl -fsSL https://raw.githubusercontent.com/imengying/NodeFlare/main/install.sh fetch -qo - https://raw.githubusercontent.com/imengying/NodeFlare/main/install.sh | sudo sh -s -- --uninstall ``` -## 安装 Agent - -在管理后台“服务器”页面创建节点,执行生成的安装命令。Agent 主动向服务端发起出站 WebSocket 连接,被监控服务器无需开放入站端口,服务地址仅接受 HTTPS(本机调试除外)。以 Linux 为例: +Linux 常用 systemd 命令: ```bash -curl -fsSL https://monitor.example.com/agent/agent.sh \ - | sudo sh -s -- -e 'https://monitor.example.com' -t 'Agent Token' +systemctl status nodeflare +systemctl restart nodeflare +journalctl -u nodeflare -f ``` +## 安装 Agent + +在管理后台“服务器”页面创建节点,执行“下载 Agent”弹窗中的安装命令;每次打开弹窗会生成新的安装 Token,旧 Token 仍可使用。Agent 通过出站 WebSocket 连接服务端,无需开放入站端口。将示例中的面板地址替换为你的实际地址。 + +Linux: + +```bash +curl -fsSL https://raw.githubusercontent.com/imengying/NodeFlare/main/agent/agent.sh \ + | sudo sh -s -- -e 'https://nodeflare.example.com' -t 'Agent Token' +``` + +其他平台脚本: + +```bash +# macOS ARM64 +curl -fsSL https://raw.githubusercontent.com/imengying/NodeFlare/main/agent/install-macos.sh \ + | sudo sh -s -- -e 'https://nodeflare.example.com' -t 'Agent Token' +# FreeBSD +fetch -qo - https://raw.githubusercontent.com/imengying/NodeFlare/main/agent/install-freebsd.sh \ + | sudo sh -s -- -e 'https://nodeflare.example.com' -t 'Agent Token' +``` + +Windows PowerShell(管理员): + +```powershell +Invoke-WebRequest -UseBasicParsing https://raw.githubusercontent.com/imengying/NodeFlare/main/agent/install.ps1 -OutFile "$env:TEMP\nodeflare-agent-install.ps1" +Unblock-File "$env:TEMP\nodeflare-agent-install.ps1" +& "$env:TEMP\nodeflare-agent-install.ps1" -e "https://nodeflare.example.com" -t "Agent Token" +``` + +更新(自动沿用已安装的服务地址、Token 和保存间隔),网络受限时可加下载加速前缀 `-m https://ghproxy.net`: + +```bash +curl -fsSL https://raw.githubusercontent.com/imengying/NodeFlare/main/agent/agent.sh | sudo sh -s -- --update +# macOS ARM64 +curl -fsSL https://raw.githubusercontent.com/imengying/NodeFlare/main/agent/install-macos.sh | sudo sh -s -- --update +# FreeBSD +fetch -qo - https://raw.githubusercontent.com/imengying/NodeFlare/main/agent/install-freebsd.sh | sudo sh -s -- --update +``` + +Windows 管理员 PowerShell(先重新下载最新脚本): + +```powershell +Invoke-WebRequest -UseBasicParsing https://raw.githubusercontent.com/imengying/NodeFlare/main/agent/install.ps1 -OutFile "$env:TEMP\nodeflare-agent-install.ps1" +Unblock-File "$env:TEMP\nodeflare-agent-install.ps1" +& "$env:TEMP\nodeflare-agent-install.ps1" -Update +``` + +Windows 下载加速参数为 `-Mirror https://ghproxy.net`。更新会校验 Release 摘要和程序版本,启动失败会回滚;无需重新输入 Token。 + +实时上/下行速率由 Agent 按“收发字节增量 / 实际采样秒数”计算,前台每秒合并展示最新样本,无新样本时保持原值。节点实时采样间隔默认为 1 秒;设置更长间隔时不会凭空生成每秒数据。历史保存间隔独立,默认 60 秒。 + +节点价格最低为 `0`,表示免费;旧版负价格在升级或恢复备份时归零。是否隐藏节点仅由“隐藏节点”开关决定。 + Agent 服务名为 `nodeflare-agent`,卸载: ```bash -curl -fsSL https://monitor.example.com/agent/agent.sh | sudo sh -s -- --uninstall +curl -fsSL https://raw.githubusercontent.com/imengying/NodeFlare/main/agent/agent.sh | sudo sh -s -- --uninstall ``` -手动更新到最新正式版本(自动沿用已安装 Agent 的服务地址、Token 和历史保存间隔): - -```bash -curl -fsSL https://monitor.example.com/agent/agent.sh | sudo sh -s -- --update -``` - -网络受限时可追加下载加速前缀:`--update -m https://ghproxy.net`。 - ## 配置 配置文件位于[默认目录](#默认目录)表中的配置位置,完整示例见 [`backend/config.example.toml`](backend/config.example.toml)。常用项: @@ -130,14 +169,10 @@ database_url = "postgres://nodeflare:password@127.0.0.1:5432/nodeflare?sslmode=d - 备份含设置、节点、监控历史、通知、主题(含文件)、远程任务与安全配置;会话等临时数据不导出,恢复后需重新登录。 - 导出、恢复、迁移前需验证当前 TOTP,未启用时为管理员密码。 -- 恢复建议使用相同版本的 NodeFlare;`pg_dump` 与 SQLite `.backup` 可作为补充。 - 迁移会覆盖目标库已有数据并自动更新 `database_url`,完成后重启生效。 - -历史指标按节点设置的保存间隔聚合(默认 60 秒):CPU、负载、内存和 GPU 保存采样均值,CPU 另存最小/最大值,网速和磁盘 I/O 保留窗口峰值,流量计数保留末值。断网补报按窗口分别写入,重复重传不会重复计数;前台实时状态仍使用最新采样。 - -Agent 保留未确认的本地采样,只有后端事务提交后才清理相应缓存。建议先更新后端再更新 Agent;双方均更新后启用完整的分批补报与聚合,单边更新时保持旧协议兼容。历史表会自动迁移,旧快照按单个采样读取,也可恢复升级前的 ZIP 备份。 - -历史保留天数保持原设置(默认 30 天),指标和延迟记录仍共用该期限。过期清理每分钟运行,每条语句最多处理 1000 条记录,轮流处理各表,每轮最多 50 批并在两秒预算耗尽后停止启动新批次;已开始的语句会执行完成。SQLite 每五分钟尝试小批量增量回收,数据库占用包含主文件、WAL 和 SHM。完整空间回收仍由后台手动触发。 +- Agent 安装 Token 不进入数据库备份,恢复后需重新生成安装命令。 +- 历史指标默认保留 30 天,过期数据自动清理;完整空间回收在后台手动触发。 +- 升级建议先更新服务端再更新 Agent;恢复备份建议使用相同版本。 ## 从源码开发 diff --git a/agent/agent.sh b/agent/agent.sh index 29c827d..6948d6e 100644 --- a/agent/agent.sh +++ b/agent/agent.sh @@ -29,8 +29,7 @@ NodeFlare Agent 安装脚本 -e NodeFlare 服务地址(必填) -t 后台生成的 Agent Token(必填,请勿泄露) -i 初始历史保存间隔,15-3600 秒(默认 60) - -m GitHub 下载加速前缀(可选,仅作用于 Release 下载; - 形如 https://ghproxy.net,脚本会自动拼接完整地址,摘要校验不受影响) + -m GitHub 下载加速前缀(可选,如 https://ghproxy.net) EOF } @@ -345,6 +344,7 @@ install_agent() { printf ' 服务:%s(%s)\n' "$SERVICE_NAME" "$init_system" if [ "$init_system" = "systemd" ]; then printf ' 查看状态:systemctl status %s\n' "$SERVICE_NAME" + printf ' 查看日志:journalctl -u %s -f\n' "$SERVICE_NAME" else printf ' 查看状态:rc-service %s status\n' "$SERVICE_NAME" fi diff --git a/agent/install-freebsd.sh b/agent/install-freebsd.sh index 6704134..745702a 100755 --- a/agent/install-freebsd.sh +++ b/agent/install-freebsd.sh @@ -52,12 +52,13 @@ NodeFlare Agent FreeBSD 安装脚本 用法: install-freebsd.sh -e -t [-i <历史保存间隔>] [-m <下载加速前缀>] + install-freebsd.sh --update [-m <下载加速前缀>] install-freebsd.sh --status install-freebsd.sh --uninstall 支持 FreeBSD amd64 和 arm64。Agent Token 请勿泄露。 --m 为可选的 GitHub 下载加速前缀(形如 https://ghproxy.net), -仅作用于 Release 下载,摘要校验不受影响。 +-m 为可选的 GitHub 下载加速前缀(如 https://ghproxy.net)。 +--update 沿用已安装 Agent 的服务地址、Token 和历史保存间隔。 EOF } @@ -65,6 +66,14 @@ safe_value() { case "$1" in *[!A-Za-z0-9_./:@-]*|'') return 1 ;; esac } +load_installed_agent_config() { + [ -f "$SERVICE_FILE" ] || fail "未找到已安装 Agent 的服务配置,请先安装" + endpoint=$(sed -n 's/^command_args=".* -e \([^ ]*\) -i [0-9]*"$/\1/p' "$SERVICE_FILE") + interval=$(sed -n 's/^command_args=".* -e [^ ]* -i \([0-9]*\)"$/\1/p' "$SERVICE_FILE") + token=$(sed -n 's/^export NODEFLARE_AGENT_TOKEN="\([^"]*\)"$/\1/p' "$SERVICE_FILE") + [ -n "$endpoint" ] && [ -n "$token" ] && [ -n "$interval" ] || fail "已安装 Agent 的服务配置不完整" +} + download_stdout() { url="$1" if command -v curl >/dev/null 2>&1; then @@ -88,6 +97,43 @@ download_file() { fi } +parse_agent_args() { + token="" + endpoint="" + interval=60 + interval_set=false + mirror="" + mirror_set=false + update=false + if [ "${1:-}" = "--update" ]; then + update=true + shift + fi + while [ "$#" -gt 0 ]; do + option="$1" + if [ "$update" = true ] && [ "$option" != "-m" ]; then + fail "--update 仅接受可选的 -m 下载加速前缀" + fi + case "$option" in + -t|-e|-i|-m) + [ "$#" -ge 2 ] && [ -n "$2" ] || fail "参数 $option 缺少值" + value="$2" + shift 2 + ;; + *) fail "未知参数:$option" ;; + esac + case "$option" in + -t) [ -z "$token" ] || fail "参数 $option 重复"; token="$value" ;; + -e) [ -z "$endpoint" ] || fail "参数 $option 重复"; endpoint="$value" ;; + -i) [ "$interval_set" = false ] || fail "参数 $option 重复"; interval="$value"; interval_set=true ;; + -m) [ "$mirror_set" = false ] || fail "参数 $option 重复"; mirror="$value"; mirror_set=true ;; + esac + done + if [ "$update" = true ]; then + load_installed_agent_config + fi +} + if [ "${1:-}" = "--uninstall" ]; then [ "$#" -eq 1 ] || fail "--uninstall 不接受其它参数" [ "$(id -u)" -eq 0 ] || fail "请使用 root 权限执行卸载" @@ -125,28 +171,7 @@ command -v service >/dev/null 2>&1 || fail "缺少 service" command -v sysrc >/dev/null 2>&1 || fail "缺少 sysrc" log "正在检查运行环境" -token="" -endpoint="" -interval=60 -interval_set=false -mirror="" -while [ "$#" -gt 0 ]; do - option="$1" - case "$option" in - -t|-e|-i|-m) - [ "$#" -ge 2 ] || fail "参数 $option 缺少值" - value="$2" - shift 2 - ;; - *) fail "未知参数:$option" ;; - esac - case "$option" in - -t) [ -z "$token" ] || fail "参数 $option 重复"; token="$value" ;; - -e) [ -z "$endpoint" ] || fail "参数 $option 重复"; endpoint="$value" ;; - -i) [ "$interval_set" = false ] || fail "参数 $option 重复"; interval="$value"; interval_set=true ;; - -m) [ -z "$mirror" ] || fail "参数 $option 重复"; mirror="$value" ;; - esac -done +parse_agent_args "$@" [ -n "$token" ] && [ -n "$endpoint" ] || { usage; exit 1; } endpoint=${endpoint%/} [ ${#token} -le 512 ] && [ ${#endpoint} -le 2048 ] || fail "安装参数长度超出限制" @@ -272,3 +297,4 @@ printf '\nNodeFlare Agent 安装完成\n' printf ' 版本:%s\n' "$installed_version" printf ' 服务:%s(FreeBSD rc.d)\n' "$SERVICE_NAME" printf ' 查看状态:service %s status\n' "$SERVICE_NAME" +printf ' 查看日志:grep nodeflare_agent /var/log/messages\n' diff --git a/agent/install-macos.sh b/agent/install-macos.sh index 67b1762..cc74011 100644 --- a/agent/install-macos.sh +++ b/agent/install-macos.sh @@ -51,12 +51,13 @@ NodeFlare Agent macOS 安装脚本 用法: install-macos.sh -e -t [-i <历史保存间隔>] [-m <下载加速前缀>] + install-macos.sh --update [-m <下载加速前缀>] install-macos.sh --status install-macos.sh --uninstall 仅支持 Apple Silicon(arm64)。Agent Token 请勿泄露。 --m 为可选的 GitHub 下载加速前缀(形如 https://ghproxy.net), -仅作用于 Release 下载,摘要校验不受影响。 +-m 为可选的 GitHub 下载加速前缀(如 https://ghproxy.net)。 +--update 沿用已安装 Agent 的服务地址、Token 和历史保存间隔。 EOF } @@ -64,6 +65,58 @@ safe_value() { case "$1" in *[!A-Za-z0-9_./:@-]*|'') return 1 ;; esac } +plist_value() { + /usr/libexec/PlistBuddy -c "Print :$1" "$PLIST_FILE" +} + +load_installed_agent_config() { + [ -f "$PLIST_FILE" ] || fail "未找到已安装 Agent 的服务配置,请先安装" + [ "$(plist_value ProgramArguments:0)" = "$AGENT_FILE" ] && + [ "$(plist_value ProgramArguments:1)" = "-e" ] && + [ "$(plist_value ProgramArguments:3)" = "-i" ] || fail "无法识别已安装 Agent 的启动参数" + endpoint=$(plist_value ProgramArguments:2) || fail "无法读取已安装 Agent 的服务地址" + interval=$(plist_value ProgramArguments:4) || fail "无法读取已安装 Agent 的历史保存间隔" + token=$(plist_value EnvironmentVariables:NODEFLARE_AGENT_TOKEN) || fail "无法读取已安装 Agent 的 Token" + [ -n "$endpoint" ] && [ -n "$token" ] && [ -n "$interval" ] || fail "已安装 Agent 的服务配置不完整" +} + +parse_agent_args() { + token="" + endpoint="" + interval=60 + interval_set=false + mirror="" + mirror_set=false + update=false + if [ "${1:-}" = "--update" ]; then + update=true + shift + fi + while [ "$#" -gt 0 ]; do + option="$1" + if [ "$update" = true ] && [ "$option" != "-m" ]; then + fail "--update 仅接受可选的 -m 下载加速前缀" + fi + case "$option" in + -t|-e|-i|-m) + [ "$#" -ge 2 ] && [ -n "$2" ] || fail "参数 $option 缺少值" + value="$2" + shift 2 + ;; + *) fail "未知参数:$option" ;; + esac + case "$option" in + -t) [ -z "$token" ] || fail "参数 $option 重复"; token="$value" ;; + -e) [ -z "$endpoint" ] || fail "参数 $option 重复"; endpoint="$value" ;; + -i) [ "$interval_set" = false ] || fail "参数 $option 重复"; interval="$value"; interval_set=true ;; + -m) [ "$mirror_set" = false ] || fail "参数 $option 重复"; mirror="$value"; mirror_set=true ;; + esac + done + if [ "$update" = true ]; then + load_installed_agent_config + fi +} + if [ "${1:-}" = "--uninstall" ]; then [ "$#" -eq 1 ] || fail "--uninstall 不接受其它参数" [ "$(id -u)" -eq 0 ] || fail "请使用 root 权限执行卸载" @@ -92,28 +145,7 @@ fi command -v curl >/dev/null || fail "缺少 curl" command -v shasum >/dev/null || fail "缺少 shasum,无法校验下载文件" log "正在检查运行环境" -token="" -endpoint="" -interval=60 -interval_set=false -mirror="" -while [ "$#" -gt 0 ]; do - option="$1" - case "$option" in - -t|-e|-i|-m) - [ "$#" -ge 2 ] || fail "参数 $option 缺少值" - value="$2" - shift 2 - ;; - *) fail "未知参数:$option" ;; - esac - case "$option" in - -t) [ -z "$token" ] || fail "参数 $option 重复"; token="$value" ;; - -e) [ -z "$endpoint" ] || fail "参数 $option 重复"; endpoint="$value" ;; - -i) [ "$interval_set" = false ] || fail "参数 $option 重复"; interval="$value"; interval_set=true ;; - -m) [ -z "$mirror" ] || fail "参数 $option 重复"; mirror="$value" ;; - esac -done +parse_agent_args "$@" [ -n "$token" ] && [ -n "$endpoint" ] || { usage; exit 1; } endpoint=${endpoint%/} [ ${#token} -le 512 ] && [ ${#endpoint} -le 2048 ] || fail "安装参数长度超出限制" @@ -235,3 +267,4 @@ printf '\nNodeFlare Agent 安装完成\n' printf ' 版本:%s\n' "$installed_version" printf ' 服务:%s(LaunchDaemon)\n' "$LABEL" printf ' 查看状态:sudo launchctl print system/%s\n' "$LABEL" +printf ' 查看日志:/var/log/nodeflare-agent.log\n' diff --git a/agent/install.ps1 b/agent/install.ps1 index 6d2d328..6d58814 100644 --- a/agent/install.ps1 +++ b/agent/install.ps1 @@ -1,9 +1,17 @@ +<# +.EXAMPLE + .\install.ps1 -Update + Update using the saved endpoint, token and history interval. +.EXAMPLE + .\install.ps1 -Update -Mirror https://ghproxy.net +#> [CmdletBinding(DefaultParameterSetName = "Install")] param( [Parameter(ParameterSetName = "Install", Mandatory = $true)][Alias("t")][string]$Token, [Parameter(ParameterSetName = "Install", Mandatory = $true)][Alias("e")][string]$Endpoint, [Parameter(ParameterSetName = "Install")][Alias("i")][ValidateRange(15, 3600)][int]$Interval = 60, - [Parameter(ParameterSetName = "Install")][Alias("m")][string]$Mirror = "", + [Parameter(ParameterSetName = "Install")][Parameter(ParameterSetName = "Update")][Alias("m")][string]$Mirror = "", + [Parameter(ParameterSetName = "Update", Mandatory = $true)][switch]$Update, [Parameter(ParameterSetName = "Uninstall", Mandatory = $true)][switch]$Uninstall, [Parameter(ParameterSetName = "Status", Mandatory = $true)][switch]$Status ) @@ -39,6 +47,24 @@ function Assert-Safe([string]$Name, [string]$Value) { } } +function Read-AgentConfig { + if (-not (Test-Path -LiteralPath $ConfigFile -PathType Leaf)) { + Write-InstallError "未找到已安装 Agent 的配置,请先安装" + } + try { + $Saved = Get-Content -LiteralPath $ConfigFile -Raw | ConvertFrom-Json + } catch { + Write-InstallError "无法读取已安装 Agent 的配置" + } + $SavedInterval = 0 + if ($Saved.endpoint -isnot [string] -or $Saved.token -isnot [string] -or + -not [int]::TryParse([string]$Saved.interval, [ref]$SavedInterval) -or + $SavedInterval -lt 15 -or $SavedInterval -gt 3600) { + Write-InstallError "已安装 Agent 的配置不完整或历史保存间隔无效" + } + return $Saved +} + function Assert-Endpoint([string]$Value) { $Parsed = $null $SecureScheme = $false @@ -107,6 +133,12 @@ if ($NativeArchitecture -ne "AMD64") { Write-InstallError "仅支持 Windows x64" } Write-Step "正在检查运行环境" +if ($Update) { + $SavedConfig = Read-AgentConfig + $Endpoint = $SavedConfig.endpoint + $Token = $SavedConfig.token + $Interval = [int]$SavedConfig.interval +} Assert-Safe "Token" $Token Assert-Endpoint $Endpoint $Mirror = $Mirror.Trim().TrimEnd('/') diff --git a/backend/migrations/postgres/03_agent_install_token.sql b/backend/migrations/postgres/03_agent_install_token.sql new file mode 100644 index 0000000..66dd00e --- /dev/null +++ b/backend/migrations/postgres/03_agent_install_token.sql @@ -0,0 +1,8 @@ +CREATE TABLE server_install_tokens ( + token_hash TEXT PRIMARY KEY, + server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE, + created_at BIGINT NOT NULL +); + +CREATE INDEX server_install_tokens_server +ON server_install_tokens(server_id, created_at DESC); diff --git a/backend/migrations/postgres/04_nonnegative_price.sql b/backend/migrations/postgres/04_nonnegative_price.sql new file mode 100644 index 0000000..7b798a3 --- /dev/null +++ b/backend/migrations/postgres/04_nonnegative_price.sql @@ -0,0 +1,2 @@ +UPDATE servers SET price = 0 WHERE price < 0; +ALTER TABLE servers ADD CONSTRAINT servers_price_nonnegative CHECK (price >= 0); diff --git a/backend/migrations/sqlite/03_agent_install_token.sql b/backend/migrations/sqlite/03_agent_install_token.sql new file mode 100644 index 0000000..4674333 --- /dev/null +++ b/backend/migrations/sqlite/03_agent_install_token.sql @@ -0,0 +1,8 @@ +CREATE TABLE server_install_tokens ( + token_hash TEXT PRIMARY KEY, + server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE, + created_at INTEGER NOT NULL +) WITHOUT ROWID; + +CREATE INDEX server_install_tokens_server +ON server_install_tokens(server_id, created_at DESC); diff --git a/backend/migrations/sqlite/04_nonnegative_price.sql b/backend/migrations/sqlite/04_nonnegative_price.sql new file mode 100644 index 0000000..94daeca --- /dev/null +++ b/backend/migrations/sqlite/04_nonnegative_price.sql @@ -0,0 +1,14 @@ +UPDATE servers SET price = 0 WHERE price < 0; + +-- Preserve the existing table and its foreign keys when tightening the limit. +CREATE TRIGGER servers_price_nonnegative_insert +BEFORE INSERT ON servers WHEN NEW.price < 0 +BEGIN + SELECT RAISE(ABORT, 'price must be nonnegative'); +END; + +CREATE TRIGGER servers_price_nonnegative_update +BEFORE UPDATE OF price ON servers WHEN NEW.price < 0 +BEGIN + SELECT RAISE(ABORT, 'price must be nonnegative'); +END; diff --git a/backend/src/backup.rs b/backend/src/backup.rs index d294877..602b9d0 100644 --- a/backend/src/backup.rs +++ b/backend/src/backup.rs @@ -41,6 +41,7 @@ const BACKUP_TABLES: &[&str] = &[ ]; const CLEAR_TABLES: &[&str] = &[ + "server_install_tokens", "theme_previews", "dashboard_proofs", "sessions", @@ -63,6 +64,7 @@ const CLEAR_TABLES: &[&str] = &[ ]; const POSTGRES_RESTORE_LOCK: &str = "LOCK TABLE settings, servers, metric_history, \ + server_install_tokens, \ server_latest_state, admin_2fa, remote_tasks, exchange_rates, sessions, dashboard_proofs, \ server_traffic_state, latency_tasks, latency_task_servers, latency_results, alert_rules, \ alert_rule_servers, alert_states, notification_telegram, themes, theme_previews \ @@ -322,6 +324,7 @@ async fn export(db: &Database, theme_dir: Option<&Path>) -> Result( if header.table != table || (header.columns != columns && !legacy_history) { bail!("{table} 备份结构与当前数据库不兼容"); } - // Original snapshot archives omit the new aggregate columns; their SQL - // defaults preserve each old row as a single sample. + // Older archives omit columns added by later migrations; SQL defaults + // preserve their original values and leave optional additions empty. let columns = &header.columns; let mut restored = 0_usize; @@ -926,11 +939,17 @@ mod tests { .execute(source.pool()) .await .unwrap(); - sqlx::query("INSERT INTO servers(id,name,token_hash,created_at,updated_at) VALUES ('legacy','Legacy','token',1,1)") + sqlx::query("INSERT INTO servers(id,name,token_hash,created_at,updated_at,price,hidden) VALUES ('legacy','Legacy','token',1,1,-1,1)") .execute(source.pool()).await.unwrap(); sqlx::query("INSERT INTO metric_history(server_id,timestamp,cpu,mem_used) VALUES ('legacy',?,75.0,500)") .bind(crate::db::now()).execute(source.pool()).await.unwrap(); assert_eq!(copy_database(&source, &target).await.unwrap(), 6); + let server = sqlx::query("SELECT price,hidden FROM servers WHERE id='legacy'") + .fetch_one(target.pool()) + .await + .unwrap(); + assert_eq!(server.try_get::("price").unwrap(), 0.0); + assert_eq!(server.try_get::("hidden").unwrap(), 1); let points = crate::db::queries::history(&target, "legacy", 1) .await .unwrap(); diff --git a/backend/src/db/mod.rs b/backend/src/db/mod.rs index 9262573..0dcc4e2 100644 --- a/backend/src/db/mod.rs +++ b/backend/src/db/mod.rs @@ -1050,6 +1050,48 @@ mod tests { assert_eq!(point.sample_count, 1); } + #[tokio::test] + async fn price_migration_preserves_visibility_and_rejects_negative_writes() { + let db = connect("sqlite::memory:").await.unwrap(); + let original = Migrator::with_migrations( + SQLITE_MIGRATOR + .iter() + .filter(|migration| migration.version < 4) + .cloned() + .collect(), + ); + original.run(db.pool()).await.unwrap(); + for (index, price) in [-1.0, -0.5, 0.0, 12.5].iter().enumerate() { + sqlx::query("INSERT INTO servers(id,name,token_hash,created_at,updated_at,price,hidden) VALUES (?,'Node',?,1,1,?,?)") + .bind(index.to_string()).bind(index.to_string()).bind(price).bind((index % 2) as i64) + .execute(db.pool()).await.unwrap(); + } + db.migrate().await.unwrap(); + let rows = sqlx::query("SELECT price,hidden FROM servers ORDER BY id") + .fetch_all(db.pool()) + .await + .unwrap(); + assert_eq!(rows.len(), 4); + for (index, row) in rows.iter().enumerate() { + assert_eq!( + row.try_get::("price").unwrap(), + if index == 3 { 12.5 } else { 0.0 } + ); + assert_eq!(row.try_get::("hidden").unwrap(), (index % 2) as i64); + } + assert!( + sqlx::query("UPDATE servers SET price=-0.01 WHERE id='0'") + .execute(db.pool()) + .await + .is_err() + ); + assert!(sqlx::query("INSERT INTO servers(id,name,token_hash,created_at,updated_at,price) VALUES ('new','New','new',1,1,-1)").execute(db.pool()).await.is_err()); + sqlx::query("UPDATE servers SET price=0 WHERE id='3'") + .execute(db.pool()) + .await + .unwrap(); + } + #[tokio::test] async fn login_sessions_can_be_listed_and_revoked() { let db = connect("sqlite::memory:").await.unwrap(); diff --git a/backend/src/db/queries.rs b/backend/src/db/queries.rs index ddbde5c..3f6022e 100644 --- a/backend/src/db/queries.rs +++ b/backend/src/db/queries.rs @@ -514,15 +514,44 @@ pub async fn reorder_servers(db: &Database, ids: &[String]) -> Result<()> { pub async fn rotate_server_token(db: &Database, id: &str) -> Result> { let token = auth::random_token(32); + let token_hash = auth::token_hash(&token); + let mut transaction = db.pool().begin().await?; let result = sqlx::query(db.sql("UPDATE servers SET token_hash=?, updated_at=? WHERE id=?")) - .bind(auth::token_hash(&token)) + .bind(&token_hash) .bind(now()) .bind(id) - .execute(db.pool()) + .execute(&mut *transaction) .await?; + if result.rows_affected() > 0 { + sqlx::query(db.sql("DELETE FROM server_install_tokens WHERE server_id=?")) + .bind(id) + .execute(&mut *transaction) + .await?; + } + transaction.commit().await?; Ok((result.rows_affected() > 0).then_some(token)) } +pub async fn agent_install_token(db: &Database, id: &str) -> Result> { + let token = auth::random_token(32); + let exists = sqlx::query_scalar::<_, i64>(db.sql("SELECT COUNT(*) FROM servers WHERE id=?")) + .bind(id) + .fetch_one(db.pool()) + .await?; + if exists == 0 { + return Ok(None); + } + sqlx::query(db.sql( + "INSERT INTO server_install_tokens(token_hash, server_id, created_at) VALUES (?, ?, ?)", + )) + .bind(auth::token_hash(&token)) + .bind(id) + .bind(now()) + .execute(db.pool()) + .await?; + Ok(Some(token)) +} + pub async fn server_name(db: &Database, id: &str) -> Result> { Ok( sqlx::query_scalar::<_, String>(db.sql("SELECT name FROM servers WHERE id=?")) @@ -533,11 +562,15 @@ pub async fn server_name(db: &Database, id: &str) -> Result> { } pub async fn agent_identity(db: &Database, token: &str) -> Result> { + let token_hash = auth::token_hash(token); let row = sqlx::query(db.sql( "SELECT id, hidden, report_interval, collect_interval, reset_day, rx_correction, \ - tx_correction FROM servers WHERE token_hash=?", + tx_correction FROM servers WHERE token_hash=? OR EXISTS (\ + SELECT 1 FROM server_install_tokens WHERE server_id=servers.id AND token_hash=?\ + )", )) - .bind(auth::token_hash(token)) + .bind(&token_hash) + .bind(&token_hash) .fetch_optional(db.pool()) .await?; row.map(|row| { @@ -2388,6 +2421,45 @@ mod tests { (db, identity) } + #[tokio::test] + async fn agent_install_tokens_do_not_replace_the_primary_token() { + let db = crate::db::connect("sqlite::memory:").await.unwrap(); + db.migrate().await.unwrap(); + let (id, primary_token) = create_server(&db, &server_input(0)).await.unwrap(); + let first_install_token = agent_install_token(&db, &id).await.unwrap().unwrap(); + let second_install_token = agent_install_token(&db, &id).await.unwrap().unwrap(); + assert!(agent_identity(&db, &primary_token).await.unwrap().is_some()); + assert!( + agent_identity(&db, &first_install_token) + .await + .unwrap() + .is_some() + ); + assert!( + agent_identity(&db, &second_install_token) + .await + .unwrap() + .is_some() + ); + assert_ne!(first_install_token, second_install_token); + + let rotated_token = rotate_server_token(&db, &id).await.unwrap().unwrap(); + assert!(agent_identity(&db, &primary_token).await.unwrap().is_none()); + assert!( + agent_identity(&db, &first_install_token) + .await + .unwrap() + .is_none() + ); + assert!( + agent_identity(&db, &second_install_token) + .await + .unwrap() + .is_none() + ); + assert!(agent_identity(&db, &rotated_token).await.unwrap().is_some()); + } + fn sample(timestamp: i64, cpu: f64) -> AgentReport { AgentReport { timestamp, diff --git a/backend/src/main.rs b/backend/src/main.rs index cdbb606..7f85686 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -247,6 +247,10 @@ async fn main() -> Result<()> { "/api/admin/servers/{id}/token", post(routes::admin::server_token_rotate), ) + .route( + "/api/admin/servers/{id}/agent-token", + post(routes::admin::server_agent_token), + ) .route( "/api/admin/servers/{id}", patch(routes::admin::server_patch).delete(routes::admin::server_delete), diff --git a/backend/src/routes/admin.rs b/backend/src/routes/admin.rs index f3e03f9..d608b7d 100644 --- a/backend/src/routes/admin.rs +++ b/backend/src/routes/admin.rs @@ -140,6 +140,18 @@ pub async fn server_token_rotate( Ok(Json(serde_json::json!({"agent_token": token})).into_response()) } +pub async fn server_agent_token( + State(state): State>, + Path(id): Path, +) -> Result { + validate_id(&id)?; + let token = crate::db::queries::agent_install_token(&state.db, &id) + .await + .map_err(ApiResponse::internal)? + .ok_or_else(|| ApiResponse::not_found("节点不存在"))?; + Ok(Json(serde_json::json!({"agent_token": token})).into_response()) +} + pub async fn settings_get(State(state): State>) -> Result { let settings = crate::db::load_settings(&state.db) .await @@ -867,7 +879,7 @@ fn validate_server(input: &ServerInput) -> Result<(), &'static str> { return Err("流量配置无效"); } if !input.price.is_finite() - || !(-1.0..=1_000_000_000.0).contains(&input.price) + || !(0.0..=1_000_000_000.0).contains(&input.price) || !(0..=3650).contains(&input.billing_cycle) { return Err("价格或计费周期无效"); @@ -894,6 +906,36 @@ fn validate_server(input: &ServerInput) -> Result<(), &'static str> { Ok(()) } +#[cfg(test)] +mod server_validation_tests { + use super::*; + + #[test] + fn price_must_be_nonnegative_and_is_independent_of_visibility() { + let mut input: ServerInput = serde_json::from_value(serde_json::json!({ + "name": "Price test", "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": 1, + "rx_correction": 0, "tx_correction": 0, "agent_mirror": "", + "offline_notify_disabled": false, "auto_update": true + })) + .unwrap(); + for hidden in [true, false] { + input.hidden = hidden; + for price in [0.0, 0.01, 1_000_000_000.0] { + input.price = price; + assert!(validate_server(&input).is_ok()); + } + for price in [-1.0, -0.01, 1_000_000_001.0, f64::NAN, f64::INFINITY] { + input.price = price; + assert!(validate_server(&input).is_err()); + } + } + } +} + async fn validate_latency_task( state: &AppState, input: &LatencyTaskInput, diff --git a/frontend/src/AppProvider.tsx b/frontend/src/AppProvider.tsx index 2b039ad..04d5791 100644 --- a/frontend/src/AppProvider.tsx +++ b/frontend/src/AppProvider.tsx @@ -13,6 +13,7 @@ import { useFavicon, useStoredAppearance, useSystemDark } from "./hooks/useBrows import { resolveBackground, themeToggle } from "./theme"; import { BOOTSTRAP_POLL_INTERVAL_MS, + createLiveFlushScheduler, createRefreshQueue, shouldSyncBootstrap, } from "./refresh"; @@ -238,22 +239,27 @@ export function AppProvider({ children }: { children: ReactNode }) { useEffect(() => { if (!configReady || access !== "ok" || demoMode) return; let active = true; - let frame: number | null = null; let pending: BatchUpdate[] = []; + // Coalesce independently timed reports onto a shared one-second UI tick. + const flush = () => { + if (!pending.length) return; + const batch = pending; + pending = []; + setLiveMetrics((current) => applyBatch(current, batch, serversRef.current)); + }; + const scheduler = createLiveFlushScheduler(flush); const disconnect = connectLive({ serverId: selectedId }, { onServer: (server) => setServers((current) => current.map((entry) => entry.id === server.id ? server : entry)), onBatch: (updates) => { pending.push(...updates); - if (frame !== null) return; - frame = window.requestAnimationFrame(() => { - frame = null; - const batch = pending; - pending = []; - setLiveMetrics((current) => applyBatch(current, batch, serversRef.current)); - }); + scheduler.schedule(); }, onConnectedChange: (connected) => { liveConnectedRef.current = connected; + if (!connected) { + scheduler.cancel(); + pending = []; + } if (active && !connected && !document.hidden && navigator.onLine !== false) { void reload(true); } @@ -270,7 +276,7 @@ export function AppProvider({ children }: { children: ReactNode }) { }); return () => { active = false; - if (frame !== null) window.cancelAnimationFrame(frame); + scheduler.cancel(); disconnect(); }; }, [access, configReady, reload, selectedId]); diff --git a/frontend/src/agentInstall.test.ts b/frontend/src/agentInstall.test.ts new file mode 100644 index 0000000..a3326ed --- /dev/null +++ b/frontend/src/agentInstall.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; + +for (const platform of ["macos", "freebsd"] as const) { + const script = readFileSync(new URL(`../../agent/install-${platform}.sh`, import.meta.url), "utf8"); + const functions = ["fail", "parse_agent_args", "load_installed_agent_config"].map((name) => { + const match = script.match(new RegExp(`^${name}\\(\\) \\{[\\s\\S]*?^\\}`, "m")); + if (!match) throw new Error(`Missing ${platform} function: ${name}`); + return match[0]; + }).join("\n"); + + describe(`${platform} agent update`, () => { + function parse(args: string[], config: "valid" | "missing" | "incomplete" = "valid") { + const directory = mkdtempSync(join(tmpdir(), "nodeflare-agent-update-")); + const file = join(directory, "service"); + try { + if (config !== "missing") { + writeFileSync(file, [ + 'command_args="-P ${pidfile} -r -R 10 -S -T ${name} /usr/local/libexec/nodeflare/agent -e https://monitor.example.com -i 120"', + config === "valid" ? 'export NODEFLARE_AGENT_TOKEN="existing-token"' : "", + ].join("\n")); + } + return spawnSync("sh", ["-c", ` + set -eu + ${functions} + SERVICE_FILE=$TEST_SERVICE_FILE + PLIST_FILE=$TEST_SERVICE_FILE + AGENT_FILE=/usr/local/libexec/nodeflare/agent + plist_value() { + case "$1" in + ProgramArguments:0) printf '%s' "$AGENT_FILE" ;; + ProgramArguments:1) printf '%s' '-e' ;; + ProgramArguments:2) printf '%s' 'https://monitor.example.com' ;; + ProgramArguments:3) printf '%s' '-i' ;; + ProgramArguments:4) printf '%s' '120' ;; + EnvironmentVariables:NODEFLARE_AGENT_TOKEN) + [ "$TEST_CONFIG" = valid ] || return 1 + printf '%s' 'existing-token' ;; + *) return 1 ;; + esac + } + parse_agent_args "$@" + printf '%s|%s|%s|%s' "$endpoint" "$token" "$interval" "$mirror" + `, "agent-test", ...args], { + env: { ...process.env, TEST_SERVICE_FILE: file, TEST_CONFIG: config }, encoding: "utf8", + }); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + } + + test("script syntax and documented update option", () => { + expect(spawnSync("sh", ["-n"], { input: script }).status).toBe(0); + expect(script).toContain(`install-${platform}.sh --update [-m`); + }); + + test("reuses installed credentials and interval with an optional mirror", () => { + for (const args of [["--update"], ["--update", "-m", "https://mirror.example.com"]]) { + const result = parse(args); + expect(result.status).toBe(0); + expect(result.stdout).toBe(`https://monitor.example.com|existing-token|120|${args[2] ?? ""}`); + } + }); + + test("still parses a fresh installation", () => { + const result = parse(["-e", "https://new.example.com", "-t", "new-token", "-i", "45"]); + expect(result.status).toBe(0); + expect(result.stdout).toBe("https://new.example.com|new-token|45|"); + }); + + test("rejects missing or incomplete installed configuration", () => { + for (const config of ["missing", "incomplete"] as const) { + const result = parse(["--update"], config); + expect(result.status).toBe(1); + expect(result.stdout).toBe(""); + expect(result.stderr).not.toContain("existing-token"); + } + }); + + test("update cannot silently replace credentials or accept malformed options", () => { + for (const args of [ + ["--update", "-e", "https://other.example.com"], ["--update", "-t", "new-token"], + ["--update", "-i", "30"], ["--update", "--update"], ["--update", "--bad"], + ["--update", "-m"], ["--update", "-m", ""], + ["--update", "-m", "https://one.example.com", "-m", "https://two.example.com"], + ]) { + expect(parse(args).status).toBe(1); + } + }); + }); +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index d8472f5..a4dd5bf 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -125,6 +125,8 @@ export const api = { ), rotateServerToken: (id: string) => request<{ agent_token: string }>(`/api/admin/servers/${encodeURIComponent(id)}/token`, { method: "POST" }, true), + createAgentInstallToken: (id: string) => + request<{ agent_token: string }>(`/api/admin/servers/${encodeURIComponent(id)}/agent-token`, { method: "POST" }, true), updateServer: (id: string, input: ServerInput) => request( `/api/admin/servers/${encodeURIComponent(id)}`, diff --git a/frontend/src/components/AdminPanel.tsx b/frontend/src/components/AdminPanel.tsx index 548f400..aea1af7 100644 --- a/frontend/src/components/AdminPanel.tsx +++ b/frontend/src/components/AdminPanel.tsx @@ -55,6 +55,7 @@ import { Flag } from "./Flag"; import pkg from "../../package.json"; const VERSION = import.meta.env.VITE_NODEFLARE_VERSION || pkg.version; +const AGENT_SCRIPT_BASE = "https://raw.githubusercontent.com/imengying/NodeFlare/main/agent"; type AgentPlatform = "linux" | "windows" | "macos" | "freebsd"; type ThemeSourceMode = "repository" | "upload"; @@ -448,7 +449,7 @@ export function AdminPanel({ } function updatePriceText(raw: string) { - if (!/^-?\d*\.?\d*$/.test(raw)) return; + if (!/^\d*\.?\d*$/.test(raw)) return; setPriceText(raw); const parsed = Number(raw); if (Number.isFinite(parsed)) updateForm("price", parsed); @@ -511,14 +512,13 @@ export function AdminPanel({ } async function showInstallCommand(server: AdminServer) { - if (!window.confirm(`重新生成“${server.name}”的 Agent Token?当前 Agent 会立即离线,需使用新安装命令重新连接。`)) return; setBusy(true); setError(""); try { - const { agent_token } = await api.rotateServerToken(server.id); + const { agent_token } = await api.createAgentInstallToken(server.id); setInstall({ agent_token, agent_mirror: server.agent_mirror }); } catch (reason) { - setError(reason instanceof Error ? reason.message : "重置 Agent Token 失败"); + setError(reason instanceof Error ? reason.message : "生成 Agent 安装命令失败"); } finally { setBusy(false); } @@ -895,7 +895,7 @@ export function AdminPanel({ const installCommand = useMemo(() => { if (!install) return ""; const origin = window.location.origin; - const installer = `${origin}/agent`; + const installer = AGENT_SCRIPT_BASE; const mirror = install.agent_mirror.trim().replace(/\/+$/, ""); const shellMirror = mirror ? ` -m ${shellLiteral(mirror)}` : ""; const powershellMirror = mirror ? ` -Mirror ${powershellLiteral(mirror)}` : ""; @@ -1128,7 +1128,7 @@ export function AdminPanel({ toggleSelected(server.id)} ariaLabel={`选择 ${server.name}`} />
{server.name}
void copyServerIp(value)} />
-
+
))} {!servers.length && !busy ?
暂无节点
: null} @@ -1290,14 +1290,14 @@ export function AdminPanel({
节点配置

{editing === "new" ? "添加节点" : `编辑 · ${editing.name}`}

-
+
-
+
updateForm("auto_renewal", value)} /> updateForm("auto_update", value)} /> updateForm("hidden", value)} /> updateForm("offline_notify_disabled", value)} />
: null} - {installCommand ?
Agent 部署

安装命令

{installCommand}
: null} + {installCommand ?
Agent 部署

下载 Agent

{installCommand}
: null} {verificationDialog.dialog} ); diff --git a/frontend/src/components/NodeCard.tsx b/frontend/src/components/NodeCard.tsx index 4eee637..54cd661 100644 --- a/frontend/src/components/NodeCard.tsx +++ b/frontend/src/components/NodeCard.tsx @@ -22,14 +22,16 @@ import type { Config, LiveLatencyResult, Server } from "../types"; import { ui } from "../locale"; import { carrierSelection, themeToggle } from "../theme"; import { useNodeLatency, type CarrierLatencyRow, type LatencyBar } from "../hooks/useNodeLatency"; -import { Flag } from "./Flag"; +import { ProgressBar, usageTone, type UsageTone } from "./ProgressBar"; import { OSIcon } from "./OSIcon"; -import { ProgressBar } from "./ProgressBar"; +import { Flag } from "./Flag"; -function Metric({ label, value, used, sub, muted = false, valueTone = "" }: { label: string; value: string; used: number; sub: string; muted?: boolean; valueTone?: string }) { +const USAGE_TEXT_TONE: Record = { good: "", warning: "warning-text", danger: "danger-text" }; + +function Metric({ label, value, used, sub, muted = false }: { label: string; value: string; used: number; sub: string; muted?: boolean }) { return (
-
{label}{value}
+
{label}{value}
{sub}
@@ -134,7 +136,7 @@ export function NodeCard({ server, config, liveLatencyResults, onOpen }: { - 0 ? `${traffic.toFixed(1)}%` : "∞"} used={traffic} sub={`${formatBytes(usedTraffic)} / ${server.traffic_limit > 0 ? formatBytes(server.traffic_limit) : "∞"}`} muted={!online} valueTone={server.traffic_limit <= 0 ? "" : traffic >= 95 ? "danger-text" : traffic >= 60 ? "warning-text" : "success-text"} /> + 0 ? `${traffic.toFixed(1)}%` : "∞"} used={traffic} sub={`${formatBytes(usedTraffic)} / ${server.traffic_limit > 0 ? formatBytes(server.traffic_limit) : "∞"}`} muted={!online} />
@@ -148,9 +150,9 @@ export function NodeCard({ server, config, liveLatencyResults, onOpen }: {
{showExpiryPanel ?
{config.show_expiry ? }>{formatExpire(server, locale)} : null} - {config.show_price ? }>{server.price === -1 + {config.show_price ? }>{server.price <= 0 ? ui(locale, "免费", "Free") - : server.price > 0 ? formatCurrency(remainingValue, server.currency) : ui(locale, "未设置", "Not set")} : null} + : formatCurrency(remainingValue, server.currency)} : null}
: null} diff --git a/frontend/src/components/NodeDetails.tsx b/frontend/src/components/NodeDetails.tsx index b8738f8..aa45542 100644 --- a/frontend/src/components/NodeDetails.tsx +++ b/frontend/src/components/NodeDetails.tsx @@ -110,7 +110,7 @@ function networkAxisMaximum(points: LoadChartPoint[]): number { } function formatNetworkAxis(value: number): string { - return formatBytes(value, value >= 1024 ** 2 ? 1 : 0); + return `${formatBytes(value, value >= 1024 ** 2 ? 1 : 0)}/s`; } function latencyBucketSeconds(hours: number, taskCount: number): number { @@ -413,7 +413,7 @@ export function NodeDetails({ server, liveLatencyResults, threshold, retentionDa chartTimeLabel(Number(value), loadChartHours, locale)} /> formatBytes(Number(value), 0)} /> chartTimeLabel(Number(value), loadChartHours, locale)} formatter={(value) => [formatBytes(Number(value)), "硬盘"]} /> ↑ {formatSpeed(last.net_out)}↓ {formatSpeed(last.net_in)}}> - chartTimeLabel(Number(value), loadChartHours, locale)} /> formatNetworkAxis(Number(value))} /> chartTimeLabel(Number(value), loadChartHours, locale)} formatter={(value, name) => [formatSpeed(Number(value)), name === "net_out" ? "上行" : "下行"]} /> + chartTimeLabel(Number(value), loadChartHours, locale)} /> formatNetworkAxis(Number(value))} /> chartTimeLabel(Number(value), loadChartHours, locale)} formatter={(value, name) => [formatSpeed(Number(value)), name === "net_out" ? "上行" : "下行"]} /> ) : ( diff --git a/frontend/src/components/ProgressBar.tsx b/frontend/src/components/ProgressBar.tsx index 9ca83f2..ab5b5bb 100644 --- a/frontend/src/components/ProgressBar.tsx +++ b/frontend/src/components/ProgressBar.tsx @@ -1,9 +1,15 @@ -export function ProgressBar({ value }: { value: number }) { +export type UsageTone = "good" | "warning" | "danger"; + +export function usageTone(value: number): UsageTone { const safe = Math.min(100, Math.max(0, value || 0)); - const status = safe >= 90 ? "danger" : safe >= 70 ? "warning" : "good"; + return safe >= 90 ? "danger" : safe >= 70 ? "warning" : "good"; +} + +export function ProgressBar({ value }: { value: number }) { + const status = usageTone(value); return (