优化前后端
This commit is contained in:
1 parent
8c2d7240eb
commit
59dbc9abeb
30 files changed
+752
-145
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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)}`,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }),
|
||||
];
|
||||
|
||||
@@ -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("未设置");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
Reference in new issue
Block a user