优化前后端

This commit is contained in:
imengying committed 2026-09-07 21:14:21 +08:00
1 parent 8c2d7240eb
commit 59dbc9abeb
30 files changed
+752 -145

No files matched your search

+70 -35
View File
@@ -2,6 +2,8 @@
自部署的服务器监控面板:服务端提供状态页与管理后台,Agent 部署在被监控服务器上采集并上报数据,两者通过 WebSocket 通信。
项目地址:<https://github.com/imengying/NodeFlare>
## 功能
- **实时监控**: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;恢复备份建议使用相同版本。
## 从源码开发
+2 -2
View File
@@ -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
+50 -24
View File
@@ -52,12 +52,13 @@ NodeFlare Agent FreeBSD 安装脚本
用法:
install-freebsd.sh -e <NodeFlare URL> -t <Agent Token> [-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'
+57 -24
View File
@@ -51,12 +51,13 @@ NodeFlare Agent macOS 安装脚本
用法:
install-macos.sh -e <NodeFlare URL> -t <Agent Token> [-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'
+33 -1
View File
@@ -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('/')
@@ -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);
@@ -0,0 +1,2 @@
UPDATE servers SET price = 0 WHERE price < 0;
ALTER TABLE servers ADD CONSTRAINT servers_price_nonnegative CHECK (price >= 0);
@@ -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);
@@ -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;
+23 -4
View File
@@ -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<DatabaseArchi
"sessions".to_string(),
"dashboard_proofs".to_string(),
"theme_previews".to_string(),
"server_install_tokens".to_string(),
],
};
@@ -541,7 +544,17 @@ async fn insert_rows(
} else {
"?".to_string()
});
add_argument(&mut arguments, column.kind, value)?;
// Normalize the old free-price sentinel before newer constraints run.
if table == "servers"
&& column.name == "price"
&& value
.as_f64()
.is_some_and(|price| (-1.0..0.0).contains(&price))
{
add_argument(&mut arguments, column.kind, &Value::from(0.0))?;
} else {
add_argument(&mut arguments, column.kind, value)?;
}
}
placeholders.push(format!("({})", row_placeholders.join(",")));
}
@@ -765,8 +778,8 @@ async fn restore_table<R: Read + Seek + Send>(
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::<f64, _>("price").unwrap(), 0.0);
assert_eq!(server.try_get::<i64, _>("hidden").unwrap(), 1);
let points = crate::db::queries::history(&target, "legacy", 1)
.await
.unwrap();
+42
View File
@@ -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::<f64, _>("price").unwrap(),
if index == 3 { 12.5 } else { 0.0 }
);
assert_eq!(row.try_get::<i64, _>("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();
+76 -4
View File
@@ -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<Option<String>> {
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<Option<String>> {
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<Option<String>> {
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<Option<String>> {
}
pub async fn agent_identity(db: &Database, token: &str) -> Result<Option<AgentIdentity>> {
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,
+4
View File
@@ -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),
+43 -1
View File
@@ -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<Arc<AppState>>,
Path(id): Path<String>,
) -> Result<Response, ApiResponse> {
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<Arc<AppState>>) -> Result<Response, ApiResponse> {
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,
+15 -9
View File
@@ -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]);
+94
View File
@@ -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);
}
});
});
}
+2
View File
@@ -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<void>(
`/api/admin/servers/${encodeURIComponent(id)}`,
+9 -9
View File
@@ -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({
<button type="button" className="drag-handle" draggable onDragStart={(event) => startDrag(event, server.id)} onDragEnd={() => setDraggingId("")} title={`拖动排序:${server.name}`}><GripVertical size={15} /></button>
<Checkbox checked={selectedIds.includes(server.id)} onChange={() => toggleSelected(server.id)} ariaLabel={`选择 ${server.name}`} />
<div className="server-name"><div className="server-name-main"><Flag region={server.region} size={17} /><strong>{server.name}</strong></div><ServerIpMeta server={server} agentVersion={server.agent_version} onCopy={(value) => void copyServerIp(value)} /></div>
<div className="row-actions"><button className="icon-btn" disabled={index === 0} onClick={() => void move(index, -1)} title="上移"><ChevronUp size={15} /></button><button className="icon-btn" disabled={index === servers.length - 1} onClick={() => void move(index, 1)} title="下移"><ChevronDown size={15} /></button><button className="icon-btn" disabled={busy} onClick={() => void showInstallCommand(server)} title="重置 Token 并生成安装命令"><Download size={15} /></button><button className="icon-btn" onClick={() => openEditor(server)} title="编辑节点"><Pencil size={15} /></button><button className="icon-btn danger" onClick={() => void remove(server)} title="删除节点"><Trash2 size={15} /></button></div>
<div className="row-actions"><button className="icon-btn" disabled={index === 0} onClick={() => void move(index, -1)} title="上移"><ChevronUp size={15} /></button><button className="icon-btn" disabled={index === servers.length - 1} onClick={() => void move(index, 1)} title="下移"><ChevronDown size={15} /></button><button className="icon-btn" disabled={busy} onClick={() => void showInstallCommand(server)} title="下载 Agent"><Download size={15} /></button><button className="icon-btn" onClick={() => openEditor(server)} title="编辑节点"><Pencil size={15} /></button><button className="icon-btn danger" onClick={() => void remove(server)} title="删除节点"><Trash2 size={15} /></button></div>
</div>
))}
{!servers.length && !busy ? <div className="list-empty">暂无节点</div> : null}
@@ -1290,14 +1290,14 @@ export function AdminPanel({
<header><div><span className="eyebrow">节点配置</span><h3 id="server-editor-title">{editing === "new" ? "添加节点" : `编辑 · ${editing.name}`}</h3></div></header>
<div className="form-grid"><label><span>名称</span><input autoFocus required value={form.name} onChange={(event) => updateForm("name", event.target.value)} /></label><label><span>地区代码</span><input maxLength={16} placeholder="CN / JP / DE" value={form.region} onChange={(event) => updateForm("region", event.target.value.toUpperCase())} /></label><label><span>分组</span><input value={form.group_name} onChange={(event) => updateForm("group_name", event.target.value)} /></label><label><span>标签</span><input placeholder="主力, 线路:BGP" value={form.tags} onChange={(event) => updateForm("tags", event.target.value)} /></label></div>
<div className="form-grid three"><label><span>流量限额(0 不限)</span><input placeholder="如 100 G,不带单位按 GB;0 不限" {...sizeInputProps(trafficLimitText, setTrafficLimitText, (bytes) => updateForm("traffic_limit", bytes), form.traffic_limit)} /></label><label><span>流量口径</span><select value={form.traffic_limit_type} onChange={(event) => updateForm("traffic_limit_type", event.target.value as ServerInput["traffic_limit_type"])}><option value="sum">上下行合计</option><option value="max">取较大值</option><option value="min">取较小值</option><option value="up">仅上行</option><option value="down">仅下行</option></select></label><label><span>流量重置日</span><input min="1" max="31" type="number" value={form.reset_day} onChange={(event) => updateForm("reset_day", Number(event.target.value))} /></label></div>
<div className="form-grid three"><label><span>价格(0 隐藏,-1 免费)</span><input inputMode="decimal" value={priceText} onChange={(event) => updatePriceText(event.target.value)} onBlur={() => setPriceText(String(form.price))} /></label><label><span>币种</span><select value={form.currency} onChange={(event) => updateForm("currency", event.target.value)}>{ASSET_CURRENCIES.map((code) => <option key={code}>{code}</option>)}</select></label><label><span>计费周期</span><select value={String(form.billing_cycle)} onChange={(event) => updateForm("billing_cycle", Number(event.target.value))}>{BILLING_CYCLES.map((cycle) => <option key={cycle.days} value={cycle.days}>{cycle.label}</option>)}{BILLING_CYCLES.every((cycle) => cycle.days !== form.billing_cycle) ? <option value={form.billing_cycle}>{form.billing_cycle} 天</option> : null}</select></label></div>
<div className="form-grid three"><label><span>价格(0 免费)</span><input type="number" min="0" max="1000000000" step="any" inputMode="decimal" value={priceText} onChange={(event) => updatePriceText(event.target.value)} onBlur={() => setPriceText(String(form.price))} /></label><label><span>币种</span><select value={form.currency} onChange={(event) => updateForm("currency", event.target.value)}>{ASSET_CURRENCIES.map((code) => <option key={code}>{code}</option>)}</select></label><label><span>计费周期</span><select value={String(form.billing_cycle)} onChange={(event) => updateForm("billing_cycle", Number(event.target.value))}>{BILLING_CYCLES.map((cycle) => <option key={cycle.days} value={cycle.days}>{cycle.label}</option>)}{BILLING_CYCLES.every((cycle) => cycle.days !== form.billing_cycle) ? <option value={form.billing_cycle}>{form.billing_cycle} 天</option> : null}</select></label></div>
<div className="form-grid three"><label><span>到期日期</span><input type="date" value={formatDate(form.expires_at)} onChange={(event) => updateForm("expires_at", event.target.value ? Math.floor(new Date(`${event.target.value}T00:00:00Z`).getTime() / 1000) : null)} /></label><label><span>历史保存间隔(秒)</span><input min="15" max="3600" type="number" value={form.report_interval} onChange={(event) => updateForm("report_interval", Number(event.target.value))} /></label><label><span>实时采样间隔(秒)</span><input min="1" max="60" type="number" value={form.collect_interval} onChange={(event) => updateForm("collect_interval", Number(event.target.value))} /></label></div>
<div className="form-grid"><label><span>统计网卡(逗号分隔,留空自动)</span><input value={form.network_interface} onChange={(event) => updateForm("network_interface", event.target.value)} placeholder="eth0,ens3" /></label><label><span>下行流量当前值</span><input placeholder="如 500 G" {...sizeInputProps(rxCurrentText, setRxCurrentText, setRxCurrentBytes, rxCurrentBytes)} /></label><label><span>上行流量当前值</span><input placeholder="如 500 G" {...sizeInputProps(txCurrentText, setTxCurrentText, setTxCurrentBytes, txCurrentBytes)} /></label><label><span>Agent 下载加速(可选)</span><input value={form.agent_mirror} onChange={(event) => updateForm("agent_mirror", event.target.value.trim())} placeholder="https://ghproxy.net" /></label></div>
<div className="form-grid"><label><span>统计网卡(逗号分隔,留空自动)</span><input value={form.network_interface} onChange={(event) => updateForm("network_interface", event.target.value)} placeholder="eth0,ens3" /></label><label><span>Agent 下载加速(可选)</span><input value={form.agent_mirror} onChange={(event) => updateForm("agent_mirror", event.target.value.trim())} placeholder="https://ghproxy.net" /></label><label><span>上行流量当前值</span><input placeholder="如 500 G" {...sizeInputProps(txCurrentText, setTxCurrentText, setTxCurrentBytes, txCurrentBytes)} /></label><label><span>下行流量当前值</span><input placeholder="如 500 G" {...sizeInputProps(rxCurrentText, setRxCurrentText, setRxCurrentBytes, rxCurrentBytes)} /></label></div>
<div className="settings-toggles editor-toggles"><Toggle label={form.billing_cycle <= 0 ? "自动续费(一次性不适用)" : "自动续费"} checked={form.auto_renewal} onChange={(value) => updateForm("auto_renewal", value)} /><Toggle label="Agent 自动更新" checked={form.auto_update} onChange={(value) => updateForm("auto_update", value)} /><Toggle label="隐藏节点" checked={form.hidden} onChange={(value) => updateForm("hidden", value)} /><Toggle label="关闭离线告警" checked={form.offline_notify_disabled} onChange={(value) => updateForm("offline_notify_disabled", value)} /></div>
<div className="form-actions"><button type="button" className="secondary-btn" onClick={() => setEditing(null)}>取消</button><button className="primary-btn" disabled={busy}><Save size={15} />保存节点</button></div>
</form></div> : null}
{installCommand ? <div className="submodal-backdrop" role="presentation" onMouseDown={installDialog.onBackdropMouseDown}><section ref={installDialog.dialogRef} className="install-modal glass-panel" role="dialog" aria-modal="true" aria-labelledby="install-dialog-title" tabIndex={-1}><header><div><span className="eyebrow">Agent 部署</span><h3 id="install-dialog-title">安装命令</h3></div><div className="segmented install-platform" role="group" aria-label="Agent 平台"><button type="button" className={installPlatform === "linux" ? "active" : ""} aria-pressed={installPlatform === "linux"} onClick={() => setInstallPlatform("linux")}>Linux</button><button type="button" className={installPlatform === "windows" ? "active" : ""} aria-pressed={installPlatform === "windows"} onClick={() => setInstallPlatform("windows")}>Windows</button><button type="button" className={installPlatform === "macos" ? "active" : ""} aria-pressed={installPlatform === "macos"} onClick={() => setInstallPlatform("macos")}>macOS ARM</button><button type="button" className={installPlatform === "freebsd" ? "active" : ""} aria-pressed={installPlatform === "freebsd"} onClick={() => setInstallPlatform("freebsd")}>FreeBSD</button></div></header><div className="install-list"><pre>{installCommand}</pre></div><div className="form-actions"><button className="secondary-btn" type="button" onClick={() => setInstall(null)}>关闭</button><button className="primary-btn" type="button" onClick={() => void copyInstallCommand()}><Copy size={15} />复制</button></div></section></div> : null}
{installCommand ? <div className="submodal-backdrop" role="presentation" onMouseDown={installDialog.onBackdropMouseDown}><section ref={installDialog.dialogRef} className="install-modal glass-panel" role="dialog" aria-modal="true" aria-labelledby="install-dialog-title" tabIndex={-1}><header><div><span className="eyebrow">Agent 部署</span><h3 id="install-dialog-title">下载 Agent</h3></div><div className="segmented install-platform" role="group" aria-label="Agent 平台"><button type="button" className={installPlatform === "linux" ? "active" : ""} aria-pressed={installPlatform === "linux"} onClick={() => setInstallPlatform("linux")}>Linux</button><button type="button" className={installPlatform === "windows" ? "active" : ""} aria-pressed={installPlatform === "windows"} onClick={() => setInstallPlatform("windows")}>Windows</button><button type="button" className={installPlatform === "macos" ? "active" : ""} aria-pressed={installPlatform === "macos"} onClick={() => setInstallPlatform("macos")}>macOS ARM</button><button type="button" className={installPlatform === "freebsd" ? "active" : ""} aria-pressed={installPlatform === "freebsd"} onClick={() => setInstallPlatform("freebsd")}>FreeBSD</button></div></header><div className="install-list"><pre>{installCommand}</pre></div><div className="form-actions"><button className="secondary-btn" type="button" onClick={() => setInstall(null)}>关闭</button><button className="primary-btn" type="button" onClick={() => void copyInstallCommand()}><Copy size={15} />复制</button></div></section></div> : null}
{verificationDialog.dialog}
</div>
);
+9 -7
View File
@@ -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<UsageTone, string> = { 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 (
<div className={`metric ${muted ? "muted" : ""}`}>
<div><span>{label}</span><strong className={valueTone}>{value}</strong></div>
<div><span>{label}</span><strong className={USAGE_TEXT_TONE[usageTone(used)]}>{value}</strong></div>
<ProgressBar value={used} />
<small title={sub}>{sub}</small>
</div>
@@ -134,7 +136,7 @@ export function NodeCard({ server, config, liveLatencyResults, onOpen }: {
<Metric label="CPU" value={`${number(server.cpu).toFixed(1)}%`} used={number(server.cpu)} sub={`${number(server.load1).toFixed(2)}, ${number(server.load5).toFixed(2)}, ${number(server.load15).toFixed(2)}`} muted={!online} />
<Metric label={ui(locale, "内存", "Memory")} value={`${memory.toFixed(1)}%`} used={memory} sub={`${formatBytes(server.mem_used)} / ${formatBytes(server.mem_total)}`} muted={!online} />
<Metric label={ui(locale, "硬盘", "Disk")} value={`${disk.toFixed(1)}%`} used={disk} sub={`${formatBytes(server.disk_used)} / ${formatBytes(server.disk_total)}`} muted={!online} />
<Metric label={ui(locale, "流量", "Traffic")} value={server.traffic_limit > 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"} />
<Metric label={ui(locale, "流量", "Traffic")} value={server.traffic_limit > 0 ? `${traffic.toFixed(1)}%` : "∞"} used={traffic} sub={`${formatBytes(usedTraffic)} / ${server.traffic_limit > 0 ? formatBytes(server.traffic_limit) : "∞"}`} muted={!online} />
</div>
<div className={`data-grid ${showExpiryPanel ? "" : "two-columns"}`}>
@@ -148,9 +150,9 @@ export function NodeCard({ server, config, liveLatencyResults, onOpen }: {
</div>
{showExpiryPanel ? <div className="data-panel" title={ui(locale, "剩余周期", "Billing cycle")}>
{config.show_expiry ? <CompactLine icon={<CalendarDays size={11} />}>{formatExpire(server, locale)}</CompactLine> : null}
{config.show_price ? <CompactLine icon={<Coins size={11} />}>{server.price === -1
{config.show_price ? <CompactLine icon={<Coins size={11} />}>{server.price <= 0
? ui(locale, "免费", "Free")
: server.price > 0 ? formatCurrency(remainingValue, server.currency) : ui(locale, "未设置", "Not set")}</CompactLine> : null}
: formatCurrency(remainingValue, server.currency)}</CompactLine> : null}
</div> : null}
</div>
+2 -2
View File
@@ -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
<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>
</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={62} 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" ? "上行" : "下行"]} /><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>
) : (
+9 -3
View File
@@ -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 (
<span className="progress-track" aria-hidden="true">
<span className={`progress-fill ${status}`} style={{ width: `${safe}%` }} />
<span className={`progress-fill ${status}`} style={{ width: `${Math.min(100, Math.max(0, value || 0))}%` }} />
</span>
);
}
+2 -2
View File
@@ -138,8 +138,8 @@ export const demoServers: Server[] = [
node({ id: "tokyo-core", name: "东京 Core", region: "JP", group_name: "核心服务", tags: "主力,线路:Premium", price: 9.9, currency: "USD", expires_at: now + 46 * 86400, mem_total: 16 * 1024 ** 3, mem_used: 6.72 * 1024 ** 3, disk_total: 224 * 1024 ** 3, disk_used: 69.44 * 1024 ** 3, traffic_limit: 2 * 1024 ** 4, net_rx_total: 810 * 1024 ** 3, net_tx_total: 310 * 1024 ** 3, cpu: 31 }),
node({ id: "singapore-data", name: "新加坡 Data", region: "SG", group_name: "数据服务", tags: "数据库,NVMe", price: 88, expires_at: now + 46 * 86400, mem_total: 24 * 1024 ** 3, mem_used: 10.08 * 1024 ** 3, disk_total: 288 * 1024 ** 3, disk_used: 89.28 * 1024 ** 3, traffic_limit: 2 * 1024 ** 4, net_rx_total: 980 * 1024 ** 3, net_tx_total: 400 * 1024 ** 3, cpu: 38 }),
node({ id: "los-angeles-west", name: "洛杉矶 West", region: "US", group_name: "边缘网络", tags: "备用,线路:CN2", price: 24, currency: "USD", expires_at: now + 46 * 86400, disk_total: 352 * 1024 ** 3, disk_used: 109.12 * 1024 ** 3, traffic_limit: 2 * 1024 ** 4, net_rx_total: 1.12 * 1024 ** 4, net_tx_total: 490 * 1024 ** 3, cpu: 45 }),
node({ id: "frankfurt-lab", name: "法兰克福 Lab", region: "DE", group_name: "实验服务", tags: "Lab,IPv6", price: -1, expires_at: null, mem_total: 16 * 1024 ** 3, mem_used: 6.72 * 1024 ** 3, cpu: 52, uptime: 134 * 86400 }),
node({ id: "taipei-homelab", name: "台北 HomeLab", region: "TW", group_name: "家庭网络", tags: "HomeLab,自建", price: -1, expires_at: null, mem_total: 24 * 1024 ** 3, mem_used: 10.08 * 1024 ** 3, cpu: 59, uptime: 122 * 86400 }),
node({ id: "frankfurt-lab", name: "法兰克福 Lab", region: "DE", group_name: "实验服务", tags: "Lab,IPv6", price: 0, expires_at: null, mem_total: 16 * 1024 ** 3, mem_used: 6.72 * 1024 ** 3, cpu: 52, uptime: 134 * 86400 }),
node({ id: "taipei-homelab", name: "台北 HomeLab", region: "TW", group_name: "家庭网络", tags: "HomeLab,自建", price: 0, expires_at: null, mem_total: 24 * 1024 ** 3, mem_used: 10.08 * 1024 ** 3, cpu: 59, uptime: 122 * 86400 }),
node({ id: "london-archive", name: "伦敦 Archive", region: "GB", group_name: "存储服务", tags: "Archive,HDD", price: 18, currency: "EUR", billing_cycle: 365, expires_at: now + 110 * 86400, disk_total: 4 * 1024 ** 4, disk_used: 2.7 * 1024 ** 4, cpu: 66, latency: [] }),
node({ id: "toronto-standby", name: "多伦多 Standby", region: "CA", group_name: "备用节点", tags: "Standby", price: 16, currency: "CAD", expires_at: now + 18 * 86400, timestamp: now - 640, cpu: 0, net_in: 0, net_out: 0 }),
];
+15 -3
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import { formatByteSize, formatPrice, formatSpeedParts, parseByteSize } from "./format";
import { formatByteSize, formatExpire, formatPrice, formatSpeedParts, parseByteSize } from "./format";
describe("parseByteSize", () => {
test("accepts manual units and defaults to GB", () => {
@@ -39,10 +39,22 @@ describe("formatSpeedParts", () => {
});
describe("formatPrice", () => {
test("renders free, hidden and one-time cycles", () => {
test("renders zero and legacy negative prices as free", () => {
expect(formatPrice({ price: -1, billing_cycle: 30, currency: "CNY" })).toBe("免费");
expect(formatPrice({ price: 0, billing_cycle: 30, currency: "CNY" })).toBe("");
expect(formatPrice({ price: 0, billing_cycle: 30, currency: "CNY" })).toBe("免费");
expect(formatPrice({ price: 0, billing_cycle: 30, currency: "USD" }, "en")).toBe("Free");
});
test("preserves paid and one-time cycles", () => {
expect(formatPrice({ price: 5, billing_cycle: 0, currency: "CNY" })).toBe("¥5 / 一次性");
expect(formatPrice({ price: 5, billing_cycle: 365, currency: "CNY" })).toBe("¥5 / 年");
});
});
describe("formatExpire", () => {
test("respects the expiration date even when a node is free", () => {
expect(formatExpire({ price: 0, expires_at: null })).toBe("长期");
expect(formatExpire({ price: 0, expires_at: 1 })).toBe("已过期");
expect(formatExpire({ price: 5, expires_at: null })).toBe("未设置");
});
});
+2 -4
View File
@@ -154,9 +154,7 @@ export function formatCurrency(value: number, currency = "CNY") {
}
export function formatPrice(server: Pick<Server, "price" | "billing_cycle" | "currency">, locale: UiLocale = "zh-CN") {
if (server.price === -1) return ui(locale, "免费", "Free");
if (server.price === 0) return "";
if (server.price < 0) return "";
if (server.price <= 0) return ui(locale, "免费", "Free");
const cycle = server.billing_cycle <= 0 ? ui(locale, "一次性", "one-time")
: server.billing_cycle >= 27 && server.billing_cycle <= 32 ? ui(locale, "月", "month")
: server.billing_cycle >= 87 && server.billing_cycle <= 95 ? ui(locale, "季", "quarter")
@@ -172,7 +170,7 @@ function daysUntil(timestamp: number | null) {
}
export function formatExpire(server: Pick<Server, "expires_at" | "price">, locale: UiLocale = "zh-CN") {
if (server.price === -1) return ui(locale, "长期", "Lifetime");
if (server.price <= 0 && !server.expires_at) return ui(locale, "长期", "Lifetime");
const days = daysUntil(server.expires_at);
if (days === null) return ui(locale, "未设置", "Not set");
if (days < 0) return ui(locale, "已过期", "Expired");
+9 -2
View File
@@ -102,7 +102,8 @@ describe("installer menu", () => {
const dispatch = installer.slice(installer.indexOf("\nmode=menu"), installer.indexOf("\nfor required_command"));
return spawnSync("sh", ["-c", `
set -eu
${shellFunctions("show_menu", "fail", "usage").replaceAll("/dev/tty", "/dev/null")}
${shellFunctions("show_menu", "confirm_purge", "fail", "usage").replaceAll("/dev/tty", "/dev/null")}
server_binary=/nonexistent/nodeflare
prompt_line() { IFS= read -r prompt_value; }
id() { printf '0'; }
detect_init_system() { printf systemd; }
@@ -127,13 +128,19 @@ describe("installer menu", () => {
[["--install"], "install"], [["--status"], "status"], [["--restart"], "restart"],
[["--uninstall"], "uninstall:false"], [["--uninstall", "--purge"], "uninstall:true"],
] as const) {
const result = route("", [...args]);
const result = route("y\n", [...args]);
expect(result.status).toBe(0);
expect(result.stdout).toBe(expected);
}
expect(route("", ["--purge"]).status).toBe(1);
expect(route("", ["--status", "--install"]).status).toBe(1);
});
test("purge cancellation never calls uninstall", () => {
const result = route("n\n", ["--uninstall", "--purge"]);
expect(result.status).toBe(1);
expect(result.stdout).toBe("");
});
});
describe("agent installer manual update", () => {
+64 -1
View File
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test";
import { describe, expect, spyOn, test } from "bun:test";
import {
BOOTSTRAP_LIVE_SYNC_INTERVAL_MS,
createLiveFlushScheduler,
createRefreshQueue,
hasActiveRemoteTasks,
isRemoteTaskActive,
@@ -38,6 +39,68 @@ describe("createRefreshQueue", () => {
});
});
describe("createLiveFlushScheduler", () => {
test("uses a fixed one-second clock, coalesces bursts and skips idle ticks", () => {
let flushes = 0;
let tick = () => {};
const timer = 123 as unknown as ReturnType<typeof setInterval>;
const start = spyOn(globalThis, "setInterval").mockImplementation(((callback: () => void, delay?: number) => {
expect(delay).toBe(1_000);
tick = callback as () => void;
return timer;
}) as typeof setInterval);
const stop = spyOn(globalThis, "clearInterval").mockImplementation(() => {});
try {
const scheduler = createLiveFlushScheduler(() => { flushes += 1; });
scheduler.schedule();
scheduler.schedule();
scheduler.schedule();
expect(flushes).toBe(0);
expect(start).toHaveBeenCalledTimes(1);
tick();
expect(flushes).toBe(1);
tick();
expect(flushes).toBe(1);
scheduler.schedule();
expect(start).toHaveBeenCalledTimes(1);
tick();
expect(flushes).toBe(2);
scheduler.cancel();
expect(stop).toHaveBeenCalledWith(timer);
} finally {
start.mockRestore();
stop.mockRestore();
}
});
test("cancel drops pending data and allows restarting the clock", () => {
let flushes = 0;
let tick = () => {};
const start = spyOn(globalThis, "setInterval").mockImplementation(((callback: () => void) => {
tick = callback as () => void;
return 123 as unknown as ReturnType<typeof setInterval>;
}) as typeof setInterval);
const stop = spyOn(globalThis, "clearInterval").mockImplementation(() => {});
try {
const scheduler = createLiveFlushScheduler(() => { flushes += 1; });
scheduler.schedule();
scheduler.cancel();
scheduler.cancel();
tick();
expect(flushes).toBe(0);
expect(stop).toHaveBeenCalledTimes(1);
scheduler.schedule();
expect(start).toHaveBeenCalledTimes(2);
tick();
expect(flushes).toBe(1);
scheduler.cancel();
} finally {
start.mockRestore();
stop.mockRestore();
}
});
});
describe("bootstrap refresh policy", () => {
test("refreshes every tick while the live connection is down", () => {
expect(shouldSyncBootstrap(false, 0)).toBe(true);
+28
View File
@@ -3,6 +3,34 @@ import type { RemoteTask } from "./types";
export const BOOTSTRAP_POLL_INTERVAL_MS = 15_000;
export const BOOTSTRAP_LIVE_SYNC_INTERVAL_MS = 5 * 60_000;
export const REMOTE_TASK_POLL_INTERVAL_MS = 2_000;
export const LIVE_FLUSH_INTERVAL_MS = 1_000;
export interface LiveFlushScheduler {
schedule: () => void;
cancel: () => void;
}
export function createLiveFlushScheduler(flush: () => void, intervalMs = LIVE_FLUSH_INTERVAL_MS): LiveFlushScheduler {
let timer: ReturnType<typeof setInterval> | undefined;
let pending = false;
return {
schedule: () => {
pending = true;
if (timer !== undefined) return;
timer = setInterval(() => {
if (!pending) return;
pending = false;
flush();
}, intervalMs);
},
cancel: () => {
pending = false;
if (timer === undefined) return;
clearInterval(timer);
timer = undefined;
},
};
}
export function createRefreshQueue(execute: (quiet: boolean) => Promise<void>) {
let active: Promise<void> | null = null;
+4 -2
View File
@@ -211,6 +211,8 @@ html[data-blur="on"] .node-offline-overlay { backdrop-filter: blur(2px); }
@media (max-width: 1023px) {
.container { padding-inline: 24px; }
.toolbar { flex-wrap: wrap; row-gap: 8px; }
.group-tabs { order: 3; flex: 1 1 100%; max-width: 100%; padding-right: 25px; -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 22px), transparent); mask-image: linear-gradient(to right, #000 calc(100% - 22px), transparent); }
.overview { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.overview-item { border-bottom: 1px solid var(--border); border-left: 0; }
.overview-item:nth-child(even) { border-left: 1px solid var(--border); }
@@ -233,8 +235,8 @@ html[data-blur="on"] .node-offline-overlay { backdrop-filter: blur(2px); }
.overview-value strong { font-size: 25px; }
.overview-detail { gap: 8px; font-size: 11px; }
.toolbar { flex-wrap: wrap; gap: 8px; }
.search-box { width: 100%; }
.group-tabs { max-width: calc(100% - 58px); }
.search-box { flex: 1 1 auto; width: auto; min-width: 0; }
.result-count { order: 1; }
.node-grid { grid-template-columns: 1fr; }
.detail-hero { padding: 14px 16px; }
.detail-hero > .icon-btn { margin-left: -4px; }
+25 -4
View File
@@ -50,9 +50,15 @@ function Escape-Toml([string]$Value) {
}
function Show-Menu {
$CurrentVersion = $null
if (Test-Path -LiteralPath $ServerFile -PathType Leaf) {
try { $VersionOutput = (& $ServerFile --version 2>$null | Out-String).Trim() } catch { $VersionOutput = "" }
if ($VersionOutput -match '(\d+\.\d+\.\d+)\s*$') { $CurrentVersion = $Matches[1] }
}
$InstallAction = if ($CurrentVersion) { "更新(当前 v$CurrentVersion)" } else { "安装" }
Write-Host ""
Write-Host "NodeFlare 面板管理"
Write-Host " 1. 安装 / 更新"
Write-Host " 1. $InstallAction"
Write-Host " 2. 查看服务状态"
Write-Host " 3. 重启服务"
Write-Host " 4. 卸载(保留配置和数据)"
@@ -155,6 +161,11 @@ if ($Mode -eq "Restart") {
}
if ($Mode -eq "Uninstall") {
if ($Purge -and -not [Console]::IsInputRedirected) {
if ((Read-Host "即将删除全部配置和数据,确认继续?[y/N]").Trim() -ne "y") {
Stop-Install "已取消卸载"
}
}
Write-Step "停止并移除 NodeFlare 面板服务"
Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
@@ -234,11 +245,18 @@ try {
do {
$Username = (Read-Host "管理员用户名 [admin]").Trim()
if (-not $Username) { $Username = "admin" }
if ($Username -notmatch '^[A-Za-z0-9_.-]{1,64}$') { Write-Host "用户名只能包含字母、数字、点、下划线和连字符(1-64 个字符)。" }
} while ($Username -notmatch '^[A-Za-z0-9_.-]{1,64}$')
do {
while ($true) {
$Password = Read-Password "管理员密码(8-128 个字符)"
if ($Password.Length -lt 8 -or $Password.Length -gt 128) {
Write-Host "密码长度必须在 8-128 个字符之间。"
continue
}
$Confirmed = Read-Password "再次输入密码"
} while ($Password.Length -lt 8 -or $Password.Length -gt 128 -or $Password -ne $Confirmed)
if ($Password -ceq $Confirmed) { break }
Write-Host "两次输入的密码不一致。"
}
$Port = Read-Port
do {
$DatabaseUrl = (Read-Host "数据库 URL [sqlite://nodeflare.db]").Trim()
@@ -273,6 +291,7 @@ try {
$Settings = New-ScheduledTaskSettingsSet -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit (New-TimeSpan -Days 3650)
$Principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest
Register-ScheduledTask -TaskName $TaskName -Action $Action -Trigger $Trigger -Settings $Settings -Principal $Principal -Force | Out-Null
Write-Step "正在启动服务"
Start-ScheduledTask -TaskName $TaskName
Wait-Server
@@ -287,10 +306,12 @@ try {
} else {
Write-Host " 监听地址:沿用 $ConfigFile 中的 bind_addr"
}
Write-Host " 下一步:登录管理后台创建节点,并按弹窗命令安装 Agent"
$InstallChanged = $false
} catch {
if ($InstallChanged) {
Write-Warning "安装未完成,正在恢复上一版本"
if ($HadPreviousInstall) { Write-Warning "安装未完成,正在恢复上一版本" }
else { Write-Warning "安装未完成,正在回滚本次更改" }
Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $InstallDir -Recurse -Force -ErrorAction SilentlyContinue
+31 -2
View File
@@ -152,10 +152,21 @@ prompt_line() {
show_menu() {
(: < /dev/tty) 2>/dev/null || fail "交互菜单需要终端;直接安装或更新请使用 --install"
installed_version=""
if [ -x "$server_binary" ]; then
binary_version=$("$server_binary" --version 2>/dev/null || true)
installed_version=${binary_version##* }
printf '%s\n' "$installed_version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' || installed_version=""
fi
if [ -n "$installed_version" ]; then
install_action="更新(当前 v$installed_version)"
else
install_action="安装"
fi
printf '%s\n' \
'' \
'NodeFlare 面板管理' \
' 1. 安装 / 更新' \
" 1. $install_action" \
' 2. 查看服务状态' \
' 3. 重启服务' \
' 4. 卸载(保留配置和数据)' \
@@ -212,6 +223,15 @@ prompt_secret() {
printf '\n' > /dev/tty
}
confirm_purge() {
(: < /dev/tty) 2>/dev/null || return 0
prompt_line "即将删除全部配置和数据,确认继续?[y/N]: "
case "$prompt_value" in
y|Y) ;;
*) fail "已取消卸载" ;;
esac
}
prompt_credentials() {
[ -r /dev/tty ] && [ -w /dev/tty ] || fail "首次安装必须在交互式终端中运行"
while :; do
@@ -489,7 +509,11 @@ snapshot_install() {
rollback_install() {
rollback_ready=false
log "安装未完成,正在恢复上一版本"
if [ "$previous_install" = true ]; then
log "安装未完成,正在恢复上一版本"
else
log "安装未完成,正在回滚本次更改"
fi
stop_server
rm -rf "$install_dir"
if [ "$previous_install" = true ]; then
@@ -633,6 +657,9 @@ fi
[ "$(id -u)" -eq 0 ] || fail "请使用 root 权限运行"
init_system=$(detect_init_system)
if [ "$mode" = uninstall ]; then
if [ "$purge" = true ]; then
confirm_purge
fi
uninstall_server "$purge"
exit 0
fi
@@ -695,6 +722,7 @@ else
chmod 0600 "$config_file"
fi
log "正在启动服务"
if ! start_server; then
if [ "$init_system" = systemd ]; then
journalctl -u nodeflare.service -n 30 --no-pager >&2 || true
@@ -714,3 +742,4 @@ if [ "$new_config" = true ]; then
else
printf '监听地址:沿用 %s 中的 bind_addr\n' "$config_file"
fi
printf '%s\n' "下一步:登录管理后台创建节点,并按弹窗命令安装 Agent"