优化整体设计
This commit is contained in:
1 parent
bfd757ce04
commit
6fce6890da
19 files changed
+2089
-269
No files matched your search
@@ -33,7 +33,7 @@
|
||||
| `OFFLINE_THRESHOLD_SECONDS` | 可选 | 离线判定秒数,未设置时使用 180,范围 30-3600 |
|
||||
| `HISTORY_RETENTION_DAYS` | 可选 | 历史保留天数,未设置时使用 30,范围 1-365 |
|
||||
| `CF_USAGE_ACCOUNT_ID` | 可选 | Cloudflare 用量查询的账户 ID,不设置则不启用用量查询 |
|
||||
| `CF_USAGE_API_TOKEN` | 可选 | 用量查询 Token,勾选“加密”;需要 Account Analytics Read 权限 |
|
||||
| `CF_USAGE_API_TOKEN` | 可选 | 用量查询 Token,勾选“加密”;需要 Account Analytics: Read,并授权对应账户 |
|
||||
|
||||
## API
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
| `GET` | `/api/history/:id?hours=24` | 节点历史 |
|
||||
| `GET` | `/api/latency/:id?hours=24` | 节点延迟任务历史 |
|
||||
| `GET` | `/api/ws` | 实时 WebSocket |
|
||||
| `GET` | `/api/agent/live` | Agent 实时指标 WebSocket |
|
||||
| `POST` | `/api/agent/report` | 探针上报,节点 Bearer token |
|
||||
| `POST` | `/api/admin/login` | 管理登录 |
|
||||
| `GET/POST` | `/api/admin/servers` | 管理节点 |
|
||||
@@ -64,7 +65,7 @@
|
||||
| `GET` | `/api/admin/theme-settings` | 读取当前前端提供的主题设置描述 |
|
||||
| `POST` | `/api/turnstile/verify` | 校验全站 Turnstile 令牌 |
|
||||
| `GET` | `/api/admin/database` | 查询 D1 节点、历史和数据库统计 |
|
||||
| `GET` | `/api/admin/cloudflare-usage` | 查询 UTC 今日/昨日的 D1 与 Workers 用量 |
|
||||
| `GET` | `/api/admin/cloudflare-usage` | 查询 UTC 今日/昨日的 D1、Workers 与 Durable Objects 用量 |
|
||||
| `POST` | `/api/admin/exchange-rates/refresh` | 立即拉取并更新 D1 汇率快照 |
|
||||
| `DELETE` | `/api/admin/history` | 清理历史指标 |
|
||||
| `POST` | `/api/admin/notifications/test` | 发送一条通知测试消息 |
|
||||
|
||||
Generated
+121
-2
@@ -51,6 +51,17 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.6"
|
||||
@@ -143,6 +154,12 @@ dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
@@ -212,6 +229,18 @@ dependencies = [
|
||||
"wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
@@ -240,6 +269,12 @@ version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "httpdate"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hybrid-array"
|
||||
version = "0.4.14"
|
||||
@@ -403,10 +438,12 @@ name = "nodeflare-agent"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"httpdate",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sysinfo",
|
||||
"tungstenite",
|
||||
"ureq",
|
||||
"url",
|
||||
]
|
||||
@@ -490,6 +527,29 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
|
||||
dependencies = [
|
||||
"chacha20",
|
||||
"getrandom 0.4.3",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
@@ -498,7 +558,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"getrandom",
|
||||
"getrandom 0.2.17",
|
||||
"libc",
|
||||
"untrusted",
|
||||
"windows-sys",
|
||||
@@ -582,6 +642,17 @@ dependencies = [
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.11.0"
|
||||
@@ -670,6 +741,26 @@ dependencies = [
|
||||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.55"
|
||||
@@ -710,6 +801,25 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.30.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"sha1",
|
||||
"thiserror",
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
@@ -744,7 +854,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"ureq-proto",
|
||||
"utf8-zero",
|
||||
"webpki-roots",
|
||||
"webpki-roots 1.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -795,6 +905,15 @@ version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.26.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
||||
dependencies = [
|
||||
"webpki-roots 1.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.9"
|
||||
|
||||
@@ -11,8 +11,10 @@ clap = { version = "4.6", default-features = false, features = ["derive", "std",
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha2 = "0.11"
|
||||
httpdate = "1"
|
||||
ureq = { version = "3.4", default-features = false, features = ["json", "rustls"] }
|
||||
url = "2.5"
|
||||
tungstenite = { version = "0.30", default-features = false, features = ["handshake", "rustls-tls-webpki-roots"] }
|
||||
|
||||
[target.'cfg(any(target_os = "windows", target_os = "macos"))'.dependencies]
|
||||
sysinfo = { version = "0.39.6", default-features = false, features = ["disk", "network", "system"] }
|
||||
|
||||
+68
-43
@@ -8,9 +8,28 @@ SERVICE_FILE="/etc/systemd/system/$SERVICE_NAME.service"
|
||||
OPENRC_FILE="/etc/init.d/$SERVICE_NAME"
|
||||
|
||||
usage() {
|
||||
echo "Usage: agent.sh -e ENDPOINT -t TOKEN [-i 60]"
|
||||
echo " agent.sh --uninstall"
|
||||
echo " agent.sh --status"
|
||||
cat <<'EOF'
|
||||
NodeFlare Agent 安装脚本
|
||||
|
||||
用法:
|
||||
agent.sh -e <Worker URL> -t <Agent Token> [-i <上报间隔>]
|
||||
agent.sh --status
|
||||
agent.sh --uninstall
|
||||
|
||||
参数:
|
||||
-e NodeFlare Worker 地址(必填)
|
||||
-t 后台生成的 Agent Token(必填,请勿泄露)
|
||||
-i 初始上报间隔,15-3600 秒(默认 60)
|
||||
EOF
|
||||
}
|
||||
|
||||
log() {
|
||||
printf '[NodeFlare] %s\n' "$1"
|
||||
}
|
||||
|
||||
fail() {
|
||||
printf '[NodeFlare] 错误:%s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
safe_value() {
|
||||
@@ -31,7 +50,7 @@ detect_init_system() {
|
||||
|
||||
ensure_curl() {
|
||||
command -v curl >/dev/null 2>&1 && return
|
||||
echo "curl not found; installing it..."
|
||||
log "未找到 curl,正在通过系统包管理器安装"
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
apt-get update
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y curl
|
||||
@@ -42,30 +61,29 @@ ensure_curl() {
|
||||
elif command -v apk >/dev/null 2>&1; then
|
||||
apk add --no-cache curl
|
||||
else
|
||||
echo "curl is required and no supported package manager was found" >&2
|
||||
exit 1
|
||||
fail "缺少 curl,且未找到受支持的包管理器"
|
||||
fi
|
||||
command -v curl >/dev/null 2>&1 || { echo "Failed to install curl" >&2; exit 1; }
|
||||
command -v curl >/dev/null 2>&1 || fail "curl 安装失败"
|
||||
}
|
||||
|
||||
explain_exec_failure() {
|
||||
target="$1"
|
||||
if [ ! -f "$target" ]; then
|
||||
echo "Downloaded Agent disappeared before validation: $target" >&2
|
||||
echo "下载文件在校验前丢失:$target" >&2
|
||||
elif [ ! -x "$target" ]; then
|
||||
echo "Downloaded Agent is not executable: $target" >&2
|
||||
echo "下载文件不可执行:$target" >&2
|
||||
elif [ "$(dd if="$target" bs=4 count=1 2>/dev/null)" != "$(printf '\177ELF')" ]; then
|
||||
echo "Downloaded asset is not a Linux ELF executable" >&2
|
||||
echo "下载文件不是有效的 Linux ELF 可执行文件" >&2
|
||||
else
|
||||
echo "Downloaded Agent could not run on this system." >&2
|
||||
echo "Use a release built with the current static Linux targets, then retry installation." >&2
|
||||
echo "下载的 Agent 无法在当前系统运行,请确认系统架构与 Release 文件匹配" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
install_agent() {
|
||||
[ "$(id -u)" -eq 0 ] || { echo "Run install as root" >&2; exit 1; }
|
||||
[ "$(id -u)" -eq 0 ] || fail "请使用 root 权限运行安装命令"
|
||||
log "正在检查运行环境"
|
||||
ensure_curl
|
||||
command -v sha256sum >/dev/null || { echo "sha256sum is required" >&2; exit 1; }
|
||||
command -v sha256sum >/dev/null || fail "缺少 sha256sum,无法校验下载文件"
|
||||
token=""
|
||||
endpoint=""
|
||||
interval=60
|
||||
@@ -74,46 +92,46 @@ install_agent() {
|
||||
option="$1"
|
||||
case "$option" in
|
||||
-t|-e|-i)
|
||||
[ "$#" -ge 2 ] || { echo "Missing value for $option" >&2; usage; exit 1; }
|
||||
[ "$#" -ge 2 ] || { echo "参数 $option 缺少值" >&2; usage; exit 1; }
|
||||
value="$2"
|
||||
shift 2
|
||||
;;
|
||||
*) echo "Unknown argument: $option" >&2; usage; exit 1 ;;
|
||||
*) echo "未知参数:$option" >&2; usage; exit 1 ;;
|
||||
esac
|
||||
case "$option" in
|
||||
-t) [ -z "$token" ] || { echo "Duplicate argument: $option" >&2; exit 1; }; token="$value" ;;
|
||||
-e) [ -z "$endpoint" ] || { echo "Duplicate argument: $option" >&2; exit 1; }; endpoint="$value" ;;
|
||||
-i) [ "$interval_set" = false ] || { echo "Duplicate argument: $option" >&2; exit 1; }; interval="$value"; interval_set=true ;;
|
||||
-t) [ -z "$token" ] || fail "参数 $option 重复"; token="$value" ;;
|
||||
-e) [ -z "$endpoint" ] || fail "参数 $option 重复"; endpoint="$value" ;;
|
||||
-i) [ "$interval_set" = false ] || fail "参数 $option 重复"; interval="$value"; interval_set=true ;;
|
||||
esac
|
||||
done
|
||||
[ -n "$token" ] && [ -n "$endpoint" ] || { usage; exit 1; }
|
||||
endpoint=${endpoint%/}
|
||||
[ ${#token} -le 512 ] && [ ${#endpoint} -le 2048 ] || { echo "Install argument is too long" >&2; exit 1; }
|
||||
safe_value "$token" && safe_value "$endpoint" || { echo "Invalid install argument" >&2; exit 1; }
|
||||
[ ${#token} -le 512 ] && [ ${#endpoint} -le 2048 ] || fail "安装参数长度超出限制"
|
||||
safe_value "$token" && safe_value "$endpoint" || fail "Worker 地址或 Agent Token 格式无效"
|
||||
case "$endpoint" in
|
||||
https://?*|http://localhost|http://localhost/*|http://localhost:*|http://127.0.0.1|http://127.0.0.1/*|http://127.0.0.1:*) ;;
|
||||
*) echo "Worker URL must use HTTPS; HTTP is only allowed for loopback development" >&2; exit 1 ;;
|
||||
*) fail "Worker 地址必须使用 HTTPS;仅本机调试可使用 HTTP" ;;
|
||||
esac
|
||||
case "$endpoint" in *@*) echo "Endpoint must not contain user information" >&2; exit 1 ;; esac
|
||||
case "$interval" in ''|*[!0-9]*) echo "Invalid interval" >&2; exit 1 ;; esac
|
||||
[ "$interval" -ge 15 ] && [ "$interval" -le 3600 ] || { echo "Interval must be between 15 and 3600 seconds" >&2; exit 1; }
|
||||
case "$(uname -m)" in x86_64|amd64) arch="x86_64" ;; aarch64|arm64) arch="aarch64" ;; *) echo "Unsupported architecture" >&2; exit 1 ;; esac
|
||||
case "$endpoint" in *@*) fail "Worker 地址不能包含用户信息" ;; esac
|
||||
case "$interval" in ''|*[!0-9]*) fail "上报间隔必须是整数" ;; esac
|
||||
[ "$interval" -ge 15 ] && [ "$interval" -le 3600 ] || fail "上报间隔必须在 15-3600 秒之间"
|
||||
case "$(uname -m)" in x86_64|amd64) arch="x86_64" ;; aarch64|arm64) arch="aarch64" ;; *) fail "暂不支持当前 CPU 架构:$(uname -m)" ;; esac
|
||||
init_system=$(detect_init_system)
|
||||
[ "$init_system" != "unknown" ] || { echo "A running systemd or OpenRC installation is required" >&2; exit 1; }
|
||||
[ "$init_system" != "unknown" ] || fail "未检测到正在运行的 systemd 或 OpenRC"
|
||||
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
temporary="$INSTALL_DIR/.agent.$$.download"
|
||||
trap 'rm -f "$temporary"' EXIT HUP INT TERM
|
||||
artifact="agent-linux-$arch"
|
||||
release_api="https://api.github.com/repos/imengying/NodeFlare/releases/latest"
|
||||
log "正在获取 GitHub 最新正式版本($artifact)"
|
||||
release_json=$(curl --fail --location --silent --show-error --max-time 30 \
|
||||
-H 'Accept: application/vnd.github+json' \
|
||||
-H 'User-Agent: nodeflare-installer' \
|
||||
"$release_api")
|
||||
release_tag=$(printf '%s\n' "$release_json" | tr ',' '\n' | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1)
|
||||
printf '%s\n' "$release_tag" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$' || {
|
||||
echo "GitHub latest release has an invalid tag: ${release_tag:-missing}" >&2
|
||||
exit 1
|
||||
fail "GitHub 最新 Release 标签无效:${release_tag:-未找到}"
|
||||
}
|
||||
expected=$(printf '%s\n' "$release_json" | tr '{' '\n' | awk -v name="$artifact" '
|
||||
{
|
||||
@@ -133,13 +151,15 @@ install_agent() {
|
||||
}
|
||||
}
|
||||
')
|
||||
[ -n "$expected" ] || { echo "GitHub release does not contain a SHA-256 digest for $artifact" >&2; exit 1; }
|
||||
[ -n "$expected" ] || fail "Release 缺少 $artifact 的 SHA-256 摘要"
|
||||
release_base="https://github.com/imengying/NodeFlare/releases/download/$release_tag"
|
||||
log "正在下载 NodeFlare Agent $release_tag"
|
||||
curl --fail --location --silent --show-error --max-time 120 \
|
||||
"$release_base/$artifact" \
|
||||
-o "$temporary"
|
||||
actual=$(sha256sum "$temporary" | awk '{ print $1 }')
|
||||
[ -n "$expected" ] && [ "$actual" = "$expected" ] || { echo "Agent checksum verification failed" >&2; exit 1; }
|
||||
[ -n "$expected" ] && [ "$actual" = "$expected" ] || fail "Agent SHA-256 校验失败,已停止安装"
|
||||
log "下载校验通过,正在验证可执行文件"
|
||||
chmod 755 "$temporary"
|
||||
if ! installed_version=$("$temporary" --version); then
|
||||
explain_exec_failure "$temporary"
|
||||
@@ -147,12 +167,10 @@ install_agent() {
|
||||
fi
|
||||
installed_version=${installed_version##* }
|
||||
printf '%s\n' "$installed_version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' || {
|
||||
echo "Downloaded Agent returned an invalid version: $installed_version" >&2
|
||||
exit 1
|
||||
fail "Agent 返回了无效版本号:$installed_version"
|
||||
}
|
||||
[ "$installed_version" = "${release_tag#v}" ] || {
|
||||
echo "Release $release_tag contains Agent version $installed_version; refusing to install it" >&2
|
||||
exit 1
|
||||
fail "Release $release_tag 与 Agent 版本 $installed_version 不一致"
|
||||
}
|
||||
case "$init_system" in
|
||||
systemd) systemctl stop "$SERVICE_NAME" 2>/dev/null || true ;;
|
||||
@@ -160,6 +178,7 @@ install_agent() {
|
||||
esac
|
||||
mv "$temporary" "$AGENT_FILE"
|
||||
trap - EXIT HUP INT TERM
|
||||
log "正在配置并启动 $init_system 服务"
|
||||
if [ "$init_system" = "systemd" ]; then
|
||||
printf '%s\n' \
|
||||
'[Unit]' \
|
||||
@@ -184,8 +203,7 @@ install_agent() {
|
||||
systemctl enable --now "$SERVICE_NAME"
|
||||
systemctl is-active --quiet "$SERVICE_NAME" || {
|
||||
systemctl status "$SERVICE_NAME" --no-pager >&2 || true
|
||||
echo "NodeFlare Agent service failed to start" >&2
|
||||
exit 1
|
||||
fail "NodeFlare 服务启动失败,请查看上方状态信息"
|
||||
}
|
||||
elif [ "$init_system" = "openrc" ]; then
|
||||
printf '%s\n' \
|
||||
@@ -202,11 +220,17 @@ install_agent() {
|
||||
rc-service "$SERVICE_NAME" restart
|
||||
rc-service "$SERVICE_NAME" status >/dev/null || {
|
||||
rc-service "$SERVICE_NAME" status >&2 || true
|
||||
echo "NodeFlare Agent service failed to start" >&2
|
||||
exit 1
|
||||
fail "NodeFlare 服务启动失败,请查看上方状态信息"
|
||||
}
|
||||
fi
|
||||
echo "NodeFlare Agent $installed_version installed with $init_system."
|
||||
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"
|
||||
else
|
||||
printf ' 查看状态:rc-service %s status\n' "$SERVICE_NAME"
|
||||
fi
|
||||
}
|
||||
|
||||
status_agent() {
|
||||
@@ -218,12 +242,13 @@ status_agent() {
|
||||
rc-service "$SERVICE_NAME" status
|
||||
return $?
|
||||
fi
|
||||
echo "NodeFlare agent service is not installed." >&2
|
||||
echo "未检测到 NodeFlare Agent 服务" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
uninstall_agent() {
|
||||
[ "$(id -u)" -eq 0 ] || { echo "Run uninstall as root" >&2; exit 1; }
|
||||
[ "$(id -u)" -eq 0 ] || fail "请使用 root 权限执行卸载"
|
||||
log "正在停止并移除 NodeFlare Agent"
|
||||
init_system=$(detect_init_system)
|
||||
case "$init_system" in
|
||||
systemd) systemctl disable --now "$SERVICE_NAME" 2>/dev/null || true ;;
|
||||
@@ -236,7 +261,7 @@ uninstall_agent() {
|
||||
rm -f "$OPENRC_FILE"
|
||||
[ "$init_system" != "systemd" ] || systemctl daemon-reload 2>/dev/null || true
|
||||
rm -rf "$INSTALL_DIR"
|
||||
echo "NodeFlare agent removed."
|
||||
echo "NodeFlare Agent 已卸载"
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
|
||||
+59
-29
@@ -6,35 +6,58 @@ INSTALL_DIR="/usr/local/libexec/nodeflare"
|
||||
AGENT_FILE="$INSTALL_DIR/agent"
|
||||
PLIST_FILE="/Library/LaunchDaemons/$LABEL.plist"
|
||||
|
||||
log() {
|
||||
printf '[NodeFlare] %s\n' "$1"
|
||||
}
|
||||
|
||||
fail() {
|
||||
printf '[NodeFlare] 错误:%s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
NodeFlare Agent macOS 安装脚本
|
||||
|
||||
用法:
|
||||
install-macos.sh -e <Worker URL> -t <Agent Token> [-i <上报间隔>]
|
||||
install-macos.sh --status
|
||||
install-macos.sh --uninstall
|
||||
|
||||
仅支持 Apple Silicon(arm64)。Agent Token 请勿泄露。
|
||||
EOF
|
||||
}
|
||||
|
||||
safe_value() {
|
||||
case "$1" in *[!A-Za-z0-9_./:@-]*|'') return 1 ;; esac
|
||||
}
|
||||
|
||||
if [ "${1:-}" = "--uninstall" ]; then
|
||||
[ "$#" -eq 1 ] || { echo "--uninstall does not accept additional arguments" >&2; exit 1; }
|
||||
[ "$(id -u)" -eq 0 ] || { echo "Run as root" >&2; exit 1; }
|
||||
[ "$#" -eq 1 ] || fail "--uninstall 不接受其它参数"
|
||||
[ "$(id -u)" -eq 0 ] || fail "请使用 root 权限执行卸载"
|
||||
log "正在停止并移除 NodeFlare Agent"
|
||||
launchctl bootout system "$PLIST_FILE" 2>/dev/null || true
|
||||
rm -f "$PLIST_FILE" "$AGENT_FILE"
|
||||
rmdir "$INSTALL_DIR" 2>/dev/null || true
|
||||
echo "NodeFlare Agent removed."
|
||||
echo "NodeFlare Agent 已卸载"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "${1:-}" = "--status" ]; then
|
||||
[ "$#" -eq 1 ] || { echo "--status does not accept additional arguments" >&2; exit 1; }
|
||||
[ "$#" -eq 1 ] || fail "--status 不接受其它参数"
|
||||
launchctl print "system/$LABEL"
|
||||
exit $?
|
||||
fi
|
||||
|
||||
[ "${1:-}" != "-h" ] && [ "${1:-}" != "--help" ] && [ "$#" -gt 0 ] || {
|
||||
echo "Usage: install-macos.sh -e ENDPOINT -t TOKEN [-i 60]"
|
||||
echo " install-macos.sh --uninstall|--status"
|
||||
usage
|
||||
exit 0
|
||||
}
|
||||
[ "$(id -u)" -eq 0 ] || { echo "Run as root" >&2; exit 1; }
|
||||
[ "$(uname -m)" = "arm64" ] || { echo "Only Apple Silicon (arm64) is supported" >&2; exit 1; }
|
||||
command -v curl >/dev/null || { echo "curl is required" >&2; exit 1; }
|
||||
command -v shasum >/dev/null || { echo "shasum is required" >&2; exit 1; }
|
||||
[ "$(id -u)" -eq 0 ] || fail "请使用 root 权限运行安装"
|
||||
[ "$(uname -m)" = "arm64" ] || fail "仅支持 Apple Silicon(arm64)"
|
||||
command -v curl >/dev/null || fail "缺少 curl"
|
||||
command -v shasum >/dev/null || fail "缺少 shasum,无法校验下载文件"
|
||||
log "正在检查运行环境"
|
||||
token=""
|
||||
endpoint=""
|
||||
interval=60
|
||||
@@ -43,41 +66,42 @@ while [ "$#" -gt 0 ]; do
|
||||
option="$1"
|
||||
case "$option" in
|
||||
-t|-e|-i)
|
||||
[ "$#" -ge 2 ] || { echo "Missing value for $option" >&2; exit 1; }
|
||||
[ "$#" -ge 2 ] || fail "参数 $option 缺少值"
|
||||
value="$2"
|
||||
shift 2
|
||||
;;
|
||||
*) echo "Unknown argument: $option" >&2; exit 1 ;;
|
||||
*) fail "未知参数:$option" ;;
|
||||
esac
|
||||
case "$option" in
|
||||
-t) [ -z "$token" ] || { echo "Duplicate argument: $option" >&2; exit 1; }; token="$value" ;;
|
||||
-e) [ -z "$endpoint" ] || { echo "Duplicate argument: $option" >&2; exit 1; }; endpoint="$value" ;;
|
||||
-i) [ "$interval_set" = false ] || { echo "Duplicate argument: $option" >&2; exit 1; }; interval="$value"; interval_set=true ;;
|
||||
-t) [ -z "$token" ] || fail "参数 $option 重复"; token="$value" ;;
|
||||
-e) [ -z "$endpoint" ] || fail "参数 $option 重复"; endpoint="$value" ;;
|
||||
-i) [ "$interval_set" = false ] || fail "参数 $option 重复"; interval="$value"; interval_set=true ;;
|
||||
esac
|
||||
done
|
||||
[ -n "$token" ] && [ -n "$endpoint" ] || { echo "Missing required argument" >&2; exit 1; }
|
||||
[ -n "$token" ] && [ -n "$endpoint" ] || { usage; exit 1; }
|
||||
endpoint=${endpoint%/}
|
||||
[ ${#token} -le 512 ] && [ ${#endpoint} -le 2048 ] || { echo "Install argument is too long" >&2; exit 1; }
|
||||
safe_value "$token" && safe_value "$endpoint" || { echo "Invalid argument" >&2; exit 1; }
|
||||
[ ${#token} -le 512 ] && [ ${#endpoint} -le 2048 ] || fail "安装参数长度超出限制"
|
||||
safe_value "$token" && safe_value "$endpoint" || fail "Worker 地址或 Agent Token 格式无效"
|
||||
case "$endpoint" in
|
||||
https://?*|http://localhost|http://localhost/*|http://localhost:*|http://127.0.0.1|http://127.0.0.1/*|http://127.0.0.1:*) ;;
|
||||
*) echo "Worker URL must use HTTPS; HTTP is only allowed for loopback development" >&2; exit 1 ;;
|
||||
*) fail "Worker 地址必须使用 HTTPS;仅本机调试可使用 HTTP" ;;
|
||||
esac
|
||||
case "$endpoint" in *@*) echo "Endpoint must not contain user information" >&2; exit 1 ;; esac
|
||||
case "$interval" in ''|*[!0-9]*) echo "Invalid interval" >&2; exit 1 ;; esac
|
||||
[ "$interval" -ge 15 ] && [ "$interval" -le 3600 ] || { echo "Interval must be between 15 and 3600 seconds" >&2; exit 1; }
|
||||
case "$endpoint" in *@*) fail "Worker 地址不能包含用户信息" ;; esac
|
||||
case "$interval" in ''|*[!0-9]*) fail "上报间隔必须是整数" ;; esac
|
||||
[ "$interval" -ge 15 ] && [ "$interval" -le 3600 ] || fail "上报间隔必须在 15-3600 秒之间"
|
||||
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
temporary="$INSTALL_DIR/.agent.$$.download"
|
||||
trap 'rm -f "$temporary"' EXIT HUP INT TERM
|
||||
artifact="agent-macos-aarch64"
|
||||
release_api="https://api.github.com/repos/imengying/NodeFlare/releases/latest"
|
||||
log "正在获取 GitHub 最新正式版本($artifact)"
|
||||
release_json=$(curl --fail --location --silent --show-error --max-time 30 \
|
||||
-H 'Accept: application/vnd.github+json' \
|
||||
-H 'User-Agent: nodeflare-installer' \
|
||||
"$release_api")
|
||||
release_tag=$(printf '%s\n' "$release_json" | tr ',' '\n' | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1)
|
||||
printf '%s\n' "$release_tag" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$' || { echo "GitHub latest release has an invalid tag" >&2; exit 1; }
|
||||
printf '%s\n' "$release_tag" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$' || fail "GitHub 最新 Release 标签无效:${release_tag:-未找到}"
|
||||
expected=$(printf '%s\n' "$release_json" | tr '{' '\n' | awk -v name="$artifact" '
|
||||
{
|
||||
compact = $0
|
||||
@@ -96,17 +120,20 @@ expected=$(printf '%s\n' "$release_json" | tr '{' '\n' | awk -v name="$artifact"
|
||||
}
|
||||
}
|
||||
')
|
||||
[ -n "$expected" ] || { echo "GitHub release does not contain a SHA-256 digest for $artifact" >&2; exit 1; }
|
||||
[ -n "$expected" ] || fail "Release 缺少 $artifact 的 SHA-256 摘要"
|
||||
release_base="https://github.com/imengying/NodeFlare/releases/download/$release_tag"
|
||||
log "正在下载 NodeFlare Agent $release_tag"
|
||||
curl --fail --location --silent --show-error --max-time 120 \
|
||||
"$release_base/$artifact" \
|
||||
-o "$temporary"
|
||||
actual=$(shasum -a 256 "$temporary" | awk '{ print $1 }')
|
||||
[ -n "$expected" ] && [ "$actual" = "$expected" ] || { echo "Agent checksum verification failed" >&2; exit 1; }
|
||||
[ -n "$expected" ] && [ "$actual" = "$expected" ] || fail "Agent SHA-256 校验失败,已停止安装"
|
||||
chmod 755 "$temporary"
|
||||
installed_version=$("$temporary" --version) || { echo "Downloaded Agent cannot run on this macOS system" >&2; exit 1; }
|
||||
log "下载校验通过,正在验证可执行文件"
|
||||
installed_version=$("$temporary" --version) || fail "下载的 Agent 无法在当前 macOS 运行"
|
||||
installed_version=${installed_version##* }
|
||||
[ "$installed_version" = "${release_tag#v}" ] || { echo "Release $release_tag contains Agent version $installed_version" >&2; exit 1; }
|
||||
[ "$installed_version" = "${release_tag#v}" ] || fail "Release $release_tag 与 Agent 版本 $installed_version 不一致"
|
||||
log "正在配置并启动 macOS LaunchDaemon 服务"
|
||||
launchctl bootout system "$PLIST_FILE" 2>/dev/null || true
|
||||
mv "$temporary" "$AGENT_FILE"
|
||||
trap - EXIT HUP INT TERM
|
||||
@@ -131,5 +158,8 @@ for _ in 1 2 3 4 5 6 7 8 9 10; do
|
||||
break
|
||||
fi
|
||||
done
|
||||
[ "$started" = true ] || { echo "NodeFlare Agent failed to start; inspect /var/log/nodeflare-agent.log" >&2; exit 1; }
|
||||
echo "NodeFlare Agent $installed_version installed and started."
|
||||
[ "$started" = true ] || fail "NodeFlare 服务启动失败,请查看 /var/log/nodeflare-agent.log"
|
||||
printf '\nNodeFlare Agent 安装完成\n'
|
||||
printf ' 版本:%s\n' "$installed_version"
|
||||
printf ' 服务:%s(LaunchDaemon)\n' "$LABEL"
|
||||
printf ' 查看状态:sudo launchctl print system/%s\n' "$LABEL"
|
||||
+32
-14
@@ -12,9 +12,17 @@ $TaskName = "NodeFlare Agent"
|
||||
$InstallDir = Join-Path $env:ProgramData "NodeFlare"
|
||||
$AgentFile = Join-Path $InstallDir "nodeflare-agent.exe"
|
||||
|
||||
function Write-Step([string]$Message) {
|
||||
Write-Host "[NodeFlare] $Message"
|
||||
}
|
||||
|
||||
function Write-InstallError([string]$Message) {
|
||||
throw "[NodeFlare] 错误:$Message"
|
||||
}
|
||||
|
||||
function Assert-Safe([string]$Name, [string]$Value) {
|
||||
if ([string]::IsNullOrWhiteSpace($Value) -or $Value -notmatch '^[A-Za-z0-9_./:@-]+$') {
|
||||
throw "Invalid $Name"
|
||||
Write-InstallError "$Name 格式无效"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,22 +43,23 @@ function Assert-Endpoint([string]$Value) {
|
||||
-not [string]::IsNullOrEmpty($Parsed.Query) -or
|
||||
-not [string]::IsNullOrEmpty($Parsed.Fragment)
|
||||
) {
|
||||
throw "Endpoint must use HTTPS; HTTP is only allowed for loopback development"
|
||||
Write-InstallError "Worker 地址必须使用 HTTPS;仅本机调试可使用 HTTP"
|
||||
}
|
||||
}
|
||||
|
||||
if ($Uninstall) {
|
||||
Write-Step "正在停止并移除 NodeFlare Agent"
|
||||
Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
|
||||
Remove-Item -LiteralPath $InstallDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Write-Host "NodeFlare Agent removed."
|
||||
Write-Host "NodeFlare Agent 已卸载"
|
||||
exit 0
|
||||
}
|
||||
|
||||
if ($Status) {
|
||||
$Task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||
if ($null -eq $Task) {
|
||||
Write-Error "NodeFlare Agent is not installed."
|
||||
Write-Error "未检测到 NodeFlare Agent 服务"
|
||||
exit 1
|
||||
}
|
||||
$Task
|
||||
@@ -58,17 +67,18 @@ if ($Status) {
|
||||
}
|
||||
|
||||
if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
||||
throw "Run PowerShell as Administrator"
|
||||
Write-InstallError "请使用管理员身份运行 PowerShell"
|
||||
}
|
||||
$NativeArchitecture = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW6432 } else { $env:PROCESSOR_ARCHITECTURE }
|
||||
if ($NativeArchitecture -ne "AMD64") {
|
||||
throw "Only Windows x86_64 is supported"
|
||||
Write-InstallError "仅支持 Windows x86_64"
|
||||
}
|
||||
Write-Step "正在检查运行环境"
|
||||
Assert-Safe "Token" $Token
|
||||
Assert-Endpoint $Endpoint
|
||||
$TokenLength = $Token.Length
|
||||
if ($TokenLength -gt 512) {
|
||||
throw "Install argument is too long"
|
||||
Write-InstallError "安装参数长度超出限制"
|
||||
}
|
||||
$Endpoint = $Endpoint.TrimEnd('/')
|
||||
|
||||
@@ -78,30 +88,33 @@ $Temporary = "$AgentFile.$PID.download.exe"
|
||||
$ReleaseApi = "https://api.github.com/repos/imengying/NodeFlare/releases/latest"
|
||||
$Artifact = "agent-windows-x86_64.exe"
|
||||
try {
|
||||
Write-Step "正在获取 GitHub 最新正式版本($Artifact)"
|
||||
$Release = Invoke-RestMethod -Uri $ReleaseApi -Headers @{ Accept = "application/vnd.github+json"; "User-Agent" = "nodeflare-installer" } -TimeoutSec 30
|
||||
$ReleaseAsset = $Release.assets | Where-Object { $_.name -eq $Artifact } | Select-Object -First 1
|
||||
if ($Release.tag_name -notmatch '^v[0-9]+\.[0-9]+\.[0-9]+$' -or $null -eq $ReleaseAsset) {
|
||||
throw "GitHub latest release is invalid or does not contain $Artifact"
|
||||
Write-InstallError "GitHub 最新 Release 无效,或缺少 $Artifact"
|
||||
}
|
||||
$DigestMatch = [regex]::Match([string]$ReleaseAsset.digest, '^sha256:([0-9a-fA-F]{64})$')
|
||||
if (-not $DigestMatch.Success) {
|
||||
throw "GitHub release does not contain a SHA-256 digest for $Artifact"
|
||||
Write-InstallError "Release 缺少 $Artifact 的 SHA-256 摘要"
|
||||
}
|
||||
$ExpectedChecksum = $DigestMatch.Groups[1].Value
|
||||
$DownloadUrl = "https://github.com/imengying/NodeFlare/releases/download/$($Release.tag_name)/$Artifact"
|
||||
Write-Step "正在下载 NodeFlare Agent $($Release.tag_name)"
|
||||
Invoke-WebRequest -Uri $DownloadUrl -OutFile $Temporary -TimeoutSec 120
|
||||
$ActualChecksum = (Get-FileHash -LiteralPath $Temporary -Algorithm SHA256).Hash
|
||||
if ($ActualChecksum -ne $ExpectedChecksum) {
|
||||
throw "Agent checksum verification failed"
|
||||
Write-InstallError "Agent SHA-256 校验失败,已停止安装"
|
||||
}
|
||||
Write-Step "下载校验通过,正在验证可执行文件"
|
||||
$InstalledVersion = (& $Temporary --version | Out-String).Trim()
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Downloaded Agent failed its version check"
|
||||
Write-InstallError "下载的 Agent 无法在当前 Windows 运行"
|
||||
}
|
||||
$InstalledVersion = ($InstalledVersion -split '\s+')[-1]
|
||||
$ExpectedVersion = $Release.tag_name.Substring(1)
|
||||
if ($InstalledVersion -ne $ExpectedVersion) {
|
||||
throw "Release $($Release.tag_name) contains Agent version $InstalledVersion"
|
||||
Write-InstallError "Release $($Release.tag_name) 与 Agent 版本 $InstalledVersion 不一致"
|
||||
}
|
||||
Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||
Move-Item -LiteralPath $Temporary -Destination $AgentFile -Force
|
||||
@@ -110,6 +123,7 @@ try {
|
||||
}
|
||||
|
||||
$TaskArguments = "-e $Endpoint -t $Token -i $Interval"
|
||||
Write-Step "正在注册并启动 Windows 计划任务"
|
||||
$TaskAction = New-ScheduledTaskAction -Execute $AgentFile -Argument $TaskArguments
|
||||
$Trigger = New-ScheduledTaskTrigger -AtStartup
|
||||
$Settings = New-ScheduledTaskSettingsSet -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit (New-TimeSpan -Days 3650)
|
||||
@@ -123,6 +137,10 @@ for ($Attempt = 0; $Attempt -lt 10; $Attempt++) {
|
||||
if ($Task.State -eq "Running") { break }
|
||||
}
|
||||
if ($Task.State -ne "Running") {
|
||||
throw "NodeFlare Agent scheduled task failed to start (state: $($Task.State))"
|
||||
Write-InstallError "NodeFlare 服务启动失败(状态:$($Task.State))"
|
||||
}
|
||||
Write-Host "NodeFlare Agent $InstalledVersion installed and started."
|
||||
Write-Host ""
|
||||
Write-Host "NodeFlare Agent 安装完成"
|
||||
Write-Host " 版本:$InstalledVersion"
|
||||
Write-Host " 服务:$TaskName(Windows 计划任务)"
|
||||
Write-Host " 查看状态:Get-ScheduledTask -TaskName '$TaskName'"
|
||||
+508
-15
@@ -1,7 +1,7 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::io::{self, Read, Write};
|
||||
use std::io::{self, ErrorKind, Read, Write};
|
||||
use std::net::{IpAddr, SocketAddr, TcpStream, ToSocketAddrs};
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
@@ -11,7 +11,7 @@ use std::path::Path;
|
||||
use std::process::Command;
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::process::Stdio;
|
||||
use std::sync::{mpsc, Arc, Mutex};
|
||||
use std::sync::{mpsc, Arc, Condvar, Mutex};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
@@ -20,6 +20,8 @@ use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
use sysinfo::{Disks, Networks, System};
|
||||
use tungstenite::client::IntoClientRequest;
|
||||
use tungstenite::{connect, Error as WebSocketError, Message};
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
|
||||
compile_error!("nodeflare-agent supports Linux, Windows, and macOS");
|
||||
@@ -40,6 +42,16 @@ const REPORT_RETRY_MIN: Duration = Duration::from_secs(5);
|
||||
const REPORT_RETRY_MAX: Duration = Duration::from_secs(300);
|
||||
const UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(6 * 60 * 60);
|
||||
const UPDATE_RETRY_INTERVAL: Duration = Duration::from_secs(30 * 60);
|
||||
const LIVE_RECONNECT_DELAY: Duration = Duration::from_secs(3);
|
||||
const LIVE_QUEUE_CAPACITY: usize = 720;
|
||||
// Keep each WebSocket frame comfortably below the Worker 1 MiB validation limit;
|
||||
// the queue preserves the remaining samples for the next frame.
|
||||
const LIVE_BATCH_CAPACITY: usize = 32;
|
||||
// D1 persistence can take a few hundred milliseconds; allow the ACK to arrive
|
||||
// before falling back to the configured interval.
|
||||
const LIVE_ACK_READ_TIMEOUT: Duration = Duration::from_millis(1_000);
|
||||
const CLOCK_CALIBRATION_MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
const CLOCK_CALIBRATION_MIN_CHANGE_MS: i64 = 20_000;
|
||||
|
||||
type Error = Box<dyn std::error::Error + Send + Sync>;
|
||||
type Result<T> = std::result::Result<T, Error>;
|
||||
@@ -187,6 +199,75 @@ struct GithubReleaseAsset {
|
||||
struct SubmitResult {
|
||||
config: Option<RemoteConfig>,
|
||||
config_hash: String,
|
||||
clock_offset_ms: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct ClockCalibration {
|
||||
offset_ms: i64,
|
||||
calibrated_at: Option<Instant>,
|
||||
}
|
||||
|
||||
impl ClockCalibration {
|
||||
fn observe(&mut self, offset_ms: i64) -> bool {
|
||||
let should_update = self.calibrated_at.is_none()
|
||||
|| self
|
||||
.calibrated_at
|
||||
.is_some_and(|at| at.elapsed() >= CLOCK_CALIBRATION_MAX_AGE)
|
||||
|| self.offset_ms.abs_diff(offset_ms) >= CLOCK_CALIBRATION_MIN_CHANGE_MS as u64;
|
||||
if should_update {
|
||||
self.offset_ms = offset_ms;
|
||||
self.calibrated_at = Some(Instant::now());
|
||||
}
|
||||
should_update
|
||||
}
|
||||
}
|
||||
|
||||
type SharedClock = Arc<Mutex<ClockCalibration>>;
|
||||
|
||||
struct LiveSender {
|
||||
pending: Arc<(Mutex<Vec<Report>>, Condvar)>,
|
||||
report_interval: Arc<Mutex<Duration>>,
|
||||
}
|
||||
|
||||
impl LiveSender {
|
||||
fn start(config: &RuntimeConfig, clock: SharedClock) -> Result<Self> {
|
||||
let pending = Arc::new((Mutex::new(Vec::new()), Condvar::new()));
|
||||
let report_interval = Arc::new(Mutex::new(Duration::from_secs(
|
||||
config.report_interval.clamp(15, 3600),
|
||||
)));
|
||||
let endpoint = live_endpoint(&config.endpoint)?;
|
||||
let token = config.token.clone();
|
||||
let sender_state = Arc::clone(&pending);
|
||||
let sender_interval = Arc::clone(&report_interval);
|
||||
thread::Builder::new()
|
||||
.name("nodeflare-live".to_string())
|
||||
.spawn(move || {
|
||||
live_sender_loop(&endpoint, &token, sender_state, sender_interval, clock)
|
||||
})?;
|
||||
Ok(Self {
|
||||
pending,
|
||||
report_interval,
|
||||
})
|
||||
}
|
||||
|
||||
fn send(&self, report: &Report) {
|
||||
let (pending, ready) = &*self.pending;
|
||||
if let Ok(mut pending) = pending.lock() {
|
||||
if pending.len() >= LIVE_QUEUE_CAPACITY {
|
||||
pending.remove(0);
|
||||
}
|
||||
pending.push(report.clone());
|
||||
ready.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
fn set_report_interval(&self, seconds: u64) {
|
||||
if let Ok(mut interval) = self.report_interval.lock() {
|
||||
*interval = Duration::from_secs(seconds.clamp(15, 3600));
|
||||
}
|
||||
self.pending.1.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -216,7 +297,7 @@ struct GpuMetric {
|
||||
memory_total: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize)]
|
||||
#[derive(Debug, Default, Clone, Serialize)]
|
||||
struct Report {
|
||||
timestamp: i64,
|
||||
cpu: f64,
|
||||
@@ -675,6 +756,52 @@ fn unix_timestamp() -> i64 {
|
||||
.as_secs() as i64
|
||||
}
|
||||
|
||||
fn unix_timestamp_millis() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis()
|
||||
.min(i64::MAX as u128) as i64
|
||||
}
|
||||
|
||||
fn clock_offset_from_http_date(value: &str, started_ms: i64, ended_ms: i64) -> Option<i64> {
|
||||
let server_ms: i64 = httpdate::parse_http_date(value)
|
||||
.ok()?
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.ok()?
|
||||
.as_millis()
|
||||
.try_into()
|
||||
.ok()?;
|
||||
let midpoint_ms = started_ms.saturating_add(ended_ms.saturating_sub(started_ms) / 2);
|
||||
Some(server_ms.saturating_sub(midpoint_ms))
|
||||
}
|
||||
|
||||
fn clock_offset_from_server_seconds(value: &str, started_ms: i64, ended_ms: i64) -> Option<i64> {
|
||||
let server_ms = value.trim().parse::<i64>().ok()?.saturating_mul(1_000);
|
||||
let midpoint_ms = started_ms.saturating_add(ended_ms.saturating_sub(started_ms) / 2);
|
||||
Some(server_ms.saturating_sub(midpoint_ms))
|
||||
}
|
||||
|
||||
fn corrected_timestamp(timestamp: i64, offset_ms: i64) -> i64 {
|
||||
timestamp
|
||||
.saturating_mul(1_000)
|
||||
.saturating_add(offset_ms)
|
||||
.div_euclid(1_000)
|
||||
}
|
||||
|
||||
fn shared_clock_offset(clock: &SharedClock) -> i64 {
|
||||
clock.lock().map_or(0, |calibration| calibration.offset_ms)
|
||||
}
|
||||
|
||||
fn observe_clock(clock: &SharedClock, offset_ms: Option<i64>) {
|
||||
let Some(offset_ms) = offset_ms else {
|
||||
return;
|
||||
};
|
||||
if let Ok(mut calibration) = clock.lock() {
|
||||
calibration.observe(offset_ms);
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_latency_task(task: LatencyTask) -> LatencyResult {
|
||||
let (latency_ms, packet_loss) = match task.task_type.as_str() {
|
||||
"tcp" => tcp_latency_probe(&task.target, task.port),
|
||||
@@ -894,7 +1021,7 @@ fn u64_to_i64(value: u64) -> i64 {
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn collect(config: &RuntimeConfig, latency_results: Vec<LatencyResult>) -> Report {
|
||||
fn collect(config: &RuntimeConfig, latency_results: Vec<LatencyResult>, timestamp: i64) -> Report {
|
||||
let cpu_before = cpu_sample();
|
||||
let io_before = io_sample(&config.network_interface);
|
||||
thread::sleep(Duration::from_secs(1));
|
||||
@@ -955,7 +1082,7 @@ fn collect(config: &RuntimeConfig, latency_results: Vec<LatencyResult>) -> Repor
|
||||
.saturating_add(io_after.write_millis.saturating_sub(io_before.write_millis));
|
||||
|
||||
Report {
|
||||
timestamp: unix_timestamp(),
|
||||
timestamp,
|
||||
cpu,
|
||||
load1: loads.first().copied().unwrap_or(0.0),
|
||||
load5: loads.get(1).copied().unwrap_or(0.0),
|
||||
@@ -1009,7 +1136,7 @@ fn collect(config: &RuntimeConfig, latency_results: Vec<LatencyResult>) -> Repor
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
fn collect(config: &RuntimeConfig, latency_results: Vec<LatencyResult>) -> Report {
|
||||
fn collect(config: &RuntimeConfig, latency_results: Vec<LatencyResult>, timestamp: i64) -> Report {
|
||||
let mut system = System::new_all();
|
||||
let mut networks = Networks::new_with_refreshed_list();
|
||||
thread::sleep(Duration::from_secs(1));
|
||||
@@ -1066,7 +1193,7 @@ fn collect(config: &RuntimeConfig, latency_results: Vec<LatencyResult>) -> Repor
|
||||
let (tcp_connections, udp_connections) = connection_counts();
|
||||
|
||||
Report {
|
||||
timestamp: unix_timestamp(),
|
||||
timestamp,
|
||||
cpu: system.global_cpu_usage() as f64,
|
||||
load1: load.one,
|
||||
load5: load.five,
|
||||
@@ -1143,12 +1270,26 @@ fn submit(
|
||||
config_hash: &str,
|
||||
) -> Result<SubmitResult> {
|
||||
let batch = ReportBatch { samples: reports };
|
||||
let started_ms = unix_timestamp_millis();
|
||||
let mut response = agent
|
||||
.post(&format!("{}/api/agent/report", config.endpoint))
|
||||
.header("Authorization", format!("Bearer {}", config.token))
|
||||
.header("User-Agent", format!("nodeflare-agent/{VERSION}"))
|
||||
.header("X-Agent-Config-Sha256", config_hash)
|
||||
.send_json(batch)?;
|
||||
let ended_ms = unix_timestamp_millis();
|
||||
let clock_offset_ms = response
|
||||
.headers()
|
||||
.get("x-nodeflare-server-time")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| clock_offset_from_server_seconds(value, started_ms, ended_ms))
|
||||
.or_else(|| {
|
||||
response
|
||||
.headers()
|
||||
.get("date")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| clock_offset_from_http_date(value, started_ms, ended_ms))
|
||||
});
|
||||
let next_hash = response
|
||||
.headers()
|
||||
.get("X-Agent-Config-Sha256")
|
||||
@@ -1163,9 +1304,253 @@ fn submit(
|
||||
Ok(SubmitResult {
|
||||
config: remote,
|
||||
config_hash: next_hash,
|
||||
clock_offset_ms,
|
||||
})
|
||||
}
|
||||
|
||||
fn fetch_clock_offset(agent: &ureq::Agent, endpoint: &str) -> Result<Option<i64>> {
|
||||
let started_ms = unix_timestamp_millis();
|
||||
let response = agent
|
||||
.get(&format!("{endpoint}/api/config"))
|
||||
.header("User-Agent", format!("nodeflare-agent/{VERSION}"))
|
||||
.call()?;
|
||||
let ended_ms = unix_timestamp_millis();
|
||||
Ok(response
|
||||
.headers()
|
||||
.get("x-nodeflare-server-time")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| clock_offset_from_server_seconds(value, started_ms, ended_ms))
|
||||
.or_else(|| {
|
||||
response
|
||||
.headers()
|
||||
.get("date")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| clock_offset_from_http_date(value, started_ms, ended_ms))
|
||||
}))
|
||||
}
|
||||
|
||||
fn live_endpoint(endpoint: &str) -> Result<String> {
|
||||
let mut url = url::Url::parse(endpoint)?;
|
||||
url.set_scheme(if url.scheme() == "https" { "wss" } else { "ws" })
|
||||
.map_err(|_| "unsupported live endpoint scheme")?;
|
||||
let path = format!("{}/api/agent/live", url.path().trim_end_matches('/'));
|
||||
url.set_path(&path);
|
||||
Ok(url.to_string())
|
||||
}
|
||||
|
||||
type LiveSocket = tungstenite::WebSocket<tungstenite::stream::MaybeTlsStream<TcpStream>>;
|
||||
|
||||
fn connect_live(endpoint: &str, token: &str) -> Result<(LiveSocket, Option<i64>)> {
|
||||
let mut request = endpoint.into_client_request()?;
|
||||
request
|
||||
.headers_mut()
|
||||
.insert("Authorization", format!("Bearer {token}").parse()?);
|
||||
request
|
||||
.headers_mut()
|
||||
.insert("User-Agent", format!("nodeflare-agent/{VERSION}").parse()?);
|
||||
let started_ms = unix_timestamp_millis();
|
||||
let (socket, response) = connect(request)?;
|
||||
let ended_ms = unix_timestamp_millis();
|
||||
let clock_offset_ms = response
|
||||
.headers()
|
||||
.get("date")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| clock_offset_from_http_date(value, started_ms, ended_ms));
|
||||
Ok((socket, clock_offset_ms))
|
||||
}
|
||||
|
||||
fn set_live_read_timeout(socket: &mut LiveSocket, timeout: Option<Duration>) -> io::Result<()> {
|
||||
match socket.get_mut() {
|
||||
tungstenite::stream::MaybeTlsStream::Plain(stream) => stream.set_read_timeout(timeout),
|
||||
tungstenite::stream::MaybeTlsStream::Rustls(stream) => {
|
||||
stream.sock.set_read_timeout(timeout)
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct LiveAck {
|
||||
#[serde(rename = "type")]
|
||||
message_type: String,
|
||||
ts: i64,
|
||||
persisted: bool,
|
||||
#[serde(rename = "nextD1WriteAfterMs")]
|
||||
next_d1_write_after_ms: u64,
|
||||
#[serde(rename = "nextWssReportAfterMs")]
|
||||
next_wss_report_after_ms: Option<u64>,
|
||||
}
|
||||
|
||||
fn ack_interval(ack: &LiveAck, fallback: Duration) -> Duration {
|
||||
ack.next_wss_report_after_ms
|
||||
.map(Duration::from_millis)
|
||||
.map(|interval| interval.clamp(Duration::from_secs(2), Duration::from_secs(3600)))
|
||||
.unwrap_or(fallback)
|
||||
}
|
||||
|
||||
fn read_live_ack(socket: &mut LiveSocket, current_interval: Duration) -> Result<Option<Duration>> {
|
||||
match socket.read() {
|
||||
Ok(Message::Text(text)) => {
|
||||
let Ok(ack) = serde_json::from_str::<LiveAck>(text.as_ref()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if ack.message_type != "ack" || ack.ts <= 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let _ = (ack.persisted, ack.next_d1_write_after_ms);
|
||||
Ok(Some(ack_interval(&ack, current_interval)))
|
||||
}
|
||||
Ok(Message::Ping(payload)) => {
|
||||
socket.send(Message::Pong(payload))?;
|
||||
Ok(None)
|
||||
}
|
||||
Ok(Message::Close(_)) => Ok(Some(Duration::ZERO)),
|
||||
Ok(_) => Ok(None),
|
||||
Err(WebSocketError::Io(error))
|
||||
if matches!(error.kind(), ErrorKind::TimedOut | ErrorKind::WouldBlock) =>
|
||||
{
|
||||
Ok(None)
|
||||
}
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn live_update_payload(reports: &[Report]) -> Result<String> {
|
||||
Ok(serde_json::to_string(&serde_json::json!({
|
||||
"type": "update",
|
||||
"samples": reports,
|
||||
}))?)
|
||||
}
|
||||
|
||||
fn requeue_reports(pending: &Arc<(Mutex<Vec<Report>>, Condvar)>, reports: Vec<Report>) {
|
||||
if reports.is_empty() {
|
||||
return;
|
||||
}
|
||||
let (queue, ready) = &**pending;
|
||||
if let Ok(mut queue) = queue.lock() {
|
||||
let mut merged = reports;
|
||||
merged.append(&mut queue);
|
||||
if merged.len() > LIVE_QUEUE_CAPACITY {
|
||||
merged.truncate(LIVE_QUEUE_CAPACITY);
|
||||
}
|
||||
*queue = merged;
|
||||
ready.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
fn live_sender_loop(
|
||||
endpoint: &str,
|
||||
token: &str,
|
||||
pending: Arc<(Mutex<Vec<Report>>, Condvar)>,
|
||||
configured_interval: Arc<Mutex<Duration>>,
|
||||
clock: SharedClock,
|
||||
) {
|
||||
let mut socket: Option<LiveSocket> = None;
|
||||
let mut send_interval = configured_interval
|
||||
.lock()
|
||||
.map(|interval| *interval)
|
||||
.unwrap_or(Duration::from_secs(60));
|
||||
let mut next_send_at = Instant::now();
|
||||
loop {
|
||||
let (queue, ready) = &*pending;
|
||||
let mut queue = match queue.lock() {
|
||||
Ok(queue) => queue,
|
||||
Err(_) => return,
|
||||
};
|
||||
while queue.is_empty() || Instant::now() < next_send_at {
|
||||
let wait = if queue.is_empty() {
|
||||
Duration::from_millis(250)
|
||||
} else {
|
||||
next_send_at
|
||||
.saturating_duration_since(Instant::now())
|
||||
.min(Duration::from_millis(250))
|
||||
};
|
||||
queue = match ready.wait_timeout(queue, wait) {
|
||||
Ok((queue, _)) => queue,
|
||||
Err(_) => return,
|
||||
};
|
||||
}
|
||||
let count = queue.len().min(LIVE_BATCH_CAPACITY);
|
||||
let batch = queue.drain(..count).collect::<Vec<_>>();
|
||||
drop(queue);
|
||||
|
||||
if let Ok(interval) = configured_interval.lock() {
|
||||
send_interval = (*interval).min(Duration::from_secs(3600));
|
||||
}
|
||||
if socket.is_none() {
|
||||
match connect_live(endpoint, token) {
|
||||
Ok((mut connected, offset_ms)) => {
|
||||
observe_clock(&clock, offset_ms);
|
||||
if set_live_read_timeout(&mut connected, Some(LIVE_ACK_READ_TIMEOUT)).is_err() {
|
||||
socket = None;
|
||||
thread::sleep(LIVE_RECONNECT_DELAY);
|
||||
requeue_reports(&pending, batch);
|
||||
continue;
|
||||
}
|
||||
socket = Some(connected);
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!("live connection failed: {error}");
|
||||
requeue_reports(&pending, batch);
|
||||
thread::sleep(LIVE_RECONNECT_DELAY);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let payload = match live_update_payload(&batch) {
|
||||
Ok(payload) => payload,
|
||||
Err(error) => {
|
||||
eprintln!("live payload encode failed: {error}");
|
||||
requeue_reports(&pending, batch);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let sent = socket
|
||||
.as_mut()
|
||||
.is_some_and(|socket| socket.send(Message::Text(payload.clone().into())).is_ok());
|
||||
if !sent {
|
||||
socket = None;
|
||||
thread::sleep(LIVE_RECONNECT_DELAY);
|
||||
let mut resent = false;
|
||||
if let Ok((mut connected, offset_ms)) = connect_live(endpoint, token) {
|
||||
observe_clock(&clock, offset_ms);
|
||||
if set_live_read_timeout(&mut connected, Some(LIVE_ACK_READ_TIMEOUT)).is_ok()
|
||||
&& connected.send(Message::Text(payload.into())).is_ok()
|
||||
{
|
||||
socket = Some(connected);
|
||||
resent = true;
|
||||
}
|
||||
}
|
||||
if !resent {
|
||||
requeue_reports(&pending, batch);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
next_send_at = Instant::now() + send_interval;
|
||||
let mut drop_socket = false;
|
||||
if let Some(socket) = socket.as_mut() {
|
||||
match read_live_ack(socket, send_interval) {
|
||||
Ok(Some(Duration::ZERO)) => drop_socket = true,
|
||||
Ok(Some(interval)) => {
|
||||
send_interval = interval;
|
||||
next_send_at = Instant::now() + send_interval;
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(error) => {
|
||||
eprintln!("live ACK read failed: {error}");
|
||||
drop_socket = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if drop_socket {
|
||||
socket = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_remote(config: &mut RuntimeConfig, remote: &RemoteConfig) -> bool {
|
||||
if (15..=3600).contains(&remote.report_interval) {
|
||||
config.report_interval = remote.report_interval;
|
||||
@@ -1480,6 +1865,16 @@ fn run(options: &CliOptions, once: bool, print_only: bool) -> Result<()> {
|
||||
let mut report_failures = 0_u32;
|
||||
let mut next_update_check = Instant::now();
|
||||
let mut config_hash = String::new();
|
||||
let clock = Arc::new(Mutex::new(ClockCalibration::default()));
|
||||
if !print_only {
|
||||
match fetch_clock_offset(&agent, &config.endpoint) {
|
||||
Ok(offset_ms) => observe_clock(&clock, offset_ms),
|
||||
Err(error) => eprintln!("server time calibration failed: {error}"),
|
||||
}
|
||||
}
|
||||
let live = (!once && !print_only)
|
||||
.then(|| LiveSender::start(&config, Arc::clone(&clock)))
|
||||
.transpose()?;
|
||||
loop {
|
||||
pending_results.extend(latency_executor.drain());
|
||||
let current = Instant::now();
|
||||
@@ -1505,17 +1900,26 @@ fn run(options: &CliOptions, once: bool, print_only: bool) -> Result<()> {
|
||||
}
|
||||
|
||||
if Instant::now() >= next_collect {
|
||||
let offset_ms = shared_clock_offset(&clock);
|
||||
let mut latest_results = HashMap::new();
|
||||
for result in std::mem::take(&mut pending_results) {
|
||||
for mut result in std::mem::take(&mut pending_results) {
|
||||
result.timestamp = corrected_timestamp(result.timestamp, offset_ms);
|
||||
latest_results.insert(result.task_id.clone(), result);
|
||||
}
|
||||
let report = collect(&config, latest_results.into_values().collect());
|
||||
let mut report = collect(&config, latest_results.into_values().collect(), 0);
|
||||
report.timestamp = corrected_timestamp(unix_timestamp(), offset_ms);
|
||||
if print_only {
|
||||
println!("{}", serde_json::to_string_pretty(&report)?);
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(live) = &live {
|
||||
live.send(&report);
|
||||
}
|
||||
pending_samples.push(report);
|
||||
prune_report_samples(&mut pending_samples, unix_timestamp());
|
||||
prune_report_samples(
|
||||
&mut pending_samples,
|
||||
corrected_timestamp(unix_timestamp(), offset_ms),
|
||||
);
|
||||
if pending_samples.len() > 720 {
|
||||
let overflow = pending_samples.len() - 720;
|
||||
pending_samples.drain(..overflow);
|
||||
@@ -1526,6 +1930,7 @@ fn run(options: &CliOptions, once: bool, print_only: bool) -> Result<()> {
|
||||
if Instant::now() >= next_report && !pending_samples.is_empty() {
|
||||
match submit(&agent, &config, &pending_samples, &config_hash) {
|
||||
Ok(result) => {
|
||||
observe_clock(&clock, result.clock_offset_ms);
|
||||
pending_samples.clear();
|
||||
report_failures = 0;
|
||||
config_hash = result.config_hash;
|
||||
@@ -1533,6 +1938,9 @@ fn run(options: &CliOptions, once: bool, print_only: bool) -> Result<()> {
|
||||
if apply_remote(&mut config, &remote) {
|
||||
next_latency.clear();
|
||||
}
|
||||
if let Some(live) = &live {
|
||||
live.set_report_interval(config.report_interval);
|
||||
}
|
||||
}
|
||||
if !once && config.auto_update && Instant::now() >= next_update_check {
|
||||
match update(&agent) {
|
||||
@@ -1592,15 +2000,17 @@ mod tests {
|
||||
use clap::Parser;
|
||||
use std::net::TcpListener;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use super::{
|
||||
agent_artifact_name, executable_format_valid, gpu_name_from_uevent, is_public_probe_ip,
|
||||
median, normalized_version, parse_lspci_gpu_names, parse_probe_target,
|
||||
ack_interval, agent_artifact_name, clock_offset_from_http_date, corrected_timestamp,
|
||||
executable_format_valid, gpu_name_from_uevent, is_public_probe_ip, live_endpoint,
|
||||
live_update_payload, median, normalized_version, parse_lspci_gpu_names, parse_probe_target,
|
||||
parse_system_profiler_gpu_names, ping_latency, prune_report_samples, release_asset_sha256,
|
||||
report_retry_delay, sanitize_latency_tasks, selected_interface, tcp_latency_probe_address,
|
||||
valid_endpoint, version_triplet, CliOptions, GithubReleaseAsset, LatencyResult,
|
||||
LatencyTask, Report, MAX_LATENCY_TASKS, MAX_PENDING_LATENCY_RESULTS, PROBE_ATTEMPTS,
|
||||
valid_endpoint, version_triplet, CliOptions, ClockCalibration, GithubReleaseAsset,
|
||||
LatencyResult, LatencyTask, LiveAck, Report, CLOCK_CALIBRATION_MAX_AGE, MAX_LATENCY_TASKS,
|
||||
MAX_PENDING_LATENCY_RESULTS, PROBE_ATTEMPTS,
|
||||
};
|
||||
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
@@ -1693,6 +2103,89 @@ mod tests {
|
||||
assert!(!valid_endpoint("https://bad host.example"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_live_websocket_endpoints() {
|
||||
assert_eq!(
|
||||
live_endpoint("https://monitor.example.com").unwrap(),
|
||||
"wss://monitor.example.com/api/agent/live"
|
||||
);
|
||||
assert_eq!(
|
||||
live_endpoint("https://monitor.example.com/base/").unwrap(),
|
||||
"wss://monitor.example.com/base/api/agent/live"
|
||||
);
|
||||
assert_eq!(
|
||||
live_endpoint("http://127.0.0.1:8787").unwrap(),
|
||||
"ws://127.0.0.1:8787/api/agent/live"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_server_realtime_ack_interval() {
|
||||
let ack: LiveAck = serde_json::from_str(
|
||||
r#"{"type":"ack","ts":100,"persisted":false,"nextD1WriteAfterMs":60000,"nextWssReportAfterMs":5000}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ack_interval(&ack, Duration::from_secs(60)),
|
||||
Duration::from_secs(5)
|
||||
);
|
||||
let slow: LiveAck = serde_json::from_str(
|
||||
r#"{"type":"ack","ts":100,"persisted":true,"nextD1WriteAfterMs":60000,"nextWssReportAfterMs":1}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ack_interval(&slow, Duration::from_secs(60)),
|
||||
Duration::from_secs(2)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encodes_realtime_samples_as_a_batch() {
|
||||
let payload = live_update_payload(&[
|
||||
Report {
|
||||
timestamp: 10,
|
||||
cpu: 20.0,
|
||||
..Report::default()
|
||||
},
|
||||
Report {
|
||||
timestamp: 15,
|
||||
cpu: 30.0,
|
||||
..Report::default()
|
||||
},
|
||||
])
|
||||
.unwrap();
|
||||
let value: serde_json::Value = serde_json::from_str(&payload).unwrap();
|
||||
assert_eq!(value["type"], "update");
|
||||
assert_eq!(value["samples"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(value["samples"][1]["cpu"], 30.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calibrates_timestamps_from_http_date() {
|
||||
let server_ms = 784_111_777_000_i64;
|
||||
assert_eq!(
|
||||
clock_offset_from_http_date(
|
||||
"Sun, 06 Nov 1994 08:49:37 GMT",
|
||||
server_ms - 1_000,
|
||||
server_ms + 1_000,
|
||||
),
|
||||
Some(0),
|
||||
);
|
||||
assert_eq!(corrected_timestamp(100, 2_500), 102);
|
||||
assert_eq!(corrected_timestamp(100, -2_500), 97);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_small_clock_jitter_until_the_calibration_expires() {
|
||||
let mut calibration = ClockCalibration::default();
|
||||
assert!(calibration.observe(1_000));
|
||||
assert!(!calibration.observe(2_000));
|
||||
assert_eq!(calibration.offset_ms, 1_000);
|
||||
assert!(calibration.observe(25_000));
|
||||
calibration.calibrated_at = Some(Instant::now() - CLOCK_CALIBRATION_MAX_AGE);
|
||||
assert!(calibration.observe(25_001));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_cli_options() {
|
||||
let parsed = CliOptions::try_parse_from([
|
||||
|
||||
+172
-27
@@ -1,18 +1,45 @@
|
||||
import { Megaphone, Moon, Search, Sun, UserCircle } from "lucide-react";
|
||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { api, ApiError } from "./api";
|
||||
import { KeyRound, Megaphone, Moon, Search, Sun, UserCircle } from "lucide-react";
|
||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from "react";
|
||||
import { api, ApiError, setToken } from "./api";
|
||||
import { NodeCard } from "./components/NodeCard";
|
||||
import { StatsBar } from "./components/StatsBar";
|
||||
import { TurnstileWidget } from "./components/TurnstileWidget";
|
||||
import { demoConfig, demoExchangeRates, demoServers } from "./demo";
|
||||
import type { Config, ExchangeRates, Server } from "./types";
|
||||
import type { Config, ExchangeRates, LatencySample, LiveLatencyResult, Server } from "./types";
|
||||
import { resolveBackground, themeToggle } from "./theme";
|
||||
import { ui } from "./locale";
|
||||
import { derivePassword } from "./password";
|
||||
|
||||
const defaultConfig: Config = { ...demoConfig, site_description: "", site_name: "" };
|
||||
const demoMode = import.meta.env.DEV && new URLSearchParams(window.location.search).has("demo");
|
||||
const NodeDetails = lazy(() => import("./components/NodeDetails").then((module) => ({ default: module.NodeDetails })));
|
||||
|
||||
interface LiveMetrics {
|
||||
timestamp: number;
|
||||
metrics: Partial<Server>;
|
||||
latencyResults?: LiveLatencyResult[];
|
||||
}
|
||||
|
||||
interface LiveSample {
|
||||
ts: number;
|
||||
data: Partial<Server> & { latency_results?: LiveLatencyResult[] };
|
||||
}
|
||||
|
||||
function mergeLiveLatency(server: Server, results: LiveLatencyResult[]): LatencySample[] {
|
||||
const definitions = new Map(server.latency.map((point) => [point.task_id, point]));
|
||||
return results.flatMap((result) => {
|
||||
const definition = definitions.get(result.task_id);
|
||||
if (!definition || !Number.isFinite(result.timestamp)) return [];
|
||||
return [{
|
||||
...definition,
|
||||
server_id: server.id,
|
||||
timestamp: result.timestamp,
|
||||
latency_ms: result.latency_ms,
|
||||
packet_loss: result.packet_loss,
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
function routeServerId() {
|
||||
const match = window.location.pathname.match(/^\/instance\/([^/]+)\/?$/);
|
||||
if (!match) return null;
|
||||
@@ -23,6 +50,7 @@ export default function App() {
|
||||
const [config, setConfig] = useState(defaultConfig);
|
||||
const [configReady, setConfigReady] = useState(demoMode);
|
||||
const [servers, setServers] = useState<Server[]>([]);
|
||||
const [liveMetrics, setLiveMetrics] = useState<Record<string, LiveMetrics>>({});
|
||||
const [exchangeRates, setExchangeRates] = useState<ExchangeRates | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
@@ -31,15 +59,28 @@ export default function App() {
|
||||
const [selectedId, setSelectedId] = useState<string | null>(routeServerId);
|
||||
const [needsVerification, setNeedsVerification] = useState(false);
|
||||
const [verificationBusy, setVerificationBusy] = useState(false);
|
||||
const [needsLogin, setNeedsLogin] = useState(false);
|
||||
const [loginUsername, setLoginUsername] = useState("");
|
||||
const [loginPassword, setLoginPassword] = useState("");
|
||||
const [loginTurnstileToken, setLoginTurnstileToken] = useState("");
|
||||
const [loginTurnstileReset, setLoginTurnstileReset] = useState(0);
|
||||
const [loginBusy, setLoginBusy] = useState(false);
|
||||
const [appearance, setAppearance] = useState<"light" | "dark" | null>(() => {
|
||||
const stored = localStorage.getItem("nodeflare-theme");
|
||||
return stored === "light" || stored === "dark" ? stored : null;
|
||||
});
|
||||
const [systemDark, setSystemDark] = useState(() => matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
const wsRef = useRef<WebSocket[]>([]);
|
||||
const liveConnectedRef = useRef(false);
|
||||
const dark = appearance ? appearance === "dark" : config.default_theme === "system" ? systemDark : config.default_theme === "dark";
|
||||
const background = resolveBackground(config.background_url, dark);
|
||||
const blur = themeToggle(config, "enableBlur");
|
||||
const liveServers = useMemo(() => servers.map((server) => {
|
||||
const live = liveMetrics[server.id];
|
||||
if (!live || live.timestamp <= (server.timestamp ?? 0)) return server;
|
||||
const latency = live.latencyResults?.length ? mergeLiveLatency(server, live.latencyResults) : server.latency;
|
||||
return { ...server, ...live.metrics, latency, timestamp: live.timestamp };
|
||||
}), [liveMetrics, servers]);
|
||||
|
||||
const load = useCallback(async (quiet = false) => {
|
||||
if (!quiet) setLoading(true);
|
||||
@@ -52,26 +93,61 @@ export default function App() {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
let dashboardPublic = true;
|
||||
try {
|
||||
const nextConfig = await api.config();
|
||||
dashboardPublic = nextConfig.public_dashboard;
|
||||
setConfig(nextConfig);
|
||||
setConfigReady(true);
|
||||
const [serverResult, ratesResult] = await Promise.all([api.servers(), api.exchangeRates()]);
|
||||
setServers(serverResult.servers);
|
||||
setExchangeRates(ratesResult);
|
||||
setNeedsVerification(false);
|
||||
setNeedsLogin(false);
|
||||
setError("");
|
||||
} catch (reason) {
|
||||
if (reason instanceof ApiError && reason.status === 403) {
|
||||
setNeedsVerification(true);
|
||||
if (reason instanceof ApiError && (reason.status === 401 || (!dashboardPublic && reason.status === 403))) {
|
||||
setNeedsLogin(true);
|
||||
setNeedsVerification(false);
|
||||
setServers([]);
|
||||
setLiveMetrics({});
|
||||
setError("");
|
||||
} else if (reason instanceof ApiError && reason.status === 403) {
|
||||
setNeedsVerification(true);
|
||||
setNeedsLogin(false);
|
||||
setServers([]);
|
||||
setLiveMetrics({});
|
||||
setError(reason.message);
|
||||
} else {
|
||||
setError(reason instanceof Error ? reason.message : ui(config.locale, "无法加载节点状态", "Unable to load server status"));
|
||||
}
|
||||
setError(reason instanceof Error ? reason.message : ui(config.locale, "无法加载节点状态", "Unable to load server status"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
async function loginDashboard(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (loginBusy) return;
|
||||
setLoginBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const passwordDerived = await derivePassword(loginPassword, config.password_client_salt);
|
||||
const result = await api.login(loginUsername.trim(), loginPassword, passwordDerived, loginTurnstileToken);
|
||||
setToken(result.token);
|
||||
setLoginPassword("");
|
||||
setLoginTurnstileToken("");
|
||||
setNeedsLogin(false);
|
||||
await load();
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : ui(config.locale, "登录失败", "Unable to sign in"));
|
||||
setLoginTurnstileToken("");
|
||||
setLoginTurnstileReset((value) => value + 1);
|
||||
} finally {
|
||||
setLoginBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const verifyDashboard = useCallback(async (token: string) => {
|
||||
if (!token || verificationBusy) return;
|
||||
setVerificationBusy(true);
|
||||
@@ -89,10 +165,18 @@ export default function App() {
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
const timer = window.setInterval(() => void load(true), 60_000);
|
||||
return () => clearInterval(timer);
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (needsLogin) return;
|
||||
let ticks = 0;
|
||||
const timer = window.setInterval(() => {
|
||||
ticks += 1;
|
||||
if (!liveConnectedRef.current || ticks % 4 === 0) void load(true);
|
||||
}, 15_000);
|
||||
return () => clearInterval(timer);
|
||||
}, [load, needsLogin]);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle("dark", dark);
|
||||
document.documentElement.style.colorScheme = dark ? "dark" : "light";
|
||||
@@ -125,12 +209,12 @@ export default function App() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!configReady) return;
|
||||
const selected = servers.find((server) => server.id === selectedId);
|
||||
const selected = liveServers.find((server) => server.id === selectedId);
|
||||
document.title = selected ? `${selected.name} · ${config.site_name}` : config.site_name;
|
||||
}, [config.site_name, configReady, selectedId, servers]);
|
||||
}, [config.site_name, configReady, liveServers, selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading || needsVerification || !config.public_dashboard || demoMode) return;
|
||||
if (loading || needsLogin || needsVerification || demoMode) return;
|
||||
const reconnects: number[] = [];
|
||||
let cancelled = false;
|
||||
const connect = () => {
|
||||
@@ -142,19 +226,67 @@ export default function App() {
|
||||
if (selectedId) endpoint.searchParams.set("server_id", selectedId);
|
||||
const socket = new WebSocket(endpoint);
|
||||
wsRef.current.push(socket);
|
||||
socket.onopen = () => void load(true);
|
||||
socket.onopen = () => {
|
||||
liveConnectedRef.current = true;
|
||||
void load(true);
|
||||
};
|
||||
socket.onclose = () => {
|
||||
wsRef.current = wsRef.current.filter((current) => current !== socket);
|
||||
liveConnectedRef.current = wsRef.current.some((current) => current.readyState === WebSocket.OPEN);
|
||||
if (!cancelled) reconnects.push(window.setTimeout(connect, 3000));
|
||||
};
|
||||
socket.onerror = () => {
|
||||
liveConnectedRef.current = false;
|
||||
};
|
||||
socket.onmessage = (event) => {
|
||||
if (event.data === "pong") return;
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.type !== "server" || !message.server?.id) return;
|
||||
setServers((current) => current.map((server) => server.id === message.server.id
|
||||
? message.server
|
||||
: server));
|
||||
if (message.type === "server" && message.server?.id) {
|
||||
setServers((current) => current.map((server) => server.id === message.server.id
|
||||
? message.server
|
||||
: server));
|
||||
return;
|
||||
}
|
||||
if (message.type === "batchUpdate" && Array.isArray(message.updates)) {
|
||||
setLiveMetrics((current) => {
|
||||
const next = { ...current };
|
||||
for (const update of message.updates as Array<{ serverId?: string; samples?: LiveSample[] }>) {
|
||||
if (!update.serverId || !Array.isArray(update.samples) || !update.samples.length) continue;
|
||||
const samples = update.samples
|
||||
.filter((sample) => Number.isFinite(sample.ts) && sample.data && typeof sample.data === "object")
|
||||
.sort((left, right) => left.ts - right.ts);
|
||||
const latest = samples.at(-1);
|
||||
if (!latest) continue;
|
||||
const previous = next[update.serverId];
|
||||
if (previous && previous.timestamp >= latest.ts) continue;
|
||||
const results = latest.data.latency_results;
|
||||
next[update.serverId] = {
|
||||
timestamp: latest.ts,
|
||||
metrics: latest.data,
|
||||
latencyResults: Array.isArray(results) ? results : previous?.latencyResults,
|
||||
};
|
||||
}
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (message.type !== "metrics" || !message.server_id || !message.metrics) return;
|
||||
if (!Number.isFinite(message.timestamp)) return;
|
||||
setLiveMetrics((current) => {
|
||||
const previous = current[message.server_id];
|
||||
if (previous && previous.timestamp >= message.timestamp) return current;
|
||||
return {
|
||||
...current,
|
||||
[message.server_id]: {
|
||||
timestamp: message.timestamp,
|
||||
metrics: message.metrics,
|
||||
latencyResults: Array.isArray(message.metrics.latency_results)
|
||||
? message.metrics.latency_results
|
||||
: previous?.latencyResults,
|
||||
},
|
||||
};
|
||||
});
|
||||
} catch { /* Ignore non-protocol messages. */ }
|
||||
};
|
||||
};
|
||||
@@ -162,21 +294,22 @@ export default function App() {
|
||||
const heartbeat = window.setInterval(() => wsRef.current.forEach((socket) => socket.readyState === WebSocket.OPEN && socket.send("ping")), 30_000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
liveConnectedRef.current = false;
|
||||
clearInterval(heartbeat);
|
||||
reconnects.forEach(clearTimeout);
|
||||
wsRef.current.forEach((socket) => socket.close());
|
||||
wsRef.current = [];
|
||||
};
|
||||
}, [config.public_dashboard, load, loading, needsVerification, selectedId]);
|
||||
}, [load, loading, needsLogin, needsVerification, selectedId]);
|
||||
|
||||
const groups = useMemo(() => ["__all__", ...Array.from(new Set(servers.map((server) => server.group_name || "默认")))], [servers]);
|
||||
const visible = useMemo(() => servers.filter((server) => {
|
||||
const groups = useMemo(() => ["__all__", ...Array.from(new Set(liveServers.map((server) => server.group_name || "默认")))], [liveServers]);
|
||||
const visible = useMemo(() => liveServers.filter((server) => {
|
||||
const text = `${server.name} ${server.region} ${server.tags} ${server.group_name}`.toLowerCase();
|
||||
const groupMatches = !config.show_groups || group === "__all__" || (server.group_name || "默认") === group;
|
||||
const queryMatches = !config.show_search || text.includes(query.trim().toLowerCase());
|
||||
return groupMatches && queryMatches;
|
||||
}), [servers, query, group, config.show_groups, config.show_search]);
|
||||
const selected = servers.find((server) => server.id === selectedId) ?? null;
|
||||
}), [liveServers, query, group, config.show_groups, config.show_search]);
|
||||
const selected = liveServers.find((server) => server.id === selectedId) ?? null;
|
||||
|
||||
function goHome() {
|
||||
window.history.pushState({}, "", demoMode ? "/?demo=1" : "/");
|
||||
@@ -220,7 +353,19 @@ export default function App() {
|
||||
|
||||
<main className="container main-content">
|
||||
{config.site_announcement ? <div className="site-announcement"><Megaphone size={16} /><span>{config.site_announcement}</span></div> : null}
|
||||
{needsVerification ? (
|
||||
{needsLogin ? (
|
||||
<section className="dashboard-login-gate glass-panel">
|
||||
<img src="/logo.svg" alt="" width="46" height="46" />
|
||||
<div className="dashboard-login-copy"><h1>{ui(config.locale, "登录仪表盘", "Sign in to dashboard")}</h1><p>{ui(config.locale, "此仪表盘仅限登录后访问", "This dashboard requires an administrator sign-in")}</p></div>
|
||||
<form className="dashboard-login-form" onSubmit={(event) => void loginDashboard(event)}>
|
||||
<label><span>{ui(config.locale, "用户名", "Username")}</span><input autoFocus autoComplete="username" value={loginUsername} onChange={(event) => setLoginUsername(event.target.value)} required /></label>
|
||||
<label><span>{ui(config.locale, "密码", "Password")}</span><input type="password" autoComplete="current-password" value={loginPassword} onChange={(event) => setLoginPassword(event.target.value)} required /></label>
|
||||
{config.turnstile_login_enabled ? <div className="dashboard-login-turnstile"><TurnstileWidget siteKey={config.turnstile_site_key} theme={dark ? "dark" : "light"} resetKey={loginTurnstileReset} onVerify={setLoginTurnstileToken} onError={setError} /></div> : null}
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
<button className="primary-btn dashboard-login-submit" disabled={loginBusy || (config.turnstile_login_enabled && !loginTurnstileToken)} type="submit"><KeyRound size={16} />{loginBusy ? ui(config.locale, "登录中", "Signing in") : ui(config.locale, "登录", "Sign in")}</button>
|
||||
</form>
|
||||
</section>
|
||||
) : needsVerification ? (
|
||||
<section className="verification-gate">
|
||||
<img src="/logo.svg" alt="" width="52" height="52" />
|
||||
<div><h1>{ui(config.locale, "访问验证", "Access verification")}</h1><p>{config.site_name}</p></div>
|
||||
@@ -229,18 +374,18 @@ export default function App() {
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
</section>
|
||||
) : selectedId ? selected ? (
|
||||
<Suspense fallback={<div className="chart-loading">{ui(config.locale, "正在加载节点", "Loading server")}</div>}><NodeDetails server={selected} threshold={config.offline_threshold_seconds} retentionDays={config.history_retention_days} locale={config.locale} demo={demoMode} onClose={goHome} /></Suspense>
|
||||
<Suspense fallback={<div className="chart-loading">{ui(config.locale, "正在加载节点", "Loading server")}</div>}><NodeDetails server={selected} liveLatencyResults={liveMetrics[selected.id]?.latencyResults ?? []} threshold={config.offline_threshold_seconds} retentionDays={config.history_retention_days} locale={config.locale} demo={demoMode} onClose={goHome} /></Suspense>
|
||||
) : loading ? <div className="chart-loading">{ui(config.locale, "正在加载节点", "Loading server")}</div> : <div className="empty-state"><strong>{ui(config.locale, "节点不存在或已隐藏", "Server not found or hidden")}</strong><button className="primary-btn" onClick={goHome}>{ui(config.locale, "返回首页", "Back")}</button></div> : <div className="home-content">
|
||||
<StatsBar servers={servers} config={config} exchangeRates={exchangeRates} />
|
||||
<StatsBar servers={liveServers} config={config} exchangeRates={exchangeRates} />
|
||||
{config.show_search || config.show_groups ? <div className="toolbar">
|
||||
{config.show_search ? <div className="search-box"><Search size={16} /><input aria-label={ui(config.locale, "搜索节点", "Search servers")} placeholder={ui(config.locale, "搜索节点", "Search servers")} value={query} onChange={(event) => setQuery(event.target.value)} /></div> : null}
|
||||
{config.show_groups ? <div className="group-tabs" aria-label={ui(config.locale, "节点分组", "Server groups")}>{groups.map((item) => <button className={group === item ? "active" : ""} key={item} onClick={() => setGroup(item)}>{item === "__all__" ? ui(config.locale, "全部", "All") : item}</button>)}</div> : null}
|
||||
<span className="result-count">{ui(config.locale, `${visible.length} 个节点`, `${visible.length} servers`)}</span>
|
||||
</div> : null}
|
||||
{error ? <div className="error-band"><span>{error}</span><button onClick={() => void load()}>{ui(config.locale, "重试", "Retry")}</button></div> : null}
|
||||
{loading && !servers.length ? <div className="loading-grid">{Array.from({ length: 8 }).map((_, index) => <span key={index} />)}</div> : visible.length ? (
|
||||
{loading && !liveServers.length ? <div className="loading-grid">{Array.from({ length: 8 }).map((_, index) => <span key={index} />)}</div> : visible.length ? (
|
||||
<section className="node-grid">{visible.map((server) => <NodeCard key={server.id} server={server} config={config} onOpen={() => openServer(server)} />)}</section>
|
||||
) : !error ? <div className="empty-state"><strong>{servers.length ? ui(config.locale, "没有匹配的节点", "No matching servers") : ui(config.locale, "尚未添加节点", "No servers added")}</strong></div> : null}
|
||||
) : !error ? <div className="empty-state"><strong>{liveServers.length ? ui(config.locale, "没有匹配的节点", "No matching servers") : ui(config.locale, "尚未添加节点", "No servers added")}</strong></div> : null}
|
||||
</div>}
|
||||
</main>
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ export const api = {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, password, password_derived: passwordDerived, turnstile_token: turnstileToken }),
|
||||
}),
|
||||
logout: () => request<void>("/api/admin/logout", { method: "POST" }),
|
||||
settings: () => request<Settings>("/api/admin/settings", {}, true),
|
||||
latencyTasks: () => request<{ tasks: LatencyTask[] }>("/api/admin/latency-tasks", {}, true),
|
||||
createLatencyTask: (input: LatencyTaskInput) =>
|
||||
|
||||
@@ -3,7 +3,9 @@ import {
|
||||
ArrowLeft,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
CircleAlert,
|
||||
Cloud,
|
||||
CircleCheck,
|
||||
Coins,
|
||||
Copy,
|
||||
Database,
|
||||
@@ -203,6 +205,12 @@ export function AdminPanel({
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [notice]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!error) return;
|
||||
const timer = window.setTimeout(() => setError(""), 6000);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [error]);
|
||||
|
||||
async function login(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setBusy(true);
|
||||
@@ -278,6 +286,14 @@ export function AdminPanel({
|
||||
}
|
||||
}
|
||||
|
||||
async function copyInstallCommands() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(commands.map((item) => `# ${item.name}\n${item.command}`).join("\n\n"));
|
||||
} catch {
|
||||
setError("复制失败,请手动选择命令复制");
|
||||
}
|
||||
}
|
||||
|
||||
async function move(index: number, offset: number) {
|
||||
const target = index + offset;
|
||||
if (target < 0 || target >= servers.length) return;
|
||||
@@ -438,8 +454,11 @@ export function AdminPanel({
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
function logout() {
|
||||
setToken(""); setAuthenticated(false); setServers([]); setSettings(null); setSelectedIds([]);
|
||||
async function logout() {
|
||||
try { await api.logout(); }
|
||||
finally {
|
||||
setToken(""); setAuthenticated(false); setServers([]); setSettings(null); setSelectedIds([]);
|
||||
}
|
||||
}
|
||||
|
||||
const commands = useMemo(() => install.map((server) => {
|
||||
@@ -459,7 +478,7 @@ export function AdminPanel({
|
||||
}
|
||||
return {
|
||||
...server,
|
||||
command: `wget -qO- ${installer}/agent.sh | sudo sh -s -- -e ${shellLiteral(origin)} -t ${shellLiteral(server.agent_token)}`,
|
||||
command: `curl -fsSL ${installer}/agent.sh | sudo sh -s -- -e ${shellLiteral(origin)} -t ${shellLiteral(server.agent_token)}`,
|
||||
};
|
||||
}), [install, installPlatform]);
|
||||
|
||||
@@ -479,6 +498,8 @@ export function AdminPanel({
|
||||
|
||||
return (
|
||||
<div className={`admin-page ${dark ? "admin-dark" : ""}`}>
|
||||
{error ? <div className="admin-toast error" role="alert" aria-live="assertive"><CircleAlert aria-hidden="true" /><span>{error}</span></div>
|
||||
: notice ? <div className="admin-toast" role="status" aria-live="polite"><CircleCheck aria-hidden="true" /><span>{notice}</span></div> : null}
|
||||
{!authenticated ? <div className="admin-login-stage">
|
||||
<button className="admin-back" type="button" onClick={onClose}><ArrowLeft size={14} />返回仪表盘</button>
|
||||
<button className="admin-login-theme" type="button" onClick={onToggleTheme} title={dark ? "切换浅色主题" : "切换深色主题"}>{dark ? <Sun size={16} /> : <Moon size={16} />}</button>
|
||||
@@ -488,7 +509,6 @@ export function AdminPanel({
|
||||
<label><span>用户名</span><input autoFocus type="text" autoComplete="username" value={username} onChange={(event) => setUsername(event.target.value)} required /></label>
|
||||
<label><span>密码</span><input type="password" autoComplete="current-password" value={password} onChange={(event) => setPassword(event.target.value)} required /></label>
|
||||
{config.turnstile_login_enabled || config.turnstile_enabled ? <div className="login-turnstile"><TurnstileWidget siteKey={config.turnstile_site_key} theme={dark ? "dark" : "light"} resetKey={turnstileReset} onVerify={setTurnstileToken} onError={setError} /></div> : null}
|
||||
{error ? <p className="form-error login-error">{error}</p> : null}
|
||||
<button className="primary-btn login-submit" disabled={busy || ((config.turnstile_login_enabled || config.turnstile_enabled) && !turnstileToken)} type="submit"><KeyRound size={16} />{busy ? "验证中" : "登录"}</button>
|
||||
</form>
|
||||
</div> : <section className="admin-shell" aria-label="管理面板">
|
||||
@@ -500,7 +520,7 @@ export function AdminPanel({
|
||||
<div className="admin-topbar-actions">
|
||||
<button type="button" onClick={onToggleTheme} title={dark ? "切换浅色主题" : "切换深色主题"}>{dark ? <Sun size={17} /> : <Moon size={17} />}</button>
|
||||
<button type="button" onClick={onClose}><ArrowLeft size={17} />返回前台</button>
|
||||
<button type="button" onClick={logout}><LogOut size={17} />退出登录</button>
|
||||
<button type="button" onClick={() => void logout()}><LogOut size={17} />退出登录</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -520,8 +540,6 @@ export function AdminPanel({
|
||||
</aside>
|
||||
<div className="admin-content">
|
||||
<header className="admin-content-header"><h1>{adminPages[tab].title}</h1><p>{adminPages[tab].description}</p></header>
|
||||
{error ? <p className="form-error admin-error">{error}</p> : null}
|
||||
{notice ? <p className="form-notice">{notice}</p> : null}
|
||||
{tab === "servers" ? (
|
||||
<div className="admin-section">
|
||||
<div className="section-head"><div><h3>监控节点</h3><span>{servers.length} 个节点 · 可拖动上下排序</span></div><div className="section-actions"><button className="primary-btn compact" onClick={() => openEditor()}><Plus size={16} />添加</button></div></div>
|
||||
@@ -532,7 +550,7 @@ export function AdminPanel({
|
||||
<button type="button" className="drag-handle" draggable onDragStart={(event) => startDrag(event, server.id)} onDragEnd={() => setDraggingId("")} title={`拖动排序:${server.name}`}><GripVertical size={15} /></button>
|
||||
<Checkbox checked={selectedIds.includes(server.id)} onChange={() => toggleSelected(server.id)} ariaLabel={`选择 ${server.name}`} />
|
||||
<span className={`status-dot ${settings && isOnline(server, settings.offline_threshold_seconds) ? "online" : ""}`} />
|
||||
<div className="server-name"><strong>{server.name}</strong><small>{server.group_name} · {server.region || "未设置地区"} · {server.last_ip || "尚未上报 IP"} · {server.report_interval}s</small></div>
|
||||
<div className="server-name"><strong>{server.name}</strong><small>{server.group_name} · {server.region || "未设置地区"} · {server.last_ip || "尚未上报 IP"} · {server.agent_version ? `Agent v${server.agent_version}` : "Agent 未上报版本"} · {server.report_interval}s</small></div>
|
||||
<span className="server-usage">{server.traffic_limit > 0 ? formatBytes(server.traffic_limit) : "不限流量"}</span>
|
||||
<div className="row-actions"><button className="icon-btn" disabled={index === 0} onClick={() => void move(index, -1)} title="上移"><ChevronUp size={15} /></button><button className="icon-btn" disabled={index === servers.length - 1} onClick={() => void move(index, 1)} title="下移"><ChevronDown size={15} /></button><button className="icon-btn" disabled={busy} onClick={() => void showInstallCommand(server)} title="显示安装命令"><Download size={15} /></button><button className="icon-btn" onClick={() => openEditor(server)} title="编辑节点"><Pencil size={15} /></button><button className="icon-btn danger" onClick={() => void remove(server)} title="删除节点"><Trash2 size={15} /></button></div>
|
||||
</div>
|
||||
@@ -603,7 +621,7 @@ export function AdminPanel({
|
||||
<div className="form-grid"><label><span>管理员用户名</span><input autoComplete="username" value={settings.admin_username} onChange={(event) => updateSettings("admin_username", event.target.value)} /></label><label><span>新密码(留空不修改)</span><input autoComplete="new-password" type="password" value={settings.new_password || ""} onChange={(event) => updateSettings("new_password", event.target.value)} placeholder="至少 8 个字符" /></label></div>
|
||||
<div className="security-note">当前密码状态:{settings.admin_password_configured ? "已使用 D1 加盐哈希" : "使用 ADMIN_PASSWORD 初始化密钥"}</div>
|
||||
<div className="form-grid"><Toggle label="保护公开仪表盘" checked={settings.turnstile_enabled} onChange={(value) => updateSettings("turnstile_enabled", value)} /><Toggle label="保护管理员登录" checked={settings.turnstile_login_enabled} onChange={(value) => updateSettings("turnstile_login_enabled", value)} /></div>
|
||||
<div className="form-grid"><label><span>Turnstile Site Key</span><input value={settings.turnstile_site_key} onChange={(event) => updateSettings("turnstile_site_key", event.target.value)} /></label><label><span>Turnstile Secret Key</span><input type="password" value={settings.turnstile_secret_key} onChange={(event) => updateSettings("turnstile_secret_key", event.target.value)} /></label></div>
|
||||
<div className="form-grid"><label><span>Turnstile Site Key</span><input autoComplete="off" type="password" value={settings.turnstile_site_key} onChange={(event) => updateSettings("turnstile_site_key", event.target.value)} /></label><label><span>Turnstile Secret Key</span><input autoComplete="off" type="password" value={settings.turnstile_secret_key} onChange={(event) => updateSettings("turnstile_secret_key", event.target.value)} /></label></div>
|
||||
<p className="settings-hint">留空时读取 Worker 的 TURNSTILE_SITE_KEY 和 TURNSTILE_SECRET_KEY,后台配置优先。</p>
|
||||
</> : null}
|
||||
|
||||
@@ -616,9 +634,9 @@ export function AdminPanel({
|
||||
</div>
|
||||
<div className="usage-section">
|
||||
<div className="usage-head"><div><div className="section-title"><Cloud size={15} />Cloudflare 用量</div><p className="settings-hint">统计周期使用 UTC</p></div><button type="button" className="secondary-btn compact" disabled={busy} onClick={() => void loadCloudflareUsage()}><RotateCw size={14} />{cloudflareUsage ? "刷新用量" : "查询用量"}</button></div>
|
||||
<div className="form-grid"><label><span>Cloudflare Account ID</span><input autoComplete="off" maxLength={32} value={settings.cloudflare_account_id} onChange={(event) => updateSettings("cloudflare_account_id", event.target.value)} placeholder="32 位账户 ID" /></label><label><span>Cloudflare API Token</span><input autoComplete="off" type="password" value={settings.cloudflare_api_token} onChange={(event) => updateSettings("cloudflare_api_token", event.target.value)} placeholder="需要 Account Analytics Read 权限" /></label></div>
|
||||
<div className="usage-config-actions"><p className="settings-hint">留空时读取 Worker Secret 中的同名变量。</p><button type="submit" className="primary-btn compact" disabled={busy}><Save size={14} />保存用量配置</button></div>
|
||||
{cloudflareUsage ? <div className="usage-table-wrap"><table className="usage-table"><thead><tr><th>周期</th><th>D1 读取</th><th>D1 写入</th><th>Workers 请求</th></tr></thead><tbody><UsageRow label="今日" usage={cloudflareUsage.today} /><UsageRow label="昨日" usage={cloudflareUsage.yesterday} /></tbody></table></div> : <div className="usage-empty">尚未读取</div>}
|
||||
<div className="form-grid"><label><span>Cloudflare Account ID</span><input autoComplete="off" type="password" maxLength={32} value={settings.cloudflare_account_id} onChange={(event) => updateSettings("cloudflare_account_id", event.target.value)} placeholder="32 位账户 ID" /></label><label><span>Cloudflare API Token</span><input autoComplete="off" type="password" value={settings.cloudflare_api_token} onChange={(event) => updateSettings("cloudflare_api_token", event.target.value)} placeholder="Account Analytics: Read" /></label></div>
|
||||
<div className="usage-config-actions"><p className="settings-hint">Token 需要账户级 Account Analytics: Read 权限,并授权对应账户;留空读取 Worker Secret。</p><button type="submit" className="primary-btn compact" disabled={busy}><Save size={14} />保存用量配置</button></div>
|
||||
{cloudflareUsage ? <><div className="usage-table-wrap"><table className="usage-table cloudflare-usage-table"><thead><tr><th>周期</th><th>D1 读取</th><th>D1 写入</th><th>Workers 请求</th><th>DO 请求(估算)</th><th>DO 时长 (GB-s)</th></tr></thead><tbody><UsageRow label="今日" usage={cloudflareUsage.today} /><UsageRow label="昨日" usage={cloudflareUsage.yesterday} /></tbody></table></div><div className="usage-do-breakdown"><UsageDoBreakdown label="今日" usage={cloudflareUsage.today} /><UsageDoBreakdown label="昨日" usage={cloudflareUsage.yesterday} /></div></> : <div className="usage-empty">尚未读取</div>}
|
||||
</div>
|
||||
<div className="data-actions"><button type="button" className="secondary-btn" onClick={() => void loadDatabase()}>刷新统计</button><button type="button" className="danger-btn" onClick={() => void clearHistory()}><Trash2 size={15} />清空历史指标</button></div>
|
||||
<p className="settings-hint">清空历史不会删除节点、密钥或最新状态。</p>
|
||||
@@ -642,7 +660,7 @@ export function AdminPanel({
|
||||
<div className="form-actions"><button type="button" className="secondary-btn" onClick={() => setEditing(null)}>取消</button><button className="primary-btn" disabled={busy}><Save size={16} />保存节点</button></div>
|
||||
</form></div> : null}
|
||||
|
||||
{commands.length ? <div className="submodal-backdrop" role="presentation" onMouseDown={() => setInstall([])}><section className="install-modal glass-panel" onMouseDown={(event) => event.stopPropagation()}><header><div><span className="eyebrow">Agent 部署</span><h3>安装命令</h3></div><div className="segmented install-platform" aria-label="Agent 平台"><button type="button" className={installPlatform === "linux" ? "active" : ""} onClick={() => setInstallPlatform("linux")}>Linux</button><button type="button" className={installPlatform === "windows" ? "active" : ""} onClick={() => setInstallPlatform("windows")}>Windows</button><button type="button" className={installPlatform === "macos" ? "active" : ""} onClick={() => setInstallPlatform("macos")}>macOS ARM</button></div></header><div className="install-list">{commands.map((item) => <div key={item.id}><strong>{item.name}</strong><pre>{item.command}</pre></div>)}</div><div className="form-actions"><button className="secondary-btn" type="button" onClick={() => setInstall([])}>完成</button><button className="primary-btn" type="button" onClick={() => navigator.clipboard.writeText(commands.map((item) => `# ${item.name}\n${item.command}`).join("\n\n"))}><Copy size={16} />复制全部</button></div></section></div> : null}
|
||||
{commands.length ? <div className="submodal-backdrop" role="presentation" onMouseDown={() => setInstall([])}><section className="install-modal glass-panel" onMouseDown={(event) => event.stopPropagation()}><header><div><span className="eyebrow">Agent 部署</span><h3>安装命令</h3></div><div className="segmented install-platform" aria-label="Agent 平台"><button type="button" className={installPlatform === "linux" ? "active" : ""} onClick={() => setInstallPlatform("linux")}>Linux</button><button type="button" className={installPlatform === "windows" ? "active" : ""} onClick={() => setInstallPlatform("windows")}>Windows</button><button type="button" className={installPlatform === "macos" ? "active" : ""} onClick={() => setInstallPlatform("macos")}>macOS ARM</button></div></header><div className="install-list">{commands.map((item) => <div key={item.id}><strong>{item.name}</strong><pre>{item.command}</pre></div>)}</div><div className="form-actions"><button className="secondary-btn" type="button" onClick={() => setInstall([])}>关闭</button><button className="primary-btn" type="button" onClick={() => void copyInstallCommands()}><Copy size={16} />复制全部</button></div></section></div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -672,5 +690,9 @@ function DataStat({ label, value }: { label: string; value: number | string }) {
|
||||
}
|
||||
|
||||
function UsageRow({ label, usage }: { label: string; usage: CloudflareUsage["today"] }) {
|
||||
return <tr><th scope="row">{label}</th><td>{usage.rows_read.toLocaleString()}</td><td>{usage.rows_written.toLocaleString()}</td><td>{usage.workers_requests.toLocaleString()}</td></tr>;
|
||||
return <tr><th scope="row">{label}</th><td>{usage.rows_read.toLocaleString()}</td><td>{usage.rows_written.toLocaleString()}</td><td>{usage.workers_requests.toLocaleString()}</td><td>{usage.durable_objects_requests.toLocaleString()}</td><td>{usage.durable_objects_duration.toLocaleString(undefined, { maximumFractionDigits: 2 })}</td></tr>;
|
||||
}
|
||||
|
||||
function UsageDoBreakdown({ label, usage }: { label: string; usage: CloudflareUsage["today"] }) {
|
||||
return <div><strong>{label}</strong><span>HTTP {usage.durable_objects_http_requests.toLocaleString()}</span><span>休眠唤醒 {usage.durable_objects_hibernation_wakeups.toLocaleString()}</span><span>WS 入站 {usage.durable_objects_inbound_websocket_messages.toLocaleString()}</span><span>WS 出站 {usage.durable_objects_outbound_websocket_messages.toLocaleString()}</span><small>每 {usage.durable_objects_request_billing_ratio} 条入站消息折算 1 次请求</small></div>;
|
||||
}
|
||||
@@ -74,7 +74,7 @@ export function NodeCard({ server, config, onOpen }: { server: Server; config: C
|
||||
const locale = config.locale;
|
||||
const price = formatPrice(server, locale);
|
||||
const remainingValue = remainingAssetValue(server.price, server.billing_cycle, server.expires_at);
|
||||
const showExpiryPanel = config.show_expiry && (server.price !== 0 || server.expires_at !== null);
|
||||
const showExpiryPanel = config.show_expiry || config.show_price;
|
||||
|
||||
return (
|
||||
<button className={`node-card glass-panel ${online ? "" : "offline"}`} onClick={onOpen} type="button" aria-label={ui(locale, `查看 ${server.name} 详情`, `View ${server.name} details`)}>
|
||||
@@ -87,7 +87,9 @@ export function NodeCard({ server, config, onOpen }: { server: Server; config: C
|
||||
|
||||
<div className="node-body">
|
||||
<div className="node-chips">
|
||||
{config.show_uptime ? <span>{ui(locale, `在线 ${Math.floor(number(server.uptime) / 86400)} 天`, `Online ${Math.floor(number(server.uptime) / 86400)} days`)}</span> : null}
|
||||
{config.show_uptime ? <span>{online
|
||||
? ui(locale, `在线 ${Math.floor(number(server.uptime) / 86400)} 天`, `Online ${Math.floor(number(server.uptime) / 86400)} days`)
|
||||
: ui(locale, `离线 · ${timeAgo(server.timestamp, locale)}`, `Offline · ${timeAgo(server.timestamp, locale)}`)}</span> : null}
|
||||
{config.show_price && price ? <span title={price}>{price}</span> : null}
|
||||
</div>
|
||||
|
||||
@@ -108,8 +110,10 @@ export function NodeCard({ server, config, onOpen }: { server: Server; config: C
|
||||
<CompactLine icon={<Download size={11} />}>{formatBytes(server.net_rx_total)}</CompactLine>
|
||||
</div>
|
||||
{showExpiryPanel ? <div className="data-panel" aria-label="剩余周期">
|
||||
<CompactLine icon={<CalendarDays size={11} />}>{formatExpire(server, locale)}</CompactLine>
|
||||
{server.price !== 0 ? <CompactLine icon={<Coins size={11} />}>{server.price === -1 ? ui(locale, "免费", "Free") : formatCurrency(remainingValue, server.currency)}</CompactLine> : null}
|
||||
{config.show_expiry ? <CompactLine icon={<CalendarDays size={11} />}>{formatExpire(server, locale)}</CompactLine> : null}
|
||||
{config.show_price ? <CompactLine icon={<Coins size={11} />}>{server.price === -1
|
||||
? ui(locale, "免费", "Free")
|
||||
: server.price > 0 ? formatCurrency(remainingValue, server.currency) : ui(locale, "未设置", "Not set")}</CompactLine> : null}
|
||||
</div> : null}
|
||||
</div>
|
||||
|
||||
@@ -117,8 +121,6 @@ export function NodeCard({ server, config, onOpen }: { server: Server; config: C
|
||||
<QualityPanel label={ui(locale, "延迟", "Latency")} value={quality.latencyDisplay} bars={quality.latencyBars} />
|
||||
<QualityPanel label={ui(locale, "丢包", "Packet loss")} value={quality.lossDisplay} bars={quality.lossBars} />
|
||||
</div> : null}
|
||||
|
||||
{!online ? <div className="offline-layer"><strong>{ui(locale, "离线", "Offline")}</strong><span>{ui(locale, `最后更新 ${timeAgo(server.timestamp, locale)}`, `Last update ${timeAgo(server.timestamp, locale)}`)}</span></div> : null}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -3,13 +3,12 @@ import {
|
||||
ArrowLeft,
|
||||
Box,
|
||||
Cpu,
|
||||
HardDrive,
|
||||
Network,
|
||||
Radio,
|
||||
RadioTower,
|
||||
ServerCog,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
@@ -25,7 +24,7 @@ import {
|
||||
import { api } from "../api";
|
||||
import { demoHistory, demoLatencyHistory, demoLatencyTasks } from "../demo";
|
||||
import { displayGpuDevices, formatBytes, formatCpuName, formatSpeed, formatUptime, isOnline, number } from "../format";
|
||||
import type { HistoryPoint, LatencySample, LatencyTestPoint, Server } from "../types";
|
||||
import type { HistoryPoint, LatencySample, LatencyTestPoint, LiveLatencyResult, Server } from "../types";
|
||||
import { Flag, regionDisplayName } from "./Flag";
|
||||
import { OSIcon } from "./OSIcon";
|
||||
import { ui } from "../locale";
|
||||
@@ -33,6 +32,48 @@ import { ui } from "../locale";
|
||||
type ChartType = "load" | "latency";
|
||||
|
||||
const LATENCY_COLORS = ["#2563eb", "#db2777", "#ea7b1b", "#0f766e", "#7c3aed", "#0891b2"];
|
||||
const REALTIME_WINDOW_SECONDS = 60 * 60;
|
||||
const MAX_REALTIME_POINTS = 720;
|
||||
|
||||
function historyPointFromServer(server: Server): HistoryPoint | null {
|
||||
if (!Number.isFinite(server.timestamp) || number(server.timestamp) <= 0) return null;
|
||||
return {
|
||||
timestamp: number(server.timestamp),
|
||||
cpu: number(server.cpu),
|
||||
load1: number(server.load1),
|
||||
load5: number(server.load5),
|
||||
load15: number(server.load15),
|
||||
mem_used: number(server.mem_used),
|
||||
mem_total: number(server.mem_total),
|
||||
swap_used: number(server.swap_used),
|
||||
swap_total: number(server.swap_total),
|
||||
disk_used: number(server.disk_used),
|
||||
disk_total: number(server.disk_total),
|
||||
net_in: number(server.net_in),
|
||||
net_out: number(server.net_out),
|
||||
net_rx_total: number(server.net_rx_total),
|
||||
net_tx_total: number(server.net_tx_total),
|
||||
processes: number(server.processes),
|
||||
tcp_connections: number(server.tcp_connections),
|
||||
udp_connections: number(server.udp_connections),
|
||||
gpu_usage: number(server.gpu_usage),
|
||||
disk_read_bps: number(server.disk_read_bps),
|
||||
disk_write_bps: number(server.disk_write_bps),
|
||||
disk_read_iops: number(server.disk_read_iops),
|
||||
disk_write_iops: number(server.disk_write_iops),
|
||||
disk_await_ms: number(server.disk_await_ms),
|
||||
disk_utilization: number(server.disk_utilization),
|
||||
};
|
||||
}
|
||||
|
||||
function appendRealtimePoint(points: HistoryPoint[], point: HistoryPoint | null): HistoryPoint[] {
|
||||
if (!point) return points;
|
||||
const cutoff = point.timestamp - REALTIME_WINDOW_SECONDS;
|
||||
const next = points.filter((current) => current.timestamp >= cutoff && current.timestamp !== point.timestamp);
|
||||
next.push(point);
|
||||
next.sort((left, right) => left.timestamp - right.timestamp);
|
||||
return next.slice(-MAX_REALTIME_POINTS);
|
||||
}
|
||||
|
||||
function InfoItem({ icon, label, value }: { icon: React.ReactNode; label: string; value: React.ReactNode }) {
|
||||
return <div className="info-item"><span>{icon}</span><div><small>{label}</small><strong>{value}</strong></div></div>;
|
||||
@@ -62,49 +103,125 @@ const tooltipStyle = {
|
||||
fontSize: 12,
|
||||
};
|
||||
|
||||
export function NodeDetails({ server, threshold, retentionDays, locale, demo = false, onClose }: {
|
||||
export function NodeDetails({ server, liveLatencyResults, threshold, retentionDays, locale, demo = false, onClose }: {
|
||||
server: Server;
|
||||
liveLatencyResults: LiveLatencyResult[];
|
||||
threshold: number;
|
||||
retentionDays: number;
|
||||
locale: "zh-CN" | "en";
|
||||
demo?: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [hours, setHours] = useState(0);
|
||||
const [loadHours, setLoadHours] = useState(0);
|
||||
const [latencyHours, setLatencyHours] = useState(1);
|
||||
const [chartType, setChartType] = useState<ChartType>("load");
|
||||
const [points, setPoints] = useState<HistoryPoint[]>([]);
|
||||
const [latencyPoints, setLatencyPoints] = useState<LatencySample[]>([]);
|
||||
const [latencyTasks, setLatencyTasks] = useState<LatencyTestPoint[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [loadLoading, setLoadLoading] = useState(true);
|
||||
const [latencyLoading, setLatencyLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState("");
|
||||
const [latencyError, setLatencyError] = useState("");
|
||||
const latestServerRef = useRef(server);
|
||||
latestServerRef.current = server;
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
const requestHours = hours === 0 ? 1 : hours;
|
||||
setLoadLoading(true);
|
||||
setLoadError("");
|
||||
const requestHours = loadHours === 0 ? 1 : loadHours;
|
||||
if (demo) {
|
||||
const taskIds = new Set(server.latency.map((point) => point.task_id));
|
||||
const tasks = demoLatencyTasks.filter((task) => taskIds.has(task.id));
|
||||
setPoints(demoHistory(server.id, requestHours));
|
||||
setLatencyTasks(tasks);
|
||||
setLatencyPoints(tasks.length ? demoLatencyHistory(server.id, requestHours).filter((point) => taskIds.has(point.task_id)) : []);
|
||||
setLoading(false);
|
||||
setLoadLoading(false);
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
Promise.all([api.history(server.id, requestHours), api.latencyHistory(server.id, requestHours)])
|
||||
.then(([history, latency]) => { if (active) { setPoints(history.points); setLatencyTasks(latency.tasks); setLatencyPoints(latency.points); } })
|
||||
.catch((reason) => { if (active) { setPoints([]); setLatencyTasks([]); setLatencyPoints([]); setError(reason instanceof Error ? reason.message : ui(locale, "历史数据加载失败", "Unable to load history")); } })
|
||||
.finally(() => { if (active) setLoading(false); });
|
||||
api.history(server.id, requestHours)
|
||||
.then((history) => {
|
||||
if (active) setPoints(loadHours === 0
|
||||
? appendRealtimePoint(history.points, historyPointFromServer(latestServerRef.current))
|
||||
: history.points);
|
||||
})
|
||||
.catch((reason) => { if (active) { setPoints([]); setLoadError(reason instanceof Error ? reason.message : ui(locale, "历史数据加载失败", "Unable to load history")); } })
|
||||
.finally(() => { if (active) setLoadLoading(false); });
|
||||
return () => { active = false; };
|
||||
}, [server.id, hours, demo, locale]);
|
||||
}, [server.id, loadHours, demo, locale]);
|
||||
|
||||
useEffect(() => {
|
||||
if (demo || loadHours !== 0) return;
|
||||
const point = historyPointFromServer(server);
|
||||
if (point) setPoints((current) => appendRealtimePoint(current, point));
|
||||
}, [demo, loadHours, server]);
|
||||
|
||||
useEffect(() => {
|
||||
setLatencyLoading(true);
|
||||
setLatencyError("");
|
||||
if (demo) {
|
||||
const taskIds = new Set(server.latency.map((point) => point.task_id));
|
||||
const tasks = demoLatencyTasks.filter((task) => taskIds.has(task.id));
|
||||
setLatencyTasks(tasks);
|
||||
setLatencyPoints(tasks.length ? demoLatencyHistory(server.id, latencyHours).filter((point) => taskIds.has(point.task_id)) : []);
|
||||
setLatencyLoading(false);
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
api.latencyHistory(server.id, latencyHours)
|
||||
.then((latency) => { if (active) { setLatencyTasks(latency.tasks); setLatencyPoints(latency.points); } })
|
||||
.catch((reason) => { if (active) { setLatencyPoints([]); setLatencyError(reason instanceof Error ? reason.message : ui(locale, "延迟数据加载失败", "Unable to load latency")); } })
|
||||
.finally(() => { if (active) setLatencyLoading(false); });
|
||||
return () => { active = false; };
|
||||
}, [server.id, latencyHours, demo, locale]);
|
||||
|
||||
useEffect(() => {
|
||||
if (demo || !server.latency.length) return;
|
||||
const latest = Math.max(...server.latency.map((point) => point.timestamp).filter(Number.isFinite));
|
||||
if (!Number.isFinite(latest)) return;
|
||||
const cutoff = latest - latencyHours * 3600;
|
||||
setLatencyPoints((current) => {
|
||||
const next = new Map(current
|
||||
.filter((point) => point.timestamp >= cutoff)
|
||||
.map((point) => [`${point.task_id}:${point.timestamp}`, point]));
|
||||
for (const point of server.latency) {
|
||||
if (point.timestamp >= cutoff) {
|
||||
next.set(`${point.task_id}:${point.timestamp}`, point);
|
||||
}
|
||||
}
|
||||
return Array.from(next.values()).sort((left, right) => left.timestamp - right.timestamp);
|
||||
});
|
||||
}, [demo, latencyHours, server.latency]);
|
||||
|
||||
useEffect(() => {
|
||||
if (demo || !liveLatencyResults.length || !latencyTasks.length) return;
|
||||
const definitions = new Map(latencyTasks.map((task) => [task.id, task]));
|
||||
const samples = liveLatencyResults.flatMap((result): LatencySample[] => {
|
||||
const task = definitions.get(result.task_id);
|
||||
if (!task || !Number.isFinite(result.timestamp)) return [];
|
||||
return [{
|
||||
task_id: task.id,
|
||||
server_id: server.id,
|
||||
name: task.name,
|
||||
task_type: task.task_type,
|
||||
target: task.target,
|
||||
port: task.port,
|
||||
timestamp: result.timestamp,
|
||||
latency_ms: result.latency_ms,
|
||||
packet_loss: result.packet_loss,
|
||||
}];
|
||||
});
|
||||
if (!samples.length) return;
|
||||
const latest = Math.max(...samples.map((point) => point.timestamp));
|
||||
const cutoff = latest - latencyHours * 3600;
|
||||
setLatencyPoints((current) => {
|
||||
const next = new Map(current
|
||||
.filter((point) => point.timestamp >= cutoff)
|
||||
.map((point) => [`${point.task_id}:${point.timestamp}`, point]));
|
||||
for (const point of samples) next.set(`${point.task_id}:${point.timestamp}`, point);
|
||||
return Array.from(next.values()).sort((left, right) => left.timestamp - right.timestamp);
|
||||
});
|
||||
}, [demo, latencyHours, latencyTasks, liveLatencyResults, server.id]);
|
||||
|
||||
const online = isOnline(server, threshold);
|
||||
const gpuDevices = useMemo(() => displayGpuDevices(server.gpus), [server.gpus]);
|
||||
const gpuNames = gpuDevices.map((gpu) => gpu.model).join(" · ");
|
||||
const gpuMemory = gpuDevices.map((gpu) => gpu.memory_total > 0
|
||||
? `${formatBytes(gpu.memory_used)} / ${formatBytes(gpu.memory_total)}`
|
||||
: ui(locale, "共享内存", "Shared memory")).join(" · ");
|
||||
const loadRanges = [
|
||||
{ value: 0, label: ui(locale, "实时", "Live") },
|
||||
{ value: 1, label: ui(locale, "1 小时", "1 hour") },
|
||||
@@ -114,6 +231,7 @@ export function NodeDetails({ server, threshold, retentionDays, locale, demo = f
|
||||
{ value: 720, label: ui(locale, "30 天", "30 days") },
|
||||
].filter((range) => range.value <= Math.max(24, retentionDays * 24));
|
||||
const ranges = chartType === "load" ? loadRanges : loadRanges.filter((range) => range.value > 0);
|
||||
const hours = chartType === "load" ? loadHours : latencyHours;
|
||||
const data = useMemo(() => points.map((point) => ({
|
||||
...point,
|
||||
time: new Date(point.timestamp * 1000).toLocaleString(locale, hours >= 24
|
||||
@@ -151,11 +269,6 @@ export function NodeDetails({ server, threshold, retentionDays, locale, demo = f
|
||||
const last = data[data.length - 1];
|
||||
const pingEnabled = latencyTasks.length > 0;
|
||||
|
||||
function selectChart(next: ChartType) {
|
||||
setChartType(next);
|
||||
if (next === "latency" && hours === 0) setHours(1);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="detail-page">
|
||||
<section className="detail-hero glass-panel">
|
||||
@@ -171,35 +284,31 @@ export function NodeDetails({ server, threshold, retentionDays, locale, demo = f
|
||||
<InfoGroup title={ui(locale, "硬件信息", "Hardware")} icon={<Cpu size={16} />}>
|
||||
<InfoItem icon={<Cpu size={14} />} label="CPU" value={formatCpuName(server.cpu_model, server.cpu_cores)} />
|
||||
<InfoItem icon={<Box size={14} />} label={ui(locale, "架构", "Architecture")} value={server.arch || "--"} />
|
||||
{gpuDevices.length ? <>
|
||||
<InfoItem icon={<Cpu size={14} />} label="GPU" value={gpuNames} />
|
||||
<InfoItem icon={<Cpu size={14} />} label={ui(locale, "GPU 显存", "GPU Memory")} value={gpuMemory} />
|
||||
</> : null}
|
||||
<InfoItem icon={<HardDrive size={14} />} label={ui(locale, "存储设备", "Storage")} value={server.disks.length ? server.disks.map((disk) => `${disk.mount_point} ${formatBytes(disk.used)} / ${formatBytes(disk.total)}`).join(" · ") : `${formatBytes(server.disk_used)} / ${formatBytes(server.disk_total)}`} />
|
||||
<InfoItem icon={<Cpu size={14} />} label="GPU" value={gpuNames || ui(locale, "未检测到", "Not detected")} />
|
||||
<InfoItem icon={<ServerCog size={14} />} label={ui(locale, "虚拟化", "Virtualization")} value={server.virtualization || "--"} />
|
||||
</InfoGroup>
|
||||
<InfoGroup title={ui(locale, "系统信息", "System")} icon={<ServerCog size={16} />}>
|
||||
<InfoItem icon={<ServerCog size={14} />} label={ui(locale, "操作系统", "Operating system")} value={server.os || "--"} />
|
||||
<InfoItem icon={<Radio size={14} />} label={ui(locale, "运行时间", "Uptime")} value={online ? formatUptime(server.uptime, locale) : "--"} />
|
||||
<InfoItem icon={<Radio size={14} />} label={ui(locale, "运行时间", "Uptime")} value={formatUptime(server.uptime, locale)} />
|
||||
<InfoItem icon={<Cpu size={14} />} label={ui(locale, "内核版本", "Kernel")} value={server.kernel || "--"} />
|
||||
<InfoItem icon={<Network size={14} />} label={ui(locale, "进程 / 连接", "Processes / connections")} value={online ? `${server.processes || 0} / ${number(server.tcp_connections) + number(server.udp_connections)}` : "--"} />
|
||||
<InfoItem icon={<HardDrive size={14} />} label={ui(locale, "磁盘 IO", "Disk IO")} value={`${number(server.disk_await_ms).toFixed(1)} ms · ${number(server.disk_utilization).toFixed(1)}%`} />
|
||||
<InfoItem icon={<Network size={14} />} label={ui(locale, "进程 / 连接", "Processes / connections")} value={`${number(server.processes)} / ${number(server.tcp_connections) + number(server.udp_connections)}`} />
|
||||
</InfoGroup>
|
||||
</div>
|
||||
|
||||
<section className="chart-section">
|
||||
<div className="chart-controls">
|
||||
<div className="segmented" aria-label="图表类型">
|
||||
<button className={chartType === "load" ? "active" : ""} onClick={() => selectChart("load")}><Activity size={14} />{ui(locale, "负载", "Load")}</button>
|
||||
{pingEnabled ? <button className={chartType === "latency" ? "active" : ""} onClick={() => selectChart("latency")}><RadioTower size={14} />{ui(locale, "延迟", "Latency")}</button> : null}
|
||||
<button className={chartType === "load" ? "active" : ""} onClick={() => setChartType("load")}><Activity size={14} />{ui(locale, "负载", "Load")}</button>
|
||||
{pingEnabled ? <button className={chartType === "latency" ? "active" : ""} onClick={() => setChartType("latency")}><RadioTower size={14} />{ui(locale, "延迟", "Latency")}</button> : null}
|
||||
</div>
|
||||
<div className="segmented range-control" aria-label="时间范围">
|
||||
{ranges.map((range) => <button className={hours === range.value ? "active" : ""} key={range.value} onClick={() => setHours(range.value)}>{range.label}</button>)}
|
||||
{ranges.map((range) => <button className={hours === range.value ? "active" : ""} key={range.value} onClick={() => chartType === "load" ? setLoadHours(range.value) : setLatencyHours(range.value)}>{range.label}</button>)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <div className="error-band">{error}</div> : null}
|
||||
{loading ? <div className="chart-loading">{ui(locale, "正在读取历史数据", "Loading history")}</div> : chartType === "load" && !data.length ? <div className="chart-loading">{ui(locale, "暂无历史数据", "No history")}</div> : chartType === "latency" && !(latencyData.length || data.length) ? <div className="chart-loading">{ui(locale, "暂无延迟数据", "No latency data")}</div> : chartType === "load" ? (
|
||||
{chartType === "load" && loadError ? <div className="error-band">{loadError}</div> : null}
|
||||
{chartType === "latency" && latencyError ? <div className="error-band">{latencyError}</div> : null}
|
||||
{chartType === "load" && loadLoading ? <div className="chart-loading">{ui(locale, "正在读取历史数据", "Loading history")}</div> : chartType === "latency" && latencyLoading ? <div className="chart-loading">{ui(locale, "正在读取延迟数据", "Loading latency")}</div> : chartType === "load" && !data.length ? <div className="chart-loading">{ui(locale, "暂无历史数据", "No history")}</div> : chartType === "load" ? (
|
||||
<div className="detail-charts-grid">
|
||||
<ChartCard title="CPU" value={`${number(last.cpu).toFixed(1)}%`}>
|
||||
<ResponsiveContainer width="100%" height="100%"><AreaChart data={data}><CartesianGrid stroke="var(--chart-grid)" strokeDasharray="3 3" /><XAxis dataKey="time" tick={{ fontSize: 10 }} minTickGap={30} /><YAxis domain={[0, 100]} tick={{ fontSize: 10 }} width={36} /><Tooltip contentStyle={tooltipStyle} formatter={(value) => [`${Number(value).toFixed(1)}%`, "CPU"]} /><Area type="monotone" dataKey="cpu" stroke="var(--danger)" fill="var(--danger)" fillOpacity={0.15} strokeWidth={2} dot={false} isAnimationActive={false} /></AreaChart></ResponsiveContainer>
|
||||
@@ -224,7 +333,7 @@ export function NodeDetails({ server, threshold, retentionDays, locale, demo = f
|
||||
</div>)}
|
||||
</div>
|
||||
<div className="latency-line-chart">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
{latencyData.length ? <ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={latencyData.length ? latencyData : data} margin={{ top: 10, right: 12, left: 4, bottom: 2 }}>
|
||||
<CartesianGrid stroke="var(--chart-grid)" strokeDasharray="3 3" />
|
||||
<XAxis dataKey="time" tick={{ fontSize: 10 }} minTickGap={40} />
|
||||
@@ -233,7 +342,7 @@ export function NodeDetails({ server, threshold, retentionDays, locale, demo = f
|
||||
<Legend iconType="rect" iconSize={9} wrapperStyle={{ fontSize: 11, paddingTop: 10 }} />
|
||||
{latencySeries.map((series) => <Line key={series.id} type="monotone" dataKey={series.dataKey} name={series.name} stroke={series.color} strokeWidth={2} dot={false} connectNulls={false} isAnimationActive={false} />)}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</ResponsiveContainer> : <div className="latency-empty">{ui(locale, "暂无延迟数据", "No latency data")}</div>}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
+25
-12
@@ -177,10 +177,6 @@ html[data-blur="off"] .glass-panel { background: color-mix(in srgb, var(--card-s
|
||||
.quality-bar:hover { z-index: 20; }
|
||||
.quality-bar:hover .quality-tooltip { visibility: visible; opacity: 1; transform: translateY(0); }
|
||||
.quality-bar:hover .quality-tooltip.center { transform: translate(-50%, 0); }
|
||||
.offline-layer { position: absolute; z-index: 3; inset: 0; display: flex; align-items: center; justify-content: center; flex-direction: column; border-radius: 0 0 12px 12px; background: var(--node-offline-bg); backdrop-filter: blur(2px); }
|
||||
html[data-blur="off"] .offline-layer { backdrop-filter: none; }
|
||||
.offline-layer strong { color: var(--danger); font-size: 13px; }
|
||||
.offline-layer span { margin-top: 4px; color: var(--muted-foreground); font-size: 9px; }
|
||||
|
||||
.error-band { display: flex; align-items: center; justify-content: space-between; border: 1px solid color-mix(in srgb, var(--danger) 32%, var(--border)); border-radius: 7px; padding: 12px 14px; color: var(--danger); background: color-mix(in srgb, var(--danger) 7%, transparent); font-size: 11px; }
|
||||
.error-band button { font-weight: 700; }
|
||||
@@ -188,6 +184,16 @@ html[data-blur="off"] .offline-layer { backdrop-filter: none; }
|
||||
.verification-gate { display: flex; width: min(430px, 100%); min-height: 380px; align-items: center; justify-content: center; flex-direction: column; gap: 14px; margin: 12vh auto 0; border: 1px solid var(--border); border-radius: 8px; padding: 30px; background: var(--card); box-shadow: var(--shadow); text-align: center; }
|
||||
.verification-gate h1 { margin: 0; font-size: 19px; }
|
||||
.verification-gate p { margin: 4px 0 0; color: var(--muted-foreground); font-size: 10px; }
|
||||
.dashboard-login-gate { display: flex; width: min(420px, 100%); align-items: center; flex-direction: column; gap: 16px; margin: 9vh auto 0; border-radius: 8px; padding: 28px; }
|
||||
.dashboard-login-copy { text-align: center; }
|
||||
.dashboard-login-copy h1 { margin: 0; font-size: 19px; }
|
||||
.dashboard-login-copy p { margin: 6px 0 0; color: var(--muted-foreground); font-size: 11px; line-height: 1.5; }
|
||||
.dashboard-login-form { display: flex; width: 100%; flex-direction: column; gap: 13px; }
|
||||
.dashboard-login-form label > span { font-size: 11px; }
|
||||
.dashboard-login-form input { min-height: 42px; font-size: 13px; }
|
||||
.dashboard-login-turnstile { display: flex; width: 100%; justify-content: center; }
|
||||
.dashboard-login-turnstile .turnstile-widget { width: 100%; }
|
||||
.dashboard-login-submit { width: 100%; min-height: 42px; }
|
||||
.verification-status { color: var(--primary); font-size: 10px; }
|
||||
.turnstile-widget { width: min(100%, 300px); min-height: 66px; }
|
||||
.turnstile-widget.testing { height: 51px; min-height: 51px; overflow: hidden; }
|
||||
@@ -238,6 +244,7 @@ html[data-blur="off"] .offline-layer { backdrop-filter: none; }
|
||||
.latency-summary strong { display: block; margin-top: 6px; font-size: 22px; font-weight: 650; font-variant-numeric: tabular-nums; letter-spacing: 0; }
|
||||
.latency-summary small { display: block; margin-top: 5px; color: var(--muted-foreground); font-size: 11px; font-variant-numeric: tabular-nums; }
|
||||
.latency-line-chart { width: 100%; height: 320px; padding: 16px 18px 10px 10px; }
|
||||
.latency-empty { display: grid; width: 100%; height: 100%; place-items: center; color: var(--muted-foreground); font-size: 12px; }
|
||||
|
||||
.modal-backdrop, .submodal-backdrop { position: fixed; z-index: 80; inset: 0; display: flex; align-items: center; justify-content: center; padding: 24px; background: rgb(5 10 12 / .56); backdrop-filter: blur(6px); }
|
||||
.submodal-backdrop { z-index: 90; background: rgb(5 10 12 / .42); }
|
||||
@@ -287,12 +294,9 @@ html[data-blur="off"] .offline-layer { backdrop-filter: none; }
|
||||
.login-turnstile:has(.turnstile-widget.testing) { min-height: 51px; }
|
||||
.login-turnstile:has(.turnstile-widget.verified) { min-height: 0; }
|
||||
.login-submit { width: 100%; min-height: 40px; }
|
||||
.login-error { border: 1px solid color-mix(in srgb, var(--danger) 32%, var(--border)); border-radius: 6px; padding: 8px 10px; background: color-mix(in srgb, var(--danger) 6%, transparent); }
|
||||
label { display: flex; min-width: 0; flex-direction: column; gap: 6px; }
|
||||
label > span { color: var(--muted-foreground); font-size: 9px; }
|
||||
.form-error { margin: 0; color: var(--danger); font-size: 9px; }
|
||||
.form-notice { margin: 0 21px 9px; border: 1px solid color-mix(in srgb, var(--success) 32%, var(--border)); border-radius: 6px; padding: 8px 10px; color: var(--success); background: color-mix(in srgb, var(--success) 7%, transparent); font-size: 9px; }
|
||||
.admin-error { margin: 0 21px 10px; border: 1px solid color-mix(in srgb, var(--danger) 32%, var(--border)); border-radius: 6px; padding: 8px 10px; background: color-mix(in srgb, var(--danger) 6%, transparent); line-height: 1.5; }
|
||||
.settings-form, .editor-modal { display: flex; flex-direction: column; gap: 14px; }
|
||||
.form-grid { display: grid; gap: 12px; grid-template-columns: 1fr 1fr; }
|
||||
.form-grid.three { grid-template-columns: repeat(3, 1fr); }
|
||||
@@ -328,6 +332,11 @@ label > span { color: var(--muted-foreground); font-size: 9px; }
|
||||
.usage-table thead th { color: var(--muted-foreground); background: color-mix(in srgb, var(--foreground) 3%, transparent); font-weight: 600; }
|
||||
.usage-table tbody th { font-weight: 650; }
|
||||
.usage-table tbody tr:last-child > * { border-bottom: 0; }
|
||||
.cloudflare-usage-table { min-width: 720px; }
|
||||
.usage-do-breakdown { display: grid; gap: 7px; grid-template-columns: 1fr 1fr; }
|
||||
.usage-do-breakdown > div { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; gap: 5px 10px; border: 1px solid var(--border); border-radius: 7px; padding: 9px 10px; color: var(--muted-foreground); font-size: 9px; }
|
||||
.usage-do-breakdown strong { color: var(--foreground); }
|
||||
.usage-do-breakdown small { flex-basis: 100%; color: var(--muted-foreground); font-size: inherit; }
|
||||
.usage-empty { display: flex; min-height: 48px; align-items: center; justify-content: center; border: 1px dashed var(--border); border-radius: 7px; color: var(--muted-foreground); font-size: 9px; }
|
||||
.data-actions { border-top: 1px solid var(--border); padding-top: 14px; }
|
||||
.theme-store-page { display: grid; gap: 16px; }
|
||||
@@ -451,7 +460,6 @@ label > span { color: var(--muted-foreground); font-size: 9px; }
|
||||
.admin-tabs button { min-height: 33px; flex: 0 0 auto; }
|
||||
.admin-content { height: calc(100vh - 112px); }
|
||||
.admin-section, .settings-form { padding: 14px; }
|
||||
.admin-error { margin-inline: 14px; }
|
||||
.section-head { align-items: flex-start; }
|
||||
.server-row { padding-block: 6px; grid-template-columns: 18px 20px 9px minmax(0, 1fr) auto; }
|
||||
.server-usage { display: none; }
|
||||
@@ -461,6 +469,7 @@ label > span { color: var(--muted-foreground); font-size: 9px; }
|
||||
.theme-card-icon { width: 32px; height: 32px; }
|
||||
.theme-card-actions { grid-column: 2; justify-content: flex-start; }
|
||||
.data-stat-grid { grid-template-columns: 1fr 1fr 1fr; }
|
||||
.usage-do-breakdown { grid-template-columns: 1fr; }
|
||||
.usage-table th, .usage-table td { padding-inline: 5px; font-size: 8px; }
|
||||
.editor-modal, .install-modal { padding: 15px 13px; }
|
||||
.editor-toggles { grid-template-columns: 1fr 1fr; }
|
||||
@@ -525,7 +534,6 @@ label > span { color: var(--muted-foreground); font-size: 9px; }
|
||||
.login-form img { width: 44px; height: 44px; }
|
||||
.login-copy h1 { color: var(--admin-text); font-size: 22px; }
|
||||
.login-copy p { color: var(--admin-muted); font-size: 13px; }
|
||||
.login-error, .admin-error { font-size: 13px; }
|
||||
.admin-shell { display: grid; width: 100%; max-width: 100vw; height: 100dvh; min-width: 0; min-height: 0; overflow: hidden; border: 0; border-radius: 0; background: var(--admin-surface); grid-template-rows: 64px minmax(0, 1fr); }
|
||||
.admin-topbar { z-index: 2; display: flex; width: 100%; max-width: 100vw; min-width: 0; align-items: center; justify-content: space-between; gap: 24px; overflow: hidden; border-bottom: 1px solid var(--admin-border); padding: 0 24px; background: var(--admin-surface); }
|
||||
.admin-brand { display: flex; min-width: 0; align-items: center; gap: 10px; padding: 0; color: var(--admin-text); text-align: left; text-decoration: none; }
|
||||
@@ -546,6 +554,11 @@ label > span { color: var(--muted-foreground); font-size: 9px; }
|
||||
.admin-content-header { padding: 25px 28px 17px; }
|
||||
.admin-content-header h1 { margin: 0; color: var(--admin-text); font-size: 22px; line-height: 1.25; }
|
||||
.admin-content-header p { margin: 6px 0 0; color: var(--admin-muted); font-size: 13px; }
|
||||
.admin-toast { position: fixed; z-index: 120; top: 80px; right: 24px; display: flex; width: min(420px, calc(100vw - 32px)); min-height: 64px; align-items: center; gap: 11px; border: 1px solid var(--admin-border); border-radius: 8px; padding: 14px 17px; color: var(--admin-text); background: var(--admin-surface); box-shadow: 0 12px 34px rgb(15 23 42 / .18); font-size: 14px; font-weight: 600; line-height: 1.45; animation: admin-toast-in .18s ease-out; }
|
||||
.admin-toast svg { width: 21px; height: 21px; flex: 0 0 auto; color: var(--success); stroke-width: 2.5; }
|
||||
.admin-toast.error { border-color: color-mix(in srgb, var(--danger) 38%, var(--admin-border)); }
|
||||
.admin-toast.error svg { color: var(--danger); }
|
||||
@keyframes admin-toast-in { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: translateY(0); } }
|
||||
.admin-section, .settings-form { margin: 0 28px 28px; border: 1px solid var(--admin-border); border-radius: 7px; padding: 21px; color: var(--admin-text); background: var(--admin-surface); }
|
||||
.section-head { margin-bottom: 16px; }
|
||||
.section-head h3 { color: var(--admin-text); font-size: 17px; }
|
||||
@@ -558,8 +571,6 @@ label > span { color: var(--muted-foreground); font-size: 9px; }
|
||||
.server-row .icon-btn { width: 32px; height: 32px; }
|
||||
.drag-handle { color: var(--admin-muted); }
|
||||
.list-empty { color: var(--admin-muted); font-size: 13px; }
|
||||
.form-notice { margin: 0 28px 14px; padding: 10px 12px; font-size: 13px; }
|
||||
.admin-error { margin: 0 28px 14px; padding: 10px 12px; }
|
||||
.settings-form, .editor-modal { gap: 17px; }
|
||||
.form-grid { gap: 15px; }
|
||||
.settings-toggles { gap: 12px; }
|
||||
@@ -573,6 +584,8 @@ label > span { color: var(--muted-foreground); font-size: 9px; }
|
||||
.data-stat span { color: var(--admin-muted); font-size: 12px; }
|
||||
.usage-table th, .usage-table td { height: 40px; color: var(--admin-text); font-size: 12px; }
|
||||
.usage-table thead th { color: var(--admin-muted); background: var(--admin-bg); }
|
||||
.admin-page .usage-do-breakdown > div { border-color: var(--admin-border); color: var(--admin-muted); font-size: 12px; }
|
||||
.admin-page .usage-do-breakdown strong { color: var(--admin-text); }
|
||||
.usage-empty { color: var(--admin-muted); font-size: 13px; }
|
||||
.admin-page .theme-store-card { border-color: var(--admin-border); background: var(--admin-surface); }
|
||||
.admin-page .theme-store-card.active { border-color: var(--admin-accent-border); box-shadow: 0 0 0 1px color-mix(in srgb, var(--admin-accent) 18%, transparent); }
|
||||
@@ -634,7 +647,7 @@ label > span { color: var(--muted-foreground); font-size: 9px; }
|
||||
.admin-content-header h1 { font-size: 20px; }
|
||||
.admin-content-header p { font-size: 12px; }
|
||||
.admin-section, .settings-form { margin: 0 12px 18px; padding: 17px 15px; }
|
||||
.form-notice, .admin-error { margin: 0 12px 12px; }
|
||||
.admin-toast { top: 70px; right: 16px; min-height: 58px; padding: 12px 14px; font-size: 13px; }
|
||||
.section-head { min-width: 0; align-items: flex-start; flex-wrap: wrap; }
|
||||
.server-row { grid-template-columns: 20px 20px 9px minmax(0, 1fr) auto; }
|
||||
.server-row .row-actions { grid-column: 4 / 6; }
|
||||
|
||||
@@ -109,6 +109,13 @@ export interface LatencySample {
|
||||
packet_loss: number;
|
||||
}
|
||||
|
||||
export interface LiveLatencyResult {
|
||||
task_id: string;
|
||||
timestamp: number;
|
||||
latency_ms: number;
|
||||
packet_loss: number;
|
||||
}
|
||||
|
||||
export interface LatencyTestPoint {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -295,6 +302,15 @@ interface CloudflareUsagePeriod {
|
||||
rows_read: number;
|
||||
rows_written: number;
|
||||
workers_requests: number;
|
||||
durable_objects_requests: number;
|
||||
durable_objects_http_requests: number;
|
||||
durable_objects_hibernation_wakeups: number;
|
||||
durable_objects_inbound_websocket_messages: number;
|
||||
durable_objects_outbound_websocket_messages: number;
|
||||
durable_objects_raw_requests: number;
|
||||
durable_objects_requests_estimated: boolean;
|
||||
durable_objects_request_billing_ratio: number;
|
||||
durable_objects_duration: number;
|
||||
}
|
||||
|
||||
export interface CloudflareUsage {
|
||||
|
||||
+32
-13
@@ -9,7 +9,7 @@ use worker::{
|
||||
Date, Env, Request,
|
||||
};
|
||||
|
||||
const SESSION_SECONDS: i64 = 7 * 24 * 60 * 60;
|
||||
pub const ADMIN_SESSION_SECONDS: i64 = 7 * 24 * 60 * 60;
|
||||
const TURNSTILE_PROOF_SECONDS: i64 = 60 * 60;
|
||||
const THEME_PREVIEW_SECONDS: i64 = 10 * 60;
|
||||
const SERVER_PASSWORD_ROUNDS: u32 = 10_000;
|
||||
@@ -137,7 +137,7 @@ fn create_admin_jwt_with_secret(secret: &str, issued_at: i64) -> Option<String>
|
||||
serde_json::to_vec(&AdminClaims {
|
||||
sub: "admin".to_string(),
|
||||
iat: issued_at,
|
||||
exp: issued_at + SESSION_SECONDS,
|
||||
exp: issued_at + ADMIN_SESSION_SECONDS,
|
||||
})
|
||||
.ok()?,
|
||||
);
|
||||
@@ -177,7 +177,7 @@ fn verify_admin_jwt(token: &str, secret: &str, now: i64) -> bool {
|
||||
claims.sub == "admin"
|
||||
&& claims.iat <= now + 60
|
||||
&& claims.exp >= now
|
||||
&& claims.exp == claims.iat + SESSION_SECONDS
|
||||
&& claims.exp == claims.iat + ADMIN_SESSION_SECONDS
|
||||
}
|
||||
|
||||
pub fn create_admin_jwt(env: &Env, password_hash: &str) -> Option<String> {
|
||||
@@ -189,14 +189,33 @@ pub fn is_admin(req: &Request, env: &Env, password_hash: &str) -> bool {
|
||||
let Some(secret) = signing_secret(env, password_hash) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(Some(header)) = req.headers().get("Authorization") else {
|
||||
return false;
|
||||
};
|
||||
let Some(token) = header.strip_prefix("Bearer ") else {
|
||||
return false;
|
||||
};
|
||||
let now = Date::now().as_millis() as i64 / 1000;
|
||||
verify_admin_jwt(token, &secret, now)
|
||||
let bearer_valid = req
|
||||
.headers()
|
||||
.get("Authorization")
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some_and(|header| {
|
||||
header
|
||||
.strip_prefix("Bearer ")
|
||||
.is_some_and(|token| verify_admin_jwt(token, &secret, now))
|
||||
});
|
||||
if bearer_valid {
|
||||
return true;
|
||||
}
|
||||
req.headers()
|
||||
.get("Cookie")
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some_and(|cookies| {
|
||||
cookies
|
||||
.split(';')
|
||||
.find_map(|entry| {
|
||||
let (name, value) = entry.trim().split_once('=')?;
|
||||
(name == "nodeflare_admin").then_some(value)
|
||||
})
|
||||
.is_some_and(|token| verify_admin_jwt(token, &secret, now))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_turnstile_proof(env: &Env, password_hash: &str) -> Option<String> {
|
||||
@@ -273,7 +292,7 @@ pub fn bearer_token(req: &Request) -> Option<String> {
|
||||
mod tests {
|
||||
use super::{
|
||||
create_admin_jwt_with_secret, hash_password, verify_admin_jwt, verify_password_hash,
|
||||
SESSION_SECONDS,
|
||||
ADMIN_SESSION_SECONDS,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -303,12 +322,12 @@ mod tests {
|
||||
assert!(verify_admin_jwt(
|
||||
&token,
|
||||
"d1-password-hash",
|
||||
issued_at + SESSION_SECONDS
|
||||
issued_at + ADMIN_SESSION_SECONDS
|
||||
));
|
||||
assert!(!verify_admin_jwt(
|
||||
&token,
|
||||
"d1-password-hash",
|
||||
issued_at + SESSION_SECONDS + 1
|
||||
issued_at + ADMIN_SESSION_SECONDS + 1
|
||||
));
|
||||
assert!(!verify_admin_jwt(&token, "different-secret", issued_at));
|
||||
}
|
||||
|
||||
+203
-8
@@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize};
|
||||
use worker::{wasm_bindgen::JsValue, Fetch, Method, Request, RequestInit, Result};
|
||||
|
||||
const GRAPHQL_URL: &str = "https://api.cloudflare.com/client/v4/graphql";
|
||||
const DURABLE_OBJECTS_WEBSOCKET_MESSAGE_BILLING_RATIO: i64 = 20;
|
||||
const USAGE_QUERY: &str = r#"
|
||||
query CloudflareUsage(
|
||||
$accountTag: string!,
|
||||
@@ -24,6 +25,19 @@ query CloudflareUsage(
|
||||
) {
|
||||
sum { requests }
|
||||
}
|
||||
durableObjectsInvocationsAdaptiveGroups(
|
||||
limit: 10000,
|
||||
filter: { date_geq: $start, date_leq: $end }
|
||||
) {
|
||||
sum { requests }
|
||||
dimensions { type }
|
||||
}
|
||||
durableObjectsPeriodicGroups(
|
||||
limit: 10000,
|
||||
filter: { date_geq: $start, date_leq: $end }
|
||||
) {
|
||||
sum { duration inboundWebsocketMsgCount outboundWebsocketMsgCount }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,6 +48,15 @@ pub struct UsagePeriod {
|
||||
pub rows_read: i64,
|
||||
pub rows_written: i64,
|
||||
pub workers_requests: i64,
|
||||
pub durable_objects_requests: i64,
|
||||
pub durable_objects_http_requests: i64,
|
||||
pub durable_objects_hibernation_wakeups: i64,
|
||||
pub durable_objects_inbound_websocket_messages: i64,
|
||||
pub durable_objects_outbound_websocket_messages: i64,
|
||||
pub durable_objects_raw_requests: i64,
|
||||
pub durable_objects_requests_estimated: bool,
|
||||
pub durable_objects_request_billing_ratio: i64,
|
||||
pub durable_objects_duration: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -61,8 +84,7 @@ struct GraphQlVariables<'a> {
|
||||
#[derive(Deserialize)]
|
||||
struct GraphQlResponse {
|
||||
data: Option<GraphQlData>,
|
||||
#[serde(default)]
|
||||
errors: Vec<GraphQlError>,
|
||||
errors: Option<Vec<GraphQlError>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -87,6 +109,10 @@ struct AccountUsage {
|
||||
d1_analytics_adaptive_groups: Vec<D1Group>,
|
||||
#[serde(default)]
|
||||
workers_invocations_adaptive: Vec<WorkersGroup>,
|
||||
#[serde(default)]
|
||||
durable_objects_invocations_adaptive_groups: Vec<DurableObjectsInvocationGroup>,
|
||||
#[serde(default)]
|
||||
durable_objects_periodic_groups: Vec<DurableObjectsPeriodicGroup>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -114,6 +140,104 @@ struct WorkersSum {
|
||||
requests: i64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DurableObjectsInvocationGroup {
|
||||
sum: DurableObjectsInvocationSum,
|
||||
dimensions: DurableObjectsInvocationDimensions,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DurableObjectsInvocationSum {
|
||||
#[serde(default)]
|
||||
requests: i64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DurableObjectsInvocationDimensions {
|
||||
#[serde(rename = "type", default)]
|
||||
invocation_type: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DurableObjectsPeriodicGroup {
|
||||
sum: DurableObjectsPeriodicSum,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DurableObjectsPeriodicSum {
|
||||
#[serde(default)]
|
||||
duration: f64,
|
||||
#[serde(default)]
|
||||
inbound_websocket_msg_count: i64,
|
||||
#[serde(default)]
|
||||
outbound_websocket_msg_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct DurableObjectsSummary {
|
||||
http_requests: i64,
|
||||
hibernation_wakeups: i64,
|
||||
inbound_websocket_messages: i64,
|
||||
outbound_websocket_messages: i64,
|
||||
raw_requests: i64,
|
||||
billable_requests: i64,
|
||||
duration: f64,
|
||||
}
|
||||
|
||||
fn is_hibernation_invocation_type(value: &str) -> bool {
|
||||
let normalized = value
|
||||
.chars()
|
||||
.filter(|character| character.is_ascii_alphanumeric())
|
||||
.flat_map(char::to_lowercase)
|
||||
.collect::<String>();
|
||||
normalized.contains("hibernation")
|
||||
|| (normalized.contains("websocket") && normalized.contains("message"))
|
||||
}
|
||||
|
||||
fn ceil_div_nonnegative(value: i64, divisor: i64) -> i64 {
|
||||
if value <= 0 {
|
||||
0
|
||||
} else {
|
||||
value.saturating_add(divisor - 1) / divisor
|
||||
}
|
||||
}
|
||||
|
||||
fn summarize_durable_objects(
|
||||
invocation_groups: Vec<DurableObjectsInvocationGroup>,
|
||||
periodic_groups: Vec<DurableObjectsPeriodicGroup>,
|
||||
) -> DurableObjectsSummary {
|
||||
let mut summary = DurableObjectsSummary::default();
|
||||
for group in invocation_groups {
|
||||
let requests = group.sum.requests.max(0);
|
||||
summary.raw_requests = summary.raw_requests.saturating_add(requests);
|
||||
if is_hibernation_invocation_type(&group.dimensions.invocation_type) {
|
||||
summary.hibernation_wakeups = summary.hibernation_wakeups.saturating_add(requests);
|
||||
} else {
|
||||
summary.http_requests = summary.http_requests.saturating_add(requests);
|
||||
}
|
||||
}
|
||||
for group in periodic_groups {
|
||||
summary.inbound_websocket_messages = summary
|
||||
.inbound_websocket_messages
|
||||
.saturating_add(group.sum.inbound_websocket_msg_count.max(0));
|
||||
summary.outbound_websocket_messages = summary
|
||||
.outbound_websocket_messages
|
||||
.saturating_add(group.sum.outbound_websocket_msg_count.max(0));
|
||||
if group.sum.duration.is_finite() && group.sum.duration > 0.0 {
|
||||
summary.duration += group.sum.duration;
|
||||
}
|
||||
}
|
||||
summary.billable_requests = summary
|
||||
.http_requests
|
||||
.saturating_add(summary.hibernation_wakeups)
|
||||
.saturating_add(ceil_div_nonnegative(
|
||||
summary.inbound_websocket_messages,
|
||||
DURABLE_OBJECTS_WEBSOCKET_MESSAGE_BILLING_RATIO,
|
||||
));
|
||||
summary
|
||||
}
|
||||
|
||||
pub fn date_from_unix_days(days: i64) -> String {
|
||||
let z = days + 719_468;
|
||||
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
||||
@@ -155,9 +279,9 @@ async fn query_period(token: &str, account_id: &str, date: &str) -> Result<Usage
|
||||
let mut response = Fetch::Request(request).send().await?;
|
||||
let status = response.status_code();
|
||||
let payload: GraphQlResponse = response.json().await?;
|
||||
if !(200..300).contains(&status) || !payload.errors.is_empty() {
|
||||
let message = payload
|
||||
.errors
|
||||
let errors = payload.errors.unwrap_or_default();
|
||||
if !(200..300).contains(&status) || !errors.is_empty() {
|
||||
let message = errors
|
||||
.into_iter()
|
||||
.map(|value| value.message)
|
||||
.collect::<Vec<_>>()
|
||||
@@ -187,18 +311,33 @@ async fn query_period(token: &str, account_id: &str, date: &str) -> Result<Usage
|
||||
.fold(0_i64, |total, group| {
|
||||
total.saturating_add(group.sum.requests)
|
||||
});
|
||||
let durable_objects = summarize_durable_objects(
|
||||
account.durable_objects_invocations_adaptive_groups,
|
||||
account.durable_objects_periodic_groups,
|
||||
);
|
||||
Ok(UsagePeriod {
|
||||
rows_read,
|
||||
rows_written,
|
||||
workers_requests,
|
||||
durable_objects_requests: durable_objects.billable_requests,
|
||||
durable_objects_http_requests: durable_objects.http_requests,
|
||||
durable_objects_hibernation_wakeups: durable_objects.hibernation_wakeups,
|
||||
durable_objects_inbound_websocket_messages: durable_objects.inbound_websocket_messages,
|
||||
durable_objects_outbound_websocket_messages: durable_objects.outbound_websocket_messages,
|
||||
durable_objects_raw_requests: durable_objects.raw_requests,
|
||||
durable_objects_requests_estimated: true,
|
||||
durable_objects_request_billing_ratio: DURABLE_OBJECTS_WEBSOCKET_MESSAGE_BILLING_RATIO,
|
||||
durable_objects_duration: durable_objects.duration,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn usage(token: &str, account_id: &str, now: i64) -> Result<CloudflareUsage> {
|
||||
let today = date_from_unix_days(now.div_euclid(86_400));
|
||||
let yesterday = date_from_unix_days(now.div_euclid(86_400) - 1);
|
||||
let today_usage = query_period(token, account_id, &today).await?;
|
||||
let yesterday_usage = query_period(token, account_id, &yesterday).await?;
|
||||
let (today_usage, yesterday_usage) = futures_util::try_join!(
|
||||
query_period(token, account_id, &today),
|
||||
query_period(token, account_id, &yesterday)
|
||||
)?;
|
||||
Ok(CloudflareUsage {
|
||||
today: today_usage,
|
||||
yesterday: yesterday_usage,
|
||||
@@ -207,11 +346,67 @@ pub async fn usage(token: &str, account_id: &str, now: i64) -> Result<Cloudflare
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::date_from_unix_days;
|
||||
use super::{
|
||||
ceil_div_nonnegative, date_from_unix_days, is_hibernation_invocation_type,
|
||||
summarize_durable_objects, DurableObjectsInvocationDimensions,
|
||||
DurableObjectsInvocationGroup, DurableObjectsInvocationSum, DurableObjectsPeriodicGroup,
|
||||
DurableObjectsPeriodicSum, GraphQlResponse,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn converts_unix_days_to_utc_date() {
|
||||
assert_eq!(date_from_unix_days(0), "1970-01-01");
|
||||
assert_eq!(date_from_unix_days(20_673), "2026-08-08");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_null_errors_in_successful_graphql_responses() {
|
||||
let response: GraphQlResponse =
|
||||
serde_json::from_str(r#"{"data":{"viewer":{"accounts":[]}},"errors":null}"#)
|
||||
.expect("Cloudflare GraphQL response");
|
||||
assert!(response.errors.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_durable_objects_hibernation_invocations() {
|
||||
assert!(is_hibernation_invocation_type("webSocketMessage"));
|
||||
assert!(is_hibernation_invocation_type("hibernationWakeup"));
|
||||
assert!(!is_hibernation_invocation_type("fetch"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calculates_durable_objects_billable_requests() {
|
||||
let summary = summarize_durable_objects(
|
||||
vec![
|
||||
DurableObjectsInvocationGroup {
|
||||
sum: DurableObjectsInvocationSum { requests: 7 },
|
||||
dimensions: DurableObjectsInvocationDimensions {
|
||||
invocation_type: "fetch".to_string(),
|
||||
},
|
||||
},
|
||||
DurableObjectsInvocationGroup {
|
||||
sum: DurableObjectsInvocationSum { requests: 3 },
|
||||
dimensions: DurableObjectsInvocationDimensions {
|
||||
invocation_type: "webSocketMessage".to_string(),
|
||||
},
|
||||
},
|
||||
],
|
||||
vec![DurableObjectsPeriodicGroup {
|
||||
sum: DurableObjectsPeriodicSum {
|
||||
duration: 2.5,
|
||||
inbound_websocket_msg_count: 21,
|
||||
outbound_websocket_msg_count: 11,
|
||||
},
|
||||
}],
|
||||
);
|
||||
assert_eq!(summary.http_requests, 7);
|
||||
assert_eq!(summary.hibernation_wakeups, 3);
|
||||
assert_eq!(summary.raw_requests, 10);
|
||||
assert_eq!(summary.billable_requests, 12);
|
||||
assert_eq!(summary.outbound_websocket_messages, 11);
|
||||
assert_eq!(summary.duration, 2.5);
|
||||
assert_eq!(ceil_div_nonnegative(0, 20), 0);
|
||||
assert_eq!(ceil_div_nonnegative(20, 20), 1);
|
||||
assert_eq!(ceil_div_nonnegative(21, 20), 2);
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,21 @@ struct AgentConfigRow {
|
||||
auto_update: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AgentLiveContext {
|
||||
pub report_interval: i64,
|
||||
pub collect_interval: i64,
|
||||
pub reset_day: i64,
|
||||
pub cycle_key: i64,
|
||||
pub raw_rx: i64,
|
||||
pub raw_tx: i64,
|
||||
pub used_rx: i64,
|
||||
pub used_tx: i64,
|
||||
pub rx_correction: i64,
|
||||
pub tx_correction: i64,
|
||||
pub last_persisted_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct TrafficCounterSample {
|
||||
timestamp: i64,
|
||||
@@ -113,6 +128,7 @@ pub struct SettingsView {
|
||||
pub password_client_salt: String,
|
||||
pub turnstile_enabled: bool,
|
||||
pub turnstile_login_enabled: bool,
|
||||
#[serde(serialize_with = "serialize_secret")]
|
||||
pub turnstile_site_key: String,
|
||||
#[serde(serialize_with = "serialize_secret")]
|
||||
pub turnstile_secret_key: String,
|
||||
@@ -122,6 +138,7 @@ pub struct SettingsView {
|
||||
pub notification_target: String,
|
||||
pub offline_alert_minutes: i64,
|
||||
pub expiry_alert_days: i64,
|
||||
#[serde(serialize_with = "serialize_secret")]
|
||||
pub cloudflare_account_id: String,
|
||||
#[serde(serialize_with = "serialize_secret")]
|
||||
pub cloudflare_api_token: String,
|
||||
@@ -215,7 +232,7 @@ fn days_in_month(year: i64, month: i64) -> i64 {
|
||||
}
|
||||
}
|
||||
|
||||
fn traffic_cycle_key(timestamp: i64, reset_day: i64) -> i64 {
|
||||
pub(crate) fn traffic_cycle_key(timestamp: i64, reset_day: i64) -> i64 {
|
||||
let (year, month, day) = civil_date_from_unix_days(timestamp.div_euclid(86_400));
|
||||
let boundary = reset_day.clamp(1, 31).min(days_in_month(year, month));
|
||||
let current_month = year * 12 + month - 1;
|
||||
@@ -257,6 +274,38 @@ pub async fn get_agent_identity(db: &D1Database, token: &str) -> Result<Option<A
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn agent_live_context(db: &D1Database, id: &str) -> Result<Option<AgentLiveContext>> {
|
||||
let mut context: Option<AgentLiveContext> = db
|
||||
.prepare(
|
||||
r#"SELECT
|
||||
s.report_interval,
|
||||
s.collect_interval,
|
||||
s.reset_day,
|
||||
COALESCE(c.cycle_key, 0) AS cycle_key,
|
||||
COALESCE(c.raw_rx, m.net_rx_total, 0) AS raw_rx,
|
||||
COALESCE(c.raw_tx, m.net_tx_total, 0) AS raw_tx,
|
||||
COALESCE(c.used_rx, m.net_rx_total, 0) AS used_rx,
|
||||
COALESCE(c.used_tx, m.net_tx_total, 0) AS used_tx,
|
||||
s.rx_correction,
|
||||
s.tx_correction,
|
||||
COALESCE(m.timestamp, 0) AS last_persisted_at
|
||||
FROM servers s
|
||||
LEFT JOIN latest_metrics m ON m.server_id = s.id
|
||||
LEFT JOIN traffic_cycles c ON c.server_id = s.id
|
||||
WHERE s.id = ?1
|
||||
LIMIT 1"#,
|
||||
)
|
||||
.bind(&[text(id)])?
|
||||
.first(None)
|
||||
.await?;
|
||||
if let Some(context) = context.as_mut() {
|
||||
if context.cycle_key == 0 {
|
||||
context.cycle_key = traffic_cycle_key(crate::now(), context.reset_day);
|
||||
}
|
||||
}
|
||||
Ok(context)
|
||||
}
|
||||
|
||||
pub async fn get_agent_token(db: &D1Database, id: &str) -> Result<Option<String>> {
|
||||
db.prepare("SELECT token FROM servers WHERE id = ?1 LIMIT 1")
|
||||
.bind(&[text(id)])?
|
||||
@@ -1122,7 +1171,11 @@ pub async fn update_settings(
|
||||
if value { "true" } else { "false" }
|
||||
);
|
||||
}
|
||||
if let Some(value) = input.turnstile_site_key.as_deref() {
|
||||
if let Some(value) = input
|
||||
.turnstile_site_key
|
||||
.as_deref()
|
||||
.filter(|value| value.trim() != SECRET_MASK)
|
||||
{
|
||||
push_setting!("turnstile_site_key", value.trim());
|
||||
}
|
||||
if let Some(value) = input
|
||||
@@ -1153,14 +1206,13 @@ pub async fn update_settings(
|
||||
let value = value.clamp(0, 365).to_string();
|
||||
push_setting!("expiry_alert_days", &value);
|
||||
}
|
||||
macro_rules! push_trimmed {
|
||||
($field:expr, $key:expr) => {
|
||||
if let Some(value) = $field.as_deref() {
|
||||
push_setting!($key, value.trim());
|
||||
}
|
||||
};
|
||||
if let Some(value) = input
|
||||
.cloudflare_account_id
|
||||
.as_deref()
|
||||
.filter(|value| value.trim() != SECRET_MASK)
|
||||
{
|
||||
push_setting!("cloudflare_account_id", value.trim());
|
||||
}
|
||||
push_trimmed!(input.cloudflare_account_id, "cloudflare_account_id");
|
||||
if let Some(value) = input
|
||||
.cloudflare_api_token
|
||||
.as_deref()
|
||||
|
||||
+112
-28
@@ -19,7 +19,7 @@ use worker::*;
|
||||
use crate::auth::{
|
||||
bearer_token, create_admin_jwt, create_theme_preview_proof, create_turnstile_proof,
|
||||
hash_password, is_admin, random_salt, sha256_hex, verify_credentials,
|
||||
verify_theme_preview_proof, verify_turnstile_proof,
|
||||
verify_theme_preview_proof, verify_turnstile_proof, ADMIN_SESSION_SECONDS,
|
||||
};
|
||||
use crate::models::{
|
||||
AgentDiskMetric, AgentGpuMetric, AgentReport, AgentReportBatch, AlertRuleInput, ApiError,
|
||||
@@ -90,7 +90,7 @@ struct PublicServer {
|
||||
latency: Vec<latency::LatencySample>,
|
||||
}
|
||||
|
||||
fn now() -> i64 {
|
||||
pub(crate) fn now() -> i64 {
|
||||
Date::now().as_millis() as i64 / 1000
|
||||
}
|
||||
|
||||
@@ -115,6 +115,20 @@ fn env_secret_text(env: &Env, key: &str) -> String {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn settings_for_admin_response(mut settings: db::SettingsView, env: &Env) -> db::SettingsView {
|
||||
for (field, variable) in [
|
||||
(&mut settings.turnstile_site_key, "TURNSTILE_SITE_KEY"),
|
||||
(&mut settings.turnstile_secret_key, "TURNSTILE_SECRET_KEY"),
|
||||
(&mut settings.cloudflare_account_id, "CF_USAGE_ACCOUNT_ID"),
|
||||
(&mut settings.cloudflare_api_token, "CF_USAGE_API_TOKEN"),
|
||||
] {
|
||||
if field.trim().is_empty() && !env_secret_text(env, variable).trim().is_empty() {
|
||||
*field = db::SECRET_MASK.to_string();
|
||||
}
|
||||
}
|
||||
settings
|
||||
}
|
||||
|
||||
fn json<T: Serialize>(value: &T, status: u16) -> Result<Response> {
|
||||
let mut response = Response::from_json(value)?.with_status(status);
|
||||
set_api_headers(&mut response)?;
|
||||
@@ -139,6 +153,25 @@ fn no_content() -> Result<Response> {
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn set_admin_session_cookie(
|
||||
response: &mut Response,
|
||||
req: &Request,
|
||||
token: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let secure = req.url().ok().is_some_and(|url| url.scheme() == "https");
|
||||
let (value, max_age) = token
|
||||
.map(|value| (value, ADMIN_SESSION_SECONDS))
|
||||
.unwrap_or(("", 0));
|
||||
response.headers_mut().append(
|
||||
"Set-Cookie",
|
||||
&format!(
|
||||
"nodeflare_admin={value}; Path=/; Max-Age={max_age}; HttpOnly{}; SameSite=Strict",
|
||||
if secure { "; Secure" } else { "" }
|
||||
),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn request_json<T: DeserializeOwned>(req: &mut Request, limit: usize) -> Result<T> {
|
||||
if req
|
||||
.headers()
|
||||
@@ -427,7 +460,8 @@ fn public_probe_ipv4(address: Ipv4Addr) -> bool {
|
||||
|
||||
fn valid_cloudflare_account_id(value: &str) -> bool {
|
||||
let value = value.trim();
|
||||
value.is_empty()
|
||||
value == db::SECRET_MASK
|
||||
|| value.is_empty()
|
||||
|| (value.len() == 32 && value.chars().all(|character| character.is_ascii_hexdigit()))
|
||||
}
|
||||
|
||||
@@ -597,7 +631,7 @@ fn validate_server(input: &ServerInput) -> Option<&'static str> {
|
||||
None
|
||||
}
|
||||
|
||||
fn validate_report(report: &AgentReport) -> Option<&'static str> {
|
||||
pub(crate) fn validate_report(report: &AgentReport) -> Option<&'static str> {
|
||||
let floats = [
|
||||
report.cpu,
|
||||
report.load1,
|
||||
@@ -783,7 +817,9 @@ fn client_ip(req: &Request) -> Option<String> {
|
||||
fn rate_limit_binding(method: Method, path: &str) -> Option<&'static str> {
|
||||
if method == Method::Post && matches!(path, "/api/admin/login" | "/api/turnstile/verify") {
|
||||
Some("AUTH_RATE_LIMITER")
|
||||
} else if method == Method::Post && path == "/api/agent/report" {
|
||||
} else if (method == Method::Post && path == "/api/agent/report")
|
||||
|| (method == Method::Get && path == "/api/agent/live")
|
||||
{
|
||||
Some("AGENT_RATE_LIMITER")
|
||||
} else if path.starts_with("/api/") {
|
||||
Some("API_RATE_LIMITER")
|
||||
@@ -991,6 +1027,9 @@ async fn handle_agent_report(
|
||||
response
|
||||
.headers_mut()
|
||||
.set("X-Agent-Config-Sha256", &config_hash)?;
|
||||
response
|
||||
.headers_mut()
|
||||
.set("X-NodeFlare-Server-Time", &received_at.to_string())?;
|
||||
response.headers_mut().set("Cache-Control", "no-store")?;
|
||||
return Ok(response);
|
||||
}
|
||||
@@ -998,6 +1037,9 @@ async fn handle_agent_report(
|
||||
response
|
||||
.headers_mut()
|
||||
.set("X-Agent-Config-Sha256", &config_hash)?;
|
||||
response
|
||||
.headers_mut()
|
||||
.set("X-NodeFlare-Server-Time", &received_at.to_string())?;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
@@ -1028,6 +1070,19 @@ async fn handle(mut req: Request, env: Env, ctx: Context) -> Result<Response> {
|
||||
if method == Method::Post && path == "/api/agent/report" {
|
||||
return handle_agent_report(req, env, ctx, &database).await;
|
||||
}
|
||||
if method == Method::Get && path == "/api/agent/live" {
|
||||
let token = match bearer_token(&req) {
|
||||
Some(value) => value,
|
||||
None => return error("缺少探针凭据", 401),
|
||||
};
|
||||
let Some(identity) = db::get_agent_identity(&database, &token).await? else {
|
||||
return error("探针凭据无效", 401);
|
||||
};
|
||||
let Some(context) = db::agent_live_context(&database, &identity.id).await? else {
|
||||
return error("节点不存在", 404);
|
||||
};
|
||||
return live::upgrade_agent(req, &env, &identity.id, identity.hidden != 0, context).await;
|
||||
}
|
||||
|
||||
let default_name = env_text(&env, "SITE_NAME", "NodeFlare");
|
||||
let default_threshold = env_number(&env, "OFFLINE_THRESHOLD_SECONDS", 180).clamp(30, 3600);
|
||||
@@ -1059,7 +1114,7 @@ async fn handle(mut req: Request, env: Env, ctx: Context) -> Result<Response> {
|
||||
let login_protection_enabled = settings.turnstile_login_enabled && turnstile_configured;
|
||||
|
||||
if method == Method::Get && path == "/api/config" {
|
||||
return json(
|
||||
let mut response = json(
|
||||
&serde_json::json!({
|
||||
"site_name": settings.site_name,
|
||||
"site_description": settings.site_description,
|
||||
@@ -1089,7 +1144,11 @@ async fn handle(mut req: Request, env: Env, ctx: Context) -> Result<Response> {
|
||||
"password_client_salt": settings.password_client_salt
|
||||
}),
|
||||
200,
|
||||
);
|
||||
)?;
|
||||
response
|
||||
.headers_mut()
|
||||
.set("X-NodeFlare-Server-Time", &now().to_string())?;
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
if method == Method::Post && path == "/api/admin/login" {
|
||||
@@ -1145,7 +1204,9 @@ async fn handle(mut req: Request, env: Env, ctx: Context) -> Result<Response> {
|
||||
let Some(token) = create_admin_jwt(&env, &session_password_hash) else {
|
||||
return error("会话密钥未配置", 503);
|
||||
};
|
||||
return json(&serde_json::json!({ "token": token }), 200);
|
||||
let mut response = json(&serde_json::json!({ "token": token }), 200)?;
|
||||
set_admin_session_cookie(&mut response, &req, Some(&token))?;
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
if method == Method::Post && path == "/api/turnstile/verify" {
|
||||
@@ -1188,6 +1249,12 @@ async fn handle(mut req: Request, env: Env, ctx: Context) -> Result<Response> {
|
||||
verify_turnstile_proof(&value, &env, &settings.admin_password_hash)
|
||||
});
|
||||
|
||||
if method == Method::Post && path == "/api/admin/logout" {
|
||||
let mut response = no_content()?;
|
||||
set_admin_session_cookie(&mut response, &req, None)?;
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
if method == Method::Get && path == "/api/exchange-rates" {
|
||||
if !turnstile_verified {
|
||||
return error("请先完成 Cloudflare 人机验证", 403);
|
||||
@@ -1497,6 +1564,9 @@ async fn handle(mut req: Request, env: Env, ctx: Context) -> Result<Response> {
|
||||
return error("批量删除列表无效", 400);
|
||||
}
|
||||
db::delete_servers(&database, &input.ids).await?;
|
||||
if let Err(error) = live::disconnect_agents(&env, &input.ids).await {
|
||||
console_error!("failed to disconnect deleted Agents: {error}");
|
||||
}
|
||||
return no_content();
|
||||
}
|
||||
|
||||
@@ -1561,11 +1631,13 @@ async fn handle(mut req: Request, env: Env, ctx: Context) -> Result<Response> {
|
||||
return error("节点 ID 无效", 400);
|
||||
};
|
||||
if method == Method::Delete {
|
||||
return if db::delete_server(&database, &id).await? {
|
||||
no_content()
|
||||
} else {
|
||||
error("节点不存在", 404)
|
||||
};
|
||||
if !db::delete_server(&database, &id).await? {
|
||||
return error("节点不存在", 404);
|
||||
}
|
||||
if let Err(error) = live::disconnect_agents(&env, std::slice::from_ref(&id)).await {
|
||||
console_error!("failed to disconnect deleted Agent: {error}");
|
||||
}
|
||||
return no_content();
|
||||
}
|
||||
let input: ServerInput = match request_json(&mut req, API_JSON_MAX_BYTES).await {
|
||||
Ok(value) => value,
|
||||
@@ -1574,15 +1646,17 @@ async fn handle(mut req: Request, env: Env, ctx: Context) -> Result<Response> {
|
||||
if let Some(message) = validate_server(&input) {
|
||||
return error(message, 400);
|
||||
}
|
||||
return if db::update_server(&database, &id, &input).await? {
|
||||
no_content()
|
||||
} else {
|
||||
error("节点不存在", 404)
|
||||
};
|
||||
if !db::update_server(&database, &id, &input).await? {
|
||||
return error("节点不存在", 404);
|
||||
}
|
||||
if let Err(error) = live::disconnect_agents(&env, std::slice::from_ref(&id)).await {
|
||||
console_error!("failed to reconnect updated Agent: {error}");
|
||||
}
|
||||
return no_content();
|
||||
}
|
||||
|
||||
if method == Method::Get && path == "/api/admin/settings" {
|
||||
return json(&settings, 200);
|
||||
return json(&settings_for_admin_response(settings.clone(), &env), 200);
|
||||
}
|
||||
|
||||
if method == Method::Get && path == "/api/admin/themes" {
|
||||
@@ -1759,7 +1833,8 @@ async fn handle(mut req: Request, env: Env, ctx: Context) -> Result<Response> {
|
||||
Ok(usage) => json(&usage, 200),
|
||||
Err(err) => {
|
||||
console_error!("cloudflare usage query failed: {err}");
|
||||
error("Cloudflare 用量查询失败,请检查 Token 权限和账户 ID", 502)
|
||||
let detail = err.to_string().chars().take(240).collect::<String>();
|
||||
error(&format!("Cloudflare 用量查询失败:{detail}"), 502)
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1874,11 +1949,10 @@ async fn handle(mut req: Request, env: Env, ctx: Context) -> Result<Response> {
|
||||
{
|
||||
return error("新密码派生值无效", 400);
|
||||
}
|
||||
let configured_site_key = input
|
||||
.turnstile_site_key
|
||||
.as_deref()
|
||||
.unwrap_or(&settings.turnstile_site_key)
|
||||
.trim();
|
||||
let configured_site_key = submitted_secret(
|
||||
input.turnstile_site_key.as_deref(),
|
||||
&settings.turnstile_site_key,
|
||||
);
|
||||
let configured_secret_key = submitted_secret(
|
||||
input.turnstile_secret_key.as_deref(),
|
||||
&settings.turnstile_secret_key,
|
||||
@@ -1970,10 +2044,15 @@ async fn handle(mut req: Request, env: Env, ctx: Context) -> Result<Response> {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
return json(
|
||||
&serde_json::json!({ "settings": updated, "token": token }),
|
||||
let response_settings = settings_for_admin_response(updated, &env);
|
||||
let mut response = json(
|
||||
&serde_json::json!({ "settings": response_settings, "token": token }),
|
||||
200,
|
||||
);
|
||||
)?;
|
||||
if let Some(token) = token.as_deref() {
|
||||
set_admin_session_cookie(&mut response, &req, Some(token))?;
|
||||
}
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
if path.starts_with("/api/") {
|
||||
@@ -2121,6 +2200,10 @@ mod tests {
|
||||
rate_limit_binding(Method::Post, "/api/agent/report"),
|
||||
Some("AGENT_RATE_LIMITER")
|
||||
);
|
||||
assert_eq!(
|
||||
rate_limit_binding(Method::Get, "/api/agent/live"),
|
||||
Some("AGENT_RATE_LIMITER")
|
||||
);
|
||||
assert_eq!(
|
||||
rate_limit_binding(Method::Get, "/api/history/node-a"),
|
||||
Some("API_RATE_LIMITER")
|
||||
@@ -2219,6 +2302,7 @@ mod tests {
|
||||
#[test]
|
||||
fn validates_cloudflare_usage_credentials() {
|
||||
assert!(valid_cloudflare_account_id(""));
|
||||
assert!(valid_cloudflare_account_id(SECRET_MASK));
|
||||
assert!(valid_cloudflare_account_id(
|
||||
"0123456789abcdef0123456789ABCDEF"
|
||||
));
|
||||
|
||||
+478
-4
@@ -1,20 +1,207 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use worker::*;
|
||||
|
||||
use crate::db::AgentLiveContext;
|
||||
use crate::models::{AgentLatencyResult, AgentReport};
|
||||
|
||||
const MAX_LIVE_SAMPLES: usize = 720;
|
||||
const MAX_LIVE_LATENCY_RESULTS: usize = 4096;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct AgentLiveBatch {
|
||||
#[serde(rename = "type")]
|
||||
message_type: String,
|
||||
samples: Vec<AgentReport>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
struct TrafficAttachment {
|
||||
reset_day: i64,
|
||||
cycle_key: i64,
|
||||
raw_rx: i64,
|
||||
raw_tx: i64,
|
||||
used_rx: i64,
|
||||
used_tx: i64,
|
||||
rx_correction: i64,
|
||||
tx_correction: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
struct SocketAttachment {
|
||||
role: String,
|
||||
server_id: Option<String>,
|
||||
hidden: bool,
|
||||
report_interval: i64,
|
||||
collect_interval: i64,
|
||||
last_d1_write_at: i64,
|
||||
traffic: Option<TrafficAttachment>,
|
||||
}
|
||||
|
||||
fn active_report_interval_ms(report_interval: i64, collect_interval: i64) -> i64 {
|
||||
let report_interval = report_interval.clamp(15, 3600);
|
||||
let collect_interval = collect_interval.clamp(2, 60).min(report_interval);
|
||||
((report_interval + 14) / 15)
|
||||
.max(collect_interval)
|
||||
.clamp(2, 60)
|
||||
* 1000
|
||||
}
|
||||
|
||||
fn apply_live_traffic(report: &mut AgentReport, traffic: &mut TrafficAttachment) {
|
||||
let cycle_key = crate::db::traffic_cycle_key(report.timestamp, traffic.reset_day);
|
||||
if cycle_key != traffic.cycle_key {
|
||||
traffic.cycle_key = cycle_key;
|
||||
traffic.raw_rx = report.net_rx_total;
|
||||
traffic.raw_tx = report.net_tx_total;
|
||||
traffic.used_rx = 0;
|
||||
traffic.used_tx = 0;
|
||||
} else {
|
||||
let rx_delta = if report.net_rx_total >= traffic.raw_rx {
|
||||
report.net_rx_total - traffic.raw_rx
|
||||
} else {
|
||||
report.net_rx_total
|
||||
};
|
||||
let tx_delta = if report.net_tx_total >= traffic.raw_tx {
|
||||
report.net_tx_total - traffic.raw_tx
|
||||
} else {
|
||||
report.net_tx_total
|
||||
};
|
||||
traffic.used_rx = traffic.used_rx.saturating_add(rx_delta);
|
||||
traffic.used_tx = traffic.used_tx.saturating_add(tx_delta);
|
||||
traffic.raw_rx = report.net_rx_total;
|
||||
traffic.raw_tx = report.net_tx_total;
|
||||
}
|
||||
report.net_rx_total = traffic.used_rx.saturating_add(traffic.rx_correction);
|
||||
report.net_tx_total = traffic.used_tx.saturating_add(traffic.tx_correction);
|
||||
}
|
||||
|
||||
fn batch_update_payload(
|
||||
server_id: &str,
|
||||
reports: &[AgentReport],
|
||||
traffic: &mut TrafficAttachment,
|
||||
) -> Result<String> {
|
||||
batch_update_payload_at(server_id, reports, traffic, crate::now())
|
||||
}
|
||||
|
||||
fn batch_update_payload_at(
|
||||
server_id: &str,
|
||||
reports: &[AgentReport],
|
||||
traffic: &mut TrafficAttachment,
|
||||
envelope_timestamp: i64,
|
||||
) -> Result<String> {
|
||||
let mut reports = reports.to_vec();
|
||||
reports.sort_by_key(|report| report.timestamp);
|
||||
let mut samples = Vec::with_capacity(reports.len());
|
||||
for mut report in reports {
|
||||
apply_live_traffic(&mut report, traffic);
|
||||
let timestamp = report.timestamp;
|
||||
let mut data = serde_json::to_value(report)?;
|
||||
if let Some(data) = data.as_object_mut() {
|
||||
data.remove("timestamp");
|
||||
}
|
||||
samples.push(serde_json::json!({ "ts": timestamp, "data": data }));
|
||||
}
|
||||
Ok(serde_json::to_string(&serde_json::json!({
|
||||
"type": "batchUpdate",
|
||||
"ts": envelope_timestamp,
|
||||
"updates": [{
|
||||
"serverId": server_id,
|
||||
"samples": samples
|
||||
}]
|
||||
}))?)
|
||||
}
|
||||
|
||||
fn agent_attachment(
|
||||
server_id: String,
|
||||
hidden: bool,
|
||||
context: AgentLiveContext,
|
||||
) -> SocketAttachment {
|
||||
SocketAttachment {
|
||||
role: "agent".to_string(),
|
||||
server_id: Some(server_id),
|
||||
hidden,
|
||||
report_interval: context.report_interval.clamp(15, 3600),
|
||||
collect_interval: context.collect_interval.clamp(2, 60),
|
||||
last_d1_write_at: context.last_persisted_at,
|
||||
traffic: Some(TrafficAttachment {
|
||||
reset_day: context.reset_day.clamp(1, 31),
|
||||
cycle_key: context.cycle_key,
|
||||
raw_rx: context.raw_rx,
|
||||
raw_tx: context.raw_tx,
|
||||
used_rx: context.used_rx,
|
||||
used_tx: context.used_tx,
|
||||
rx_correction: context.rx_correction,
|
||||
tx_correction: context.tx_correction,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn required_i64_header(req: &Request, name: &str) -> Result<i64> {
|
||||
req.headers()
|
||||
.get(name)?
|
||||
.and_then(|value| value.parse::<i64>().ok())
|
||||
.ok_or_else(|| Error::RustError(format!("missing or invalid {name}")))
|
||||
}
|
||||
|
||||
fn live_context_from_headers(req: &Request) -> Result<AgentLiveContext> {
|
||||
Ok(AgentLiveContext {
|
||||
report_interval: required_i64_header(req, "X-Live-Report-Interval")?,
|
||||
collect_interval: required_i64_header(req, "X-Live-Collect-Interval")?,
|
||||
reset_day: required_i64_header(req, "X-Live-Reset-Day")?,
|
||||
cycle_key: required_i64_header(req, "X-Live-Cycle-Key")?,
|
||||
raw_rx: required_i64_header(req, "X-Live-Raw-Rx")?,
|
||||
raw_tx: required_i64_header(req, "X-Live-Raw-Tx")?,
|
||||
used_rx: required_i64_header(req, "X-Live-Used-Rx")?,
|
||||
used_tx: required_i64_header(req, "X-Live-Used-Tx")?,
|
||||
rx_correction: required_i64_header(req, "X-Live-Rx-Correction")?,
|
||||
tx_correction: required_i64_header(req, "X-Live-Tx-Correction")?,
|
||||
last_persisted_at: required_i64_header(req, "X-Live-Last-Persisted-At")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn dashboard_attachment() -> SocketAttachment {
|
||||
SocketAttachment {
|
||||
role: "dashboard".to_string(),
|
||||
server_id: None,
|
||||
hidden: false,
|
||||
report_interval: 0,
|
||||
collect_interval: 0,
|
||||
last_d1_write_at: 0,
|
||||
traffic: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[durable_object(websocket)]
|
||||
pub struct LiveHub {
|
||||
state: State,
|
||||
env: Env,
|
||||
}
|
||||
|
||||
impl DurableObject for LiveHub {
|
||||
fn new(state: State, _env: Env) -> Self {
|
||||
fn new(state: State, env: Env) -> Self {
|
||||
if let Ok(pair) = WebSocketRequestResponsePair::new("ping", "pong") {
|
||||
state.set_websocket_auto_response(&pair);
|
||||
}
|
||||
Self { state }
|
||||
Self { state, env }
|
||||
}
|
||||
|
||||
async fn fetch(&self, mut req: Request) -> Result<Response> {
|
||||
if req.method() == Method::Post {
|
||||
if req.headers().get("X-Live-Action")?.as_deref() == Some("disconnect-agents") {
|
||||
let server_ids = req.json::<Vec<String>>().await?;
|
||||
for server_id in server_ids {
|
||||
if server_id.is_empty() || server_id.len() > 80 || server_id.contains('/') {
|
||||
continue;
|
||||
}
|
||||
for socket in self
|
||||
.state
|
||||
.get_websockets_with_tag(&format!("agent:{server_id}"))
|
||||
{
|
||||
let _ = socket.close(Some(1008), Some("server configuration changed"));
|
||||
}
|
||||
}
|
||||
return Response::empty();
|
||||
}
|
||||
let payload = req.text().await?;
|
||||
let server_id = req.headers().get("X-Server-ID")?.unwrap_or_default();
|
||||
let mut sockets = self.state.get_websockets_with_tag("all");
|
||||
@@ -42,21 +229,154 @@ impl DurableObject for LiveHub {
|
||||
}
|
||||
|
||||
let pair = WebSocketPair::new()?;
|
||||
if let Some(server_id) = req.headers().get("X-Live-Agent-ID")? {
|
||||
if server_id.is_empty() || server_id.len() > 80 || server_id.contains('/') {
|
||||
return Response::error("Invalid live Agent identity", 400);
|
||||
}
|
||||
let hidden = req
|
||||
.headers()
|
||||
.get("X-Live-Agent-Hidden")?
|
||||
.is_some_and(|value| value == "1");
|
||||
let context = match live_context_from_headers(&req) {
|
||||
Ok(context) => context,
|
||||
Err(_) => return Response::error("Invalid live Agent context", 400),
|
||||
};
|
||||
pair.server.serialize_attachment(agent_attachment(
|
||||
server_id.clone(),
|
||||
hidden,
|
||||
context,
|
||||
))?;
|
||||
self.state
|
||||
.accept_websocket_with_tags(&pair.server, &[&format!("agent:{server_id}")]);
|
||||
return Response::from_websocket(pair.client);
|
||||
}
|
||||
let server_id = req
|
||||
.url()?
|
||||
.query_pairs()
|
||||
.find_map(|(key, value)| (key == "server_id").then(|| value.to_string()))
|
||||
.filter(|value| !value.is_empty() && value.len() <= 80 && !value.contains('/'));
|
||||
let tag = server_id.map_or_else(|| "all".to_string(), |id| format!("server:{id}"));
|
||||
pair.server.serialize_attachment(dashboard_attachment())?;
|
||||
self.state.accept_websocket_with_tags(&pair.server, &[&tag]);
|
||||
Response::from_websocket(pair.client)
|
||||
}
|
||||
|
||||
async fn websocket_message(
|
||||
&self,
|
||||
_ws: WebSocket,
|
||||
_message: WebSocketIncomingMessage,
|
||||
ws: WebSocket,
|
||||
message: WebSocketIncomingMessage,
|
||||
) -> Result<()> {
|
||||
let Some(mut attachment) = ws.deserialize_attachment::<SocketAttachment>()? else {
|
||||
return Ok(());
|
||||
};
|
||||
if attachment.role != "agent" {
|
||||
return Ok(());
|
||||
}
|
||||
let WebSocketIncomingMessage::String(message) = message else {
|
||||
return Ok(());
|
||||
};
|
||||
if message.len() > 1024 * 1024 {
|
||||
return ws.close(Some(1009), Some("live metric payload too large"));
|
||||
}
|
||||
let Ok(batch) = serde_json::from_str::<AgentLiveBatch>(&message) else {
|
||||
return ws.close(Some(1007), Some("invalid live metric payload"));
|
||||
};
|
||||
if batch.message_type != "update"
|
||||
|| batch.samples.is_empty()
|
||||
|| batch.samples.len() > MAX_LIVE_SAMPLES
|
||||
{
|
||||
return ws.close(Some(1007), Some("invalid live metric batch"));
|
||||
}
|
||||
let received_at = crate::now();
|
||||
let mut latency_results = Vec::<AgentLatencyResult>::new();
|
||||
for report in &batch.samples {
|
||||
if report.timestamp <= 0
|
||||
|| (report.timestamp - received_at).abs() > 7200
|
||||
|| crate::validate_report(report).is_some()
|
||||
{
|
||||
return ws.close(Some(1007), Some("invalid live metric sample"));
|
||||
}
|
||||
latency_results.extend(report.latency_results.iter().cloned());
|
||||
}
|
||||
if latency_results.len() > MAX_LIVE_LATENCY_RESULTS {
|
||||
return ws.close(Some(1009), Some("too many live latency results"));
|
||||
}
|
||||
let Some(server_id) = attachment.server_id.clone() else {
|
||||
return ws.close(Some(1008), Some("missing server identity"));
|
||||
};
|
||||
let mut traffic = attachment
|
||||
.traffic
|
||||
.take()
|
||||
.ok_or_else(|| Error::RustError("missing live traffic state".to_string()))?;
|
||||
let payload = batch_update_payload(&server_id, &batch.samples, &mut traffic)?;
|
||||
attachment.traffic = Some(traffic);
|
||||
|
||||
let report_interval = attachment.report_interval.clamp(15, 3600);
|
||||
let due_for_d1 = received_at.saturating_sub(attachment.last_d1_write_at) >= report_interval;
|
||||
let mut persisted = false;
|
||||
if due_for_d1 {
|
||||
let database = self.env.d1("DB")?;
|
||||
if let Err(error) = crate::db::save_reports(&database, &server_id, &batch.samples).await
|
||||
{
|
||||
console_warn!("live D1 metrics write failed for {server_id}: {error:?}");
|
||||
} else if let Err(error) =
|
||||
crate::latency::save_results(&database, &server_id, &latency_results, received_at)
|
||||
.await
|
||||
{
|
||||
console_warn!("live D1 latency write failed for {server_id}: {error:?}");
|
||||
} else {
|
||||
attachment.last_d1_write_at = received_at;
|
||||
persisted = true;
|
||||
}
|
||||
}
|
||||
|
||||
if !attachment.hidden {
|
||||
let mut sockets = self.state.get_websockets_with_tag("all");
|
||||
sockets.extend(
|
||||
self.state
|
||||
.get_websockets_with_tag(&format!("server:{server_id}")),
|
||||
);
|
||||
let has_dashboard = !sockets.is_empty();
|
||||
for dashboard in sockets {
|
||||
if dashboard.send_with_str(&payload).is_err() {
|
||||
let _ = dashboard.close(Some(1011), Some("send failed"));
|
||||
}
|
||||
}
|
||||
let next_wss_ms = if has_dashboard {
|
||||
active_report_interval_ms(attachment.report_interval, attachment.collect_interval)
|
||||
} else {
|
||||
report_interval * 1000
|
||||
};
|
||||
let next_d1_ms = if persisted {
|
||||
report_interval * 1000
|
||||
} else {
|
||||
let due_at = attachment.last_d1_write_at.saturating_add(report_interval);
|
||||
due_at.saturating_sub(received_at).saturating_mul(1000)
|
||||
};
|
||||
ws.serialize_attachment(attachment)?;
|
||||
ws.send_with_str(&serde_json::to_string(&serde_json::json!({
|
||||
"type": "ack",
|
||||
"ts": received_at,
|
||||
"persisted": persisted,
|
||||
"nextD1WriteAfterMs": next_d1_ms,
|
||||
"nextWssReportAfterMs": next_wss_ms
|
||||
}))?)?;
|
||||
} else {
|
||||
let next_d1_ms = if persisted {
|
||||
report_interval * 1000
|
||||
} else {
|
||||
let due_at = attachment.last_d1_write_at.saturating_add(report_interval);
|
||||
due_at.saturating_sub(received_at).saturating_mul(1000)
|
||||
};
|
||||
ws.serialize_attachment(attachment)?;
|
||||
ws.send_with_str(&serde_json::to_string(&serde_json::json!({
|
||||
"type": "ack",
|
||||
"ts": received_at,
|
||||
"persisted": persisted,
|
||||
"nextD1WriteAfterMs": next_d1_ms,
|
||||
"nextWssReportAfterMs": report_interval * 1000
|
||||
}))?)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -87,8 +407,162 @@ pub async fn broadcast(env: &Env, server_id: &str, payload: &str) -> Result<()>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn disconnect_agents(env: &Env, server_ids: &[String]) -> Result<()> {
|
||||
if server_ids.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let namespace = env.durable_object("LIVE_HUB")?;
|
||||
let stub = namespace.id_from_name("dashboard")?.get_stub()?;
|
||||
let mut init = RequestInit::new();
|
||||
init.with_method(Method::Post)
|
||||
.with_body(Some(worker::wasm_bindgen::JsValue::from_str(
|
||||
&serde_json::to_string(server_ids)?,
|
||||
)));
|
||||
let req = Request::new_with_init("https://live.internal/disconnect-agents", &init)?;
|
||||
req.headers().set("Content-Type", "application/json")?;
|
||||
req.headers().set("X-Live-Action", "disconnect-agents")?;
|
||||
stub.fetch_with_request(req).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn upgrade(req: Request, env: &Env) -> Result<Response> {
|
||||
let namespace = env.durable_object("LIVE_HUB")?;
|
||||
let stub = namespace.id_from_name("dashboard")?.get_stub()?;
|
||||
req.headers().delete("X-Live-Agent-ID")?;
|
||||
req.headers().delete("X-Live-Agent-Hidden")?;
|
||||
for header in [
|
||||
"X-Live-Report-Interval",
|
||||
"X-Live-Collect-Interval",
|
||||
"X-Live-Reset-Day",
|
||||
"X-Live-Cycle-Key",
|
||||
"X-Live-Raw-Rx",
|
||||
"X-Live-Raw-Tx",
|
||||
"X-Live-Used-Rx",
|
||||
"X-Live-Used-Tx",
|
||||
"X-Live-Rx-Correction",
|
||||
"X-Live-Tx-Correction",
|
||||
"X-Live-Last-Persisted-At",
|
||||
] {
|
||||
req.headers().delete(header)?;
|
||||
}
|
||||
stub.fetch_with_request(req).await
|
||||
}
|
||||
|
||||
pub async fn upgrade_agent(
|
||||
req: Request,
|
||||
env: &Env,
|
||||
server_id: &str,
|
||||
hidden: bool,
|
||||
context: AgentLiveContext,
|
||||
) -> Result<Response> {
|
||||
let namespace = env.durable_object("LIVE_HUB")?;
|
||||
let stub = namespace.id_from_name("dashboard")?.get_stub()?;
|
||||
req.headers().set("X-Live-Agent-ID", server_id)?;
|
||||
req.headers()
|
||||
.set("X-Live-Agent-Hidden", if hidden { "1" } else { "0" })?;
|
||||
req.headers().set(
|
||||
"X-Live-Report-Interval",
|
||||
&context.report_interval.to_string(),
|
||||
)?;
|
||||
req.headers().set(
|
||||
"X-Live-Collect-Interval",
|
||||
&context.collect_interval.to_string(),
|
||||
)?;
|
||||
req.headers()
|
||||
.set("X-Live-Reset-Day", &context.reset_day.to_string())?;
|
||||
req.headers()
|
||||
.set("X-Live-Cycle-Key", &context.cycle_key.to_string())?;
|
||||
req.headers()
|
||||
.set("X-Live-Raw-Rx", &context.raw_rx.to_string())?;
|
||||
req.headers()
|
||||
.set("X-Live-Raw-Tx", &context.raw_tx.to_string())?;
|
||||
req.headers()
|
||||
.set("X-Live-Used-Rx", &context.used_rx.to_string())?;
|
||||
req.headers()
|
||||
.set("X-Live-Used-Tx", &context.used_tx.to_string())?;
|
||||
req.headers()
|
||||
.set("X-Live-Rx-Correction", &context.rx_correction.to_string())?;
|
||||
req.headers()
|
||||
.set("X-Live-Tx-Correction", &context.tx_correction.to_string())?;
|
||||
req.headers().set(
|
||||
"X-Live-Last-Persisted-At",
|
||||
&context.last_persisted_at.to_string(),
|
||||
)?;
|
||||
req.headers().delete("Authorization")?;
|
||||
stub.fetch_with_request(req).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{active_report_interval_ms, batch_update_payload_at, TrafficAttachment};
|
||||
use crate::models::AgentReport;
|
||||
|
||||
#[test]
|
||||
fn live_payload_contains_cycle_adjusted_traffic_and_latency() {
|
||||
let report = AgentReport {
|
||||
timestamp: 1_234,
|
||||
cpu: 12.5,
|
||||
load1: 0.5,
|
||||
load5: 0.4,
|
||||
load15: 0.3,
|
||||
mem_used: 1,
|
||||
mem_total: 2,
|
||||
swap_used: 0,
|
||||
swap_total: 0,
|
||||
disk_used: 3,
|
||||
disk_total: 4,
|
||||
net_in: 100.0,
|
||||
net_out: 200.0,
|
||||
net_rx_total: 300,
|
||||
net_tx_total: 400,
|
||||
uptime: 500,
|
||||
processes: 10,
|
||||
tcp_connections: 2,
|
||||
udp_connections: 1,
|
||||
cpu_cores: 2,
|
||||
cpu_model: "CPU".to_string(),
|
||||
os: "Linux".to_string(),
|
||||
kernel: "6.0".to_string(),
|
||||
arch: "x86_64".to_string(),
|
||||
virtualization: "kvm".to_string(),
|
||||
gpu_usage: 0.0,
|
||||
gpu_model: String::new(),
|
||||
agent_version: "test".to_string(),
|
||||
disk_read_bps: 0.0,
|
||||
disk_write_bps: 0.0,
|
||||
disk_read_iops: 0.0,
|
||||
disk_write_iops: 0.0,
|
||||
disk_await_ms: 0.0,
|
||||
disk_utilization: 0.0,
|
||||
disks: vec![],
|
||||
gpus: vec![],
|
||||
latency_results: vec![],
|
||||
};
|
||||
let mut traffic = TrafficAttachment {
|
||||
reset_day: 1,
|
||||
cycle_key: crate::db::traffic_cycle_key(1_234, 1),
|
||||
raw_rx: 100,
|
||||
raw_tx: 100,
|
||||
used_rx: 500,
|
||||
used_tx: 700,
|
||||
rx_correction: 10,
|
||||
tx_correction: 20,
|
||||
};
|
||||
let payload: serde_json::Value = serde_json::from_str(
|
||||
&batch_update_payload_at("node-a", &[report], &mut traffic, 1_235).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(payload["type"], "batchUpdate");
|
||||
assert_eq!(payload["updates"][0]["serverId"], "node-a");
|
||||
assert_eq!(payload["updates"][0]["samples"][0]["ts"], 1_234);
|
||||
assert_eq!(payload["updates"][0]["samples"][0]["data"]["cpu"], 12.5);
|
||||
assert_eq!(
|
||||
payload["updates"][0]["samples"][0]["data"]["net_rx_total"],
|
||||
710
|
||||
);
|
||||
assert!(payload["updates"][0]["samples"][0]["data"]
|
||||
.get("timestamp")
|
||||
.is_none());
|
||||
assert_eq!(active_report_interval_ms(60, 5), 5_000);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user