优化安装脚本
This commit is contained in:
1 parent
58dea92e5f
commit
c70564d2f9
30 files changed
+769
-437
No files matched your search
@@ -2,8 +2,6 @@
|
||||
|
||||
自部署的服务器监控面板:服务端提供状态页与管理后台,Agent 部署在被监控服务器上采集并上报数据,两者通过 WebSocket 通信。
|
||||
|
||||
项目地址:<https://github.com/imengying/NodeFlare>
|
||||
|
||||
## 功能
|
||||
|
||||
- **实时监控**:CPU、内存、负载、磁盘、网络流量、TCP/UDP 连接数
|
||||
@@ -52,7 +50,7 @@ nodeflare.example.com {
|
||||
|
||||
只有 `trusted_proxies` 中列出的代理写入的 `X-Forwarded-For` 会被信任,代理不在本机时将其 IP 或 CIDR 加入 `config.toml`。
|
||||
|
||||
更新:重新运行安装脚本即可,已安装的配置和数据会保留;也可直接执行 `install.sh --install`(Windows 为 `install.ps1 -Install`)。卸载默认保留配置和数据,追加 `--purge` 一并删除:
|
||||
更新:同一数据库基线内重新运行安装脚本即可,已安装的配置和数据会保留;也可直接执行 `install.sh --install`(Windows 为 `install.ps1 -Install`)。从旧迁移 `01–04` 切换到本版前,必须先按[数据库基线切换](#数据库基线切换)导出备份并准备全新数据库。卸载默认保留配置和数据,追加 `--purge` 一并删除:
|
||||
|
||||
```bash
|
||||
# Linux / macOS
|
||||
@@ -121,7 +119,7 @@ Windows 下载加速参数为 `-Mirror https://ghproxy.net`。更新会校验 Re
|
||||
|
||||
实时上/下行速率由 Agent 按“收发字节增量 / 实际采样秒数”计算,前台每秒合并展示最新样本,无新样本时保持原值。节点实时采样间隔默认为 1 秒;设置更长间隔时不会凭空生成每秒数据。历史保存间隔独立,默认 60 秒。
|
||||
|
||||
节点价格最低为 `0`,表示免费;旧版负价格在升级或恢复备份时归零。是否隐藏节点仅由“隐藏节点”开关决定。
|
||||
节点价格最低为 `0`,表示免费;不再接受负价格。是否隐藏节点仅由“隐藏节点”开关决定。
|
||||
|
||||
Agent 服务名为 `nodeflare-agent`,卸载:
|
||||
|
||||
@@ -170,10 +168,23 @@ database_url = "postgres://nodeflare:password@127.0.0.1:5432/nodeflare?sslmode=d
|
||||
- 备份含设置、节点、监控历史、通知、主题(含文件)、远程任务与安全配置;会话等临时数据不导出,恢复后需重新登录。
|
||||
- 导出、恢复、迁移前需验证当前 TOTP,未启用时为管理员密码。
|
||||
- 迁移会覆盖目标库已有数据并自动更新 `database_url`,完成后重启生效。
|
||||
- Agent 安装 Token 不进入数据库备份,恢复后需重新生成安装命令。
|
||||
- 节点主 Token 保留;“下载 Agent”额外生成的安装 Token 不进入数据库备份,使用这些 Token 的节点恢复后需重新生成并执行安装命令,单独 `--update` 不会更换 Token。
|
||||
- 历史指标默认保留 30 天,过期数据自动清理;完整空间回收在后台手动触发。
|
||||
- 升级建议先更新服务端再更新 Agent;恢复备份建议使用相同版本。
|
||||
|
||||
### 数据库基线切换
|
||||
|
||||
本版按新项目维护,SQLite / PostgreSQL 均只有一个包含完整结构的 `01_initial.sql`。Agent 协议为 `2`,不再接受旧协议或缺少 `persist` 标志的上报,服务端与 Agent 必须配套更新。
|
||||
|
||||
从已经执行旧迁移 `01–04` 的版本切换时:
|
||||
|
||||
1. **更新前**在原版本后台导出 ZIP 备份,并保留原程序、配置和数据库以便回退。支持该版本导出的完整表结构备份(格式版本 `2`),不支持更早缺少历史聚合字段的备份。
|
||||
2. 停止服务端,将 `database_url` 指向全新的 SQLite 文件或空 PostgreSQL 数据库。不要覆盖原库、清空迁移记录,或把旧 `.db` / SQL 转储直接作为新库使用。
|
||||
3. 安装本版,用临时管理员账号初始化新库;原配置中的管理员密码已被清空时,需要重新设置 `admin_username`、`admin_password`。登录新后台后恢复刚导出的 ZIP。
|
||||
4. 恢复会覆盖临时账号和新库业务数据,并退出登录。使用备份中的管理员账号、密码和两步验证重新登录,再更新各节点 Agent;使用额外安装 Token 的节点还需重新安装以更换 Token。
|
||||
|
||||
ZIP 恢复只导入业务数据,不导入 `_sqlx_migrations`,新库仍保留单一 `01` 基线。现版本备份中未填充的历史统计字段会在恢复事务内补齐;设置、节点、历史记录、主题文件和安全配置会保留。全部验证正常前不要删除原库。
|
||||
|
||||
## 从源码开发
|
||||
|
||||
```bash
|
||||
|
||||
+28
-19
@@ -14,6 +14,7 @@ backup_service=""
|
||||
rollback_agent=false
|
||||
had_agent=false
|
||||
had_service=false
|
||||
update=false
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
@@ -34,14 +35,29 @@ EOF
|
||||
}
|
||||
|
||||
log() {
|
||||
printf '[NodeFlare] %s\n' "$1"
|
||||
printf '%s\n' "$1"
|
||||
}
|
||||
|
||||
fail() {
|
||||
printf '[NodeFlare] 错误:%s\n' "$1" >&2
|
||||
printf '错误:%s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
print_install_result() {
|
||||
if [ "$update" = true ] || [ "$had_agent" = true ]; then
|
||||
printf '\n更新完成(v%s)\n' "$installed_version"
|
||||
return
|
||||
fi
|
||||
printf '\n安装完成(v%s)\n' "$installed_version"
|
||||
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
|
||||
}
|
||||
|
||||
cleanup_agent_install() {
|
||||
if [ "$rollback_agent" = true ]; then
|
||||
rollback_agent=false
|
||||
@@ -252,7 +268,7 @@ install_agent() {
|
||||
download_url="$mirror/$release_base/$artifact"
|
||||
log "正在通过下载加速前缀拉取 Agent $release_tag"
|
||||
else
|
||||
log "正在下载 NodeFlare Agent $release_tag"
|
||||
log "正在下载 Agent $release_tag"
|
||||
fi
|
||||
curl --fail --location --silent --show-error --max-time 120 \
|
||||
"$download_url" \
|
||||
@@ -290,7 +306,7 @@ install_agent() {
|
||||
openrc) rc-service "$SERVICE_NAME" stop 2>/dev/null || true ;;
|
||||
esac
|
||||
mv "$temporary" "$AGENT_FILE"
|
||||
log "正在配置并启动 $init_system 服务"
|
||||
log "正在启动 $init_system 服务"
|
||||
if [ "$init_system" = "systemd" ]; then
|
||||
printf '%s\n' \
|
||||
'[Unit]' \
|
||||
@@ -311,10 +327,10 @@ install_agent() {
|
||||
'WantedBy=multi-user.target' > "$SERVICE_FILE"
|
||||
chmod 600 "$SERVICE_FILE"
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now "$SERVICE_NAME"
|
||||
systemctl enable --now --quiet "$SERVICE_NAME"
|
||||
systemctl is-active --quiet "$SERVICE_NAME" || {
|
||||
systemctl status "$SERVICE_NAME" --no-pager >&2 || true
|
||||
fail "NodeFlare 服务启动失败,请查看上方状态信息"
|
||||
fail "服务启动失败,请查看上方状态信息"
|
||||
}
|
||||
elif [ "$init_system" = "openrc" ]; then
|
||||
printf '%s\n' \
|
||||
@@ -333,21 +349,13 @@ install_agent() {
|
||||
rc-service "$SERVICE_NAME" restart
|
||||
rc-service "$SERVICE_NAME" status >/dev/null || {
|
||||
rc-service "$SERVICE_NAME" status >&2 || true
|
||||
fail "NodeFlare 服务启动失败,请查看上方状态信息"
|
||||
fail "服务启动失败,请查看上方状态信息"
|
||||
}
|
||||
fi
|
||||
rollback_agent=false
|
||||
cleanup_agent_install
|
||||
trap - EXIT HUP INT TERM
|
||||
printf '\nNodeFlare Agent 安装完成\n'
|
||||
printf ' 版本:%s\n' "$installed_version"
|
||||
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
|
||||
print_install_result
|
||||
}
|
||||
|
||||
load_installed_agent_config() {
|
||||
@@ -377,6 +385,7 @@ load_installed_agent_config() {
|
||||
|
||||
update_agent() {
|
||||
[ "$(id -u)" -eq 0 ] || fail "请使用 root 权限执行更新"
|
||||
update=true
|
||||
mirror=""
|
||||
while [ "$#" -gt 0 ]; do
|
||||
option="$1"
|
||||
@@ -412,13 +421,13 @@ status_agent() {
|
||||
rc-service "$SERVICE_NAME" status
|
||||
return $?
|
||||
fi
|
||||
echo "未检测到 NodeFlare Agent 服务" >&2
|
||||
echo "未检测到 Agent 服务" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
uninstall_agent() {
|
||||
[ "$(id -u)" -eq 0 ] || fail "请使用 root 权限执行卸载"
|
||||
log "正在停止并移除 NodeFlare Agent"
|
||||
log "正在停止并移除 Agent 服务"
|
||||
init_system=$(detect_init_system)
|
||||
case "$init_system" in
|
||||
systemd) systemctl disable --now "$SERVICE_NAME" 2>/dev/null || true ;;
|
||||
@@ -434,7 +443,7 @@ uninstall_agent() {
|
||||
rm -rf "$STATE_DIR"
|
||||
rmdir "$STATE_ROOT" 2>/dev/null || true
|
||||
rmdir "$INSTALL_DIR" 2>/dev/null || true
|
||||
echo "NodeFlare Agent 已卸载"
|
||||
echo "Agent 已卸载"
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
|
||||
+20
-13
@@ -16,14 +16,25 @@ had_agent=false
|
||||
had_service=false
|
||||
|
||||
log() {
|
||||
printf '[NodeFlare] %s\n' "$1"
|
||||
printf '%s\n' "$1"
|
||||
}
|
||||
|
||||
fail() {
|
||||
printf '[NodeFlare] 错误:%s\n' "$1" >&2
|
||||
printf '错误:%s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
print_install_result() {
|
||||
if [ "$update" = true ] || [ "$had_agent" = true ]; then
|
||||
printf '\n更新完成(v%s)\n' "$installed_version"
|
||||
return
|
||||
fi
|
||||
printf '\n安装完成(v%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'
|
||||
}
|
||||
|
||||
cleanup_agent_install() {
|
||||
if [ "$rollback_agent" = true ]; then
|
||||
rollback_agent=false
|
||||
@@ -58,7 +69,7 @@ NodeFlare Agent FreeBSD 安装脚本
|
||||
|
||||
支持 FreeBSD amd64 和 arm64。Agent Token 请勿泄露。
|
||||
-m 为可选的 GitHub 下载加速前缀(如 https://ghproxy.net)。
|
||||
--update 沿用已安装 Agent 的服务地址、Token 和历史保存间隔。
|
||||
--update 手动更新 Agent。
|
||||
EOF
|
||||
}
|
||||
|
||||
@@ -137,14 +148,14 @@ parse_agent_args() {
|
||||
if [ "${1:-}" = "--uninstall" ]; then
|
||||
[ "$#" -eq 1 ] || fail "--uninstall 不接受其它参数"
|
||||
[ "$(id -u)" -eq 0 ] || fail "请使用 root 权限执行卸载"
|
||||
log "正在停止并移除 NodeFlare Agent"
|
||||
log "正在停止并移除 Agent 服务"
|
||||
service "$SERVICE_NAME" stop 2>/dev/null || true
|
||||
sysrc -x "${RC_NAME}_enable" >/dev/null 2>&1 || true
|
||||
rm -f "$SERVICE_FILE" "$AGENT_FILE"
|
||||
rm -rf "$STATE_DIR"
|
||||
rmdir "$STATE_ROOT" 2>/dev/null || true
|
||||
rmdir "$INSTALL_DIR" 2>/dev/null || true
|
||||
echo "NodeFlare Agent 已卸载"
|
||||
echo "Agent 已卸载"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -234,7 +245,7 @@ if [ -n "$mirror" ]; then
|
||||
download_url="$mirror/$release_base/$artifact"
|
||||
log "正在通过下载加速前缀拉取 Agent $release_tag"
|
||||
else
|
||||
log "正在下载 NodeFlare Agent $release_tag"
|
||||
log "正在下载 Agent $release_tag"
|
||||
fi
|
||||
download_file "$download_url" "$temporary"
|
||||
actual=$(sha256 -q "$temporary")
|
||||
@@ -257,7 +268,7 @@ if [ -f "$SERVICE_FILE" ]; then
|
||||
fi
|
||||
rollback_agent=true
|
||||
|
||||
log "正在配置并启动 FreeBSD rc.d 服务"
|
||||
log "正在启动 FreeBSD rc.d 服务"
|
||||
service "$SERVICE_NAME" stop 2>/dev/null || true
|
||||
mv "$temporary" "$AGENT_FILE"
|
||||
cat > "$SERVICE_FILE" <<EOF
|
||||
@@ -286,15 +297,11 @@ sysrc "${RC_NAME}_enable=YES" >/dev/null
|
||||
service "$SERVICE_NAME" start
|
||||
service "$SERVICE_NAME" status >/dev/null || {
|
||||
service "$SERVICE_NAME" status >&2 || true
|
||||
fail "NodeFlare 服务启动失败,请查看 /var/log/messages"
|
||||
fail "服务启动失败,请查看 /var/log/messages"
|
||||
}
|
||||
|
||||
rollback_agent=false
|
||||
cleanup_agent_install
|
||||
trap - EXIT HUP INT TERM
|
||||
|
||||
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'
|
||||
print_install_result
|
||||
+20
-13
@@ -15,14 +15,25 @@ had_agent=false
|
||||
had_service=false
|
||||
|
||||
log() {
|
||||
printf '[NodeFlare] %s\n' "$1"
|
||||
printf '%s\n' "$1"
|
||||
}
|
||||
|
||||
fail() {
|
||||
printf '[NodeFlare] 错误:%s\n' "$1" >&2
|
||||
printf '错误:%s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
print_install_result() {
|
||||
if [ "$update" = true ] || [ "$had_agent" = true ]; then
|
||||
printf '\n更新完成(v%s)\n' "$installed_version"
|
||||
return
|
||||
fi
|
||||
printf '\n安装完成(v%s)\n' "$installed_version"
|
||||
printf ' 服务:%s(LaunchDaemon)\n' "$LABEL"
|
||||
printf ' 查看状态:sudo launchctl print system/%s\n' "$LABEL"
|
||||
printf ' 查看日志:/var/log/nodeflare-agent.log\n'
|
||||
}
|
||||
|
||||
cleanup_agent_install() {
|
||||
if [ "$rollback_agent" = true ]; then
|
||||
rollback_agent=false
|
||||
@@ -57,7 +68,7 @@ NodeFlare Agent macOS 安装脚本
|
||||
|
||||
仅支持 Apple Silicon(arm64)。Agent Token 请勿泄露。
|
||||
-m 为可选的 GitHub 下载加速前缀(如 https://ghproxy.net)。
|
||||
--update 沿用已安装 Agent 的服务地址、Token 和历史保存间隔。
|
||||
--update 手动更新 Agent。
|
||||
EOF
|
||||
}
|
||||
|
||||
@@ -120,13 +131,13 @@ parse_agent_args() {
|
||||
if [ "${1:-}" = "--uninstall" ]; then
|
||||
[ "$#" -eq 1 ] || fail "--uninstall 不接受其它参数"
|
||||
[ "$(id -u)" -eq 0 ] || fail "请使用 root 权限执行卸载"
|
||||
log "正在停止并移除 NodeFlare Agent"
|
||||
log "正在停止并移除 Agent 服务"
|
||||
launchctl bootout system "$PLIST_FILE" 2>/dev/null || true
|
||||
rm -f "$PLIST_FILE" "$AGENT_FILE"
|
||||
rm -rf "$STATE_DIR"
|
||||
rmdir "$STATE_ROOT" 2>/dev/null || true
|
||||
rmdir "$INSTALL_DIR" 2>/dev/null || true
|
||||
echo "NodeFlare Agent 已卸载"
|
||||
echo "Agent 已卸载"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -211,7 +222,7 @@ if [ -n "$mirror" ]; then
|
||||
download_url="$mirror/$release_base/$artifact"
|
||||
log "正在通过下载加速前缀拉取 Agent $release_tag"
|
||||
else
|
||||
log "正在下载 NodeFlare Agent $release_tag"
|
||||
log "正在下载 Agent $release_tag"
|
||||
fi
|
||||
curl --fail --location --silent --show-error --max-time 120 \
|
||||
"$download_url" \
|
||||
@@ -234,7 +245,7 @@ if [ -f "$PLIST_FILE" ]; then
|
||||
had_service=true
|
||||
fi
|
||||
rollback_agent=true
|
||||
log "正在配置并启动 macOS LaunchDaemon 服务"
|
||||
log "正在启动 macOS LaunchDaemon 服务"
|
||||
launchctl bootout system "$PLIST_FILE" 2>/dev/null || true
|
||||
mv "$temporary" "$AGENT_FILE"
|
||||
cat > "$PLIST_FILE" <<EOF
|
||||
@@ -259,12 +270,8 @@ for _ in 1 2 3 4 5 6 7 8 9 10; do
|
||||
break
|
||||
fi
|
||||
done
|
||||
[ "$started" = true ] || fail "NodeFlare 服务启动失败,请查看 /var/log/nodeflare-agent.log"
|
||||
[ "$started" = true ] || fail "服务启动失败,请查看 /var/log/nodeflare-agent.log"
|
||||
rollback_agent=false
|
||||
cleanup_agent_install
|
||||
trap - EXIT HUP INT TERM
|
||||
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'
|
||||
print_install_result
|
||||
+20
-13
@@ -34,11 +34,22 @@ $PreviousTaskXml = $null
|
||||
$InstallChanged = $false
|
||||
|
||||
function Write-Step([string]$Message) {
|
||||
Write-Host "[NodeFlare] $Message"
|
||||
Write-Host $Message
|
||||
}
|
||||
|
||||
function Write-InstallError([string]$Message) {
|
||||
throw "[NodeFlare] 错误:$Message"
|
||||
throw "错误:$Message"
|
||||
}
|
||||
|
||||
function Show-InstallResult {
|
||||
Write-Host ""
|
||||
if ($Update -or $HadPreviousAgent) {
|
||||
Write-Host "更新完成(v$InstalledVersion)"
|
||||
return
|
||||
}
|
||||
Write-Host "安装完成(v$InstalledVersion)"
|
||||
Write-Host " 服务:$TaskName(Windows 计划任务)"
|
||||
Write-Host " 查看状态:Get-ScheduledTask -TaskName '$TaskName'"
|
||||
}
|
||||
|
||||
function Assert-Safe([string]$Name, [string]$Value) {
|
||||
@@ -104,21 +115,21 @@ function Assert-Mirror([string]$Value) {
|
||||
}
|
||||
|
||||
if ($Uninstall) {
|
||||
Write-Step "正在停止并移除 NodeFlare Agent"
|
||||
Write-Step "正在停止并移除 Agent 服务"
|
||||
Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
|
||||
Remove-Item -LiteralPath $AgentFile -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item -LiteralPath $StateDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item -LiteralPath $DataDir -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item -LiteralPath $InstallDir -Force -ErrorAction SilentlyContinue
|
||||
Write-Host "NodeFlare Agent 已卸载"
|
||||
Write-Host "Agent 已卸载"
|
||||
exit 0
|
||||
}
|
||||
|
||||
if ($Status) {
|
||||
$Task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||
if ($null -eq $Task) {
|
||||
Write-Error "未检测到 NodeFlare Agent 服务"
|
||||
Write-Error "未检测到 Agent 服务"
|
||||
exit 1
|
||||
}
|
||||
$Task
|
||||
@@ -174,9 +185,9 @@ try {
|
||||
$DownloadUrl = "https://github.com/imengying/NodeFlare/releases/download/$($Release.tag_name)/$Artifact"
|
||||
if ($Mirror) {
|
||||
$DownloadUrl = "$Mirror/$DownloadUrl"
|
||||
Write-Step "正在通过下载加速前缀拉取 NodeFlare Agent $($Release.tag_name)"
|
||||
Write-Step "正在通过下载加速前缀拉取 Agent $($Release.tag_name)"
|
||||
} else {
|
||||
Write-Step "正在下载 NodeFlare Agent $($Release.tag_name)"
|
||||
Write-Step "正在下载 Agent $($Release.tag_name)"
|
||||
}
|
||||
Invoke-WebRequest -UseBasicParsing -Uri $DownloadUrl -OutFile $Temporary -TimeoutSec 120
|
||||
$ActualChecksum = (Get-FileHash -LiteralPath $Temporary -Algorithm SHA256).Hash
|
||||
@@ -244,14 +255,10 @@ exit $LASTEXITCODE
|
||||
if ($Task.State -eq "Running") { break }
|
||||
}
|
||||
if ($Task.State -ne "Running") {
|
||||
Write-InstallError "NodeFlare 服务启动失败(状态:$($Task.State))"
|
||||
Write-InstallError "服务启动失败(状态:$($Task.State))"
|
||||
}
|
||||
Write-Host ""
|
||||
Write-Host "NodeFlare Agent 安装完成"
|
||||
Write-Host " 版本:$InstalledVersion"
|
||||
Write-Host " 服务:$TaskName(Windows 计划任务)"
|
||||
Write-Host " 查看状态:Get-ScheduledTask -TaskName '$TaskName'"
|
||||
$InstallChanged = $false
|
||||
Show-InstallResult
|
||||
} catch {
|
||||
if ($InstallChanged) {
|
||||
Write-Warning "安装未完成,正在恢复上一版本"
|
||||
|
||||
+12
-13
@@ -44,7 +44,7 @@ mod tests {
|
||||
assert!((1..10).contains(&count));
|
||||
let payload = crate::live_update_payload(
|
||||
&queue.iter().take(count).cloned().collect::<Vec<_>>(),
|
||||
Some(true),
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(payload.len() <= MAX_LIVE_BATCH_BYTES);
|
||||
@@ -59,7 +59,7 @@ mod tests {
|
||||
..Report::default()
|
||||
})
|
||||
.collect::<VecDeque<_>>();
|
||||
let reports = crate::live_persistence_batch(&queue, -1);
|
||||
let reports = crate::live_batch_after(&queue, -1);
|
||||
assert!(!reports.is_empty());
|
||||
assert!(reports.len() < queue.len());
|
||||
assert_eq!(reports.first().unwrap().timestamp, 0);
|
||||
@@ -71,23 +71,20 @@ mod tests {
|
||||
let mut persisted = -1;
|
||||
let mut received = Vec::new();
|
||||
loop {
|
||||
let batch = crate::live_persistence_batch(&queue, persisted);
|
||||
let batch = crate::live_batch_after(&queue, persisted);
|
||||
if batch.is_empty() {
|
||||
break;
|
||||
}
|
||||
let message = crate::live_update_payload(&batch, Some(true)).unwrap();
|
||||
let message = crate::live_update_payload(&batch, true).unwrap();
|
||||
assert!(message.len() <= MAX_LIVE_BATCH_BYTES);
|
||||
persisted = batch.last().unwrap().timestamp;
|
||||
received.extend(batch.into_iter().map(|report| report.timestamp));
|
||||
}
|
||||
assert_eq!(received, (0..10).collect::<Vec<_>>());
|
||||
let legacy = crate::legacy_persistence_batch(&queue, -1);
|
||||
assert_eq!(legacy.last().unwrap().timestamp, 9);
|
||||
assert!(legacy.len() < queue.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caps_batch_count_and_preserves_legacy_payload_shape() {
|
||||
fn caps_batch_count_and_always_sends_the_persistence_flag() {
|
||||
let queue = (0..1000)
|
||||
.map(|timestamp| Report {
|
||||
timestamp,
|
||||
@@ -95,11 +92,13 @@ mod tests {
|
||||
})
|
||||
.collect::<VecDeque<_>>();
|
||||
assert!(batch_len(&queue) <= crate::LIVE_QUEUE_CAPACITY);
|
||||
let reports = crate::live_persistence_batch(&queue, 100);
|
||||
let reports = crate::live_batch_after(&queue, 100);
|
||||
assert_eq!(reports.first().unwrap().timestamp, 101);
|
||||
let message = crate::live_update_payload(&reports, None).unwrap();
|
||||
let value: serde_json::Value = serde_json::from_str(&message).unwrap();
|
||||
assert!(value.get("persist").is_none());
|
||||
assert_eq!(value.as_object().unwrap().len(), 3);
|
||||
for persist in [true, false] {
|
||||
let message = crate::live_update_payload(&reports, persist).unwrap();
|
||||
let value: serde_json::Value = serde_json::from_str(&message).unwrap();
|
||||
assert_eq!(value["persist"], persist);
|
||||
assert_eq!(value.as_object().unwrap().len(), 4);
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
-44
@@ -74,7 +74,7 @@ const PUBLIC_IP_V6_URL: &str = "https://ipv6.icanhazip.com/";
|
||||
const RUNTIME_STATS_INTERVAL: Duration = Duration::from_secs(5 * 60);
|
||||
const CLOCK_CALIBRATION_MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
const CLOCK_CALIBRATION_MIN_CHANGE_MS: i64 = 20_000;
|
||||
const AGENT_PROTOCOL_VERSION: &str = "1";
|
||||
const AGENT_PROTOCOL_VERSION: &str = "2";
|
||||
const AGENT_CAPABILITIES: &str = "metrics-v1,config-v1,remote-exec-v1,task-ack-v1";
|
||||
|
||||
type Error = Box<dyn std::error::Error + Send + Sync>;
|
||||
@@ -1746,7 +1746,7 @@ fn report_batch_id(reports: &[Report]) -> String {
|
||||
|
||||
type LiveSocket = tungstenite::WebSocket<tungstenite::stream::MaybeTlsStream<TcpStream>>;
|
||||
|
||||
fn connect_live(endpoint: &str, token: &str) -> Result<(LiveSocket, Option<i64>, bool)> {
|
||||
fn connect_live(endpoint: &str, token: &str) -> Result<(LiveSocket, Option<i64>)> {
|
||||
let mut request = endpoint.into_client_request()?;
|
||||
request
|
||||
.headers_mut()
|
||||
@@ -1770,11 +1770,17 @@ fn connect_live(endpoint: &str, token: &str) -> Result<(LiveSocket, Option<i64>,
|
||||
.get("date")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| clock_offset_from_http_date(value, started_ms, ended_ms));
|
||||
let persistence_batches = response
|
||||
if !response
|
||||
.headers()
|
||||
.get("x-nodeflare-persistence-batches")
|
||||
.is_some_and(|value| value == "1");
|
||||
Ok((socket, clock_offset_ms, persistence_batches))
|
||||
.get("x-nodeflare-agent-protocol")
|
||||
.is_some_and(|value| value == AGENT_PROTOCOL_VERSION)
|
||||
{
|
||||
return Err(format!(
|
||||
"backend protocol mismatch; update the backend to protocol {AGENT_PROTOCOL_VERSION}"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
Ok((socket, clock_offset_ms))
|
||||
}
|
||||
|
||||
fn set_live_read_timeout(socket: &mut LiveSocket, timeout: Option<Duration>) -> io::Result<()> {
|
||||
@@ -2539,15 +2545,13 @@ fn wait_for_live_ack(
|
||||
}
|
||||
}
|
||||
|
||||
fn live_update_payload(reports: &[Report], persist: Option<bool>) -> Result<String> {
|
||||
let mut message = serde_json::json!({
|
||||
fn live_update_payload(reports: &[Report], persist: bool) -> Result<String> {
|
||||
let message = serde_json::json!({
|
||||
"type": "update",
|
||||
"batchId": report_batch_id(reports),
|
||||
"samples": reports,
|
||||
"persist": persist,
|
||||
});
|
||||
if let Some(persist) = persist {
|
||||
message["persist"] = serde_json::json!(persist);
|
||||
}
|
||||
Ok(serde_json::to_string(&message)?)
|
||||
}
|
||||
|
||||
@@ -2575,29 +2579,6 @@ fn live_batch_after(queue: &VecDeque<Report>, timestamp: i64) -> Vec<Report> {
|
||||
candidates.into_iter().take(count).collect()
|
||||
}
|
||||
|
||||
fn live_persistence_batch(queue: &VecDeque<Report>, timestamp: i64) -> Vec<Report> {
|
||||
let candidates = queue
|
||||
.iter()
|
||||
.filter(|report| report.timestamp > timestamp)
|
||||
.cloned()
|
||||
.collect::<VecDeque<_>>();
|
||||
let count = live_batch::batch_len(&candidates);
|
||||
candidates.into_iter().take(count).collect()
|
||||
}
|
||||
|
||||
fn legacy_persistence_batch(queue: &VecDeque<Report>, timestamp: i64) -> Vec<Report> {
|
||||
let candidates = queue
|
||||
.iter()
|
||||
.rev()
|
||||
.filter(|report| report.timestamp > timestamp)
|
||||
.cloned()
|
||||
.collect::<VecDeque<_>>();
|
||||
let count = live_batch::batch_len(&candidates);
|
||||
let mut batch = candidates.into_iter().take(count).collect::<Vec<_>>();
|
||||
batch.reverse();
|
||||
batch
|
||||
}
|
||||
|
||||
fn live_sender_loop(endpoint: &str, token: &str, worker: LiveSenderWorker) {
|
||||
let LiveSenderWorker {
|
||||
pending,
|
||||
@@ -2608,7 +2589,6 @@ fn live_sender_loop(endpoint: &str, token: &str, worker: LiveSenderWorker) {
|
||||
stats,
|
||||
} = worker;
|
||||
let mut socket: Option<LiveSocket> = None;
|
||||
let mut persistence_batches = false;
|
||||
let mut wss_interval = configured_interval
|
||||
.lock()
|
||||
.map_or(Duration::from_secs(1), |interval| *interval);
|
||||
@@ -2625,7 +2605,7 @@ fn live_sender_loop(endpoint: &str, token: &str, worker: LiveSenderWorker) {
|
||||
'sender: loop {
|
||||
if socket.is_none() {
|
||||
match connect_live(endpoint, token) {
|
||||
Ok((mut connected, offset_ms, supports_persistence_batches)) => {
|
||||
Ok((mut connected, offset_ms)) => {
|
||||
observe_clock(&clock, offset_ms);
|
||||
if set_live_read_timeout(&mut connected, Some(LIVE_HINT_READ_TIMEOUT)).is_err()
|
||||
{
|
||||
@@ -2633,7 +2613,6 @@ fn live_sender_loop(endpoint: &str, token: &str, worker: LiveSenderWorker) {
|
||||
continue;
|
||||
}
|
||||
socket = Some(connected);
|
||||
persistence_batches = supports_persistence_batches;
|
||||
remote_executor.reset_delivery();
|
||||
accepted_through = persisted_through.load(Ordering::Acquire);
|
||||
next_send_at = Instant::now();
|
||||
@@ -2664,11 +2643,7 @@ fn live_sender_loop(endpoint: &str, token: &str, worker: LiveSenderWorker) {
|
||||
let now = Instant::now();
|
||||
let persisted = persisted_through.load(Ordering::Acquire);
|
||||
if now >= next_probe_at {
|
||||
let persistence_batch = if persistence_batches {
|
||||
live_persistence_batch(&queue, persisted)
|
||||
} else {
|
||||
legacy_persistence_batch(&queue, persisted)
|
||||
};
|
||||
let persistence_batch = live_batch_after(&queue, persisted);
|
||||
if !persistence_batch.is_empty() {
|
||||
let last = persistence_batch.last().unwrap().timestamp;
|
||||
let more_pending = queue.iter().any(|report| report.timestamp > last);
|
||||
@@ -2762,7 +2737,7 @@ fn live_sender_loop(endpoint: &str, token: &str, worker: LiveSenderWorker) {
|
||||
wss_interval = (*interval).clamp(Duration::from_secs(1), Duration::from_secs(60));
|
||||
}
|
||||
|
||||
let payload = match live_update_payload(&batch, persistence_batches.then_some(persist)) {
|
||||
let payload = match live_update_payload(&batch, persist) {
|
||||
Ok(payload) => payload,
|
||||
Err(error) => {
|
||||
eprintln!("live payload encode failed: {error}");
|
||||
@@ -3820,7 +3795,7 @@ mod tests {
|
||||
..Report::default()
|
||||
},
|
||||
],
|
||||
Some(false),
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let value: serde_json::Value = serde_json::from_str(&payload).unwrap();
|
||||
|
||||
@@ -15,7 +15,7 @@ CREATE TABLE servers (
|
||||
traffic_limit BIGINT NOT NULL DEFAULT 0 CHECK(traffic_limit >= 0),
|
||||
traffic_limit_type TEXT NOT NULL DEFAULT 'sum'
|
||||
CHECK(traffic_limit_type IN ('sum', 'max', 'min', 'up', 'down')),
|
||||
price DOUBLE PRECISION NOT NULL DEFAULT 0 CHECK(price BETWEEN -1 AND 1000000000),
|
||||
price DOUBLE PRECISION NOT NULL DEFAULT 0 CHECK(price BETWEEN 0 AND 1000000000),
|
||||
billing_cycle BIGINT NOT NULL DEFAULT 30 CHECK(billing_cycle BETWEEN 0 AND 3650),
|
||||
currency TEXT NOT NULL DEFAULT 'CNY' CHECK(currency ~ '^[A-Z]{3}$'),
|
||||
auto_renewal BIGINT NOT NULL DEFAULT 0 CHECK(auto_renewal IN (0, 1)),
|
||||
@@ -41,6 +41,14 @@ CREATE TABLE servers (
|
||||
CREATE INDEX servers_sort ON servers(sort_order, created_at);
|
||||
CREATE INDEX servers_public_sort ON servers(hidden, sort_order, created_at);
|
||||
|
||||
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);
|
||||
|
||||
CREATE TABLE metric_history (
|
||||
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
timestamp BIGINT NOT NULL,
|
||||
@@ -69,6 +77,20 @@ CREATE TABLE metric_history (
|
||||
disk_write_iops DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
disk_await_ms DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
disk_utilization DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
sample_count BIGINT NOT NULL DEFAULT 1 CHECK(sample_count > 0),
|
||||
first_timestamp BIGINT NOT NULL DEFAULT 0,
|
||||
last_timestamp BIGINT NOT NULL DEFAULT 0,
|
||||
cpu_min DOUBLE PRECISION,
|
||||
cpu_max DOUBLE PRECISION,
|
||||
mem_used_max BIGINT,
|
||||
memory_avg DOUBLE PRECISION,
|
||||
memory_min DOUBLE PRECISION,
|
||||
disk_avg DOUBLE PRECISION,
|
||||
disk_min DOUBLE PRECISION,
|
||||
net_in_avg DOUBLE PRECISION,
|
||||
net_in_min DOUBLE PRECISION,
|
||||
net_out_avg DOUBLE PRECISION,
|
||||
net_out_min DOUBLE PRECISION,
|
||||
PRIMARY KEY(server_id, timestamp)
|
||||
);
|
||||
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
ALTER TABLE metric_history ADD COLUMN sample_count BIGINT NOT NULL DEFAULT 1 CHECK(sample_count > 0);
|
||||
ALTER TABLE metric_history ADD COLUMN first_timestamp BIGINT NOT NULL DEFAULT 0;
|
||||
ALTER TABLE metric_history ADD COLUMN last_timestamp BIGINT NOT NULL DEFAULT 0;
|
||||
ALTER TABLE metric_history ADD COLUMN cpu_min DOUBLE PRECISION;
|
||||
ALTER TABLE metric_history ADD COLUMN cpu_max DOUBLE PRECISION;
|
||||
ALTER TABLE metric_history ADD COLUMN mem_used_max BIGINT;
|
||||
ALTER TABLE metric_history ADD COLUMN memory_avg DOUBLE PRECISION;
|
||||
ALTER TABLE metric_history ADD COLUMN memory_min DOUBLE PRECISION;
|
||||
ALTER TABLE metric_history ADD COLUMN disk_avg DOUBLE PRECISION;
|
||||
ALTER TABLE metric_history ADD COLUMN disk_min DOUBLE PRECISION;
|
||||
ALTER TABLE metric_history ADD COLUMN net_in_avg DOUBLE PRECISION;
|
||||
ALTER TABLE metric_history ADD COLUMN net_in_min DOUBLE PRECISION;
|
||||
ALTER TABLE metric_history ADD COLUMN net_out_avg DOUBLE PRECISION;
|
||||
ALTER TABLE metric_history ADD COLUMN net_out_min DOUBLE PRECISION;
|
||||
@@ -1,8 +0,0 @@
|
||||
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);
|
||||
@@ -1,2 +0,0 @@
|
||||
UPDATE servers SET price = 0 WHERE price < 0;
|
||||
ALTER TABLE servers ADD CONSTRAINT servers_price_nonnegative CHECK (price >= 0);
|
||||
@@ -17,7 +17,7 @@ CREATE TABLE servers (
|
||||
traffic_limit INTEGER NOT NULL DEFAULT 0 CHECK(traffic_limit >= 0),
|
||||
traffic_limit_type TEXT NOT NULL DEFAULT 'sum'
|
||||
CHECK(traffic_limit_type IN ('sum', 'max', 'min', 'up', 'down')),
|
||||
price REAL NOT NULL DEFAULT 0 CHECK(price BETWEEN -1 AND 1000000000),
|
||||
price REAL NOT NULL DEFAULT 0 CHECK(price BETWEEN 0 AND 1000000000),
|
||||
billing_cycle INTEGER NOT NULL DEFAULT 30 CHECK(billing_cycle BETWEEN 0 AND 3650),
|
||||
currency TEXT NOT NULL DEFAULT 'CNY'
|
||||
CHECK(length(currency) = 3 AND currency NOT GLOB '*[^A-Z]*'),
|
||||
@@ -44,6 +44,14 @@ CREATE TABLE servers (
|
||||
CREATE INDEX servers_sort ON servers(sort_order, created_at);
|
||||
CREATE INDEX servers_public_sort ON servers(hidden, sort_order, created_at);
|
||||
|
||||
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);
|
||||
|
||||
CREATE TABLE metric_history (
|
||||
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
timestamp INTEGER NOT NULL,
|
||||
@@ -72,6 +80,20 @@ CREATE TABLE metric_history (
|
||||
disk_write_iops REAL NOT NULL DEFAULT 0,
|
||||
disk_await_ms REAL NOT NULL DEFAULT 0,
|
||||
disk_utilization REAL NOT NULL DEFAULT 0,
|
||||
sample_count INTEGER NOT NULL DEFAULT 1 CHECK(sample_count > 0),
|
||||
first_timestamp INTEGER NOT NULL DEFAULT 0,
|
||||
last_timestamp INTEGER NOT NULL DEFAULT 0,
|
||||
cpu_min REAL,
|
||||
cpu_max REAL,
|
||||
mem_used_max INTEGER,
|
||||
memory_avg REAL,
|
||||
memory_min REAL,
|
||||
disk_avg REAL,
|
||||
disk_min REAL,
|
||||
net_in_avg REAL,
|
||||
net_in_min REAL,
|
||||
net_out_avg REAL,
|
||||
net_out_min REAL,
|
||||
PRIMARY KEY(server_id, timestamp)
|
||||
) WITHOUT ROWID;
|
||||
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
ALTER TABLE metric_history ADD COLUMN sample_count INTEGER NOT NULL DEFAULT 1 CHECK(sample_count > 0);
|
||||
ALTER TABLE metric_history ADD COLUMN first_timestamp INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE metric_history ADD COLUMN last_timestamp INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE metric_history ADD COLUMN cpu_min REAL;
|
||||
ALTER TABLE metric_history ADD COLUMN cpu_max REAL;
|
||||
ALTER TABLE metric_history ADD COLUMN mem_used_max INTEGER;
|
||||
ALTER TABLE metric_history ADD COLUMN memory_avg REAL;
|
||||
ALTER TABLE metric_history ADD COLUMN memory_min REAL;
|
||||
ALTER TABLE metric_history ADD COLUMN disk_avg REAL;
|
||||
ALTER TABLE metric_history ADD COLUMN disk_min REAL;
|
||||
ALTER TABLE metric_history ADD COLUMN net_in_avg REAL;
|
||||
ALTER TABLE metric_history ADD COLUMN net_in_min REAL;
|
||||
ALTER TABLE metric_history ADD COLUMN net_out_avg REAL;
|
||||
ALTER TABLE metric_history ADD COLUMN net_out_min REAL;
|
||||
@@ -1,8 +0,0 @@
|
||||
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);
|
||||
@@ -1,14 +0,0 @@
|
||||
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;
|
||||
+143
-38
@@ -544,17 +544,7 @@ async fn insert_rows(
|
||||
} else {
|
||||
"?".to_string()
|
||||
});
|
||||
// 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)?;
|
||||
}
|
||||
add_argument(&mut arguments, column.kind, value)?;
|
||||
}
|
||||
placeholders.push(format!("({})", row_placeholders.join(",")));
|
||||
}
|
||||
@@ -767,21 +757,9 @@ async fn restore_table<R: Read + Seek + Send>(
|
||||
bail!("{table} 备份表头无效");
|
||||
}
|
||||
let header: BackupTableHeader = serde_json::from_str(line.trim_end())?;
|
||||
let legacy_history = table == "metric_history"
|
||||
&& columns.len()
|
||||
== header.columns.len() + crate::db::queries::HISTORY_AGGREGATE_COLUMNS.len()
|
||||
&& columns.starts_with(&header.columns)
|
||||
&& columns[header.columns.len()..]
|
||||
.iter()
|
||||
.map(|column| column.name.as_str())
|
||||
.eq(crate::db::queries::HISTORY_AGGREGATE_COLUMNS);
|
||||
if header.table != table || (header.columns != columns && !legacy_history) {
|
||||
if header.table != table || header.columns != columns {
|
||||
bail!("{table} 备份结构与当前数据库不兼容");
|
||||
}
|
||||
// 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;
|
||||
let mut batch = Vec::with_capacity(INSERT_BATCH_ROWS);
|
||||
let mut batch_bytes = 0_usize;
|
||||
@@ -841,6 +819,27 @@ async fn restore<R: Read + Seek + Send>(
|
||||
for (table, columns) in BACKUP_TABLES.iter().zip(schema.iter()) {
|
||||
restored += restore_table(&mut archive, db, &mut transaction, table, columns).await?;
|
||||
}
|
||||
// Current-format backups can contain snapshot rows with unset aggregates.
|
||||
// Fill them once on import so live queries only read canonical aggregates.
|
||||
sqlx::query(
|
||||
"UPDATE metric_history SET \
|
||||
first_timestamp=COALESCE(NULLIF(first_timestamp,0),timestamp), \
|
||||
last_timestamp=COALESCE(NULLIF(last_timestamp,0),timestamp), \
|
||||
cpu_min=COALESCE(cpu_min,cpu), cpu_max=COALESCE(cpu_max,cpu), \
|
||||
mem_used_max=COALESCE(mem_used_max,mem_used), \
|
||||
memory_avg=COALESCE(memory_avg,CASE WHEN mem_total>0 THEN CAST(mem_used AS DOUBLE PRECISION)*100.0/mem_total ELSE 0.0 END), \
|
||||
memory_min=COALESCE(memory_min,CASE WHEN mem_total>0 THEN CAST(mem_used AS DOUBLE PRECISION)*100.0/mem_total ELSE 0.0 END), \
|
||||
disk_avg=COALESCE(disk_avg,CASE WHEN disk_total>0 THEN CAST(disk_used AS DOUBLE PRECISION)*100.0/disk_total ELSE 0.0 END), \
|
||||
disk_min=COALESCE(disk_min,CASE WHEN disk_total>0 THEN CAST(disk_used AS DOUBLE PRECISION)*100.0/disk_total ELSE 0.0 END), \
|
||||
net_in_avg=COALESCE(net_in_avg,net_in), net_in_min=COALESCE(net_in_min,net_in), \
|
||||
net_out_avg=COALESCE(net_out_avg,net_out), net_out_min=COALESCE(net_out_min,net_out) \
|
||||
WHERE first_timestamp=0 OR last_timestamp=0 OR cpu_min IS NULL OR cpu_max IS NULL \
|
||||
OR mem_used_max IS NULL OR memory_avg IS NULL OR memory_min IS NULL \
|
||||
OR disk_avg IS NULL OR disk_min IS NULL OR net_in_avg IS NULL OR net_in_min IS NULL \
|
||||
OR net_out_avg IS NULL OR net_out_min IS NULL",
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
let required_settings = sqlx::query_scalar::<_, i64>(
|
||||
"SELECT COUNT(*) FROM settings WHERE key IN \
|
||||
('admin_username','admin_password_hash','password_client_salt','password_scheme')",
|
||||
@@ -919,17 +918,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restores_legacy_snapshot_archives_and_round_trips_aggregates() {
|
||||
async fn restores_current_format_snapshots_and_round_trips_aggregates() {
|
||||
let source = crate::db::connect("sqlite::memory:").await.unwrap();
|
||||
let target = crate::db::connect("sqlite::memory:").await.unwrap();
|
||||
let original = sqlx::migrate::Migrator::with_migrations(
|
||||
crate::db::SQLITE_MIGRATOR
|
||||
.iter()
|
||||
.filter(|migration| migration.version == 1)
|
||||
.cloned()
|
||||
.collect(),
|
||||
);
|
||||
original.run(source.pool()).await.unwrap();
|
||||
source.migrate().await.unwrap();
|
||||
target.migrate().await.unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO settings(key,value) VALUES \
|
||||
@@ -939,18 +931,35 @@ mod tests {
|
||||
.execute(source.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO servers(id,name,token_hash,created_at,updated_at,price,hidden) VALUES ('legacy','Legacy','token',1,1,-1,1)")
|
||||
sqlx::query("INSERT INTO servers(id,name,token_hash,created_at,updated_at,price,hidden) VALUES ('node','Node',?,1,1,0,1)")
|
||||
.bind(crate::auth::token_hash("primary-token"))
|
||||
.execute(source.pool()).await.unwrap();
|
||||
let timestamp = crate::db::now();
|
||||
sqlx::query("INSERT INTO metric_history(server_id,timestamp,cpu,mem_used,mem_total,disk_used,disk_total,net_in,net_out) VALUES ('node',?,75.0,500,1000,250,1000,20.0,30.0)")
|
||||
.bind(timestamp).execute(source.pool()).await.unwrap();
|
||||
sqlx::query("INSERT INTO server_install_tokens(token_hash,server_id,created_at) VALUES (?,'node',1)")
|
||||
.bind(crate::auth::token_hash("install-token"))
|
||||
.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'")
|
||||
let server = sqlx::query("SELECT price,hidden FROM servers WHERE id='node'")
|
||||
.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)
|
||||
assert!(
|
||||
crate::db::queries::agent_identity(&target, "primary-token")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
assert!(
|
||||
crate::db::queries::agent_identity(&target, "install-token")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
let points = crate::db::queries::history(&target, "node", 1)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(points.len(), 1);
|
||||
@@ -958,6 +967,25 @@ mod tests {
|
||||
assert_eq!(points[0].cpu_max, 75.0);
|
||||
assert_eq!(points[0].mem_used_max, 500);
|
||||
assert_eq!(points[0].sample_count, 1);
|
||||
let row = sqlx::query("SELECT * FROM metric_history")
|
||||
.fetch_one(target.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(row.get::<i64, _>("first_timestamp"), timestamp);
|
||||
assert_eq!(row.get::<i64, _>("last_timestamp"), timestamp);
|
||||
for (prefix, value) in [
|
||||
("memory", 50.0),
|
||||
("disk", 25.0),
|
||||
("net_in", 20.0),
|
||||
("net_out", 30.0),
|
||||
] {
|
||||
for suffix in ["avg", "min"] {
|
||||
assert_eq!(
|
||||
row.get::<f64, _>(format!("{prefix}_{suffix}").as_str()),
|
||||
value
|
||||
);
|
||||
}
|
||||
}
|
||||
sqlx::query("UPDATE metric_history SET sample_count=4,cpu=50.0,cpu_min=10.0,cpu_max=90.0, \
|
||||
memory_avg=50.0,memory_min=10.0,net_in_avg=20.0,net_in_min=1.0,first_timestamp=?,last_timestamp=?")
|
||||
.bind(crate::db::now() - 3).bind(crate::db::now()).execute(target.pool()).await.unwrap();
|
||||
@@ -974,6 +1002,83 @@ mod tests {
|
||||
assert_eq!(row.try_get::<f64, _>("cpu_max").unwrap(), 90.0);
|
||||
assert_eq!(row.try_get::<f64, _>("memory_avg").unwrap(), 50.0);
|
||||
assert_eq!(row.try_get::<f64, _>("memory_min").unwrap(), 10.0);
|
||||
target.migrate().await.unwrap();
|
||||
assert_eq!(
|
||||
sqlx::query_scalar::<_, i64>("SELECT version FROM _sqlx_migrations")
|
||||
.fetch_all(target.pool())
|
||||
.await
|
||||
.unwrap(),
|
||||
vec![1]
|
||||
);
|
||||
assert!(
|
||||
crate::db::queries::agent_identity(&source, "install-token")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_snapshot_only_headers_without_changing_the_target() {
|
||||
let source = crate::db::connect("sqlite::memory:").await.unwrap();
|
||||
let target = crate::db::connect("sqlite::memory:").await.unwrap();
|
||||
source.migrate().await.unwrap();
|
||||
target.migrate().await.unwrap();
|
||||
crate::db::set_setting(&target, "site_name", "Preserved")
|
||||
.await
|
||||
.unwrap();
|
||||
for column in crate::db::queries::HISTORY_AGGREGATE_COLUMNS {
|
||||
sqlx::query(AssertSqlSafe(format!(
|
||||
"ALTER TABLE metric_history DROP COLUMN {column}"
|
||||
)))
|
||||
.execute(source.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let error = copy_database(&source, &target).await.unwrap_err();
|
||||
assert!(
|
||||
error.to_string().contains("metric_history 备份结构"),
|
||||
"{error:#}"
|
||||
);
|
||||
assert_eq!(
|
||||
crate::db::get_setting(&target, "site_name")
|
||||
.await
|
||||
.unwrap()
|
||||
.as_deref(),
|
||||
Some("Preserved")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_negative_backup_prices_without_changing_the_target() {
|
||||
let source = crate::db::connect("sqlite::memory:").await.unwrap();
|
||||
let target = crate::db::connect("sqlite::memory:").await.unwrap();
|
||||
source.migrate().await.unwrap();
|
||||
target.migrate().await.unwrap();
|
||||
crate::db::set_setting(&target, "site_name", "Preserved")
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("PRAGMA ignore_check_constraints=ON")
|
||||
.execute(source.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO servers(id,name,token_hash,created_at,updated_at,price) VALUES ('negative','Negative','hash',1,1,-1)")
|
||||
.execute(source.pool()).await.unwrap();
|
||||
assert!(copy_database(&source, &target).await.is_err());
|
||||
assert_eq!(
|
||||
crate::db::get_setting(&target, "site_name")
|
||||
.await
|
||||
.unwrap()
|
||||
.as_deref(),
|
||||
Some("Preserved")
|
||||
);
|
||||
assert_eq!(
|
||||
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM servers")
|
||||
.fetch_one(target.pool())
|
||||
.await
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
+76
-33
@@ -5,7 +5,7 @@ use crate::config::Config;
|
||||
use crate::models::{LoginSessionView, PublicConfig, SettingsInput, SettingsView};
|
||||
use anyhow::{Context, Result};
|
||||
use sqlx::any::{AnyPoolOptions, install_default_drivers};
|
||||
use sqlx::migrate::Migrator;
|
||||
use sqlx::migrate::{MigrateError, Migrator};
|
||||
use sqlx::sqlite::SqliteConnectOptions;
|
||||
use sqlx::{AnyPool, Row};
|
||||
use std::collections::HashMap;
|
||||
@@ -191,9 +191,20 @@ impl Database {
|
||||
}
|
||||
|
||||
pub async fn migrate(&self) -> Result<()> {
|
||||
match self.kind {
|
||||
DatabaseKind::Sqlite => SQLITE_MIGRATOR.run(&self.pool).await?,
|
||||
DatabaseKind::Postgres => POSTGRES_MIGRATOR.run(&self.pool).await?,
|
||||
let result = match self.kind {
|
||||
DatabaseKind::Sqlite => SQLITE_MIGRATOR.run(&self.pool).await,
|
||||
DatabaseKind::Postgres => POSTGRES_MIGRATOR.run(&self.pool).await,
|
||||
};
|
||||
if let Err(error) = result {
|
||||
if matches!(
|
||||
error,
|
||||
MigrateError::VersionMissing(_) | MigrateError::VersionMismatch(_)
|
||||
) {
|
||||
return Err(anyhow::Error::new(error).context(
|
||||
"数据库迁移基线不匹配;请保留原数据库,使用原版本导出完整结构的 ZIP 备份,配置全新数据库后再恢复;不要删除或修改 _sqlx_migrations",
|
||||
));
|
||||
}
|
||||
return Err(error.into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1028,40 +1039,72 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn aggregate_migration_preserves_existing_history() {
|
||||
async fn initial_migration_contains_the_complete_schema() {
|
||||
let db = connect("sqlite::memory:").await.unwrap();
|
||||
let original = Migrator::with_migrations(
|
||||
SQLITE_MIGRATOR
|
||||
.iter()
|
||||
.filter(|migration| migration.version == 1)
|
||||
.cloned()
|
||||
.collect(),
|
||||
);
|
||||
original.run(db.pool()).await.unwrap();
|
||||
sqlx::query("INSERT INTO servers(id,name,token_hash,created_at,updated_at) VALUES ('old','Old','hash',1,1)")
|
||||
.execute(db.pool()).await.unwrap();
|
||||
sqlx::query("INSERT INTO metric_history(server_id,timestamp,cpu,mem_used) VALUES ('old',?,73.0,100)")
|
||||
.bind(now()).execute(db.pool()).await.unwrap();
|
||||
for migrator in [&SQLITE_MIGRATOR, &POSTGRES_MIGRATOR] {
|
||||
assert_eq!(
|
||||
migrator
|
||||
.iter()
|
||||
.map(|migration| migration.version)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![1]
|
||||
);
|
||||
}
|
||||
db.migrate().await.unwrap();
|
||||
let point = queries::history(&db, "old", 1).await.unwrap().remove(0);
|
||||
assert_eq!(point.cpu, 73.0);
|
||||
assert_eq!(point.cpu_min, 73.0);
|
||||
assert_eq!(point.cpu_max, 73.0);
|
||||
assert_eq!(point.sample_count, 1);
|
||||
db.migrate().await.unwrap();
|
||||
let columns = sqlx::query("PRAGMA table_info(metric_history)")
|
||||
.fetch_all(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(columns.len(), 41);
|
||||
assert!(
|
||||
columns
|
||||
.iter()
|
||||
.skip(27)
|
||||
.map(|row| row.get::<String, _>("name"))
|
||||
.eq(queries::HISTORY_AGGREGATE_COLUMNS)
|
||||
);
|
||||
sqlx::query("INSERT INTO servers(id,name,token_hash,created_at,updated_at) VALUES ('node','Node','hash',1,1)")
|
||||
.execute(db.pool()).await.unwrap();
|
||||
sqlx::query("INSERT INTO server_install_tokens(token_hash,server_id,created_at) VALUES ('install','node',1)")
|
||||
.execute(db.pool()).await.unwrap();
|
||||
queries::delete_server(&db, "node").await.unwrap();
|
||||
assert_eq!(
|
||||
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM server_install_tokens")
|
||||
.fetch_one(db.pool())
|
||||
.await
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn price_migration_preserves_visibility_and_rejects_negative_writes() {
|
||||
async fn mismatched_migrations_are_rejected_without_rewriting_data() {
|
||||
for statement in [
|
||||
"UPDATE _sqlx_migrations SET checksum=X'' WHERE version=1",
|
||||
"INSERT INTO _sqlx_migrations(version,description,success,checksum,execution_time) VALUES (2,'retired',1,X'',0)",
|
||||
] {
|
||||
let db = connect("sqlite::memory:").await.unwrap();
|
||||
db.migrate().await.unwrap();
|
||||
set_setting(&db, "site_name", "Preserved").await.unwrap();
|
||||
sqlx::query(sqlx::AssertSqlSafe(statement))
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let error = db.migrate().await.unwrap_err();
|
||||
assert!(error.to_string().contains("全新数据库"), "{error:#}");
|
||||
assert_eq!(
|
||||
get_setting(&db, "site_name").await.unwrap().as_deref(),
|
||||
Some("Preserved")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn initial_schema_keeps_visibility_independent_and_rejects_negative_prices() {
|
||||
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() {
|
||||
db.migrate().await.unwrap();
|
||||
for (index, price) in [0.0, 0.0, 12.5, 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();
|
||||
@@ -1075,7 +1118,7 @@ mod tests {
|
||||
for (index, row) in rows.iter().enumerate() {
|
||||
assert_eq!(
|
||||
row.try_get::<f64, _>("price").unwrap(),
|
||||
if index == 3 { 12.5 } else { 0.0 }
|
||||
if index >= 2 { 12.5 } else { 0.0 }
|
||||
);
|
||||
assert_eq!(row.try_get::<i64, _>("hidden").unwrap(), (index % 2) as i64);
|
||||
}
|
||||
|
||||
+54
-118
@@ -616,7 +616,7 @@ pub async fn save_agent_batch(
|
||||
batch_id: &str,
|
||||
reports: &[AgentReport],
|
||||
remote_ip: &str,
|
||||
persist: Option<bool>,
|
||||
persist: bool,
|
||||
) -> Result<PersistResult> {
|
||||
let mut reports = reports.to_vec();
|
||||
reports.sort_by_key(|report| report.timestamp);
|
||||
@@ -635,7 +635,7 @@ pub async fn save_agent_batch(
|
||||
anyhow::bail!("report batch exceeds 720 samples");
|
||||
}
|
||||
|
||||
let needs_write = persist != Some(false)
|
||||
let needs_write = persist
|
||||
|| reports
|
||||
.iter()
|
||||
.any(|report| !report.latency_results.is_empty());
|
||||
@@ -690,10 +690,7 @@ pub async fn save_agent_batch(
|
||||
let report_interval = identity.report_interval.clamp(15, 3600);
|
||||
let next_persist_at = persisted_before.saturating_add(report_interval);
|
||||
// Only a complete persistence batch may advance the durable watermark.
|
||||
// Older agents omit this flag and retain their scheduled persistence behavior.
|
||||
if persist == Some(false)
|
||||
|| (persist.is_none() && persisted_before > 0 && latest.timestamp < next_persist_at)
|
||||
{
|
||||
if !persist {
|
||||
let next_persist_after_ms =
|
||||
next_persist_at.saturating_sub(latest.timestamp).max(1) as u64 * 1000;
|
||||
if reports
|
||||
@@ -1080,34 +1077,21 @@ fn history_merge_sql() -> String {
|
||||
THEN metric_history.{column} ELSE excluded.{column} END"
|
||||
));
|
||||
}
|
||||
for (column, fallback, minimum) in [
|
||||
("cpu_min", "cpu", true),
|
||||
("cpu_max", "cpu", false),
|
||||
("mem_used_max", "mem_used", false),
|
||||
for (column, minimum) in [
|
||||
("cpu_min", true),
|
||||
("cpu_max", false),
|
||||
("mem_used_max", false),
|
||||
] {
|
||||
let old = format!("COALESCE(metric_history.{column},metric_history.{fallback})");
|
||||
let old = format!("metric_history.{column}");
|
||||
let comparison = if minimum { "<" } else { ">" };
|
||||
updates.push(format!(
|
||||
"{column}=CASE WHEN {old}{comparison}excluded.{column} \
|
||||
THEN {old} ELSE excluded.{column} END"
|
||||
));
|
||||
}
|
||||
for (prefix, fallback) in [
|
||||
(
|
||||
"memory",
|
||||
"CASE WHEN metric_history.mem_total>0 THEN \
|
||||
CAST(metric_history.mem_used AS DOUBLE PRECISION)*100.0/metric_history.mem_total ELSE 0.0 END",
|
||||
),
|
||||
(
|
||||
"disk",
|
||||
"CASE WHEN metric_history.disk_total>0 THEN \
|
||||
CAST(metric_history.disk_used AS DOUBLE PRECISION)*100.0/metric_history.disk_total ELSE 0.0 END",
|
||||
),
|
||||
("net_in", "metric_history.net_in"),
|
||||
("net_out", "metric_history.net_out"),
|
||||
] {
|
||||
let mean = format!("COALESCE(metric_history.{prefix}_avg,{fallback})");
|
||||
let min = format!("COALESCE(metric_history.{prefix}_min,{fallback})");
|
||||
for prefix in ["memory", "disk", "net_in", "net_out"] {
|
||||
let mean = format!("metric_history.{prefix}_avg");
|
||||
let min = format!("metric_history.{prefix}_min");
|
||||
updates.push(format!("{prefix}_avg=({mean}*metric_history.sample_count \
|
||||
+excluded.{prefix}_avg*excluded.sample_count)/(metric_history.sample_count+excluded.sample_count)"));
|
||||
updates.push(format!(
|
||||
@@ -1119,9 +1103,8 @@ fn history_merge_sql() -> String {
|
||||
updates.push(format!("{column}=excluded.{column}"));
|
||||
}
|
||||
updates.push(
|
||||
"first_timestamp=CASE WHEN COALESCE(NULLIF(metric_history.first_timestamp,0),\
|
||||
metric_history.timestamp)<excluded.first_timestamp THEN \
|
||||
COALESCE(NULLIF(metric_history.first_timestamp,0),metric_history.timestamp) \
|
||||
"first_timestamp=CASE WHEN metric_history.first_timestamp<excluded.first_timestamp THEN \
|
||||
metric_history.first_timestamp \
|
||||
ELSE excluded.first_timestamp END"
|
||||
.to_string(),
|
||||
);
|
||||
@@ -1200,13 +1183,13 @@ pub async fn history(db: &Database, server_id: &str, hours: i64) -> Result<Vec<H
|
||||
let since = now() - hours * 3600;
|
||||
let rows = sqlx::query(db.sql(
|
||||
"SELECT bucket_timestamp, SUM(cpu * sample_count) / SUM(sample_count) AS cpu, \
|
||||
MIN(COALESCE(cpu_min, cpu)) AS cpu_min, MAX(COALESCE(cpu_max, cpu)) AS cpu_max, \
|
||||
MIN(cpu_min) AS cpu_min, MAX(cpu_max) AS cpu_max, \
|
||||
CAST(SUM(sample_count) AS BIGINT) AS sample_count, \
|
||||
SUM(load1 * sample_count) / SUM(sample_count) AS load1, \
|
||||
SUM(load5 * sample_count) / SUM(sample_count) AS load5, \
|
||||
SUM(load15 * sample_count) / SUM(sample_count) AS load15, \
|
||||
SUM(CAST(mem_used AS DOUBLE PRECISION) * sample_count) / SUM(sample_count) AS mem_used, \
|
||||
MAX(COALESCE(mem_used_max, mem_used)) AS mem_used_max, \
|
||||
MAX(mem_used_max) AS mem_used_max, \
|
||||
MAX(mem_total) AS mem_total, \
|
||||
SUM(CAST(swap_used AS DOUBLE PRECISION) * sample_count) / SUM(sample_count) AS swap_used, \
|
||||
MAX(swap_total) AS swap_total, \
|
||||
@@ -1220,7 +1203,7 @@ pub async fn history(db: &Database, server_id: &str, hours: i64) -> Result<Vec<H
|
||||
MAX(disk_utilization) AS disk_utilization FROM ( \
|
||||
SELECT (timestamp / ?) * ? AS bucket_timestamp, metric_history.* \
|
||||
FROM metric_history WHERE server_id=? \
|
||||
AND COALESCE(NULLIF(last_timestamp,0),timestamp)>=? \
|
||||
AND last_timestamp>=? \
|
||||
) samples GROUP BY bucket_timestamp ORDER BY bucket_timestamp",
|
||||
))
|
||||
.bind(bucket)
|
||||
@@ -1283,7 +1266,7 @@ async fn cleanup_database_with_budget(
|
||||
let statements = [
|
||||
"DELETE FROM metric_history WHERE (server_id, timestamp) IN ( \
|
||||
SELECT server_id, timestamp FROM metric_history WHERE \
|
||||
COALESCE(NULLIF(last_timestamp,0),timestamp)<? \
|
||||
last_timestamp<? \
|
||||
ORDER BY timestamp LIMIT ?)",
|
||||
"DELETE FROM latency_results WHERE (task_id, server_id, timestamp) IN ( \
|
||||
SELECT task_id, server_id, timestamp FROM latency_results WHERE timestamp<? \
|
||||
@@ -1781,31 +1764,17 @@ pub async fn evaluate_resource_rules(
|
||||
&& (rule.server_ids.is_empty() || rule.server_ids.iter().any(|id| id == server_id))
|
||||
}) {
|
||||
let (average_expression, minimum_expression) = match rule.metric.as_str() {
|
||||
"cpu" => ("cpu", "COALESCE(cpu_min, cpu)"),
|
||||
"memory" => (
|
||||
"COALESCE(memory_avg, CASE WHEN mem_total>0 THEN CAST(mem_used AS DOUBLE PRECISION)*100.0/mem_total ELSE 0.0 END)",
|
||||
"COALESCE(memory_min, CASE WHEN mem_total>0 THEN CAST(mem_used AS DOUBLE PRECISION)*100.0/mem_total ELSE 0.0 END)",
|
||||
),
|
||||
"disk" => (
|
||||
"COALESCE(disk_avg, CASE WHEN disk_total>0 THEN CAST(disk_used AS DOUBLE PRECISION)*100.0/disk_total ELSE 0.0 END)",
|
||||
"COALESCE(disk_min, CASE WHEN disk_total>0 THEN CAST(disk_used AS DOUBLE PRECISION)*100.0/disk_total ELSE 0.0 END)",
|
||||
),
|
||||
"net_in" => (
|
||||
"COALESCE(net_in_avg, net_in)/1048576.0",
|
||||
"COALESCE(net_in_min, net_in)/1048576.0",
|
||||
),
|
||||
"net_out" => (
|
||||
"COALESCE(net_out_avg, net_out)/1048576.0",
|
||||
"COALESCE(net_out_min, net_out)/1048576.0",
|
||||
),
|
||||
"cpu" => ("cpu", "cpu_min"),
|
||||
"memory" => ("memory_avg", "memory_min"),
|
||||
"disk" => ("disk_avg", "disk_min"),
|
||||
"net_in" => ("net_in_avg/1048576.0", "net_in_min/1048576.0"),
|
||||
"net_out" => ("net_out_avg/1048576.0", "net_out_min/1048576.0"),
|
||||
_ => continue,
|
||||
};
|
||||
let filter = db.sql(
|
||||
"server_id=? AND timestamp>=? AND COALESCE(NULLIF(last_timestamp,0),timestamp)>=?",
|
||||
);
|
||||
let filter = db.sql("server_id=? AND timestamp>=? AND last_timestamp>=?");
|
||||
let query = format!(
|
||||
"SELECT MIN(COALESCE(NULLIF(first_timestamp,0),timestamp)) AS first_timestamp, \
|
||||
MAX(COALESCE(NULLIF(last_timestamp,0),timestamp)) AS last_timestamp, \
|
||||
"SELECT MIN(first_timestamp) AS first_timestamp, \
|
||||
MAX(last_timestamp) AS last_timestamp, \
|
||||
SUM(({average_expression}) * sample_count) / NULLIF(SUM(sample_count), 0) AS average_value, \
|
||||
MIN({minimum_expression}) AS minimum_value, COUNT(*) AS sample_count \
|
||||
FROM metric_history WHERE {filter}"
|
||||
@@ -2488,17 +2457,17 @@ mod tests {
|
||||
sample(start + 1, 90.0),
|
||||
sample(start + 2, 20.0),
|
||||
];
|
||||
let first = save_agent_batch(&db, &identity, "first", &reports[..2], "", Some(true))
|
||||
let first = save_agent_batch(&db, &identity, "first", &reports[..2], "", true)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(first.persisted_through, start + 1);
|
||||
// Reconnect with an overlapping batch: only the unacknowledged sample is added.
|
||||
let second = save_agent_batch(&db, &identity, "overlap", &reports[1..], "", Some(true))
|
||||
let second = save_agent_batch(&db, &identity, "overlap", &reports[1..], "", true)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(second.persisted_through, start + 2);
|
||||
for id in ["overlap", "another-retry"] {
|
||||
let retry = save_agent_batch(&db, &identity, id, &reports, "", Some(true))
|
||||
let retry = save_agent_batch(&db, &identity, id, &reports, "", true)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(retry.reports.is_empty());
|
||||
@@ -2528,26 +2497,20 @@ mod tests {
|
||||
let reports = (0..121)
|
||||
.map(|offset| sample(start + offset, 25.0))
|
||||
.collect::<Vec<_>>();
|
||||
save_agent_batch(&db, &identity, "initial", &reports[..1], "", Some(true))
|
||||
save_agent_batch(&db, &identity, "initial", &reports[..1], "", true)
|
||||
.await
|
||||
.unwrap();
|
||||
let live = save_agent_batch(&db, &identity, "live", &reports[120..], "", Some(false))
|
||||
let live = save_agent_batch(&db, &identity, "live", &reports[120..], "", false)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!live.persisted);
|
||||
assert_eq!(live.persisted_through, start);
|
||||
// Even a payload-size-limited batch shorter than report_interval must commit.
|
||||
for (index, batch) in reports[1..].chunks(3).enumerate() {
|
||||
let result = save_agent_batch(
|
||||
&db,
|
||||
&identity,
|
||||
&format!("batch-{index}"),
|
||||
batch,
|
||||
"",
|
||||
Some(true),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let result =
|
||||
save_agent_batch(&db, &identity, &format!("batch-{index}"), batch, "", true)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.persisted);
|
||||
assert_eq!(result.persisted_through, batch.last().unwrap().timestamp);
|
||||
}
|
||||
@@ -2564,16 +2527,9 @@ mod tests {
|
||||
async fn failed_persistence_rolls_back_history_and_watermark() {
|
||||
let (db, identity) = test_server().await;
|
||||
let start = now() - 60;
|
||||
save_agent_batch(
|
||||
&db,
|
||||
&identity,
|
||||
"initial",
|
||||
&[sample(start, 10.0)],
|
||||
"",
|
||||
Some(true),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
save_agent_batch(&db, &identity, "initial", &[sample(start, 10.0)], "", true)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"CREATE TRIGGER reject_latest BEFORE UPDATE ON server_latest_state \
|
||||
BEGIN SELECT RAISE(ABORT, 'test write failure'); END",
|
||||
@@ -2589,7 +2545,7 @@ mod tests {
|
||||
"next",
|
||||
std::slice::from_ref(&next),
|
||||
"",
|
||||
Some(true)
|
||||
true
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
@@ -2611,14 +2567,14 @@ mod tests {
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let result = save_agent_batch(&db, &identity, "next", &[next], "", Some(true))
|
||||
let result = save_agent_batch(&db, &identity, "next", &[next], "", true)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.persisted_through, start + 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn history_query_weights_different_windows_and_reads_legacy_snapshots() {
|
||||
async fn history_query_weights_different_windows() {
|
||||
let (db, identity) = test_server().await;
|
||||
let start = (now() - 1200).div_euclid(120) * 120;
|
||||
let reports = [
|
||||
@@ -2627,7 +2583,7 @@ mod tests {
|
||||
sample(start + 2, 90.0),
|
||||
sample(start + 60, 10.0),
|
||||
];
|
||||
save_agent_batch(&db, &identity, "weighted", &reports, "", Some(true))
|
||||
save_agent_batch(&db, &identity, "weighted", &reports, "", true)
|
||||
.await
|
||||
.unwrap();
|
||||
let points = history(&db, &identity.server_id, 24).await.unwrap();
|
||||
@@ -2636,20 +2592,6 @@ mod tests {
|
||||
assert_eq!(points[0].cpu_min, 10.0);
|
||||
assert_eq!(points[0].cpu_max, 90.0);
|
||||
assert_eq!(points[0].sample_count, 4);
|
||||
sqlx::query(
|
||||
"INSERT INTO metric_history(server_id,timestamp,cpu,mem_used) VALUES (?,?,33.0,44)",
|
||||
)
|
||||
.bind(&identity.server_id)
|
||||
.bind(start + 240)
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let points = history(&db, &identity.server_id, 24).await.unwrap();
|
||||
let legacy = points.last().unwrap();
|
||||
assert_eq!(legacy.cpu_min, 33.0);
|
||||
assert_eq!(legacy.cpu_max, 33.0);
|
||||
assert_eq!(legacy.mem_used_max, 44);
|
||||
assert_eq!(legacy.sample_count, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -2658,8 +2600,8 @@ mod tests {
|
||||
let current = now();
|
||||
let since = current - 3600;
|
||||
sqlx::query(
|
||||
"INSERT INTO metric_history(server_id,timestamp,cpu,first_timestamp,last_timestamp) \
|
||||
VALUES (?,?,?,?,?)",
|
||||
"INSERT INTO metric_history(server_id,timestamp,cpu,cpu_min,cpu_max,mem_used_max,first_timestamp,last_timestamp) \
|
||||
VALUES (?,?,?,42.0,42.0,0,?,?)",
|
||||
)
|
||||
.bind(&identity.server_id)
|
||||
.bind(since - 4)
|
||||
@@ -2681,7 +2623,7 @@ mod tests {
|
||||
let reports = (0..=120)
|
||||
.map(|offset| sample(start + offset, if offset == 60 { 10.0 } else { 90.0 }))
|
||||
.collect::<Vec<_>>();
|
||||
save_agent_batch(&db, &identity, "alert-data", &reports, "", Some(true))
|
||||
save_agent_batch(&db, &identity, "alert-data", &reports, "", true)
|
||||
.await
|
||||
.unwrap();
|
||||
for metric in ["cpu", "memory", "disk", "net_in", "net_out"] {
|
||||
@@ -2742,7 +2684,7 @@ mod tests {
|
||||
&format!("retry-{index}"),
|
||||
&reports,
|
||||
"",
|
||||
Some(true),
|
||||
true,
|
||||
)
|
||||
.await
|
||||
}));
|
||||
@@ -2873,7 +2815,7 @@ mod tests {
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let result = save_agent_batch(&db, &identity, "large-batch", &reports, "127.0.0.1", None)
|
||||
let result = save_agent_batch(&db, &identity, "large-batch", &reports, "127.0.0.1", true)
|
||||
.await
|
||||
.unwrap();
|
||||
let rows = sqlx::query_scalar::<_, i64>(
|
||||
@@ -2905,7 +2847,7 @@ mod tests {
|
||||
..AgentReport::default()
|
||||
}],
|
||||
"127.0.0.1",
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -2922,7 +2864,7 @@ mod tests {
|
||||
..AgentReport::default()
|
||||
}],
|
||||
"127.0.0.1",
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -2973,7 +2915,7 @@ mod tests {
|
||||
..AgentReport::default()
|
||||
}],
|
||||
"127.0.0.1",
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -3052,16 +2994,9 @@ mod tests {
|
||||
async fn cleanup_is_bounded_and_keeps_current_data_and_server_state() {
|
||||
let (db, identity) = test_server().await;
|
||||
let current = now();
|
||||
save_agent_batch(
|
||||
&db,
|
||||
&identity,
|
||||
"latest",
|
||||
&[sample(current, 40.0)],
|
||||
"",
|
||||
Some(true),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
save_agent_batch(&db, &identity, "latest", &[sample(current, 40.0)], "", true)
|
||||
.await
|
||||
.unwrap();
|
||||
let task_id = create_latency_task(
|
||||
&db,
|
||||
&LatencyTaskInput {
|
||||
@@ -3079,10 +3014,11 @@ mod tests {
|
||||
let old = current - 3 * 86_400;
|
||||
sqlx::query(
|
||||
"WITH RECURSIVE seq(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM seq WHERE n<2100) \
|
||||
INSERT INTO metric_history(server_id,timestamp) SELECT ?,?+n FROM seq",
|
||||
INSERT INTO metric_history(server_id,timestamp,last_timestamp) SELECT ?,?+n,?+n FROM seq",
|
||||
)
|
||||
.bind(&identity.server_id)
|
||||
.bind(old)
|
||||
.bind(old)
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -16,7 +16,7 @@ use tokio::sync::mpsc;
|
||||
|
||||
const MAX_AGENT_MESSAGE_BYTES: usize = 2 * 1024 * 1024;
|
||||
const MAX_TASK_RESULT_BYTES: usize = 1024 * 1024;
|
||||
const AGENT_PROTOCOL_VERSION: &str = "1";
|
||||
const AGENT_PROTOCOL_VERSION: &str = "2";
|
||||
const AGENT_PROTOCOL_HEADER: &str = "x-nodeflare-agent-protocol";
|
||||
const AGENT_CAPABILITIES_HEADER: &str = "x-nodeflare-agent-capabilities";
|
||||
const REQUIRED_AGENT_CAPABILITIES: [&str; 4] =
|
||||
@@ -30,8 +30,7 @@ struct UpdateMessage {
|
||||
#[serde(rename = "batchId")]
|
||||
batch_id: String,
|
||||
samples: Vec<AgentReport>,
|
||||
#[serde(default)]
|
||||
persist: Option<bool>,
|
||||
persist: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -93,8 +92,8 @@ pub async fn handle(
|
||||
.max_frame_size(MAX_AGENT_MESSAGE_BYTES)
|
||||
.on_upgrade(move |socket| run(socket, state, identity, remote_ip, token));
|
||||
response.headers_mut().insert(
|
||||
"x-nodeflare-persistence-batches",
|
||||
axum::http::HeaderValue::from_static("1"),
|
||||
AGENT_PROTOCOL_HEADER,
|
||||
axum::http::HeaderValue::from_static(AGENT_PROTOCOL_VERSION),
|
||||
);
|
||||
response
|
||||
}
|
||||
@@ -488,7 +487,32 @@ mod tests {
|
||||
HeaderValue::from_static("metrics-v1,config-v1,remote-exec-v1,task-ack-v1"),
|
||||
);
|
||||
assert!(agent_protocol_supported(&headers));
|
||||
headers.remove(AGENT_CAPABILITIES_HEADER);
|
||||
assert!(!agent_protocol_supported(&headers));
|
||||
headers.insert(
|
||||
AGENT_CAPABILITIES_HEADER,
|
||||
HeaderValue::from_static("metrics-v1,config-v1,remote-exec-v1,task-ack-v1"),
|
||||
);
|
||||
headers.insert(AGENT_PROTOCOL_HEADER, HeaderValue::from_static("1"));
|
||||
assert!(!agent_protocol_supported(&headers));
|
||||
headers.insert(AGENT_PROTOCOL_HEADER, HeaderValue::from_static("999"));
|
||||
assert!(!agent_protocol_supported(&headers));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn updates_require_an_explicit_persistence_flag() {
|
||||
let mut message = serde_json::json!({"type": "update", "batchId": "batch", "samples": []});
|
||||
assert!(serde_json::from_value::<UpdateMessage>(message.clone()).is_err());
|
||||
message["persist"] = serde_json::Value::Null;
|
||||
assert!(serde_json::from_value::<UpdateMessage>(message.clone()).is_err());
|
||||
for persist in [true, false] {
|
||||
message["persist"] = persist.into();
|
||||
assert_eq!(
|
||||
serde_json::from_value::<UpdateMessage>(message.clone())
|
||||
.unwrap()
|
||||
.persist,
|
||||
persist
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -249,7 +249,6 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||
};
|
||||
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);
|
||||
scheduler.schedule();
|
||||
|
||||
@@ -150,7 +150,7 @@ 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 <= 0
|
||||
{config.show_price ? <CompactLine icon={<Coins size={11} />}>{server.price === 0
|
||||
? ui(locale, "免费", "Free")
|
||||
: formatCurrency(remainingValue, server.currency)}</CompactLine> : null}
|
||||
</div> : null}
|
||||
|
||||
@@ -39,8 +39,7 @@ describe("formatSpeedParts", () => {
|
||||
});
|
||||
|
||||
describe("formatPrice", () => {
|
||||
test("renders zero and legacy negative prices as free", () => {
|
||||
expect(formatPrice({ price: -1, billing_cycle: 30, currency: "CNY" })).toBe("免费");
|
||||
test("renders zero prices as free", () => {
|
||||
expect(formatPrice({ price: 0, billing_cycle: 30, currency: "CNY" })).toBe("免费");
|
||||
expect(formatPrice({ price: 0, billing_cycle: 30, currency: "USD" }, "en")).toBe("Free");
|
||||
});
|
||||
|
||||
@@ -154,7 +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 <= 0) return ui(locale, "免费", "Free");
|
||||
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")
|
||||
@@ -170,7 +170,7 @@ function daysUntil(timestamp: number | null) {
|
||||
}
|
||||
|
||||
export function formatExpire(server: Pick<Server, "expires_at" | "price">, locale: UiLocale = "zh-CN") {
|
||||
if (server.price <= 0 && !server.expires_at) 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");
|
||||
|
||||
@@ -97,6 +97,65 @@ describe("installer port selection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("server installer update output", () => {
|
||||
for (const healthy of [true, false]) {
|
||||
test(healthy ? "updates quietly without prompting or rewriting configuration" : "keeps startup diagnostics and never announces success on failure", () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "nodeflare-install-output-"));
|
||||
const config = join(directory, "config.toml");
|
||||
const original = 'bind_addr = "127.0.0.1:3100"\nadmin_password = ""\n';
|
||||
try {
|
||||
writeFileSync(config, original);
|
||||
const start = installer.lastIndexOf("\ndownload_release\n");
|
||||
if (start < 0) throw new Error("Missing installer entry point");
|
||||
const result = spawnSync("sh", ["-c", `
|
||||
set -eu
|
||||
${shellFunctions("log", "fail", "print_install_result")}
|
||||
config_dir=$TEST_DIRECTORY
|
||||
config_file=$config_dir/config.toml
|
||||
theme_dir=$config_dir/themes
|
||||
install_dir=$config_dir/install
|
||||
share_dir=$install_dir/share
|
||||
public_frontend_dir=$share_dir/frontend
|
||||
admin_frontend_dir=$share_dir/admin
|
||||
agent_installer_dir=$share_dir/agent
|
||||
server_binary=$install_dir/nodeflare
|
||||
init_system=systemd
|
||||
download_release() { release_version=1.2.3; package_dir=$TEST_DIRECTORY/package; }
|
||||
prompt_credentials() { fail 'Unexpected credential prompt'; }
|
||||
prompt_port() { fail 'Unexpected port prompt'; }
|
||||
prompt_database() { fail 'Unexpected database prompt'; }
|
||||
write_config() { fail 'Unexpected config write'; }
|
||||
snapshot_install() { :; }
|
||||
stop_server() { :; }
|
||||
install_service() { :; }
|
||||
install() { :; }
|
||||
cp() { :; }
|
||||
rm() { :; }
|
||||
chown() { :; }
|
||||
chmod() { :; }
|
||||
start_server() { ${healthy ? "return 0" : "return 1"}; }
|
||||
journalctl() { printf 'startup diagnostic\\n'; }
|
||||
${installer.slice(start)}
|
||||
`], { env: { ...process.env, TEST_DIRECTORY: directory }, encoding: "utf8" });
|
||||
expect(readFileSync(config, "utf8")).toBe(original);
|
||||
expect(result.stdout).not.toMatch(/NodeFlare|配置|沿用|复用|下一步|本机访问/);
|
||||
if (healthy) {
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toBe("正在更新至 v1.2.3\n正在启动服务\n\n更新完成(v1.2.3)\n");
|
||||
expect(result.stderr).toBe("");
|
||||
} else {
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stdout).not.toContain("完成");
|
||||
expect(result.stderr).toContain("startup diagnostic");
|
||||
expect(result.stderr).toContain("服务启动失败");
|
||||
}
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("installer menu", () => {
|
||||
function route(input: string, args: string[] = []) {
|
||||
const dispatch = installer.slice(installer.indexOf("\nmode=menu"), installer.indexOf("\nfor required_command"));
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
function shellFunction(script: string, name: string) {
|
||||
const match = script.match(new RegExp(`^${name}\\(\\) \\{[\\s\\S]*?^\\}`, "m"));
|
||||
if (!match) throw new Error(`Missing shell function: ${name}`);
|
||||
return match[0];
|
||||
}
|
||||
|
||||
for (const [name, path, server] of [
|
||||
["server", "../../install.sh", true],
|
||||
["Linux agent", "../../agent/agent.sh", false],
|
||||
["macOS agent", "../../agent/install-macos.sh", false],
|
||||
["FreeBSD agent", "../../agent/install-freebsd.sh", false],
|
||||
] as const) {
|
||||
const script = readFileSync(new URL(path, import.meta.url), "utf8");
|
||||
function output(command: string, newConfig = true, update = false, hadAgent = false, init = "systemd") {
|
||||
return spawnSync("sh", ["-c", `
|
||||
set -eu
|
||||
${["log", "fail", "print_install_result"].map(fn => shellFunction(script, fn)).join("\n")}
|
||||
new_config=${newConfig}
|
||||
update=${update}
|
||||
had_agent=${hadAgent}
|
||||
release_version=1.2.3
|
||||
installed_version=1.2.3
|
||||
config_dir=/test/config
|
||||
server_binary=/test/nodeflare
|
||||
server_port=3100
|
||||
SERVICE_NAME=nodeflare-agent
|
||||
LABEL=nodeflare-agent
|
||||
init_system=${init}
|
||||
${command}
|
||||
`], { encoding: "utf8" });
|
||||
}
|
||||
|
||||
describe(`${name} installer output`, () => {
|
||||
test("valid shell syntax and no repeated branding prefix", () => {
|
||||
expect(spawnSync("sh", ["-n"], { input: script }).status).toBe(0);
|
||||
expect(script).not.toContain("[NodeFlare]");
|
||||
const result = output('log "正在检查运行环境"');
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toBe("正在检查运行环境\n");
|
||||
});
|
||||
|
||||
test("keeps first-install guidance and displays the version once", () => {
|
||||
for (const init of ["systemd", "openrc"]) {
|
||||
const result = output("print_install_result", true, false, false, init);
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toStartWith("\n安装完成(v1.2.3)\n");
|
||||
expect(result.stdout.match(/1\.2\.3/g)?.length).toBe(1);
|
||||
expect(result.stdout).toContain(server ? "本机访问:http://127.0.0.1:3100/admin/login" : "查看状态:");
|
||||
if (server) expect(result.stdout).toContain("下一步:");
|
||||
}
|
||||
});
|
||||
|
||||
test("updates only print the result and version", () => {
|
||||
const states = server ? [[false, false]] : [[true, false], [false, true], [true, true]];
|
||||
for (const [update, hadAgent] of states) {
|
||||
const result = output("print_install_result", false, update, hadAgent);
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toBe("\n更新完成(v1.2.3)\n");
|
||||
expect(result.stderr).toBe("");
|
||||
}
|
||||
});
|
||||
|
||||
test("errors remain visible and return failure", () => {
|
||||
const result = output('fail "服务启动失败"');
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stdout).toBe("");
|
||||
expect(result.stderr).toBe("错误:服务启动失败\n");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const powershell = Bun.which("pwsh");
|
||||
for (const [name, path, server] of [
|
||||
["Windows server", "../../install.ps1", true],
|
||||
["Windows agent", "../../agent/install.ps1", false],
|
||||
] as const) {
|
||||
const file = fileURLToPath(new URL(path, import.meta.url));
|
||||
const script = readFileSync(file, "utf8");
|
||||
const functions = ["Write-Step", "Show-InstallResult"].map(name => {
|
||||
const match = script.match(new RegExp(`^function ${name}(?:\\([^\\n]*\\))? \\{[\\s\\S]*?^\\}`, "m"));
|
||||
if (!match) throw new Error(`Missing PowerShell function: ${name}`);
|
||||
return match[0];
|
||||
}).join("\n");
|
||||
|
||||
describe(`${name} installer output`, () => {
|
||||
test("does not contain a repeated branding prefix", () => {
|
||||
expect(script).not.toContain("[NodeFlare]");
|
||||
});
|
||||
|
||||
test.skipIf(!powershell)("parses with PowerShell", () => {
|
||||
const result = spawnSync(powershell!, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", `
|
||||
$tokens = $null
|
||||
$errors = $null
|
||||
[System.Management.Automation.Language.Parser]::ParseFile($env:TEST_SCRIPT, [ref]$tokens, [ref]$errors) > $null
|
||||
if ($errors.Count) { $errors | ForEach-Object { Write-Error $_ }; exit 1 }
|
||||
`], { env: { ...process.env, TEST_SCRIPT: file }, encoding: "utf8" });
|
||||
expect(result.status).toBe(0);
|
||||
});
|
||||
|
||||
test.skipIf(!powershell)("separates fresh-install guidance from update results", () => {
|
||||
const states = server ? [[true, false, false], [false, false, false]] : [
|
||||
[true, false, false], [false, true, false], [false, false, true], [false, true, true],
|
||||
];
|
||||
for (const [fresh, update, hadAgent] of states) {
|
||||
const result = spawnSync(powershell!, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", `
|
||||
$ErrorActionPreference = "Stop"
|
||||
Set-StrictMode -Version Latest
|
||||
${functions}
|
||||
$NewConfig = $${fresh}
|
||||
$Update = $${update}
|
||||
$HadPreviousAgent = $${hadAgent}
|
||||
$Version = "1.2.3"
|
||||
$InstalledVersion = "1.2.3"
|
||||
$DataDir = "C:/test/data"
|
||||
$TaskName = "test-service"
|
||||
$Port = 3100
|
||||
Write-Step "正在启动服务"
|
||||
Show-InstallResult
|
||||
`], { encoding: "utf8" });
|
||||
expect(result.status).toBe(0);
|
||||
const output = result.stdout.replaceAll("\r\n", "\n");
|
||||
if (fresh) {
|
||||
expect(output).toContain("安装完成(v1.2.3)");
|
||||
expect(output).toContain(server ? "本机访问:http://127.0.0.1:3100/admin/login" : "查看状态:");
|
||||
} else {
|
||||
expect(output).toBe("正在启动服务\n\n更新完成(v1.2.3)\n");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -68,7 +68,6 @@ function transportHarness() {
|
||||
const connections: boolean[] = [];
|
||||
const batches: BatchUpdate[][] = [];
|
||||
const dispose = connectLive({ serverId: "node/1" }, {
|
||||
onServer: () => {},
|
||||
onBatch: (batch) => { batches.push(batch); },
|
||||
onConnectedChange: (connected) => { connections.push(connected); },
|
||||
onWakeRequested: async () => {},
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { BatchUpdate } from "./live";
|
||||
import type { Server } from "./types";
|
||||
|
||||
const RECONNECT_BASE_DELAY = 1_000;
|
||||
const RECONNECT_MAX_DELAY = 30_000;
|
||||
@@ -18,7 +17,6 @@ export function reconnectDelay(attempt: number, randomValue = Math.random()) {
|
||||
}
|
||||
|
||||
export interface LiveTransportHandlers {
|
||||
onServer: (server: Server) => void;
|
||||
onBatch: (updates: BatchUpdate[]) => void;
|
||||
onConnectedChange: (connected: boolean) => void;
|
||||
onWakeRequested: () => Promise<void>;
|
||||
@@ -156,10 +154,6 @@ export function connectLive(
|
||||
}
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.type === "server" && message.server?.id) {
|
||||
handlers.onServer(message.server as Server);
|
||||
return;
|
||||
}
|
||||
if (message.type === "batchUpdate" && Array.isArray(message.updates)) {
|
||||
handlers.onBatch(message.updates as BatchUpdate[]);
|
||||
}
|
||||
|
||||
+27
-20
@@ -20,11 +20,24 @@ $ConfigFile = Join-Path $DataDir "config.toml"
|
||||
$ThemeDir = Join-Path $DataDir "themes"
|
||||
|
||||
function Write-Step([string]$Message) {
|
||||
Write-Host "[NodeFlare] $Message"
|
||||
Write-Host $Message
|
||||
}
|
||||
|
||||
function Stop-Install([string]$Message) {
|
||||
throw "[NodeFlare] 错误:$Message"
|
||||
throw "错误:$Message"
|
||||
}
|
||||
|
||||
function Show-InstallResult {
|
||||
Write-Host ""
|
||||
if (-not $NewConfig) {
|
||||
Write-Host "更新完成(v$Version)"
|
||||
return
|
||||
}
|
||||
Write-Host "安装完成(v$Version)"
|
||||
Write-Host " 配置和数据:$DataDir"
|
||||
Write-Host " 服务:Windows 计划任务 $TaskName"
|
||||
Write-Host " 本机访问:http://127.0.0.1:$Port/admin/login"
|
||||
Write-Host " 下一步:登录管理后台创建节点,并按弹窗命令安装 Agent"
|
||||
}
|
||||
|
||||
function Assert-Administrator {
|
||||
@@ -99,13 +112,13 @@ function Wait-Server {
|
||||
if ($Task.State -eq "Running") { break }
|
||||
}
|
||||
if ($Task.State -ne "Running") {
|
||||
Stop-Install "NodeFlare 服务启动失败(状态:$($Task.State))"
|
||||
Stop-Install "服务启动失败(状态:$($Task.State))"
|
||||
}
|
||||
for ($Attempt = 0; $Attempt -lt 10; $Attempt++) {
|
||||
Start-Sleep -Seconds 1
|
||||
$Task = Get-ScheduledTask -TaskName $TaskName
|
||||
if ($Task.State -ne "Running") {
|
||||
Stop-Install "NodeFlare 启动后退出,请检查 bind_addr 端口占用及数据库连接"
|
||||
Stop-Install "服务启动后退出,请检查 bind_addr 端口占用及数据库连接"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -139,7 +152,7 @@ if ($Mode -eq "Exit") { exit 0 }
|
||||
if ($Mode -eq "Status") {
|
||||
$Task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||
if ($null -eq $Task) {
|
||||
Write-Error "未检测到 NodeFlare 服务"
|
||||
Write-Error "未检测到面板服务"
|
||||
exit 1
|
||||
}
|
||||
$Task
|
||||
@@ -156,7 +169,7 @@ if ($Mode -eq "Restart") {
|
||||
Stop-ScheduledTask -TaskName $TaskName
|
||||
Start-ScheduledTask -TaskName $TaskName
|
||||
Wait-Server
|
||||
Write-Step "NodeFlare 已重启"
|
||||
Write-Step "服务已重启"
|
||||
exit 0
|
||||
}
|
||||
|
||||
@@ -166,7 +179,7 @@ if ($Mode -eq "Uninstall") {
|
||||
Stop-Install "已取消卸载"
|
||||
}
|
||||
}
|
||||
Write-Step "停止并移除 NodeFlare 面板服务"
|
||||
Write-Step "停止并移除面板服务"
|
||||
Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
|
||||
Remove-Item -LiteralPath $ServerFile -Force -ErrorAction SilentlyContinue
|
||||
@@ -208,7 +221,7 @@ try {
|
||||
$DigestMatch = [regex]::Match([string]$ReleaseAsset.digest, '^sha256:([0-9a-fA-F]{64})$')
|
||||
if (-not $DigestMatch.Success) { Stop-Install "Release 缺少 $Asset 的 SHA-256 摘要" }
|
||||
$Expected = $DigestMatch.Groups[1].Value
|
||||
Write-Step "下载 NodeFlare $($Release.tag_name)(Windows x64)"
|
||||
Write-Step "下载 $($Release.tag_name)(Windows x64)"
|
||||
Invoke-WebRequest -UseBasicParsing -Uri $ReleaseAsset.browser_download_url -OutFile $Archive -TimeoutSec 120
|
||||
$Actual = (Get-FileHash -LiteralPath $Archive -Algorithm SHA256).Hash
|
||||
if ($Actual -ne $Expected) {
|
||||
@@ -264,7 +277,11 @@ try {
|
||||
} while ($DatabaseUrl -notmatch '^(sqlite|postgres|postgresql)://')
|
||||
}
|
||||
|
||||
Write-Step "安装 NodeFlare $Version"
|
||||
if ($NewConfig) {
|
||||
Write-Step "正在安装 v$Version"
|
||||
} else {
|
||||
Write-Step "正在更新至 v$Version"
|
||||
}
|
||||
$HadPreviousInstall = Test-Path -LiteralPath $InstallDir -PathType Container
|
||||
if ($HadPreviousInstall) {
|
||||
Copy-Item -LiteralPath $InstallDir -Destination $PreviousInstall -Recurse -Force
|
||||
@@ -296,18 +313,8 @@ try {
|
||||
|
||||
Wait-Server
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "NodeFlare 安装完成"
|
||||
Write-Host " 版本:$Version"
|
||||
Write-Host " 配置和数据:$DataDir"
|
||||
Write-Host " 服务:Windows 计划任务 $TaskName"
|
||||
if ($NewConfig) {
|
||||
Write-Host " 本机访问:http://127.0.0.1:$Port/admin/login"
|
||||
} else {
|
||||
Write-Host " 监听地址:沿用 $ConfigFile 中的 bind_addr"
|
||||
}
|
||||
Write-Host " 下一步:登录管理后台创建节点,并按弹窗命令安装 Agent"
|
||||
$InstallChanged = $false
|
||||
Show-InstallResult
|
||||
} catch {
|
||||
if ($InstallChanged) {
|
||||
if ($HadPreviousInstall) { Write-Warning "安装未完成,正在恢复上一版本" }
|
||||
|
||||
+31
-26
@@ -42,7 +42,7 @@ case "$(uname -s)" in
|
||||
default_database_url=sqlite://nodeflare.db
|
||||
;;
|
||||
*)
|
||||
printf '[NodeFlare] 错误:当前系统不支持此安装脚本\n' >&2
|
||||
printf '错误:当前系统不支持此安装脚本\n' >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -67,19 +67,33 @@ usage() {
|
||||
' sudo sh install.sh --uninstall --purge' \
|
||||
'' \
|
||||
'首次安装会询问管理员用户名、密码、监听端口(默认 2206)和数据库连接。' \
|
||||
'安装和更新均使用 GitHub latest Release,并保留已有配置和数据库。' \
|
||||
'安装和更新均使用 GitHub latest Release。' \
|
||||
'--uninstall 保留配置和数据;只有同时指定 --purge 才彻底删除。'
|
||||
}
|
||||
|
||||
log() {
|
||||
printf '[NodeFlare] %s\n' "$1"
|
||||
printf '%s\n' "$1"
|
||||
}
|
||||
|
||||
fail() {
|
||||
printf '[NodeFlare] 错误:%s\n' "$1" >&2
|
||||
printf '错误:%s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
print_install_result() {
|
||||
if [ "$new_config" = false ]; then
|
||||
printf '\n更新完成(v%s)\n' "$release_version"
|
||||
return
|
||||
fi
|
||||
printf '\n安装完成(v%s)\n' "$release_version"
|
||||
printf '%s\n' \
|
||||
"配置和数据:$config_dir" \
|
||||
"程序:$server_binary" \
|
||||
"服务系统:$init_system"
|
||||
printf '本机访问:http://127.0.0.1:%s/admin/login\n' "$server_port"
|
||||
printf '%s\n' "下一步:登录管理后台创建节点,并按弹窗命令安装 Agent"
|
||||
}
|
||||
|
||||
detect_glibc_version() {
|
||||
[ -x "$glibc_loader" ] || return
|
||||
LC_ALL=C "$glibc_loader" --version 2>/dev/null | awk '
|
||||
@@ -399,7 +413,7 @@ download_release() {
|
||||
')
|
||||
[ -n "$expected" ] || fail "Release 缺少 $asset 的 SHA-256 摘要"
|
||||
release_base="https://github.com/$repository/releases/download/$release_tag"
|
||||
log "下载 NodeFlare $release_tag ($release_label)"
|
||||
log "下载 $release_tag ($release_label)"
|
||||
download_file "$release_base/$asset" "$archive" 120
|
||||
verify_checksum
|
||||
|
||||
@@ -534,7 +548,7 @@ rollback_install() {
|
||||
if start_server; then
|
||||
log "已恢复并重新启动上一版本"
|
||||
else
|
||||
printf '[NodeFlare] 警告:上一版本已恢复,但服务未能自动启动\n' >&2
|
||||
printf '警告:上一版本已恢复,但服务未能自动启动\n' >&2
|
||||
fi
|
||||
fi
|
||||
}
|
||||
@@ -552,7 +566,7 @@ start_server() {
|
||||
case "$init_system" in
|
||||
systemd)
|
||||
systemctl daemon-reload || return 1
|
||||
systemctl enable nodeflare.service >/dev/null || return 1
|
||||
systemctl enable --quiet nodeflare.service >/dev/null || return 1
|
||||
systemctl restart nodeflare.service || return 1
|
||||
started_pid=$(systemctl show -p MainPID --value nodeflare.service) || return 1
|
||||
[ "$started_pid" -gt 0 ] || return 1
|
||||
@@ -595,13 +609,13 @@ restart_server() {
|
||||
[ -x "$server_binary" ] && [ -f "$config_file" ] && [ -f "$(service_definition)" ] \
|
||||
|| fail "未检测到完整安装,请先选择安装 / 更新"
|
||||
stop_server
|
||||
start_server || fail "NodeFlare 重启失败,请检查服务日志"
|
||||
log "NodeFlare 已重启"
|
||||
start_server || fail "服务重启失败,请检查日志"
|
||||
log "服务已重启"
|
||||
}
|
||||
|
||||
uninstall_server() {
|
||||
purge=$1
|
||||
log "停止并移除 NodeFlare 面板服务"
|
||||
log "停止并移除面板服务"
|
||||
stop_server
|
||||
case "$init_system" in
|
||||
systemd)
|
||||
@@ -696,11 +710,13 @@ if [ ! -f "$config_file" ]; then
|
||||
prompt_credentials
|
||||
prompt_port
|
||||
prompt_database
|
||||
else
|
||||
log "保留已有配置:$config_file"
|
||||
fi
|
||||
|
||||
log "安装 NodeFlare $release_version"
|
||||
if [ "$new_config" = true ]; then
|
||||
log "正在安装 v$release_version"
|
||||
else
|
||||
log "正在更新至 v$release_version"
|
||||
fi
|
||||
snapshot_install
|
||||
stop_server
|
||||
install -d -m 0700 "$config_dir" "$theme_dir"
|
||||
@@ -727,19 +743,8 @@ if ! start_server; then
|
||||
if [ "$init_system" = systemd ]; then
|
||||
journalctl -u nodeflare.service -n 30 --no-pager >&2 || true
|
||||
fi
|
||||
fail "NodeFlare 服务启动失败,请检查配置中的 bind_addr 是否被占用及数据库连接"
|
||||
fail "服务启动失败,请检查配置中的 bind_addr 是否被占用及数据库连接"
|
||||
fi
|
||||
rollback_ready=false
|
||||
|
||||
log "安装完成"
|
||||
printf '%s\n' \
|
||||
"版本:$release_version" \
|
||||
"配置和数据:$config_dir" \
|
||||
"程序:$server_binary" \
|
||||
"服务系统:$init_system"
|
||||
if [ "$new_config" = true ]; then
|
||||
printf '本机访问:http://127.0.0.1:%s/admin/login\n' "$server_port"
|
||||
else
|
||||
printf '监听地址:沿用 %s 中的 bind_addr\n' "$config_file"
|
||||
fi
|
||||
printf '%s\n' "下一步:登录管理后台创建节点,并按弹窗命令安装 Agent"
|
||||
print_install_result
|
||||
@@ -12,7 +12,7 @@ const configOnly = process.env.MONITOR_CONFIG_ONLY === "1";
|
||||
const expectAgentRejected = process.env.MONITOR_EXPECT_AGENT_REJECTED === "1";
|
||||
const rotateAgentToken = process.env.MONITOR_ROTATE_AGENT_TOKEN === "1";
|
||||
const agentProtocolHeaders = {
|
||||
"X-NodeFlare-Agent-Protocol": "1",
|
||||
"X-NodeFlare-Agent-Protocol": "2",
|
||||
"X-NodeFlare-Agent-Capabilities":
|
||||
"metrics-v1,config-v1,remote-exec-v1,task-ack-v1",
|
||||
};
|
||||
@@ -118,6 +118,7 @@ function socketHeaders(path, token, headers = {}, includeAgentProtocol = true) {
|
||||
|
||||
function openSocket(path, token, headers = {}, includeAgentProtocol = true) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let serverProtocol;
|
||||
const socket = new WebSocket(websocketUrl(path), {
|
||||
headers: socketHeaders(path, token, headers, includeAgentProtocol),
|
||||
});
|
||||
@@ -125,8 +126,16 @@ function openSocket(path, token, headers = {}, includeAgentProtocol = true) {
|
||||
socket.terminate();
|
||||
reject(new Error(`${path} WebSocket handshake timed out`));
|
||||
}, 5_000);
|
||||
socket.once("upgrade", (response) => {
|
||||
serverProtocol = response.headers["x-nodeflare-agent-protocol"];
|
||||
});
|
||||
socket.once("open", () => {
|
||||
clearTimeout(timer);
|
||||
if (path === "/api/agent/ws" && serverProtocol !== "2") {
|
||||
socket.terminate();
|
||||
reject(new Error(`Invalid backend Agent protocol: ${serverProtocol}`));
|
||||
return;
|
||||
}
|
||||
resolve(socket);
|
||||
});
|
||||
socket.once("unexpected-response", (_request, response) => {
|
||||
@@ -280,6 +289,12 @@ function waitForSocketClose(socket, expected, timeoutMs = 5_000) {
|
||||
}
|
||||
|
||||
await expectSocketStatus("/api/agent/ws", "invalid-agent-token", 426, {}, false);
|
||||
await expectSocketStatus(
|
||||
"/api/agent/ws",
|
||||
"invalid-agent-token",
|
||||
426,
|
||||
{ "X-NodeFlare-Agent-Protocol": "1" },
|
||||
);
|
||||
await expectSocketStatus(
|
||||
"/api/agent/ws",
|
||||
"invalid-agent-token",
|
||||
@@ -623,6 +638,18 @@ try {
|
||||
sample(5, { net_rx_total: 536870912, net_tx_total: 268435456 }),
|
||||
];
|
||||
const latestTimestamp = timestamp + 5;
|
||||
for (const persist of [undefined, null]) {
|
||||
const rejectedAck = waitForJsonMessage(
|
||||
agent,
|
||||
"invalid persistence flag ACK",
|
||||
(message) => message.type === "ack" && message.realtimeHint === false,
|
||||
);
|
||||
agent.send(JSON.stringify({ type: "update", batchId: "invalid-persist", samples, persist }));
|
||||
const rejected = await rejectedAck;
|
||||
if (rejected.persistenceError !== true || rejected.persisted !== false) {
|
||||
throw new Error(`Accepted invalid persistence flag: ${JSON.stringify(rejected)}`);
|
||||
}
|
||||
}
|
||||
const ackPromise = waitForJsonMessage(
|
||||
agent,
|
||||
"Agent metric ACK",
|
||||
@@ -641,7 +668,7 @@ try {
|
||||
),
|
||||
),
|
||||
);
|
||||
agent.send(JSON.stringify({ type: "update", batchId: `smoke-${timestamp}`, samples }));
|
||||
agent.send(JSON.stringify({ type: "update", batchId: `smoke-${timestamp}`, samples, persist: true }));
|
||||
const [ack, update] = await Promise.all([ackPromise, updatePromise]);
|
||||
if (
|
||||
ack.ts <= 0 ||
|
||||
|
||||
Reference in new issue
Block a user