This commit is contained in:
imengying committed 2026-08-27 21:27:40 +08:00
1 parent e153d0ad10
commit 0cdcbe40f4
58 files changed
+5295 -2138

No files matched your search

+49 -14
View File
@@ -1,9 +1,12 @@
name: Build tagged release
name: CI and tagged release
on:
push:
branches:
- main
tags:
- "v*.*.*"
pull_request:
workflow_dispatch:
inputs:
version:
@@ -16,28 +19,31 @@ permissions:
jobs:
metadata:
name: Validate tag
name: Resolve build metadata
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
version: ${{ steps.version.outputs.version }}
release: ${{ steps.version.outputs.release }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- id: version
name: Validate three-part tag
name: Resolve release version
shell: bash
env:
RAW_VERSION: ${{ github.event_name == 'workflow_dispatch' && inputs.version || github.ref_name }}
RAW_VERSION: ${{ github.event_name == 'workflow_dispatch' && inputs.version || startsWith(github.ref, 'refs/tags/') && github.ref_name || 'v0.0.0' }}
RELEASE_BUILD: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/') }}
run: |
if [[ ! "$RAW_VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
if [ "$RELEASE_BUILD" = "true" ] && [[ ! "$RAW_VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Tag must use the v-prefixed format v1.2.3" >&2
exit 1
fi
version="${RAW_VERSION#v}"
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "release=$RELEASE_BUILD" >> "$GITHUB_OUTPUT"
quality:
name: Quality checks
@@ -56,9 +62,13 @@ jobs:
targets: wasm32-unknown-unknown
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: 1.3.14
bun-version: 1.4.0
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Typecheck frontend
run: bunx --bun tsc -b frontend
- name: Test frontend
run: bun test --cwd frontend
- name: Build frontend
run: bun run build:frontend
- name: Check Rust formatting
@@ -115,6 +125,7 @@ jobs:
agent:
name: Agent (${{ matrix.platform }})
needs: metadata
if: needs.metadata.outputs.release == 'true'
runs-on: ${{ matrix.runner }}
timeout-minutes: 45
permissions:
@@ -128,22 +139,24 @@ jobs:
fail-fast: false
matrix:
include:
- platform: Linux x86_64
- platform: Linux x64
runner: ubuntu-latest
target: x86_64-unknown-linux-musl
asset: agent-linux-x86_64
asset: agent-linux-x64
extension: ""
cross: true
static_linux: true
- platform: Linux ARM64
runner: ubuntu-latest
target: aarch64-unknown-linux-musl
asset: agent-linux-aarch64
extension: ""
cross: true
- platform: Windows x86_64
static_linux: true
- platform: Windows x64
runner: windows-latest
target: x86_64-pc-windows-msvc
asset: agent-windows-x86_64.exe
asset: agent-windows-x64.exe
extension: .exe
cross: false
- platform: macOS ARM64
@@ -152,6 +165,12 @@ jobs:
asset: agent-macos-aarch64
extension: ""
cross: false
- platform: FreeBSD x64
runner: ubuntu-latest
target: x86_64-unknown-freebsd
asset: agent-freebsd-x64
extension: ""
cross: true
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
@@ -159,7 +178,7 @@ jobs:
- uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
with:
targets: ${{ matrix.target }}
- name: Install Linux cross compiler
- name: Install cross compiler
if: matrix.cross
run: cargo install cross --version 0.2.5 --locked
- name: Build Rust Agent with cross
@@ -210,13 +229,28 @@ jobs:
exit 1
fi
- name: Verify static Linux binary
if: matrix.cross
if: matrix.static_linux
shell: bash
run: |
if readelf -l "release/${{ matrix.asset }}" | grep -q 'Requesting program interpreter'; then
echo "Linux Agent must be statically linked" >&2
exit 1
fi
- name: Verify FreeBSD Agent in FreeBSD
if: matrix.target == 'x86_64-unknown-freebsd'
uses: vmactions/freebsd-vm@83b151f58c6047089f4c80eb5ba2039d158ce093 # v1.5.3
with:
release: "15"
envs: NODEFLARE_VERSION
copyback: false
run: |
set -eu
version_output=$(./release/agent-freebsd-x64 --version)
actual_version=${version_output##* }
if [ "$actual_version" != "$NODEFLARE_VERSION" ]; then
echo "Agent version $actual_version does not match tag $NODEFLARE_VERSION" >&2
exit 1
fi
- uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4
with:
subject-path: release/${{ matrix.asset }}
@@ -229,6 +263,7 @@ jobs:
frontend:
name: Frontend
needs: metadata
if: needs.metadata.outputs.release == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
@@ -244,7 +279,7 @@ jobs:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: 1.3.14
bun-version: 1.4.0
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Build public frontend
@@ -262,7 +297,7 @@ jobs:
release:
name: Publish GitHub Release
if: github.event_name == 'push'
if: startsWith(github.ref, 'refs/tags/v')
needs: [metadata, quality, agent, frontend]
runs-on: ubuntu-latest
timeout-minutes: 10
-11
View File
@@ -1,11 +0,0 @@
/build/
/target/
/agent/target/
/node_modules/
/frontend/dist/
/frontend/admin-dist/
/frontend/node_modules/
/.wrangler/
/.dev.vars
*.log
*.tsbuildinfo
Generated
+13 -13
View File
@@ -10,7 +10,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
"syn 3.0.4",
]
[[package]]
@@ -122,7 +122,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
"syn 3.0.4",
]
[[package]]
@@ -163,7 +163,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
"syn 3.0.4",
]
[[package]]
@@ -308,9 +308,9 @@ checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
[[package]]
name = "icu_provider"
version = "2.3.0"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428"
checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
dependencies = [
"displaydoc",
"icu_locale_core",
@@ -531,7 +531,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
"syn 3.0.4",
]
[[package]]
@@ -622,9 +622,9 @@ dependencies = [
[[package]]
name = "syn"
version = "3.0.3"
version = "3.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
dependencies = [
"proc-macro2",
"quote",
@@ -891,9 +891,9 @@ dependencies = [
[[package]]
name = "zerovec"
version = "0.11.7"
version = "0.11.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8"
checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
dependencies = [
"yoke",
"zerofrom",
@@ -902,13 +902,13 @@ dependencies = [
[[package]]
name = "zerovec-derive"
version = "0.11.4"
version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47402523226a02bfe5230160dc3ccc089aa6f6f19e7fcbb4e6f824bbb1b4aa62"
checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
"syn 3.0.4",
]
[[package]]
+16 -9
View File
@@ -1,6 +1,6 @@
# NodeFlare
基于 Rust、WebAssembly 和 Cloudflare Workers 的服务器监控,支持 Linux、Windows 和 macOS Agent。
基于 Rust、WebAssembly 和 Cloudflare Workers 的服务器监控,支持 Linux、Windows、macOS 和 FreeBSD Agent。
## 界面预览
@@ -14,14 +14,14 @@
- TCP/ICMP 延迟检测,可按节点分配测试点和周期
- NodeFlare Glass 风格总览、节点卡片、搜索/分组筛选、响应式深浅主题
- 节点详情与历史图表,支持实时、1/4/24/168/720 小时范围
- WebSocket 实时刷新,断线后自动轮询
- WebSocket 实时刷新与 Agent WSS 上报
- 计费、到期、流量和多币种资产统计,Worker 每日更新汇率
- 用户名密码登录、Cloudflare Turnstile 和节点隐藏
- 节点管理、批量删除、拖拽排序和 Agent 在线配置
- Telegram 通知、资源告警、离线/到期提醒和数据维护
- Telegram 通知、失败重试、资源/流量/离线/到期提醒和数据维护
- 内置 NodeFlare Glass 主题,并提供远程主题商店
- 远程主题支持 GitHub 仓库地址(默认 `main` 分支,也可用 `tree` 指定分支或子目录),Worker 代理 `index.html` 与 `assets/`
- Rust Agent 支持 Linux x86_64/ARM64、Windows x86_64 和 macOS ARM64,可按节点自动更新
- Rust Agent 支持 Linux x64/ARM64、Windows x64、macOS ARM64 和 FreeBSD x64,可按节点自动更新
## 部署到 Cloudflare
@@ -37,16 +37,24 @@
| `TURNSTILE_SITE_KEY` | 可选 | Turnstile Site Key,无需加密;也可在后台设置 |
| `TURNSTILE_SECRET_KEY` | 可选 | Turnstile Secret Key,勾选“加密”;也可在后台设置 |
| `OFFLINE_THRESHOLD_SECONDS` | 可选 | 离线判定秒数,未设置时使用 180,范围 30-3600 |
| `HISTORY_RETENTION_DAYS` | 可选 | 历史保留天数,未设置时使用 30,范围 1-365 |
| `HISTORY_RETENTION_DAYS` | 可选 | 历史保留天数,未设置时使用 30,范围 1-30 |
| `CF_USAGE_ACCOUNT_ID` | 可选 | Cloudflare 用量查询的账户 ID,不设置则不启用用量查询 |
| `CF_USAGE_API_TOKEN` | 可选 | 用量查询 Token,勾选“加密”;需要 Account Analytics: Read,并授权对应账户 |
## Agent 卸载
Linux:
```sh
curl -fsSL https://raw.githubusercontent.com/imengying/NodeFlare/main/agent/agent.sh | sudo sh -s -- --uninstall
```
FreeBSD:
```sh
fetch -qo - https://raw.githubusercontent.com/imengying/NodeFlare/main/agent/install-freebsd.sh | sudo sh -s -- --uninstall
```
## API
| 方法 | 路径 | 用途 |
@@ -58,9 +66,7 @@ curl -fsSL https://raw.githubusercontent.com/imengying/NodeFlare/main/agent/agen
| `GET` | `/api/history/:id?hours=24` | 节点历史 |
| `GET` | `/api/latency/:id?hours=24` | 节点延迟任务历史 |
| `GET` | `/api/ws` | 实时 WebSocket |
| `GET` | `/api/agent/live` | Agent 实时指标 WebSocket |
| `GET` | `/api/agent/config` | Agent 配置同步,节点 Bearer Token |
| `POST` | `/api/agent/report` | Agent 上报,节点 Bearer Token |
| `GET` | `/api/agent/ws` | Agent 配置同步与指标上报 WebSocket,节点 Bearer Token |
| `POST` | `/api/admin/login` | 管理登录 |
| `POST` | `/api/admin/logout` | 退出登录(清除会话 Cookie) |
| `GET/POST` | `/api/admin/servers` | 管理节点 |
@@ -69,6 +75,8 @@ curl -fsSL https://raw.githubusercontent.com/imengying/NodeFlare/main/agent/agen
| `PATCH/DELETE` | `/api/admin/latency-tasks/:id` | 编辑或删除延迟任务 |
| `GET/POST` | `/api/admin/alert-rules` | 查询或创建资源告警规则 |
| `PATCH/DELETE` | `/api/admin/alert-rules/:id` | 编辑或删除资源告警规则 |
| `GET/PUT` | `/api/admin/telegram` | 读写 Telegram 通知配置 |
| `POST` | `/api/admin/telegram/test` | 发送 Telegram 测试消息 |
| `PATCH/DELETE` | `/api/admin/servers/:id` | 编辑或删除节点 |
| `PATCH` | `/api/admin/servers/order` | 更新全部节点顺序 |
| `DELETE` | `/api/admin/servers` | 批量删除节点 |
@@ -83,7 +91,6 @@ curl -fsSL https://raw.githubusercontent.com/imengying/NodeFlare/main/agent/agen
| `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` | 发送一条通知测试消息 |
## 主题
+19 -19
View File
@@ -37,9 +37,9 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cc"
version = "1.4.3"
version = "1.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d"
checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273"
dependencies = [
"find-msvc-tools",
"shlex",
@@ -92,7 +92,7 @@ dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 3.0.3",
"syn 3.0.4",
]
[[package]]
@@ -185,7 +185,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
"syn 3.0.4",
]
[[package]]
@@ -354,9 +354,9 @@ checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
[[package]]
name = "icu_provider"
version = "2.3.0"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428"
checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
dependencies = [
"displaydoc",
"icu_locale_core",
@@ -424,9 +424,9 @@ checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
[[package]]
name = "log"
version = "0.4.33"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "memchr"
@@ -591,9 +591,9 @@ dependencies = [
[[package]]
name = "rustls-webpki"
version = "0.103.14"
version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [
"ring",
"rustls-pki-types",
@@ -627,7 +627,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
"syn 3.0.4",
]
[[package]]
@@ -708,9 +708,9 @@ dependencies = [
[[package]]
name = "syn"
version = "3.0.3"
version = "3.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
dependencies = [
"proc-macro2",
"quote",
@@ -759,7 +759,7 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
"syn 3.0.4",
]
[[package]]
@@ -1198,9 +1198,9 @@ dependencies = [
[[package]]
name = "zerovec"
version = "0.11.7"
version = "0.11.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8"
checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
dependencies = [
"yoke",
"zerofrom",
@@ -1209,13 +1209,13 @@ dependencies = [
[[package]]
name = "zerovec-derive"
version = "0.11.4"
version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47402523226a02bfe5230160dc3ccc089aa6f6f19e7fcbb4e6f824bbb1b4aa62"
checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
"syn 3.0.4",
]
[[package]]
+1 -1
View File
@@ -16,7 +16,7 @@ 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]
[target.'cfg(any(target_os = "windows", target_os = "macos", target_os = "freebsd"))'.dependencies]
sysinfo = { version = "0.39.6", default-features = false, features = ["disk", "network", "system"] }
[profile.release]
+7 -3
View File
@@ -4,6 +4,7 @@ set -eu
SERVICE_NAME="nodeflare"
INSTALL_DIR="/opt/nodeflare"
AGENT_FILE="$INSTALL_DIR/agent"
TOKEN_FILE="$INSTALL_DIR/token"
SERVICE_FILE="/etc/systemd/system/$SERVICE_NAME.service"
OPENRC_FILE="/etc/init.d/$SERVICE_NAME"
@@ -130,7 +131,7 @@ install_agent() {
esac
case "$mirror" in *@*) fail "下载加速前缀不能包含用户信息" ;; esac
fi
case "$(uname -m)" in x86_64|amd64) arch="x86_64" ;; aarch64|arm64) arch="aarch64" ;; *) fail "暂不支持当前 CPU 架构:$(uname -m)" ;; esac
case "$(uname -m)" in x86_64|amd64) arch="x64" ;; aarch64|arm64) arch="aarch64" ;; *) fail "暂不支持当前 CPU 架构:$(uname -m)" ;; esac
init_system=$(detect_init_system)
[ "$init_system" != "unknown" ] || fail "未检测到正在运行的 systemd 或 OpenRC"
@@ -199,6 +200,9 @@ install_agent() {
esac
mv "$temporary" "$AGENT_FILE"
trap - EXIT HUP INT TERM
(umask 077; printf '%s\n' "$token" > "$TOKEN_FILE")
chmod 600 "$TOKEN_FILE"
token=""
log "正在配置并启动 $init_system 服务"
if [ "$init_system" = "systemd" ]; then
printf '%s\n' \
@@ -209,7 +213,7 @@ install_agent() {
'' \
'[Service]' \
'Type=simple' \
"ExecStart=$AGENT_FILE -e $endpoint -t $token -i $interval" \
"ExecStart=$AGENT_FILE -e $endpoint --token-file $TOKEN_FILE -i $interval" \
'Restart=always' \
'RestartSec=10' \
'NoNewPrivileges=true' \
@@ -232,7 +236,7 @@ install_agent() {
'#!/sbin/openrc-run' \
"name=\"$SERVICE_NAME\"" \
"command=\"$AGENT_FILE\"" \
"command_args=\"-e $endpoint -t $token -i $interval\"" \
"command_args=\"-e $endpoint --token-file $TOKEN_FILE -i $interval\"" \
"command_user=\"root\"" \
"supervisor=\"supervise-daemon\"" \
"respawn_delay=10" \
+221
View File
@@ -0,0 +1,221 @@
#!/bin/sh
set -eu
SERVICE_NAME="nodeflare"
INSTALL_DIR="/usr/local/libexec/nodeflare"
AGENT_FILE="$INSTALL_DIR/agent"
TOKEN_FILE="$INSTALL_DIR/token"
SERVICE_FILE="/usr/local/etc/rc.d/$SERVICE_NAME"
log() {
printf '[NodeFlare] %s\n' "$1"
}
fail() {
printf '[NodeFlare] 错误:%s\n' "$1" >&2
exit 1
}
usage() {
cat <<'EOF'
NodeFlare Agent FreeBSD 安装脚本
用法:
install-freebsd.sh -e <Worker URL> -t <Agent Token> [-i <上报间隔>] [-m <下载加速前缀>]
install-freebsd.sh --status
install-freebsd.sh --uninstall
仅支持 FreeBSD amd64。Agent Token 请勿泄露。
-m 为可选的 GitHub 下载加速前缀(形如 https://ghproxy.net),
仅作用于 Release 下载,摘要校验不受影响。
EOF
}
safe_value() {
case "$1" in *[!A-Za-z0-9_./:@-]*|'') return 1 ;; esac
}
download_stdout() {
url="$1"
if command -v curl >/dev/null 2>&1; then
curl --fail --location --silent --show-error --max-time 30 \
-H 'Accept: application/vnd.github+json' \
-H 'User-Agent: nodeflare-installer' \
"$url"
else
fetch -q -T 30 -o - "$url"
fi
}
download_file() {
url="$1"
destination="$2"
if command -v curl >/dev/null 2>&1; then
curl --fail --location --silent --show-error --max-time 120 \
"$url" -o "$destination"
else
fetch -q -T 120 -o "$destination" "$url"
fi
}
if [ "${1:-}" = "--uninstall" ]; then
[ "$#" -eq 1 ] || fail "--uninstall 不接受其它参数"
[ "$(id -u)" -eq 0 ] || fail "请使用 root 权限执行卸载"
log "正在停止并移除 NodeFlare Agent"
service "$SERVICE_NAME" stop 2>/dev/null || true
sysrc -x "${SERVICE_NAME}_enable" >/dev/null 2>&1 || true
rm -f "$SERVICE_FILE" "$AGENT_FILE" "$TOKEN_FILE"
rmdir "$INSTALL_DIR" 2>/dev/null || true
echo "NodeFlare Agent 已卸载"
exit 0
fi
if [ "${1:-}" = "--status" ]; then
[ "$#" -eq 1 ] || fail "--status 不接受其它参数"
service "$SERVICE_NAME" status
exit $?
fi
[ "${1:-}" != "-h" ] && [ "${1:-}" != "--help" ] && [ "$#" -gt 0 ] || {
usage
exit 0
}
[ "$(id -u)" -eq 0 ] || fail "请使用 root 权限运行安装"
[ "$(uname -s)" = "FreeBSD" ] || fail "此脚本仅支持 FreeBSD"
case "$(uname -m)" in
amd64|x86_64) ;;
*) fail "仅支持 FreeBSD amd64" ;;
esac
command -v fetch >/dev/null 2>&1 || command -v curl >/dev/null 2>&1 || fail "缺少 fetch 或 curl"
command -v sha256 >/dev/null 2>&1 || fail "缺少 sha256,无法校验下载文件"
command -v service >/dev/null 2>&1 || fail "缺少 service"
command -v sysrc >/dev/null 2>&1 || fail "缺少 sysrc"
log "正在检查运行环境"
token=""
endpoint=""
interval=60
interval_set=false
mirror=""
while [ "$#" -gt 0 ]; do
option="$1"
case "$option" in
-t|-e|-i|-m)
[ "$#" -ge 2 ] || fail "参数 $option 缺少值"
value="$2"
shift 2
;;
*) fail "未知参数:$option" ;;
esac
case "$option" in
-t) [ -z "$token" ] || fail "参数 $option 重复"; token="$value" ;;
-e) [ -z "$endpoint" ] || fail "参数 $option 重复"; endpoint="$value" ;;
-i) [ "$interval_set" = false ] || fail "参数 $option 重复"; interval="$value"; interval_set=true ;;
-m) [ -z "$mirror" ] || fail "参数 $option 重复"; mirror="$value" ;;
esac
done
[ -n "$token" ] && [ -n "$endpoint" ] || { usage; exit 1; }
endpoint=${endpoint%/}
[ ${#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:*) ;;
*) fail "Worker 地址必须使用 HTTPS;仅本机调试可使用 HTTP" ;;
esac
case "$endpoint" in *@*) fail "Worker 地址不能包含用户信息" ;; esac
case "$interval" in ''|*[!0-9]*) fail "上报间隔必须是整数" ;; esac
[ "$interval" -ge 15 ] && [ "$interval" -le 3600 ] || fail "上报间隔必须在 15-3600 秒之间"
mirror=${mirror%/}
if [ -n "$mirror" ]; then
[ ${#mirror} -le 2048 ] || fail "下载加速前缀长度超出限制"
safe_value "$mirror" || fail "下载加速前缀格式无效"
case "$mirror" in
https://?*) ;;
http://localhost|http://localhost/*|http://localhost:*|http://127.0.0.1|http://127.0.0.1/*|http://127.0.0.1:*) ;;
*) fail "下载加速前缀必须使用 HTTPS;仅本机调试可使用 HTTP" ;;
esac
case "$mirror" in *@*) fail "下载加速前缀不能包含用户信息" ;; esac
fi
mkdir -p "$INSTALL_DIR"
temporary="$INSTALL_DIR/.agent.$$.download"
trap 'rm -f "$temporary"' EXIT HUP INT TERM
artifact="agent-freebsd-x64"
release_api="https://api.github.com/repos/imengying/NodeFlare/releases/latest"
log "正在获取 GitHub 最新正式版本($artifact)"
release_json=$(download_stdout "$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]+$' || fail "GitHub 最新 Release 标签无效:${release_tag:-未找到}"
expected=$(printf '%s\n' "$release_json" | tr '{' '\n' | awk -v name="$artifact" '
{
compact = $0
gsub(/[[:space:]]/, "", compact)
if (index(compact, "\"name\":\"" name "\"") > 0) selected = 1
else if (index(compact, "\"name\":") > 0) selected = 0
}
selected {
marker = "\"digest\":\"sha256:"
position = index(compact, marker)
if (position == 0) next
digest = substr(compact, position + length(marker), 64)
if (length(digest) == 64 && digest !~ /[^0-9a-fA-F]/) {
print tolower(digest)
exit
}
}
')
[ -n "$expected" ] || fail "Release 缺少 $artifact 的 SHA-256 摘要"
release_base="https://github.com/imengying/NodeFlare/releases/download/$release_tag"
download_url="$release_base/$artifact"
if [ -n "$mirror" ]; then
download_url="$mirror/$release_base/$artifact"
log "正在通过下载加速前缀拉取 Agent $release_tag"
else
log "正在下载 NodeFlare Agent $release_tag"
fi
download_file "$download_url" "$temporary"
actual=$(sha256 -q "$temporary")
[ "$actual" = "$expected" ] || fail "Agent SHA-256 校验失败,已停止安装"
chmod 755 "$temporary"
log "下载校验通过,正在验证可执行文件"
installed_version=$("$temporary" --version) || fail "下载的 Agent 无法在当前 FreeBSD 运行"
installed_version=${installed_version##* }
[ "$installed_version" = "${release_tag#v}" ] || fail "Release $release_tag 与 Agent 版本 $installed_version 不一致"
log "正在配置并启动 FreeBSD rc.d 服务"
service "$SERVICE_NAME" stop 2>/dev/null || true
mv "$temporary" "$AGENT_FILE"
trap - EXIT HUP INT TERM
(umask 077; printf '%s\n' "$token" > "$TOKEN_FILE")
chmod 600 "$TOKEN_FILE"
token=""
cat > "$SERVICE_FILE" <<EOF
#!/bin/sh
# PROVIDE: nodeflare
# REQUIRE: NETWORKING
# KEYWORD: shutdown
. /etc/rc.subr
name="$SERVICE_NAME"
rcvar="${SERVICE_NAME}_enable"
pidfile="/var/run/\${name}.pid"
command="/usr/sbin/daemon"
command_args="-P \${pidfile} -r -R 10 -S -T \${name} $AGENT_FILE -e $endpoint --token-file $TOKEN_FILE -i $interval"
load_rc_config "\${name}"
: \${nodeflare_enable:="NO"}
run_rc_command "\$1"
EOF
chmod 700 "$SERVICE_FILE"
sysrc "${SERVICE_NAME}_enable=YES" >/dev/null
service "$SERVICE_NAME" start
service "$SERVICE_NAME" status >/dev/null || {
service "$SERVICE_NAME" status >&2 || true
fail "NodeFlare 服务启动失败,请查看 /var/log/messages"
}
printf '\nNodeFlare Agent 安装完成\n'
printf ' 版本:%s\n' "$installed_version"
printf ' 服务:%s(FreeBSD rc.d)\n' "$SERVICE_NAME"
printf ' 查看状态:service %s status\n' "$SERVICE_NAME"
+6 -2
View File
@@ -4,6 +4,7 @@ set -eu
LABEL="com.nodeflare.agent"
INSTALL_DIR="/usr/local/libexec/nodeflare"
AGENT_FILE="$INSTALL_DIR/agent"
TOKEN_FILE="$INSTALL_DIR/token"
PLIST_FILE="/Library/LaunchDaemons/$LABEL.plist"
log() {
@@ -39,7 +40,7 @@ if [ "${1:-}" = "--uninstall" ]; then
[ "$(id -u)" -eq 0 ] || fail "请使用 root 权限执行卸载"
log "正在停止并移除 NodeFlare Agent"
launchctl bootout system "$PLIST_FILE" 2>/dev/null || true
rm -f "$PLIST_FILE" "$AGENT_FILE"
rm -f "$PLIST_FILE" "$AGENT_FILE" "$TOKEN_FILE"
rmdir "$INSTALL_DIR" 2>/dev/null || true
echo "NodeFlare Agent 已卸载"
exit 0
@@ -158,12 +159,15 @@ log "正在配置并启动 macOS LaunchDaemon 服务"
launchctl bootout system "$PLIST_FILE" 2>/dev/null || true
mv "$temporary" "$AGENT_FILE"
trap - EXIT HUP INT TERM
(umask 077; printf '%s\n' "$token" > "$TOKEN_FILE")
chmod 600 "$TOKEN_FILE"
token=""
cat > "$PLIST_FILE" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>Label</key><string>$LABEL</string>
<key>ProgramArguments</key><array><string>$AGENT_FILE</string><string>-e</string><string>$endpoint</string><string>-t</string><string>$token</string><string>-i</string><string>$interval</string></array>
<key>ProgramArguments</key><array><string>$AGENT_FILE</string><string>-e</string><string>$endpoint</string><string>--token-file</string><string>$TOKEN_FILE</string><string>-i</string><string>$interval</string></array>
<key>KeepAlive</key><true/><key>RunAtLoad</key><true/>
<key>StandardOutPath</key><string>/var/log/nodeflare-agent.log</string>
<key>StandardErrorPath</key><string>/var/log/nodeflare-agent.log</string>
+7 -3
View File
@@ -12,6 +12,7 @@ $ErrorActionPreference = "Stop"
$TaskName = "NodeFlare Agent"
$InstallDir = Join-Path $env:ProgramData "NodeFlare"
$AgentFile = Join-Path $InstallDir "nodeflare-agent.exe"
$TokenFile = Join-Path $InstallDir "token"
function Write-Step([string]$Message) {
Write-Host "[NodeFlare] $Message"
@@ -89,7 +90,7 @@ if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdenti
}
$NativeArchitecture = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW6432 } else { $env:PROCESSOR_ARCHITECTURE }
if ($NativeArchitecture -ne "AMD64") {
Write-InstallError "仅支持 Windows x86_64"
Write-InstallError "仅支持 Windows x64"
}
Write-Step "正在检查运行环境"
Assert-Safe "Token" $Token
@@ -106,7 +107,7 @@ New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
& icacls.exe $InstallDir /inheritance:r /grant:r 'SYSTEM:(OI)(CI)F' 'Administrators:(OI)(CI)F' | Out-Null
$Temporary = "$AgentFile.$PID.download.exe"
$ReleaseApi = "https://api.github.com/repos/imengying/NodeFlare/releases/latest"
$Artifact = "agent-windows-x86_64.exe"
$Artifact = "agent-windows-x64.exe"
try {
Write-Step "正在获取 GitHub 最新正式版本($Artifact)"
$Release = Invoke-RestMethod -Uri $ReleaseApi -Headers @{ Accept = "application/vnd.github+json"; "User-Agent" = "nodeflare-installer" } -TimeoutSec 30
@@ -147,7 +148,10 @@ try {
Remove-Item -LiteralPath $Temporary -Force -ErrorAction SilentlyContinue
}
$TaskArguments = "-e $Endpoint -t $Token -i $Interval"
[IO.File]::WriteAllText($TokenFile, "$Token`n", [Text.UTF8Encoding]::new($false))
& icacls.exe $TokenFile /inheritance:r /grant:r 'SYSTEM:F' 'Administrators:F' | Out-Null
$Token = ""
$TaskArguments = "-e `"$Endpoint`" --token-file `"$TokenFile`" -i $Interval"
Write-Step "正在注册并启动 Windows 计划任务"
$TaskAction = New-ScheduledTaskAction -Execute $AgentFile -Argument $TaskArguments
$Trigger = New-ScheduledTaskTrigger -AtStartup
+51
View File
@@ -0,0 +1,51 @@
use std::collections::VecDeque;
use super::Report;
pub(crate) const MAX_LIVE_BATCH_BYTES: usize = 768 * 1024;
pub(crate) fn batch_len(queue: &VecDeque<Report>) -> usize {
// Allow room for the JSON envelope, commas, and future small protocol fields.
let mut encoded_bytes = 64_usize;
let mut count = 0_usize;
for report in queue {
let Ok(report_bytes) = serde_json::to_vec(report) else {
break;
};
let next_bytes = encoded_bytes
.saturating_add(report_bytes.len())
.saturating_add(usize::from(count > 0));
if next_bytes > MAX_LIVE_BATCH_BYTES {
break;
}
encoded_bytes = next_bytes;
count += 1;
if encoded_bytes >= MAX_LIVE_BATCH_BYTES {
break;
}
}
count.min(super::LIVE_QUEUE_CAPACITY)
}
#[cfg(test)]
mod tests {
use std::collections::VecDeque;
use super::{batch_len, MAX_LIVE_BATCH_BYTES};
use crate::Report;
#[test]
fn bounds_batches_by_serialized_size() {
let report = Report {
cpu_model: "x".repeat(128 * 1024),
..Report::default()
};
let queue = std::iter::repeat_n(report, 10).collect::<VecDeque<_>>();
let count = batch_len(&queue);
assert!((1..10).contains(&count));
let payload =
crate::live_update_payload(&queue.iter().take(count).cloned().collect::<Vec<_>>())
.unwrap();
assert!(payload.len() <= MAX_LIVE_BATCH_BYTES);
}
}
+811 -468
View File
File diff suppressed because it is too large. Load diff
+83
View File
@@ -0,0 +1,83 @@
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
#[derive(Default)]
pub(crate) struct RuntimeStats {
latency_queue_rejected: AtomicU64,
live_queue_dropped: AtomicU64,
live_connect_failures: AtomicU64,
live_persistence_failures: AtomicU64,
live_batches: AtomicU64,
live_samples: AtomicU64,
persisted_samples_pruned: AtomicU64,
collection_count: AtomicU64,
collection_total_micros: AtomicU64,
collection_max_micros: AtomicU64,
}
impl RuntimeStats {
pub(crate) fn latency_queue_rejected(&self) {
self.latency_queue_rejected.fetch_add(1, Ordering::Relaxed);
}
pub(crate) fn live_queue_dropped(&self, count: usize) {
self.live_queue_dropped
.fetch_add(count as u64, Ordering::Relaxed);
}
pub(crate) fn live_connect_failed(&self) {
self.live_connect_failures.fetch_add(1, Ordering::Relaxed);
}
pub(crate) fn live_persistence_failed(&self) {
self.live_persistence_failures
.fetch_add(1, Ordering::Relaxed);
}
pub(crate) fn live_batch_sent(&self, count: usize) {
self.live_batches.fetch_add(1, Ordering::Relaxed);
self.live_samples.fetch_add(count as u64, Ordering::Relaxed);
}
pub(crate) fn persisted_samples_pruned(&self, count: usize) {
self.persisted_samples_pruned
.fetch_add(count as u64, Ordering::Relaxed);
}
pub(crate) fn collection_finished(&self, elapsed: Duration) {
let micros = elapsed.as_micros().min(u64::MAX as u128) as u64;
self.collection_count.fetch_add(1, Ordering::Relaxed);
self.collection_total_micros
.fetch_add(micros, Ordering::Relaxed);
self.collection_max_micros
.fetch_max(micros, Ordering::Relaxed);
}
pub(crate) fn log_and_reset(&self) {
let collections = self.collection_count.swap(0, Ordering::Relaxed);
let total_micros = self.collection_total_micros.swap(0, Ordering::Relaxed);
let max_micros = self.collection_max_micros.swap(0, Ordering::Relaxed);
let average_ms = if collections == 0 {
0.0
} else {
total_micros as f64 / collections as f64 / 1000.0
};
eprintln!(
"{}",
serde_json::json!({
"level": "info",
"message": "agent runtime stats",
"collections": collections,
"collectionAverageMs": average_ms,
"collectionMaxMs": max_micros as f64 / 1000.0,
"latencyQueueRejected": self.latency_queue_rejected.swap(0, Ordering::Relaxed),
"liveQueueDropped": self.live_queue_dropped.swap(0, Ordering::Relaxed),
"liveConnectFailures": self.live_connect_failures.swap(0, Ordering::Relaxed),
"livePersistenceFailures": self.live_persistence_failures.swap(0, Ordering::Relaxed),
"liveBatches": self.live_batches.swap(0, Ordering::Relaxed),
"liveSamples": self.live_samples.swap(0, Ordering::Relaxed),
"persistedSamplesPruned": self.persisted_samples_pruned.swap(0, Ordering::Relaxed),
})
);
}
}
+48 -34
View File
@@ -5,24 +5,26 @@
"": {
"name": "nodeflare",
"devDependencies": {
"wrangler": "^4.123.0",
"wrangler": "^4.127.0",
"ws": "^8.21.3",
},
},
"frontend": {
"name": "nodeflare-ui",
"version": "0.0.1",
"dependencies": {
"lucide-react": "^1.31.0",
"lucide-react": "^1.34.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"recharts": "^3.10.1",
},
"devDependencies": {
"@types/bun": "^1.4.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@vitejs/plugin-react": "^6.0.5",
"@types/react-dom": "^19.2.5",
"@vitejs/plugin-react": "^6.1.0",
"typescript": "^7.0.2",
"vite": "^8.2.1",
"vite": "^8.2.2",
},
},
},
@@ -34,15 +36,15 @@
"@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="],
"@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260811.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-i5jqz+ywtOefr0AJbiAc8qxBLfSim/B0WJG7aW3B+pWnoVfMJdUQvi+BWcFKZJ0MoCci3KadTx6g31VfuEEqpQ=="],
"@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260826.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-8UsGGY8ZUiYHOWdsxBlNsGmaHBGArVwJ3CM4nWpfBhthjjYe4M/OqrTpqKF7NNWb63qQiv1d8Z+Z6/hmUqNKIQ=="],
"@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260811.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-NoOUM/nvaDdm2Onlnz33FikWjtatzulNtvwvy4xs0IrHaTCHwC0c8NwIt6s+AI13FkDs02/vm2I3GTPLCT9+hQ=="],
"@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260826.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0bLqVQYsQ3v3FdYGmzh23vi9fJeYTBx19o4LUySIsRcgBggGSlR39ml162vTXvZzUISOjVW2qfL7Y+SMrrfXmQ=="],
"@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260811.1", "", { "os": "linux", "cpu": "x64" }, "sha512-sdYq2jL1AD1supa3fsi5O4zTB28wSjvTHj7Migh6/ts8EROPdvrSwv+rdGHhv8HJNAz/wbIAY3wZsi1Rw4uUIg=="],
"@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260826.1", "", { "os": "linux", "cpu": "x64" }, "sha512-DTC0yWzybX4gUH5Q1pJo3UwEQjp0Gmz0Q71I+39xT9SXBesX5QndOIQv45yHQ86Z84EzK2WwaENdlpmDSvJKmw=="],
"@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260811.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-RIRv4shbu1kg05sD+DHTpSFCNnb5Dl2SkPDMUykqZa508tkPqe7VVw7gO0Q5msTBGyL0FfFrLuRxwwfA8u5Sow=="],
"@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260826.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-PFerWi+DP2Ckc6eATAS4dhotBt8IeXJjTzIgy18H+uqsswlXc7A8HsnAunHL9v/7/BjCgq46iMo2g4iUAsEpDw=="],
"@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260811.1", "", { "os": "win32", "cpu": "x64" }, "sha512-g6VquwjASlYAibcNW/0E6Zszht4qLkmnXOGwIjjRHl2A0Qz48kVeMcGvyH6eA0G9U3OzZojjYFpP+YeyQmmdjw=="],
"@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260826.1", "", { "os": "win32", "cpu": "x64" }, "sha512-X26hulrG2MSSfpRmjZbCq98LNaZnrRqbmGcgo7g1U/U0nJ5npYruPm3fAyZ2y6VDr5CmQo9BLCahxP8VB/QR6A=="],
"@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="],
@@ -160,7 +162,7 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="],
"@oxc-project/types": ["@oxc-project/types@0.143.0", "", {}, "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA=="],
"@oxc-project/types": ["@oxc-project/types@0.147.0", "", {}, "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg=="],
"@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="],
@@ -170,33 +172,35 @@
"@reduxjs/toolkit": ["@reduxjs/toolkit@2.12.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.3", "", { "os": "android", "cpu": "arm64" }, "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw=="],
"@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.6", "", { "os": "android", "cpu": "arm" }, "sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.6", "", { "os": "android", "cpu": "arm64" }, "sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.3", "", { "os": "linux", "cpu": "arm" }, "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.6", "", { "os": "freebsd", "cpu": "x64" }, "sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.6", "", { "os": "linux", "cpu": "arm" }, "sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.6", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.6", "", { "os": "linux", "cpu": "s390x" }, "sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.3", "", { "os": "none", "cpu": "arm64" }, "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.6", "", { "os": "none", "cpu": "arm64" }, "sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.3", "", { "os": "win32", "cpu": "x64" }, "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
@@ -208,6 +212,8 @@
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
"@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="],
"@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
"@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
@@ -226,9 +232,11 @@
"@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
"@types/node": ["@types/node@26.4.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ=="],
"@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="],
"@types/react-dom": ["@types/react-dom@19.2.4", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw=="],
"@types/react-dom": ["@types/react-dom@19.2.5", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg=="],
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
@@ -272,10 +280,12 @@
"@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.5", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.1.0", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "oxc-transform-react": "^0.145.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler", "oxc-transform-react"] }, "sha512-qd2BzUBehkov86WFhg0JkEFEYyCLG9uPCe6qWTY/kRlss9OvJrOF2UbIWT7p+8IzZHkEu0DNGHc4HSv+JdDLsw=="],
"blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="],
"bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
@@ -350,9 +360,9 @@
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="],
"lucide-react": ["lucide-react@1.31.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-G8u2eEtoHUnUa9f8lbvqDhCiORMnYLdUEo06EEG9MQvHQrInKcX3Pa2TH39MM5qyzRcWETxB0+aOwAPI1g1kEg=="],
"lucide-react": ["lucide-react@1.34.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-vnjGJNI7Htk5+oWW8gXGuaLgwgAb0T6/iZbBrp9JCfRFwdNWZ0YTm3eyxjOLgwN6r8iyAf3UA70zNmBRBNv7yg=="],
"miniflare": ["miniflare@5.20260811.1-alpha", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.2", "undici": "7.29.0", "workerd": "1.20260811.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" } }, "sha512-DtOG0BeanIxs2sH0smFvExZD89cBQwGckbHiFkRJrrNAUu3NGClZkUxqu+zy7HYfKBAgq935EMY49vIPm3JVdA=="],
"miniflare": ["miniflare@5.20260826.0-alpha", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.2", "undici": "7.29.0", "workerd": "1.20260826.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" } }, "sha512-ZXR3Bieg+B5MK0T/zYIWaZiCGCb9Z3en4+/TleYKhIGPLx/e0cRcG/ZvvTviUapVBBK2zODB+aiypdIojU3x6Q=="],
"nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="],
@@ -384,7 +394,7 @@
"reselect": ["reselect@5.2.0", "", {}, "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw=="],
"rolldown": ["rolldown@1.2.3", "", { "dependencies": { "@oxc-project/types": "=0.143.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.2.3", "@rolldown/binding-darwin-arm64": "1.2.3", "@rolldown/binding-darwin-x64": "1.2.3", "@rolldown/binding-freebsd-x64": "1.2.3", "@rolldown/binding-linux-arm-gnueabihf": "1.2.3", "@rolldown/binding-linux-arm64-gnu": "1.2.3", "@rolldown/binding-linux-arm64-musl": "1.2.3", "@rolldown/binding-linux-ppc64-gnu": "1.2.3", "@rolldown/binding-linux-s390x-gnu": "1.2.3", "@rolldown/binding-linux-x64-gnu": "1.2.3", "@rolldown/binding-linux-x64-musl": "1.2.3", "@rolldown/binding-openharmony-arm64": "1.2.3", "@rolldown/binding-win32-arm64-msvc": "1.2.3", "@rolldown/binding-win32-x64-msvc": "1.2.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A=="],
"rolldown": ["rolldown@1.2.6", "", { "dependencies": { "@oxc-project/types": "=0.147.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.6", "@rolldown/binding-android-arm64": "1.2.6", "@rolldown/binding-darwin-arm64": "1.2.6", "@rolldown/binding-darwin-x64": "1.2.6", "@rolldown/binding-freebsd-x64": "1.2.6", "@rolldown/binding-linux-arm-gnueabihf": "1.2.6", "@rolldown/binding-linux-arm64-gnu": "1.2.6", "@rolldown/binding-linux-arm64-musl": "1.2.6", "@rolldown/binding-linux-ppc64-gnu": "1.2.6", "@rolldown/binding-linux-s390x-gnu": "1.2.6", "@rolldown/binding-linux-x64-gnu": "1.2.6", "@rolldown/binding-linux-x64-musl": "1.2.6", "@rolldown/binding-openharmony-arm64": "1.2.6", "@rolldown/binding-win32-arm64-msvc": "1.2.6", "@rolldown/binding-win32-x64-msvc": "1.2.6" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
@@ -406,22 +416,26 @@
"undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="],
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
"unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="],
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="],
"vite": ["vite@8.2.1", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", "postcss": "^8.5.25", "rolldown": "~1.2.1", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.4.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw=="],
"vite": ["vite@8.2.2", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", "postcss": "^8.5.26", "rolldown": "~1.2.4", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.4.0 || ^0.5.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q=="],
"workerd": ["workerd@1.20260811.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260811.1", "@cloudflare/workerd-darwin-arm64": "1.20260811.1", "@cloudflare/workerd-linux-64": "1.20260811.1", "@cloudflare/workerd-linux-arm64": "1.20260811.1", "@cloudflare/workerd-windows-64": "1.20260811.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-kh+FFm55JQ4ssxhHZV9VPdMQq3D1nHxNJgwxMtWGD4dGppJvLySdguTRDKgeNTvgq6heSz+6TTXyPSDGj8Yllw=="],
"workerd": ["workerd@1.20260826.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260826.1", "@cloudflare/workerd-darwin-arm64": "1.20260826.1", "@cloudflare/workerd-linux-64": "1.20260826.1", "@cloudflare/workerd-linux-arm64": "1.20260826.1", "@cloudflare/workerd-windows-64": "1.20260826.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-oTG9ot5zxO9OjjKCskt86+nVrBL0kiUqExHnYaTg4OCkHf/K4zr+9hYvgwmG8DdCWG0TQGErHzD+1h50TM5b+w=="],
"wrangler": ["wrangler@4.123.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "5.20260811.1-alpha", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260811.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260811.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-VXo2I1oa0x9aGAKIFPRSQPqTh0RBY5Ktl44YOhNmsJQFUdJKDA2vVTU6Xj+FC2koll6orJqWZN8jbXVIk9O67Q=="],
"wrangler": ["wrangler@4.127.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "5.20260826.0-alpha", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260826.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260826.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-4dPqcBEMJfGeZeNnjHT7ThNJs+EiNYxUTg4ywqIdQubcXHBhFeVMQyHV4A9AOhZRFr83cckqdds034KGcr/dtw=="],
"ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
"ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="],
"youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="],
"youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="],
"miniflare/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
}
}
+6 -4
View File
@@ -6,22 +6,24 @@
"type": "module",
"scripts": {
"dev": "vite",
"test": "bun test",
"build": "tsc -b && vite build && vite build --config vite.admin.config.ts",
"build:public": "tsc -b && vite build",
"build:admin": "vite build --config vite.admin.config.ts",
"preview": "vite preview"
},
"dependencies": {
"lucide-react": "^1.31.0",
"lucide-react": "^1.34.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"recharts": "^3.10.1"
},
"devDependencies": {
"@types/bun": "^1.4.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@vitejs/plugin-react": "^6.0.5",
"@types/react-dom": "^19.2.5",
"@vitejs/plugin-react": "^6.1.0",
"typescript": "^7.0.2",
"vite": "^8.2.1"
"vite": "^8.2.2"
}
}
+141 -449
View File
@@ -1,445 +1,168 @@
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 { lazy, Suspense, useMemo, useState, type FormEvent } from "react";
import { demoMode, useApp } from "./AppProvider";
import { NodeCard } from "./components/NodeCard";
import { SiteLogo } from "./components/SiteLogo";
import { StatsBar } from "./components/StatsBar";
import { TurnstileWidget } from "./components/TurnstileWidget";
import { demoConfig, demoExchangeRates, demoServers } from "./demo";
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 MAX_PLAYBACK_SAMPLES_PER_SERVER = 600;
const NodeDetails = lazy(() => import("./components/NodeDetails").then((module) => ({ default: module.NodeDetails })));
interface LiveMetrics {
timestamp: number;
displayTimestamp: number;
metrics: Partial<Server>;
latencyResults?: LiveLatencyResult[];
}
function LoginGate() {
const { config, dark, error, setError, login } = useApp();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [turnstileToken, setTurnstileToken] = useState("");
const [turnstileReset, setTurnstileReset] = useState(0);
const [busy, setBusy] = useState(false);
const locale = config.locale;
interface LiveSample {
ts: number;
data: Partial<Server> & { latency_results?: LiveLatencyResult[] };
}
function mergeLiveResults(previous: LiveLatencyResult[] | undefined, incoming: LiveLatencyResult[]): LiveLatencyResult[] {
const results = new Map<string, LiveLatencyResult>();
for (const result of [...(previous ?? []), ...incoming]) {
if (!result.task_id || !Number.isFinite(result.timestamp) || result.timestamp <= 0) continue;
results.set(`${result.task_id}:${result.timestamp}`, result);
async function submit(event: FormEvent) {
event.preventDefault();
if (busy) return;
setBusy(true);
setError("");
try {
await login(username, password, turnstileToken);
setPassword("");
setTurnstileToken("");
} catch (reason) {
setError(reason instanceof Error ? reason.message : ui(locale, "登录失败", "Unable to sign in"));
setTurnstileToken("");
setTurnstileReset((value) => value + 1);
} finally {
setBusy(false);
}
}
return Array.from(results.values())
.sort((left, right) => left.timestamp - right.timestamp)
.slice(-4096);
return (
<section className="dashboard-login-gate glass-panel">
<SiteLogo src={config.logo_url} alt="" width="46" height="46" />
<div className="dashboard-login-copy">
<h1>{ui(locale, "登录仪表盘", "Sign in to dashboard")}</h1>
<p>{ui(locale, "此仪表盘仅限登录后访问", "This dashboard requires an administrator sign-in")}</p>
</div>
<form className="dashboard-login-form" onSubmit={(event) => void submit(event)}>
<label><span>{ui(locale, "用户名", "Username")}</span><input autoFocus autoComplete="username" value={username} onChange={(event) => setUsername(event.target.value)} required /></label>
<label><span>{ui(locale, "密码", "Password")}</span><input type="password" autoComplete="current-password" value={password} onChange={(event) => setPassword(event.target.value)} required /></label>
{config.turnstile_login_enabled ? <div className="dashboard-login-turnstile"><TurnstileWidget siteKey={config.turnstile_site_key} action="admin-login" theme={dark ? "dark" : "light"} resetKey={turnstileReset} onVerify={setTurnstileToken} onError={setError} /></div> : null}
{error ? <p className="form-error">{error}</p> : null}
<button className="primary-btn dashboard-login-submit" disabled={busy || (config.turnstile_login_enabled && !turnstileToken)} type="submit"><KeyRound size={16} />{busy ? ui(locale, "登录中", "Signing in") : ui(locale, "登录", "Sign in")}</button>
</form>
</section>
);
}
function mergeLiveLatency(server: Server, results: LiveLatencyResult[]): LatencySample[] {
const latest = new Map<string, LiveLatencyResult>();
for (const result of results) {
const current = latest.get(result.task_id);
if (!current || result.timestamp >= current.timestamp) latest.set(result.task_id, result);
function VerificationGate() {
const { config, dark, error, setError, verify } = useApp();
const [busy, setBusy] = useState(false);
const locale = config.locale;
async function run(token: string) {
if (!token || busy) return;
setBusy(true);
setError("");
try {
await verify(token);
} catch (reason) {
setError(reason instanceof Error ? reason.message : ui(locale, "Cloudflare 验证失败", "Cloudflare verification failed"));
} finally {
setBusy(false);
}
}
let changed = false;
const merged = server.latency.map((definition) => {
const result = latest.get(definition.task_id);
if (!result || result.timestamp <= definition.timestamp) return definition;
changed = true;
return {
...definition,
server_id: server.id,
timestamp: result.timestamp,
latency_ms: result.latency_ms,
packet_loss: result.packet_loss,
};
});
return changed ? merged : server.latency;
return (
<section className="verification-gate">
<SiteLogo src={config.logo_url} alt="" width="52" height="52" />
<div><h1>{ui(locale, "访问验证", "Access verification")}</h1><p>{config.site_name}</p></div>
{config.turnstile_site_key
? <TurnstileWidget siteKey={config.turnstile_site_key} action="public-dashboard" theme={dark ? "dark" : "light"} onVerify={(token) => void run(token)} onError={setError} />
: <p className="form-error">{ui(locale, "Turnstile 尚未正确配置", "Turnstile is not configured")}</p>}
{busy ? <span className="verification-status">{ui(locale, "正在验证", "Verifying")}</span> : null}
{error ? <p className="form-error">{error}</p> : null}
</section>
);
}
function routeServerId() {
const match = window.location.pathname.match(/^\/instance\/([^/]+)\/?$/);
if (!match) return null;
try { return decodeURIComponent(match[1]); } catch { return null; }
}
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 [clockNow, setClockNow] = useState(() => Date.now());
const [exchangeRates, setExchangeRates] = useState<ExchangeRates | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
function HomeView() {
const { carrierLatency, config, error, exchangeRates, loading, openServer, reload, servers } = useApp();
const [query, setQuery] = useState("");
const [group, setGroup] = useState("__all__");
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 playbackRef = useRef<Map<string, LiveSample[]>>(new Map());
const serversRef = useRef<Server[]>([]);
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];
const merged = !live ? server : (() => {
const latency = live.latencyResults?.length ? mergeLiveLatency(server, live.latencyResults) : server.latency;
if (live.timestamp <= (server.timestamp ?? 0)) {
return latency === server.latency ? server : { ...server, latency };
}
return { ...server, ...live.metrics, latency, timestamp: live.timestamp };
})();
// Keep the card clock moving between Agent samples, while preserving the
// actual sample timestamp for freshness/online checks.
if (!merged.timestamp || !merged.uptime || !Number.isFinite(merged.uptime)) return merged;
const elapsed = Math.max(0, Math.floor(clockNow / 1000 - merged.timestamp));
return elapsed > 0 && elapsed <= config.offline_threshold_seconds
? { ...merged, uptime: merged.uptime + elapsed }
: merged;
}), [clockNow, config.offline_threshold_seconds, liveMetrics, servers]);
const locale = config.locale;
const load = useCallback(async (quiet = false) => {
if (!quiet) setLoading(true);
if (demoMode) {
setConfig(demoConfig);
setConfigReady(true);
setServers(demoServers);
setExchangeRates(demoExchangeRates);
setError("");
setLoading(false);
return;
}
try {
const result = await api.bootstrap();
setConfig(result.config);
setConfigReady(true);
setServers(result.servers);
setExchangeRates(result.exchange_rates);
setNeedsVerification(result.access === "turnstile");
setNeedsLogin(result.access === "login");
if (result.access !== "ok") {
playbackRef.current.clear();
setLiveMetrics({});
}
setError("");
} catch (reason) {
if (reason instanceof ApiError && reason.status === 401) {
setNeedsLogin(true);
setNeedsVerification(false);
setServers([]);
playbackRef.current.clear();
setLiveMetrics({});
setError("");
} else if (reason instanceof ApiError && reason.status === 403) {
setNeedsVerification(true);
setNeedsLogin(false);
setServers([]);
playbackRef.current.clear();
setLiveMetrics({});
setError(reason.message);
} else {
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);
setError("");
try {
await api.verifyTurnstile(token);
setNeedsVerification(false);
await load();
} catch (reason) {
setError(reason instanceof Error ? reason.message : ui(config.locale, "Cloudflare 验证失败", "Cloudflare verification failed"));
} finally {
setVerificationBusy(false);
}
}, [load, verificationBusy]);
useEffect(() => {
void load();
}, [load]);
useEffect(() => {
serversRef.current = servers;
for (const server of servers) {
const samples = playbackRef.current.get(server.id);
if (!samples || !server.timestamp) continue;
const fresh = samples.filter((sample) => sample.ts > server.timestamp!);
if (fresh.length) playbackRef.current.set(server.id, fresh);
else playbackRef.current.delete(server.id);
}
}, [servers]);
useEffect(() => {
if (needsLogin || needsVerification) return;
let ticks = 0;
const timer = window.setInterval(() => {
ticks += 1;
if (!liveConnectedRef.current || ticks % 20 === 0) void load(true);
}, 15_000);
return () => clearInterval(timer);
}, [load, needsLogin, needsVerification]);
useEffect(() => {
let previous = Date.now();
const timer = window.setInterval(() => {
const current = Date.now();
const elapsed = Math.max(0, Math.min(5_000, current - previous));
previous = current;
setClockNow(current);
if (elapsed === 0) return;
setLiveMetrics((currentMetrics) => {
if (!playbackRef.current.size) return currentMetrics;
const next = { ...currentMetrics };
for (const [serverId, samples] of playbackRef.current) {
const state = next[serverId];
if (!state || !samples.length) continue;
const displayTimestamp = state.displayTimestamp + elapsed / 1000;
let selected: LiveSample | undefined;
while (samples.length && samples[0].ts <= displayTimestamp) selected = samples.shift();
if (selected) {
const metrics = { ...selected.data };
const latencyResults = Array.isArray(metrics.latency_results) ? metrics.latency_results : [];
delete metrics.latency_results;
next[serverId] = {
timestamp: selected.ts,
displayTimestamp,
metrics,
latencyResults: latencyResults.length
? mergeLiveResults(state.latencyResults, latencyResults)
: state.latencyResults,
};
} else {
next[serverId] = { ...state, displayTimestamp };
}
if (!samples.length) playbackRef.current.delete(serverId);
}
return next;
});
}, 1_000);
return () => clearInterval(timer);
}, []);
useEffect(() => {
document.documentElement.classList.toggle("dark", dark);
document.documentElement.style.colorScheme = dark ? "dark" : "light";
document.documentElement.dataset.blur = blur ? "on" : "off";
document.documentElement.lang = config.locale;
}, [blur, config.locale, dark]);
useEffect(() => {
let link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
if (!link) {
link = document.createElement("link");
link.rel = "icon";
document.head.append(link);
}
link.href = config.favicon_url || config.logo_url || "/logo.svg";
}, [config.favicon_url, config.logo_url]);
useEffect(() => {
const media = matchMedia("(prefers-color-scheme: dark)");
const update = () => setSystemDark(media.matches);
media.addEventListener("change", update);
return () => media.removeEventListener("change", update);
}, []);
useEffect(() => {
const onPop = () => setSelectedId(routeServerId());
window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop);
}, []);
useEffect(() => {
if (!configReady) return;
const selected = liveServers.find((server) => server.id === selectedId);
document.title = selected ? `${selected.name} · ${config.site_name}` : config.site_name;
}, [config.site_name, configReady, liveServers, selectedId]);
useEffect(() => {
if (loading || needsLogin || needsVerification || demoMode) return;
const reconnects: number[] = [];
let cancelled = false;
const connect = () => {
if (cancelled) return;
const endpoint = new URL(location.origin);
endpoint.protocol = endpoint.protocol === "https:" ? "wss:" : "ws:";
endpoint.pathname = "/api/ws";
endpoint.search = "";
if (selectedId) endpoint.searchParams.set("server_id", selectedId);
const socket = new WebSocket(endpoint);
wsRef.current.push(socket);
socket.onopen = () => {
liveConnectedRef.current = 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) {
setServers((current) => current.map((server) => server.id === message.server.id
? message.server
: server));
return;
}
if (message.type === "batchUpdate" && Array.isArray(message.updates)) {
const replayCached = message.cached === true;
setLiveMetrics((current) => {
const next = { ...current };
for (const update of message.updates as Array<{
serverId?: string;
samples?: LiveSample[];
reportAgeMs?: number;
}>) {
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 previous = next[update.serverId];
const pending = playbackRef.current.get(update.serverId) ?? [];
const persistedTimestamp = serversRef.current.find((server) => server.id === update.serverId)?.timestamp ?? 0;
const appliedTimestamp = Math.max(previous?.timestamp ?? 0, persistedTimestamp);
const seen = new Set<number>([
...pending.map((sample) => sample.ts),
]);
const incoming = samples.filter((sample) => sample.ts > appliedTimestamp && !seen.has(sample.ts));
if (!incoming.length) continue;
const reportAgeSeconds = replayCached && Number.isFinite(update.reportAgeMs)
? Math.max(0, update.reportAgeMs! / 1000)
: 0;
const cursor = replayCached
? Math.max(previous?.displayTimestamp ?? 0, incoming[incoming.length - 1].ts + reportAgeSeconds)
: previous?.displayTimestamp ?? incoming[0].ts;
const all = [...pending, ...incoming]
.sort((left, right) => left.ts - right.ts)
.slice(-MAX_PLAYBACK_SAMPLES_PER_SERVER);
let selected: LiveSample | undefined;
while (all.length && all[0].ts <= cursor) selected = all.shift();
if (selected) {
const metrics = { ...selected.data };
const latencyResults = Array.isArray(metrics.latency_results) ? metrics.latency_results : [];
delete metrics.latency_results;
next[update.serverId] = {
timestamp: selected.ts,
displayTimestamp: cursor,
metrics,
latencyResults: latencyResults.length
? mergeLiveResults(previous?.latencyResults, latencyResults)
: previous?.latencyResults,
};
} else if (!previous) {
continue;
}
if (all.length) playbackRef.current.set(update.serverId, all);
else playbackRef.current.delete(update.serverId);
}
return next;
});
return;
}
} catch { /* Ignore non-protocol messages. */ }
};
};
connect();
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 = [];
};
}, [load, loading, needsLogin, needsVerification, selectedId]);
const groups = useMemo(() => ["__all__", ...Array.from(new Set(liveServers.map((server) => server.group_name || "默认")))], [liveServers]);
const visible = useMemo(() => liveServers.filter((server) => {
const groups = useMemo(
() => ["__all__", ...Array.from(new Set(servers.map((server) => server.group_name || "默认")))],
[servers],
);
const visible = useMemo(() => servers.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;
}), [liveServers, query, group, config.show_groups, config.show_search]);
const selected = liveServers.find((server) => server.id === selectedId) ?? null;
}), [config.show_groups, config.show_search, group, query, servers]);
function goHome() {
window.history.pushState({}, "", demoMode ? "/?demo=1" : "/");
setSelectedId(null);
window.scrollTo({ top: 0, behavior: "auto" });
}
return (
<div className="home-content">
<StatsBar servers={servers} 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(locale, "搜索节点", "Search servers")} placeholder={ui(locale, "搜索节点", "Search servers")} value={query} onChange={(event) => setQuery(event.target.value)} /></div> : null}
{config.show_groups ? <div className="group-tabs" aria-label={ui(locale, "节点分组", "Server groups")}>{groups.map((item) => <button className={group === item ? "active" : ""} key={item} onClick={() => setGroup(item)}>{item === "__all__" ? ui(locale, "全部", "All") : item}</button>)}</div> : null}
<span className="result-count">{ui(locale, `${visible.length} 个节点`, `${visible.length} servers`)}</span>
</div> : null}
{error ? <div className="error-band"><span>{error}</span><button onClick={() => void reload()}>{ui(locale, "重试", "Retry")}</button></div> : null}
{loading && !servers.length ? (
<div className={`loading-grid ${carrierLatency ? "carrier-latency" : ""}`}>{Array.from({ length: 8 }).map((_, index) => <span key={index} />)}</div>
) : visible.length ? (
<section className={`node-grid ${carrierLatency ? "carrier-latency" : ""}`}>
{visible.map((server) => <NodeCard key={server.id} server={server} config={config} onOpen={() => openServer(server)} />)}
</section>
) : !error ? (
<div className="empty-state"><strong>{servers.length ? ui(locale, "没有匹配的节点", "No matching servers") : ui(locale, "尚未添加节点", "No servers added")}</strong></div>
) : null}
</div>
);
}
function openServer(server: Server) {
const suffix = demoMode ? "?demo=1" : "";
window.history.pushState({}, "", `/instance/${encodeURIComponent(server.id)}${suffix}`);
setSelectedId(server.id);
window.scrollTo({ top: 0, behavior: "auto" });
}
function DetailView() {
const { config, goHome, liveMetrics, loading, selectedId, servers } = useApp();
const locale = config.locale;
const selected = servers.find((server) => server.id === selectedId) ?? null;
function openAdmin() {
window.location.assign("/admin");
if (selected) {
return (
<Suspense fallback={<div className="chart-loading">{ui(locale, "正在加载节点", "Loading server")}</div>}>
<NodeDetails
server={selected}
liveLatencyResults={liveMetrics[selected.id]?.latencyResults ?? []}
threshold={config.offline_threshold_seconds}
retentionDays={config.history_retention_days}
locale={locale}
demo={demoMode}
onClose={goHome}
/>
</Suspense>
);
}
if (loading) return <div className="chart-loading">{ui(locale, "正在加载节点", "Loading server")}</div>;
return (
<div className="empty-state">
<strong>{ui(locale, "节点不存在或已隐藏", "Server not found or hidden")}</strong>
<button className="primary-btn" onClick={goHome}>{ui(locale, "返回首页", "Back")}</button>
</div>
);
}
function toggleTheme() {
const next = dark ? "light" : "dark";
localStorage.setItem("nodeflare-theme", next);
setAppearance(next);
}
export default function App() {
const { access, background, config, configReady, dark, error, loading, reload, selectedId, toggleTheme } = useApp();
const locale = config.locale;
if (!configReady) {
return <div className="app-bootstrap" aria-busy={loading}>{error ? <div className="error-band"><span>{error}</span><button onClick={() => void load()}>重试</button></div> : null}</div>;
return (
<div className="app-bootstrap" aria-busy={loading}>
{error ? <div className="error-band"><span>{error}</span><button onClick={() => void reload()}>{ui(locale, "重试", "Retry")}</button></div> : null}
</div>
);
}
return (
@@ -449,50 +172,19 @@ export default function App() {
<div className="container header-inner">
<div className="brand"><SiteLogo src={config.logo_url} alt="" width="36" height="36" /><strong>{config.site_name}</strong></div>
<div className="header-actions">
<button className="icon-btn" onClick={toggleTheme} title={dark ? ui(config.locale, "浅色主题", "Light theme") : ui(config.locale, "深色主题", "Dark theme")}>{dark ? <Sun size={18} /> : <Moon size={18} />}</button>
<button className="icon-btn" onClick={openAdmin} title={ui(config.locale, "进入后台", "Administration")}><UserCircle size={18} /></button>
<button className="icon-btn" onClick={toggleTheme} title={dark ? ui(locale, "浅色主题", "Light theme") : ui(locale, "深色主题", "Dark theme")}>{dark ? <Sun size={18} /> : <Moon size={18} />}</button>
<button className="icon-btn" onClick={() => window.location.assign("/admin")} title={ui(locale, "进入后台", "Administration")}><UserCircle size={18} /></button>
</div>
</div>
</header>
<main className="container main-content">
{config.site_announcement ? <div className="site-announcement"><Megaphone size={16} /><span>{config.site_announcement}</span></div> : null}
{needsLogin ? (
<section className="dashboard-login-gate glass-panel">
<SiteLogo src={config.logo_url} 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} action="admin-login" 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">
<SiteLogo src={config.logo_url} alt="" width="52" height="52" />
<div><h1>{ui(config.locale, "访问验证", "Access verification")}</h1><p>{config.site_name}</p></div>
{config.turnstile_site_key ? <TurnstileWidget siteKey={config.turnstile_site_key} action="public-dashboard" theme={dark ? "dark" : "light"} onVerify={(token) => void verifyDashboard(token)} onError={setError} /> : <p className="form-error">{ui(config.locale, "Turnstile 尚未正确配置", "Turnstile is not configured")}</p>}
{verificationBusy ? <span className="verification-status">{ui(config.locale, "正在验证", "Verifying")}</span> : null}
{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} 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={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 && !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>{liveServers.length ? ui(config.locale, "没有匹配的节点", "No matching servers") : ui(config.locale, "尚未添加节点", "No servers added")}</strong></div> : null}
</div>}
{access === "login" ? <LoginGate />
: access === "turnstile" ? <VerificationGate />
: selectedId ? <DetailView />
: <HomeView />}
</main>
</div>
);
}
+286
View File
@@ -0,0 +1,286 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { api, ApiError, setToken } from "./api";
import { demoConfig, demoExchangeRates, demoServers } from "./demo";
import {
advancePlayback,
applyBatch,
mergeServerLive,
pruneStalePlayback,
type LiveMetricsMap,
type PlaybackBuffer,
} from "./live";
import { ui } from "./locale";
import { resolveBackground, themeToggle } from "./theme";
import { connectLive } from "./transport";
import type { Config, ExchangeRates, Server } from "./types";
import { derivePassword } from "./password";
const search = new URLSearchParams(window.location.search);
export const demoMode = import.meta.env.DEV && search.has("demo");
/**
* `?demo=1&carrier=1` previews the per-carrier card layout locally. Gated on
* `demoMode` so a stray `?carrier=1` cannot affect a real deployment.
*/
const demoViewConfig: Config = demoMode && search.has("carrier")
? { ...demoConfig, theme_options: { ...demoConfig.theme_options, showCarrierLatency: true } }
: demoConfig;
const defaultConfig: Config = { ...demoViewConfig, site_description: "", site_name: "" };
const BOOTSTRAP_POLL_INTERVAL = 15_000;
const BOOTSTRAP_POLL_TICKS_WHEN_LIVE = 20;
const MAX_CLOCK_STEP_MS = 5_000;
type Access = "ok" | "login" | "turnstile";
export interface AppState {
config: Config;
configReady: boolean;
servers: Server[];
liveMetrics: LiveMetricsMap;
exchangeRates: ExchangeRates | null;
loading: boolean;
error: string;
setError: (message: string) => void;
access: Access;
dark: boolean;
blur: boolean;
background: string;
carrierLatency: boolean;
toggleTheme: () => void;
selectedId: string | null;
openServer: (server: Server) => void;
goHome: () => void;
reload: () => Promise<void>;
login: (username: string, password: string, turnstileToken: string) => Promise<void>;
verify: (token: string) => Promise<void>;
}
const AppContext = createContext<AppState | null>(null);
export function useApp(): AppState {
const state = useContext(AppContext);
if (!state) throw new Error("useApp must be used within AppProvider");
return state;
}
function routeServerId() {
const match = window.location.pathname.match(/^\/instance\/([^/]+)\/?$/);
if (!match) return null;
try { return decodeURIComponent(match[1]); } catch { return null; }
}
export function AppProvider({ children }: { children: ReactNode }) {
const [config, setConfig] = useState(defaultConfig);
const [configReady, setConfigReady] = useState(demoMode);
const [servers, setServers] = useState<Server[]>([]);
const [liveMetrics, setLiveMetrics] = useState<LiveMetricsMap>({});
const [clockNow, setClockNow] = useState(() => Date.now());
const [exchangeRates, setExchangeRates] = useState<ExchangeRates | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [access, setAccess] = useState<Access>("ok");
const [selectedId, setSelectedId] = useState<string | null>(routeServerId);
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 liveConnectedRef = useRef(false);
const playbackRef = useRef<PlaybackBuffer>(new Map());
const serversRef = useRef<Server[]>([]);
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 carrierLatency = config.show_latency && themeToggle(config, "showCarrierLatency", false);
const reload = useCallback(async (quiet = false) => {
if (!quiet) setLoading(true);
if (demoMode) {
setConfig(demoViewConfig);
setConfigReady(true);
setServers(demoServers);
setExchangeRates(demoExchangeRates);
setError("");
setLoading(false);
return;
}
try {
const result = await api.bootstrap();
setConfig(result.config);
setConfigReady(true);
setServers(result.servers);
setExchangeRates(result.exchange_rates);
setAccess(result.access);
if (result.access !== "ok") {
playbackRef.current.clear();
setLiveMetrics({});
}
setError("");
} catch (reason) {
const status = reason instanceof ApiError ? reason.status : 0;
if (status === 401 || status === 403) {
setAccess(status === 401 ? "login" : "turnstile");
setServers([]);
playbackRef.current.clear();
setLiveMetrics({});
setError(status === 403 && reason instanceof Error ? reason.message : "");
} else {
setError(reason instanceof Error ? reason.message : ui(config.locale, "无法加载节点状态", "Unable to load server status"));
}
} finally {
setLoading(false);
}
}, []);
const login = useCallback(async (username: string, password: string, turnstileToken: string) => {
const derived = await derivePassword(password, config.password_client_salt);
const result = await api.login(username.trim(), password, derived, turnstileToken);
setToken(result.token);
setAccess("ok");
await reload();
}, [config.password_client_salt, reload]);
const verify = useCallback(async (token: string) => {
await api.verifyTurnstile(token);
setAccess("ok");
await reload();
}, [reload]);
useEffect(() => { void reload(); }, [reload]);
useEffect(() => {
serversRef.current = servers;
pruneStalePlayback(playbackRef.current, servers);
}, [servers]);
// Poll as a safety net: often when the live feed is down, rarely when it is
// healthy, so a missed frame cannot leave the page stale indefinitely.
useEffect(() => {
if (access !== "ok") return;
let ticks = 0;
const timer = window.setInterval(() => {
ticks += 1;
if (!liveConnectedRef.current || ticks % BOOTSTRAP_POLL_TICKS_WHEN_LIVE === 0) void reload(true);
}, BOOTSTRAP_POLL_INTERVAL);
return () => clearInterval(timer);
}, [access, reload]);
useEffect(() => {
let previous = Date.now();
const timer = window.setInterval(() => {
const current = Date.now();
const elapsed = Math.max(0, Math.min(MAX_CLOCK_STEP_MS, current - previous));
previous = current;
setClockNow(current);
if (elapsed === 0) return;
setLiveMetrics((currentMetrics) => advancePlayback(currentMetrics, playbackRef.current, elapsed / 1000));
}, 1_000);
return () => clearInterval(timer);
}, []);
useEffect(() => {
if (loading || access !== "ok" || demoMode) return;
return connectLive({ serverId: selectedId }, {
onServer: (server) => setServers((current) => current.map((entry) => entry.id === server.id ? server : entry)),
onBatch: (updates, cached) => setLiveMetrics((current) => applyBatch(current, updates, {
cached,
playback: playbackRef.current,
servers: serversRef.current,
})),
onConnectedChange: (connected) => { liveConnectedRef.current = connected; },
wakeTargets: () => {
const now = Date.now() / 1000;
return serversRef.current
.filter((server) => server.timestamp && now - server.timestamp <= config.offline_threshold_seconds)
.map((server) => server.id);
},
});
}, [access, config.offline_threshold_seconds, loading, selectedId]);
useEffect(() => {
document.documentElement.classList.toggle("dark", dark);
document.documentElement.style.colorScheme = dark ? "dark" : "light";
document.documentElement.dataset.blur = blur ? "on" : "off";
document.documentElement.lang = config.locale;
}, [blur, config.locale, dark]);
useEffect(() => {
let link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
if (!link) {
link = document.createElement("link");
link.rel = "icon";
document.head.append(link);
}
link.href = config.favicon_url || config.logo_url || "/logo.svg";
}, [config.favicon_url, config.logo_url]);
useEffect(() => {
const media = matchMedia("(prefers-color-scheme: dark)");
const update = () => setSystemDark(media.matches);
media.addEventListener("change", update);
return () => media.removeEventListener("change", update);
}, []);
useEffect(() => {
const onPop = () => setSelectedId(routeServerId());
window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop);
}, []);
const liveServers = useMemo(
() => servers.map((server) => mergeServerLive(server, liveMetrics[server.id], clockNow, config.offline_threshold_seconds)),
[clockNow, config.offline_threshold_seconds, liveMetrics, servers],
);
useEffect(() => {
if (!configReady) return;
const selected = liveServers.find((server) => server.id === selectedId);
document.title = selected ? `${selected.name} · ${config.site_name}` : config.site_name;
}, [config.site_name, configReady, liveServers, selectedId]);
const goHome = useCallback(() => {
window.history.pushState({}, "", demoMode ? "/?demo=1" : "/");
setSelectedId(null);
window.scrollTo({ top: 0, behavior: "auto" });
}, []);
const openServer = useCallback((server: Server) => {
window.history.pushState({}, "", `/instance/${encodeURIComponent(server.id)}${demoMode ? "?demo=1" : ""}`);
setSelectedId(server.id);
window.scrollTo({ top: 0, behavior: "auto" });
}, []);
const toggleTheme = useCallback(() => {
setAppearance((current) => {
const resolved = current ?? (dark ? "dark" : "light");
const next = resolved === "dark" ? "light" : "dark";
localStorage.setItem("nodeflare-theme", next);
return next;
});
}, [dark]);
const value = useMemo<AppState>(() => ({
config,
configReady,
servers: liveServers,
liveMetrics,
exchangeRates,
loading,
error,
setError,
access,
dark,
blur,
background,
carrierLatency,
toggleTheme,
selectedId,
openServer,
goHome,
reload: () => reload(),
login,
verify,
}), [access, background, blur, carrierLatency, config, configReady, dark, error, exchangeRates, goHome, liveMetrics, liveServers, loading, login, openServer, reload, selectedId, toggleTheme, verify]);
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}
+5 -2
View File
@@ -1,4 +1,4 @@
import type { AdminServer, AlertRule, AlertRuleInput, Bootstrap, CloudflareUsage, Config, DatabaseStats, ExchangeRates, HistoryPoint, LatencySample, LatencyTask, LatencyTaskInput, LatencyTestPoint, Server, ServerInput, Settings, Theme, ThemeSettingsSchema } from "./types";
import type { AdminServer, AlertRule, AlertRuleInput, Bootstrap, CloudflareUsage, Config, DatabaseStats, ExchangeRates, HistoryPoint, LatencySample, LatencyTask, LatencyTaskInput, LatencyTestPoint, Server, ServerInput, Settings, TelegramSettings, TelegramSettingsInput, Theme, ThemeSettingsSchema } from "./types";
const TOKEN_KEY = "nodeflare-admin-token";
export const ADMIN_UNAUTHORIZED_EVENT = "nodeflare:admin-unauthorized";
@@ -71,6 +71,10 @@ export const api = {
request<void>(`/api/admin/alert-rules/${encodeURIComponent(id)}`, { method: "PATCH", body: JSON.stringify(input) }, true),
deleteAlertRule: (id: string) =>
request<void>(`/api/admin/alert-rules/${encodeURIComponent(id)}`, { method: "DELETE" }, true),
telegramSettings: () => request<{ telegram: TelegramSettings | null }>("/api/admin/telegram", {}, true),
saveTelegramSettings: (input: TelegramSettingsInput) =>
request<void>("/api/admin/telegram", { method: "PUT", body: JSON.stringify(input) }, true),
testTelegram: () => request<void>("/api/admin/telegram/test", { method: "POST" }, true),
themeSettings: () => request<ThemeSettingsSchema>("/api/admin/theme-settings", {}, true),
themes: () => request<{ themes: Theme[] }>("/api/admin/themes", {}, true),
addTheme: (input: Pick<Theme, "name" | "description" | "url">) =>
@@ -117,5 +121,4 @@ export const api = {
databaseStats: () => request<DatabaseStats>("/api/admin/database", {}, true),
cloudflareUsage: () => request<CloudflareUsage>("/api/admin/cloudflare-usage", {}, true),
clearHistory: () => request<void>("/api/admin/history", { method: "DELETE" }, true),
testNotification: () => request<void>("/api/admin/notifications/test", { method: "POST" }, true),
};
+72
View File
@@ -0,0 +1,72 @@
import type { UiLocale } from "./locale";
interface TimedPoint {
timestamp: number;
}
/**
* Lower quartile of the observed sampling deltas. A quartile keeps repeated
* outages from inflating what we treat as the normal cadence, which is what
* decides where a timeline break belongs.
*/
export function typicalInterval(points: readonly TimedPoint[]): number | null {
const intervals = points
.slice(1)
.map((point, index) => point.timestamp - points[index].timestamp)
.filter((interval) => interval > 0 && Number.isFinite(interval))
.sort((left, right) => left - right);
return intervals.length ? intervals[Math.floor((intervals.length - 1) / 4)] : null;
}
/**
* Insert a break point whenever the timeline skips more than the expected
* cadence, so a chart never draws a straight line across an outage.
* `minGap`/`maxGap` use the same unit as `timestamp`.
*/
export function insertTimelineGaps<T extends TimedPoint>(
points: readonly T[],
createGap: (timestamp: number) => T,
{ minGap, maxGap }: { minGap: number; maxGap: number },
): T[] {
if (points.length < 2) return [...points];
const interval = typicalInterval(points);
if (interval === null) return [...points];
const threshold = Math.min(Math.max(minGap, interval * 1.5), maxGap);
const result: T[] = [points[0]];
for (let index = 1; index < points.length; index += 1) {
const previous = points[index - 1];
const current = points[index];
if (current.timestamp - previous.timestamp > threshold) {
result.push(createGap(previous.timestamp + interval));
}
result.push(current);
}
return result;
}
/** Widest break a chart of this span should draw, in milliseconds. */
export function chartGapLimit(hours: number): number {
if (hours <= 0) return 30_000;
return Math.max(5 * 60_000, (hours * 60 * 60_000) / 36);
}
/** Fixed axis window so a partially populated range keeps its true scale. */
export function chartTimeRange(hours: number, now = Date.now()) {
const start = now - hours * 60 * 60_000;
return {
domain: [start, now] as [number, number],
ticks: Array.from({ length: 5 }, (_, index) => start + ((now - start) * index) / 4),
};
}
export function chartTimeLabel(timestamp: number, hours: number, locale: UiLocale): string {
const date = new Date(timestamp);
if (hours <= 1) return date.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false });
if (hours <= 4) return date.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit", hour12: false });
if (hours <= 24) return date.toLocaleString(locale, { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false });
return date.toLocaleDateString(locale, { month: "2-digit", day: "2-digit" });
}
export function clockLabel(timestamp: number, locale: UiLocale): string {
return new Date(timestamp).toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit", hour12: false });
}
+10 -13
View File
@@ -41,13 +41,14 @@ import { useDialog } from "./useDialog";
import { SiteLogo } from "./SiteLogo";
import { LatencyManager } from "./LatencyManager";
import { AlertRuleManager } from "./AlertRuleManager";
import { TelegramSettings } from "./TelegramSettings";
import { Flag } from "./Flag";
import pkg from "../../package.json";
const VERSION = import.meta.env.VITE_NODEFLARE_VERSION || pkg.version;
type AdminTab = "servers" | "latency" | "appearance" | "themes" | "themeSettings" | "alerts" | "security" | "data" | "about";
type AgentPlatform = "linux" | "windows" | "macos";
type AgentPlatform = "linux" | "windows" | "macos" | "freebsd";
interface AgentInstallInfo {
agent_token: string;
@@ -376,13 +377,6 @@ export function AdminPanel({
finally { setBusy(false); }
}
async function testNotification() {
setBusy(true); setError(""); setNotice("");
try { await api.testNotification(); setNotice("测试通知已发送"); }
catch (reason) { setError(reason instanceof Error ? reason.message : "测试通知失败"); }
finally { setBusy(false); }
}
async function loadDatabase() {
setBusy(true); setError("");
try {
@@ -499,6 +493,9 @@ export function AdminPanel({
if (installPlatform === "macos") {
return `curl -fsSL ${installer}/install-macos.sh | sudo sh -s -- -e ${shellLiteral(origin)} -t ${shellLiteral(install.agent_token)}${shellMirror}`;
}
if (installPlatform === "freebsd") {
return `fetch -qo - ${installer}/install-freebsd.sh | sudo sh -s -- -e ${shellLiteral(origin)} -t ${shellLiteral(install.agent_token)}${shellMirror}`;
}
return `curl -fsSL ${installer}/agent.sh | sudo sh -s -- -e ${shellLiteral(origin)} -t ${shellLiteral(install.agent_token)}${shellMirror}`;
}, [install, installPlatform]);
const serverDialog = useDialog<HTMLFormElement>(editing !== null, () => setEditing(null));
@@ -617,7 +614,7 @@ export function AdminPanel({
<div className="form-grid"><label><span>站点名称</span><input required value={settings.site_name} onChange={(event) => updateSettings("site_name", event.target.value)} /></label><label><span>站点描述</span><input value={settings.site_description} onChange={(event) => updateSettings("site_description", event.target.value)} /></label></div>
<label><span>站点公告</span><textarea rows={3} maxLength={1000} value={settings.site_announcement} onChange={(event) => updateSettings("site_announcement", event.target.value)} /></label>
<div className="form-grid three"><label><span>界面语言</span><select value={settings.locale} onChange={(event) => updateSettings("locale", event.target.value as Settings["locale"])}><option value="zh-CN">简体中文</option><option value="en">English</option></select></label><label><span>站点 Logo 地址</span><input type="url" maxLength={1000} value={settings.logo_url} onChange={(event) => updateSettings("logo_url", event.target.value)} placeholder="https://example.com/logo.svg" /></label><label><span>浏览器图标地址</span><input type="url" maxLength={1000} value={settings.favicon_url} onChange={(event) => updateSettings("favicon_url", event.target.value)} placeholder="https://example.com/favicon.png" /></label></div>
<div className="form-grid"><label><span>离线判定(秒)</span><input type="number" min="30" max="3600" value={settings.offline_threshold_seconds} onChange={(event) => updateSettings("offline_threshold_seconds", Number(event.target.value))} /></label><label><span>历史保留(天)</span><input type="number" min="1" max="365" value={settings.history_retention_days} onChange={(event) => updateSettings("history_retention_days", Number(event.target.value))} /></label></div>
<div className="form-grid"><label><span>离线判定(秒)</span><input type="number" min="30" max="3600" value={settings.offline_threshold_seconds} onChange={(event) => updateSettings("offline_threshold_seconds", Number(event.target.value))} /></label><label><span>历史保留(天)</span><input type="number" min="1" max="30" value={settings.history_retention_days} onChange={(event) => updateSettings("history_retention_days", Number(event.target.value))} /></label></div>
</> : null}
{tab === "themeSettings" ? <>
@@ -631,10 +628,10 @@ export function AdminPanel({
{tab === "alerts" ? <>
<div className="section-title"><AlertTriangle size={15} />通知与告警</div>
<div className="form-grid"><label><span>Telegram Bot Token</span><input autoComplete="off" type="password" value={settings.notification_endpoint} onChange={(event) => updateSettings("notification_endpoint", event.target.value)} placeholder="123456789:AA..." /></label><label><span>Telegram Chat ID</span><input value={settings.notification_target} onChange={(event) => updateSettings("notification_target", event.target.value)} placeholder="个人、群组或频道 ID" /></label></div>
<Toggle label="启用通知与告警" checked={settings.notification_enabled} onChange={(value) => updateSettings("notification_enabled", value)} />
<div className="form-grid three"><label><span>离线告警延迟(分钟)</span><input type="number" min="2" max="1440" value={settings.offline_alert_minutes} onChange={(event) => updateSettings("offline_alert_minutes", Number(event.target.value))} /></label><label><span>到期提醒(天)</span><input type="number" min="0" max="365" value={settings.expiry_alert_days} onChange={(event) => updateSettings("expiry_alert_days", Number(event.target.value))} /></label><label><span>通知测试</span><button type="button" className="secondary-btn" disabled={busy} onClick={() => void testNotification()}>发送测试通知</button></label></div>
<p className="settings-hint">通过 Telegram Bot 发送告警;告警状态会记录在 D1,同一故障和恢复只发送一次。</p>
<div className="form-grid three"><label><span>离线告警延迟(分钟)</span><input type="number" min="2" max="1440" value={settings.offline_alert_minutes} onChange={(event) => updateSettings("offline_alert_minutes", Number(event.target.value))} /></label><label><span>到期提醒(天)</span><input type="number" min="0" max="365" value={settings.expiry_alert_days} onChange={(event) => updateSettings("expiry_alert_days", Number(event.target.value))} /></label><label><span>流量提醒起始阈值(%)</span><input type="number" min="50" max="100" value={settings.traffic_alert_percentage} onChange={(event) => updateSettings("traffic_alert_percentage", Number(event.target.value))} /></label></div>
<p className="settings-hint">流量达到起始阈值后,每增加 5 个百分点生成一次事件,最多提醒到 100%。</p>
<TelegramSettings onError={setError} onNotice={setNotice} />
<AlertRuleManager servers={servers} onError={setError} onNotice={setNotice} />
</> : null}
@@ -690,7 +687,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}
{installCommand ? <div className="submodal-backdrop" role="presentation" onMouseDown={installDialog.onBackdropMouseDown}><section ref={installDialog.dialogRef} className="install-modal glass-panel" role="dialog" aria-modal="true" aria-labelledby="install-dialog-title" tabIndex={-1}><header><div><span className="eyebrow">Agent 部署</span><h3 id="install-dialog-title">安装命令</h3></div><div className="segmented install-platform" 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"><pre>{installCommand}</pre></div><div className="form-actions"><button className="secondary-btn" type="button" onClick={() => setInstall(null)}>关闭</button><button className="primary-btn" type="button" onClick={() => void copyInstallCommand()}><Copy size={16} />复制</button></div></section></div> : null}
{installCommand ? <div className="submodal-backdrop" role="presentation" onMouseDown={installDialog.onBackdropMouseDown}><section ref={installDialog.dialogRef} className="install-modal glass-panel" role="dialog" aria-modal="true" aria-labelledby="install-dialog-title" tabIndex={-1}><header><div><span className="eyebrow">Agent 部署</span><h3 id="install-dialog-title">安装命令</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><button type="button" className={installPlatform === "freebsd" ? "active" : ""} onClick={() => setInstallPlatform("freebsd")}>FreeBSD</button></div></header><div className="install-list"><pre>{installCommand}</pre></div><div className="form-actions"><button className="secondary-btn" type="button" onClick={() => setInstall(null)}>关闭</button><button className="primary-btn" type="button" onClick={() => void copyInstallCommand()}><Copy size={16} />复制</button></div></section></div> : null}
</div>
);
}
+58 -16
View File
@@ -21,7 +21,8 @@ import {
} from "../format";
import type { Config, Server } from "../types";
import { ui } from "../locale";
import { useNodeLatency, type LatencyBar } from "../hooks/useNodeLatency";
import { carrierSelection, themeToggle } from "../theme";
import { useNodeLatency, type CarrierLatencyRow, type LatencyBar } from "../hooks/useNodeLatency";
import { Flag } from "./Flag";
import { OSIcon } from "./OSIcon";
import { ProgressBar } from "./ProgressBar";
@@ -40,25 +41,55 @@ function CompactLine({ icon, children, tone = "" }: { icon: React.ReactNode; chi
return <b className={tone}><span>{icon}</span><em>{children}</em></b>;
}
function QualityBars({ bars }: { bars: LatencyBar[] }) {
return (
<div className="quality-bars" style={{ gridTemplateColumns: `repeat(${Math.max(1, bars.length)}, minmax(0, 1fr))` }}>
{bars.map((bar, index) => {
const alignment = bars.length === 1 || (index >= 3 && index < bars.length - 3)
? "center"
: index < 3 ? "start" : "end";
return <span className="quality-bar" key={bar.key}>
<i className={bar.tone} />
<small className={`quality-tooltip ${alignment}`} role="tooltip">{bar.tooltip}</small>
</span>;
})}
</div>
);
}
function QualityPanel({ label, value, bars }: {
label: string;
value: string;
bars: LatencyBar[];
}) {
return (
<div className="quality-panel">
<div className="quality-panel" aria-label={`${label} ${value}`}>
<div><span>{label}</span><b>{value}</b></div>
<div className="quality-bars" style={{ gridTemplateColumns: `repeat(${Math.max(1, bars.length)}, minmax(0, 1fr))` }}>
{bars.map((bar, index) => {
const alignment = bars.length === 1 || (index >= 3 && index < bars.length - 3)
? "center"
: index < 3 ? "start" : "end";
return <span className="quality-bar" key={bar.key}>
<i className={bar.tone} />
<small className={`quality-tooltip ${alignment}`} role="tooltip">{bar.tooltip}</small>
</span>;
})}
</div>
<QualityBars bars={bars} />
</div>
);
}
/** One column per metric, one row per carrier line. */
function CarrierPanel({ label, rows, kind, empty }: {
label: string;
rows: CarrierLatencyRow[];
kind: "latency" | "loss";
empty: string;
}) {
return (
<div className="quality-panel carrier-panel">
<div><span>{label}</span></div>
{rows.length ? <div className="carrier-rows">
{rows.map((row) => <div className="carrier-row" key={row.id}>
<div className="carrier-row-head" title={row.name}>
<i style={{ backgroundColor: row.color }} />
<span>{row.label}</span>
<b>{kind === "latency" ? row.latencyDisplay : row.lossDisplay}</b>
</div>
<QualityBars bars={kind === "latency" ? row.latencyBars : row.lossBars} />
</div>)}
</div> : <div className="empty-inline">{empty}</div>}
</div>
);
}
@@ -70,11 +101,17 @@ export function NodeCard({ server, config, onOpen }: { server: Server; config: C
const disk = percent(server.disk_used, server.disk_total);
const usedTraffic = trafficUsed(server);
const traffic = server.traffic_limit > 0 ? Math.min(100, (usedTraffic / server.traffic_limit) * 100) : 0;
const quality = useNodeLatency(server, config.show_latency);
const locale = config.locale;
const showCarriers = themeToggle(config, "showCarrierLatency", false);
// The hook keys its memo on the selection values, not the object identity,
// so rebuilding this each render is free.
const quality = useNodeLatency(server, config.show_latency, locale, showCarriers ? carrierSelection(config) : null);
const price = formatPrice(server, locale);
const remainingValue = remainingAssetValue(server.price, server.billing_cycle, server.expires_at);
const showExpiryPanel = config.show_expiry || config.show_price;
const carrierEmpty = quality.loading
? ui(locale, "加载中", "Loading")
: ui(locale, "未匹配到线路", "No lines matched");
return (
<button className={`node-card glass-panel ${online ? "" : "offline"}`} onClick={onOpen} type="button" aria-label={ui(locale, `查看 ${server.name} 详情`, `View ${server.name} details`)}>
@@ -118,8 +155,13 @@ export function NodeCard({ server, config, onOpen }: { server: Server; config: C
</div>
{config.show_latency ? <div className="quality-grid">
<QualityPanel label={ui(locale, "延迟", "Latency")} value={quality.latencyDisplay} bars={quality.latencyBars} />
<QualityPanel label={ui(locale, "丢包", "Packet loss")} value={quality.lossDisplay} bars={quality.lossBars} />
{showCarriers ? <>
<CarrierPanel label={ui(locale, "延迟", "Latency")} rows={quality.carriers} kind="latency" empty={carrierEmpty} />
<CarrierPanel label={ui(locale, "丢包", "Packet loss")} rows={quality.carriers} kind="loss" empty={carrierEmpty} />
</> : <>
<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}
</div>
</button>
+108 -53
View File
@@ -13,7 +13,6 @@ import {
Area,
AreaChart,
CartesianGrid,
Legend,
Line,
LineChart,
ResponsiveContainer,
@@ -22,6 +21,7 @@ import {
YAxis,
} from "recharts";
import { api } from "../api";
import { chartGapLimit, chartTimeLabel, chartTimeRange, insertTimelineGaps } from "../chart";
import { demoHistory, demoLatencyHistory, demoLatencyTasks } from "../demo";
import { displayGpuDevices, formatBytes, formatCpuName, formatSpeed, formatUptime, isOnline, number } from "../format";
import type { HistoryPoint, LatencySample, LatencyTestPoint, LiveLatencyResult, Server } from "../types";
@@ -98,7 +98,7 @@ function average(values: number[]): number | null {
}
function latencyBucketSeconds(hours: number, taskCount: number): number {
const boundedHours = Math.max(1, Math.min(24 * 365, Math.trunc(hours)));
const boundedHours = Math.max(1, Math.min(24 * 30, Math.trunc(hours)));
const boundedTasks = Math.max(1, Math.min(128, Math.trunc(taskCount)));
const base = boundedHours === 1 ? 60
: boundedHours <= 4 ? 120
@@ -134,37 +134,64 @@ interface LatencyChartPoint {
value: number | null;
}
function latencyChartGapLimit(hours: number): number {
return Math.max(5 * 60_000, (hours * 60 * 60_000) / 36);
interface LoadChartPoint {
timestamp: number;
time: string;
cpu: number | null;
mem_used: number | null;
mem_total: number;
disk_used: number | null;
disk_total: number;
net_in: number | null;
net_out: number | null;
}
function insertLatencyGaps(points: LatencyChartPoint[], hours: number): LatencyChartPoint[] {
if (points.length < 2) return points;
const intervals = points.slice(1)
.map((point, index) => point.timestamp - points[index].timestamp)
.filter((interval) => interval > 0 && Number.isFinite(interval))
.sort((left, right) => left - right);
if (!intervals.length) return points;
const typicalInterval = intervals[Math.floor((intervals.length - 1) / 4)];
const threshold = Math.min(Math.max(10_000, typicalInterval * 1.5), latencyChartGapLimit(hours));
const result: LatencyChartPoint[] = [points[0]];
for (let index = 1; index < points.length; index += 1) {
const previous = points[index - 1];
const current = points[index];
if (current.timestamp - previous.timestamp > threshold) {
result.push({ timestamp: previous.timestamp + typicalInterval, label: "", value: null });
}
result.push(current);
}
return result;
return insertTimelineGaps(
points,
(timestamp) => ({ timestamp, label: "", value: null }),
{ minGap: 10_000, maxGap: chartGapLimit(hours) },
);
}
function latencyChartTime(timestamp: number, hours: number, locale: "zh-CN" | "en"): string {
const date = new Date(timestamp);
if (hours <= 1) return date.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false });
if (hours <= 4) return date.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit", hour12: false });
if (hours <= 24) return date.toLocaleString(locale, { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false });
return date.toLocaleDateString(locale, { month: "2-digit", day: "2-digit" });
function loadChartTime(timestamp: number, hours: number, locale: "zh-CN" | "en"): string {
return new Date(timestamp * 1000).toLocaleString(locale, hours >= 24
? { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false }
: { hour: "2-digit", minute: "2-digit", hour12: false });
}
function insertLoadGaps(points: HistoryPoint[], hours: number, locale: "zh-CN" | "en"): LoadChartPoint[] {
const mapped = points
.filter((point) => Number.isFinite(point.timestamp) && point.timestamp > 0)
.sort((left, right) => left.timestamp - right.timestamp)
.map((point) => ({
timestamp: point.timestamp,
time: loadChartTime(point.timestamp, hours, locale),
cpu: Number.isFinite(point.cpu) ? point.cpu : null,
mem_used: Number.isFinite(point.mem_used) ? point.mem_used : null,
mem_total: Math.max(0, number(point.mem_total)),
disk_used: Number.isFinite(point.disk_used) ? point.disk_used : null,
disk_total: Math.max(0, number(point.disk_total)),
net_in: Number.isFinite(point.net_in) ? point.net_in : null,
net_out: Number.isFinite(point.net_out) ? point.net_out : null,
}));
// Load history keeps second-resolution timestamps, so the gap bounds are
// the millisecond limits divided back down.
return insertTimelineGaps(
mapped,
(timestamp) => ({
timestamp,
time: "",
cpu: null,
mem_used: null,
mem_total: 0,
disk_used: null,
disk_total: 0,
net_in: null,
net_out: null,
}),
{ minGap: 10, maxGap: chartGapLimit(hours) / 1000 },
);
}
const tooltipStyle = {
@@ -189,6 +216,7 @@ export function NodeDetails({ server, liveLatencyResults, threshold, retentionDa
const [points, setPoints] = useState<HistoryPoint[]>([]);
const [latencyPoints, setLatencyPoints] = useState<LatencySample[]>([]);
const [latencyTasks, setLatencyTasks] = useState<LatencyTestPoint[]>([]);
const [hiddenLatencyTaskIds, setHiddenLatencyTaskIds] = useState<Set<string>>(() => new Set());
const [loadLoading, setLoadLoading] = useState(true);
const [latencyLoading, setLatencyLoading] = useState(true);
const [loadError, setLoadError] = useState("");
@@ -284,6 +312,14 @@ export function NodeDetails({ server, liveLatencyResults, threshold, retentionDa
setLatencyPoints((current) => mergeLatencySamples(current, samples, latencyHours, latencyTasks.length));
}, [demo, latencyHours, latencyTasks, liveLatencyResults, server.id]);
useEffect(() => {
setHiddenLatencyTaskIds((current) => {
const available = new Set(latencyTasks.map((task) => task.id));
const next = new Set([...current].filter((id) => available.has(id)));
return next.size === current.size ? current : next;
});
}, [latencyTasks]);
const online = isOnline(server, threshold);
const gpuDevices = useMemo(() => displayGpuDevices(server.gpus), [server.gpus]);
const gpuNames = gpuDevices.map((gpu) => gpu.model).join(" · ");
@@ -297,12 +333,11 @@ export function NodeDetails({ server, liveLatencyResults, threshold, retentionDa
].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
? { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false }
: { hour: "2-digit", minute: "2-digit", hour12: false }),
})), [points, hours, locale]);
const loadChartHours = loadHours === 0 ? 1 : loadHours;
const data = useMemo(
() => insertLoadGaps(points, loadChartHours, locale),
[loadChartHours, locale, points],
);
const latencySeries = useMemo(() => {
return latencyTasks.map((task, index) => {
const samples = latencyPoints.filter((point) => point.task_id === task.id);
@@ -311,7 +346,7 @@ export function NodeDetails({ server, liveLatencyResults, threshold, retentionDa
.sort((left, right) => left.timestamp - right.timestamp)
.map((sample) => ({
timestamp: sample.timestamp * 1000,
label: latencyChartTime(sample.timestamp * 1000, hours, locale),
label: chartTimeLabel(sample.timestamp * 1000, hours, locale),
value: sample.latency_ms >= 0 ? sample.latency_ms : null,
}));
return {
@@ -324,25 +359,30 @@ export function NodeDetails({ server, liveLatencyResults, threshold, retentionDa
};
});
}, [hours, latencyPoints, latencyTasks, locale]);
const visibleLatencySeries = useMemo(
() => latencySeries.filter((series) => !hiddenLatencyTaskIds.has(series.id)),
[hiddenLatencyTaskIds, latencySeries],
);
const latencyData = useMemo(() => {
const timestamps = new Set<number>();
for (const series of latencySeries) {
for (const series of visibleLatencySeries) {
for (const point of series.points) timestamps.add(point.timestamp);
}
return Array.from(timestamps)
.sort((left, right) => left - right)
.map((timestamp) => ({ timestamp, label: latencyChartTime(timestamp, hours, locale) }));
}, [hours, latencySeries, locale]);
const latencyTimeRange = useMemo(() => {
const now = Date.now();
const start = now - latencyHours * 60 * 60_000;
return {
domain: [start, now] as [number, number],
ticks: Array.from({ length: 5 }, (_, index) => start + ((now - start) * index) / 4),
};
}, [latencyHours, latencyPoints]);
.map((timestamp) => ({ timestamp, label: chartTimeLabel(timestamp, hours, locale) }));
}, [hours, locale, visibleLatencySeries]);
const latencyTimeRange = useMemo(() => chartTimeRange(latencyHours), [latencyHours, latencyPoints]);
const last = data[data.length - 1];
const pingEnabled = latencyTasks.length > 0;
const toggleLatencyTask = (taskId: string) => {
setHiddenLatencyTaskIds((current) => {
const next = new Set(current);
if (next.has(taskId)) next.delete(taskId);
else next.add(taskId);
return next;
});
};
return (
<div className="detail-page">
@@ -401,23 +441,38 @@ export function NodeDetails({ server, liveLatencyResults, threshold, retentionDa
) : (
<section className="latency-overview glass-panel">
<div className="latency-summary-grid" style={{ gridTemplateColumns: `repeat(${Math.max(1, latencySeries.length)}, minmax(180px, 1fr))` }}>
{latencySeries.map((series) => <div className="latency-summary" key={series.id}>
{latencySeries.map((series) => <div className={`latency-summary ${hiddenLatencyTaskIds.has(series.id) ? "hidden" : ""}`} key={series.id}>
<span><i style={{ backgroundColor: series.color }} />{series.name}</span>
<strong>{series.latency == null ? "--" : `${series.latency.toFixed(2)} ms`}</strong>
<small>{series.loss == null ? "--" : `${series.loss.toFixed(2)}%`} 平均丢包</small>
</div>)}
</div>
<div className="latency-task-legend" aria-label={ui(locale, "延迟任务", "Latency tasks")}>
{latencySeries.map((series) => {
const hidden = hiddenLatencyTaskIds.has(series.id);
return <button
className={`latency-task-toggle ${hidden ? "hidden" : ""}`}
key={series.id}
type="button"
aria-pressed={!hidden}
title={hidden ? ui(locale, "显示此任务", "Show this task") : ui(locale, "隐藏此任务", "Hide this task")}
onClick={() => toggleLatencyTask(series.id)}
>
<i style={{ backgroundColor: series.color }} />
<span>{series.name}</span>
</button>;
})}
</div>
<div className="latency-line-chart">
{latencyData.length ? <ResponsiveContainer width="100%" height="100%">
{latencyData.length && visibleLatencySeries.length ? <ResponsiveContainer width="100%" height="100%">
<LineChart data={latencyData} margin={{ top: 10, right: 12, left: 4, bottom: 2 }}>
<CartesianGrid stroke="var(--chart-grid)" strokeDasharray="3 3" />
<XAxis dataKey="timestamp" type="number" domain={latencyTimeRange.domain} ticks={latencyTimeRange.ticks} allowDataOverflow tick={{ fontSize: 10 }} minTickGap={40} tickFormatter={(value) => latencyChartTime(Number(value), latencyHours, locale)} />
<XAxis dataKey="timestamp" type="number" domain={latencyTimeRange.domain} ticks={latencyTimeRange.ticks} allowDataOverflow tick={{ fontSize: 10 }} minTickGap={40} tickFormatter={(value) => chartTimeLabel(Number(value), latencyHours, locale)} />
<YAxis tick={{ fontSize: 10 }} width={48} unit="ms" />
<Tooltip contentStyle={tooltipStyle} labelFormatter={(value) => latencyChartTime(Number(value), latencyHours, locale)} formatter={(value, name) => [value == null ? "--" : `${Number(value).toFixed(1)} ms`, name]} />
<Legend iconType="rect" iconSize={9} wrapperStyle={{ fontSize: 11, paddingTop: 10 }} />
{latencySeries.map((series) => <Line key={series.id} data={series.points} type="monotone" dataKey="value" name={series.name} stroke={series.color} strokeWidth={2} dot={false} connectNulls={false} isAnimationActive={false} />)}
<Tooltip contentStyle={tooltipStyle} labelFormatter={(value) => chartTimeLabel(Number(value), latencyHours, locale)} formatter={(value, name) => [value == null ? "--" : `${Number(value).toFixed(1)} ms`, name]} />
{visibleLatencySeries.map((series) => <Line key={series.id} data={series.points} type="monotone" dataKey="value" name={series.name} stroke={series.color} strokeWidth={2} dot={false} connectNulls={false} isAnimationActive={false} />)}
</LineChart>
</ResponsiveContainer> : <div className="latency-empty">{ui(locale, "暂无延迟数据", "No latency data")}</div>}
</ResponsiveContainer> : <div className="latency-empty">{visibleLatencySeries.length ? ui(locale, "暂无延迟数据", "No latency data") : ui(locale, "已隐藏全部任务", "All tasks are hidden")}</div>}
</div>
</section>
)}
+8 -1
View File
@@ -11,5 +11,12 @@ export function SiteLogo({ src = "", ...props }: SiteLogoProps) {
const [failedSource, setFailedSource] = useState("");
const resolved = failedSource === requested ? DEFAULT_LOGO : requested;
return <img {...props} src={resolved} onError={() => setFailedSource(requested)} />;
return <img
{...props}
src={resolved}
onError={() => {
// Do not retry the built-in fallback forever when it is unavailable.
if (resolved !== DEFAULT_LOGO) setFailedSource(requested);
}}
/>;
}
@@ -0,0 +1,100 @@
import { CheckCircle2, Send, Save } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { api } from "../api";
import type { TelegramSettingsInput } from "../types";
const defaultTemplate = "{{title}}\n\n服务器:{{server}}\n{{message}}\n时间:{{time}}";
const emptySettings: TelegramSettingsInput = {
bot_token: "",
chat_id: "",
message_thread_id: null,
template: defaultTemplate,
};
export function TelegramSettings({ onError, onNotice }: {
onError: (message: string) => void;
onNotice: (message: string) => void;
}) {
const [settings, setSettings] = useState<TelegramSettingsInput>(emptySettings);
const [configured, setConfigured] = useState(false);
const [busy, setBusy] = useState(false);
const load = useCallback(async () => {
try {
const result = await api.telegramSettings();
if (result.telegram) {
setSettings({
bot_token: result.telegram.bot_token,
chat_id: result.telegram.chat_id,
message_thread_id: result.telegram.message_thread_id,
template: result.telegram.template,
});
setConfigured(true);
} else {
setSettings(emptySettings);
setConfigured(false);
}
} catch (reason) {
onError(reason instanceof Error ? reason.message : "读取 Telegram 配置失败");
}
}, [onError]);
useEffect(() => { void load(); }, [load]);
function update<K extends keyof TelegramSettingsInput>(key: K, value: TelegramSettingsInput[K]) {
setSettings((current) => ({ ...current, [key]: value }));
}
async function save() {
setBusy(true);
onError("");
try {
await api.saveTelegramSettings({
...settings,
bot_token: settings.bot_token.trim(),
chat_id: settings.chat_id.trim(),
template: settings.template.trim(),
});
await load();
onNotice("Telegram 配置已保存");
} catch (reason) {
onError(reason instanceof Error ? reason.message : "保存 Telegram 配置失败");
} finally {
setBusy(false);
}
}
async function test() {
setBusy(true);
onError("");
try {
await api.testTelegram();
onNotice("Telegram 测试消息已发送");
} catch (reason) {
onError(reason instanceof Error ? reason.message : "Telegram 测试消息发送失败");
} finally {
setBusy(false);
}
}
const ready = Boolean(settings.bot_token.trim() && settings.chat_id.trim() && settings.template.trim());
return <section className="telegram-settings" aria-labelledby="telegram-settings-title">
<header className="telegram-settings-header">
<div className="telegram-settings-title">
<span className="telegram-mark"><Send size={18} /></span>
<div><h3 id="telegram-settings-title">Telegram</h3><span className={`telegram-state ${configured ? "configured" : ""}`}>{configured ? <CheckCircle2 size={13} /> : null}{configured ? "已配置" : "未配置"}</span></div>
</div>
</header>
<div className="telegram-fields">
<label className="telegram-token-field"><span>Bot Token</span><input type="password" autoComplete="off" value={settings.bot_token} onChange={(event) => update("bot_token", event.target.value)} placeholder="123456789:AA..." /></label>
<label><span>Chat ID</span><input value={settings.chat_id} onChange={(event) => update("chat_id", event.target.value)} placeholder="-1001234567890" /></label>
<label><span>话题 ID(可选)</span><input type="number" min="1" value={settings.message_thread_id ?? ""} onChange={(event) => update("message_thread_id", event.target.value ? Number(event.target.value) : null)} /></label>
</div>
<label className="telegram-template"><span>消息模板</span><textarea rows={5} maxLength={4000} value={settings.template} onChange={(event) => update("template", event.target.value)} /></label>
<div className="telegram-actions">
<button type="button" className="secondary-btn" disabled={busy || !configured} onClick={() => void test()}><Send size={15} />发送测试</button>
<button type="button" className="primary-btn" disabled={busy || !ready} onClick={() => void save()}><Save size={15} />保存 Telegram</button>
</div>
</section>;
}
Binary file not shown.
+139
View File
@@ -0,0 +1,139 @@
import { describe, expect, test } from "bun:test";
import { insertTimelineGaps, typicalInterval } from "./chart";
import { BAR_COUNT, bucketSamples, selectCarrierTasks } from "./latency";
import type { LatencySample } from "./types";
function sample(taskId: string, timestamp: number, latency = 30, loss = 0): LatencySample {
return {
task_id: taskId,
server_id: "server-1",
name: taskId,
task_type: "icmp",
target: "example.com",
port: null,
timestamp,
latency_ms: latency,
packet_loss: loss,
};
}
describe("selectCarrierTasks", () => {
const tasks = [
{ id: "a", name: "上海电信" },
{ id: "b", name: "China Mobile CN" },
{ id: "c", name: "cucc-guangzhou" },
];
test("matches carriers by name when nothing is configured", () => {
const selected = selectCarrierTasks(tasks, { telecom: "", mobile: "", unicom: "" });
expect(selected.get("telecom")?.id).toBe("a");
expect(selected.get("mobile")?.id).toBe("b");
expect(selected.get("unicom")?.id).toBe("c");
});
test("honours explicit task names and ignores unmatched slots", () => {
const selected = selectCarrierTasks(tasks, { telecom: "cucc-guangzhou", mobile: "", unicom: "missing" });
expect(selected.get("telecom")?.id).toBe("c");
expect(selected.has("mobile")).toBe(false);
expect(selected.has("unicom")).toBe(false);
});
test("never assigns one task to two slots", () => {
const selected = selectCarrierTasks([{ id: "only", name: "电信移动联通" }], { telecom: "", mobile: "", unicom: "" });
expect([...selected.values()].map((task) => task.id)).toEqual(["only"]);
});
test("falls back to backend order for unmatched slots", () => {
const selected = selectCarrierTasks(
[{ id: "x", name: "Tokyo" }, { id: "y", name: "Osaka" }],
{ telecom: "", mobile: "", unicom: "" },
);
expect(selected.get("telecom")?.id).toBe("x");
expect(selected.get("mobile")?.id).toBe("y");
expect(selected.has("unicom")).toBe(false);
});
});
describe("bucketSamples", () => {
const now = 3_600;
test("returns one bucket per bar", () => {
const points = Array.from({ length: 60 }, (_, index) => sample("a", 60 * index + 60));
expect(bucketSamples(points, 3_600, now)).toHaveLength(BAR_COUNT);
});
test("leaves older buckets empty when a node only just came back", () => {
// Five minutes of samples at the end of a one-hour window.
const points = Array.from({ length: 5 }, (_, index) => sample("a", now - 300 + 60 * index));
const buckets = bucketSamples(points, 3_600, now);
const filled = buckets.filter((bucket) => bucket.latency !== null);
expect(filled.length).toBeGreaterThan(0);
expect(filled.length).toBeLessThanOrEqual(4);
expect(buckets[0].latency).toBeNull();
});
test("bridges buckets for a task slower than one bucket", () => {
// One sample every 10 minutes cannot fill 20 three-minute buckets, so each
// reading carries forward rather than alternating filled/empty bars.
const points = Array.from({ length: 6 }, (_, index) => sample("a", now - 3_000 + 600 * index));
const buckets = bucketSamples(points, 3_600, now);
const filled = buckets.filter((bucket) => bucket.latency !== null);
expect(filled.length).toBeGreaterThan(6);
// Nothing is invented before the first sample.
expect(buckets[0].latency).toBeNull();
expect(buckets[buckets.length - 1].latency).not.toBeNull();
});
test("does not bridge a real outage at normal cadence", () => {
const before = Array.from({ length: 5 }, (_, index) => sample("a", now - 3_600 + 60 * index));
const after = Array.from({ length: 5 }, (_, index) => sample("a", now - 240 + 60 * index));
const buckets = bucketSamples([...before, ...after], 3_600, now);
expect(buckets.filter((bucket) => bucket.latency === null).length).toBeGreaterThan(10);
});
test("keeps the grid pinned to the requested window", () => {
const points = Array.from({ length: 6 }, (_, index) => sample("a", now - 3_000 + 600 * index));
const buckets = bucketSamples(points, 3_600, now);
expect(buckets[0].timestamp).toBe(now - 3_600);
expect(buckets[buckets.length - 1].timestamp).toBe(now - 3_600 / BAR_COUNT);
});
test("averages within a bucket and ignores negative readings", () => {
const points = [sample("a", now - 30, 10), sample("a", now - 20, 30), sample("a", now - 10, -1)];
const buckets = bucketSamples(points, 3_600, now);
const last = buckets[buckets.length - 1];
expect(last.latency).toBe(20);
});
test("returns nothing when there are no usable samples", () => {
expect(bucketSamples([], 3_600, now)).toEqual([]);
});
});
describe("insertTimelineGaps", () => {
test("breaks the line across an outage but not across normal cadence", () => {
const points = [
{ timestamp: 0, value: 1 },
{ timestamp: 60, value: 2 },
{ timestamp: 120, value: 3 },
{ timestamp: 3_600, value: 4 },
];
const result = insertTimelineGaps(
points,
(timestamp) => ({ timestamp, value: null as number | null }),
{ minGap: 10, maxGap: 600 },
);
expect(result).toHaveLength(5);
expect(result[3].value).toBeNull();
expect(result[4].timestamp).toBe(3_600);
});
test("leaves short series untouched", () => {
expect(insertTimelineGaps([{ timestamp: 5 }], () => ({ timestamp: 0 }), { minGap: 1, maxGap: 2 })).toHaveLength(1);
});
test("uses the lower quartile so outages do not inflate the cadence", () => {
const points = [{ timestamp: 0 }, { timestamp: 60 }, { timestamp: 120 }, { timestamp: 900 }];
expect(typicalInterval(points)).toBe(60);
});
});
+220
View File
@@ -0,0 +1,220 @@
import { clockLabel } from "./chart";
import { ui, type UiLocale } from "./locale";
import type { LatencySample } from "./types";
export type LatencyTone = "good" | "fair" | "warning" | "poor" | "danger" | "empty";
export interface LatencyBar {
key: string;
tone: LatencyTone;
tooltip: string;
}
export interface CarrierSlotKey {
telecom: string;
mobile: string;
unicom: string;
}
export const BAR_COUNT = 20;
/**
* Carrier lines, in display order. Aliases cover the Chinese names plus the
* common latin spellings and operator codes, so an admin who names a task
* "CT-Guangzhou" or "电信广州" lands in the same slot without extra config.
*/
export const CARRIER_SLOTS = [
{ key: "telecom" as const, label: "电信", labelEn: "Telecom", color: "#fb7185", aliases: ["电信", "chinatelecom", "telecom", "ctcc", "ct"] },
{ key: "mobile" as const, label: "移动", labelEn: "Mobile", color: "#34d399", aliases: ["移动", "chinamobile", "mobile", "cmcc", "cm"] },
{ key: "unicom" as const, label: "联通", labelEn: "Unicom", color: "#60a5fa", aliases: ["联通", "chinaunicom", "unicom", "cucc", "cu"] },
] as const;
export type CarrierSlot = typeof CARRIER_SLOTS[number];
export function latencyTone(value: number): LatencyTone {
if (value <= 60) return "good";
if (value <= 100) return "fair";
if (value <= 160) return "warning";
if (value <= 200) return "poor";
return "danger";
}
export function lossTone(value: number): LatencyTone {
if (value <= 1) return "good";
if (value <= 3) return "fair";
if (value <= 6) return "warning";
if (value <= 9) return "poor";
return "danger";
}
export function emptyBars(label: string): LatencyBar[] {
return Array.from({ length: BAR_COUNT }, (_, index) => ({ key: `empty-${index}`, tone: "empty" as const, tooltip: label }));
}
function normalizeTaskName(name: string): string {
return name.normalize("NFKC").toLowerCase().replace(/[\s_·、,,/|-]+/g, "");
}
export interface LatencyTaskRef {
id: string;
name: string;
}
/**
* Resolve the three carrier slots. Explicit names win; when every name is
* blank we match on the task name, then fill any slot still empty using the
* backend order so a two-task setup still renders two lines.
*/
export function selectCarrierTasks(
tasks: readonly LatencyTaskRef[],
configured: CarrierSlotKey,
): Map<CarrierSlot["key"], LatencyTaskRef> {
const selected = new Map<CarrierSlot["key"], LatencyTaskRef>();
const used = new Set<string>();
const names = CARRIER_SLOTS.map((slot) => configured[slot.key].trim());
const explicit = names.some((name) => name !== "");
if (explicit) {
const byName = new Map<string, LatencyTaskRef>();
for (const task of tasks) {
const name = task.name.trim();
if (name && !byName.has(name)) byName.set(name, task);
}
CARRIER_SLOTS.forEach((slot, index) => {
const task = names[index] ? byName.get(names[index]) : undefined;
if (!task || used.has(task.id)) return;
selected.set(slot.key, task);
used.add(task.id);
});
return selected;
}
const available = tasks.map((task) => ({ task, normalized: normalizeTaskName(task.name) }));
for (const slot of CARRIER_SLOTS) {
const match = available.find((candidate) => !used.has(candidate.task.id)
&& slot.aliases.some((alias) => candidate.normalized.includes(alias)));
if (!match) continue;
selected.set(slot.key, match.task);
used.add(match.task.id);
}
for (const slot of CARRIER_SLOTS) {
if (selected.has(slot.key)) continue;
const match = available.find((candidate) => !used.has(candidate.task.id));
if (!match) break;
selected.set(slot.key, match.task);
used.add(match.task.id);
}
return selected;
}
export interface LatencyBucket {
timestamp: number;
latency: number | null;
loss: number | null;
}
function validValue(value: number): number | null {
return Number.isFinite(value) && value >= 0 ? value : null;
}
/**
* Bucket samples across the requested window rather than across the extent of
* whatever data came back. A node that only reported for the last five minutes
* of an hour shows fifteen empty buckets instead of twenty healthy ones.
*
* The grid always spans exactly `windowSeconds`, so a bar's position means the
* same thing on every card. When a task samples more slowly than one bucket,
* its last reading carries forward for one cadence — otherwise a 10-minute
* task would alternate between filled and empty bars and read as flapping.
*/
export function bucketSamples(points: readonly LatencySample[], windowSeconds: number, now = Date.now() / 1000): LatencyBucket[] {
const samples = points
.filter((point) => Number.isFinite(point.timestamp) && point.timestamp > 0)
.map((point) => ({ timestamp: point.timestamp, latency: validValue(point.latency_ms), loss: validValue(point.packet_loss) }))
.filter((point) => point.latency !== null || point.loss !== null)
.sort((left, right) => left.timestamp - right.timestamp);
if (!samples.length) return [];
const deltas = samples
.slice(1)
.map((sample, index) => sample.timestamp - samples[index].timestamp)
.filter((delta) => delta > 0 && Number.isFinite(delta))
.sort((left, right) => left - right);
const cadence = deltas.length ? deltas[Math.floor((deltas.length - 1) / 4)] : 0;
const span = Math.max(1, windowSeconds);
const bucketSize = span / BAR_COUNT;
const end = Math.max(now, samples[samples.length - 1].timestamp);
const start = end - span;
// Only bridge buckets when the task genuinely samples slower than the grid.
const carryFor = cadence > bucketSize ? cadence * 1.5 : 0;
const buckets: LatencyBucket[] = [];
let cursor = 0;
let carried: LatencyBucket | null = null;
let carriedAt = -Infinity;
while (cursor < samples.length && samples[cursor].timestamp < start) {
carried = { timestamp: 0, latency: samples[cursor].latency, loss: samples[cursor].loss };
carriedAt = samples[cursor].timestamp;
cursor += 1;
}
for (let index = 0; index < BAR_COUNT; index += 1) {
const bucketStart = start + bucketSize * index;
// The final bucket includes its upper bound so a sample landing exactly on
// `end` is not dropped.
const bucketEnd = bucketStart + bucketSize;
const inclusive = index === BAR_COUNT - 1;
let latencySum = 0;
let latencyCount = 0;
let lossSum = 0;
let lossCount = 0;
while (cursor < samples.length
&& (inclusive ? samples[cursor].timestamp <= bucketEnd : samples[cursor].timestamp < bucketEnd)) {
const sample = samples[cursor];
if (sample.latency !== null) { latencySum += sample.latency; latencyCount += 1; }
if (sample.loss !== null) { lossSum += sample.loss; lossCount += 1; }
carriedAt = sample.timestamp;
cursor += 1;
}
if (latencyCount || lossCount) {
const bucket = {
timestamp: Math.round(bucketStart),
latency: latencyCount ? latencySum / latencyCount : null,
loss: lossCount ? lossSum / lossCount : null,
};
carried = bucket;
buckets.push(bucket);
continue;
}
const bridged = carryFor > 0 && carried !== null && bucketStart - carriedAt <= carryFor;
buckets.push({
timestamp: Math.round(bucketStart),
latency: bridged ? carried!.latency : null,
loss: bridged ? carried!.loss : null,
});
}
return buckets;
}
export function latencyBars(buckets: readonly LatencyBucket[], prefix: string, locale: UiLocale): LatencyBar[] {
const missing = ui(locale, "无采样数据", "No samples");
return buckets.map((bucket, index) => ({
key: `${prefix}-latency-${bucket.timestamp}-${index}`,
tone: bucket.latency === null ? "empty" as const : latencyTone(bucket.latency),
tooltip: `${clockLabel(bucket.timestamp * 1000, locale)}\n${bucket.latency === null ? missing : `${Math.round(bucket.latency)} ms`}`,
}));
}
export function lossBars(buckets: readonly LatencyBucket[], prefix: string, locale: UiLocale): LatencyBar[] {
const missing = ui(locale, "无采样数据", "No samples");
return buckets.map((bucket, index) => ({
key: `${prefix}-loss-${bucket.timestamp}-${index}`,
tone: bucket.loss === null ? "empty" as const : lossTone(bucket.loss),
tooltip: `${clockLabel(bucket.timestamp * 1000, locale)}\n${bucket.loss === null ? missing : `${bucket.loss.toFixed(1)}%`}`,
}));
}
export function averageOf(values: readonly number[]): number | null {
const valid = values.filter((value) => Number.isFinite(value) && value >= 0);
return valid.length ? valid.reduce((sum, value) => sum + value, 0) / valid.length : null;
}
+227
View File
@@ -0,0 +1,227 @@
import { describe, expect, test } from "bun:test";
import {
advancePlayback,
applyBatch,
mergeLiveLatency,
mergeLiveResults,
mergeServerLive,
pruneStalePlayback,
type LiveMetricsMap,
type PlaybackBuffer,
} from "./live";
import type { Server } from "./types";
function server(overrides: Partial<Server> = {}): Server {
return {
id: "s1",
name: "node",
region: "JP",
group_name: "",
tags: "",
expires_at: null,
traffic_limit: 0,
traffic_limit_type: "sum",
price: 0,
billing_cycle: 30,
currency: "CNY",
auto_renewal: false,
reset_day: 1,
timestamp: 1_000,
cpu: 10,
load1: 0, load5: 0, load15: 0,
mem_used: 0, mem_total: 0, swap_used: 0, swap_total: 0,
disk_used: 0, disk_total: 0,
net_in: 0, net_out: 0, net_rx_total: 0, net_tx_total: 0,
uptime: 100,
processes: 0, tcp_connections: 0, udp_connections: 0,
cpu_cores: 1, cpu_model: null, os: null, kernel: null, arch: null,
virtualization: null, gpu_usage: null, gpu_model: null, agent_version: null,
disk_read_bps: null, disk_write_bps: null, disk_read_iops: null,
disk_write_iops: null, disk_await_ms: null, disk_utilization: null,
disks: [],
gpus: [],
latency: [],
...overrides,
};
}
describe("applyBatch", () => {
test("shows the first sample immediately and buffers the rest", () => {
const playback: PlaybackBuffer = new Map();
const next = applyBatch({}, [{
serverId: "s1",
samples: [
{ ts: 1_010, data: { cpu: 11 } },
{ ts: 1_011, data: { cpu: 12 } },
{ ts: 1_012, data: { cpu: 13 } },
],
}], { cached: false, playback, servers: [server()] });
expect(next.s1.timestamp).toBe(1_010);
expect(next.s1.metrics.cpu).toBe(11);
expect(playback.get("s1")?.map((sample) => sample.ts)).toEqual([1_011, 1_012]);
});
test("ignores samples already covered by the persisted row", () => {
const playback: PlaybackBuffer = new Map();
const next = applyBatch({}, [{
serverId: "s1",
samples: [{ ts: 900, data: { cpu: 5 } }],
}], { cached: false, playback, servers: [server({ timestamp: 1_000 })] });
expect(next.s1).toBeUndefined();
expect(playback.size).toBe(0);
});
test("skips ahead on a cached replay instead of re-playing the window", () => {
const playback: PlaybackBuffer = new Map();
const next = applyBatch({}, [{
serverId: "s1",
samples: [{ ts: 1_010, data: { cpu: 11 } }, { ts: 1_070, data: { cpu: 12 } }],
reportAgeMs: 5_000,
}], { cached: true, playback, servers: [server()] });
expect(next.s1.timestamp).toBe(1_070);
expect(playback.size).toBe(0);
});
test("does not re-buffer a sample it is already holding", () => {
const playback: PlaybackBuffer = new Map([["s1", [{ ts: 1_011, data: { cpu: 12 } }]]]);
const current: LiveMetricsMap = {
s1: { timestamp: 1_010, displayTimestamp: 1_010, metrics: { cpu: 11 } },
};
applyBatch(current, [{
serverId: "s1",
samples: [{ ts: 1_011, data: { cpu: 12 } }, { ts: 1_012, data: { cpu: 13 } }],
}], { cached: false, playback, servers: [server()] });
expect(playback.get("s1")?.map((sample) => sample.ts)).toEqual([1_011, 1_012]);
});
test("keeps latency results out of the metric patch", () => {
const playback: PlaybackBuffer = new Map();
const next = applyBatch({}, [{
serverId: "s1",
samples: [{
ts: 1_010,
data: { cpu: 11, latency_results: [{ task_id: "t1", timestamp: 1_010, latency_ms: 20, packet_loss: 0 }] },
}],
}], { cached: false, playback, servers: [server()] });
expect("latency_results" in next.s1.metrics).toBe(false);
expect(next.s1.latencyResults).toHaveLength(1);
});
});
describe("advancePlayback", () => {
test("releases a buffered sample once the cursor reaches it", () => {
const playback: PlaybackBuffer = new Map([["s1", [{ ts: 1_011, data: { cpu: 12 } }]]]);
const next = advancePlayback(
{ s1: { timestamp: 1_010, displayTimestamp: 1_010, metrics: { cpu: 11 } } },
playback,
1,
);
expect(next.s1.timestamp).toBe(1_011);
expect(next.s1.metrics.cpu).toBe(12);
expect(playback.size).toBe(0);
});
test("advances only the cursor when nothing is due yet", () => {
const playback: PlaybackBuffer = new Map([["s1", [{ ts: 1_020, data: { cpu: 12 } }]]]);
const next = advancePlayback(
{ s1: { timestamp: 1_010, displayTimestamp: 1_010, metrics: { cpu: 11 } } },
playback,
1,
);
expect(next.s1.timestamp).toBe(1_010);
expect(next.s1.displayTimestamp).toBe(1_011);
expect(playback.get("s1")).toHaveLength(1);
});
test("is a no-op with an empty buffer", () => {
const current: LiveMetricsMap = { s1: { timestamp: 1, displayTimestamp: 1, metrics: {} } };
expect(advancePlayback(current, new Map(), 1)).toBe(current);
});
});
describe("mergeServerLive", () => {
test("applies a newer live sample and ticks uptime", () => {
const merged = mergeServerLive(
server({ timestamp: 1_000, uptime: 100 }),
{ timestamp: 1_010, displayTimestamp: 1_010, metrics: { cpu: 42 } },
1_015_000,
180,
);
expect(merged.cpu).toBe(42);
expect(merged.uptime).toBe(105);
});
test("keeps persisted metrics when the live sample is older", () => {
const merged = mergeServerLive(
server({ timestamp: 2_000, cpu: 10, uptime: null }),
{ timestamp: 1_000, displayTimestamp: 1_000, metrics: { cpu: 99 } },
2_000_000,
180,
);
expect(merged.cpu).toBe(10);
});
test("does not tick uptime past the offline threshold", () => {
const merged = mergeServerLive(server({ timestamp: 1_000, uptime: 100 }), undefined, 9_000_000, 180);
expect(merged.uptime).toBe(100);
});
});
describe("mergeLiveResults / mergeLiveLatency", () => {
test("deduplicates by task and timestamp, keeping chronological order", () => {
const merged = mergeLiveResults(
[{ task_id: "t1", timestamp: 10, latency_ms: 1, packet_loss: 0 }],
[
{ task_id: "t1", timestamp: 10, latency_ms: 2, packet_loss: 0 },
{ task_id: "t1", timestamp: 5, latency_ms: 3, packet_loss: 0 },
{ task_id: "", timestamp: 20, latency_ms: 4, packet_loss: 0 },
],
);
expect(merged.map((result) => result.timestamp)).toEqual([5, 10]);
expect(merged[1].latency_ms).toBe(2);
});
test("overlays the newest reading onto the persisted definition", () => {
const base = server({
latency: [{
task_id: "t1", server_id: "s1", name: "Tokyo", task_type: "icmp",
target: "example.com", port: null, timestamp: 100, latency_ms: 50, packet_loss: 0,
}],
});
const merged = mergeLiveLatency(base, [{ task_id: "t1", timestamp: 200, latency_ms: 12, packet_loss: 1 }]);
expect(merged[0].latency_ms).toBe(12);
expect(merged[0].name).toBe("Tokyo");
});
test("returns the same array when nothing is newer", () => {
const base = server({
latency: [{
task_id: "t1", server_id: "s1", name: "Tokyo", task_type: "icmp",
target: "example.com", port: null, timestamp: 300, latency_ms: 50, packet_loss: 0,
}],
});
expect(mergeLiveLatency(base, [{ task_id: "t1", timestamp: 200, latency_ms: 12, packet_loss: 1 }])).toBe(base.latency);
});
});
describe("pruneStalePlayback", () => {
test("drops buffered samples the persisted row already covers", () => {
const playback: PlaybackBuffer = new Map([["s1", [
{ ts: 900, data: {} },
{ ts: 1_100, data: {} },
]]]);
pruneStalePlayback(playback, [server({ timestamp: 1_000 })]);
expect(playback.get("s1")?.map((sample) => sample.ts)).toEqual([1_100]);
});
test("removes the entry entirely once nothing is left", () => {
const playback: PlaybackBuffer = new Map([["s1", [{ ts: 900, data: {} }]]]);
pruneStalePlayback(playback, [server({ timestamp: 1_000 })]);
expect(playback.has("s1")).toBe(false);
});
});
+201
View File
@@ -0,0 +1,201 @@
import type { LatencySample, LiveLatencyResult, Server } from "./types";
/** A single Agent report as replayed by the Worker. */
export interface LiveSample {
ts: number;
data: Partial<Server> & { latency_results?: LiveLatencyResult[] };
}
/**
* The sample currently on screen for one server. `timestamp` is the real
* sample time used for freshness checks; `displayTimestamp` is the playback
* cursor, which advances every second so buffered samples are shown at the
* cadence they were produced instead of all at once.
*/
export interface LiveMetrics {
timestamp: number;
displayTimestamp: number;
metrics: Partial<Server>;
latencyResults?: LiveLatencyResult[];
}
export type LiveMetricsMap = Record<string, LiveMetrics>;
/** Pending samples per server, ordered by sample time. */
export type PlaybackBuffer = Map<string, LiveSample[]>;
export interface BatchUpdate {
serverId?: string;
samples?: LiveSample[];
reportAgeMs?: number;
}
export const MAX_PLAYBACK_SAMPLES_PER_SERVER = 600;
const MAX_LIVE_LATENCY_RESULTS = 4096;
export function mergeLiveResults(
previous: LiveLatencyResult[] | undefined,
incoming: LiveLatencyResult[],
): LiveLatencyResult[] {
const results = new Map<string, LiveLatencyResult>();
for (const result of [...(previous ?? []), ...incoming]) {
if (!result.task_id || !Number.isFinite(result.timestamp) || result.timestamp <= 0) continue;
results.set(`${result.task_id}:${result.timestamp}`, result);
}
return Array.from(results.values())
.sort((left, right) => left.timestamp - right.timestamp)
.slice(-MAX_LIVE_LATENCY_RESULTS);
}
/** Overlay the newest live result onto each persisted latency definition. */
export function mergeLiveLatency(server: Server, results: LiveLatencyResult[]): LatencySample[] {
const latest = new Map<string, LiveLatencyResult>();
for (const result of results) {
const current = latest.get(result.task_id);
if (!current || result.timestamp >= current.timestamp) latest.set(result.task_id, result);
}
let changed = false;
const merged = server.latency.map((definition) => {
const result = latest.get(definition.task_id);
if (!result || result.timestamp <= definition.timestamp) return definition;
changed = true;
return {
...definition,
server_id: server.id,
timestamp: result.timestamp,
latency_ms: result.latency_ms,
packet_loss: result.packet_loss,
};
});
return changed ? merged : server.latency;
}
function splitSample(sample: LiveSample) {
const metrics = { ...sample.data };
const latencyResults = Array.isArray(metrics.latency_results) ? metrics.latency_results : [];
delete metrics.latency_results;
return { metrics, latencyResults };
}
/**
* Fold a `batchUpdate` frame into the live map, buffering anything newer than
* the playback cursor. `playback` is mutated in place — it is a ref-held
* buffer rather than render state, because it changes on every frame.
*/
export function applyBatch(
current: LiveMetricsMap,
updates: readonly BatchUpdate[],
context: { cached: boolean; playback: PlaybackBuffer; servers: readonly Server[] },
): LiveMetricsMap {
const next = { ...current };
for (const update of updates) {
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 previous = next[update.serverId];
const pending = context.playback.get(update.serverId) ?? [];
const persistedTimestamp = context.servers.find((server) => server.id === update.serverId)?.timestamp ?? 0;
const appliedTimestamp = Math.max(previous?.timestamp ?? 0, persistedTimestamp);
const seen = new Set<number>(pending.map((sample) => sample.ts));
const incoming = samples.filter((sample) => sample.ts > appliedTimestamp && !seen.has(sample.ts));
if (!incoming.length) continue;
// A cached replay arrives late, so start the cursor past the report age
// instead of re-playing the whole window in real time.
const reportAgeSeconds = context.cached && Number.isFinite(update.reportAgeMs)
? Math.max(0, update.reportAgeMs! / 1000)
: 0;
const cursor = context.cached
? Math.max(previous?.displayTimestamp ?? 0, incoming[incoming.length - 1].ts + reportAgeSeconds)
: previous?.displayTimestamp ?? incoming[0].ts;
const all = [...pending, ...incoming]
.sort((left, right) => left.ts - right.ts)
.slice(-MAX_PLAYBACK_SAMPLES_PER_SERVER);
let selected: LiveSample | undefined;
while (all.length && all[0].ts <= cursor) selected = all.shift();
if (selected) {
const { metrics, latencyResults } = splitSample(selected);
next[update.serverId] = {
timestamp: selected.ts,
displayTimestamp: cursor,
metrics,
latencyResults: latencyResults.length
? mergeLiveResults(previous?.latencyResults, latencyResults)
: previous?.latencyResults,
};
} else if (!previous) {
continue;
}
if (all.length) context.playback.set(update.serverId, all);
else context.playback.delete(update.serverId);
}
return next;
}
/** Move every playback cursor forward and release any sample it has reached. */
export function advancePlayback(
current: LiveMetricsMap,
playback: PlaybackBuffer,
elapsedSeconds: number,
): LiveMetricsMap {
if (!playback.size) return current;
const next = { ...current };
for (const [serverId, samples] of playback) {
const state = next[serverId];
if (!state || !samples.length) continue;
const displayTimestamp = state.displayTimestamp + elapsedSeconds;
let selected: LiveSample | undefined;
while (samples.length && samples[0].ts <= displayTimestamp) selected = samples.shift();
if (selected) {
const { metrics, latencyResults } = splitSample(selected);
next[serverId] = {
timestamp: selected.ts,
displayTimestamp,
metrics,
latencyResults: latencyResults.length
? mergeLiveResults(state.latencyResults, latencyResults)
: state.latencyResults,
};
} else {
next[serverId] = { ...state, displayTimestamp };
}
if (!samples.length) playback.delete(serverId);
}
return next;
}
/**
* Fold the live sample onto its persisted server row, then keep the uptime
* clock ticking between Agent reports while the sample is still fresh.
*/
export function mergeServerLive(
server: Server,
live: LiveMetrics | undefined,
clockNow: number,
offlineThresholdSeconds: number,
): Server {
const merged = !live ? server : (() => {
const latency = live.latencyResults?.length ? mergeLiveLatency(server, live.latencyResults) : server.latency;
if (live.timestamp <= (server.timestamp ?? 0)) {
return latency === server.latency ? server : { ...server, latency };
}
return { ...server, ...live.metrics, latency, timestamp: live.timestamp };
})();
if (!merged.timestamp || !merged.uptime || !Number.isFinite(merged.uptime)) return merged;
const elapsed = Math.max(0, Math.floor(clockNow / 1000 - merged.timestamp));
return elapsed > 0 && elapsed <= offlineThresholdSeconds
? { ...merged, uptime: merged.uptime + elapsed }
: merged;
}
/** Drop buffered samples the persisted rows have already caught up to. */
export function pruneStalePlayback(playback: PlaybackBuffer, servers: readonly Server[]) {
for (const server of servers) {
const samples = playback.get(server.id);
if (!samples || !server.timestamp) continue;
const fresh = samples.filter((sample) => sample.ts > server.timestamp!);
if (fresh.length) playback.set(server.id, fresh);
else playback.delete(server.id);
}
}
+4 -2
View File
@@ -1,7 +1,9 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import { AppProvider } from "./AppProvider";
import "./styles/dashboard.css";
createRoot(document.getElementById("root")!).render(<StrictMode><App /></StrictMode>);
createRoot(document.getElementById("root")!).render(
<StrictMode><AppProvider><App /></AppProvider></StrictMode>,
);
+16
View File
@@ -226,8 +226,22 @@
.alert-server-picker label { display: flex; min-width: 0; align-items: center; gap: 9px; border-bottom: 1px solid var(--admin-border); padding: 10px 11px; }
.alert-server-picker span:last-child { overflow: hidden; color: var(--admin-text); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.telegram-settings { display: grid; gap: 15px; border-top: 1px solid var(--admin-border); padding-top: 18px; }
.telegram-settings-header { display: flex; min-height: 38px; align-items: center; justify-content: space-between; gap: 14px; }
.telegram-settings-title { display: flex; min-width: 0; align-items: center; gap: 10px; }
.telegram-settings-title > div { display: flex; min-width: 0; align-items: baseline; flex-wrap: wrap; gap: 8px; }
.telegram-settings-title h3 { margin: 0; color: var(--admin-text); font-size: 16px; }
.telegram-mark { display: inline-flex; width: 34px; height: 34px; flex: 0 0 auto; align-items: center; justify-content: center; border-radius: 6px; color: var(--admin-accent); background: var(--admin-active-bg); }
.telegram-state { display: inline-flex; align-items: center; gap: 4px; color: var(--admin-muted); font-size: 11px; }
.telegram-state.configured { color: var(--success); }
.telegram-fields { display: grid; gap: 15px; grid-template-columns: minmax(220px, 1.4fr) minmax(180px, 1fr) minmax(130px, .65fr); }
.telegram-template textarea { min-height: 116px; resize: vertical; }
.telegram-actions { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: 8px; }
@media (max-width: 960px) {
.form-grid.three { grid-template-columns: 1fr 1fr; }
.telegram-fields { grid-template-columns: 1fr 1fr; }
.telegram-token-field { grid-column: 1 / -1; }
}
@media (max-width: 639px) {
@@ -250,6 +264,8 @@
.usage-do-breakdown { grid-template-columns: 1fr; }
.usage-table th, .usage-table td { padding-inline: 5px; font-size: 11px; }
.editor-modal, .install-modal { padding: 15px 13px; }
.telegram-fields { grid-template-columns: 1fr; }
.telegram-token-field { grid-column: auto; }
.editor-toggles { grid-template-columns: 1fr 1fr; }
.latency-task-row { padding-block: 8px; grid-template-columns: 70px minmax(0, 1fr) auto; }
.latency-task-row .latency-task-meta:nth-of-type(2) { display: none; }
+6
View File
@@ -25,6 +25,12 @@
--chart-grid: rgb(83 104 116 / 0.15);
--shadow: 0 10px 32px rgb(31 48 58 / 0.09), inset 0 1px 0 rgb(255 255 255 / 0.58);
--node-offline-bg: rgb(255 255 255 / 0.2);
/* Card geometry lives here so the grid, skeletons and the latency panels
cannot drift apart and make rows jump as data arrives. */
--node-card-min-height: 336px;
--node-card-carrier-min-height: 416px;
--quality-panel-height: 44px;
--carrier-panel-height: 124px;
color: var(--foreground);
background: var(--background);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", "PingFang SC", "Noto Sans SC", sans-serif;
+29 -6
View File
@@ -1,9 +1,9 @@
@import "./base.css";
.app-shell { position: relative; z-index: 0; display: flex; min-height: 100vh; flex-direction: column; }
.app-shell { position: relative; z-index: 0; isolation: isolate; display: flex; min-height: 100vh; flex-direction: column; }
.app-bootstrap { display: grid; min-height: 100vh; place-items: center; padding: 24px; background: var(--background); }
.app-bootstrap .error-band { width: min(520px, 100%); }
.theme-background { position: fixed; z-index: -2; inset: 0; opacity: 0; background-position: center; background-repeat: no-repeat; background-size: cover; transition: opacity 180ms ease; }
.theme-background { position: fixed; z-index: -1; inset: 0; pointer-events: none; opacity: 0; background-position: center; background-repeat: no-repeat; background-size: cover; transition: opacity 180ms ease; }
.theme-background::after { position: absolute; content: ""; inset: 0; background: color-mix(in srgb, var(--background) 18%, transparent); }
.container { width: min(1440px, 100%); margin: 0 auto; padding-inline: 32px; }
@@ -44,7 +44,8 @@
.result-count { margin-left: auto; color: var(--muted-foreground); font-size: 11px; white-space: nowrap; }
.node-grid, .loading-grid { display: grid; align-items: start; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); }
.node-card { position: relative; display: flex; width: 100%; min-width: 0; min-height: 336px; align-self: start; flex-direction: column; overflow: hidden; border-radius: 12px; padding: 0; text-align: left; box-shadow: var(--node-card-shadow); transition: border-color 160ms ease, background-color 160ms ease, box-shadow 160ms ease; }
.node-card { position: relative; display: flex; width: 100%; min-width: 0; min-height: var(--node-card-min-height); align-self: start; flex-direction: column; overflow: hidden; border-radius: 12px; padding: 0; text-align: left; box-shadow: var(--node-card-shadow); transition: border-color 160ms ease, background-color 160ms ease, box-shadow 160ms ease; }
.node-grid.carrier-latency .node-card, .loading-grid.carrier-latency span { min-height: var(--node-card-carrier-min-height); }
.node-card:hover { z-index: 2; border-color: color-mix(in srgb, var(--foreground) 24%, var(--node-card-border)); background: var(--node-card-hover); box-shadow: 0 12px 38px rgb(15 23 42 / .2); }
.dark .node-card:hover { box-shadow: 0 12px 38px rgb(0 0 0 / .54); }
.node-card.offline { box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger-bar) 30%, transparent), var(--node-card-shadow); }
@@ -82,8 +83,17 @@
.data-panel b svg { width: 10px; height: 10px; }
.data-panel em { overflow: hidden; font-style: normal; font-variant-numeric: tabular-nums; text-overflow: ellipsis; white-space: nowrap; }
.quality-grid { display: grid; gap: 6px; grid-template-columns: 1fr 1fr; }
.quality-panel { display: flex; min-width: 0; height: 44px; flex-direction: column; gap: 6px; overflow: visible; border-radius: 8px; padding: 8px; background: var(--node-data-bg); }
.quality-panel { display: flex; min-width: 0; height: var(--quality-panel-height); flex-direction: column; gap: 6px; overflow: visible; border-radius: 8px; padding: 8px; background: var(--node-data-bg); }
.quality-panel > div:first-child { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--muted-foreground); font-size: 12px; line-height: 1; }
.carrier-panel { height: var(--carrier-panel-height); }
.carrier-rows { display: flex; min-height: 0; flex: 1; flex-direction: column; justify-content: space-between; gap: 5px; }
.carrier-row { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 3px; }
.carrier-row-head { display: flex; min-width: 0; align-items: center; gap: 5px; color: var(--muted-foreground); font-size: 11px; line-height: 1.2; }
.carrier-row-head i { width: 7px; height: 7px; flex: 0 0 auto; border-radius: 50%; }
.carrier-row-head span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.carrier-row-head b { margin-left: auto; flex: 0 0 auto; color: var(--foreground); font-weight: 500; font-variant-numeric: tabular-nums; }
.carrier-row .quality-bars { min-height: 8px; }
.empty-inline { display: grid; min-height: 0; flex: 1; place-items: center; color: var(--muted-foreground); font-size: 11px; }
.quality-panel b { color: var(--foreground); font-size: 12px; font-weight: 500; font-variant-numeric: tabular-nums; }
.quality-bars { display: grid; min-height: 0; flex: 1; align-items: end; gap: 1px; opacity: .85; }
.quality-bars > span { position: relative; display: flex; height: 100%; min-width: 0; align-items: end; overflow: visible; border-radius: 1px; background: color-mix(in srgb, var(--foreground) 6%, transparent); }
@@ -101,6 +111,12 @@
.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); }
/* Keyboard users reach the card, not each bar, so surface the bar readings
whenever the card itself has focus. */
.node-card:focus-visible .quality-bar:first-child .quality-tooltip,
.node-card:focus-visible .quality-bar:last-child .quality-tooltip { visibility: visible; opacity: 1; transform: translateY(0); }
.node-card:focus-visible .quality-bar:first-child .quality-tooltip.center,
.node-card:focus-visible .quality-bar:last-child .quality-tooltip.center { transform: translate(-50%, 0); }
.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: 12px; }
.error-band button { font-weight: 700; }
@@ -120,7 +136,7 @@
.dashboard-login-turnstile .turnstile-widget { width: 100%; }
.dashboard-login-submit { width: 100%; min-height: 42px; }
.verification-status { color: var(--primary); font-size: 11px; }
.loading-grid span { height: 336px; border: 1px solid var(--border); border-radius: 12px; background: var(--card); animation: loading-pulse 1.3s ease-in-out infinite alternate; }
.loading-grid span { height: var(--node-card-min-height); border: 1px solid var(--border); border-radius: 12px; background: var(--card); animation: loading-pulse 1.3s ease-in-out infinite alternate; }
@keyframes loading-pulse { to { opacity: .46; } }
.detail-page { display: flex; flex-direction: column; gap: 14px; }
@@ -162,6 +178,14 @@
.latency-summary > span i { width: 9px; height: 9px; flex: 0 0 auto; border-radius: 2px; }
.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: 12px; font-variant-numeric: tabular-nums; }
.latency-summary.hidden { opacity: .42; }
.latency-task-legend { display: flex; min-width: 0; align-items: center; gap: 6px; overflow-x: auto; border-bottom: 1px solid var(--border); padding: 10px 16px; }
.latency-task-toggle { display: inline-flex; min-width: 0; min-height: 28px; flex: 0 0 auto; align-items: center; gap: 6px; border: 1px solid transparent; border-radius: 6px; padding: 4px 8px; color: var(--muted-foreground); font-size: 11px; white-space: nowrap; }
.latency-task-toggle:hover { color: var(--foreground); background: color-mix(in srgb, var(--foreground) 6%, transparent); }
.latency-task-toggle:focus-visible { outline: 2px solid var(--primary); outline-offset: 1px; }
.latency-task-toggle i { width: 8px; height: 8px; flex: 0 0 auto; border-radius: 2px; }
.latency-task-toggle.hidden { border-color: var(--border); color: var(--muted-foreground); opacity: .58; }
.latency-task-toggle.hidden i { opacity: .38; }
.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: 13px; }
@@ -194,7 +218,6 @@
.search-box { width: 100%; }
.group-tabs { max-width: calc(100% - 58px); }
.node-grid, .loading-grid { grid-template-columns: 1fr; }
.node-card { min-height: 336px; }
.detail-hero { padding: 14px 16px; }
.detail-hero > .icon-btn { margin-left: -4px; }
.detail-flag { font-size: 19px; }
+19
View File
@@ -1,3 +1,4 @@
import type { CarrierSlotKey } from "./latency";
import { ASSET_CURRENCIES, type AssetCurrency, type Config, type ThemeSettingValue } from "./types";
function option(config: Config, key: string): ThemeSettingValue | undefined {
@@ -16,6 +17,24 @@ export function themeToggle(config: Config, key: string, fallback = true) {
return typeof value === "boolean" ? value : fallback;
}
export function themeText(config: Config, key: string) {
const value = option(config, key);
return typeof value === "string" ? value.trim() : "";
}
/**
* Carrier line names as configured in the theme settings. Blank entries are
* intentional: the latency layer falls back to name matching when all three
* are empty, so an unconfigured site still shows sensible lines.
*/
export function carrierSelection(config: Config): CarrierSlotKey {
return {
telecom: themeText(config, "telecomLatencyTask"),
mobile: themeText(config, "mobileLatencyTask"),
unicom: themeText(config, "unicomLatencyTask"),
};
}
export function resolveBackground(raw: string, dark: boolean) {
const parts = raw.split("|").map((part) => part.trim());
if (!parts[0] && !parts[1]) return "";
+135
View File
@@ -0,0 +1,135 @@
import type { BatchUpdate } from "./live";
import type { Server } from "./types";
const RECONNECT_DELAY = 3_000;
const HEARTBEAT_INTERVAL = 30_000;
const WAKE_CONCURRENCY = 6;
const WAKE_TIMEOUT = 5_000;
const WAKE_SETTLE = 100;
export interface LiveTransportHandlers {
onServer: (server: Server) => void;
onBatch: (updates: BatchUpdate[], cached: boolean) => void;
onConnectedChange: (connected: boolean) => void;
/** Online servers that should be woken when the overview connects. */
wakeTargets: () => string[];
}
export interface LiveTransportOptions {
/** When set, the socket follows a single server instead of the overview. */
serverId: string | null;
}
function endpoint(serverId: string | null): URL {
const url = new URL(location.origin);
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
url.pathname = "/api/ws";
url.search = "";
if (serverId) url.searchParams.set("server_id", serverId);
return url;
}
/**
* WSS-only live feed. Returns a disposer; the caller owns when the connection
* starts and stops. Reconnects on close, and on the overview it opens a short
* socket per online server so hibernating Durable Objects resume reporting.
*/
export function connectLive(
{ serverId }: LiveTransportOptions,
handlers: LiveTransportHandlers,
): () => void {
const sockets: WebSocket[] = [];
const reconnects: number[] = [];
const wakeTimers: number[] = [];
const wakeSockets = new Set<WebSocket>();
let cancelled = false;
const reportConnected = () => {
handlers.onConnectedChange(sockets.some((socket) => socket.readyState === WebSocket.OPEN));
};
const wakeOverviewAgents = () => {
if (serverId || cancelled) return;
const queue = Array.from(new Set(handlers.wakeTargets()));
let cursor = 0;
let active = 0;
const launch = () => {
while (!cancelled && active < WAKE_CONCURRENCY && cursor < queue.length) {
const socket = new WebSocket(endpoint(queue[cursor++]));
wakeSockets.add(socket);
active += 1;
let finished = false;
let timeout = 0;
const finish = () => {
if (finished) return;
finished = true;
clearTimeout(timeout);
wakeSockets.delete(socket);
active -= 1;
socket.onopen = null;
socket.onclose = null;
socket.onerror = null;
if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) {
socket.close();
}
launch();
};
timeout = window.setTimeout(finish, WAKE_TIMEOUT);
wakeTimers.push(timeout);
socket.onopen = () => wakeTimers.push(window.setTimeout(finish, WAKE_SETTLE));
socket.onclose = finish;
socket.onerror = finish;
}
};
launch();
};
const connect = () => {
if (cancelled) return;
const socket = new WebSocket(endpoint(serverId));
sockets.push(socket);
socket.onopen = () => {
handlers.onConnectedChange(true);
wakeOverviewAgents();
};
socket.onclose = () => {
const index = sockets.indexOf(socket);
if (index >= 0) sockets.splice(index, 1);
reportConnected();
if (!cancelled) reconnects.push(window.setTimeout(connect, RECONNECT_DELAY));
};
socket.onerror = () => handlers.onConnectedChange(false);
socket.onmessage = (event) => {
if (event.data === "pong") return;
try {
const message = JSON.parse(event.data);
if (message.type === "server" && message.server?.id) {
handlers.onServer(message.server as Server);
return;
}
if (message.type === "batchUpdate" && Array.isArray(message.updates)) {
handlers.onBatch(message.updates as BatchUpdate[], message.cached === true);
}
} catch { /* Ignore non-protocol messages. */ }
};
};
connect();
const heartbeat = window.setInterval(() => {
for (const socket of sockets) {
if (socket.readyState === WebSocket.OPEN) socket.send("ping");
}
}, HEARTBEAT_INTERVAL);
return () => {
cancelled = true;
handlers.onConnectedChange(false);
clearInterval(heartbeat);
reconnects.forEach(clearTimeout);
wakeTimers.forEach(clearTimeout);
wakeSockets.forEach((socket) => socket.close());
wakeSockets.clear();
sockets.forEach((socket) => socket.close());
sockets.length = 0;
};
}
+10 -2
View File
@@ -275,14 +275,22 @@ export interface Settings extends Omit<Config, "password_client_salt"> {
new_password_derived?: string;
turnstile_secret_key: string;
notification_enabled: boolean;
notification_endpoint: string;
notification_target: string;
offline_alert_minutes: number;
expiry_alert_days: number;
traffic_alert_percentage: number;
cloudflare_account_id: string;
cloudflare_api_token: string;
}
export interface TelegramSettings {
bot_token: string;
chat_id: string;
message_thread_id: number | null;
template: string;
}
export type TelegramSettingsInput = Pick<TelegramSettings, "bot_token" | "chat_id" | "message_thread_id" | "template">;
type AlertMetric = "cpu" | "memory" | "disk" | "net_in" | "net_out";
type AlertAggregation = "average" | "continuous";
+2 -1
View File
@@ -20,5 +20,6 @@
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"]
"include": ["src"],
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
}
+2 -1
View File
@@ -2,7 +2,8 @@
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
{ "path": "./tsconfig.node.json" },
{ "path": "./tsconfig.test.json" }
]
}
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"tsBuildInfoFile": "../node_modules/.cache/nodeflare/tsconfig.test.tsbuildinfo",
"composite": true,
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["bun", "vite/client"],
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/**/*.ts", "src/**/*.tsx"]
}
+149 -60
View File
@@ -36,6 +36,12 @@ CREATE TABLE servers (
updated_at INTEGER NOT NULL
);
CREATE INDEX idx_servers_sort
ON servers(sort_order, created_at);
-- 分钟粒度历史。下面三张 metric_* 表结构完全一致,统一由
-- db::metric_history_ddl 生成,src/db.rs 的 metric_history_ddl_matches_migration
-- 负责防止漂移——轮换要在运行时重建 metric_history,DDL 因此在 Rust 里也存了一份。
CREATE TABLE metric_history (
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
timestamp INTEGER NOT NULL,
@@ -70,6 +76,45 @@ CREATE TABLE metric_history (
PRIMARY KEY(server_id, timestamp)
) WITHOUT ROWID;
-- 分钟粒度历史的上一代。保留期靠表轮换而不是 DELETE:DELETE 每删一行计一行
-- D1 写入,DROP TABLE 是页级回收、不计行写入。轮换后当前表是空的,所以近期
-- 读取(<=24h)必须同时读这两张表,两代合起来覆盖 24-48 小时。这里预建空表,
-- 保证 union 读取在首次轮换之前就不会撞上缺表。
CREATE TABLE metric_history_old (
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
timestamp INTEGER NOT NULL,
cpu REAL NOT NULL DEFAULT 0,
load1 REAL NOT NULL DEFAULT 0,
load5 REAL NOT NULL DEFAULT 0,
load15 REAL NOT NULL DEFAULT 0,
mem_used INTEGER NOT NULL DEFAULT 0,
mem_total INTEGER NOT NULL DEFAULT 0,
swap_used INTEGER NOT NULL DEFAULT 0,
swap_total INTEGER NOT NULL DEFAULT 0,
disk_used INTEGER NOT NULL DEFAULT 0,
disk_total INTEGER NOT NULL DEFAULT 0,
net_in REAL NOT NULL DEFAULT 0,
net_out REAL NOT NULL DEFAULT 0,
net_rx_total INTEGER NOT NULL DEFAULT 0,
net_tx_total INTEGER NOT NULL DEFAULT 0,
processes INTEGER NOT NULL DEFAULT 0,
tcp_connections INTEGER NOT NULL DEFAULT 0,
udp_connections INTEGER NOT NULL DEFAULT 0,
gpu_usage REAL NOT NULL DEFAULT 0,
disk_read_bps REAL NOT NULL DEFAULT 0,
disk_write_bps REAL NOT NULL DEFAULT 0,
disk_read_iops REAL NOT NULL DEFAULT 0,
disk_write_iops REAL NOT NULL DEFAULT 0,
disk_await_ms REAL NOT NULL DEFAULT 0,
disk_utilization REAL NOT NULL DEFAULT 0,
sample_count INTEGER NOT NULL DEFAULT 1 CHECK(sample_count > 0),
latest_timestamp INTEGER NOT NULL,
latest_json TEXT NOT NULL CHECK(json_valid(latest_json)),
latency_json TEXT NOT NULL DEFAULT '[]' CHECK(json_valid(latency_json)),
PRIMARY KEY(server_id, timestamp)
) WITHOUT ROWID;
-- 小时粒度历史。分钟表轮换掉之后的长期留存,按 history_retention_days 删除。
CREATE TABLE metric_history_hourly (
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
timestamp INTEGER NOT NULL,
@@ -104,52 +149,13 @@ CREATE TABLE metric_history_hourly (
PRIMARY KEY(server_id, timestamp)
) WITHOUT ROWID;
CREATE INDEX idx_servers_sort
ON servers(sort_order, created_at);
INSERT INTO settings(id, value, updated_at) VALUES (
1,
json_patch(
'{
"site_description": "轻量、实时的服务器运行状态",
"site_announcement": "",
"logo_url": "",
"favicon_url": "",
"locale": "zh-CN",
"public_dashboard": "true",
"history_cache_version": "0",
"default_theme": "system",
"active_theme_id": "builtin-nodeflare-glass",
"background_url": "",
"theme_options": "{}",
"show_search": "true",
"show_groups": "true",
"show_stats": "true",
"show_assets": "true",
"show_traffic": "true",
"show_speed": "true",
"show_price": "true",
"show_expiry": "true",
"show_latency": "true",
"show_uptime": "true",
"admin_username": "",
"admin_password_hash": "",
"turnstile_enabled": "false",
"turnstile_login_enabled": "true",
"turnstile_site_key": "",
"turnstile_secret_key": "",
"notification_enabled": "false",
"notification_endpoint": "",
"notification_target": "",
"offline_alert_minutes": "5",
"expiry_alert_days": "7",
"cloudflare_account_id": "",
"cloudflare_api_token": ""
}',
json_object('password_client_salt', lower(hex(randomblob(16))))
),
unixepoch()
);
-- 每台机器最新一次上报的落点。和 metric_history 分开,避免轮换影响实时读取。
CREATE TABLE server_latest_state (
server_id TEXT PRIMARY KEY REFERENCES servers(id) ON DELETE CASCADE,
latest_timestamp INTEGER NOT NULL,
latest_json TEXT NOT NULL CHECK(json_valid(latest_json)),
last_batch_id TEXT NOT NULL DEFAULT ''
) WITHOUT ROWID;
CREATE TABLE exchange_rate_snapshots (
base_currency TEXT PRIMARY KEY,
@@ -172,14 +178,6 @@ CREATE TABLE themes (
CREATE INDEX idx_themes_created
ON themes(created_at DESC);
INSERT INTO exchange_rate_snapshots (
base_currency, rates_json, source, rate_date, fetched_at, attempted_at
) VALUES (
'CNY',
'{"CNY":1,"USD":0.14799,"CAD":0.2086,"HKD":1.1594,"EUR":0.1275,"GBP":0.11027,"JPY":23.707,"RUB":11.560694,"CHF":0.120661,"INR":14.248668,"VND":3875.968992,"THB":4.97107}',
'default', '', 0, 0
);
CREATE TABLE latency_tasks (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
@@ -222,12 +220,103 @@ CREATE TABLE alert_rule_servers (
PRIMARY KEY(rule_id, server_id)
) WITHOUT ROWID;
CREATE TABLE alert_states (
rule_id TEXT NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE,
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
active INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL,
PRIMARY KEY(rule_id, server_id)
-- 通知只走 Telegram,单行配置表。
CREATE TABLE notification_telegram (
id INTEGER PRIMARY KEY CHECK(id = 1),
bot_token TEXT NOT NULL,
chat_id TEXT NOT NULL,
message_thread_id INTEGER CHECK(message_thread_id IS NULL OR message_thread_id > 0),
template TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE notification_events (
id TEXT PRIMARY KEY,
dedupe_key TEXT NOT NULL UNIQUE,
kind TEXT NOT NULL CHECK(kind IN (
'offline', 'online', 'expiry', 'resource_alert', 'resource_recovery', 'traffic', 'test'
)),
server_id TEXT REFERENCES servers(id) ON DELETE SET NULL,
server_name TEXT NOT NULL DEFAULT '',
title TEXT NOT NULL,
message TEXT NOT NULL,
details_json TEXT NOT NULL DEFAULT '{}' CHECK(json_valid(details_json)),
occurred_at INTEGER NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE notification_deliveries (
event_id TEXT PRIMARY KEY REFERENCES notification_events(id) ON DELETE CASCADE,
status TEXT NOT NULL CHECK(status IN ('pending', 'sent', 'failed', 'dead')),
attempts INTEGER NOT NULL DEFAULT 0,
next_attempt_at INTEGER NOT NULL,
last_error TEXT,
sent_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
) WITHOUT ROWID;
CREATE INDEX idx_notification_deliveries_pending
ON notification_deliveries(status, next_attempt_at, created_at);
CREATE TABLE notification_state (
id INTEGER PRIMARY KEY CHECK(id = 1),
value TEXT NOT NULL CHECK(json_valid(value)),
updated_at INTEGER NOT NULL
);
INSERT INTO notification_state(id, value, updated_at)
VALUES (1, '{"offline":[],"expiry":[],"resources":[],"traffic":{}}', unixepoch());
INSERT INTO settings(id, value, updated_at) VALUES (
1,
json_patch(
'{
"site_description": "轻量、实时的服务器运行状态",
"site_announcement": "",
"logo_url": "",
"favicon_url": "",
"locale": "zh-CN",
"public_dashboard": "true",
"history_cache_version": "0",
"default_theme": "system",
"active_theme_id": "builtin-nodeflare-glass",
"background_url": "",
"theme_options": "{}",
"show_search": "true",
"show_groups": "true",
"show_stats": "true",
"show_assets": "true",
"show_traffic": "true",
"show_speed": "true",
"show_price": "true",
"show_expiry": "true",
"show_latency": "true",
"show_uptime": "true",
"admin_username": "",
"admin_password_hash": "",
"turnstile_enabled": "false",
"turnstile_login_enabled": "true",
"turnstile_site_key": "",
"turnstile_secret_key": "",
"notification_enabled": "false",
"offline_alert_minutes": "5",
"expiry_alert_days": "7",
"traffic_alert_percentage": "80",
"cloudflare_account_id": "",
"cloudflare_api_token": ""
}',
json_object('password_client_salt', lower(hex(randomblob(16))))
),
unixepoch()
);
INSERT INTO exchange_rate_snapshots (
base_currency, rates_json, source, rate_date, fetched_at, attempted_at
) VALUES (
'CNY',
'{"CNY":1,"USD":0.14799,"CAD":0.2086,"HKD":1.1594,"EUR":0.1275,"GBP":0.11027,"JPY":23.707,"RUB":11.560694,"CHF":0.120661,"INR":14.248668,"VND":3875.968992,"THB":4.97107}',
'default', '', 0, 0
);
PRAGMA optimize;
+3 -3
View File
@@ -3,7 +3,7 @@
"version": "0.0.1",
"private": true,
"repository": "https://github.com/imengying/NodeFlare",
"packageManager": "bun@1.3.14",
"packageManager": "bun@1.4.0",
"workspaces": [
"frontend"
],
@@ -19,8 +19,8 @@
"test:smoke": "sh scripts/smoke-test.sh"
},
"devDependencies": {
"wrangler": "^4.123.0",
"ws": "^8.21.0"
"wrangler": "^4.127.0",
"ws": "^8.21.3"
},
"overrides": {
"nanoid": "3.3.18"
+28 -97
View File
@@ -77,11 +77,7 @@ server_input='{"name":"Smoke Test Node","region":"JP","group_name":"Test","tags"
server_id=
latency_task_id=
alert_rule_id=
stress_report_file=
cleanup() {
if [ -n "$stress_report_file" ]; then
rm -f "$stress_report_file"
fi
if [ -n "$alert_rule_id" ]; then
monitor_curl --silent --show-error -H "Authorization: Bearer $admin_token" \
-X DELETE "$MONITOR_BASE_URL/api/admin/alert-rules/$alert_rule_id" >/dev/null || true
@@ -97,6 +93,18 @@ cleanup() {
}
trap cleanup EXIT
request -H "Authorization: Bearer $admin_token" \
"$MONITOR_BASE_URL/api/admin/telegram" | \
jq -e '.telegram == null or (.telegram.bot_token == "********" and (.telegram.chat_id | length > 0))' >/dev/null
invalid_telegram_status=$(monitor_curl --silent --output /dev/null --write-out '%{http_code}' \
-X PUT -H "Authorization: Bearer $admin_token" -H 'Content-Type: application/json' \
--data '{"bot_token":"invalid","chat_id":"","message_thread_id":null,"template":"{{message}}"}' \
"$MONITOR_BASE_URL/api/admin/telegram")
[ "$invalid_telegram_status" = "400" ]
removed_channels_status=$(monitor_curl --silent --output /dev/null --write-out '%{http_code}' \
-H "Authorization: Bearer $admin_token" "$MONITOR_BASE_URL/api/admin/notification-channels")
[ "$removed_channels_status" = "404" ]
latency_task_json=$(request -H "Authorization: Bearer $admin_token" \
-H 'Content-Type: application/json' \
--data '{"name":"Smoke TCP","task_type":"tcp","target":"example.com","port":443,"interval_seconds":60,"default_enabled":true,"server_ids":[]}' \
@@ -110,106 +118,29 @@ server_json=$(request -H "Authorization: Bearer $admin_token" \
server_id=$(printf '%s' "$server_json" | jq -er '.id')
agent_token=$(printf '%s' "$server_json" | jq -er '.agent_token')
MONITOR_BASE_URL="$MONITOR_BASE_URL" MONITOR_ADMIN_TOKEN="$admin_token" \
MONITOR_AGENT_TOKEN="$agent_token" node scripts/websocket-smoke.mjs
MONITOR_AGENT_TOKEN="$agent_token" MONITOR_SERVER_ID="$server_id" \
MONITOR_LATENCY_TASK_ID="$latency_task_id" \
node scripts/websocket-smoke.mjs
token_without_admin_status=$(monitor_curl --silent --output /dev/null --write-out '%{http_code}' \
"$MONITOR_BASE_URL/api/admin/servers/$server_id/token")
[ "$token_without_admin_status" = "401" ]
request -H "Authorization: Bearer $admin_token" \
"$MONITOR_BASE_URL/api/admin/servers/$server_id/token" | \
jq -e --arg token "$agent_token" '.agent_token == $token' >/dev/null
agent_version=$(sh scripts/resolve-version.sh)
sample=$(jq -nc --arg agent_version "$agent_version" '{
timestamp:((((now|floor) / 60 | floor) + 1) * 60 + 10),cpu:18.5,load1:0.42,load5:0.36,load15:0.31,
mem_used:2147483648,mem_total:4294967296,swap_used:0,swap_total:0,disk_used:21474836480,disk_total:53687091200,
net_in:4096,net_out:2048,net_rx_total:1073741824,net_tx_total:536870912,
uptime:86400,processes:90,tcp_connections:18,udp_connections:4,cpu_cores:2,
cpu_model:"Smoke CPU",os:"Debian 12",kernel:"6.1",arch:"x86_64",virtualization:"kvm",
gpu_usage:32.5,gpu_model:"NVIDIA T4",disk_read_bps:4194304,disk_write_bps:2097152,
disk_read_iops:120,disk_write_iops:48,disk_await_ms:1.4,disk_utilization:8.2,
disks:[{name:"/dev/vda1",mount_point:"/",used:21474836480,total:53687091200,read_bps:4194304,write_bps:2097152,read_iops:120,write_iops:48,await_ms:1.4,utilization:8.2}],
gpus:[{model:"NVIDIA T4",usage:32.5,memory_used:1073741824,memory_total:17179869184}],
agent_version:$agent_version,latency_results:[]
}')
report=$(jq -nc --argjson sample "$sample" '{samples:[$sample]}')
invalid_token_status=$(monitor_curl --silent --output /dev/null --write-out '%{http_code}' \
-H 'Authorization: Bearer invalid-agent-token' -H 'Content-Type: application/json' \
--data "$report" "$MONITOR_BASE_URL/api/agent/report")
[ "$invalid_token_status" = "401" ]
report_response=$(request -H "Authorization: Bearer $agent_token" -H 'Content-Type: application/json' \
-H 'CF-Connecting-IP: 8.8.8.8' \
--data "$report" "$MONITOR_BASE_URL/api/agent/report")
printf '%s' "$report_response" | jq -e \
--arg task_id "$latency_task_id" \
'.collect_interval == 5 and .agent_mirror == "https://mirror.example.com" and .auto_update == true and (.latency_tasks | any(.id == $task_id and .task_type == "tcp" and .target == "example.com" and .port == 443))' >/dev/null
request -H "Authorization: Bearer $agent_token" \
"$MONITOR_BASE_URL/api/agent/config" | jq -e \
--arg task_id "$latency_task_id" \
'.collect_interval == 5 and .agent_mirror == "https://mirror.example.com" and (.latency_tasks | any(.id == $task_id))' >/dev/null
legacy_report_status=$(monitor_curl --silent --output /dev/null --write-out '%{http_code}' \
-X POST -H "Authorization: Bearer $agent_token" -H 'Content-Type: application/json' \
--data '{}' "$MONITOR_BASE_URL/api/agent/report")
[ "$legacy_report_status" = "404" ]
for legacy_path in /api/agent/config /api/agent/live; do
legacy_status=$(monitor_curl --silent --output /dev/null --write-out '%{http_code}' \
-H "Authorization: Bearer $agent_token" "$MONITOR_BASE_URL$legacy_path")
[ "$legacy_status" = "404" ]
done
request -H "Authorization: Bearer $admin_token" "$MONITOR_BASE_URL/api/admin/servers" | \
jq -e --arg id "$server_id" '.servers | any(.id == $id and .last_ip == "8.8.8.8")' >/dev/null
latency_report=$(printf '%s' "$report" | jq --arg task_id "$latency_task_id" \
'.samples[0].timestamp += 1 |
.samples[0].latency_results=[{
task_id:$task_id,
timestamp:.samples[0].timestamp,
latency_ms:28.4,
packet_loss:25
}]')
request -H "Authorization: Bearer $agent_token" -H 'Content-Type: application/json' \
--data "$latency_report" "$MONITOR_BASE_URL/api/agent/report" | jq -e '.collect_interval == 5' >/dev/null
latency_report_second=$(printf '%s' "$report" | jq --arg task_id "$latency_task_id" \
'.samples[0].timestamp += 2 |
.samples[0].latency_results=[{
task_id:$task_id,
timestamp:.samples[0].timestamp,
latency_ms:48.4,
packet_loss:75
}]')
request -H "Authorization: Bearer $agent_token" -H 'Content-Type: application/json' \
--data "$latency_report_second" "$MONITOR_BASE_URL/api/agent/report" | jq -e '.collect_interval == 5' >/dev/null
# A whole Agent batch must collapse into one traffic-cycle write while preserving
# counter resets. Replaying the same batch must not count any bytes twice.
traffic_report=$(printf '%s' "$report" | jq '.samples = [
(.samples[0] | .timestamp += 3 | .net_rx_total=2147483648 | .net_tx_total=1073741824),
(.samples[0] | .timestamp += 4 | .net_rx_total=268435456 | .net_tx_total=134217728),
(.samples[0] | .timestamp += 5 | .net_rx_total=536870912 | .net_tx_total=268435456)
]')
request -H "Authorization: Bearer $agent_token" -H 'Content-Type: application/json' \
--data "$traffic_report" "$MONITOR_BASE_URL/api/agent/report" | jq -e '.collect_interval == 5' >/dev/null
request -H "Authorization: Bearer $agent_token" -H 'Content-Type: application/json' \
--data "$traffic_report" "$MONITOR_BASE_URL/api/agent/report" | jq -e '.collect_interval == 5' >/dev/null
request -H "Authorization: Bearer $admin_token" "$MONITOR_BASE_URL/api/bootstrap" | jq -e --arg id "$server_id" --arg task_id "$latency_task_id" '.servers | any(.id == $id and .cpu == 18.5 and .gpu_usage == 32.5 and .disk_await_ms == 1.4 and (.gpus | length) == 1 and (.disks | length) == 1 and .disk_used == 21474836480 and .traffic_limit == 107374182400 and .net_rx_total == 2684354560 and .net_tx_total == 1342177280 and .price == 9.9 and (has("last_ip") | not) and (.latency | any(.task_id == $task_id and .latency_ms == 48.4 and .packet_loss == 75)))' >/dev/null
# Changing the reset day starts a new traffic cycle. Only growth after the first
# sample in that cycle counts toward usage.
request -H "Authorization: Bearer $admin_token" -H 'Content-Type: application/json' -X PATCH \
--data "$(printf '%s' "$server_input" | jq '.reset_day=2')" \
"$MONITOR_BASE_URL/api/admin/servers/$server_id" >/dev/null
cycle_report=$(printf '%s' "$report" | jq '.samples = [
(.samples[0] | .timestamp += 6 | .net_rx_total=805306368 | .net_tx_total=536870912),
(.samples[0] | .timestamp += 7 | .net_rx_total=1073741824 | .net_tx_total=805306368)
]')
request -H "Authorization: Bearer $agent_token" -H 'Content-Type: application/json' \
--data "$cycle_report" "$MONITOR_BASE_URL/api/agent/report" | jq -e '.collect_interval == 5' >/dev/null
request -H "Authorization: Bearer $admin_token" "$MONITOR_BASE_URL/api/bootstrap" | \
jq -e --arg id "$server_id" '.servers | any(.id == $id and .net_rx_total == 268435456 and .net_tx_total == 268435456)' >/dev/null
# Exercise the protocol maximum so batch aggregation and request-size limits
# stay covered when an Agent flushes a full pending queue.
stress_report_file=$(mktemp "${TMPDIR:-/tmp}/nodeflare-stress.XXXXXX")
printf '%s' "$report" | jq '(now | floor) as $start | .samples = [
range(0; 720) as $index |
(.samples[0] |
.timestamp=($start + $index + 10) |
.net_rx_total=(1073741824 + (($index + 1) * 4096)) |
.net_tx_total=(805306368 + (($index + 1) * 2048)))
]' >"$stress_report_file"
request -H "Authorization: Bearer $agent_token" -H 'Content-Type: application/json' \
--data-binary "@$stress_report_file" "$MONITOR_BASE_URL/api/agent/report" | jq -e '.collect_interval == 5' >/dev/null
request -H "Authorization: Bearer $admin_token" "$MONITOR_BASE_URL/api/history/$server_id?hours=1" | jq -e '.points | length >= 1 and any(.gpu_usage == 32.5)' >/dev/null
history_cache_header=$(monitor_curl --silent --dump-header - --output /dev/null \
-H "Authorization: Bearer $admin_token" "$MONITOR_BASE_URL/api/history/$server_id?hours=1" | \
@@ -223,10 +154,10 @@ request -H "Authorization: Bearer $admin_token" -H 'Content-Type: application/js
--data '{"name":"Smoke TCP","task_type":"tcp","target":"example.com","port":443,"interval_seconds":60,"default_enabled":true,"server_ids":[]}' \
"$MONITOR_BASE_URL/api/admin/latency-tasks/$latency_task_id" >/dev/null
request -H "Authorization: Bearer $admin_token" "$MONITOR_BASE_URL/api/latency/$server_id?hours=1" | jq -e --arg task_id "$latency_task_id" '(.tasks | all(.id != $task_id)) and (.points | all(.task_id != $task_id))' >/dev/null
stale_latency_response=$(request -H "Authorization: Bearer $agent_token" -H 'Content-Type: application/json' \
--data "$latency_report" "$MONITOR_BASE_URL/api/agent/report")
printf '%s' "$stale_latency_response" | jq -e --arg task_id "$latency_task_id" \
'.latency_tasks | all(.id != $task_id)' >/dev/null
MONITOR_BASE_URL="$MONITOR_BASE_URL" MONITOR_ADMIN_TOKEN="$admin_token" \
MONITOR_AGENT_TOKEN="$agent_token" MONITOR_SERVER_ID="$server_id" \
MONITOR_LATENCY_TASK_ID="$latency_task_id" MONITOR_EXPECT_TASK_ASSIGNED=0 \
MONITOR_CONFIG_ONLY=1 node scripts/websocket-smoke.mjs
alert_rule_json=$(request -H "Authorization: Bearer $admin_token" -H 'Content-Type: application/json' \
--data "$(jq -nc --arg server_id "$server_id" '{name:"Smoke CPU",metric:"cpu",threshold:80,duration_minutes:5,aggregation:"average",enabled:true,server_ids:[$server_id]}')" \
+246 -23
View File
@@ -3,9 +3,15 @@ import WebSocket from "ws";
const baseUrl = process.env.MONITOR_BASE_URL;
const adminToken = process.env.MONITOR_ADMIN_TOKEN;
const agentToken = process.env.MONITOR_AGENT_TOKEN;
const serverId = process.env.MONITOR_SERVER_ID;
const latencyTaskId = process.env.MONITOR_LATENCY_TASK_ID;
const expectTaskAssigned = process.env.MONITOR_EXPECT_TASK_ASSIGNED !== "0";
const configOnly = process.env.MONITOR_CONFIG_ONLY === "1";
if (!baseUrl || !adminToken || !agentToken) {
throw new Error("MONITOR_BASE_URL, MONITOR_ADMIN_TOKEN and MONITOR_AGENT_TOKEN are required");
if (!baseUrl || !adminToken || !agentToken || !serverId || !latencyTaskId) {
throw new Error(
"MONITOR_BASE_URL, MONITOR_ADMIN_TOKEN, MONITOR_AGENT_TOKEN, MONITOR_SERVER_ID and MONITOR_LATENCY_TASK_ID are required",
);
}
function websocketUrl(path) {
@@ -14,10 +20,10 @@ function websocketUrl(path) {
return url.toString();
}
function openSocket(path, token) {
function openSocket(path, token, headers = {}) {
return new Promise((resolve, reject) => {
const socket = new WebSocket(websocketUrl(path), {
headers: { Authorization: `Bearer ${token}` },
headers: { Authorization: `Bearer ${token}`, ...headers },
});
const timer = setTimeout(() => {
socket.terminate();
@@ -38,6 +44,36 @@ function openSocket(path, token) {
});
}
function expectSocketStatus(path, token, statusCode) {
return new Promise((resolve, reject) => {
const socket = new WebSocket(websocketUrl(path), {
headers: { Authorization: `Bearer ${token}` },
});
const timer = setTimeout(() => {
socket.terminate();
reject(new Error(`${path} did not reject the WebSocket handshake`));
}, 5_000);
socket.once("open", () => {
clearTimeout(timer);
socket.terminate();
reject(new Error(`${path} unexpectedly accepted the WebSocket handshake`));
});
socket.once("unexpected-response", (_request, response) => {
clearTimeout(timer);
response.resume();
if (response.statusCode === statusCode) {
resolve();
} else {
reject(new Error(`${path} returned HTTP ${response.statusCode}, expected ${statusCode}`));
}
});
socket.once("error", (error) => {
clearTimeout(timer);
reject(error);
});
});
}
function waitForMessage(socket, expected) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`WebSocket did not receive ${expected}`)), 5_000);
@@ -54,29 +90,216 @@ function waitForMessage(socket, expected) {
});
}
function closeSocket(socket) {
function waitForJsonMessage(socket, expected, predicate) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
socket.terminate();
reject(new Error("WebSocket close timed out"));
}, 5_000);
socket.once("close", () => {
clearTimeout(timer);
resolve();
});
socket.once("error", (error) => {
const cleanup = () => {
clearTimeout(timer);
socket.off("message", onMessage);
socket.off("close", onClose);
socket.off("error", onError);
};
const fail = (error) => {
cleanup();
reject(error);
});
socket.close();
};
const onMessage = (data) => {
let message;
try {
message = JSON.parse(data.toString());
} catch {
return;
}
if (!predicate(message)) return;
cleanup();
resolve(message);
};
const onClose = () => fail(new Error(`WebSocket closed before receiving ${expected}`));
const onError = (error) => fail(error);
const timer = setTimeout(
() => fail(new Error(`WebSocket did not receive ${expected}`)),
5_000,
);
socket.on("message", onMessage);
socket.once("close", onClose);
socket.once("error", onError);
});
}
const dashboard = await openSocket("/api/ws", adminToken);
const pong = waitForMessage(dashboard, "pong");
dashboard.send("ping");
await pong;
await closeSocket(dashboard);
function closeSocket(socket) {
return new Promise((resolve, reject) => {
if (socket.readyState === WebSocket.CLOSED) {
resolve();
return;
}
const cleanup = () => {
clearTimeout(timer);
socket.off("close", onClose);
socket.off("error", onError);
};
const onClose = () => {
cleanup();
resolve();
};
const onError = (error) => {
cleanup();
reject(error);
};
const timer = setTimeout(() => {
cleanup();
socket.terminate();
reject(new Error("WebSocket close timed out"));
}, 5_000);
socket.once("close", onClose);
socket.once("error", onError);
if (socket.readyState === WebSocket.OPEN) socket.close();
});
}
const agent = await openSocket("/api/agent/live", agentToken);
await closeSocket(agent);
await expectSocketStatus("/api/agent/ws", "invalid-agent-token", 401);
const dashboard = configOnly ? null : await openSocket("/api/ws", adminToken);
let agent;
try {
if (dashboard) {
const pong = waitForMessage(dashboard, "pong");
dashboard.send("ping");
await pong;
}
agent = await openSocket("/api/agent/ws", agentToken, { "CF-Connecting-IP": "8.8.8.8" });
const config = await waitForJsonMessage(
agent,
"Agent config",
(message) => message.type === "config" && message.config,
);
const hasTask = config.config.latency_tasks?.some(
(task) =>
task.id === latencyTaskId &&
task.task_type === "tcp" &&
task.target === "example.com" &&
task.port === 443,
);
if (
config.config.collect_interval !== 5 ||
config.config.agent_mirror !== "https://mirror.example.com" ||
config.config.auto_update !== true ||
hasTask !== expectTaskAssigned
) {
throw new Error(`Invalid Agent config: ${JSON.stringify(config)}`);
}
if (!configOnly) {
const timestamp = Math.floor(Date.now() / 1_000);
const baseSample = {
timestamp,
cpu: 18.5,
load1: 0.42,
load5: 0.36,
load15: 0.31,
mem_used: 2147483648,
mem_total: 4294967296,
swap_used: 0,
swap_total: 0,
disk_used: 21474836480,
disk_total: 53687091200,
net_in: 4096,
net_out: 2048,
net_rx_total: 1073741824,
net_tx_total: 536870912,
uptime: 86400,
processes: 90,
tcp_connections: 18,
udp_connections: 4,
cpu_cores: 2,
cpu_model: "Smoke CPU",
os: "Debian 12",
kernel: "6.1",
arch: "x86_64",
virtualization: "kvm",
gpu_usage: 32.5,
gpu_model: "NVIDIA T4",
agent_version: "smoke",
disk_read_bps: 4194304,
disk_write_bps: 2097152,
disk_read_iops: 120,
disk_write_iops: 48,
disk_await_ms: 1.4,
disk_utilization: 8.2,
disks: [
{
name: "/dev/vda1",
mount_point: "/",
used: 21474836480,
total: 53687091200,
read_bps: 4194304,
write_bps: 2097152,
read_iops: 120,
write_iops: 48,
await_ms: 1.4,
utilization: 8.2,
},
],
gpus: [
{ model: "NVIDIA T4", usage: 32.5, memory_used: 1073741824, memory_total: 17179869184 },
],
latency_results: [],
};
const sample = (offset, overrides = {}) => ({
...structuredClone(baseSample),
timestamp: timestamp + offset,
...overrides,
});
const samples = [
sample(0),
sample(1, {
latency_results: [
{ task_id: latencyTaskId, timestamp: timestamp + 1, latency_ms: 28.4, packet_loss: 25 },
],
}),
sample(2, {
latency_results: [
{ task_id: latencyTaskId, timestamp: timestamp + 2, latency_ms: 48.4, packet_loss: 75 },
],
}),
sample(3, { net_rx_total: 2147483648, net_tx_total: 1073741824 }),
sample(4, { net_rx_total: 268435456, net_tx_total: 134217728 }),
sample(5, { net_rx_total: 536870912, net_tx_total: 268435456 }),
];
const latestTimestamp = timestamp + 5;
const ackPromise = waitForJsonMessage(
agent,
"Agent metric ACK",
(message) => message.type === "ack" && message.realtimeHint === false,
);
const updatePromise = waitForJsonMessage(
dashboard,
"dashboard batchUpdate",
(message) =>
message.type === "batchUpdate" &&
message.updates?.some(
(update) =>
update.serverId === serverId &&
update.samples?.some(
(entry) => entry.ts === latestTimestamp && entry.data?.cpu === 18.5,
),
),
);
agent.send(JSON.stringify({ type: "update", batchId: `smoke-${timestamp}`, samples }));
const [ack] = await Promise.all([ackPromise, updatePromise]);
if (
ack.ts <= 0 ||
ack.persisted !== true ||
ack.persistenceError !== false ||
!Number.isInteger(ack.persistedThroughTs) ||
ack.persistedThroughTs < latestTimestamp ||
ack.nextD1WriteAfterMs !== 120_000 ||
ack.nextWssReportAfterMs !== 5_000
) {
throw new Error(`Invalid Agent metric ACK: ${JSON.stringify(ack)}`);
}
}
} finally {
await Promise.all([
agent ? closeSocket(agent) : Promise.resolve(),
dashboard ? closeSocket(dashboard) : Promise.resolve(),
]);
}
+24 -17
View File
@@ -1,7 +1,13 @@
use std::time::Duration;
use serde::{Deserialize, Serialize};
use worker::{wasm_bindgen::JsValue, Fetch, Method, Request, RequestInit, Result};
use worker::{wasm_bindgen::JsValue, Headers, Method, Request, RequestInit, Result};
use crate::outbound::{fetch_with_timeout, read_json_limited};
const GRAPHQL_URL: &str = "https://api.cloudflare.com/client/v4/graphql";
const GRAPHQL_TIMEOUT: Duration = Duration::from_secs(10);
const GRAPHQL_MAX_BYTES: usize = 1024 * 1024;
const DURABLE_OBJECTS_WEBSOCKET_MESSAGE_BILLING_RATIO: i64 = 20;
const USAGE_QUERY: &str = r#"
query CloudflareUsage(
@@ -228,13 +234,10 @@ fn summarize_durable_objects(
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.billable_requests = summary.http_requests.saturating_add(ceil_div_nonnegative(
summary.inbound_websocket_messages,
DURABLE_OBJECTS_WEBSOCKET_MESSAGE_BILLING_RATIO,
));
summary
}
@@ -266,19 +269,23 @@ async fn query_period(token: &str, account_id: &str, date: &str) -> Result<Usage
end_time: &end_time,
},
})?;
let headers = Headers::new();
headers.set("Authorization", &format!("Bearer {token}"))?;
headers.set("Content-Type", "application/json")?;
headers.set("Accept", "application/json")?;
let mut init = RequestInit::new();
init.with_method(Method::Post)
.with_headers(headers)
.with_body(Some(JsValue::from_str(&body)));
let request = Request::new_with_init(GRAPHQL_URL, &init)?;
request
.headers()
.set("Authorization", &format!("Bearer {token}"))?;
request.headers().set("Content-Type", "application/json")?;
request.headers().set("Accept", "application/json")?;
let mut response = Fetch::Request(request).send().await?;
let Some(mut response) = fetch_with_timeout(request, GRAPHQL_TIMEOUT).await? else {
return Err(worker::Error::RustError(
"Cloudflare GraphQL 请求超时".to_string(),
));
};
let status = response.status_code();
let payload: GraphQlResponse = response.json().await?;
let payload: GraphQlResponse = read_json_limited(&mut response, GRAPHQL_MAX_BYTES).await?;
let errors = payload.errors.unwrap_or_default();
if !(200..300).contains(&status) || !errors.is_empty() {
let message = errors
@@ -390,7 +397,7 @@ mod tests {
vec![DurableObjectsPeriodicGroup {
sum: DurableObjectsPeriodicSum {
duration: 2.5,
inbound_websocket_msg_count: 21,
inbound_websocket_msg_count: 20,
outbound_websocket_msg_count: 11,
},
}],
@@ -398,7 +405,7 @@ mod tests {
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.billable_requests, 8);
assert_eq!(summary.outbound_websocket_messages, 11);
assert_eq!(summary.duration, 2.5);
assert_eq!(ceil_div_nonnegative(0, 20), 0);
+308 -272
View File
@@ -1,22 +1,77 @@
use std::collections::HashMap;
use serde::{Deserialize, Serialize, Serializer};
use worker::{wasm_bindgen::JsValue, D1Database, Date, Result};
use worker::{wasm_bindgen::JsValue, D1Database, D1PreparedStatement, Date, Result};
use crate::models::{
AgentConfigView, AgentIdentityRow, AgentReport, AlertRuleInput, AlertRuleView, HistoryPoint,
ServerInput, ServerView, SettingsInput, ThemeInput, ThemeView,
};
pub(crate) const HISTORY_WRITE_INTERVAL_SECONDS: i64 = 120;
pub(crate) const MAX_HISTORY_RETENTION_DAYS: i64 = 30;
/// 分钟粒度历史按天轮换,`metric_history` 的上一代留在 `metric_history_old`。
/// 轮换而不是 DELETE 的理由:DELETE 每行计一行 D1 写入,DROP TABLE 不计。
/// 代价是当前表在刚轮换后是空的,所以任何近期读取都必须走 [`RECENT_HISTORY`]。
pub(crate) const HISTORY_ROTATION_INTERVAL_SECONDS: i64 = 86_400;
/// 分钟粒度历史的读取源。两代合起来覆盖 24-48 小时。
///
/// 轮换边界上同一分钟桶可能在两张表里各有一行(当前表刚建好、旧表还留着同
/// 一分钟的前半段)。所有消费方都按 `sample_count` 加权聚合,两个半行合并后
/// 与单行等价,所以重复是良性的,不需要去重。
pub(crate) const RECENT_HISTORY: &str =
"SELECT * FROM metric_history UNION ALL SELECT * FROM metric_history_old";
/// `metric_history` 及其轮换副本的表结构,两处必须完全一致。
///
/// 轮换要在运行时重建表,所以 DDL 不能只存在于 migration 里。
/// `metric_history_ddl_matches_migration` 测试比对本函数与 migration 的文本,
/// 防止两边漂移。
pub(crate) fn metric_history_ddl(table: &str) -> String {
format!(
r#"CREATE TABLE {table} (
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
timestamp INTEGER NOT NULL,
cpu REAL NOT NULL DEFAULT 0,
load1 REAL NOT NULL DEFAULT 0,
load5 REAL NOT NULL DEFAULT 0,
load15 REAL NOT NULL DEFAULT 0,
mem_used INTEGER NOT NULL DEFAULT 0,
mem_total INTEGER NOT NULL DEFAULT 0,
swap_used INTEGER NOT NULL DEFAULT 0,
swap_total INTEGER NOT NULL DEFAULT 0,
disk_used INTEGER NOT NULL DEFAULT 0,
disk_total INTEGER NOT NULL DEFAULT 0,
net_in REAL NOT NULL DEFAULT 0,
net_out REAL NOT NULL DEFAULT 0,
net_rx_total INTEGER NOT NULL DEFAULT 0,
net_tx_total INTEGER NOT NULL DEFAULT 0,
processes INTEGER NOT NULL DEFAULT 0,
tcp_connections INTEGER NOT NULL DEFAULT 0,
udp_connections INTEGER NOT NULL DEFAULT 0,
gpu_usage REAL NOT NULL DEFAULT 0,
disk_read_bps REAL NOT NULL DEFAULT 0,
disk_write_bps REAL NOT NULL DEFAULT 0,
disk_read_iops REAL NOT NULL DEFAULT 0,
disk_write_iops REAL NOT NULL DEFAULT 0,
disk_await_ms REAL NOT NULL DEFAULT 0,
disk_utilization REAL NOT NULL DEFAULT 0,
sample_count INTEGER NOT NULL DEFAULT 1 CHECK(sample_count > 0),
latest_timestamp INTEGER NOT NULL,
latest_json TEXT NOT NULL CHECK(json_valid(latest_json)),
latency_json TEXT NOT NULL DEFAULT '[]' CHECK(json_valid(latency_json)),
PRIMARY KEY(server_id, timestamp)
) WITHOUT ROWID"#
)
}
const SERVER_SELECT: &str = r#"
WITH latest_state AS (
SELECT s0.id AS server_id, COALESCE(
(SELECT h.latest_json FROM metric_history h
WHERE h.server_id = s0.id ORDER BY h.timestamp DESC LIMIT 1),
(SELECT h.latest_json FROM metric_history_hourly h
WHERE h.server_id = s0.id ORDER BY h.timestamp DESC LIMIT 1)
) AS state
SELECT s0.id AS server_id, l.latest_json AS state
FROM servers s0
LEFT JOIN server_latest_state l ON l.server_id = s0.id
)
SELECT
s.id, s.name, s.region, s.group_name, s.tags, s.hidden,
@@ -133,18 +188,6 @@ pub struct TrafficCounterState {
pub used_tx: i64,
}
#[derive(Debug, Deserialize)]
struct TrafficCounterContext {
configured_reset_day: i64,
cycle_key: i64,
traffic_reset_day: i64,
traffic_timestamp: i64,
raw_rx: i64,
raw_tx: i64,
used_rx: i64,
used_tx: i64,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct HistoryMetricAggregate {
#[serde(rename = "n")]
@@ -357,11 +400,9 @@ pub struct SettingsView {
#[serde(serialize_with = "serialize_secret")]
pub turnstile_secret_key: String,
pub notification_enabled: bool,
#[serde(serialize_with = "serialize_secret")]
pub notification_endpoint: String,
pub notification_target: String,
pub offline_alert_minutes: i64,
pub expiry_alert_days: i64,
pub traffic_alert_percentage: i64,
#[serde(serialize_with = "serialize_secret")]
pub cloudflare_account_id: String,
#[serde(serialize_with = "serialize_secret")]
@@ -415,12 +456,6 @@ pub struct AlertMetricRow {
pub report_interval: i64,
}
#[derive(Debug, Deserialize)]
struct AlertStateRow {
rule_id: String,
server_id: String,
}
fn now() -> i64 {
Date::now().as_millis() as i64 / 1000
}
@@ -468,6 +503,7 @@ pub(crate) fn traffic_cycle_key(timestamp: i64, reset_day: i64) -> i64 {
}
}
#[cfg(test)]
impl TrafficCounterState {
pub fn apply(&mut self, report: &AgentReport, configured_reset_day: i64) {
if report.timestamp <= self.timestamp {
@@ -501,31 +537,6 @@ impl TrafficCounterState {
self.raw_rx = report.net_rx_total;
self.raw_tx = report.net_tx_total;
}
pub fn extend(&mut self, reports: &[AgentReport], configured_reset_day: i64) {
let mut reports = reports.iter().collect::<Vec<_>>();
reports.sort_by_key(|report| report.timestamp);
for report in reports {
self.apply(report, configured_reset_day);
}
}
}
impl TrafficCounterContext {
fn into_parts(self) -> (TrafficCounterState, i64) {
(
TrafficCounterState {
cycle_key: self.cycle_key,
reset_day: self.traffic_reset_day,
timestamp: self.traffic_timestamp,
raw_rx: self.raw_rx,
raw_tx: self.raw_tx,
used_rx: self.used_rx,
used_tx: self.used_tx,
},
self.configured_reset_day,
)
}
}
pub async fn list_servers(db: &D1Database, include_hidden: bool) -> Result<Vec<ServerView>> {
@@ -563,12 +574,7 @@ pub async fn agent_live_context(db: &D1Database, id: &str) -> Result<Option<Agen
let mut context: Option<AgentLiveContext> = db
.prepare(
r#"WITH latest_state AS (
SELECT COALESCE(
(SELECT latest_json FROM metric_history
WHERE server_id = ?1 ORDER BY timestamp DESC LIMIT 1),
(SELECT latest_json FROM metric_history_hourly
WHERE server_id = ?1 ORDER BY timestamp DESC LIMIT 1)
) AS state
SELECT latest_json AS state FROM server_latest_state WHERE server_id = ?1
) SELECT
s.report_interval,
s.collect_interval,
@@ -584,7 +590,7 @@ pub async fn agent_live_context(db: &D1Database, id: &str) -> Result<Option<Agen
s.tx_correction,
COALESCE(CAST(json_extract(m.state, '$.report.timestamp') AS INTEGER), 0) AS last_persisted_at
FROM servers s
CROSS JOIN latest_state m
LEFT JOIN latest_state m ON true
WHERE s.id = ?1
LIMIT 1"#,
)
@@ -808,11 +814,19 @@ pub async fn list_alert_rules(db: &D1Database) -> Result<Vec<AlertRuleView>> {
Ok(rules)
}
async fn replace_alert_rule_servers(
pub async fn has_enabled_alert_rules(db: &D1Database) -> Result<bool> {
Ok(db
.prepare("SELECT 1 AS present FROM alert_rules WHERE enabled=1 LIMIT 1")
.first::<serde_json::Value>(None)
.await?
.is_some())
}
fn alert_rule_server_statements(
db: &D1Database,
rule_id: &str,
server_ids: &[String],
) -> Result<()> {
) -> Result<Vec<D1PreparedStatement>> {
let mut statements = vec![db
.prepare("DELETE FROM alert_rule_servers WHERE rule_id = ?1")
.bind(&[text(rule_id)])?];
@@ -820,34 +834,43 @@ async fn replace_alert_rule_servers(
for server_id in server_ids {
statements.push(insert.clone().bind(&[text(rule_id), text(server_id)])?);
}
db.batch(statements).await?;
Ok(())
Ok(statements)
}
pub async fn create_alert_rule(db: &D1Database, id: &str, input: &AlertRuleInput) -> Result<()> {
let timestamp = now();
db.prepare(
"INSERT INTO alert_rules(id, name, metric, threshold, duration_minutes, \
let mut statements = vec![db
.prepare(
"INSERT INTO alert_rules(id, name, metric, threshold, duration_minutes, \
aggregation, enabled, created_at, updated_at) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?8)",
)
.bind(&[
text(id),
text(input.name.trim()),
text(input.metric.trim()),
number(input.threshold),
number(input.duration_minutes),
text(input.aggregation.trim()),
JsValue::from_bool(input.enabled),
number(timestamp),
])?
.run()
.await?;
replace_alert_rule_servers(db, id, &input.server_ids).await
)
.bind(&[
text(id),
text(input.name.trim()),
text(input.metric.trim()),
number(input.threshold),
number(input.duration_minutes),
text(input.aggregation.trim()),
JsValue::from_bool(input.enabled),
number(timestamp),
])?];
statements.extend(alert_rule_server_statements(db, id, &input.server_ids)?);
db.batch(statements).await?;
Ok(())
}
pub async fn update_alert_rule(db: &D1Database, id: &str, input: &AlertRuleInput) -> Result<bool> {
let result = db
let exists = db
.prepare("SELECT 1 AS present FROM alert_rules WHERE id=?1 LIMIT 1")
.bind(&[text(id)])?
.first::<serde_json::Value>(None)
.await?
.is_some();
if !exists {
return Ok(false);
}
let mut statements = vec![db
.prepare(
"UPDATE alert_rules SET name=?2, metric=?3, threshold=?4, duration_minutes=?5, \
aggregation=?6, enabled=?7, updated_at=?8 WHERE id=?1",
@@ -861,14 +884,10 @@ pub async fn update_alert_rule(db: &D1Database, id: &str, input: &AlertRuleInput
text(input.aggregation.trim()),
JsValue::from_bool(input.enabled),
number(now()),
])?
.run()
.await?;
let changed = result.meta()?.and_then(|meta| meta.changes).unwrap_or(0) > 0;
if changed {
replace_alert_rule_servers(db, id, &input.server_ids).await?;
}
Ok(changed)
])?];
statements.extend(alert_rule_server_statements(db, id, &input.server_ids)?);
db.batch(statements).await?;
Ok(true)
}
pub async fn delete_alert_rule(db: &D1Database, id: &str) -> Result<bool> {
@@ -886,65 +905,22 @@ pub(crate) fn alert_window_covered(
current_time: i64,
) -> bool {
let window_seconds = duration_minutes.clamp(1, 1440) * 60;
let report_interval = row.report_interval.clamp(15, 3600).max(60);
let expected_samples = ((window_seconds + report_interval - 1) / report_interval).max(1);
let sample_interval = row
.report_interval
.clamp(15, 3600)
.max(HISTORY_WRITE_INTERVAL_SECONDS);
let expected_samples = ((window_seconds + sample_interval - 1) / sample_interval).max(1);
let required_samples = if expected_samples == 1 {
1
} else {
((expected_samples * 3 + 4) / 5).max(2)
};
let fresh_within = (report_interval * 2).max(120);
let fresh_within = sample_interval * 2;
row.sample_count >= required_samples
&& row.first_timestamp <= row.last_timestamp
&& current_time.saturating_sub(row.last_timestamp) <= fresh_within
}
pub async fn active_alert_states(db: &D1Database) -> Result<std::collections::HashSet<String>> {
let rows: Vec<AlertStateRow> = db
.prepare("SELECT rule_id, server_id FROM alert_states WHERE active=1")
.all()
.await?
.results()?;
Ok(rows
.into_iter()
.map(|row| format!("{}:{}", row.rule_id, row.server_id))
.collect())
}
pub async fn sync_active_alert_states(
db: &D1Database,
previous: &std::collections::HashSet<String>,
current: &std::collections::HashSet<String>,
) -> Result<()> {
let mut statements = Vec::new();
let delete = db.prepare("DELETE FROM alert_states WHERE rule_id=?1 AND server_id=?2");
let insert = db.prepare(
"INSERT INTO alert_states(rule_id, server_id, active, updated_at) VALUES (?1, ?2, 1, ?3) \
ON CONFLICT(rule_id, server_id) DO UPDATE SET active=1, updated_at=excluded.updated_at",
);
let timestamp = now();
for key in previous.difference(current) {
let Some((rule_id, server_id)) = key.split_once(':') else {
continue;
};
statements.push(delete.clone().bind(&[text(rule_id), text(server_id)])?);
}
for key in current.difference(previous) {
let Some((rule_id, server_id)) = key.split_once(':') else {
continue;
};
statements.push(insert.clone().bind(&[
text(rule_id),
text(server_id),
number(timestamp),
])?);
}
if !statements.is_empty() {
db.batch(statements).await?;
}
Ok(())
}
pub async fn history(db: &D1Database, id: &str, hours: i64) -> Result<Vec<HistoryPoint>> {
let hours = hours.clamp(1, 24 * 30);
let since = now() - hours * 3600;
@@ -956,9 +932,9 @@ pub async fn history(db: &D1Database, id: &str, hours: i64) -> Result<Vec<Histor
_ => 14_400,
};
let source = if hours <= 24 {
"SELECT * FROM metric_history".to_string()
RECENT_HISTORY.to_string()
} else {
"SELECT * FROM metric_history UNION ALL SELECT * FROM metric_history_hourly".to_string()
format!("{RECENT_HISTORY} UNION ALL SELECT * FROM metric_history_hourly")
};
let query = format!(
r#"SELECT
@@ -1007,79 +983,30 @@ pub(crate) fn reports_after(reports: &[AgentReport], timestamp: i64) -> Vec<Agen
.collect()
}
pub async fn save_reports(
db: &D1Database,
server_id: &str,
reports: &[AgentReport],
) -> Result<Option<HistoryPoint>> {
let context: TrafficCounterContext = db
.prepare(
r#"WITH latest_state AS (
SELECT COALESCE(
(SELECT latest_json FROM metric_history
WHERE server_id = ?1 ORDER BY timestamp DESC LIMIT 1),
(SELECT latest_json FROM metric_history_hourly
WHERE server_id = ?1 ORDER BY timestamp DESC LIMIT 1)
) AS state
) SELECT
s.reset_day AS configured_reset_day,
COALESCE(CAST(json_extract(m.state, '$.traffic.cycle_key') AS INTEGER), 0) AS cycle_key,
COALESCE(CAST(json_extract(m.state, '$.traffic.reset_day') AS INTEGER), s.reset_day) AS traffic_reset_day,
COALESCE(CAST(json_extract(m.state, '$.traffic.timestamp') AS INTEGER), 0) AS traffic_timestamp,
COALESCE(CAST(json_extract(m.state, '$.traffic.raw_rx') AS INTEGER), 0) AS raw_rx,
COALESCE(CAST(json_extract(m.state, '$.traffic.raw_tx') AS INTEGER), 0) AS raw_tx,
COALESCE(CAST(json_extract(m.state, '$.traffic.used_rx') AS INTEGER), 0) AS used_rx,
COALESCE(CAST(json_extract(m.state, '$.traffic.used_tx') AS INTEGER), 0) AS used_tx
FROM servers s
CROSS JOIN latest_state m
WHERE s.id = ?1
LIMIT 1"#,
)
.bind(&[text(server_id)])?
.first(None)
.await?
.ok_or_else(|| worker::Error::RustError("节点不存在".to_string()))?;
let (mut traffic, configured_reset_day) = context.into_parts();
let fresh_reports = reports_after(reports, traffic.timestamp);
if fresh_reports.is_empty() {
return Ok(None);
}
pub(crate) fn valid_report_batch_id(batch_id: &str) -> bool {
!batch_id.is_empty()
&& batch_id.len() <= 128
&& batch_id
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':'))
}
let mut aggregate = HistoryMetricAggregate::default();
aggregate.extend(&fresh_reports);
let Some(history) = aggregate.point() else {
return Ok(None);
};
let mut latency = crate::latency::LatencyMetricAggregates::default();
let received_at = now();
for report in &fresh_reports {
latency.extend(&report.latency_results, received_at);
}
traffic.extend(&fresh_reports, configured_reset_day);
save_reports_with_history(
db,
server_id,
&fresh_reports,
&history,
aggregate.sample_count(),
&traffic,
&latency,
)
.await?;
Ok(Some(history))
pub(crate) struct ReportBatchRef<'a> {
pub reports: &'a [AgentReport],
pub id: &'a str,
}
pub async fn save_reports_with_history(
db: &D1Database,
server_id: &str,
reports: &[AgentReport],
batch: ReportBatchRef<'_>,
history_point: &HistoryPoint,
sample_count: i64,
traffic: &TrafficCounterState,
latency: &crate::latency::LatencyMetricAggregates,
) -> Result<()> {
let Some(latest_report) = reports.iter().max_by_key(|report| report.timestamp) else {
return Ok(());
) -> Result<bool> {
let Some(latest_report) = batch.reports.iter().max_by_key(|report| report.timestamp) else {
return Ok(false);
};
let latest_timestamp = latest_report.timestamp;
let mut latest_report = latest_report.clone();
@@ -1092,7 +1019,8 @@ pub async fn save_reports_with_history(
})
.to_string();
let latency_json = latency.stored_json()?;
db.prepare(
let batch_id = batch.id;
let metric = db.prepare(
r#"INSERT INTO metric_history (
server_id, timestamp, cpu, load1, load5, load15,
mem_used, mem_total, swap_used, swap_total, disk_used, disk_total,
@@ -1101,10 +1029,13 @@ pub async fn save_reports_with_history(
disk_read_bps, disk_write_bps, disk_read_iops, disk_write_iops,
disk_await_ms, disk_utilization, sample_count,
latest_timestamp, latest_json, latency_json
) VALUES (
) SELECT
?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14,
?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26,
?27, ?28, ?29, ?30
WHERE NOT EXISTS (
SELECT 1 FROM server_latest_state
WHERE server_id = ?1 AND last_batch_id = ?31
) ON CONFLICT(server_id, timestamp) DO UPDATE SET
cpu=(metric_history.cpu * metric_history.sample_count +
excluded.cpu * excluded.sample_count) /
@@ -1200,8 +1131,8 @@ pub async fn save_reports_with_history(
)) FROM (SELECT * FROM latency_tasks ORDER BY task_id)
) END
WHERE excluded.latest_timestamp > metric_history.latest_timestamp"#,
)
.bind(&[
);
let metric = metric.bind(&[
text(server_id),
number(history_point.timestamp),
number(history_point.cpu),
@@ -1232,10 +1163,34 @@ pub async fn save_reports_with_history(
number(latest_timestamp),
text(&latest_json),
text(&latency_json),
])?
.run()
.await?;
Ok(())
text(batch_id),
])?;
let snapshot = db
.prepare(
r#"INSERT INTO server_latest_state(
server_id, latest_timestamp, latest_json, last_batch_id
) SELECT ?1, ?2, ?3, ?4 WHERE NOT EXISTS (
SELECT 1 FROM server_latest_state
WHERE server_id=?1 AND last_batch_id=?4
) ON CONFLICT(server_id) DO UPDATE SET
latest_timestamp=excluded.latest_timestamp,
latest_json=excluded.latest_json,
last_batch_id=excluded.last_batch_id
WHERE excluded.latest_timestamp >= server_latest_state.latest_timestamp"#,
)
.bind(&[
text(server_id),
number(latest_timestamp),
text(&latest_json),
text(batch_id),
])?;
let results = db.batch(vec![metric, snapshot]).await?;
Ok(results
.first()
.and_then(|result| result.meta().ok().flatten())
.and_then(|meta| meta.changes)
.unwrap_or(0)
> 0)
}
fn bool_setting(values: &HashMap<String, String>, key: &str, fallback: bool) -> bool {
@@ -1300,8 +1255,9 @@ pub async fn settings(
history_retention_days: integer_setting(
&values,
"history_retention_days",
default_retention.clamp(1, 365),
),
default_retention.clamp(1, MAX_HISTORY_RETENTION_DAYS),
)
.clamp(1, MAX_HISTORY_RETENTION_DAYS),
history_cache_version: integer_setting(&values, "history_cache_version", 0),
default_theme: string_setting(&values, "default_theme", "system"),
active_theme_id: string_setting(&values, "active_theme_id", crate::theme::BUILTIN_THEME_ID),
@@ -1332,10 +1288,10 @@ pub async fn settings(
turnstile_site_key: string_setting(&values, "turnstile_site_key", ""),
turnstile_secret_key: string_setting(&values, "turnstile_secret_key", ""),
notification_enabled: bool_setting(&values, "notification_enabled", false),
notification_endpoint: string_setting(&values, "notification_endpoint", ""),
notification_target: string_setting(&values, "notification_target", ""),
offline_alert_minutes: integer_setting(&values, "offline_alert_minutes", 5),
expiry_alert_days: integer_setting(&values, "expiry_alert_days", 7),
traffic_alert_percentage: integer_setting(&values, "traffic_alert_percentage", 80)
.clamp(50, 100),
cloudflare_account_id: string_setting(&values, "cloudflare_account_id", ""),
cloudflare_api_token: string_setting(&values, "cloudflare_api_token", ""),
})
@@ -1379,7 +1335,7 @@ pub async fn update_settings(
push_setting!("offline_threshold_seconds", &value);
}
if let Some(value) = input.history_retention_days {
let value = value.clamp(1, 365).to_string();
let value = value.clamp(1, MAX_HISTORY_RETENTION_DAYS).to_string();
push_setting!("history_retention_days", &value);
}
if let Some(value) = input.default_theme.as_deref() {
@@ -1457,16 +1413,6 @@ pub async fn update_settings(
if let Some(value) = input.notification_enabled {
push_setting!("notification_enabled", if value { "true" } else { "false" });
}
if let Some(value) = input
.notification_endpoint
.as_deref()
.filter(|value| value.trim() != SECRET_MASK)
{
push_setting!("notification_endpoint", value.trim());
}
if let Some(value) = input.notification_target.as_deref() {
push_setting!("notification_target", value.trim());
}
if let Some(value) = input.offline_alert_minutes {
let value = value.clamp(2, 1440).to_string();
push_setting!("offline_alert_minutes", &value);
@@ -1475,6 +1421,10 @@ pub async fn update_settings(
let value = value.clamp(0, 365).to_string();
push_setting!("expiry_alert_days", &value);
}
if let Some(value) = input.traffic_alert_percentage {
let value = value.clamp(50, 100).to_string();
push_setting!("traffic_alert_percentage", &value);
}
if let Some(value) = input
.cloudflare_account_id
.as_deref()
@@ -1601,17 +1551,41 @@ pub async fn delete_theme(db: &D1Database, id: &str) -> Result<bool> {
Ok(deleted)
}
/// 把分钟历史压成小时粒度,然后轮换分钟表、按保留期删除过期小时行。
///
/// 压缩读两代分钟表,区间是 `[保留期下界, 当前整小时)`,小时表按覆盖语义写入
/// (不累加,见那条 SQL 里的注释)。三者合起来让压缩天然幂等:
///
/// - 读两代保证轮换即将丢弃的行一定已经压过,不需要「哪一代压过了」的状态标记。
/// - 上界卡在整小时,避免把还在写的小时压进归档。
/// - 覆盖语义让重复压缩变成重算同一个值,跑几次结果都一样。
///
/// 所以这个函数崩在任何一步、下一次 cron 重跑都是对的。
///
/// 顺序是「压缩 + 删除过期小时行(同一个 batch)→ 轮换分钟表」。
///
/// 调用方(`src/lib.rs` 的 cron)已经把频率限制在一天一次,与
/// [`HISTORY_ROTATION_INTERVAL_SECONDS`] 一致。分钟数据因此保留 24-48 小时而不
/// 是精确 24 小时;小时数据仍然精确按 `retention_days` 删除。
pub async fn cleanup_history(db: &D1Database, retention_days: i64) -> Result<()> {
let (cutoff, recent_cutoff) = history_cutoffs(now(), retention_days);
let current = now();
let cutoff = history_cutoff(current, retention_days);
// 只压已经走完的小时;当前这个未完成的小时留给下一次 cron。
let complete_hours_before = current.div_euclid(3600) * 3600;
let compact = db
.prepare(
r#"WITH ranked AS (
SELECT *, (timestamp / 3600) * 3600 AS bucket,
r#"WITH recent AS (
-- 两代分钟表,等价于 db::RECENT_HISTORY。下面 ranked 和 latency_ranked
-- 都从这里取,保证指标和延迟压的是同一批源行。
SELECT * FROM metric_history UNION ALL SELECT * FROM metric_history_old
), ranked AS (
SELECT h.*, (h.timestamp / 3600) * 3600 AS bucket,
ROW_NUMBER() OVER (
PARTITION BY server_id, timestamp / 3600
PARTITION BY h.server_id, h.timestamp / 3600
ORDER BY latest_timestamp DESC
) AS latest_position
FROM metric_history WHERE timestamp >= ?1 AND timestamp < ?2
FROM recent h
WHERE h.timestamp >= ?1 AND h.timestamp < ?2
), metrics AS (
SELECT
server_id, bucket,
@@ -1637,8 +1611,7 @@ pub async fn cleanup_history(db: &D1Database, retention_days: i64) -> Result<()>
MAX(disk_await_ms) AS disk_await_ms,
MAX(disk_utilization) AS disk_utilization,
SUM(sample_count) AS sample_count,
MAX(CASE WHEN latest_position = 1 THEN latest_timestamp END) AS latest_timestamp,
MAX(CASE WHEN latest_position = 1 THEN latest_json END) AS latest_json
MAX(CASE WHEN latest_position = 1 THEN latest_timestamp END) AS latest_timestamp
FROM ranked GROUP BY server_id, bucket
), latency_ranked AS (
SELECT h.server_id, (h.timestamp / 3600) * 3600 AS bucket,
@@ -1654,7 +1627,7 @@ pub async fn cleanup_history(db: &D1Database, retention_days: i64) -> Result<()>
PARTITION BY h.server_id, h.timestamp / 3600, json_extract(j.value, '$.task_id')
ORDER BY CAST(json_extract(j.value, '$.latest_timestamp') AS INTEGER) DESC
) AS latest_position
FROM metric_history h, json_each(h.latency_json) j
FROM recent h, json_each(h.latency_json) j
WHERE h.timestamp >= ?1 AND h.timestamp < ?2
), latency_tasks AS (
SELECT server_id, bucket, task_id,
@@ -1691,58 +1664,73 @@ pub async fn cleanup_history(db: &D1Database, retention_days: i64) -> Result<()>
m.net_in, m.net_out, m.net_rx_total, m.net_tx_total, m.processes,
m.tcp_connections, m.udp_connections, m.gpu_usage, m.disk_read_bps,
m.disk_write_bps, m.disk_read_iops, m.disk_write_iops, m.disk_await_ms,
m.disk_utilization, m.sample_count, m.latest_timestamp, m.latest_json,
m.disk_utilization, m.sample_count, m.latest_timestamp, '{}',
COALESCE(l.latency_json, '[]')
FROM metrics m LEFT JOIN latency_packed l
ON l.server_id = m.server_id AND l.bucket = m.bucket
WHERE true
-- 直接覆盖,不做累加。上面的 CTE 是从两代分钟表里把这个小时桶的全部
-- 源行重新算了一遍,所以 excluded 就是该桶的完整真值。
-- 一个桶的源行在分钟表里可见 24-48 小时,而它在写完后的第一次 cron 就会
-- 被压缩,所以每次压缩看到的都是完整的桶;等源行被轮换丢弃时,这个桶也
-- 不再出现在 union 里,不会再触发 upsert。
-- 覆盖语义顺带让压缩对乱序到达免疫:迟到的行只是让下一次重算包含它。
ON CONFLICT(server_id, timestamp) DO UPDATE SET
cpu=excluded.cpu, load1=excluded.load1, load5=excluded.load5,
load15=excluded.load15, mem_used=excluded.mem_used, mem_total=excluded.mem_total,
swap_used=excluded.swap_used, swap_total=excluded.swap_total,
disk_used=excluded.disk_used, disk_total=excluded.disk_total,
net_in=excluded.net_in, net_out=excluded.net_out,
net_rx_total=excluded.net_rx_total, net_tx_total=excluded.net_tx_total,
processes=excluded.processes, tcp_connections=excluded.tcp_connections,
udp_connections=excluded.udp_connections, gpu_usage=excluded.gpu_usage,
disk_read_bps=excluded.disk_read_bps, disk_write_bps=excluded.disk_write_bps,
disk_read_iops=excluded.disk_read_iops, disk_write_iops=excluded.disk_write_iops,
disk_await_ms=excluded.disk_await_ms, disk_utilization=excluded.disk_utilization,
load15=excluded.load15, mem_used=excluded.mem_used,
mem_total=excluded.mem_total, swap_used=excluded.swap_used,
swap_total=excluded.swap_total, disk_used=excluded.disk_used,
disk_total=excluded.disk_total, net_in=excluded.net_in,
net_out=excluded.net_out, net_rx_total=excluded.net_rx_total,
net_tx_total=excluded.net_tx_total, processes=excluded.processes,
tcp_connections=excluded.tcp_connections,
udp_connections=excluded.udp_connections,
gpu_usage=excluded.gpu_usage, disk_read_bps=excluded.disk_read_bps,
disk_write_bps=excluded.disk_write_bps,
disk_read_iops=excluded.disk_read_iops,
disk_write_iops=excluded.disk_write_iops,
disk_await_ms=excluded.disk_await_ms,
disk_utilization=excluded.disk_utilization,
sample_count=excluded.sample_count,
latest_timestamp=excluded.latest_timestamp, latest_json=excluded.latest_json,
latest_timestamp=excluded.latest_timestamp,
latest_json=excluded.latest_json,
latency_json=excluded.latency_json"#,
)
.bind(&[number(cutoff), number(recent_cutoff)])?;
let delete_recent = db
.prepare(
"DELETE FROM metric_history AS h WHERE timestamp < ?1 AND timestamp < ( \
SELECT MAX(newest.timestamp) FROM metric_history AS newest \
WHERE newest.server_id = h.server_id \
)",
)
.bind(&[number(recent_cutoff)])?;
.bind(&[number(cutoff), number(complete_hours_before)])?;
let delete_archive = db
.prepare("DELETE FROM metric_history_hourly WHERE timestamp < ?1")
.bind(&[number(cutoff)])?;
db.batch(vec![compact, delete_recent, delete_archive])
// DDL 不和上面的语句放进同一个 batch:batch 是隐式事务,而 SQLite 不允许
// 在事务里 ALTER TABLE ... RENAME。压缩+删除先提交,轮换单独走;轮换中途
// 失败下一次 cron 重来即可,重复压缩无害(见上)。
db.batch(vec![compact, delete_archive]).await?;
rotate_recent_history(db).await
}
/// 丢弃上一代分钟历史,把当前代降级为上一代,再建一张空的当前代。
async fn rotate_recent_history(db: &D1Database) -> Result<()> {
db.prepare("DROP TABLE IF EXISTS metric_history_old")
.run()
.await?;
db.prepare("ALTER TABLE metric_history RENAME TO metric_history_old")
.run()
.await?;
db.prepare(metric_history_ddl("metric_history"))
.run()
.await?;
Ok(())
}
fn history_cutoffs(current: i64, retention_days: i64) -> (i64, i64) {
let cutoff = current - retention_days.clamp(1, 365) * 86_400;
let recent_cutoff = (current - 86_400).max(cutoff);
(cutoff, recent_cutoff)
/// 保留期下界,对齐到整小时。
fn history_cutoff(current: i64, retention_days: i64) -> i64 {
let cutoff = current - retention_days.clamp(1, MAX_HISTORY_RETENTION_DAYS) * 86_400;
cutoff.div_euclid(3600) * 3600
}
pub async fn clear_history(db: &D1Database) -> Result<()> {
db.batch(vec![
db.prepare(
"DELETE FROM metric_history AS h WHERE timestamp < ( \
SELECT MAX(newest.timestamp) FROM metric_history AS newest \
WHERE newest.server_id = h.server_id \
)",
),
db.prepare("DELETE FROM metric_history"),
db.prepare("DELETE FROM metric_history_old"),
db.prepare("DELETE FROM metric_history_hourly"),
])
.await?;
@@ -1814,20 +1802,25 @@ pub async fn database_stats(db: &D1Database, offline_threshold: i64) -> Result<D
r#"SELECT
(SELECT COUNT(*) FROM servers) AS server_count,
(SELECT COUNT(*) FROM servers s WHERE COALESCE(
(SELECT latest_timestamp FROM metric_history h
WHERE h.server_id = s.id ORDER BY timestamp DESC LIMIT 1),
(SELECT MAX(latest_timestamp) FROM (
SELECT server_id, latest_timestamp FROM metric_history UNION ALL
SELECT server_id, latest_timestamp FROM metric_history_old
) h WHERE h.server_id = s.id),
(SELECT latest_timestamp FROM metric_history_hourly h
WHERE h.server_id = s.id ORDER BY timestamp DESC LIMIT 1),
0
) >= ?1) AS online_count,
((SELECT COUNT(*) FROM metric_history) +
(SELECT COUNT(*) FROM metric_history_old) +
(SELECT COUNT(*) FROM metric_history_hourly)) AS history_rows,
(SELECT MIN(timestamp) FROM (
SELECT timestamp FROM metric_history UNION ALL
SELECT timestamp FROM metric_history_old UNION ALL
SELECT timestamp FROM metric_history_hourly
)) AS oldest_history,
(SELECT MAX(timestamp) FROM (
SELECT timestamp FROM metric_history UNION ALL
SELECT timestamp FROM metric_history_old UNION ALL
SELECT timestamp FROM metric_history_hourly
)) AS newest_history"#,
)
@@ -1875,8 +1868,9 @@ pub async fn update_expiry(db: &D1Database, id: &str, expires_at: i64) -> Result
#[cfg(test)]
mod tests {
use super::{
alert_window_covered, secret_for_api, traffic_cycle_key, AlertMetricRow,
HistoryMetricAggregate, TrafficCounterState, SECRET_MASK,
alert_window_covered, history_cutoff, metric_history_ddl, secret_for_api,
traffic_cycle_key, AlertMetricRow, HistoryMetricAggregate, TrafficCounterState,
MAX_HISTORY_RETENTION_DAYS, SECRET_MASK,
};
use crate::models::AgentReport;
@@ -1895,9 +1889,9 @@ mod tests {
#[test]
fn alert_coverage_tracks_report_interval_and_freshness() {
let current = 10_000;
assert!(alert_window_covered(&row(6, current - 30, 60), 10, current));
assert!(alert_window_covered(&row(3, current - 30, 60), 10, current));
assert!(!alert_window_covered(
&row(5, current - 30, 60),
&row(2, current - 30, 60),
10,
current
));
@@ -1913,6 +1907,48 @@ mod tests {
));
}
#[test]
fn aligns_history_cutoff_and_caps_retention() {
let current = 40 * 86_400 + 12 * 3600 + 34 * 60 + 56;
let cutoff = history_cutoff(current, 365);
assert_eq!(cutoff % 3600, 0);
assert_eq!(
cutoff,
(40 - MAX_HISTORY_RETENTION_DAYS) * 86_400 + 12 * 3600
);
}
/// 轮换要在运行时重建 `metric_history`,DDL 因此在 Rust 里存了一份。
/// 这个测试比对它和 migration 的文本,防止两边漂移。三张 metric_* 表必须同构:
/// 轮换靠 RENAME 把当前代变成上一代,压缩靠 UNION ALL 同时读它们。
#[test]
fn metric_history_ddl_matches_migration() {
let schema = include_str!("../migrations/initial_01.sql");
let table_body = |sql: &str, table: &str| -> String {
let head = format!("CREATE TABLE {table} (");
let start = sql.find(&head).unwrap_or_else(|| {
panic!("migration 里找不到 {table}");
}) + head.len();
let rest = &sql[start..];
let end = rest.find(") WITHOUT ROWID").expect("缺少 WITHOUT ROWID");
rest[..end].split_whitespace().collect::<Vec<_>>().join(" ")
};
let generated = table_body(&metric_history_ddl("metric_history"), "metric_history");
for table in [
"metric_history",
"metric_history_old",
"metric_history_hourly",
] {
assert_eq!(
generated,
table_body(schema, table),
"migration 的 {table} 与 db::metric_history_ddl 不一致"
);
}
}
#[test]
fn masks_stored_secrets_in_api_responses() {
assert_eq!(secret_for_api(""), "");
+10 -8
View File
@@ -2,9 +2,9 @@ use std::{collections::BTreeMap, time::Duration};
use serde::Serialize;
use serde_json::Value;
use worker::{D1Database, Error, Method, Request, Result};
use worker::{D1Database, Error, Headers, Method, Request, RequestInit, Result};
use crate::outbound::fetch_with_timeout;
use crate::outbound::{fetch_with_timeout, read_json_limited};
use crate::{cloudflare, db};
@@ -13,6 +13,7 @@ const PRIMARY_URL: &str = "https://open.er-api.com/v6/latest/CNY";
const FALLBACK_URL: &str = "https://api.frankfurter.dev/v1/latest?base=CNY";
const REFRESH_INTERVAL: i64 = 86_400;
const RETRY_INTERVAL: i64 = 3_600;
const UPSTREAM_JSON_MAX_BYTES: usize = 256 * 1024;
#[derive(Debug)]
struct FetchedRates {
@@ -133,11 +134,12 @@ fn parse_er_api(value: &Value) -> Option<FetchedRates> {
}
async fn fetch_json(url: &str) -> Result<Value> {
let request = Request::new(url, Method::Get)?;
request.headers().set("Accept", "application/json")?;
request
.headers()
.set("User-Agent", "NodeFlare-Exchange-Rates")?;
let headers = Headers::new();
headers.set("Accept", "application/json")?;
headers.set("User-Agent", "NodeFlare-Exchange-Rates")?;
let mut init = RequestInit::new();
init.with_method(Method::Get).with_headers(headers);
let request = Request::new_with_init(url, &init)?;
let Some(mut response) = fetch_with_timeout(request, Duration::from_secs(8)).await? else {
return Err(Error::RustError(
"exchange-rate upstream request timed out".to_string(),
@@ -149,7 +151,7 @@ async fn fetch_json(url: &str) -> Result<Value> {
"exchange-rate upstream returned HTTP {status}"
)));
}
response.json().await
read_json_limited(&mut response, UPSTREAM_JSON_MAX_BYTES).await
}
async fn fetch_latest() -> Result<FetchedRates> {
+43 -38
View File
@@ -201,6 +201,21 @@ pub async fn task_count(db: &D1Database) -> Result<i64> {
.ok_or_else(|| worker::Error::RustError("无法读取延迟任务数量".to_string()))
}
pub async fn server_ids_for_task(db: &D1Database, task_id: &str) -> Result<Vec<String>> {
let assignments: Vec<AssignmentRow> = db
.prepare(
"SELECT task_id, server_id FROM latency_task_servers WHERE task_id = ?1 ORDER BY server_id ASC",
)
.bind(&[text(task_id)])?
.all()
.await?
.results()?;
Ok(assignments
.into_iter()
.map(|assignment| assignment.server_id)
.collect())
}
pub async fn tasks_for_server(db: &D1Database, server_id: &str) -> Result<Vec<AgentLatencyTask>> {
db.prepare(
"SELECT t.id, t.name, t.task_type, t.target, t.port, t.interval_seconds \
@@ -342,14 +357,6 @@ pub async fn latest_all(db: &D1Database) -> Result<Vec<LatencySample>> {
db.prepare(latest_query(None)).all().await?.results()
}
pub async fn latest_for_server(db: &D1Database, server_id: &str) -> Result<Vec<LatencySample>> {
db.prepare(latest_query(Some("a.server_id = ?1")))
.bind(&[text(server_id)])?
.all()
.await?
.results()
}
fn latest_query(filter: Option<&str>) -> String {
let filter = filter.map_or(String::new(), |value| format!("WHERE {value}"));
format!(
@@ -360,7 +367,9 @@ fn latest_query(filter: Option<&str>) -> String {
INNER JOIN latency_tasks t ON t.id = a.task_id {filter} \
), latest AS ( \
SELECT assignments.*, COALESCE( \
(SELECT j.value FROM metric_history h, json_each(h.latency_json) j \
(SELECT j.value FROM (SELECT * FROM metric_history \
UNION ALL SELECT * FROM metric_history_old) h, \
json_each(h.latency_json) j \
WHERE h.server_id = assignments.server_id \
AND json_extract(j.value, '$.task_id') = assignments.task_id \
AND CAST(json_extract(j.value, '$.latest_timestamp') AS INTEGER) >= assignments.assigned_at \
@@ -379,8 +388,21 @@ fn latest_query(filter: Option<&str>) -> String {
)
}
/// 把一张历史表的 `latency_json` 摊平成延迟样本行。
fn latency_samples_from(table: &str) -> String {
format!(
"SELECT h.server_id, h.timestamp, json_extract(j.value, '$.task_id') AS task_id, \
CAST(json_extract(j.value, '$.latency_ms') AS REAL) AS latency_ms, \
CAST(json_extract(j.value, '$.packet_loss') AS REAL) AS packet_loss, \
CAST(json_extract(j.value, '$.sample_count') AS INTEGER) AS sample_count, \
CAST(json_extract(j.value, '$.success_count') AS INTEGER) AS success_count, \
CAST(json_extract(j.value, '$.latest_timestamp') AS INTEGER) AS latest_timestamp \
FROM {table} h, json_each(h.latency_json) j"
)
}
pub async fn history(db: &D1Database, server_id: &str, hours: i64) -> Result<Vec<LatencySample>> {
let hours = hours.clamp(1, 24 * 365);
let hours = hours.clamp(1, 24 * crate::db::MAX_HISTORY_RETENTION_DAYS);
let cutoff = (Date::now().as_millis() as i64 / 1000) - hours * 3600;
let task_count = db
.prepare("SELECT COUNT(*) AS count FROM latency_task_servers WHERE server_id = ?1")
@@ -392,33 +414,16 @@ pub async fn history(db: &D1Database, server_id: &str, hours: i64) -> Result<Vec
return Ok(Vec::new());
}
let bucket = history_bucket_seconds(hours, task_count);
let source = if hours <= 24 {
"SELECT h.server_id, h.timestamp, json_extract(j.value, '$.task_id') AS task_id, \
CAST(json_extract(j.value, '$.latency_ms') AS REAL) AS latency_ms, \
CAST(json_extract(j.value, '$.packet_loss') AS REAL) AS packet_loss, \
CAST(json_extract(j.value, '$.sample_count') AS INTEGER) AS sample_count, \
CAST(json_extract(j.value, '$.success_count') AS INTEGER) AS success_count, \
CAST(json_extract(j.value, '$.latest_timestamp') AS INTEGER) AS latest_timestamp \
FROM metric_history h, json_each(h.latency_json) j"
.to_string()
} else {
"SELECT h.server_id, h.timestamp, json_extract(j.value, '$.task_id') AS task_id, \
CAST(json_extract(j.value, '$.latency_ms') AS REAL) AS latency_ms, \
CAST(json_extract(j.value, '$.packet_loss') AS REAL) AS packet_loss, \
CAST(json_extract(j.value, '$.sample_count') AS INTEGER) AS sample_count, \
CAST(json_extract(j.value, '$.success_count') AS INTEGER) AS success_count, \
CAST(json_extract(j.value, '$.latest_timestamp') AS INTEGER) AS latest_timestamp \
FROM metric_history h, json_each(h.latency_json) j \
UNION ALL \
SELECT h.server_id, h.timestamp, json_extract(j.value, '$.task_id') AS task_id, \
CAST(json_extract(j.value, '$.latency_ms') AS REAL) AS latency_ms, \
CAST(json_extract(j.value, '$.packet_loss') AS REAL) AS packet_loss, \
CAST(json_extract(j.value, '$.sample_count') AS INTEGER) AS sample_count, \
CAST(json_extract(j.value, '$.success_count') AS INTEGER) AS success_count, \
CAST(json_extract(j.value, '$.latest_timestamp') AS INTEGER) AS latest_timestamp \
FROM metric_history_hourly h, json_each(h.latency_json) j"
.to_string()
};
// 分钟历史分两代(见 db::RECENT_HISTORY),近期区间必须都读。
let mut tables = vec!["metric_history", "metric_history_old"];
if hours > 24 {
tables.push("metric_history_hourly");
}
let source = tables
.into_iter()
.map(latency_samples_from)
.collect::<Vec<_>>()
.join(" UNION ALL ");
let query = format!(
"SELECT h.task_id, h.server_id, t.name, t.task_type, t.target, t.port, \
(h.timestamp / {bucket}) * {bucket} AS timestamp, \
@@ -441,7 +446,7 @@ pub async fn history(db: &D1Database, server_id: &str, hours: i64) -> Result<Vec
}
fn history_bucket_seconds(hours: i64, task_count: i64) -> i64 {
let hours = hours.clamp(1, 24 * 365);
let hours = hours.clamp(1, 24 * crate::db::MAX_HISTORY_RETENTION_DAYS);
let task_count = task_count.clamp(1, MAX_LATENCY_TASKS as i64);
let base = match hours {
1 => 60,
+69 -39
View File
@@ -13,6 +13,7 @@ mod turnstile;
use std::collections::HashSet;
use std::net::Ipv4Addr;
use std::time::Duration;
use futures_util::TryStreamExt;
use serde::{de::DeserializeOwned, Serialize};
@@ -30,9 +31,9 @@ pub(crate) const ADMIN_HTML: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/a
pub(crate) const ADMIN_SCRIPT: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/admin.js"));
pub(crate) const ADMIN_STYLE: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/admin.css"));
pub(crate) const API_JSON_MAX_BYTES: usize = 1024 * 1024;
pub(crate) const AGENT_JSON_MAX_BYTES: usize = 16 * 1024 * 1024;
pub(crate) const MAX_AGENT_SAMPLES: usize = 720;
pub(crate) const MAX_AGENT_LATENCY_RESULTS: usize = 4096;
const SERVER_RENEWAL_INTERVAL_SECONDS: i64 = 60 * 60;
const REMOTE_THEME_FETCH_TIMEOUT: Duration = Duration::from_secs(10);
pub(crate) const VERSION: &str = match option_env!("NODEFLARE_VERSION") {
Some(version) => version,
None => env!("CARGO_PKG_VERSION"),
@@ -342,9 +343,10 @@ async fn remote_theme_response(path: &str, base: &str) -> Result<Option<Response
return Ok(Some(secure_public_response(mutable_response(response)?)?));
}
let request = Request::new(&remote_url, Method::Get)?;
let mut response = match Fetch::Request(request).send().await {
Ok(response) if (200..300).contains(&response.status_code()) => response,
Ok(response) => {
let mut response = match outbound::fetch_with_timeout(request, REMOTE_THEME_FETCH_TIMEOUT).await
{
Ok(Some(response)) if (200..300).contains(&response.status_code()) => response,
Ok(Some(response)) => {
console_warn!(
"remote theme returned HTTP {} for {}",
response.status_code(),
@@ -356,6 +358,14 @@ async fn remote_theme_response(path: &str, base: &str) -> Result<Option<Response
Ok(Some(remote_theme_failure("远程主题资源暂时不可用")?))
};
}
Ok(None) => {
console_warn!("remote theme request timed out for {remote_url}");
return if is_index {
Ok(None)
} else {
Ok(Some(remote_theme_failure("远程主题资源请求超时")?))
};
}
Err(error) => {
console_warn!("remote theme request failed for {remote_url}: {error}");
return if is_index {
@@ -370,7 +380,7 @@ async fn remote_theme_response(path: &str, base: &str) -> Result<Option<Response
} else {
theme::ASSET_MAX_BYTES
};
let Some(body) = theme::read_response_limited(&mut response, limit).await? else {
let Some(body) = outbound::read_response_limited(&mut response, limit).await? else {
return if is_index {
Ok(None)
} else {
@@ -415,11 +425,15 @@ async fn remote_theme_preview_response(
return remote_theme_failure("远程主题来源不受支持");
};
let request = Request::new(&index, Method::Get)?;
let mut response = Fetch::Request(request).send().await?;
let Some(mut response) =
outbound::fetch_with_timeout(request, REMOTE_THEME_FETCH_TIMEOUT).await?
else {
return remote_theme_failure("主题预览页面请求超时");
};
if !(200..300).contains(&response.status_code()) {
return remote_theme_failure("主题预览页面暂时不可用");
}
let Some(body) = theme::read_response_limited(&mut response, theme::INDEX_MAX_BYTES).await?
let Some(body) = outbound::read_response_limited(&mut response, theme::INDEX_MAX_BYTES).await?
else {
return remote_theme_failure("主题预览页面大小无效");
};
@@ -759,7 +773,10 @@ pub(crate) fn validate_report(report: &AgentReport) -> Option<&'static str> {
report.disk_await_ms,
report.disk_utilization,
];
if floats.iter().any(|value| !value.is_finite()) {
if floats
.iter()
.any(|value| !value.is_finite() || *value < 0.0)
{
return Some("指标包含非法数值");
}
if !(0.0..=100.0).contains(&report.cpu) || !(0.0..=100.0).contains(&report.gpu_usage) {
@@ -783,6 +800,12 @@ pub(crate) fn validate_report(report: &AgentReport) -> Option<&'static str> {
if counters.iter().any(|value| *value < 0) {
return Some("计数指标不能为负数");
}
if report.mem_used > report.mem_total
|| report.swap_used > report.swap_total
|| report.disk_used > report.disk_total
{
return Some("已用资源不能超过总量");
}
if report.gpu_model.chars().count() > 240 {
return Some("GPU 型号字段过长");
}
@@ -796,6 +819,7 @@ pub(crate) fn validate_report(report: &AgentReport) -> Option<&'static str> {
|| disk.mount_point.chars().count() > 240
|| disk.used < 0
|| disk.total < 0
|| disk.used > disk.total
|| ![
disk.read_bps,
disk.write_bps,
@@ -811,6 +835,7 @@ pub(crate) fn validate_report(report: &AgentReport) -> Option<&'static str> {
gpu.model.chars().count() > 240
|| gpu.memory_used < 0
|| gpu.memory_total < 0
|| gpu.memory_used > gpu.memory_total
|| gpu
.usage
.is_some_and(|usage| !usage.is_finite() || !(0.0..=100.0).contains(&usage))
@@ -932,9 +957,7 @@ pub(crate) 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")
|| (method == Method::Get && matches!(path, "/api/agent/live" | "/api/agent/config"))
{
} else if method == Method::Get && path == "/api/agent/ws" {
Some("AGENT_RATE_LIMITER")
} else if path.starts_with("/api/") {
Some("API_RATE_LIMITER")
@@ -963,7 +986,8 @@ fn agent_rate_limit_key(req: &Request) -> String {
}
fn allow_on_rate_limit_failure(binding: &str) -> bool {
binding != "AUTH_RATE_LIMITER"
let _ = binding;
false
}
async fn request_within_rate_limit(env: &Env, binding: &str, key: String) -> bool {
@@ -1070,7 +1094,7 @@ pub(crate) fn server_id(path: &str, prefix: &str) -> Option<String> {
}
}
async fn handle(req: Request, env: Env, ctx: Context) -> Result<Response> {
async fn handle(req: Request, env: Env) -> Result<Response> {
let method = req.method();
let path = req.path();
@@ -1082,19 +1106,14 @@ async fn handle(req: Request, env: Env, ctx: Context) -> Result<Response> {
let database = env.d1("DB")?;
if method == Method::Post && path == "/api/agent/report" {
return routes::agent::report(req, env, ctx, &database).await;
}
if method == Method::Get && path == "/api/agent/config" {
return routes::agent::config(&req, &database).await;
}
if method == Method::Get && path == "/api/agent/live" {
return routes::agent::live_websocket(req, &env, &database).await;
if method == Method::Get && path == "/api/agent/ws" {
return routes::agent::websocket(req, &env, &database).await;
}
let default_name = env_text(&env, "SITE_NAME", "NodeFlare");
let default_threshold = env_number(&env, "OFFLINE_THRESHOLD_SECONDS", 180).clamp(30, 3600);
let default_retention = env_number(&env, "HISTORY_RETENTION_DAYS", 30).clamp(1, 365);
let default_retention =
env_number(&env, "HISTORY_RETENTION_DAYS", 30).clamp(1, db::MAX_HISTORY_RETENTION_DAYS);
let default_username = env_text(&env, "ADMIN_USERNAME", "");
let settings = db::settings(
&database,
@@ -1165,7 +1184,7 @@ async fn handle(req: Request, env: Env, ctx: Context) -> Result<Response> {
secure_public_response(mutable_response(response)?)
}
#[event(fetch, respond_with_errors)]
async fn fetch(req: Request, env: Env, ctx: Context) -> Result<Response> {
async fn fetch(req: Request, env: Env, _ctx: Context) -> Result<Response> {
let method = req.method();
let method_name = format!("{method:?}");
let path = req.path();
@@ -1193,7 +1212,7 @@ async fn fetch(req: Request, env: Env, ctx: Context) -> Result<Response> {
}
}
match handle(req, env, ctx).await {
match handle(req, env).await {
Ok(response) => Ok(response),
Err(err) => {
console_error!(
@@ -1218,7 +1237,8 @@ async fn scheduled(_event: ScheduledEvent, env: Env, _ctx: ScheduleContext) {
};
let default_name = env_text(&env, "SITE_NAME", "NodeFlare");
let default_threshold = env_number(&env, "OFFLINE_THRESHOLD_SECONDS", 180).clamp(30, 3600);
let default_retention = env_number(&env, "HISTORY_RETENTION_DAYS", 30).clamp(1, 365);
let default_retention =
env_number(&env, "HISTORY_RETENTION_DAYS", 30).clamp(1, db::MAX_HISTORY_RETENTION_DAYS);
let default_username = env_text(&env, "ADMIN_USERNAME", "");
let settings = db::settings(
&database,
@@ -1239,12 +1259,15 @@ async fn scheduled(_event: ScheduledEvent, env: Env, _ctx: ScheduleContext) {
.flatten()
.and_then(|value| value.parse::<i64>().ok())
.unwrap_or(0);
if current - last_cleanup >= 86_400 {
if current.saturating_sub(last_cleanup) >= db::HISTORY_ROTATION_INTERVAL_SECONDS {
if let Err(err) = db::cleanup_history(&database, retention).await {
console_error!("history cleanup failed: {err}");
} else {
let _ = db::save_setting(&database, "last_history_cleanup", &current.to_string()).await;
}
if let Err(err) = notify::cleanup(&database, current).await {
console_error!("notification cleanup failed: {err}");
}
}
match exchange::refresh(&database, current, false).await {
Ok((rates, true)) => console_log!(
@@ -1255,13 +1278,28 @@ async fn scheduled(_event: ScheduledEvent, env: Env, _ctx: ScheduleContext) {
Ok((_, false)) => {}
Err(err) => console_error!("exchange-rate refresh failed: {err}"),
}
if let Err(err) = notify::renew_servers(&database).await {
console_error!("server renewal failed: {err}");
let last_server_renewal = db::get_setting(&database, "last_server_renewal")
.await
.ok()
.flatten()
.and_then(|value| value.parse::<i64>().ok())
.unwrap_or(0);
if current.saturating_sub(last_server_renewal) >= SERVER_RENEWAL_INTERVAL_SECONDS {
if let Err(err) = notify::renew_servers(&database).await {
console_error!("server renewal failed: {err}");
} else {
let _ = db::save_setting(&database, "last_server_renewal", &current.to_string()).await;
}
}
if let Ok(settings) = settings {
if let Err(err) = notify::check_alerts(&database, &env, &settings).await {
console_error!("alert check failed: {err}");
}
if settings.notification_enabled {
if let Err(err) = notify::process_pending(&database, current).await {
console_error!("notification delivery processing failed: {err}");
}
}
}
}
@@ -1284,15 +1322,7 @@ mod tests {
Some("AUTH_RATE_LIMITER")
);
assert_eq!(
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/agent/config"),
rate_limit_binding(Method::Get, "/api/agent/ws"),
Some("AGENT_RATE_LIMITER")
);
assert_eq!(
@@ -1301,7 +1331,7 @@ mod tests {
);
assert_eq!(rate_limit_binding(Method::Get, "/"), None);
assert!(!allow_on_rate_limit_failure("AUTH_RATE_LIMITER"));
assert!(allow_on_rate_limit_failure("API_RATE_LIMITER"));
assert!(!allow_on_rate_limit_failure("API_RATE_LIMITER"));
}
#[test]
+329 -129
View File
@@ -1,15 +1,21 @@
use std::cell::{Cell, RefCell};
use std::cell::RefCell;
use std::collections::HashSet;
use serde::{Deserialize, Serialize};
use worker::*;
use crate::db::{AgentLiveContext, AlertMetricRow, HistoryMetricAggregate};
use crate::db::{
AgentLiveContext, AlertMetricRow, HistoryMetricAggregate, HISTORY_WRITE_INTERVAL_SECONDS,
};
use crate::latency::LatencyMetricAggregates;
use crate::models::{AgentReport, AlertRuleView, HistoryPoint, ServerView};
use crate::models::{AgentConfigView, AgentReport, AlertRuleView, HistoryPoint, ServerView};
const MAX_LIVE_SAMPLES: usize = 720;
const MAX_LIVE_LATENCY_RESULTS: usize = 4096;
const AGENT_MESSAGE_WINDOW_SECONDS: i64 = 60;
const MAX_AGENT_MESSAGES_PER_WINDOW: u32 = 120;
const MAX_AGENT_SAMPLES_PER_WINDOW: u32 = 1440;
const MAX_AGENT_BYTES_PER_WINDOW: u32 = 4 * 1024 * 1024;
const LIVE_REPORT_DIVISOR: i64 = 15;
const MAX_SERIALIZED_ATTACHMENT_BYTES: usize = 15 * 1024;
const MAX_CACHED_LIVE_SAMPLES: usize = 32;
@@ -20,8 +26,11 @@ const CACHED_LIVE_TTL_SECONDS: i64 = 5 * 60;
const ALERT_WINDOW_SECONDS: i64 = 24 * 60 * 60;
const MAX_ALERT_WINDOW_SAMPLES: usize = 24 * 60 + 1;
const ALERT_STORAGE_PREFIX: &str = "resource-alert:";
const ALERT_COLLECTION_STATE_KEY: &str = "resource-alert-enabled-until";
const ALERT_COLLECTION_LEASE_SECONDS: i64 = 10 * 60;
const DASHBOARD_HUB_NAME: &str = "dashboard";
fn server_hub_name(server_id: &str) -> String {
format!("server:{server_id}")
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
struct AlertWindowSample(i64, u32, f64, f64, f64, f64, f64);
@@ -165,6 +174,8 @@ fn evaluate_alert_window(
struct AgentLiveBatch {
#[serde(rename = "type")]
message_type: String,
#[serde(rename = "batchId")]
batch_id: String,
samples: Vec<AgentReport>,
}
@@ -198,6 +209,45 @@ struct CachedLiveSample {
data: serde_json::Value,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
struct AgentMessageBudget {
#[serde(rename = "w")]
window_started_at: i64,
#[serde(rename = "m")]
messages: u32,
#[serde(rename = "s")]
samples: u32,
#[serde(rename = "b")]
bytes: u32,
}
fn consume_agent_message_budget(
budget: &mut AgentMessageBudget,
now: i64,
message_bytes: usize,
sample_count: usize,
) -> bool {
if budget.window_started_at <= 0
|| now < budget.window_started_at
|| now.saturating_sub(budget.window_started_at) >= AGENT_MESSAGE_WINDOW_SECONDS
{
*budget = AgentMessageBudget {
window_started_at: now,
..AgentMessageBudget::default()
};
}
budget.messages = budget.messages.saturating_add(1);
budget.samples = budget
.samples
.saturating_add(u32::try_from(sample_count).unwrap_or(u32::MAX));
budget.bytes = budget
.bytes
.saturating_add(u32::try_from(message_bytes).unwrap_or(u32::MAX));
budget.messages <= MAX_AGENT_MESSAGES_PER_WINDOW
&& budget.samples <= MAX_AGENT_SAMPLES_PER_WINDOW
&& budget.bytes <= MAX_AGENT_BYTES_PER_WINDOW
}
#[derive(Clone, Debug, Deserialize, Serialize)]
struct SocketAttachment {
#[serde(rename = "r")]
@@ -212,6 +262,8 @@ struct SocketAttachment {
collect_interval: i64,
#[serde(rename = "dw")]
last_d1_write_at: i64,
#[serde(rename = "pw")]
persisted_through: i64,
#[serde(rename = "df")]
d1_flush: Option<D1FlushSnapshot>,
#[serde(rename = "tr")]
@@ -224,6 +276,8 @@ struct SocketAttachment {
latest_samples: Vec<CachedLiveSample>,
#[serde(rename = "lr")]
latest_received_at: i64,
#[serde(rename = "mb")]
message_budget: AgentMessageBudget,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
@@ -247,12 +301,18 @@ fn begin_d1_flush(attachment: &mut SocketAttachment) -> Option<D1FlushSnapshot>
Some(snapshot)
}
fn finish_d1_flush(attachment: &mut SocketAttachment, succeeded: bool, persisted_at: i64) {
fn finish_d1_flush(
attachment: &mut SocketAttachment,
succeeded: bool,
persisted_at: i64,
persisted_through: i64,
) {
let Some(snapshot) = attachment.d1_flush.take() else {
return;
};
if succeeded {
attachment.last_d1_write_at = persisted_at;
attachment.persisted_through = attachment.persisted_through.max(persisted_through);
} else {
attachment.history_aggregate.merge(snapshot.history);
attachment.latency_aggregates.merge(snapshot.latency);
@@ -275,7 +335,7 @@ fn next_d1_write_ms(attachment: &SocketAttachment, now: i64) -> i64 {
if attachment.d1_flush.is_some() {
return 0;
}
let interval = attachment.report_interval.clamp(15, 3600);
let interval = HISTORY_WRITE_INTERVAL_SECONDS;
if attachment.last_d1_write_at <= 0 {
return interval * 1000;
}
@@ -290,6 +350,8 @@ fn next_d1_write_ms(attachment: &SocketAttachment, now: i64) -> i64 {
fn live_ack_payload(
timestamp: i64,
persisted: bool,
persistence_error: bool,
persisted_through: i64,
next_d1_write_after_ms: i64,
next_wss_report_after_ms: i64,
realtime_hint: bool,
@@ -298,12 +360,22 @@ fn live_ack_payload(
"type": "ack",
"ts": timestamp,
"persisted": persisted,
"persistenceError": persistence_error,
"persistedThroughTs": persisted_through,
"nextD1WriteAfterMs": next_d1_write_after_ms,
"nextWssReportAfterMs": next_wss_report_after_ms,
"realtimeHint": realtime_hint
}))?)
}
fn agent_config_payload(timestamp: i64, config: &AgentConfigView) -> Result<String> {
Ok(serde_json::to_string(&serde_json::json!({
"type": "config",
"ts": timestamp,
"config": config
}))?)
}
fn apply_live_traffic(report: &mut AgentReport, traffic: &mut TrafficAttachment) {
if report.timestamp <= traffic.timestamp {
report.net_rx_total = traffic.used_rx.saturating_add(traffic.rx_correction).max(0);
@@ -443,6 +515,7 @@ fn agent_attachment(
report_interval: context.report_interval.clamp(15, 3600),
collect_interval: context.collect_interval.clamp(1, 60),
last_d1_write_at: context.last_persisted_at,
persisted_through: context.last_persisted_at,
d1_flush: None,
traffic: Some(TrafficAttachment {
reset_day: context.reset_day.clamp(1, 31),
@@ -460,6 +533,7 @@ fn agent_attachment(
latency_aggregates: LatencyMetricAggregates::default(),
latest_samples: Vec::new(),
latest_received_at: 0,
message_budget: AgentMessageBudget::default(),
}
}
@@ -496,12 +570,14 @@ fn dashboard_attachment() -> SocketAttachment {
report_interval: 0,
collect_interval: 0,
last_d1_write_at: 0,
persisted_through: 0,
d1_flush: None,
traffic: None,
history_aggregate: HistoryMetricAggregate::default(),
latency_aggregates: LatencyMetricAggregates::default(),
latest_samples: Vec::new(),
latest_received_at: 0,
message_budget: AgentMessageBudget::default(),
}
}
@@ -509,37 +585,53 @@ fn dashboard_attachment() -> SocketAttachment {
pub struct LiveHub {
state: State,
env: Env,
alert_collection_until: Cell<i64>,
active_d1_flushes: RefCell<HashSet<String>>,
}
impl LiveHub {
async fn alert_collection_enabled(&self, current_time: i64) -> Result<bool> {
let mut enabled_until = self.alert_collection_until.get();
if enabled_until < 0 {
enabled_until = self
.state
.storage()
.get::<i64>(ALERT_COLLECTION_STATE_KEY)
.await?
.unwrap_or(0);
self.alert_collection_until.set(enabled_until);
async fn broadcast_to_global_dashboard(&self, server_id: &str, payload: &str) -> bool {
let result = async {
let namespace = self.env.durable_object("LIVE_HUB")?;
let stub = namespace.id_from_name(DASHBOARD_HUB_NAME)?.get_stub()?;
let headers = Headers::new();
headers.set("X-Server-ID", server_id)?;
let mut init = RequestInit::new();
init.with_method(Method::Post)
.with_headers(headers)
.with_body(Some(worker::wasm_bindgen::JsValue::from_str(payload)));
let req = Request::new_with_init("https://live.internal/push", &init)?;
let mut response = stub.fetch_with_request(req).await?;
ensure_live_success(&response, "global-push")?;
response.json::<bool>().await
}
.await;
match result {
Ok(active) => active,
Err(error) => {
console_warn!("global live broadcast failed for {server_id}: {error:?}");
false
}
}
Ok(enabled_until >= current_time)
}
async fn configure_alert_collection(&self, enabled: bool, current_time: i64) -> Result<()> {
let enabled_until = if enabled {
current_time.saturating_add(ALERT_COLLECTION_LEASE_SECONDS)
} else {
0
};
self.state
.storage()
.put(ALERT_COLLECTION_STATE_KEY, enabled_until)
async fn record_global_alert_point(&self, server_id: &str, point: &HistoryPoint) {
let result = async {
let response = post_live_action(
&self.env,
"record-alert-sample",
"record-alert-sample",
&serde_json::to_string(&AlertRecordRequest {
server_id: server_id.to_string(),
point: point.clone(),
})?,
)
.await?;
self.alert_collection_until.set(enabled_until);
Ok(())
ensure_live_success(&response, "record-alert-sample")
}
.await;
if let Err(error) = result {
console_warn!("global alert window write failed for {server_id}: {error:?}");
}
}
fn dashboard_active_for(&self, server_id: &str) -> bool {
@@ -570,6 +662,8 @@ impl LiveHub {
let Ok(payload) = live_ack_payload(
now,
false,
false,
attachment.persisted_through,
next_d1_write_ms(&attachment, now),
agent_wss_interval_ms(&attachment, realtime_active),
true,
@@ -645,9 +739,6 @@ impl LiveHub {
"invalid alert server identity".to_string(),
));
}
if !self.alert_collection_enabled(crate::now()).await? {
return Ok(());
}
let key = alert_storage_key(server_id);
let storage = self.state.storage();
let mut samples = storage
@@ -663,8 +754,6 @@ impl LiveHub {
request: &AlertEvaluationRequest,
) -> Result<Vec<AlertEvaluationValue>> {
let enabled = request.rules.iter().any(|rule| rule.enabled);
self.configure_alert_collection(enabled, request.current_time)
.await?;
if !enabled {
return Ok(Vec::new());
}
@@ -713,7 +802,6 @@ impl DurableObject for LiveHub {
Self {
state,
env,
alert_collection_until: Cell::new(-1),
active_d1_flushes: RefCell::new(HashSet::new()),
}
}
@@ -748,7 +836,6 @@ impl DurableObject for LiveHub {
}
Some("clear-alert-windows") => {
self.state.storage().delete_all().await?;
self.alert_collection_until.set(-1);
return Response::empty();
}
Some("remove-alert-samples") => {
@@ -766,6 +853,9 @@ impl DurableObject for LiveHub {
_ => {}
}
let payload = req.text().await?;
if payload.len() > 1024 * 1024 {
return Response::error("live broadcast payload too large", 413);
}
let server_id = req.headers().get("X-Server-ID")?.unwrap_or_default();
let mut sockets = self.state.get_websockets_with_tag("all");
if !server_id.is_empty() {
@@ -774,12 +864,13 @@ impl DurableObject for LiveHub {
.get_websockets_with_tag(&format!("server:{server_id}")),
);
}
let dashboard_active = !sockets.is_empty();
for socket in sockets {
if socket.send_with_str(&payload).is_err() {
let _ = socket.close(Some(1011), Some("send failed"));
}
}
return Response::empty();
return Response::from_json(&dashboard_active);
}
let is_upgrade = req
@@ -804,14 +895,23 @@ impl DurableObject for LiveHub {
Ok(context) => context,
Err(_) => return Response::error("Invalid live Agent context", 400),
};
let database = self.env.d1("DB")?;
let Some(config) = crate::db::agent_config(&database, &server_id).await? else {
return Response::error("Agent server not found", 404);
};
let config_payload = agent_config_payload(crate::now(), &config)?;
let agent_tag = format!("agent:{server_id}");
for existing in self.state.get_websockets_with_tag(&agent_tag) {
let _ = existing.close(Some(1008), Some("replaced by a newer Agent connection"));
}
pair.server.serialize_attachment(agent_attachment(
server_id.clone(),
hidden,
context,
))?;
let agent_tag = format!("agent:{server_id}");
self.state
.accept_websocket_with_tags(&pair.server, &["agents", agent_tag.as_str()]);
pair.server.send_with_str(&config_payload)?;
return Response::from_websocket(pair.client);
}
let server_id = req
@@ -838,10 +938,13 @@ impl DurableObject for LiveHub {
return Ok(());
};
if attachment.role != "agent" {
return Ok(());
return ws.close(Some(1008), Some("dashboard messages are not accepted"));
}
let WebSocketIncomingMessage::String(message) = message else {
return Ok(());
return ws.close(
Some(1003),
Some("binary live metric payload is not accepted"),
);
};
if message.len() > 1024 * 1024 {
return ws.close(Some(1009), Some("live metric payload too large"));
@@ -852,10 +955,19 @@ impl DurableObject for LiveHub {
if batch.message_type != "update"
|| batch.samples.is_empty()
|| batch.samples.len() > MAX_LIVE_SAMPLES
|| !crate::db::valid_report_batch_id(&batch.batch_id)
{
return ws.close(Some(1007), Some("invalid live metric batch"));
}
let received_at = crate::now();
if !consume_agent_message_budget(
&mut attachment.message_budget,
received_at,
message.len(),
batch.samples.len(),
) {
return ws.close(Some(1008), Some("live metric message rate exceeded"));
}
let mut latency_result_count = 0usize;
for report in &batch.samples {
if report.timestamp <= 0
@@ -873,6 +985,8 @@ impl DurableObject for LiveHub {
let Some(server_id) = attachment.server_id.clone() else {
return ws.close(Some(1008), Some("missing server identity"));
};
let unpersisted_reports =
crate::db::reports_after(&batch.samples, attachment.persisted_through);
let mut traffic = attachment
.traffic
.take()
@@ -905,10 +1019,10 @@ impl DurableObject for LiveHub {
.extend(&latency_results, received_at);
}
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 due_for_d1 = received_at.saturating_sub(attachment.last_d1_write_at)
>= HISTORY_WRITE_INTERVAL_SECONDS;
let flush_active = self.active_d1_flushes.borrow().contains(&server_id);
let mut flush = if !fresh_reports.is_empty()
let mut flush = if !unpersisted_reports.is_empty()
&& (due_for_d1 || attachment.d1_flush.is_some())
&& !flush_active
{
@@ -924,6 +1038,7 @@ impl DurableObject for LiveHub {
ws.serialize_attachment(&attachment)?;
}
let mut global_dashboard_active = false;
if !fresh_reports.is_empty() && !attachment.hidden {
let mut sockets = self.state.get_websockets_with_tag("all");
sockets.extend(
@@ -935,9 +1050,13 @@ impl DurableObject for LiveHub {
let _ = dashboard.close(Some(1011), Some("send failed"));
}
}
global_dashboard_active = self
.broadcast_to_global_dashboard(&server_id, &payload)
.await;
}
let mut persisted = false;
let mut persistence_error = false;
let mut attachment_serialized = false;
if let Some((database, snapshot)) = flush.take() {
self.active_d1_flushes
@@ -951,19 +1070,23 @@ impl DurableObject for LiveHub {
let metrics_result = crate::db::save_reports_with_history(
&database,
&server_id,
&fresh_reports,
crate::db::ReportBatchRef {
reports: &unpersisted_reports,
id: &batch.batch_id,
},
&history_point,
sample_count,
&traffic_state,
&snapshot.latency,
)
.await;
let write_succeeded = match metrics_result {
let (write_succeeded, newly_written) = match metrics_result {
Err(error) => {
console_warn!("live D1 write failed for {server_id}: {error:?}");
false
persistence_error = true;
(false, false)
}
Ok(()) => true,
Ok(inserted) => (true, inserted),
};
self.active_d1_flushes.borrow_mut().remove(&server_id);
@@ -976,7 +1099,12 @@ impl DurableObject for LiveHub {
{
return ws.close(Some(1011), Some("invalid live state after D1 write"));
}
finish_d1_flush(&mut current_attachment, write_succeeded, received_at);
finish_d1_flush(
&mut current_attachment,
write_succeeded,
received_at,
traffic_state.timestamp,
);
if !trim_socket_attachment(&mut current_attachment) {
return ws.close(Some(1011), Some("live state exceeds attachment limit"));
}
@@ -985,20 +1113,36 @@ impl DurableObject for LiveHub {
attachment_serialized = true;
persisted = write_succeeded;
if write_succeeded {
if let Err(error) = self.record_alert_point(&server_id, &history_point).await {
console_warn!("live alert window write failed for {server_id}: {error:?}");
if newly_written {
match crate::db::has_enabled_alert_rules(&database).await {
Ok(true) => {
self.record_global_alert_point(&server_id, &history_point)
.await;
}
Ok(false) => {}
Err(error) => {
console_warn!("enabled alert rule check failed for {server_id}: {error:?}");
}
}
}
}
let realtime_active = !attachment.hidden && self.dashboard_active_for(&server_id);
let realtime_active = !attachment.hidden
&& (global_dashboard_active || self.dashboard_active_for(&server_id));
let next_wss_ms = agent_wss_interval_ms(&attachment, realtime_active);
let next_d1_ms = if persisted {
report_interval * 1000
HISTORY_WRITE_INTERVAL_SECONDS * 1000
} else {
next_d1_write_ms(&attachment, received_at)
};
let ack = live_ack_payload(received_at, persisted, next_d1_ms, next_wss_ms, false)?;
let ack = live_ack_payload(
received_at,
persisted,
persistence_error,
attachment.persisted_through,
next_d1_ms,
next_wss_ms,
false,
)?;
if !attachment_serialized {
if !trim_socket_attachment(&mut attachment) {
return ws.close(Some(1011), Some("live state exceeds attachment limit"));
@@ -1034,23 +1178,27 @@ fn outbound_close_code(code: usize) -> Option<u16> {
async fn post_live_action(env: &Env, path: &str, action: &str, body: &str) -> Result<Response> {
let namespace = env.durable_object("LIVE_HUB")?;
let stub = namespace.id_from_name("dashboard")?.get_stub()?;
let stub = namespace.id_from_name(DASHBOARD_HUB_NAME)?.get_stub()?;
let headers = Headers::new();
headers.set("Content-Type", "application/json")?;
headers.set("X-Live-Action", action)?;
let mut init = RequestInit::new();
init.with_method(Method::Post)
.with_headers(headers)
.with_body(Some(worker::wasm_bindgen::JsValue::from_str(body)));
let req = Request::new_with_init(&format!("https://live.internal/{path}"), &init)?;
req.headers().set("Content-Type", "application/json")?;
req.headers().set("X-Live-Action", action)?;
stub.fetch_with_request(req).await
}
pub async fn record_alert_sample(env: &Env, server_id: &str, point: &HistoryPoint) -> Result<()> {
let body = serde_json::to_string(&AlertRecordRequest {
server_id: server_id.to_string(),
point: point.clone(),
})?;
post_live_action(env, "record-alert-sample", "record-alert-sample", &body).await?;
Ok(())
fn ensure_live_success(response: &Response, action: &str) -> Result<()> {
let status = response.status_code();
if (200..300).contains(&status) {
Ok(())
} else {
Err(Error::RustError(format!(
"live action {action} returned HTTP {status}"
)))
}
}
pub async fn evaluate_resource_alerts(
@@ -1079,11 +1227,14 @@ pub async fn evaluate_resource_alerts(
&serde_json::to_string(&request)?,
)
.await?;
ensure_live_success(&response, "evaluate-alerts")?;
response.json::<Vec<AlertEvaluationValue>>().await
}
pub async fn clear_alert_windows(env: &Env) -> Result<()> {
post_live_action(env, "clear-alert-windows", "clear-alert-windows", "{}").await?;
let response =
post_live_action(env, "clear-alert-windows", "clear-alert-windows", "{}").await?;
ensure_live_success(&response, "clear-alert-windows")?;
Ok(())
}
@@ -1091,26 +1242,29 @@ pub async fn remove_alert_samples(env: &Env, server_ids: &[String]) -> Result<()
if server_ids.is_empty() {
return Ok(());
}
post_live_action(
let response = post_live_action(
env,
"remove-alert-samples",
"remove-alert-samples",
&serde_json::to_string(server_ids)?,
)
.await?;
ensure_live_success(&response, "remove-alert-samples")?;
Ok(())
}
pub async fn broadcast(env: &Env, server_id: &str, payload: &str) -> Result<()> {
let namespace = env.durable_object("LIVE_HUB")?;
let stub = namespace.id_from_name("dashboard")?.get_stub()?;
fn copied_request_headers(req: &Request) -> Result<Headers> {
let headers = Headers::new();
for (name, value) in req.headers().entries() {
headers.append(&name, &value)?;
}
Ok(headers)
}
fn request_with_headers(req: &Request, headers: Headers) -> Result<Request> {
let mut init = RequestInit::new();
init.with_method(Method::Post)
.with_body(Some(worker::wasm_bindgen::JsValue::from_str(payload)));
let req = Request::new_with_init("https://live.internal/push", &init)?;
req.headers().set("X-Server-ID", server_id)?;
stub.fetch_with_request(req).await?;
Ok(())
init.with_method(req.method()).with_headers(headers);
Request::new_with_init(req.url()?.as_str(), &init)
}
pub async fn disconnect_agents(env: &Env, server_ids: &[String]) -> Result<()> {
@@ -1118,25 +1272,41 @@ pub async fn disconnect_agents(env: &Env, server_ids: &[String]) -> Result<()> {
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?;
for server_id in server_ids {
let stub = namespace
.id_from_name(&server_hub_name(server_id))?
.get_stub()?;
let headers = Headers::new();
headers.set("Content-Type", "application/json")?;
headers.set("X-Live-Action", "disconnect-agents")?;
let mut init = RequestInit::new();
init.with_method(Method::Post)
.with_headers(headers)
.with_body(Some(worker::wasm_bindgen::JsValue::from_str(
&serde_json::to_string(std::slice::from_ref(server_id))?,
)));
let req = Request::new_with_init("https://live.internal/disconnect-agents", &init)?;
let response = stub.fetch_with_request(req).await?;
ensure_live_success(&response, "disconnect-agents")?;
}
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()?;
let req = req.clone_mut()?;
req.headers().delete("X-Live-Agent-ID")?;
req.headers().delete("X-Live-Agent-Hidden")?;
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 hub_name = server_id
.as_deref()
.map(server_hub_name)
.unwrap_or_else(|| DASHBOARD_HUB_NAME.to_string());
let stub = namespace.id_from_name(&hub_name)?.get_stub()?;
let headers = copied_request_headers(&req)?;
headers.delete("X-Live-Agent-ID")?;
headers.delete("X-Live-Agent-Hidden")?;
for header in [
"X-Live-Report-Interval",
"X-Live-Collect-Interval",
@@ -1152,8 +1322,9 @@ pub async fn upgrade(req: Request, env: &Env) -> Result<Response> {
"X-Live-Tx-Correction",
"X-Live-Last-Persisted-At",
] {
req.headers().delete(header)?;
headers.delete(header)?;
}
let req = request_with_headers(&req, headers)?;
stub.fetch_with_request(req).await
}
@@ -1165,48 +1336,42 @@ pub async fn upgrade_agent(
context: AgentLiveContext,
) -> Result<Response> {
let namespace = env.durable_object("LIVE_HUB")?;
let stub = namespace.id_from_name("dashboard")?.get_stub()?;
let req = req.clone_mut()?;
req.headers().set("X-Live-Agent-ID", server_id)?;
req.headers()
.set("X-Live-Agent-Hidden", if hidden { "1" } else { "0" })?;
req.headers().set(
let stub = namespace
.id_from_name(&server_hub_name(server_id))?
.get_stub()?;
let headers = copied_request_headers(&req)?;
headers.set("X-Live-Agent-ID", server_id)?;
headers.set("X-Live-Agent-Hidden", if hidden { "1" } else { "0" })?;
headers.set(
"X-Live-Report-Interval",
&context.report_interval.to_string(),
)?;
req.headers().set(
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(
headers.set("X-Live-Reset-Day", &context.reset_day.to_string())?;
headers.set("X-Live-Cycle-Key", &context.cycle_key.to_string())?;
headers.set(
"X-Live-Traffic-Reset-Day",
&context.traffic_reset_day.to_string(),
)?;
req.headers().set(
headers.set(
"X-Live-Traffic-Timestamp",
&context.traffic_timestamp.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(
headers.set("X-Live-Raw-Rx", &context.raw_rx.to_string())?;
headers.set("X-Live-Raw-Tx", &context.raw_tx.to_string())?;
headers.set("X-Live-Used-Rx", &context.used_rx.to_string())?;
headers.set("X-Live-Used-Tx", &context.used_tx.to_string())?;
headers.set("X-Live-Rx-Correction", &context.rx_correction.to_string())?;
headers.set("X-Live-Tx-Correction", &context.tx_correction.to_string())?;
headers.set(
"X-Live-Last-Persisted-At",
&context.last_persisted_at.to_string(),
)?;
req.headers().delete("Authorization")?;
headers.delete("Authorization")?;
let req = request_with_headers(&req, headers)?;
stub.fetch_with_request(req).await
}
@@ -1214,11 +1379,12 @@ pub async fn upgrade_agent(
mod tests {
use super::{
agent_wss_interval_ms, batch_update_parts_at, begin_d1_flush, cached_update_value,
finish_d1_flush, live_ack_payload, next_d1_write_ms, outbound_close_code,
trim_cached_live_samples, trim_socket_attachment, update_alert_window,
AlertEvaluationServer, AlertWindowSample, CachedLiveSample, SocketAttachment,
TrafficAttachment, ALERT_WINDOW_SECONDS, CACHED_LIVE_TTL_SECONDS, MAX_CACHED_LIVE_BYTES,
MAX_CACHED_LIVE_SAMPLES, MAX_SERIALIZED_ATTACHMENT_BYTES,
consume_agent_message_budget, finish_d1_flush, live_ack_payload, next_d1_write_ms,
outbound_close_code, trim_cached_live_samples, trim_socket_attachment, update_alert_window,
AgentMessageBudget, AlertEvaluationServer, AlertWindowSample, CachedLiveSample,
SocketAttachment, TrafficAttachment, ALERT_WINDOW_SECONDS, CACHED_LIVE_TTL_SECONDS,
MAX_AGENT_BYTES_PER_WINDOW, MAX_AGENT_MESSAGES_PER_WINDOW, MAX_AGENT_SAMPLES_PER_WINDOW,
MAX_CACHED_LIVE_BYTES, MAX_CACHED_LIVE_SAMPLES, MAX_SERIALIZED_ATTACHMENT_BYTES,
};
use crate::db::HistoryMetricAggregate;
use crate::latency::LatencyMetricAggregates;
@@ -1251,6 +1417,30 @@ mod tests {
assert_eq!(outbound_close_code(5000), None);
}
#[test]
fn bounds_agent_messages_per_connection_window() {
let mut budget = AgentMessageBudget::default();
assert!(consume_agent_message_budget(
&mut budget,
100,
MAX_AGENT_BYTES_PER_WINDOW as usize,
MAX_AGENT_SAMPLES_PER_WINDOW as usize,
));
assert!(!consume_agent_message_budget(&mut budget, 100, 1, 0));
let mut message_budget = AgentMessageBudget::default();
for _ in 0..MAX_AGENT_MESSAGES_PER_WINDOW {
assert!(consume_agent_message_budget(&mut message_budget, 100, 1, 1));
}
assert!(!consume_agent_message_budget(
&mut message_budget,
100,
1,
1
));
assert!(consume_agent_message_budget(&mut message_budget, 160, 1, 1));
}
#[test]
fn live_payload_contains_cycle_adjusted_traffic_and_latency() {
let report = AgentReport {
@@ -1327,20 +1517,24 @@ mod tests {
report_interval: 60,
collect_interval: 1,
last_d1_write_at: 1_200,
persisted_through: 1_190,
d1_flush: None,
traffic: None,
history_aggregate: HistoryMetricAggregate::default(),
latency_aggregates: LatencyMetricAggregates::default(),
latest_samples: Vec::new(),
latest_received_at: 0,
message_budget: AgentMessageBudget::default(),
};
assert_eq!(agent_wss_interval_ms(&attachment, true), 4_000);
assert_eq!(agent_wss_interval_ms(&attachment, false), 60_000);
assert_eq!(next_d1_write_ms(&attachment, 1_235), 25_000);
let hint: serde_json::Value =
serde_json::from_str(&live_ack_payload(1_235, false, 25_000, 4_000, true).unwrap())
.unwrap();
assert_eq!(next_d1_write_ms(&attachment, 1_235), 85_000);
let hint: serde_json::Value = serde_json::from_str(
&live_ack_payload(1_235, false, false, 1_190, 25_000, 4_000, true).unwrap(),
)
.unwrap();
assert_eq!(hint["realtimeHint"], true);
assert_eq!(hint["persistedThroughTs"], 1_190);
assert_eq!(hint["nextWssReportAfterMs"], 4_000);
let mut corrected_down = traffic;
@@ -1378,12 +1572,14 @@ mod tests {
report_interval: 60,
collect_interval: 1,
last_d1_write_at: 0,
persisted_through: 0,
d1_flush: None,
traffic: None,
history_aggregate: HistoryMetricAggregate::default(),
latency_aggregates: LatencyMetricAggregates::default(),
latest_samples: samples,
latest_received_at: 100,
message_budget: AgentMessageBudget::default(),
};
let update = cached_update_value(&attachment, 102, Some("node-a")).unwrap();
assert_eq!(update["serverId"], "node-a");
@@ -1448,12 +1644,14 @@ mod tests {
report_interval: 60,
collect_interval: 1,
last_d1_write_at: 60,
persisted_through: 60,
d1_flush: None,
traffic: None,
history_aggregate: HistoryMetricAggregate::default(),
latency_aggregates: LatencyMetricAggregates::default(),
latest_samples: Vec::new(),
latest_received_at: 0,
message_budget: AgentMessageBudget::default(),
};
attachment
.history_aggregate
@@ -1485,12 +1683,13 @@ mod tests {
.latency_aggregates
.extend(&second.latency_results, second.timestamp);
assert_eq!(successful_snapshot.history.point().unwrap().cpu, 10.0);
finish_d1_flush(&mut successful, true, 180);
finish_d1_flush(&mut successful, true, 180, 120);
let pending = successful.history_aggregate.point().unwrap();
assert_eq!(pending.cpu, 20.0);
assert_eq!(pending.net_in, 500.0);
assert_eq!(successful.last_d1_write_at, 180);
assert_eq!(successful.persisted_through, 120);
assert!(successful.d1_flush.is_none());
let latency = serde_json::to_value(&successful.latency_aggregates).unwrap();
assert_eq!(latency["v"]["task-a"][0].as_u64(), Some(1));
@@ -1504,12 +1703,13 @@ mod tests {
.latency_aggregates
.extend(&second.latency_results, second.timestamp);
assert_eq!(failed_snapshot.history.point().unwrap().cpu, 10.0);
finish_d1_flush(&mut attachment, false, 180);
finish_d1_flush(&mut attachment, false, 180, 120);
let retried = attachment.history_aggregate.point().unwrap();
assert_eq!(retried.cpu, 15.0);
assert_eq!(retried.net_in, 500.0);
assert_eq!(attachment.last_d1_write_at, 60);
assert_eq!(attachment.persisted_through, 60);
assert!(attachment.d1_flush.is_none());
let latency = serde_json::to_value(&attachment.latency_aggregates).unwrap();
assert_eq!(latency["v"]["task-a"][0].as_u64(), Some(2));
+11 -8
View File
@@ -82,14 +82,23 @@ pub struct SettingsInput {
pub turnstile_site_key: Option<String>,
pub turnstile_secret_key: Option<String>,
pub notification_enabled: Option<bool>,
pub notification_endpoint: Option<String>,
pub notification_target: Option<String>,
pub offline_alert_minutes: Option<i64>,
pub expiry_alert_days: Option<i64>,
pub traffic_alert_percentage: Option<i64>,
pub cloudflare_account_id: Option<String>,
pub cloudflare_api_token: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TelegramNotificationInput {
pub bot_token: String,
pub chat_id: String,
#[serde(default)]
pub message_thread_id: Option<i64>,
pub template: String,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ThemeInput {
@@ -176,12 +185,6 @@ pub struct AgentGpuMetric {
pub memory_total: i64,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AgentReportBatch {
pub samples: Vec<AgentReport>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct AgentReport {
+753 -124
View File
@@ -1,65 +1,607 @@
use std::collections::{HashMap, HashSet};
use std::time::Duration;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use worker::{
console_warn, wasm_bindgen::JsValue, D1Database, Env, Fetch, Method, Request, RequestInit,
Result,
console_warn, wasm_bindgen::JsValue, D1Database, Headers, Method, Request, RequestInit, Result,
};
use crate::db::{self, SettingsView};
use crate::auth::sha256_hex;
use crate::db::{self, SettingsView, SECRET_MASK};
use crate::live;
use crate::models::TelegramNotificationInput;
use crate::outbound::{fetch_with_timeout, read_response_limited};
#[derive(Default, Deserialize, Serialize, PartialEq, Eq)]
const NOTIFICATION_TIMEOUT: Duration = Duration::from_secs(8);
const NOTIFICATION_MAX_BYTES: usize = 64 * 1024;
const MAX_DELIVERY_ATTEMPTS: i64 = 6;
const DELIVERY_BATCH_SIZE: i64 = 5;
const TEMPLATE_KEYS: [&str; 7] = [
"event",
"title",
"server",
"message",
"time",
"value",
"threshold",
];
#[derive(Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
struct AlertState {
offline: HashSet<String>,
expiry: HashSet<String>,
resources: HashSet<String>,
traffic: HashMap<String, TrafficAlertState>,
}
pub fn valid_config(token: &str, chat_id: &str) -> bool {
let token = token.trim();
!chat_id.trim().is_empty()
&& token.split_once(':').is_some_and(|(bot_id, secret)| {
!bot_id.is_empty()
&& bot_id.chars().all(|value| value.is_ascii_digit())
&& secret.len() >= 20
&& secret
.chars()
.all(|value| value.is_ascii_alphanumeric() || matches!(value, '_' | '-'))
})
#[derive(Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
struct TrafficAlertState {
cycle_key: i64,
step: i64,
}
fn notification_request(settings: &SettingsView, message: &str) -> Result<(String, String)> {
let token = settings.notification_endpoint.trim();
let chat_id = settings.notification_target.trim();
if !valid_config(token, chat_id) {
return Err(worker::Error::RustError(
"请填写有效的 Telegram Bot Token 和 Chat ID".to_string(),
));
#[derive(Clone, Deserialize)]
struct TelegramSettingsRow {
bot_token: String,
chat_id: String,
message_thread_id: Option<i64>,
template: String,
}
#[derive(Serialize)]
pub struct TelegramSettingsView {
bot_token: String,
chat_id: String,
message_thread_id: Option<i64>,
template: String,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct TelegramConfig {
bot_token: String,
chat_id: String,
#[serde(default)]
message_thread_id: Option<i64>,
}
#[derive(Serialize)]
struct NewEvent {
id: String,
dedupe_key: String,
kind: String,
server_id: Option<String>,
server_name: String,
title: String,
message: String,
details: Value,
occurred_at: i64,
}
struct EventSource<'a> {
server_id: Option<&'a str>,
server_name: &'a str,
}
#[derive(Deserialize)]
struct PendingDeliveryRow {
event_id: String,
attempts: i64,
kind: String,
server_name: String,
title: String,
message: String,
details_json: String,
occurred_at: i64,
bot_token: String,
chat_id: String,
message_thread_id: Option<i64>,
template: String,
}
fn text(value: &str) -> JsValue {
JsValue::from_str(value)
}
fn number(value: impl ToString) -> JsValue {
JsValue::from_f64(value.to_string().parse::<f64>().unwrap_or(0.0))
}
fn event(
dedupe_key: String,
kind: &str,
source: EventSource<'_>,
title: String,
message: String,
details: Value,
occurred_at: i64,
) -> NewEvent {
NewEvent {
id: sha256_hex(&dedupe_key).chars().take(32).collect(),
dedupe_key,
kind: kind.to_string(),
server_id: source.server_id.map(str::to_string),
server_name: source.server_name.to_string(),
title,
message,
details,
occurred_at,
}
Ok((
format!("https://api.telegram.org/bot{token}/sendMessage"),
serde_json::json!({ "chat_id": chat_id, "text": message }).to_string(),
))
}
pub async fn send(settings: &SettingsView, message: &str) -> Result<()> {
let (url, body) = notification_request(settings, message)?;
fn valid_telegram_token(value: &str) -> bool {
let value = value.trim();
value.split_once(':').is_some_and(|(bot_id, secret)| {
!bot_id.is_empty()
&& bot_id.chars().all(|ch| ch.is_ascii_digit())
&& secret.len() >= 20
&& secret
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-'))
})
}
fn validate_template(template: &str) -> Option<String> {
if template.trim().is_empty() || template.chars().count() > 4000 {
return Some("通知模板长度应为 1 至 4000 个字符".to_string());
}
let mut rest = template;
while let Some(start) = rest.find("{{") {
if rest[..start].contains("}}") {
return Some("通知模板占位符格式无效".to_string());
}
let after = &rest[start + 2..];
let Some(end) = after.find("}}") else {
return Some("通知模板占位符未闭合".to_string());
};
let placeholder = &after[..end];
let key = placeholder.trim();
if placeholder != key || !TEMPLATE_KEYS.contains(&key) {
return Some(format!("通知模板包含不支持的占位符:{{{{{key}}}}}"));
}
rest = &after[end + 2..];
}
if rest.contains("}}") {
return Some("通知模板占位符格式无效".to_string());
}
None
}
fn normalized_telegram(
input: &TelegramNotificationInput,
previous: Option<&TelegramSettingsRow>,
) -> std::result::Result<TelegramConfig, String> {
if let Some(message) = validate_template(&input.template) {
return Err(message);
}
let bot_token = if input.bot_token.trim() == SECRET_MASK {
let Some(previous) = previous else {
return Err("Telegram Bot Token 不能为空".to_string());
};
previous.bot_token.clone()
} else {
input.bot_token.trim().to_string()
};
let chat_id = input.chat_id.trim().to_string();
if !valid_telegram_token(&bot_token)
|| chat_id.is_empty()
|| chat_id.chars().count() > 128
|| input.message_thread_id.is_some_and(|value| value <= 0)
{
return Err("Telegram Bot Token、Chat ID 或话题 ID 格式无效".to_string());
}
Ok(TelegramConfig {
bot_token,
chat_id,
message_thread_id: input.message_thread_id,
})
}
async fn telegram_row(db_conn: &D1Database) -> Result<Option<TelegramSettingsRow>> {
db_conn
.prepare(
"SELECT bot_token, chat_id, message_thread_id, template \
FROM notification_telegram WHERE id=1",
)
.first(None)
.await
}
pub async fn telegram_settings(db_conn: &D1Database) -> Result<Option<TelegramSettingsView>> {
Ok(telegram_row(db_conn)
.await?
.map(|row| TelegramSettingsView {
bot_token: SECRET_MASK.to_string(),
chat_id: row.chat_id,
message_thread_id: row.message_thread_id,
template: row.template,
}))
}
pub async fn save_telegram(
db_conn: &D1Database,
input: &TelegramNotificationInput,
timestamp: i64,
) -> Result<std::result::Result<(), String>> {
let previous = telegram_row(db_conn).await?;
let config = match normalized_telegram(input, previous.as_ref()) {
Ok(value) => value,
Err(message) => return Ok(Err(message)),
};
db_conn
.prepare(
"INSERT INTO notification_telegram( \
id, bot_token, chat_id, message_thread_id, template, updated_at \
) VALUES (1, ?1, ?2, ?3, ?4, ?5) \
ON CONFLICT(id) DO UPDATE SET bot_token=excluded.bot_token, chat_id=excluded.chat_id, \
message_thread_id=excluded.message_thread_id, template=excluded.template, \
updated_at=excluded.updated_at",
)
.bind(&[
text(&config.bot_token),
text(&config.chat_id),
config
.message_thread_id
.map(number)
.unwrap_or(JsValue::NULL),
text(input.template.trim()),
number(timestamp),
])?
.run()
.await?;
Ok(Ok(()))
}
fn value_text(value: &Value) -> String {
value
.as_str()
.map(str::to_string)
.unwrap_or_else(|| value.to_string())
}
fn template_values(
kind: &str,
title: &str,
server: &str,
message: &str,
time: &str,
details: &Value,
) -> Vec<(&'static str, String)> {
vec![
("event", kind.to_string()),
("title", title.to_string()),
("server", server.to_string()),
("message", message.to_string()),
("time", time.to_string()),
(
"value",
details.get("value").map(value_text).unwrap_or_default(),
),
(
"threshold",
details.get("threshold").map(value_text).unwrap_or_default(),
),
]
}
fn render_template(template: &str, values: &[(&str, String)]) -> String {
let mut output = String::with_capacity(template.len());
let mut rest = template;
while let Some(start) = rest.find("{{") {
output.push_str(&rest[..start]);
let after = &rest[start + 2..];
let Some(end) = after.find("}}") else {
output.push_str(&rest[start..]);
return output;
};
let key = &after[..end];
if let Some((_, value)) = values.iter().find(|(candidate, _)| *candidate == key) {
output.push_str(value);
} else {
output.push_str(&rest[start..start + end + 4]);
}
rest = &after[end + 2..];
}
output.push_str(rest);
output
}
fn iso_time(timestamp: i64) -> String {
let value = worker::js_sys::Date::new(&JsValue::from_f64(timestamp as f64 * 1000.0));
value
.to_iso_string()
.as_string()
.unwrap_or_else(|| timestamp.to_string())
}
async fn send_request(url: &str, headers: Headers, body: &str) -> Result<()> {
let mut init = RequestInit::new();
init.with_method(Method::Post)
.with_body(Some(JsValue::from_str(&body)));
let request = Request::new_with_init(&url, &init)?;
request.headers().set("Content-Type", "application/json")?;
let response = Fetch::Request(request).send().await?;
if !(200..300).contains(&response.status_code()) {
.with_headers(headers)
.with_body(Some(JsValue::from_str(body)));
let request = Request::new_with_init(url, &init)
.map_err(|_| worker::Error::RustError("通知服务请求无效".to_string()))?;
let Some(mut response) = fetch_with_timeout(request, NOTIFICATION_TIMEOUT)
.await
.map_err(|_| worker::Error::RustError("通知服务请求失败".to_string()))?
else {
return Err(worker::Error::RustError("通知服务请求超时".to_string()));
};
let status = response.status_code();
read_response_limited(&mut response, NOTIFICATION_MAX_BYTES)
.await?
.ok_or_else(|| worker::Error::RustError("通知服务响应体大小无效".to_string()))?;
if !(200..300).contains(&status) {
return Err(worker::Error::RustError(format!(
"通知服务返回 HTTP {}",
response.status_code()
"通知服务返回 HTTP {status}"
)));
}
Ok(())
}
async fn send_delivery(row: &PendingDeliveryRow) -> Result<()> {
let details = serde_json::from_str::<Value>(&row.details_json).unwrap_or_else(|_| json!({}));
let occurred_at = iso_time(row.occurred_at);
let values = template_values(
&row.kind,
&row.title,
&row.server_name,
&row.message,
&occurred_at,
&details,
);
let rendered_message = render_template(&row.template, &values);
let headers = Headers::new();
headers.set("Content-Type", "application/json")?;
let mut body = json!({ "chat_id": row.chat_id, "text": rendered_message });
if let Some(message_thread_id) = row.message_thread_id {
body["message_thread_id"] = json!(message_thread_id);
}
send_request(
&format!("https://api.telegram.org/bot{}/sendMessage", row.bot_token),
headers,
&body.to_string(),
)
.await
}
fn retry_delay(attempts: i64) -> i64 {
match attempts {
0 | 1 => 60,
2 => 300,
3 => 900,
_ => 3600,
}
}
async fn attempt_delivery(
db_conn: &D1Database,
row: &PendingDeliveryRow,
timestamp: i64,
) -> Result<Option<String>> {
let attempts = row.attempts + 1;
match send_delivery(row).await {
Ok(()) => {
db_conn
.prepare(
"UPDATE notification_deliveries SET status='sent', attempts=?2, sent_at=?3, \
last_error=NULL, updated_at=?3 WHERE event_id=?1",
)
.bind(&[text(&row.event_id), number(attempts), number(timestamp)])?
.run()
.await?;
Ok(None)
}
Err(error) => {
let message = error.to_string().chars().take(300).collect::<String>();
let status = if attempts >= MAX_DELIVERY_ATTEMPTS {
"dead"
} else {
"failed"
};
db_conn
.prepare(
"UPDATE notification_deliveries SET status=?2, attempts=?3, next_attempt_at=?4, \
last_error=?5, updated_at=?6 WHERE event_id=?1",
)
.bind(&[
text(&row.event_id),
text(status),
number(attempts),
number(timestamp.saturating_add(retry_delay(attempts))),
text(&message),
number(timestamp),
])?
.run()
.await?;
Ok(Some(message))
}
}
}
const DELIVERY_SELECT: &str =
"SELECT d.event_id, d.attempts, e.kind, e.server_name, e.title, e.message, \
e.details_json, e.occurred_at, t.bot_token, t.chat_id, t.message_thread_id, t.template \
FROM notification_deliveries d \
JOIN notification_events e ON e.id=d.event_id \
JOIN notification_telegram t ON t.id=1";
pub async fn process_pending(db_conn: &D1Database, timestamp: i64) -> Result<usize> {
let rows: Vec<PendingDeliveryRow> = db_conn
.prepare(format!(
"{DELIVERY_SELECT} WHERE d.status IN ('pending', 'failed') AND d.next_attempt_at<=?1 \
ORDER BY d.created_at ASC LIMIT ?2"
))
.bind(&[number(timestamp), number(DELIVERY_BATCH_SIZE)])?
.all()
.await?
.results()?;
let count = rows.len();
for row in rows {
if let Some(message) = attempt_delivery(db_conn, &row, timestamp).await? {
console_warn!("telegram notification delivery failed: {message}");
}
}
Ok(count)
}
pub async fn test_telegram(
db_conn: &D1Database,
test_id: &str,
timestamp: i64,
) -> Result<std::result::Result<(), String>> {
if telegram_row(db_conn).await?.is_none() {
return Ok(Err("尚未保存 Telegram 配置".to_string()));
};
let test_event = event(
format!("test:telegram:{test_id}"),
"test",
EventSource {
server_id: None,
server_name: "NodeFlare",
},
"NodeFlare 测试通知".to_string(),
"Telegram 配置成功,事件投递链路工作正常。".to_string(),
json!({}),
timestamp,
);
let event_insert = db_conn
.prepare(
"INSERT INTO notification_events( \
id, dedupe_key, kind, server_id, server_name, title, message, details_json, occurred_at, created_at \
) VALUES (?1, ?2, 'test', NULL, ?3, ?4, ?5, '{}', ?6, ?6)",
)
.bind(&[
text(&test_event.id),
text(&test_event.dedupe_key),
text(&test_event.server_name),
text(&test_event.title),
text(&test_event.message),
number(timestamp),
])?;
let delivery_insert = db_conn
.prepare(
"INSERT INTO notification_deliveries( \
event_id, status, attempts, next_attempt_at, created_at, updated_at \
) VALUES (?1, 'pending', 0, ?2, ?2, ?2)",
)
.bind(&[text(&test_event.id), number(timestamp)])?;
db_conn.batch(vec![event_insert, delivery_insert]).await?;
let row = db_conn
.prepare(format!("{DELIVERY_SELECT} WHERE d.event_id=?1"))
.bind(&[text(&test_event.id)])?
.first::<PendingDeliveryRow>(None)
.await?
.ok_or_else(|| worker::Error::RustError("测试投递记录创建失败".to_string()))?;
match attempt_delivery(db_conn, &row, timestamp).await? {
Some(message) => Ok(Err(message)),
None => Ok(Ok(())),
}
}
async fn has_telegram(db_conn: &D1Database) -> Result<bool> {
Ok(db_conn
.prepare("SELECT 1 AS found FROM notification_telegram WHERE id=1")
.first::<i64>(Some("found"))
.await?
.is_some())
}
async fn notification_state(db_conn: &D1Database) -> Result<AlertState> {
let raw = db_conn
.prepare("SELECT value FROM notification_state WHERE id=1")
.first::<String>(Some("value"))
.await?
.unwrap_or_else(|| "{}".to_string());
Ok(serde_json::from_str(&raw).unwrap_or_default())
}
async fn persist_events_and_state(
db_conn: &D1Database,
events: &[NewEvent],
state: &AlertState,
timestamp: i64,
) -> Result<()> {
let state_json = serde_json::to_string(state)?;
let state_update = db_conn
.prepare("UPDATE notification_state SET value=?1, updated_at=?2 WHERE id=1")
.bind(&[text(&state_json), number(timestamp)])?;
if events.is_empty() {
state_update.run().await?;
return Ok(());
}
let events_json = serde_json::to_string(events)?;
let event_insert = db_conn
.prepare(
"INSERT OR IGNORE INTO notification_events( \
id, dedupe_key, kind, server_id, server_name, title, message, details_json, occurred_at, created_at \
) SELECT json_extract(value, '$.id'), json_extract(value, '$.dedupe_key'), \
json_extract(value, '$.kind'), json_extract(value, '$.server_id'), \
json_extract(value, '$.server_name'), json_extract(value, '$.title'), \
json_extract(value, '$.message'), json_extract(value, '$.details'), \
json_extract(value, '$.occurred_at'), ?2 FROM json_each(?1)",
)
.bind(&[text(&events_json), number(timestamp)])?;
let delivery_insert = db_conn
.prepare(
"INSERT OR IGNORE INTO notification_deliveries( \
event_id, status, attempts, next_attempt_at, created_at, updated_at \
) SELECT e.id, 'pending', 0, ?2, ?2, ?2 \
FROM json_each(?1) j JOIN notification_events e ON e.id=json_extract(j.value, '$.id') \
WHERE EXISTS (SELECT 1 FROM notification_telegram WHERE id=1)",
)
.bind(&[text(&events_json), number(timestamp)])?;
db_conn
.batch(vec![event_insert, delivery_insert, state_update])
.await?;
Ok(())
}
fn traffic_used(server: &crate::models::ServerView) -> Option<i64> {
let rx = server.net_rx_total?;
let tx = server.net_tx_total?;
Some(match server.traffic_limit_type.as_str() {
"max" => rx.max(tx),
"min" => rx.min(tx),
"up" => tx,
"down" => rx,
_ => rx.saturating_add(tx),
})
}
fn traffic_step(used: i64, limit: i64, threshold: i64) -> i64 {
if limit <= 0 {
return 0;
}
let percent = ((i128::from(used.max(0)) * 100) / i128::from(limit)).min(100) as i64;
if percent < threshold {
0
} else {
threshold + ((percent - threshold) / 5) * 5
}
}
fn gibibytes(value: i64) -> String {
format!("{:.2} GiB", value.max(0) as f64 / 1024_f64.powi(3))
}
pub async fn renew_servers(db_conn: &D1Database) -> Result<()> {
let current_time = crate::now();
for server in db::list_servers(db_conn, true).await? {
if server.auto_renewal == 0 {
continue;
}
let Some(expires_at) = server.expires_at else {
continue;
};
if let Some(next_expiry) = renewed_expiry(expires_at, server.billing_cycle, current_time) {
db::update_expiry(db_conn, &server.id, next_expiry).await?;
}
}
Ok(())
}
fn renewed_expiry(expires_at: i64, billing_cycle_days: i64, current_time: i64) -> Option<i64> {
let renew_before = i128::from(current_time) + 86_400;
let expires_at = i128::from(expires_at);
@@ -72,72 +614,113 @@ fn renewed_expiry(expires_at: i64, billing_cycle_days: i64, current_time: i64) -
Some((expires_at + cycles * cycle).min(i128::from(i64::MAX)) as i64)
}
pub async fn renew_servers(db_conn: &D1Database) -> Result<()> {
let current_time = worker::Date::now().as_millis() as i64 / 1000;
for server in db::list_servers(db_conn, true).await? {
if server.auto_renewal == 0 {
continue;
}
let Some(expires_at) = server.expires_at else {
continue;
};
if let Some(next_expiry) = renewed_expiry(expires_at, server.billing_cycle, current_time) {
db::update_expiry(db_conn, &server.id, next_expiry).await?;
}
}
Ok(())
}
pub async fn check_alerts(db_conn: &D1Database, env: &Env, settings: &SettingsView) -> Result<()> {
if !settings.notification_enabled || settings.notification_endpoint.trim().is_empty() {
pub async fn check_alerts(
db_conn: &D1Database,
env: &worker::Env,
settings: &SettingsView,
) -> Result<()> {
if !settings.notification_enabled || !has_telegram(db_conn).await? {
return Ok(());
}
let servers = db::list_servers(db_conn, true).await?;
let stored = db::get_setting(db_conn, "alert_state").await?;
let previous: AlertState = stored
.as_deref()
.and_then(|value| serde_json::from_str(value).ok())
.unwrap_or_default();
let previous = notification_state(db_conn).await?;
let mut current = AlertState::default();
let current_time = worker::Date::now().as_millis() as i64 / 1000;
let current_time = crate::now();
let offline_after = settings.offline_alert_minutes.clamp(2, 1440) * 60;
let mut messages = Vec::new();
let mut events = Vec::new();
for server in &servers {
let server_id = server.id.clone();
let is_offline = server
.timestamp
.map(|timestamp| current_time - timestamp > offline_after)
.unwrap_or(current_time - server.created_at > offline_after);
let source_timestamp = server.timestamp.unwrap_or(server.created_at);
let is_offline = current_time.saturating_sub(source_timestamp) > offline_after;
if server.offline_notify_disabled == 0 && is_offline {
current.offline.insert(server_id.clone());
if !previous.offline.contains(&server_id) {
messages.push(format!(
"[离线] {} 已超过 {} 分钟未上报",
server.name, settings.offline_alert_minutes
current.offline.insert(server.id.clone());
if !previous.offline.contains(&server.id) {
events.push(event(
format!("offline:{}:{source_timestamp}", server.id),
"offline",
EventSource {
server_id: Some(&server.id),
server_name: &server.name,
},
"服务器离线".to_string(),
format!(
"已超过 {} 分钟未收到状态上报。",
settings.offline_alert_minutes
),
json!({ "threshold": settings.offline_alert_minutes }),
current_time,
));
}
} else if previous.offline.contains(&server_id) {
messages.push(format!("[恢复] {} 已恢复上报", server.name));
} else if previous.offline.contains(&server.id) {
events.push(event(
format!("online:{}:{source_timestamp}", server.id),
"online",
EventSource {
server_id: Some(&server.id),
server_name: &server.name,
},
"服务器恢复".to_string(),
"状态上报已恢复。".to_string(),
json!({}),
current_time,
));
}
if settings.expiry_alert_days > 0 {
if let Some(value) = server.expires_at {
let days = (value - current_time + 86_399) / 86_400;
if let Some(expires_at) = server.expires_at {
let days = (expires_at - current_time + 86_399) / 86_400;
if days >= 0 && days <= settings.expiry_alert_days {
let key = format!("{server_id}:{value}");
let key = format!("{}:{expires_at}", server.id);
current.expiry.insert(key.clone());
if !previous.expiry.contains(&key) {
messages.push(format!("[到期] {} 剩余 {} 天", server.name, days));
events.push(event(
format!("expiry:{key}"),
"expiry",
EventSource {
server_id: Some(&server.id),
server_name: &server.name,
},
"服务器即将到期".to_string(),
format!("剩余 {days} 天到期。"),
json!({ "value": days, "threshold": settings.expiry_alert_days }),
current_time,
));
}
}
}
}
if server.traffic_limit > 0 {
if let Some(used) = traffic_used(server) {
let cycle_key = db::traffic_cycle_key(current_time, server.reset_day);
let step = traffic_step(
used,
server.traffic_limit,
settings.traffic_alert_percentage,
);
current
.traffic
.insert(server.id.clone(), TrafficAlertState { cycle_key, step });
let previous_traffic = previous.traffic.get(&server.id);
if step >= settings.traffic_alert_percentage
&& previous_traffic
.is_none_or(|state| state.cycle_key != cycle_key || step > state.step)
{
events.push(event(
format!("traffic:{}:{cycle_key}:{step}", server.id),
"traffic",
EventSource {
server_id: Some(&server.id),
server_name: &server.name,
},
"流量限额提醒".to_string(),
format!("本周期已使用 {} / {}({}%)。", gibibytes(used), gibibytes(server.traffic_limit), step),
json!({ "value": step, "threshold": settings.traffic_alert_percentage, "used_bytes": used, "limit_bytes": server.traffic_limit }),
current_time,
));
}
}
}
}
let previous_resources = db::active_alert_states(db_conn).await?;
let mut current_resources = HashSet::new();
let mut evaluated_resources = HashSet::new();
let mut eligible_resources = HashSet::new();
let rules = db::list_alert_rules(db_conn).await?;
@@ -173,19 +756,14 @@ pub async fn check_alerts(db_conn: &D1Database, env: &Env, settings: &SettingsVi
.map(|server_id| format!("{}:{server_id}", rule.id)),
);
}
let metric_label = match rule.metric.as_str() {
"cpu" => "CPU",
"memory" => "内存",
"disk" => "磁盘",
"net_in" => "下行",
"net_out" => "上行",
let (metric_label, unit) = match rule.metric.as_str() {
"cpu" => ("CPU", "%"),
"memory" => ("内存", "%"),
"disk" => ("磁盘", "%"),
"net_in" => ("下行", "MiB/s"),
"net_out" => ("上行", "MiB/s"),
_ => continue,
};
let unit = if matches!(rule.metric.as_str(), "net_in" | "net_out") {
"MiB/s"
} else {
"%"
};
for value in values_by_rule.remove(&rule.id).unwrap_or_default() {
if !db::alert_window_covered(&value, rule.duration_minutes, current_time) {
continue;
@@ -193,56 +771,107 @@ pub async fn check_alerts(db_conn: &D1Database, env: &Env, settings: &SettingsVi
let key = format!("{}:{}", rule.id, value.server_id);
evaluated_resources.insert(key.clone());
if value.value >= rule.threshold {
current_resources.insert(key.clone());
if !previous_resources.contains(&key) {
messages.push(format!(
"[资源] {} · {}:{} {:.1}{} >= {:.1}{}({} 分钟{})",
value.name,
rule.name,
metric_label,
value.value,
unit,
rule.threshold,
unit,
rule.duration_minutes,
if rule.aggregation == "continuous" {
"持续"
} else {
"平均"
current.resources.insert(key.clone());
if !previous.resources.contains(&key) {
events.push(event(
format!("resource:{}:{}:{}:alert", rule.id, value.server_id, value.last_timestamp),
"resource_alert",
EventSource {
server_id: Some(&value.server_id),
server_name: &value.name,
},
format!("资源告警:{}", rule.name),
format!("{} {:.1}{unit},阈值 {:.1}{unit},{} 分钟{}。", metric_label, value.value, rule.threshold, rule.duration_minutes, if rule.aggregation == "continuous" { "持续超限" } else { "窗口平均" }),
json!({ "value": value.value, "threshold": rule.threshold, "rule": rule.name, "metric": rule.metric }),
current_time,
));
}
} else if previous_resources.contains(&key) {
messages.push(format!("[恢复] {} · {} 已恢复正常", value.name, rule.name));
} else if previous.resources.contains(&key) {
events.push(event(
format!("resource:{}:{}:{}:recovery", rule.id, value.server_id, value.last_timestamp),
"resource_recovery",
EventSource {
server_id: Some(&value.server_id),
server_name: &value.name,
},
format!("资源恢复:{}", rule.name),
format!("{metric_label} 已恢复到阈值以内。"),
json!({ "value": value.value, "threshold": rule.threshold, "rule": rule.name, "metric": rule.metric }),
current_time,
));
}
}
}
for key in &previous_resources {
for key in &previous.resources {
if eligible_resources.contains(key) && !evaluated_resources.contains(key) {
current_resources.insert(key.clone());
current.resources.insert(key.clone());
}
}
if previous != current || !events.is_empty() {
persist_events_and_state(db_conn, &events, &current, current_time).await?;
}
Ok(())
}
if !messages.is_empty() {
let body = format!("NodeFlare 告警\n\n{}", messages.join("\n"));
send(settings, &body).await?;
}
if previous != current {
db::save_setting(db_conn, "alert_state", &serde_json::to_string(&current)?).await?;
}
db::sync_active_alert_states(db_conn, &previous_resources, &current_resources).await
pub async fn cleanup(db_conn: &D1Database, timestamp: i64) -> Result<()> {
let cutoff = timestamp.saturating_sub(30 * 86_400);
db_conn
.prepare("DELETE FROM notification_events WHERE created_at<?1")
.bind(&[number(cutoff)])?
.run()
.await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::renewed_expiry;
use super::{render_template, renewed_expiry, retry_delay, traffic_step, validate_template};
#[test]
fn advances_expired_servers_without_looping() {
fn renders_whitelisted_templates() {
let values = [
("title", "离线".to_string()),
("message", "超时".to_string()),
];
assert_eq!(
render_template("{{title}}: {{message}}", &values),
"离线: 超时"
);
assert!(validate_template("{{unknown}}").is_some());
assert!(validate_template("{{ title }}").is_some());
assert_eq!(
render_template(
"{{server}}",
&[
("server", "{{title}}".to_string()),
("title", "告警".to_string())
]
),
"{{title}}"
);
}
#[test]
fn traffic_steps_and_retry_backoff_are_bounded() {
assert_eq!(traffic_step(79, 100, 80), 0);
assert_eq!(traffic_step(80, 100, 80), 80);
assert_eq!(traffic_step(89, 100, 80), 85);
assert_eq!(traffic_step(120, 100, 80), 100);
assert_eq!(
[
retry_delay(1),
retry_delay(2),
retry_delay(3),
retry_delay(4)
],
[60, 300, 900, 3600]
);
}
#[test]
fn expiry_renewal_skips_future_dates() {
let day = 86_400;
assert_eq!(renewed_expiry(80 * day, 30, 100 * day), Some(110 * day));
assert_eq!(renewed_expiry(101 * day, 30, 100 * day), Some(131 * day));
assert_eq!(renewed_expiry(102 * day, 30, 100 * day), None);
assert_eq!(renewed_expiry(i64::MIN, 1, i64::MAX), Some(i64::MAX));
}
}
+52 -2
View File
@@ -1,7 +1,10 @@
use std::time::Duration;
use futures_util::{future::select, pin_mut};
use worker::{AbortController, Delay, Fetch, Request, Response, Result};
use futures_util::{future::select, pin_mut, TryStreamExt};
use serde::de::DeserializeOwned;
use worker::{AbortController, Delay, Error, Fetch, Request, Response, Result};
const RESPONSE_BODY_TIMEOUT: Duration = Duration::from_secs(15);
pub async fn fetch_with_timeout(request: Request, timeout: Duration) -> Result<Option<Response>> {
let controller = AbortController::default();
@@ -19,3 +22,50 @@ pub async fn fetch_with_timeout(request: Request, timeout: Duration) -> Result<O
}
}
}
pub async fn read_response_limited(
response: &mut Response,
limit: usize,
) -> Result<Option<Vec<u8>>> {
let read = async {
let declared = response
.headers()
.get("Content-Length")?
.and_then(|value| value.parse::<usize>().ok());
if declared.is_some_and(|length| length == 0 || length > limit) {
return Ok(None);
};
let mut body = Vec::with_capacity(declared.unwrap_or(0).min(limit));
let mut stream = response.stream()?;
while let Some(mut chunk) = stream.try_next().await? {
let Some(length) = body.len().checked_add(chunk.len()) else {
return Ok(None);
};
if length > limit {
return Ok(None);
}
body.append(&mut chunk);
}
Ok((!body.is_empty()).then_some(body))
};
let timeout = Delay::from(RESPONSE_BODY_TIMEOUT);
pin_mut!(read, timeout);
match select(read, timeout).await {
futures_util::future::Either::Left((body, _)) => body,
futures_util::future::Either::Right(((), _)) => Err(Error::RustError(
"upstream response body timed out".to_string(),
)),
}
}
pub async fn read_json_limited<T: DeserializeOwned>(
response: &mut Response,
limit: usize,
) -> Result<T> {
let body = read_response_limited(response, limit)
.await?
.ok_or_else(|| Error::RustError("upstream response body is empty or too large".into()))?;
serde_json::from_slice(&body)
.map_err(|error| Error::RustError(format!("upstream returned invalid JSON: {error}")))
}
+61 -26
View File
@@ -65,6 +65,19 @@ pub(crate) async fn route(mut req: Request, ctx: &RouteContext) -> Result<RouteO
return handled(alert_rules_patch_delete(&mut req, ctx, &method, &path).await);
}
if method == Method::Get && path == "/api/admin/telegram" {
return handled(json(
&serde_json::json!({ "telegram": notify::telegram_settings(&ctx.database).await? }),
200,
));
}
if method == Method::Put && path == "/api/admin/telegram" {
return handled(telegram_put(&mut req, ctx).await);
}
if method == Method::Post && path == "/api/admin/telegram/test" {
return handled(telegram_test(ctx).await);
}
if method == Method::Get && path == "/api/admin/servers" {
let servers = db::list_servers(&ctx.database, true).await?;
return handled(json(&serde_json::json!({ "servers": servers }), 200));
@@ -140,10 +153,6 @@ pub(crate) async fn route(mut req: Request, ctx: &RouteContext) -> Result<RouteO
db::increment_setting(&ctx.database, "history_cache_version").await?;
return handled(no_content());
}
if method == Method::Post && path == "/api/admin/notifications/test" {
return handled(notifications_test(ctx).await);
}
Ok(RouteOutcome::Unmatched(req))
}
@@ -170,6 +179,15 @@ fn short_durable_id(ctx: &RouteContext) -> Result<String> {
.collect::<String>())
}
fn unique_admin_id(ctx: &RouteContext, namespace: &str) -> Result<String> {
let source = format!(
"{namespace}:{}:{}",
short_durable_id(ctx)?,
worker::Date::now().as_millis()
);
Ok(sha256_hex(&source).chars().take(24).collect())
}
async fn latency_tasks_post(req: &mut Request, ctx: &RouteContext) -> Result<Response> {
let input: LatencyTaskInput = match request_json(req, API_JSON_MAX_BYTES).await {
Ok(value) => value,
@@ -185,8 +203,12 @@ async fn latency_tasks_post(req: &mut Request, ctx: &RouteContext) -> Result<Res
if input.server_ids.iter().any(|id| !server_ids.contains(id)) {
return error("延迟任务包含不存在的服务器", 400);
}
let id = short_durable_id(ctx)?;
let assigned_server_ids = input.server_ids.clone();
let id = unique_admin_id(ctx, "latency-task")?;
latency::create_task(&ctx.database, &id, &input, now()).await?;
if let Err(error) = live::disconnect_agents(&ctx.env, &assigned_server_ids).await {
console_error!("failed to refresh Agents after latency task creation: {error}");
}
db::increment_setting(&ctx.database, "history_cache_version").await?;
json(&serde_json::json!({ "id": id }), 201)
}
@@ -201,7 +223,11 @@ async fn latency_tasks_patch_delete(
return error("延迟任务 ID 无效", 400);
};
if *method == Method::Delete {
let assigned_server_ids = latency::server_ids_for_task(&ctx.database, &id).await?;
return if latency::delete_task(&ctx.database, &id).await? {
if let Err(error) = live::disconnect_agents(&ctx.env, &assigned_server_ids).await {
console_error!("failed to refresh Agents after latency task deletion: {error}");
}
db::increment_setting(&ctx.database, "history_cache_version").await?;
no_content()
} else {
@@ -223,7 +249,14 @@ async fn latency_tasks_patch_delete(
{
return error("延迟任务包含不存在的服务器", 400);
}
let mut affected_server_ids = latency::server_ids_for_task(&ctx.database, &id).await?;
affected_server_ids.extend(input.server_ids.iter().cloned());
affected_server_ids.sort();
affected_server_ids.dedup();
if latency::update_task(&ctx.database, &id, &input, now()).await? {
if let Err(error) = live::disconnect_agents(&ctx.env, &affected_server_ids).await {
console_error!("failed to refresh Agents after latency task update: {error}");
}
db::increment_setting(&ctx.database, "history_cache_version").await?;
no_content()
} else {
@@ -578,16 +611,29 @@ async fn cloudflare_usage(ctx: &RouteContext) -> Result<Response> {
}
}
async fn notifications_test(ctx: &RouteContext) -> Result<Response> {
if ctx.settings.notification_endpoint.trim().is_empty() {
return error("请先填写 Telegram Bot Token 和 Chat ID", 400);
async fn telegram_put(req: &mut Request, ctx: &RouteContext) -> Result<Response> {
let input: crate::models::TelegramNotificationInput =
match request_json(req, API_JSON_MAX_BYTES).await {
Ok(value) => value,
Err(_) => return error("Telegram 配置格式无效", 400),
};
match notify::save_telegram(&ctx.database, &input, now()).await? {
Ok(()) => no_content(),
Err(message) => error(&message, 400),
}
if let Err(err) = notify::send(&ctx.settings, "NodeFlare 测试通知:通知渠道配置成功。").await
}
async fn telegram_test(ctx: &RouteContext) -> Result<Response> {
match notify::test_telegram(
&ctx.database,
&unique_admin_id(ctx, "notification-test")?,
now(),
)
.await?
{
console_error!("test notification failed: {err}");
return error("测试通知发送失败,请检查 Bot Token 和 Chat ID", 502);
Ok(()) => no_content(),
Err(message) => error(&format!("Telegram 测试消息发送失败:{message}"), 502),
}
no_content()
}
async fn settings_patch(req: &mut Request, ctx: &RouteContext) -> Result<Response> {
@@ -717,21 +763,10 @@ async fn settings_patch(req: &mut Request, ctx: &RouteContext) -> Result<Respons
return error("启用 Turnstile 前必须填写站点密钥和私钥", 400);
}
if input
.notification_enabled
.unwrap_or(settings.notification_enabled)
.traffic_alert_percentage
.is_some_and(|value| !(50..=100).contains(&value))
{
let token = submitted_secret(
input.notification_endpoint.as_deref(),
&settings.notification_endpoint,
);
let chat_id = input
.notification_target
.as_deref()
.unwrap_or(&settings.notification_target)
.trim();
if !notify::valid_config(token, chat_id) {
return error("Telegram Bot Token 或 Chat ID 格式无效", 400);
}
return error("流量提醒起始阈值应为 50 至 100", 400);
}
if input
.cloudflare_account_id
+3 -129
View File
@@ -1,139 +1,13 @@
use std::net::IpAddr;
use worker::{Context, D1Database, Env, Request, Response, Result};
use worker::{D1Database, Env, Request, Response, Result};
use crate::auth::bearer_token;
use crate::db;
use crate::latency;
use crate::live;
use crate::models::AgentReportBatch;
use crate::sha256_hex;
use crate::{
client_ip, error, no_content, now, public_server, request_json, validate_report,
AGENT_JSON_MAX_BYTES, MAX_AGENT_LATENCY_RESULTS, MAX_AGENT_SAMPLES,
};
use crate::{client_ip, error};
async fn config_response(
database: &D1Database,
server_id: &str,
agent_config_hash: &str,
received_at: i64,
changed_status: u16,
) -> Result<Response> {
let Some(config) = db::agent_config(database, server_id).await? else {
return error("节点不存在", 404);
};
let config_json = serde_json::to_string(&config)?;
let config_hash = sha256_hex(&config_json);
let mut response = if agent_config_hash == config_hash {
no_content()?
} else {
crate::json(&config, changed_status)?
};
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")?;
Ok(response)
}
pub(crate) async fn report(
mut req: Request,
env: Env,
ctx: Context,
database: &D1Database,
) -> Result<Response> {
let agent_config_hash = req
.headers()
.get("X-Agent-Config-Sha256")?
.unwrap_or_default();
let token = match bearer_token(&req) {
Some(value) => value,
None => return error("缺少 Agent Token", 401),
};
let batch: AgentReportBatch = match request_json(&mut req, AGENT_JSON_MAX_BYTES).await {
Ok(value) => value,
Err(_) => return error("指标格式无效", 400),
};
if batch.samples.is_empty() || batch.samples.len() > MAX_AGENT_SAMPLES {
return error("每批应包含 1 至 720 个样本", 400);
}
let received_at = now();
for report in &batch.samples {
if report.timestamp <= 0 || (report.timestamp - received_at).abs() > 7200 {
return error("样本时间戳超出允许范围", 400);
}
if let Some(message) = validate_report(report) {
return error(message, 400);
}
}
let Some(identity) = db::get_agent_identity(database, &token).await? else {
return error("Agent Token 无效", 401);
};
let server_id = identity.id;
if let Some(ip) = client_ip(&req).and_then(|value| value.parse::<IpAddr>().ok()) {
db::update_last_ip(database, &server_id, &ip.to_string()).await?;
}
let latency_result_count = batch
.samples
.iter()
.flat_map(|report| report.latency_results.iter())
.count();
if latency_result_count > MAX_AGENT_LATENCY_RESULTS {
return error("每批最多包含 4096 条延迟结果", 400);
}
let alert_point = db::save_reports(database, &server_id, &batch.samples).await?;
if let Some(point) = alert_point.as_ref() {
if let Err(error) = live::record_alert_sample(&env, &server_id, point).await {
worker::console_warn!("resource alert sample write failed: {error:?}");
}
}
if identity.hidden == 0 {
let server = db::get_server(database, &server_id, false).await?;
let samples = latency::latest_for_server(database, &server_id).await?;
let payload = server
.map(|server| public_server(server, samples))
.map(|server| {
serde_json::to_string(&serde_json::json!({
"type": "server",
"server": server
}))
})
.transpose()?;
let broadcast_server_id = server_id.clone();
if let Some(payload) = payload {
ctx.wait_until(async move {
let _ = live::broadcast(&env, &broadcast_server_id, &payload).await;
});
}
}
config_response(database, &server_id, &agent_config_hash, received_at, 202).await
}
pub(crate) async fn config(req: &Request, database: &D1Database) -> Result<Response> {
let agent_config_hash = req
.headers()
.get("X-Agent-Config-Sha256")?
.unwrap_or_default();
let token = match bearer_token(req) {
Some(value) => value,
None => return error("缺少 Agent Token", 401),
};
let Some(identity) = db::get_agent_identity(database, &token).await? else {
return error("Agent Token 无效", 401);
};
config_response(database, &identity.id, &agent_config_hash, now(), 200).await
}
pub(crate) async fn live_websocket(
req: Request,
env: &Env,
database: &D1Database,
) -> Result<Response> {
pub(crate) async fn websocket(req: Request, env: &Env, database: &D1Database) -> Result<Response> {
let token = match bearer_token(&req) {
Some(value) => value,
None => return error("缺少 Agent Token", 401),
+1 -1
View File
@@ -341,7 +341,7 @@ async fn latency(req: &Request, ctx: &RouteContext) -> Result<Response> {
if db::get_server(&ctx.database, &id, false).await?.is_none() {
return error("节点不存在", 404);
}
let hours = requested_hours(req, 24 * 365)?;
let hours = requested_hours(req, 24 * db::MAX_HISTORY_RETENTION_DAYS)?;
let cache_key = if ctx.settings.public_dashboard {
Some(history_cache_key(
&req.url()?,
+42 -31
View File
@@ -1,12 +1,16 @@
use futures_util::TryStreamExt;
use std::time::Duration;
use serde_json::Value;
use worker::{Error, Fetch, Method, Request, Response, Result, Url};
use worker::{Error, Method, Request, Result, Url};
use crate::outbound::{fetch_with_timeout, read_response_limited};
pub const BUILTIN_THEME_ID: &str = "builtin-nodeflare-glass";
pub const BUILTIN_THEME_NAME: &str = "NodeFlare Glass";
pub const INDEX_MAX_BYTES: usize = 4 * 1024 * 1024;
pub const ASSET_MAX_BYTES: usize = 16 * 1024 * 1024;
const SETTINGS_MAX_BYTES: usize = 64 * 1024;
const REMOTE_FETCH_TIMEOUT: Duration = Duration::from_secs(10);
pub struct ResolvedTheme {
pub source_url: String,
@@ -143,37 +147,13 @@ fn settings_url(base: &str) -> Option<String> {
raw_github_base(base).map(|base| remote_url(base.as_str(), "theme.json"))
}
pub async fn read_response_limited(
response: &mut Response,
limit: usize,
) -> Result<Option<Vec<u8>>> {
let declared = response
.headers()
.get("Content-Length")?
.and_then(|value| value.parse::<usize>().ok());
if declared.is_some_and(|length| length == 0 || length > limit) {
return Ok(None);
}
let mut body = Vec::with_capacity(declared.unwrap_or(0).min(limit));
let mut stream = response.stream()?;
while let Some(mut chunk) = stream.try_next().await? {
let Some(length) = body.len().checked_add(chunk.len()) else {
return Ok(None);
};
if length > limit {
return Ok(None);
}
body.append(&mut chunk);
}
Ok((!body.is_empty()).then_some(body))
}
pub async fn validate_remote(base: &str) -> Result<()> {
let index = index_url(base)
.ok_or_else(|| Error::RustError("主题资源地址不是 GitHub Raw 地址".to_string()))?;
let request = Request::new(&index, Method::Get)?;
let mut response = Fetch::Request(request).send().await?;
let Some(mut response) = fetch_with_timeout(request, REMOTE_FETCH_TIMEOUT).await? else {
return Err(Error::RustError("主题 index.html 请求超时".to_string()));
};
let status = response.status_code();
if !(200..300).contains(&status) {
return Err(Error::RustError(format!(
@@ -219,6 +199,33 @@ pub fn builtin_settings_schema() -> Value {
"label": "总览显示在线节点",
"type": "toggle",
"default": true
},
{
"key": "showCarrierLatency",
"label": "节点卡片分线路显示延迟",
"type": "toggle",
"default": false
},
{
"key": "telecomLatencyTask",
"label": "电信线路任务名称",
"type": "text",
"default": "",
"placeholder": "留空时按任务名称自动匹配"
},
{
"key": "mobileLatencyTask",
"label": "移动线路任务名称",
"type": "text",
"default": "",
"placeholder": "留空时按任务名称自动匹配"
},
{
"key": "unicomLatencyTask",
"label": "联通线路任务名称",
"type": "text",
"default": "",
"placeholder": "留空时按任务名称自动匹配"
}
]
})
@@ -323,7 +330,9 @@ pub async fn remote_settings_schema(base: &str) -> Result<Value> {
let url = settings_url(base)
.ok_or_else(|| Error::RustError("主题资源地址不是 GitHub Raw 地址".to_string()))?;
let request = Request::new(&url, Method::Get)?;
let mut response = Fetch::Request(request).send().await?;
let Some(mut response) = fetch_with_timeout(request, REMOTE_FETCH_TIMEOUT).await? else {
return Err(Error::RustError("主题 theme.json 请求超时".to_string()));
};
if response.status_code() == 404 {
return Ok(empty_settings_schema("remote"));
}
@@ -346,7 +355,9 @@ pub async fn remote_settings_schema(base: &str) -> Result<Value> {
pub async fn remote_theme_version(base: &str) -> Option<String> {
let url = settings_url(base)?;
let request = Request::new(&url, Method::Get).ok()?;
let mut response = Fetch::Request(request).send().await.ok()?;
let mut response = fetch_with_timeout(request, REMOTE_FETCH_TIMEOUT)
.await
.ok()??;
if !(200..300).contains(&response.status_code()) {
return None;
}
+8 -4
View File
@@ -1,12 +1,13 @@
use std::time::Duration;
use serde::{Deserialize, Serialize};
use worker::{wasm_bindgen::JsValue, Method, Request, RequestInit, Result};
use worker::{wasm_bindgen::JsValue, Headers, Method, Request, RequestInit, Result};
use crate::outbound::fetch_with_timeout;
use crate::outbound::{fetch_with_timeout, read_json_limited};
const SITEVERIFY_URL: &str = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
const VERIFY_TIMEOUT_SECONDS: u64 = 8;
const VERIFY_RESPONSE_MAX_BYTES: usize = 64 * 1024;
pub const ADMIN_LOGIN_ACTION: &str = "admin-login";
pub const PUBLIC_DASHBOARD_ACTION: &str = "public-dashboard";
@@ -59,11 +60,13 @@ pub async fn verify(
response: token,
remoteip: remote_ip,
})?;
let headers = Headers::new();
headers.set("Content-Type", "application/json")?;
let mut init = RequestInit::new();
init.with_method(Method::Post)
.with_headers(headers)
.with_body(Some(JsValue::from_str(&body)));
let request = Request::new_with_init(SITEVERIFY_URL, &init)?;
request.headers().set("Content-Type", "application/json")?;
let Some(mut response) =
fetch_with_timeout(request, Duration::from_secs(VERIFY_TIMEOUT_SECONDS)).await?
@@ -73,7 +76,8 @@ pub async fn verify(
if !(200..300).contains(&response.status_code()) {
return Ok(false);
}
let result: VerifyResponse = response.json().await?;
let result: VerifyResponse =
read_json_limited(&mut response, VERIFY_RESPONSE_MAX_BYTES).await?;
Ok(matches_request(&result, expected_hostname, expected_action))
}
+1
View File
@@ -21,6 +21,7 @@
"/admin",
"/admin/*",
"/admin-assets/*",
"/__scheduled",
"/__theme-active/*",
"/__theme-preview/*"
]