优化安装脚本

This commit is contained in:
imengying committed 2026-09-07 23:41:50 +08:00
1 parent 58dea92e5f
commit c70564d2f9
30 files changed
+769 -437

No files matched your search

+16 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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();
+23 -1
View File
@@ -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);
+23 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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();
+29 -5
View File
@@ -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
);
}
}
}
-1
View File
@@ -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();
+1 -1
View File
@@ -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}
+1 -2
View File
@@ -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");
});
+2 -2
View File
@@ -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");
+59
View File
@@ -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"));
+136
View File
@@ -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");
}
}
});
});
}
-1
View File
@@ -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 () => {},
-6
View File
@@ -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
View File
@@ -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
View File
@@ -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
+29 -2
View File
@@ -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 ||