From 0cdcbe40f43d7ed7d0f1b70397d4c72e80a1d936 Mon Sep 17 00:00:00 2001 From: imengying Date: Thu, 27 Aug 2026 21:27:40 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/release.yml | 63 +- .gitignore | 11 - Cargo.lock | 26 +- README.md | 25 +- agent/Cargo.lock | 38 +- agent/Cargo.toml | 2 +- agent/agent.sh | 10 +- agent/install-freebsd.sh | 221 +++ agent/install-macos.sh | 8 +- agent/install.ps1 | 10 +- agent/src/live_batch.rs | 51 + agent/src/main.rs | 1279 +++++++++++------- agent/src/runtime_stats.rs | 83 ++ bun.lock | 82 +- frontend/package.json | 10 +- frontend/src/App.tsx | 590 ++------ frontend/src/AppProvider.tsx | 286 ++++ frontend/src/api.ts | 7 +- frontend/src/chart.ts | 72 + frontend/src/components/AdminPanel.tsx | 23 +- frontend/src/components/NodeCard.tsx | 74 +- frontend/src/components/NodeDetails.tsx | 161 ++- frontend/src/components/SiteLogo.tsx | 9 +- frontend/src/components/TelegramSettings.tsx | 100 ++ frontend/src/hooks/useNodeLatency.ts | Bin 7862 -> 6335 bytes frontend/src/latency.test.ts | 139 ++ frontend/src/latency.ts | 220 +++ frontend/src/live.test.ts | 227 ++++ frontend/src/live.ts | 201 +++ frontend/src/main.tsx | 6 +- frontend/src/styles/admin.css | 16 + frontend/src/styles/base.css | 6 + frontend/src/styles/dashboard.css | 35 +- frontend/src/theme.ts | 19 + frontend/src/transport.ts | 135 ++ frontend/src/types.ts | 12 +- frontend/tsconfig.app.json | 3 +- frontend/tsconfig.json | 3 +- frontend/tsconfig.test.json | 18 + migrations/initial_01.sql | 209 ++- package.json | 6 +- scripts/smoke-test.sh | 125 +- scripts/websocket-smoke.mjs | 269 +++- src/cloudflare.rs | 41 +- src/db.rs | 580 ++++---- src/exchange.rs | 18 +- src/latency.rs | 81 +- src/lib.rs | 108 +- src/live.rs | 458 +++++-- src/models.rs | 19 +- src/notify.rs | 877 ++++++++++-- src/outbound.rs | 54 +- src/routes/admin.rs | 87 +- src/routes/agent.rs | 132 +- src/routes/public.rs | 2 +- src/theme.rs | 73 +- src/turnstile.rs | 12 +- wrangler.jsonc | 1 + 58 files changed, 5295 insertions(+), 2138 deletions(-) delete mode 100644 .gitignore create mode 100755 agent/install-freebsd.sh create mode 100644 agent/src/live_batch.rs create mode 100644 agent/src/runtime_stats.rs create mode 100644 frontend/src/AppProvider.tsx create mode 100644 frontend/src/chart.ts create mode 100644 frontend/src/components/TelegramSettings.tsx create mode 100644 frontend/src/latency.test.ts create mode 100644 frontend/src/latency.ts create mode 100644 frontend/src/live.test.ts create mode 100644 frontend/src/live.ts create mode 100644 frontend/src/transport.ts create mode 100644 frontend/tsconfig.test.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 273243e..b8d5a95 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/.gitignore b/.gitignore deleted file mode 100644 index a41d63d..0000000 --- a/.gitignore +++ /dev/null @@ -1,11 +0,0 @@ -/build/ -/target/ -/agent/target/ -/node_modules/ -/frontend/dist/ -/frontend/admin-dist/ -/frontend/node_modules/ -/.wrangler/ -/.dev.vars -*.log -*.tsbuildinfo diff --git a/Cargo.lock b/Cargo.lock index 317455c..2f5cd1b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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]] diff --git a/README.md b/README.md index d849c37..8066347 100644 --- a/README.md +++ b/README.md @@ -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` | 发送一条通知测试消息 | ## 主题 diff --git a/agent/Cargo.lock b/agent/Cargo.lock index 3b97dc1..84a11af 100644 --- a/agent/Cargo.lock +++ b/agent/Cargo.lock @@ -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]] diff --git a/agent/Cargo.toml b/agent/Cargo.toml index 7565970..5b9f941 100644 --- a/agent/Cargo.toml +++ b/agent/Cargo.toml @@ -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] diff --git a/agent/agent.sh b/agent/agent.sh index e147940..86444ec 100644 --- a/agent/agent.sh +++ b/agent/agent.sh @@ -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" \ diff --git a/agent/install-freebsd.sh b/agent/install-freebsd.sh new file mode 100755 index 0000000..1a324a4 --- /dev/null +++ b/agent/install-freebsd.sh @@ -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 -t [-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" </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" diff --git a/agent/install-macos.sh b/agent/install-macos.sh index fe47237..a1a38a2 100644 --- a/agent/install-macos.sh +++ b/agent/install-macos.sh @@ -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" < Label$LABEL -ProgramArguments$AGENT_FILE-e$endpoint-t$token-i$interval +ProgramArguments$AGENT_FILE-e$endpoint--token-file$TOKEN_FILE-i$interval KeepAliveRunAtLoad StandardOutPath/var/log/nodeflare-agent.log StandardErrorPath/var/log/nodeflare-agent.log diff --git a/agent/install.ps1 b/agent/install.ps1 index b53e8cb..3f7f390 100644 --- a/agent/install.ps1 +++ b/agent/install.ps1 @@ -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 diff --git a/agent/src/live_batch.rs b/agent/src/live_batch.rs new file mode 100644 index 0000000..e266a45 --- /dev/null +++ b/agent/src/live_batch.rs @@ -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) -> 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::>(); + let count = batch_len(&queue); + assert!((1..10).contains(&count)); + let payload = + crate::live_update_payload(&queue.iter().take(count).cloned().collect::>()) + .unwrap(); + assert!(payload.len() <= MAX_LIVE_BATCH_BYTES); + } +} diff --git a/agent/src/main.rs b/agent/src/main.rs index 5e5fe00..371c20a 100644 --- a/agent/src/main.rs +++ b/agent/src/main.rs @@ -1,17 +1,20 @@ +mod live_batch; +mod runtime_stats; + use std::collections::{HashMap, HashSet, VecDeque}; use std::env; -use std::fs; -use std::io::{self, ErrorKind, Read, Write}; +use std::fs::{self, OpenOptions}; +use std::io::{self, BufRead, BufReader, ErrorKind, Read, Write}; use std::net::{IpAddr, SocketAddr, TcpStream, ToSocketAddrs}; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; #[cfg(unix)] use std::os::unix::process::CommandExt; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::Command; #[cfg(target_os = "windows")] use std::process::Stdio; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicI64, Ordering}; use std::sync::{mpsc, Arc, Condvar, Mutex}; use std::thread; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -19,13 +22,18 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use clap::Parser; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -#[cfg(any(target_os = "windows", target_os = "macos"))] -use sysinfo::{Disks, Networks, ProcessesToUpdate, System}; +#[cfg(any(target_os = "windows", target_os = "macos", target_os = "freebsd"))] +use sysinfo::{Disks, Networks, ProcessRefreshKind, ProcessesToUpdate, System}; use tungstenite::client::IntoClientRequest; use tungstenite::{connect, Error as WebSocketError, Message}; -#[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))] -compile_error!("nodeflare-agent supports Linux, Windows, and macOS"); +#[cfg(not(any( + target_os = "linux", + target_os = "windows", + target_os = "macos", + target_os = "freebsd" +)))] +compile_error!("nodeflare-agent supports Linux, Windows, macOS, and FreeBSD"); const VERSION: &str = match option_env!("NODEFLARE_VERSION") { Some(version) if !version.is_empty() => version, @@ -39,21 +47,22 @@ const MAX_LATENCY_TASKS: usize = 128; const LATENCY_WORKERS: usize = 4; const MAX_PENDING_LATENCY_RESULTS: usize = 4096; const MAX_REPORT_AGE_SECONDS: i64 = 7_000; -const REPORT_RETRY_MIN: Duration = Duration::from_secs(5); -const REPORT_RETRY_MAX: Duration = Duration::from_secs(300); +const ONCE_WSS_TIMEOUT: Duration = Duration::from_secs(5 * 60); const UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(6 * 60 * 60); const UPDATE_RETRY_INTERVAL: Duration = Duration::from_secs(30 * 60); +const UPDATE_CHECK_JITTER_MAX_SECONDS: u64 = 30 * 60; const LIVE_RECONNECT_DELAY: Duration = Duration::from_secs(3); const LIVE_QUEUE_CAPACITY: usize = 720; -// Keep each WebSocket frame comfortably below the Worker 1 MiB validation limit; -// the queue preserves the remaining samples for the next frame. -const LIVE_BATCH_CAPACITY: usize = 32; +const MAX_PENDING_SPOOL_BYTES: u64 = 64 * 1024 * 1024; +const MAX_PENDING_SPOOL_LINE_BYTES: usize = 1024 * 1024; // D1 persistence can take a few hundred milliseconds; allow the ACK to arrive // before continuing with the configured live interval. -const LIVE_ACK_READ_TIMEOUT: Duration = Duration::from_millis(1_000); +const LIVE_ACK_READ_TIMEOUT: Duration = Duration::from_secs(5); const LIVE_HINT_READ_TIMEOUT: Duration = Duration::from_millis(10); const LIVE_REPORT_DIVISOR: u64 = 15; const BASIC_INFO_REFRESH_INTERVAL: Duration = Duration::from_secs(5 * 60); +const SLOW_METRICS_REFRESH_INTERVAL: Duration = Duration::from_secs(5); +const RUNTIME_STATS_INTERVAL: Duration = Duration::from_secs(5 * 60); const CLOCK_CALIBRATION_MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60); const CLOCK_CALIBRATION_MIN_CHANGE_MS: i64 = 20_000; @@ -78,9 +87,9 @@ struct CliOptions { /// NodeFlare endpoint #[arg(short = 'e', value_name = "URL")] endpoint: String, - /// Agent token - #[arg(short = 't', value_name = "TOKEN")] - token: String, + /// Path to a file containing the Agent token + #[arg(long, value_name = "PATH")] + token_file: PathBuf, /// Initial report interval in seconds (15-3600) #[arg(short = 'i', value_name = "SECONDS", default_value_t = 60)] interval: u64, @@ -103,7 +112,7 @@ struct LatencyTask { interval_seconds: u64, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] struct LatencyResult { task_id: String, timestamp: i64, @@ -115,10 +124,11 @@ struct LatencyExecutor { task_tx: mpsc::SyncSender, result_rx: mpsc::Receiver, in_flight: HashSet, + stats: Arc, } impl LatencyExecutor { - fn new() -> Result { + fn new(stats: Arc) -> Result { let (task_tx, task_rx) = mpsc::sync_channel::(MAX_LATENCY_TASKS); let (result_tx, result_rx) = mpsc::channel(); let task_rx = Arc::new(Mutex::new(task_rx)); @@ -130,7 +140,7 @@ impl LatencyExecutor { .name(format!("nodeflare-latency-{index}")) .spawn(move || loop { let task = match task_rx.lock() { - Ok(receiver) => receiver.try_recv(), + Ok(receiver) => receiver.recv(), Err(_) => return, }; match task { @@ -139,10 +149,7 @@ impl LatencyExecutor { return; } } - Err(mpsc::TryRecvError::Empty) => { - thread::sleep(Duration::from_millis(25)); - } - Err(mpsc::TryRecvError::Disconnected) => return, + Err(mpsc::RecvError) => return, }; })?; } @@ -151,6 +158,7 @@ impl LatencyExecutor { task_tx, result_rx, in_flight: HashSet::new(), + stats, }) } @@ -164,8 +172,10 @@ impl LatencyExecutor { self.in_flight.insert(task_id); true } - Err(mpsc::TrySendError::Full(_)) => false, - Err(mpsc::TrySendError::Disconnected(_)) => false, + Err(mpsc::TrySendError::Full(_)) | Err(mpsc::TrySendError::Disconnected(_)) => { + self.stats.latency_queue_rejected(); + false + } } } @@ -202,12 +212,6 @@ struct GithubReleaseAsset { digest: Option, } -struct SubmitResult { - config: Option, - config_hash: String, - clock_offset_ms: Option, -} - #[derive(Debug, Default)] struct ClockCalibration { offset_ms: i64, @@ -234,38 +238,62 @@ type SharedClock = Arc>; struct LiveSender { pending: Arc<(Mutex>, Condvar)>, send_interval: Arc>, - healthy: Arc, + persisted_through: Arc, + remote_config: Arc>>, + stats: Arc, +} + +struct LiveSenderWorker { + pending: Arc<(Mutex>, Condvar)>, + configured_interval: Arc>, + persisted_through: Arc, + remote_config: Arc>>, + clock: SharedClock, + stats: Arc, } impl LiveSender { - fn start(config: &RuntimeConfig, clock: SharedClock) -> Result { + fn start( + config: &RuntimeConfig, + clock: SharedClock, + stats: Arc, + ) -> Result { let pending = Arc::new((Mutex::new(VecDeque::new()), Condvar::new())); let send_interval = Arc::new(Mutex::new(live_batch_interval( config.report_interval, config.collect_interval, ))); - let healthy = Arc::new(AtomicBool::new(false)); + let persisted_through = Arc::new(AtomicI64::new(0)); + let remote_config = Arc::new(Mutex::new(None)); let endpoint = live_endpoint(&config.endpoint)?; let token = config.token.clone(); let sender_state = Arc::clone(&pending); let sender_interval = Arc::clone(&send_interval); - let sender_healthy = Arc::clone(&healthy); + let sender_persisted_through = Arc::clone(&persisted_through); + let sender_remote_config = Arc::clone(&remote_config); + let sender_stats = Arc::clone(&stats); thread::Builder::new() .name("nodeflare-live".to_string()) .spawn(move || { live_sender_loop( &endpoint, &token, - sender_state, - sender_interval, - sender_healthy, - clock, + LiveSenderWorker { + pending: sender_state, + configured_interval: sender_interval, + persisted_through: sender_persisted_through, + remote_config: sender_remote_config, + clock, + stats: sender_stats, + }, ) })?; Ok(Self { pending, send_interval, - healthy, + persisted_through, + remote_config, + stats, }) } @@ -274,6 +302,7 @@ impl LiveSender { if let Ok(mut pending) = pending.lock() { if pending.len() >= LIVE_QUEUE_CAPACITY { pending.pop_front(); + self.stats.live_queue_dropped(1); } pending.push_back(report.clone()); ready.notify_one(); @@ -287,8 +316,15 @@ impl LiveSender { self.pending.1.notify_one(); } - fn is_healthy(&self) -> bool { - self.healthy.load(Ordering::Acquire) + fn persisted_through(&self) -> i64 { + self.persisted_through.load(Ordering::Acquire) + } + + fn take_remote_config(&self) -> Option { + self.remote_config + .lock() + .ok() + .and_then(|mut config| config.take()) } } @@ -301,12 +337,7 @@ fn live_batch_interval(report_interval: u64, collect_interval: u64) -> Duration Duration::from_secs(seconds) } -#[derive(Debug, Serialize)] -struct ReportBatch<'a> { - samples: &'a [Report], -} - -#[derive(Debug, Default, Clone, Serialize)] +#[derive(Debug, Default, Clone, Deserialize, Serialize)] struct DiskMetric { name: String, mount_point: String, @@ -320,7 +351,7 @@ struct DiskMetric { utilization: f64, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] struct GpuMetric { model: String, usage: Option, @@ -328,7 +359,7 @@ struct GpuMetric { memory_total: i64, } -#[derive(Debug, Default, Clone, Serialize)] +#[derive(Debug, Default, Clone, Deserialize, Serialize)] struct Report { timestamp: i64, cpu: f64, @@ -382,6 +413,14 @@ struct BasicMetrics { gpus: Vec, } +#[derive(Debug, Default, Clone)] +struct SlowMetrics { + disks: Vec, + processes: i64, + tcp_connections: i64, + udp_connections: i64, +} + #[cfg(target_os = "linux")] #[derive(Debug, Default, Clone, Copy)] struct CpuSample { @@ -518,7 +557,7 @@ fn file_line_count(path: &str) -> i64 { text(path).lines().skip(1).count() as i64 } -#[cfg(any(target_os = "windows", target_os = "macos"))] +#[cfg(any(target_os = "windows", target_os = "macos", target_os = "freebsd"))] fn connection_counts_from_netstat(output: &str) -> (i64, i64) { output.lines().fold((0_i64, 0_i64), |(tcp, udp), line| { let protocol = line @@ -536,7 +575,7 @@ fn connection_counts_from_netstat(output: &str) -> (i64, i64) { }) } -#[cfg(any(target_os = "windows", target_os = "macos"))] +#[cfg(any(target_os = "windows", target_os = "macos", target_os = "freebsd"))] fn connection_counts() -> (i64, i64) { Command::new("netstat") .args(["-an"]) @@ -755,6 +794,10 @@ fn ping_latency(output: &str) -> Option { value.parse().ok() } +fn ping_latencies(output: &str) -> Vec { + output.lines().filter_map(ping_latency).collect() +} + fn icmp_latency_probe(target: &str) -> (f64, f64) { let host = target.trim(); let Some((host, _)) = parse_probe_target(host, None) else { @@ -764,27 +807,18 @@ fn icmp_latency_probe(target: &str) -> (f64, f64) { return (-1.0, 100.0); }; let destination = address.ip().to_string(); - let mut latencies = Vec::with_capacity(PROBE_ATTEMPTS); - for _ in 0..PROBE_ATTEMPTS { - let started = Instant::now(); - let mut ping = Command::new("ping"); - #[cfg(target_os = "linux")] - ping.args(["-n", "-c", "1", "-W", "1", destination.as_str()]); - #[cfg(target_os = "macos")] - ping.args(["-n", "-c", "1", "-W", "1000", destination.as_str()]); - #[cfg(target_os = "windows")] - ping.args(["-n", "1", "-w", "1000", destination.as_str()]); - let output = ping.output(); - if let Ok(output) = output { - if output.status.success() { - let stdout = String::from_utf8_lossy(&output.stdout); - latencies.push( - ping_latency(&stdout) - .unwrap_or_else(|| started.elapsed().as_secs_f64() * 1000.0), - ); - } - } - } + let mut ping = Command::new("ping"); + #[cfg(target_os = "linux")] + ping.args(["-n", "-c", "4", "-W", "1", destination.as_str()]); + #[cfg(any(target_os = "macos", target_os = "freebsd"))] + ping.args(["-n", "-c", "4", "-W", "1000", destination.as_str()]); + #[cfg(target_os = "windows")] + ping.args(["-n", "4", "-w", "1000", destination.as_str()]); + let mut latencies = ping + .output() + .map(|output| ping_latencies(&String::from_utf8_lossy(&output.stdout))) + .unwrap_or_default(); + latencies.truncate(PROBE_ATTEMPTS); let loss = (PROBE_ATTEMPTS - latencies.len()) as f64 * 100.0 / PROBE_ATTEMPTS as f64; if latencies.is_empty() { (-1.0, loss) @@ -820,12 +854,6 @@ fn clock_offset_from_http_date(value: &str, started_ms: i64, ended_ms: i64) -> O Some(server_ms.saturating_sub(midpoint_ms)) } -fn clock_offset_from_server_seconds(value: &str, started_ms: i64, ended_ms: i64) -> Option { - let server_ms = value.trim().parse::().ok()?.saturating_mul(1_000); - let midpoint_ms = started_ms.saturating_add(ended_ms.saturating_sub(started_ms) / 2); - Some(server_ms.saturating_sub(midpoint_ms)) -} - fn corrected_timestamp(timestamp: i64, offset_ms: i64) -> i64 { timestamp .saturating_mul(1_000) @@ -833,6 +861,12 @@ fn corrected_timestamp(timestamp: i64, offset_ms: i64) -> i64 { .div_euclid(1_000) } +fn monotonic_report_timestamp(corrected: i64, last_emitted: i64, persisted_through: i64) -> i64 { + corrected + .max(last_emitted.saturating_add(1)) + .max(persisted_through.saturating_add(1)) +} + fn shared_clock_offset(clock: &SharedClock) -> i64 { clock.lock().map_or(0, |calibration| calibration.offset_ms) } @@ -1006,6 +1040,68 @@ fn parse_system_profiler_gpu_names(output: &str) -> Vec { .collect() } +#[cfg(any(target_os = "freebsd", test))] +fn parse_pciconf_gpu_names(output: &str) -> Vec { + fn block_name(lines: &[&str]) -> Option { + let header = lines.first()?.to_ascii_lowercase(); + let mut display = header.contains("class=0x03"); + let mut vendor = None; + let mut device = None; + for line in lines.iter().skip(1) { + let Some((key, value)) = line.trim().split_once('=') else { + continue; + }; + let key = key.trim(); + let value = value.trim().trim_matches(['\'', '"']); + match key { + "class" if value.eq_ignore_ascii_case("display") => display = true, + "vendor" => vendor = Some(value), + "device" => device = Some(value), + _ => {} + } + } + if !display { + return None; + } + match (vendor, device) { + (Some(vendor), Some(device)) + if !device + .to_ascii_lowercase() + .contains(&vendor.to_ascii_lowercase()) => + { + Some(format!("{vendor} {device}")) + } + (_, Some(device)) => Some(device.to_string()), + (Some(vendor), None) => Some(vendor.to_string()), + _ => None, + } + } + + let mut names = Vec::new(); + let mut block = Vec::new(); + for line in output.lines() { + if !line.is_empty() + && !line + .chars() + .next() + .is_some_and(|character| character.is_whitespace()) + && !block.is_empty() + { + if let Some(name) = block_name(&block) { + names.push(name); + } + block.clear(); + } + if !line.trim().is_empty() { + block.push(line); + } + } + if let Some(name) = block_name(&block) { + names.push(name); + } + names +} + #[cfg(target_os = "linux")] fn basic_gpu_info() -> Vec { let names = parse_lspci_gpu_names(&command("lspci", &[])); @@ -1041,6 +1137,11 @@ fn basic_gpu_info() -> Vec { ))) } +#[cfg(target_os = "freebsd")] +fn basic_gpu_info() -> Vec { + basic_gpu_metrics(parse_pciconf_gpu_names(&command("pciconf", &["-lv"]))) +} + fn gpu_info() -> Vec { let detailed = nvidia_gpu_info(); if detailed.is_empty() { @@ -1059,7 +1160,7 @@ fn average_gpu_usage(gpus: &[GpuMetric]) -> f64 { } } -#[cfg(any(target_os = "windows", target_os = "macos"))] +#[cfg(any(target_os = "windows", target_os = "macos", target_os = "freebsd"))] fn u64_to_i64(value: u64) -> i64 { value.min(i64::MAX as u64) as i64 } @@ -1090,6 +1191,8 @@ struct Collector { network_interface: String, basic: BasicMetrics, basic_at: Instant, + slow: SlowMetrics, + slow_at: Instant, } #[cfg(target_os = "linux")] @@ -1102,8 +1205,11 @@ impl Collector { network_interface: config.network_interface.clone(), basic: BasicMetrics::default(), basic_at: Instant::now(), + slow: SlowMetrics::default(), + slow_at: Instant::now(), }; collector.refresh_basic(); + collector.refresh_slow(); collector } @@ -1129,6 +1235,29 @@ impl Collector { self.basic_at = Instant::now(); } + fn refresh_slow(&mut self) { + let processes = fs::read_dir("/proc") + .map(|items| { + items + .filter_map(|item| item.ok()) + .filter(|item| { + item.file_name() + .to_string_lossy() + .chars() + .all(|ch| ch.is_ascii_digit()) + }) + .count() as i64 + }) + .unwrap_or(0); + self.slow = SlowMetrics { + disks: disk_usage(), + processes, + tcp_connections: file_line_count("/proc/net/tcp") + file_line_count("/proc/net/tcp6"), + udp_connections: file_line_count("/proc/net/udp") + file_line_count("/proc/net/udp6"), + }; + self.slow_at = Instant::now(); + } + fn collect( &mut self, config: &RuntimeConfig, @@ -1163,6 +1292,9 @@ impl Collector { if self.basic_at.elapsed() >= BASIC_INFO_REFRESH_INTERVAL { self.refresh_basic(); } + if self.slow_at.elapsed() >= SLOW_METRICS_REFRESH_INTERVAL { + self.refresh_slow(); + } let mem = text("/proc/meminfo"); let mem_total = mem_value(&mem, "MemTotal:"); @@ -1179,24 +1311,12 @@ impl Collector { .next() .and_then(|value| value.parse::().ok()) .unwrap_or(0.0) as i64; - let processes = fs::read_dir("/proc") - .map(|items| { - items - .filter_map(|item| item.ok()) - .filter(|item| { - item.file_name() - .to_string_lossy() - .chars() - .all(|ch| ch.is_ascii_digit()) - }) - .count() as i64 - }) - .unwrap_or(0); - let disks = disk_usage(); + let processes = self.slow.processes; + let disks = self.slow.disks.clone(); let disk_used = disks.iter().map(|disk| disk.used).sum(); let disk_total = disks.iter().map(|disk| disk.total).sum(); - let tcp_connections = file_line_count("/proc/net/tcp") + file_line_count("/proc/net/tcp6"); - let udp_connections = file_line_count("/proc/net/udp") + file_line_count("/proc/net/udp6"); + let tcp_connections = self.slow.tcp_connections; + let udp_connections = self.slow.udp_connections; let read_ops = io_now.read_ops.saturating_sub(io_before.read_ops); let write_ops = io_now.write_ops.saturating_sub(io_before.write_ops); let total_ops = read_ops.saturating_add(write_ops); @@ -1269,26 +1389,33 @@ impl Collector { } } -#[cfg(any(target_os = "windows", target_os = "macos"))] +#[cfg(any(target_os = "windows", target_os = "macos", target_os = "freebsd"))] struct Collector { system: System, networks: Networks, + disks: Disks, previous_at: Instant, basic: BasicMetrics, basic_at: Instant, + slow: SlowMetrics, + slow_at: Instant, } -#[cfg(any(target_os = "windows", target_os = "macos"))] +#[cfg(any(target_os = "windows", target_os = "macos", target_os = "freebsd"))] impl Collector { fn new(_config: &RuntimeConfig) -> Self { let mut collector = Self { system: System::new_all(), networks: Networks::new_with_refreshed_list(), + disks: Disks::new_with_refreshed_list(), previous_at: Instant::now(), basic: BasicMetrics::default(), basic_at: Instant::now(), + slow: SlowMetrics::default(), + slow_at: Instant::now(), }; collector.refresh_basic(); + collector.refresh_slow(); collector } @@ -1317,35 +1444,22 @@ impl Collector { self.basic_at = Instant::now(); } - fn collect( - &mut self, - config: &RuntimeConfig, - latency_results: Vec, - timestamp: i64, - ) -> Report { - let sampled_at = Instant::now(); - let elapsed = sampled_at - .saturating_duration_since(self.previous_at) - .as_secs_f64() - .max(0.001); - self.system.refresh_cpu_usage(); - self.system.refresh_memory(); - self.system.refresh_processes(ProcessesToUpdate::All, true); - self.networks.refresh(true); - self.previous_at = sampled_at; - if self.basic_at.elapsed() >= BASIC_INFO_REFRESH_INTERVAL { - self.refresh_basic(); - } - - let disks = Disks::new_with_refreshed_list(); + fn refresh_slow(&mut self) { + self.system.refresh_processes_specifics( + ProcessesToUpdate::All, + true, + ProcessRefreshKind::nothing().without_tasks(), + ); + self.disks.refresh(true); #[cfg(target_os = "windows")] let root = format!( "{}\\", env::var("SystemDrive").unwrap_or_else(|_| "C:".to_string()) ); - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "freebsd"))] let root = "/".to_string(); - let mut disk_metrics = disks + let mut disks = self + .disks .iter() .filter(|disk| !disk.is_removable()) .map(|disk| { @@ -1359,8 +1473,42 @@ impl Collector { } }) .collect::>(); - disk_metrics.sort_by_key(|disk| (disk.mount_point != root, disk.mount_point.clone())); + disks.sort_by_key(|disk| (disk.mount_point != root, disk.mount_point.clone())); let (tcp_connections, udp_connections) = connection_counts(); + self.slow = SlowMetrics { + disks, + processes: self.system.processes().len() as i64, + tcp_connections, + udp_connections, + }; + self.slow_at = Instant::now(); + } + + fn collect( + &mut self, + config: &RuntimeConfig, + latency_results: Vec, + timestamp: i64, + ) -> Report { + let sampled_at = Instant::now(); + let elapsed = sampled_at + .saturating_duration_since(self.previous_at) + .as_secs_f64() + .max(0.001); + self.system.refresh_cpu_usage(); + self.system.refresh_memory(); + self.networks.refresh(true); + self.previous_at = sampled_at; + if self.basic_at.elapsed() >= BASIC_INFO_REFRESH_INTERVAL { + self.refresh_basic(); + } + if self.slow_at.elapsed() >= SLOW_METRICS_REFRESH_INTERVAL { + self.refresh_slow(); + } + + let disk_metrics = self.slow.disks.clone(); + let tcp_connections = self.slow.tcp_connections; + let udp_connections = self.slow.udp_connections; let mut net_in = 0_u64; let mut net_out = 0_u64; @@ -1393,7 +1541,7 @@ impl Collector { net_rx_total: u64_to_i64(net_rx_total), net_tx_total: u64_to_i64(net_tx_total), uptime: u64_to_i64(System::uptime()), - processes: self.system.processes().len() as i64, + processes: self.slow.processes, tcp_connections, udp_connections, cpu_cores: self.basic.cpu_cores, @@ -1423,7 +1571,18 @@ fn runtime_config(options: &CliOptions) -> Result { if !(15..=3600).contains(&interval) { return Err("interval must be between 15 and 3600 seconds".into()); } - let token = options.token.clone(); + let mut token_file = fs::File::open(&options.token_file)?; + let metadata = token_file.metadata()?; + if !metadata.is_file() || metadata.len() > 513 { + return Err("token file is invalid".into()); + } + #[cfg(unix)] + if metadata.permissions().mode() & 0o077 != 0 { + return Err("token file must not be accessible by group or other users".into()); + } + let mut token = String::new(); + token_file.read_to_string(&mut token)?; + let token = token.trim().to_string(); let endpoint = options.endpoint.clone(); if token.is_empty() || token.len() > 512 || token.chars().any(char::is_whitespace) { return Err("token is invalid".into()); @@ -1445,124 +1604,21 @@ fn runtime_config(options: &CliOptions) -> Result { }) } -fn submit( - agent: &ureq::Agent, - config: &RuntimeConfig, - reports: &[Report], - config_hash: &str, -) -> Result { - let batch = ReportBatch { samples: reports }; - let started_ms = unix_timestamp_millis(); - let mut response = agent - .post(&format!("{}/api/agent/report", config.endpoint)) - .header("Authorization", format!("Bearer {}", config.token)) - .header("User-Agent", format!("nodeflare-agent/{VERSION}")) - .header("X-Agent-Config-Sha256", config_hash) - .send_json(batch)?; - let ended_ms = unix_timestamp_millis(); - let clock_offset_ms = response - .headers() - .get("x-nodeflare-server-time") - .and_then(|value| value.to_str().ok()) - .and_then(|value| clock_offset_from_server_seconds(value, started_ms, ended_ms)) - .or_else(|| { - response - .headers() - .get("date") - .and_then(|value| value.to_str().ok()) - .and_then(|value| clock_offset_from_http_date(value, started_ms, ended_ms)) - }); - let next_hash = response - .headers() - .get("X-Agent-Config-Sha256") - .and_then(|value| value.to_str().ok()) - .unwrap_or(config_hash) - .to_string(); - let remote = if response.status().as_u16() == 204 { - None - } else { - Some(response.body_mut().read_json::()?) - }; - Ok(SubmitResult { - config: remote, - config_hash: next_hash, - clock_offset_ms, - }) -} - -fn fetch_remote_config( - agent: &ureq::Agent, - config: &RuntimeConfig, - config_hash: &str, -) -> Result { - let started_ms = unix_timestamp_millis(); - let mut response = agent - .get(&format!("{}/api/agent/config", config.endpoint)) - .header("Authorization", format!("Bearer {}", config.token)) - .header("User-Agent", format!("nodeflare-agent/{VERSION}")) - .header("X-Agent-Config-Sha256", config_hash) - .call()?; - let ended_ms = unix_timestamp_millis(); - let clock_offset_ms = response - .headers() - .get("x-nodeflare-server-time") - .and_then(|value| value.to_str().ok()) - .and_then(|value| clock_offset_from_server_seconds(value, started_ms, ended_ms)) - .or_else(|| { - response - .headers() - .get("date") - .and_then(|value| value.to_str().ok()) - .and_then(|value| clock_offset_from_http_date(value, started_ms, ended_ms)) - }); - let next_hash = response - .headers() - .get("X-Agent-Config-Sha256") - .and_then(|value| value.to_str().ok()) - .unwrap_or(config_hash) - .to_string(); - let remote = if response.status().as_u16() == 204 { - None - } else { - Some(response.body_mut().read_json::()?) - }; - Ok(SubmitResult { - config: remote, - config_hash: next_hash, - clock_offset_ms, - }) -} - -fn fetch_clock_offset(agent: &ureq::Agent, endpoint: &str) -> Result> { - let started_ms = unix_timestamp_millis(); - let response = agent - .get(&format!("{endpoint}/api/config")) - .header("User-Agent", format!("nodeflare-agent/{VERSION}")) - .call()?; - let ended_ms = unix_timestamp_millis(); - Ok(response - .headers() - .get("x-nodeflare-server-time") - .and_then(|value| value.to_str().ok()) - .and_then(|value| clock_offset_from_server_seconds(value, started_ms, ended_ms)) - .or_else(|| { - response - .headers() - .get("date") - .and_then(|value| value.to_str().ok()) - .and_then(|value| clock_offset_from_http_date(value, started_ms, ended_ms)) - })) -} - fn live_endpoint(endpoint: &str) -> Result { let mut url = url::Url::parse(endpoint)?; url.set_scheme(if url.scheme() == "https" { "wss" } else { "ws" }) .map_err(|_| "unsupported live endpoint scheme")?; - let path = format!("{}/api/agent/live", url.path().trim_end_matches('/')); + let path = format!("{}/api/agent/ws", url.path().trim_end_matches('/')); url.set_path(&path); Ok(url.to_string()) } +fn report_batch_id(reports: &[Report]) -> String { + let encoded = serde_json::to_vec(reports).unwrap_or_default(); + let digest = Sha256::digest(encoded); + digest.iter().map(|byte| format!("{byte:02x}")).collect() +} + type LiveSocket = tungstenite::WebSocket>; fn connect_live(endpoint: &str, token: &str) -> Result<(LiveSocket, Option)> { @@ -1601,6 +1657,10 @@ struct LiveAck { message_type: String, ts: i64, persisted: bool, + #[serde(rename = "persistenceError")] + persistence_error: bool, + #[serde(rename = "persistedThroughTs")] + persisted_through_ts: i64, #[serde(rename = "nextD1WriteAfterMs")] next_d1_write_after_ms: u64, #[serde(rename = "nextWssReportAfterMs")] @@ -1609,89 +1669,213 @@ struct LiveAck { realtime_hint: bool, } -fn ack_interval(ack: &LiveAck) -> Duration { - Duration::from_millis(ack.next_wss_report_after_ms) - .clamp(Duration::from_secs(1), Duration::from_secs(60)) +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct LiveConfigMessage { + #[serde(rename = "type")] + message_type: String, + ts: i64, + config: RemoteConfig, } -fn read_live_ack(socket: &mut LiveSocket) -> Result> { +fn ack_wss_interval(ack: &LiveAck) -> Duration { + Duration::from_millis(ack.next_wss_report_after_ms) + .clamp(Duration::from_secs(1), Duration::from_secs(3600)) +} + +fn ack_d1_interval(ack: &LiveAck) -> Duration { + Duration::from_millis(ack.next_d1_write_after_ms) + .clamp(Duration::from_secs(1), Duration::from_secs(3600)) +} + +enum LiveRead { + Ack(LiveAck), + Config(RemoteConfig), + Closed, + Pending, +} + +fn read_live_ack(socket: &mut LiveSocket) -> Result { match socket.read() { Ok(Message::Text(text)) => { - let Ok(ack) = serde_json::from_str::(text.as_ref()) else { - return Ok(None); + let Ok(value) = serde_json::from_str::(text.as_ref()) else { + return Ok(LiveRead::Pending); }; - if ack.message_type != "ack" || ack.ts <= 0 { - return Ok(None); + match value.get("type").and_then(serde_json::Value::as_str) { + Some("ack") => { + let Ok(ack) = serde_json::from_value::(value) else { + return Ok(LiveRead::Pending); + }; + if ack.message_type != "ack" || ack.ts <= 0 { + return Ok(LiveRead::Pending); + } + Ok(LiveRead::Ack(ack)) + } + Some("config") => { + let Ok(message) = serde_json::from_value::(value) else { + return Ok(LiveRead::Pending); + }; + if message.message_type != "config" || message.ts <= 0 { + return Ok(LiveRead::Pending); + } + Ok(LiveRead::Config(message.config)) + } + _ => Ok(LiveRead::Pending), } - let _ = (ack.persisted, ack.next_d1_write_after_ms, ack.realtime_hint); - Ok(Some(ack_interval(&ack))) } Ok(Message::Ping(payload)) => { socket.send(Message::Pong(payload))?; - Ok(None) + Ok(LiveRead::Pending) } - Ok(Message::Close(_)) => Ok(Some(Duration::ZERO)), - Ok(_) => Ok(None), + Ok(Message::Close(_)) => Ok(LiveRead::Closed), + Ok(_) => Ok(LiveRead::Pending), Err(WebSocketError::Io(error)) if matches!(error.kind(), ErrorKind::TimedOut | ErrorKind::WouldBlock) => { - Ok(None) + Ok(LiveRead::Pending) } Err(error) => Err(error.into()), } } +fn wait_for_live_ack( + socket: &mut LiveSocket, + remote_config: &Arc>>, +) -> Result { + let deadline = Instant::now() + LIVE_ACK_READ_TIMEOUT; + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Ok(LiveRead::Pending); + } + set_live_read_timeout(socket, Some(remaining))?; + match read_live_ack(socket)? { + LiveRead::Ack(ack) if !ack.realtime_hint => { + return Ok(LiveRead::Ack(ack)); + } + LiveRead::Config(config) => { + if let Ok(mut target) = remote_config.lock() { + *target = Some(config); + } + } + LiveRead::Closed => return Ok(LiveRead::Closed), + LiveRead::Ack(_) | LiveRead::Pending => {} + } + } +} + fn live_update_payload(reports: &[Report]) -> Result { Ok(serde_json::to_string(&serde_json::json!({ "type": "update", + "batchId": report_batch_id(reports), "samples": reports, }))?) } -fn requeue_reports(pending: &Arc<(Mutex>, Condvar)>, reports: Vec) { - if reports.is_empty() { +fn observe_persisted_through(target: &AtomicI64, ack: &LiveAck) { + let _ = ack.persisted; + target.fetch_max(ack.persisted_through_ts.max(0), Ordering::AcqRel); +} + +fn prune_live_queue(pending: &Arc<(Mutex>, Condvar)>, persisted_through: i64) { + if persisted_through <= 0 { return; } - let (queue, ready) = &**pending; - if let Ok(mut queue) = queue.lock() { - for report in reports.into_iter().rev() { - queue.push_front(report); - } - while queue.len() > LIVE_QUEUE_CAPACITY { - queue.pop_back(); - } - ready.notify_one(); + if let Ok(mut queue) = pending.0.lock() { + queue.retain(|report| report.timestamp > persisted_through); } } -fn live_sender_loop( - endpoint: &str, - token: &str, - pending: Arc<(Mutex>, Condvar)>, - configured_interval: Arc>, - healthy: Arc, - clock: SharedClock, -) { +fn live_batch_after(queue: &VecDeque, timestamp: i64) -> Vec { + let candidates = queue + .iter() + .filter(|report| report.timestamp > timestamp) + .cloned() + .collect::>(); + let count = live_batch::batch_len(&candidates); + candidates.into_iter().take(count).collect() +} + +fn live_persistence_probe(queue: &VecDeque, persisted_through: i64) -> Vec { + queue + .iter() + .rev() + .find(|report| report.timestamp > persisted_through) + .cloned() + .into_iter() + .collect() +} + +fn live_sender_loop(endpoint: &str, token: &str, worker: LiveSenderWorker) { + let LiveSenderWorker { + pending, + configured_interval, + persisted_through, + remote_config, + clock, + stats, + } = worker; let mut socket: Option = None; - let mut send_interval = configured_interval + let mut wss_interval = configured_interval .lock() .map(|interval| *interval) .unwrap_or(Duration::from_secs(1)); - let mut next_send_at = Instant::now() + send_interval; - loop { + let mut accepted_through = persisted_through.load(Ordering::Acquire); + let mut next_send_at = Instant::now(); + let mut next_probe_at = Instant::now(); + 'sender: loop { + if socket.is_none() { + match connect_live(endpoint, token) { + Ok((mut connected, offset_ms)) => { + observe_clock(&clock, offset_ms); + if set_live_read_timeout(&mut connected, Some(LIVE_HINT_READ_TIMEOUT)).is_err() + { + thread::sleep(LIVE_RECONNECT_DELAY); + continue; + } + socket = Some(connected); + accepted_through = persisted_through.load(Ordering::Acquire); + next_send_at = Instant::now(); + next_probe_at = Instant::now(); + } + Err(error) => { + stats.live_connect_failed(); + eprintln!("live connection failed: {error}"); + thread::sleep(LIVE_RECONNECT_DELAY); + continue; + } + } + } + let (queue_lock, ready) = &*pending; let mut queue = match queue_lock.lock() { Ok(queue) => queue, Err(_) => return, }; - while queue.is_empty() || Instant::now() < next_send_at { - let wait = if queue.is_empty() { - Duration::from_millis(250) + let batch = loop { + let now = Instant::now(); + let unsent = live_batch_after(&queue, accepted_through); + if !unsent.is_empty() && (now >= next_send_at || now >= next_probe_at) { + break unsent; + } + let persisted = persisted_through.load(Ordering::Acquire); + if unsent.is_empty() && now >= next_probe_at { + let probe = live_persistence_probe(&queue, persisted); + if !probe.is_empty() { + break probe; + } + } + + let wake_at = if !unsent.is_empty() { + next_send_at.min(next_probe_at) + } else if queue.iter().any(|report| report.timestamp > persisted) { + next_probe_at } else { - next_send_at - .saturating_duration_since(Instant::now()) - .min(Duration::from_millis(250)) + now + Duration::from_millis(250) }; + let wait = wake_at + .saturating_duration_since(now) + .min(Duration::from_millis(250)); queue = match ready.wait_timeout(queue, wait) { Ok((queue, _)) => queue, Err(_) => return, @@ -1704,13 +1888,26 @@ fn live_sender_loop( drop_socket = true; } else { match read_live_ack(connected) { - Ok(Some(Duration::ZERO)) => drop_socket = true, - Ok(Some(interval)) => { - healthy.store(true, Ordering::Release); - send_interval = interval; - next_send_at = Instant::now() + send_interval; + Ok(LiveRead::Closed) => drop_socket = true, + Ok(LiveRead::Ack(ack)) => { + observe_persisted_through(&persisted_through, &ack); + if ack.persistence_error { + stats.live_persistence_failed(); + drop_socket = true; + } else { + prune_live_queue(&pending, ack.persisted_through_ts); + accepted_through = accepted_through.max(ack.persisted_through_ts); + wss_interval = ack_wss_interval(&ack); + next_send_at = Instant::now() + wss_interval; + next_probe_at = Instant::now() + ack_d1_interval(&ack); + } } - Ok(None) => {} + Ok(LiveRead::Config(config)) => { + if let Ok(mut target) = remote_config.lock() { + *target = Some(config); + } + } + Ok(LiveRead::Pending) => {} Err(error) => { eprintln!("live hint read failed: {error}"); drop_socket = true; @@ -1720,99 +1917,86 @@ fn live_sender_loop( } if drop_socket { socket = None; - healthy.store(false, Ordering::Release); + accepted_through = persisted_through.load(Ordering::Acquire); next_send_at = Instant::now(); + next_probe_at = Instant::now(); + thread::sleep(LIVE_RECONNECT_DELAY); + continue 'sender; } queue = match queue_lock.lock() { Ok(queue) => queue, Err(_) => return, }; - } - let count = queue.len().min(LIVE_BATCH_CAPACITY); - let batch = queue.drain(..count).collect::>(); + }; drop(queue); if let Ok(interval) = configured_interval.lock() { - send_interval = (*interval).clamp(Duration::from_secs(1), Duration::from_secs(60)); - } - if socket.is_none() { - match connect_live(endpoint, token) { - Ok((mut connected, offset_ms)) => { - observe_clock(&clock, offset_ms); - if set_live_read_timeout(&mut connected, Some(LIVE_ACK_READ_TIMEOUT)).is_err() { - socket = None; - healthy.store(false, Ordering::Release); - thread::sleep(LIVE_RECONNECT_DELAY); - requeue_reports(&pending, batch); - continue; - } - socket = Some(connected); - } - Err(error) => { - healthy.store(false, Ordering::Release); - eprintln!("live connection failed: {error}"); - requeue_reports(&pending, batch); - thread::sleep(LIVE_RECONNECT_DELAY); - continue; - } - } + wss_interval = (*interval).clamp(Duration::from_secs(1), Duration::from_secs(60)); } let payload = match live_update_payload(&batch) { Ok(payload) => payload, Err(error) => { eprintln!("live payload encode failed: {error}"); - requeue_reports(&pending, batch); + next_send_at = Instant::now() + wss_interval; continue; } }; let sent = socket .as_mut() - .is_some_and(|socket| socket.send(Message::Text(payload.clone().into())).is_ok()); + .is_some_and(|socket| socket.send(Message::Text(payload.into())).is_ok()); if !sent { socket = None; - healthy.store(false, Ordering::Release); + accepted_through = persisted_through.load(Ordering::Acquire); + next_send_at = Instant::now(); + next_probe_at = Instant::now(); thread::sleep(LIVE_RECONNECT_DELAY); - let mut resent = false; - if let Ok((mut connected, offset_ms)) = connect_live(endpoint, token) { - observe_clock(&clock, offset_ms); - if set_live_read_timeout(&mut connected, Some(LIVE_ACK_READ_TIMEOUT)).is_ok() - && connected.send(Message::Text(payload.into())).is_ok() - { - socket = Some(connected); - resent = true; - } - } - if !resent { - requeue_reports(&pending, batch); - } continue; } + stats.live_batch_sent(batch.len()); - next_send_at = Instant::now() + send_interval; let mut drop_socket = false; if let Some(socket) = socket.as_mut() { - if set_live_read_timeout(socket, Some(LIVE_ACK_READ_TIMEOUT)).is_err() { - drop_socket = true; - } else { - match read_live_ack(socket) { - Ok(Some(Duration::ZERO)) => drop_socket = true, - Ok(Some(interval)) => { - healthy.store(true, Ordering::Release); - send_interval = interval; - next_send_at = Instant::now() + send_interval; - } - Ok(None) => {} - Err(error) => { - eprintln!("live ACK read failed: {error}"); + match wait_for_live_ack(socket, &remote_config) { + Ok(LiveRead::Closed) => drop_socket = true, + Ok(LiveRead::Ack(ack)) => { + observe_persisted_through(&persisted_through, &ack); + if ack.persistence_error { + stats.live_persistence_failed(); drop_socket = true; + } else { + prune_live_queue(&pending, ack.persisted_through_ts); + if let Some(timestamp) = batch.iter().map(|report| report.timestamp).max() { + accepted_through = accepted_through.max(timestamp); + } + accepted_through = accepted_through.max(ack.persisted_through_ts); + wss_interval = ack_wss_interval(&ack); + next_send_at = Instant::now() + wss_interval; + next_probe_at = Instant::now() + ack_d1_interval(&ack); } } + Ok(LiveRead::Config(config)) => { + if let Ok(mut target) = remote_config.lock() { + *target = Some(config); + } + drop_socket = true; + } + Ok(LiveRead::Pending) => { + eprintln!("live ACK timed out"); + drop_socket = true; + } + Err(error) => { + eprintln!("live ACK read failed: {error}"); + drop_socket = true; + } } } if drop_socket { socket = None; - healthy.store(false, Ordering::Release); + accepted_through = persisted_through.load(Ordering::Acquire); + next_send_at = Instant::now(); + next_probe_at = Instant::now(); + thread::sleep(LIVE_RECONNECT_DELAY); } } } @@ -1887,15 +2071,13 @@ fn valid_endpoint(value: &str) -> bool { } } -fn report_retry_delay(failures: u32, report_interval: u64) -> Duration { - if failures == 0 { - return Duration::from_secs(report_interval); - } - let exponent = failures.saturating_sub(1).min(16); - REPORT_RETRY_MIN - .checked_mul(1_u32 << exponent) - .unwrap_or(REPORT_RETRY_MAX) - .min(REPORT_RETRY_MAX) +fn update_check_jitter(token: &str) -> Duration { + let digest = Sha256::digest(token.as_bytes()); + let mut bytes = [0_u8; 8]; + bytes.copy_from_slice(&digest[..8]); + Duration::from_secs( + u64::from_be_bytes(bytes) % UPDATE_CHECK_JITTER_MAX_SECONDS.saturating_add(1), + ) } fn prune_report_samples(samples: &mut Vec, now: i64) { @@ -1912,6 +2094,83 @@ fn prune_report_samples(samples: &mut Vec, now: i64) { } } +fn pending_spool_path(token_file: &Path) -> PathBuf { + let name = token_file + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("nodeflare-token"); + token_file + .parent() + .unwrap_or_else(|| Path::new(".")) + .join(format!(".{name}.pending.jsonl")) +} + +fn load_pending_spool(path: &Path) -> Result> { + let file = match fs::File::open(path) { + Ok(file) => file, + Err(error) if error.kind() == ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(error.into()), + }; + if file.metadata()?.len() > MAX_PENDING_SPOOL_BYTES { + return Err("pending report spool is too large".into()); + } + let mut samples = Vec::new(); + for line in BufReader::new(file).lines() { + let line = line?; + if line.len() > MAX_PENDING_SPOOL_LINE_BYTES { + continue; + } + if let Ok(report) = serde_json::from_str::(&line) { + samples.push(report); + } + } + if samples.len() > LIVE_QUEUE_CAPACITY { + let overflow = samples.len() - LIVE_QUEUE_CAPACITY; + samples.drain(..overflow); + } + Ok(samples) +} + +fn append_pending_spool(path: &Path, report: &Report) -> Result<()> { + let mut file = OpenOptions::new().create(true).append(true).open(path)?; + #[cfg(unix)] + fs::set_permissions(path, fs::Permissions::from_mode(0o600))?; + serde_json::to_writer(&mut file, report)?; + file.write_all(b"\n")?; + Ok(()) +} + +fn rewrite_pending_spool(path: &Path, samples: &[Report]) -> Result<()> { + if samples.is_empty() { + return match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.into()), + }; + } + let temporary = path.with_file_name(format!( + ".{}.{}.tmp", + path.file_name() + .and_then(|value| value.to_str()) + .unwrap_or("pending"), + std::process::id() + )); + let mut file = fs::File::create(&temporary)?; + #[cfg(unix)] + fs::set_permissions(&temporary, fs::Permissions::from_mode(0o600))?; + for report in samples { + serde_json::to_writer(&mut file, report)?; + file.write_all(b"\n")?; + } + file.sync_data()?; + #[cfg(target_os = "windows")] + if path.exists() { + fs::remove_file(path)?; + } + fs::rename(&temporary, path)?; + Ok(()) +} + fn normalized_version(value: &str) -> &str { value.trim().strip_prefix('v').unwrap_or(value.trim()) } @@ -1928,10 +2187,11 @@ fn version_triplet(value: &str) -> Option<(u64, u64, u64)> { fn agent_artifact_name() -> Option<&'static str> { match (env::consts::OS, env::consts::ARCH) { - ("linux", "x86_64") => Some("agent-linux-x86_64"), + ("linux", "x86_64") => Some("agent-linux-x64"), ("linux", "aarch64") => Some("agent-linux-aarch64"), - ("windows", "x86_64") => Some("agent-windows-x86_64.exe"), + ("windows", "x86_64") => Some("agent-windows-x64.exe"), ("macos", "aarch64") => Some("agent-macos-aarch64"), + ("freebsd", "x86_64") => Some("agent-freebsd-x64"), _ => None, } } @@ -1945,7 +2205,7 @@ fn executable_format_valid(path: &Path) -> bool { return false; } match env::consts::OS { - "linux" => magic == *b"\x7fELF", + "linux" | "freebsd" => magic == *b"\x7fELF", "windows" => magic.starts_with(b"MZ"), "macos" => matches!( magic, @@ -2134,32 +2394,86 @@ fn run(options: &CliOptions, once: bool, print_only: bool) -> Result<()> { .build() .into(); let mut collector = Collector::new(&config); - let mut next_report = Instant::now(); let mut next_collect = Instant::now() + Duration::from_secs(config.collect_interval); let mut next_latency: HashMap = HashMap::new(); - let mut latency_executor = LatencyExecutor::new()?; + let stats = Arc::new(runtime_stats::RuntimeStats::default()); + let mut latency_executor = LatencyExecutor::new(Arc::clone(&stats))?; let mut pending_results = Vec::new(); - let mut pending_samples = Vec::new(); - let mut report_failures = 0_u32; - let mut next_update_check = Instant::now(); - let mut config_hash = String::new(); - let mut live_was_healthy = false; - let clock = Arc::new(Mutex::new(ClockCalibration::default())); - if !print_only { - match fetch_clock_offset(&agent, &config.endpoint) { - Ok(offset_ms) => observe_clock(&clock, offset_ms), - Err(error) => eprintln!("server time calibration failed: {error}"), + let spool_path = pending_spool_path(&options.token_file); + let mut pending_samples = load_pending_spool(&spool_path).unwrap_or_else(|error| { + eprintln!("pending report spool ignored: {error}"); + Vec::new() + }); + let loaded_len = pending_samples.len(); + let loaded_latency = pending_samples + .iter() + .map(|report| report.latency_results.len()) + .sum::(); + prune_report_samples(&mut pending_samples, unix_timestamp()); + if loaded_len != pending_samples.len() + || loaded_latency + != pending_samples + .iter() + .map(|report| report.latency_results.len()) + .sum::() + { + if let Err(error) = rewrite_pending_spool(&spool_path, &pending_samples) { + eprintln!("pending report spool cleanup failed: {error}"); } } - let live = (!once && !print_only) - .then(|| LiveSender::start(&config, Arc::clone(&clock))) + let mut last_emitted_timestamp = pending_samples + .iter() + .map(|report| report.timestamp) + .max() + .unwrap_or(0); + let mut next_update_check = Instant::now() + update_check_jitter(&config.token); + let mut next_stats_log = Instant::now() + RUNTIME_STATS_INTERVAL; + let clock = Arc::new(Mutex::new(ClockCalibration::default())); + let live = (!print_only) + .then(|| LiveSender::start(&config, Arc::clone(&clock), Arc::clone(&stats))) .transpose()?; - loop { - let live_healthy = live.as_ref().is_some_and(LiveSender::is_healthy); - if live_was_healthy && !live_healthy { - next_report = Instant::now(); + if let Some(live) = &live { + for report in &pending_samples { + live.send(report); + } + } + let once_deadline = (once && !print_only).then(|| Instant::now() + ONCE_WSS_TIMEOUT); + let mut once_target_timestamp = None; + loop { + if let Some(live) = &live { + let persisted_through = live.persisted_through(); + let before = pending_samples.len(); + pending_samples.retain(|report| report.timestamp > persisted_through); + stats.persisted_samples_pruned(before.saturating_sub(pending_samples.len())); + if before != pending_samples.len() { + if let Err(error) = rewrite_pending_spool(&spool_path, &pending_samples) { + eprintln!("pending report spool compaction failed: {error}"); + } + } + if once_target_timestamp.is_some_and(|target| persisted_through >= target) { + return Ok(()); + } + if let Some(remote) = live.take_remote_config() { + let previous_collect_interval = config.collect_interval; + let previous_report_interval = config.report_interval; + if apply_remote(&mut config, &remote) { + next_latency.clear(); + } + if config.collect_interval != previous_collect_interval { + next_collect = Instant::now() + Duration::from_secs(config.collect_interval); + } + if config.collect_interval != previous_collect_interval + || config.report_interval != previous_report_interval + { + live.set_send_interval(config.report_interval, config.collect_interval); + } + } + } + if once_target_timestamp.is_some() + && once_deadline.is_some_and(|deadline| Instant::now() >= deadline) + { + return Err("WSS report persistence acknowledgement timed out".into()); } - live_was_healthy = live_healthy; pending_results.extend(latency_executor.drain()); let current = Instant::now(); let due = config @@ -2183,23 +2497,41 @@ fn run(options: &CliOptions, once: bool, print_only: bool) -> Result<()> { } } - if Instant::now() >= next_collect { + if Instant::now() >= next_collect && (!once || once_target_timestamp.is_none()) { let offset_ms = shared_clock_offset(&clock); let mut latest_results = HashMap::new(); for mut result in std::mem::take(&mut pending_results) { result.timestamp = corrected_timestamp(result.timestamp, offset_ms); latest_results.insert(result.task_id.clone(), result); } + let collection_started = Instant::now(); let mut report = collector.collect(&config, latest_results.into_values().collect(), 0); - report.timestamp = corrected_timestamp(unix_timestamp(), offset_ms); + stats.collection_finished(collection_started.elapsed()); + let persisted_through = live.as_ref().map_or(0, LiveSender::persisted_through); + report.timestamp = monotonic_report_timestamp( + corrected_timestamp(unix_timestamp(), offset_ms), + last_emitted_timestamp, + persisted_through, + ); + last_emitted_timestamp = report.timestamp; if print_only { println!("{}", serde_json::to_string_pretty(&report)?); return Ok(()); } + append_pending_spool(&spool_path, &report) + .map_err(|error| format!("pending report spool append failed: {error}"))?; if let Some(live) = &live { live.send(&report); } + if once { + once_target_timestamp = Some(report.timestamp); + } pending_samples.push(report); + let before_prune = pending_samples.len(); + let latency_before_prune = pending_samples + .iter() + .map(|report| report.latency_results.len()) + .sum::(); prune_report_samples( &mut pending_samples, corrected_timestamp(unix_timestamp(), offset_ms), @@ -2207,6 +2539,18 @@ fn run(options: &CliOptions, once: bool, print_only: bool) -> Result<()> { if pending_samples.len() > LIVE_QUEUE_CAPACITY { let overflow = pending_samples.len() - LIVE_QUEUE_CAPACITY; pending_samples.drain(..overflow); + stats.live_queue_dropped(overflow); + } + if before_prune != pending_samples.len() + || latency_before_prune + != pending_samples + .iter() + .map(|report| report.latency_results.len()) + .sum::() + { + if let Err(error) = rewrite_pending_spool(&spool_path, &pending_samples) { + eprintln!("pending report spool compaction failed: {error}"); + } } next_collect = advance_deadline( next_collect, @@ -2215,85 +2559,41 @@ fn run(options: &CliOptions, once: bool, print_only: bool) -> Result<()> { ); } - if Instant::now() >= next_report && !pending_samples.is_empty() { - let sync_result = if live_healthy { - fetch_remote_config(&agent, &config, &config_hash).map(|result| (result, false)) - } else { - submit(&agent, &config, &pending_samples, &config_hash).map(|result| (result, true)) - }; - let sync_failed = sync_result.is_err(); - match sync_result { - Ok((result, submitted_metrics)) => { - observe_clock(&clock, result.clock_offset_ms); - if submitted_metrics { - pending_samples.clear(); - } - report_failures = 0; - config_hash = result.config_hash; - if let Some(remote) = result.config { - let previous_collect_interval = config.collect_interval; - let previous_report_interval = config.report_interval; - if apply_remote(&mut config, &remote) { - next_latency.clear(); - } - if config.collect_interval != previous_collect_interval { - next_collect = - Instant::now() + Duration::from_secs(config.collect_interval); - } - if config.collect_interval != previous_collect_interval - || config.report_interval != previous_report_interval - { - if let Some(live) = &live { - live.set_send_interval( - config.report_interval, - config.collect_interval, - ); - } - } - } - if !once && config.auto_update && Instant::now() >= next_update_check { - match update(&agent, &config.agent_mirror) { - Ok(true) => return Ok(()), - Ok(false) => { - next_update_check = Instant::now() + UPDATE_CHECK_INTERVAL; - } - Err(error) => { - eprintln!("agent update failed: {error}"); - next_update_check = Instant::now() + UPDATE_RETRY_INTERVAL; - } - } - } - } + if !once + && config.auto_update + && pending_samples.is_empty() + && Instant::now() >= next_update_check + { + match update(&agent, &config.agent_mirror) { + Ok(true) => return Ok(()), + Ok(false) => next_update_check = Instant::now() + UPDATE_CHECK_INTERVAL, Err(error) => { - if live_healthy { - eprintln!("config sync failed: {error}"); - } else { - eprintln!("report failed: {error}"); - report_failures = report_failures.saturating_add(1); - } - if once { - return Err(error); - } + eprintln!("agent update failed: {error}"); + next_update_check = Instant::now() + UPDATE_RETRY_INTERVAL; } } - let retry_delay = if live_healthy && sync_failed { - REPORT_RETRY_MIN - } else { - report_retry_delay(report_failures, config.report_interval) - }; - next_report = Instant::now() + retry_delay; - if once { - return Ok(()); - } + } + if Instant::now() >= next_stats_log { + stats.log_and_reset(); + next_stats_log = Instant::now() + RUNTIME_STATS_INTERVAL; } + let collect_wake_at = if once_target_timestamp.is_some() { + once_deadline.unwrap_or(next_collect) + } else { + next_collect + }; + let maintenance_wake_at = if config.auto_update && pending_samples.is_empty() { + next_stats_log.min(next_update_check) + } else { + next_stats_log + }; let wake_at = next_latency .values() .copied() .min() - .map_or(next_report.min(next_collect), |deadline| { - deadline.min(next_report).min(next_collect) - }); + .map_or(collect_wake_at, |deadline| deadline.min(collect_wake_at)) + .min(maintenance_wake_at); let wait = wake_at .saturating_duration_since(Instant::now()) .min(Duration::from_secs(1)); @@ -2315,19 +2615,22 @@ fn main() { #[cfg(test)] mod tests { use clap::Parser; + use std::path::PathBuf; use std::time::{Duration, Instant}; use super::{ - ack_interval, advance_deadline, clock_offset_from_http_date, corrected_timestamp, - gpu_name_from_uevent, is_public_probe_ip, live_endpoint, live_update_payload, - normalized_version, parse_lspci_gpu_names, parse_probe_target, - parse_system_profiler_gpu_names, ping_latency, prune_report_samples, release_asset_sha256, - sanitize_latency_tasks, valid_endpoint, version_triplet, CliOptions, ClockCalibration, - GithubReleaseAsset, LatencyResult, LatencyTask, LiveAck, Report, CLOCK_CALIBRATION_MAX_AGE, - MAX_LATENCY_TASKS, MAX_PENDING_LATENCY_RESULTS, + ack_d1_interval, ack_wss_interval, advance_deadline, clock_offset_from_http_date, + corrected_timestamp, gpu_name_from_uevent, is_public_probe_ip, live_endpoint, + live_update_payload, monotonic_report_timestamp, normalized_version, parse_lspci_gpu_names, + parse_pciconf_gpu_names, parse_probe_target, parse_system_profiler_gpu_names, + ping_latencies, ping_latency, prune_report_samples, release_asset_sha256, + sanitize_latency_tasks, update_check_jitter, valid_endpoint, version_triplet, CliOptions, + ClockCalibration, GithubReleaseAsset, LatencyResult, LatencyTask, LiveAck, Report, + CLOCK_CALIBRATION_MAX_AGE, MAX_LATENCY_TASKS, MAX_PENDING_LATENCY_RESULTS, + UPDATE_CHECK_JITTER_MAX_SECONDS, }; - #[cfg(any(target_os = "windows", target_os = "macos"))] + #[cfg(any(target_os = "windows", target_os = "macos", target_os = "freebsd"))] use super::connection_counts_from_netstat; #[cfg(target_os = "linux")] use super::disk_device; @@ -2348,6 +2651,19 @@ mod tests { let profiler = "Graphics/Displays:\n\n Apple M3 Max:\n\n Chipset Model: Apple M3 Max\n Metal Support: Metal 3"; assert_eq!(parse_system_profiler_gpu_names(profiler), ["Apple M3 Max"]); + let pciconf = "vgapci0@pci0:0:2:0:\tclass=0x030000 rev=0x02 hdr=0x00 vendor=0x8086\n\ + \tvendor = 'Intel Corporation'\n\ + \tdevice = 'UHD Graphics 630'\n\ + \tclass = display\n\ + em0@pci0:0:25:0:\tclass=0x020000 rev=0x05 hdr=0x00 vendor=0x8086\n\ + \tvendor = 'Intel Corporation'\n\ + \tdevice = 'Ethernet Connection'\n\ + \tclass = network"; + assert_eq!( + parse_pciconf_gpu_names(pciconf), + ["Intel Corporation UHD Graphics 630"] + ); + assert_eq!( gpu_name_from_uevent("DRIVER=i915\nPCI_CLASS=30000\nPCI_ID=8086:591B"), Some("Intel Integrated Graphics".to_string()) @@ -2380,7 +2696,7 @@ mod tests { fn validates_release_asset_digests() { let hash = "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF"; let mut asset = GithubReleaseAsset { - name: "agent-linux-x86_64".to_string(), + name: "agent-linux-x64".to_string(), browser_download_url: "https://example.com/agent".to_string(), digest: Some(format!("sha256:{hash}")), }; @@ -2414,31 +2730,37 @@ mod tests { fn builds_live_websocket_endpoints() { assert_eq!( live_endpoint("https://monitor.example.com").unwrap(), - "wss://monitor.example.com/api/agent/live" + "wss://monitor.example.com/api/agent/ws" ); assert_eq!( live_endpoint("https://monitor.example.com/base/").unwrap(), - "wss://monitor.example.com/base/api/agent/live" + "wss://monitor.example.com/base/api/agent/ws" ); assert_eq!( live_endpoint("http://127.0.0.1:8787").unwrap(), - "ws://127.0.0.1:8787/api/agent/live" + "ws://127.0.0.1:8787/api/agent/ws" ); } #[test] fn accepts_server_realtime_ack_interval() { let ack: LiveAck = serde_json::from_str( - r#"{"type":"ack","ts":100,"persisted":false,"nextD1WriteAfterMs":60000,"nextWssReportAfterMs":5000,"realtimeHint":false}"#, + r#"{"type":"ack","ts":100,"persisted":false,"persistenceError":false,"persistedThroughTs":90,"nextD1WriteAfterMs":60000,"nextWssReportAfterMs":5000,"realtimeHint":false}"#, ) .unwrap(); - assert_eq!(ack_interval(&ack), Duration::from_secs(5)); + assert_eq!(ack_wss_interval(&ack), Duration::from_secs(5)); + assert_eq!(ack_d1_interval(&ack), Duration::from_secs(60)); let slow: LiveAck = serde_json::from_str( - r#"{"type":"ack","ts":100,"persisted":true,"nextD1WriteAfterMs":60000,"nextWssReportAfterMs":1,"realtimeHint":true}"#, + r#"{"type":"ack","ts":100,"persisted":true,"persistenceError":false,"persistedThroughTs":100,"nextD1WriteAfterMs":60000,"nextWssReportAfterMs":1,"realtimeHint":true}"#, ) .unwrap(); - assert_eq!(ack_interval(&slow), Duration::from_secs(1)); + assert_eq!(ack_wss_interval(&slow), Duration::from_secs(1)); assert!(slow.realtime_hint); + let idle: LiveAck = serde_json::from_str( + r#"{"type":"ack","ts":100,"persisted":true,"persistenceError":false,"persistedThroughTs":100,"nextD1WriteAfterMs":120000,"nextWssReportAfterMs":3600000,"realtimeHint":false}"#, + ) + .unwrap(); + assert_eq!(ack_d1_interval(&idle), Duration::from_secs(120)); } #[test] @@ -2475,6 +2797,9 @@ mod tests { ); assert_eq!(corrected_timestamp(100, 2_500), 102); assert_eq!(corrected_timestamp(100, -2_500), 97); + assert_eq!(monotonic_report_timestamp(97, 100, 90), 101); + assert_eq!(monotonic_report_timestamp(110, 100, 120), 121); + assert_eq!(monotonic_report_timestamp(130, 120, 110), 130); } #[test] @@ -2494,25 +2819,32 @@ mod tests { "nodeflare", "-e", "https://monitor.example.com", - "-t", - "secret", + "--token-file", + "/run/nodeflare/token", "-i", "60", "--once", ]) .unwrap(); assert_eq!(parsed.endpoint, "https://monitor.example.com"); - assert_eq!(parsed.token, "secret"); + assert_eq!(parsed.token_file, PathBuf::from("/run/nodeflare/token")); assert_eq!(parsed.interval, 60); assert!(parsed.once); - assert!(CliOptions::try_parse_from(["nodeflare", "-t"]).is_err()); - assert!(CliOptions::try_parse_from(["nodeflare", "-t", "first", "-t", "second"]).is_err()); + assert!(CliOptions::try_parse_from(["nodeflare", "--token-file"]).is_err()); + assert!(CliOptions::try_parse_from([ + "nodeflare", + "--token-file", + "first", + "--token-file", + "second" + ]) + .is_err()); assert!(CliOptions::try_parse_from([ "nodeflare", "-e", "https://monitor.example.com", - "-t", - "secret", + "--token-file", + "/run/nodeflare/token", "-s", "server-a", ]) @@ -2608,7 +2940,7 @@ mod tests { assert_eq!(samples.last().unwrap().latency_results.len(), 1); } - #[cfg(any(target_os = "windows", target_os = "macos"))] + #[cfg(any(target_os = "windows", target_os = "macos", target_os = "freebsd"))] #[test] fn parses_netstat_connection_counts() { let output = "tcp4 0 0 host.443 peer.1 ESTABLISHED\nudp4 0 0 *.5353 *.*\nTCP host peer ESTABLISHED\n"; @@ -2671,5 +3003,16 @@ mod tests { assert_eq!(ping_latency("64 bytes time=12.34 ms"), Some(12.34)); assert_eq!(ping_latency("64 bytes time<1 ms"), Some(0.5)); assert_eq!(ping_latency("unreachable"), None); + assert_eq!( + ping_latencies("reply time=12.34 ms\nrequest timeout\nreply time=8.5 ms"), + vec![12.34, 8.5] + ); + } + + #[test] + fn update_check_jitter_is_stable_and_bounded() { + let first = update_check_jitter("agent-token"); + assert_eq!(first, update_check_jitter("agent-token")); + assert!(first.as_secs() <= UPDATE_CHECK_JITTER_MAX_SECONDS); } } diff --git a/agent/src/runtime_stats.rs b/agent/src/runtime_stats.rs new file mode 100644 index 0000000..c87a650 --- /dev/null +++ b/agent/src/runtime_stats.rs @@ -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), + }) + ); + } +} diff --git a/bun.lock b/bun.lock index a2c59f1..9bc4b6b 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], } } diff --git a/frontend/package.json b/frontend/package.json index 4a4946e..98fc25e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3560035..bd9eb19 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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; - 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 & { latency_results?: LiveLatencyResult[] }; -} - -function mergeLiveResults(previous: LiveLatencyResult[] | undefined, incoming: LiveLatencyResult[]): LiveLatencyResult[] { - const results = new Map(); - 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 ( +
+ +
+

{ui(locale, "登录仪表盘", "Sign in to dashboard")}

+

{ui(locale, "此仪表盘仅限登录后访问", "This dashboard requires an administrator sign-in")}

+
+
void submit(event)}> + + + {config.turnstile_login_enabled ?
: null} + {error ?

{error}

: null} + +
+
+ ); } -function mergeLiveLatency(server: Server, results: LiveLatencyResult[]): LatencySample[] { - const latest = new Map(); - 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 ( +
+ +

{ui(locale, "访问验证", "Access verification")}

{config.site_name}

+ {config.turnstile_site_key + ? void run(token)} onError={setError} /> + :

{ui(locale, "Turnstile 尚未正确配置", "Turnstile is not configured")}

} + {busy ? {ui(locale, "正在验证", "Verifying")} : null} + {error ?

{error}

: null} +
+ ); } -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([]); - const [liveMetrics, setLiveMetrics] = useState>({}); - const [clockNow, setClockNow] = useState(() => Date.now()); - const [exchangeRates, setExchangeRates] = useState(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(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([]); - const liveConnectedRef = useRef(false); - const playbackRef = useRef>(new Map()); - const serversRef = useRef([]); - 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('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([ - ...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 ( +
+ + {config.show_search || config.show_groups ?
+ {config.show_search ?
setQuery(event.target.value)} />
: null} + {config.show_groups ?
{groups.map((item) => )}
: null} + {ui(locale, `${visible.length} 个节点`, `${visible.length} servers`)} +
: null} + {error ?
{error}
: null} + {loading && !servers.length ? ( +
{Array.from({ length: 8 }).map((_, index) => )}
+ ) : visible.length ? ( +
+ {visible.map((server) => openServer(server)} />)} +
+ ) : !error ? ( +
{servers.length ? ui(locale, "没有匹配的节点", "No matching servers") : ui(locale, "尚未添加节点", "No servers added")}
+ ) : null} +
+ ); +} - 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 ( + {ui(locale, "正在加载节点", "Loading server")}}> + + + ); } + if (loading) return
{ui(locale, "正在加载节点", "Loading server")}
; + return ( +
+ {ui(locale, "节点不存在或已隐藏", "Server not found or hidden")} + +
+ ); +} - 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
{error ?
{error}
: null}
; + return ( +
+ {error ?
{error}
: null} +
+ ); } return ( @@ -449,50 +172,19 @@ export default function App() {
{config.site_name}
- - + +
{config.site_announcement ?
{config.site_announcement}
: null} - {needsLogin ? ( -
- -

{ui(config.locale, "登录仪表盘", "Sign in to dashboard")}

{ui(config.locale, "此仪表盘仅限登录后访问", "This dashboard requires an administrator sign-in")}

-
void loginDashboard(event)}> - - - {config.turnstile_login_enabled ?
: null} - {error ?

{error}

: null} - -
-
- ) : needsVerification ? ( -
- -

{ui(config.locale, "访问验证", "Access verification")}

{config.site_name}

- {config.turnstile_site_key ? void verifyDashboard(token)} onError={setError} /> :

{ui(config.locale, "Turnstile 尚未正确配置", "Turnstile is not configured")}

} - {verificationBusy ? {ui(config.locale, "正在验证", "Verifying")} : null} - {error ?

{error}

: null} -
- ) : selectedId ? selected ? ( - {ui(config.locale, "正在加载节点", "Loading server")}}> - ) : loading ?
{ui(config.locale, "正在加载节点", "Loading server")}
:
{ui(config.locale, "节点不存在或已隐藏", "Server not found or hidden")}
:
- - {config.show_search || config.show_groups ?
- {config.show_search ?
setQuery(event.target.value)} />
: null} - {config.show_groups ?
{groups.map((item) => )}
: null} - {ui(config.locale, `${visible.length} 个节点`, `${visible.length} servers`)} -
: null} - {error ?
{error}
: null} - {loading && !liveServers.length ?
{Array.from({ length: 8 }).map((_, index) => )}
: visible.length ? ( -
{visible.map((server) => openServer(server)} />)}
- ) : !error ?
{liveServers.length ? ui(config.locale, "没有匹配的节点", "No matching servers") : ui(config.locale, "尚未添加节点", "No servers added")}
: null} -
} + {access === "login" ? + : access === "turnstile" ? + : selectedId ? + : }
- ); } diff --git a/frontend/src/AppProvider.tsx b/frontend/src/AppProvider.tsx new file mode 100644 index 0000000..0a75ad6 --- /dev/null +++ b/frontend/src/AppProvider.tsx @@ -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; + login: (username: string, password: string, turnstileToken: string) => Promise; + verify: (token: string) => Promise; +} + +const AppContext = createContext(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([]); + const [liveMetrics, setLiveMetrics] = useState({}); + const [clockNow, setClockNow] = useState(() => Date.now()); + const [exchangeRates, setExchangeRates] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [access, setAccess] = useState("ok"); + const [selectedId, setSelectedId] = useState(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(new Map()); + const serversRef = useRef([]); + + 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('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(() => ({ + 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 {children}; +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index d6234fb..07db199 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -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(`/api/admin/alert-rules/${encodeURIComponent(id)}`, { method: "PATCH", body: JSON.stringify(input) }, true), deleteAlertRule: (id: string) => request(`/api/admin/alert-rules/${encodeURIComponent(id)}`, { method: "DELETE" }, true), + telegramSettings: () => request<{ telegram: TelegramSettings | null }>("/api/admin/telegram", {}, true), + saveTelegramSettings: (input: TelegramSettingsInput) => + request("/api/admin/telegram", { method: "PUT", body: JSON.stringify(input) }, true), + testTelegram: () => request("/api/admin/telegram/test", { method: "POST" }, true), themeSettings: () => request("/api/admin/theme-settings", {}, true), themes: () => request<{ themes: Theme[] }>("/api/admin/themes", {}, true), addTheme: (input: Pick) => @@ -117,5 +121,4 @@ export const api = { databaseStats: () => request("/api/admin/database", {}, true), cloudflareUsage: () => request("/api/admin/cloudflare-usage", {}, true), clearHistory: () => request("/api/admin/history", { method: "DELETE" }, true), - testNotification: () => request("/api/admin/notifications/test", { method: "POST" }, true), }; diff --git a/frontend/src/chart.ts b/frontend/src/chart.ts new file mode 100644 index 0000000..01aab27 --- /dev/null +++ b/frontend/src/chart.ts @@ -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( + 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 }); +} diff --git a/frontend/src/components/AdminPanel.tsx b/frontend/src/components/AdminPanel.tsx index 30ab90f..946e4cd 100644 --- a/frontend/src/components/AdminPanel.tsx +++ b/frontend/src/components/AdminPanel.tsx @@ -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(editing !== null, () => setEditing(null)); @@ -617,7 +614,7 @@ export function AdminPanel({