优化
This commit is contained in:
1 parent
e153d0ad10
commit
0cdcbe40f4
58 files changed
+5295
-2138
No files matched your search
@@ -1,9 +1,12 @@
|
||||
name: Build tagged release
|
||||
name: CI and tagged release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
@@ -16,28 +19,31 @@ permissions:
|
||||
|
||||
jobs:
|
||||
metadata:
|
||||
name: Validate tag
|
||||
name: Resolve build metadata
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
release: ${{ steps.version.outputs.release }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- id: version
|
||||
name: Validate three-part tag
|
||||
name: Resolve release version
|
||||
shell: bash
|
||||
env:
|
||||
RAW_VERSION: ${{ github.event_name == 'workflow_dispatch' && inputs.version || github.ref_name }}
|
||||
RAW_VERSION: ${{ github.event_name == 'workflow_dispatch' && inputs.version || startsWith(github.ref, 'refs/tags/') && github.ref_name || 'v0.0.0' }}
|
||||
RELEASE_BUILD: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/') }}
|
||||
run: |
|
||||
if [[ ! "$RAW_VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
if [ "$RELEASE_BUILD" = "true" ] && [[ ! "$RAW_VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Tag must use the v-prefixed format v1.2.3" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
version="${RAW_VERSION#v}"
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
echo "release=$RELEASE_BUILD" >> "$GITHUB_OUTPUT"
|
||||
|
||||
quality:
|
||||
name: Quality checks
|
||||
@@ -56,9 +62,13 @@ jobs:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: 1.3.14
|
||||
bun-version: 1.4.0
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Typecheck frontend
|
||||
run: bunx --bun tsc -b frontend
|
||||
- name: Test frontend
|
||||
run: bun test --cwd frontend
|
||||
- name: Build frontend
|
||||
run: bun run build:frontend
|
||||
- name: Check Rust formatting
|
||||
@@ -115,6 +125,7 @@ jobs:
|
||||
agent:
|
||||
name: Agent (${{ matrix.platform }})
|
||||
needs: metadata
|
||||
if: needs.metadata.outputs.release == 'true'
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
@@ -128,22 +139,24 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: Linux x86_64
|
||||
- platform: Linux x64
|
||||
runner: ubuntu-latest
|
||||
target: x86_64-unknown-linux-musl
|
||||
asset: agent-linux-x86_64
|
||||
asset: agent-linux-x64
|
||||
extension: ""
|
||||
cross: true
|
||||
static_linux: true
|
||||
- platform: Linux ARM64
|
||||
runner: ubuntu-latest
|
||||
target: aarch64-unknown-linux-musl
|
||||
asset: agent-linux-aarch64
|
||||
extension: ""
|
||||
cross: true
|
||||
- platform: Windows x86_64
|
||||
static_linux: true
|
||||
- platform: Windows x64
|
||||
runner: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
asset: agent-windows-x86_64.exe
|
||||
asset: agent-windows-x64.exe
|
||||
extension: .exe
|
||||
cross: false
|
||||
- platform: macOS ARM64
|
||||
@@ -152,6 +165,12 @@ jobs:
|
||||
asset: agent-macos-aarch64
|
||||
extension: ""
|
||||
cross: false
|
||||
- platform: FreeBSD x64
|
||||
runner: ubuntu-latest
|
||||
target: x86_64-unknown-freebsd
|
||||
asset: agent-freebsd-x64
|
||||
extension: ""
|
||||
cross: true
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
@@ -159,7 +178,7 @@ jobs:
|
||||
- uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- name: Install Linux cross compiler
|
||||
- name: Install cross compiler
|
||||
if: matrix.cross
|
||||
run: cargo install cross --version 0.2.5 --locked
|
||||
- name: Build Rust Agent with cross
|
||||
@@ -210,13 +229,28 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
- name: Verify static Linux binary
|
||||
if: matrix.cross
|
||||
if: matrix.static_linux
|
||||
shell: bash
|
||||
run: |
|
||||
if readelf -l "release/${{ matrix.asset }}" | grep -q 'Requesting program interpreter'; then
|
||||
echo "Linux Agent must be statically linked" >&2
|
||||
exit 1
|
||||
fi
|
||||
- name: Verify FreeBSD Agent in FreeBSD
|
||||
if: matrix.target == 'x86_64-unknown-freebsd'
|
||||
uses: vmactions/freebsd-vm@83b151f58c6047089f4c80eb5ba2039d158ce093 # v1.5.3
|
||||
with:
|
||||
release: "15"
|
||||
envs: NODEFLARE_VERSION
|
||||
copyback: false
|
||||
run: |
|
||||
set -eu
|
||||
version_output=$(./release/agent-freebsd-x64 --version)
|
||||
actual_version=${version_output##* }
|
||||
if [ "$actual_version" != "$NODEFLARE_VERSION" ]; then
|
||||
echo "Agent version $actual_version does not match tag $NODEFLARE_VERSION" >&2
|
||||
exit 1
|
||||
fi
|
||||
- uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4
|
||||
with:
|
||||
subject-path: release/${{ matrix.asset }}
|
||||
@@ -229,6 +263,7 @@ jobs:
|
||||
frontend:
|
||||
name: Frontend
|
||||
needs: metadata
|
||||
if: needs.metadata.outputs.release == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
@@ -244,7 +279,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: 1.3.14
|
||||
bun-version: 1.4.0
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Build public frontend
|
||||
@@ -262,7 +297,7 @@ jobs:
|
||||
|
||||
release:
|
||||
name: Publish GitHub Release
|
||||
if: github.event_name == 'push'
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
needs: [metadata, quality, agent, frontend]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
/build/
|
||||
/target/
|
||||
/agent/target/
|
||||
/node_modules/
|
||||
/frontend/dist/
|
||||
/frontend/admin-dist/
|
||||
/frontend/node_modules/
|
||||
/.wrangler/
|
||||
/.dev.vars
|
||||
*.log
|
||||
*.tsbuildinfo
|
||||
Generated
+13
-13
@@ -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]]
|
||||
|
||||
@@ -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` | 发送一条通知测试消息 |
|
||||
|
||||
## 主题
|
||||
|
||||
|
||||
Generated
+19
-19
@@ -37,9 +37,9 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.4.3"
|
||||
version = "1.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d"
|
||||
checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
@@ -92,7 +92,7 @@ dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
"syn 3.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -185,7 +185,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
"syn 3.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -354,9 +354,9 @@ checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
|
||||
|
||||
[[package]]
|
||||
name = "icu_provider"
|
||||
version = "2.3.0"
|
||||
version = "2.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428"
|
||||
checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_locale_core",
|
||||
@@ -424,9 +424,9 @@ checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.33"
|
||||
version = "0.4.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
@@ -591,9 +591,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.14"
|
||||
version = "0.103.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a"
|
||||
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
@@ -627,7 +627,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
"syn 3.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -708,9 +708,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.3"
|
||||
version = "3.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
|
||||
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -759,7 +759,7 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
"syn 3.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1198,9 +1198,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zerovec"
|
||||
version = "0.11.7"
|
||||
version = "0.11.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8"
|
||||
checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
|
||||
dependencies = [
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
@@ -1209,13 +1209,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zerovec-derive"
|
||||
version = "0.11.4"
|
||||
version = "0.11.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47402523226a02bfe5230160dc3ccc089aa6f6f19e7fcbb4e6f824bbb1b4aa62"
|
||||
checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
"syn 3.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ ureq = { version = "3.4", default-features = false, features = ["json", "rustls"
|
||||
url = "2.5"
|
||||
tungstenite = { version = "0.30", default-features = false, features = ["handshake", "rustls-tls-webpki-roots"] }
|
||||
|
||||
[target.'cfg(any(target_os = "windows", target_os = "macos"))'.dependencies]
|
||||
[target.'cfg(any(target_os = "windows", target_os = "macos", target_os = "freebsd"))'.dependencies]
|
||||
sysinfo = { version = "0.39.6", default-features = false, features = ["disk", "network", "system"] }
|
||||
|
||||
[profile.release]
|
||||
|
||||
+7
-3
@@ -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" \
|
||||
|
||||
Executable
+221
@@ -0,0 +1,221 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
SERVICE_NAME="nodeflare"
|
||||
INSTALL_DIR="/usr/local/libexec/nodeflare"
|
||||
AGENT_FILE="$INSTALL_DIR/agent"
|
||||
TOKEN_FILE="$INSTALL_DIR/token"
|
||||
SERVICE_FILE="/usr/local/etc/rc.d/$SERVICE_NAME"
|
||||
|
||||
log() {
|
||||
printf '[NodeFlare] %s\n' "$1"
|
||||
}
|
||||
|
||||
fail() {
|
||||
printf '[NodeFlare] 错误:%s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
NodeFlare Agent FreeBSD 安装脚本
|
||||
|
||||
用法:
|
||||
install-freebsd.sh -e <Worker URL> -t <Agent Token> [-i <上报间隔>] [-m <下载加速前缀>]
|
||||
install-freebsd.sh --status
|
||||
install-freebsd.sh --uninstall
|
||||
|
||||
仅支持 FreeBSD amd64。Agent Token 请勿泄露。
|
||||
-m 为可选的 GitHub 下载加速前缀(形如 https://ghproxy.net),
|
||||
仅作用于 Release 下载,摘要校验不受影响。
|
||||
EOF
|
||||
}
|
||||
|
||||
safe_value() {
|
||||
case "$1" in *[!A-Za-z0-9_./:@-]*|'') return 1 ;; esac
|
||||
}
|
||||
|
||||
download_stdout() {
|
||||
url="$1"
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl --fail --location --silent --show-error --max-time 30 \
|
||||
-H 'Accept: application/vnd.github+json' \
|
||||
-H 'User-Agent: nodeflare-installer' \
|
||||
"$url"
|
||||
else
|
||||
fetch -q -T 30 -o - "$url"
|
||||
fi
|
||||
}
|
||||
|
||||
download_file() {
|
||||
url="$1"
|
||||
destination="$2"
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl --fail --location --silent --show-error --max-time 120 \
|
||||
"$url" -o "$destination"
|
||||
else
|
||||
fetch -q -T 120 -o "$destination" "$url"
|
||||
fi
|
||||
}
|
||||
|
||||
if [ "${1:-}" = "--uninstall" ]; then
|
||||
[ "$#" -eq 1 ] || fail "--uninstall 不接受其它参数"
|
||||
[ "$(id -u)" -eq 0 ] || fail "请使用 root 权限执行卸载"
|
||||
log "正在停止并移除 NodeFlare Agent"
|
||||
service "$SERVICE_NAME" stop 2>/dev/null || true
|
||||
sysrc -x "${SERVICE_NAME}_enable" >/dev/null 2>&1 || true
|
||||
rm -f "$SERVICE_FILE" "$AGENT_FILE" "$TOKEN_FILE"
|
||||
rmdir "$INSTALL_DIR" 2>/dev/null || true
|
||||
echo "NodeFlare Agent 已卸载"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "${1:-}" = "--status" ]; then
|
||||
[ "$#" -eq 1 ] || fail "--status 不接受其它参数"
|
||||
service "$SERVICE_NAME" status
|
||||
exit $?
|
||||
fi
|
||||
|
||||
[ "${1:-}" != "-h" ] && [ "${1:-}" != "--help" ] && [ "$#" -gt 0 ] || {
|
||||
usage
|
||||
exit 0
|
||||
}
|
||||
[ "$(id -u)" -eq 0 ] || fail "请使用 root 权限运行安装"
|
||||
[ "$(uname -s)" = "FreeBSD" ] || fail "此脚本仅支持 FreeBSD"
|
||||
case "$(uname -m)" in
|
||||
amd64|x86_64) ;;
|
||||
*) fail "仅支持 FreeBSD amd64" ;;
|
||||
esac
|
||||
command -v fetch >/dev/null 2>&1 || command -v curl >/dev/null 2>&1 || fail "缺少 fetch 或 curl"
|
||||
command -v sha256 >/dev/null 2>&1 || fail "缺少 sha256,无法校验下载文件"
|
||||
command -v service >/dev/null 2>&1 || fail "缺少 service"
|
||||
command -v sysrc >/dev/null 2>&1 || fail "缺少 sysrc"
|
||||
|
||||
log "正在检查运行环境"
|
||||
token=""
|
||||
endpoint=""
|
||||
interval=60
|
||||
interval_set=false
|
||||
mirror=""
|
||||
while [ "$#" -gt 0 ]; do
|
||||
option="$1"
|
||||
case "$option" in
|
||||
-t|-e|-i|-m)
|
||||
[ "$#" -ge 2 ] || fail "参数 $option 缺少值"
|
||||
value="$2"
|
||||
shift 2
|
||||
;;
|
||||
*) fail "未知参数:$option" ;;
|
||||
esac
|
||||
case "$option" in
|
||||
-t) [ -z "$token" ] || fail "参数 $option 重复"; token="$value" ;;
|
||||
-e) [ -z "$endpoint" ] || fail "参数 $option 重复"; endpoint="$value" ;;
|
||||
-i) [ "$interval_set" = false ] || fail "参数 $option 重复"; interval="$value"; interval_set=true ;;
|
||||
-m) [ -z "$mirror" ] || fail "参数 $option 重复"; mirror="$value" ;;
|
||||
esac
|
||||
done
|
||||
[ -n "$token" ] && [ -n "$endpoint" ] || { usage; exit 1; }
|
||||
endpoint=${endpoint%/}
|
||||
[ ${#token} -le 512 ] && [ ${#endpoint} -le 2048 ] || fail "安装参数长度超出限制"
|
||||
safe_value "$token" && safe_value "$endpoint" || fail "Worker 地址或 Agent Token 格式无效"
|
||||
case "$endpoint" in
|
||||
https://?*|http://localhost|http://localhost/*|http://localhost:*|http://127.0.0.1|http://127.0.0.1/*|http://127.0.0.1:*) ;;
|
||||
*) fail "Worker 地址必须使用 HTTPS;仅本机调试可使用 HTTP" ;;
|
||||
esac
|
||||
case "$endpoint" in *@*) fail "Worker 地址不能包含用户信息" ;; esac
|
||||
case "$interval" in ''|*[!0-9]*) fail "上报间隔必须是整数" ;; esac
|
||||
[ "$interval" -ge 15 ] && [ "$interval" -le 3600 ] || fail "上报间隔必须在 15-3600 秒之间"
|
||||
mirror=${mirror%/}
|
||||
if [ -n "$mirror" ]; then
|
||||
[ ${#mirror} -le 2048 ] || fail "下载加速前缀长度超出限制"
|
||||
safe_value "$mirror" || fail "下载加速前缀格式无效"
|
||||
case "$mirror" in
|
||||
https://?*) ;;
|
||||
http://localhost|http://localhost/*|http://localhost:*|http://127.0.0.1|http://127.0.0.1/*|http://127.0.0.1:*) ;;
|
||||
*) fail "下载加速前缀必须使用 HTTPS;仅本机调试可使用 HTTP" ;;
|
||||
esac
|
||||
case "$mirror" in *@*) fail "下载加速前缀不能包含用户信息" ;; esac
|
||||
fi
|
||||
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
temporary="$INSTALL_DIR/.agent.$$.download"
|
||||
trap 'rm -f "$temporary"' EXIT HUP INT TERM
|
||||
artifact="agent-freebsd-x64"
|
||||
release_api="https://api.github.com/repos/imengying/NodeFlare/releases/latest"
|
||||
log "正在获取 GitHub 最新正式版本($artifact)"
|
||||
release_json=$(download_stdout "$release_api")
|
||||
release_tag=$(printf '%s\n' "$release_json" | tr ',' '\n' | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1)
|
||||
printf '%s\n' "$release_tag" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$' || fail "GitHub 最新 Release 标签无效:${release_tag:-未找到}"
|
||||
expected=$(printf '%s\n' "$release_json" | tr '{' '\n' | awk -v name="$artifact" '
|
||||
{
|
||||
compact = $0
|
||||
gsub(/[[:space:]]/, "", compact)
|
||||
if (index(compact, "\"name\":\"" name "\"") > 0) selected = 1
|
||||
else if (index(compact, "\"name\":") > 0) selected = 0
|
||||
}
|
||||
selected {
|
||||
marker = "\"digest\":\"sha256:"
|
||||
position = index(compact, marker)
|
||||
if (position == 0) next
|
||||
digest = substr(compact, position + length(marker), 64)
|
||||
if (length(digest) == 64 && digest !~ /[^0-9a-fA-F]/) {
|
||||
print tolower(digest)
|
||||
exit
|
||||
}
|
||||
}
|
||||
')
|
||||
[ -n "$expected" ] || fail "Release 缺少 $artifact 的 SHA-256 摘要"
|
||||
release_base="https://github.com/imengying/NodeFlare/releases/download/$release_tag"
|
||||
download_url="$release_base/$artifact"
|
||||
if [ -n "$mirror" ]; then
|
||||
download_url="$mirror/$release_base/$artifact"
|
||||
log "正在通过下载加速前缀拉取 Agent $release_tag"
|
||||
else
|
||||
log "正在下载 NodeFlare Agent $release_tag"
|
||||
fi
|
||||
download_file "$download_url" "$temporary"
|
||||
actual=$(sha256 -q "$temporary")
|
||||
[ "$actual" = "$expected" ] || fail "Agent SHA-256 校验失败,已停止安装"
|
||||
chmod 755 "$temporary"
|
||||
log "下载校验通过,正在验证可执行文件"
|
||||
installed_version=$("$temporary" --version) || fail "下载的 Agent 无法在当前 FreeBSD 运行"
|
||||
installed_version=${installed_version##* }
|
||||
[ "$installed_version" = "${release_tag#v}" ] || fail "Release $release_tag 与 Agent 版本 $installed_version 不一致"
|
||||
|
||||
log "正在配置并启动 FreeBSD rc.d 服务"
|
||||
service "$SERVICE_NAME" stop 2>/dev/null || true
|
||||
mv "$temporary" "$AGENT_FILE"
|
||||
trap - EXIT HUP INT TERM
|
||||
(umask 077; printf '%s\n' "$token" > "$TOKEN_FILE")
|
||||
chmod 600 "$TOKEN_FILE"
|
||||
token=""
|
||||
cat > "$SERVICE_FILE" <<EOF
|
||||
#!/bin/sh
|
||||
# PROVIDE: nodeflare
|
||||
# REQUIRE: NETWORKING
|
||||
# KEYWORD: shutdown
|
||||
|
||||
. /etc/rc.subr
|
||||
|
||||
name="$SERVICE_NAME"
|
||||
rcvar="${SERVICE_NAME}_enable"
|
||||
pidfile="/var/run/\${name}.pid"
|
||||
command="/usr/sbin/daemon"
|
||||
command_args="-P \${pidfile} -r -R 10 -S -T \${name} $AGENT_FILE -e $endpoint --token-file $TOKEN_FILE -i $interval"
|
||||
|
||||
load_rc_config "\${name}"
|
||||
: \${nodeflare_enable:="NO"}
|
||||
run_rc_command "\$1"
|
||||
EOF
|
||||
chmod 700 "$SERVICE_FILE"
|
||||
sysrc "${SERVICE_NAME}_enable=YES" >/dev/null
|
||||
service "$SERVICE_NAME" start
|
||||
service "$SERVICE_NAME" status >/dev/null || {
|
||||
service "$SERVICE_NAME" status >&2 || true
|
||||
fail "NodeFlare 服务启动失败,请查看 /var/log/messages"
|
||||
}
|
||||
|
||||
printf '\nNodeFlare Agent 安装完成\n'
|
||||
printf ' 版本:%s\n' "$installed_version"
|
||||
printf ' 服务:%s(FreeBSD rc.d)\n' "$SERVICE_NAME"
|
||||
printf ' 查看状态:service %s status\n' "$SERVICE_NAME"
|
||||
@@ -4,6 +4,7 @@ set -eu
|
||||
LABEL="com.nodeflare.agent"
|
||||
INSTALL_DIR="/usr/local/libexec/nodeflare"
|
||||
AGENT_FILE="$INSTALL_DIR/agent"
|
||||
TOKEN_FILE="$INSTALL_DIR/token"
|
||||
PLIST_FILE="/Library/LaunchDaemons/$LABEL.plist"
|
||||
|
||||
log() {
|
||||
@@ -39,7 +40,7 @@ if [ "${1:-}" = "--uninstall" ]; then
|
||||
[ "$(id -u)" -eq 0 ] || fail "请使用 root 权限执行卸载"
|
||||
log "正在停止并移除 NodeFlare Agent"
|
||||
launchctl bootout system "$PLIST_FILE" 2>/dev/null || true
|
||||
rm -f "$PLIST_FILE" "$AGENT_FILE"
|
||||
rm -f "$PLIST_FILE" "$AGENT_FILE" "$TOKEN_FILE"
|
||||
rmdir "$INSTALL_DIR" 2>/dev/null || true
|
||||
echo "NodeFlare Agent 已卸载"
|
||||
exit 0
|
||||
@@ -158,12 +159,15 @@ log "正在配置并启动 macOS LaunchDaemon 服务"
|
||||
launchctl bootout system "$PLIST_FILE" 2>/dev/null || true
|
||||
mv "$temporary" "$AGENT_FILE"
|
||||
trap - EXIT HUP INT TERM
|
||||
(umask 077; printf '%s\n' "$token" > "$TOKEN_FILE")
|
||||
chmod 600 "$TOKEN_FILE"
|
||||
token=""
|
||||
cat > "$PLIST_FILE" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0"><dict>
|
||||
<key>Label</key><string>$LABEL</string>
|
||||
<key>ProgramArguments</key><array><string>$AGENT_FILE</string><string>-e</string><string>$endpoint</string><string>-t</string><string>$token</string><string>-i</string><string>$interval</string></array>
|
||||
<key>ProgramArguments</key><array><string>$AGENT_FILE</string><string>-e</string><string>$endpoint</string><string>--token-file</string><string>$TOKEN_FILE</string><string>-i</string><string>$interval</string></array>
|
||||
<key>KeepAlive</key><true/><key>RunAtLoad</key><true/>
|
||||
<key>StandardOutPath</key><string>/var/log/nodeflare-agent.log</string>
|
||||
<key>StandardErrorPath</key><string>/var/log/nodeflare-agent.log</string>
|
||||
|
||||
+7
-3
@@ -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
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use super::Report;
|
||||
|
||||
pub(crate) const MAX_LIVE_BATCH_BYTES: usize = 768 * 1024;
|
||||
|
||||
pub(crate) fn batch_len(queue: &VecDeque<Report>) -> usize {
|
||||
// Allow room for the JSON envelope, commas, and future small protocol fields.
|
||||
let mut encoded_bytes = 64_usize;
|
||||
let mut count = 0_usize;
|
||||
for report in queue {
|
||||
let Ok(report_bytes) = serde_json::to_vec(report) else {
|
||||
break;
|
||||
};
|
||||
let next_bytes = encoded_bytes
|
||||
.saturating_add(report_bytes.len())
|
||||
.saturating_add(usize::from(count > 0));
|
||||
if next_bytes > MAX_LIVE_BATCH_BYTES {
|
||||
break;
|
||||
}
|
||||
encoded_bytes = next_bytes;
|
||||
count += 1;
|
||||
if encoded_bytes >= MAX_LIVE_BATCH_BYTES {
|
||||
break;
|
||||
}
|
||||
}
|
||||
count.min(super::LIVE_QUEUE_CAPACITY)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use super::{batch_len, MAX_LIVE_BATCH_BYTES};
|
||||
use crate::Report;
|
||||
|
||||
#[test]
|
||||
fn bounds_batches_by_serialized_size() {
|
||||
let report = Report {
|
||||
cpu_model: "x".repeat(128 * 1024),
|
||||
..Report::default()
|
||||
};
|
||||
let queue = std::iter::repeat_n(report, 10).collect::<VecDeque<_>>();
|
||||
let count = batch_len(&queue);
|
||||
assert!((1..10).contains(&count));
|
||||
let payload =
|
||||
crate::live_update_payload(&queue.iter().take(count).cloned().collect::<Vec<_>>())
|
||||
.unwrap();
|
||||
assert!(payload.len() <= MAX_LIVE_BATCH_BYTES);
|
||||
}
|
||||
}
|
||||
+811
-468
File diff suppressed because it is too large.
Load diff
@@ -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),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,24 +5,26 @@
|
||||
"": {
|
||||
"name": "nodeflare",
|
||||
"devDependencies": {
|
||||
"wrangler": "^4.123.0",
|
||||
"wrangler": "^4.127.0",
|
||||
"ws": "^8.21.3",
|
||||
},
|
||||
},
|
||||
"frontend": {
|
||||
"name": "nodeflare-ui",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"lucide-react": "^1.31.0",
|
||||
"lucide-react": "^1.34.0",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"recharts": "^3.10.1",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.4.0",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@vitejs/plugin-react": "^6.0.5",
|
||||
"@types/react-dom": "^19.2.5",
|
||||
"@vitejs/plugin-react": "^6.1.0",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.2.1",
|
||||
"vite": "^8.2.2",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -34,15 +36,15 @@
|
||||
|
||||
"@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="],
|
||||
|
||||
"@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260811.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-i5jqz+ywtOefr0AJbiAc8qxBLfSim/B0WJG7aW3B+pWnoVfMJdUQvi+BWcFKZJ0MoCci3KadTx6g31VfuEEqpQ=="],
|
||||
"@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260826.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-8UsGGY8ZUiYHOWdsxBlNsGmaHBGArVwJ3CM4nWpfBhthjjYe4M/OqrTpqKF7NNWb63qQiv1d8Z+Z6/hmUqNKIQ=="],
|
||||
|
||||
"@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260811.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-NoOUM/nvaDdm2Onlnz33FikWjtatzulNtvwvy4xs0IrHaTCHwC0c8NwIt6s+AI13FkDs02/vm2I3GTPLCT9+hQ=="],
|
||||
"@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260826.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0bLqVQYsQ3v3FdYGmzh23vi9fJeYTBx19o4LUySIsRcgBggGSlR39ml162vTXvZzUISOjVW2qfL7Y+SMrrfXmQ=="],
|
||||
|
||||
"@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260811.1", "", { "os": "linux", "cpu": "x64" }, "sha512-sdYq2jL1AD1supa3fsi5O4zTB28wSjvTHj7Migh6/ts8EROPdvrSwv+rdGHhv8HJNAz/wbIAY3wZsi1Rw4uUIg=="],
|
||||
"@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260826.1", "", { "os": "linux", "cpu": "x64" }, "sha512-DTC0yWzybX4gUH5Q1pJo3UwEQjp0Gmz0Q71I+39xT9SXBesX5QndOIQv45yHQ86Z84EzK2WwaENdlpmDSvJKmw=="],
|
||||
|
||||
"@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260811.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-RIRv4shbu1kg05sD+DHTpSFCNnb5Dl2SkPDMUykqZa508tkPqe7VVw7gO0Q5msTBGyL0FfFrLuRxwwfA8u5Sow=="],
|
||||
"@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260826.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-PFerWi+DP2Ckc6eATAS4dhotBt8IeXJjTzIgy18H+uqsswlXc7A8HsnAunHL9v/7/BjCgq46iMo2g4iUAsEpDw=="],
|
||||
|
||||
"@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260811.1", "", { "os": "win32", "cpu": "x64" }, "sha512-g6VquwjASlYAibcNW/0E6Zszht4qLkmnXOGwIjjRHl2A0Qz48kVeMcGvyH6eA0G9U3OzZojjYFpP+YeyQmmdjw=="],
|
||||
"@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260826.1", "", { "os": "win32", "cpu": "x64" }, "sha512-X26hulrG2MSSfpRmjZbCq98LNaZnrRqbmGcgo7g1U/U0nJ5npYruPm3fAyZ2y6VDr5CmQo9BLCahxP8VB/QR6A=="],
|
||||
|
||||
"@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="],
|
||||
|
||||
@@ -160,7 +162,7 @@
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="],
|
||||
|
||||
"@oxc-project/types": ["@oxc-project/types@0.143.0", "", {}, "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA=="],
|
||||
"@oxc-project/types": ["@oxc-project/types@0.147.0", "", {}, "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg=="],
|
||||
|
||||
"@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="],
|
||||
|
||||
@@ -170,33 +172,35 @@
|
||||
|
||||
"@reduxjs/toolkit": ["@reduxjs/toolkit@2.12.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw=="],
|
||||
|
||||
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.3", "", { "os": "android", "cpu": "arm64" }, "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw=="],
|
||||
"@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.6", "", { "os": "android", "cpu": "arm" }, "sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ=="],
|
||||
|
||||
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA=="],
|
||||
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.6", "", { "os": "android", "cpu": "arm64" }, "sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q=="],
|
||||
|
||||
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA=="],
|
||||
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA=="],
|
||||
|
||||
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg=="],
|
||||
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q=="],
|
||||
|
||||
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.3", "", { "os": "linux", "cpu": "arm" }, "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw=="],
|
||||
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.6", "", { "os": "freebsd", "cpu": "x64" }, "sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw=="],
|
||||
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.6", "", { "os": "linux", "cpu": "arm" }, "sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q=="],
|
||||
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg=="],
|
||||
|
||||
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w=="],
|
||||
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw=="],
|
||||
|
||||
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg=="],
|
||||
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.6", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w=="],
|
||||
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.6", "", { "os": "linux", "cpu": "s390x" }, "sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A=="],
|
||||
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w=="],
|
||||
|
||||
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.3", "", { "os": "none", "cpu": "arm64" }, "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug=="],
|
||||
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ=="],
|
||||
|
||||
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw=="],
|
||||
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.6", "", { "os": "none", "cpu": "arm64" }, "sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg=="],
|
||||
|
||||
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.3", "", { "os": "win32", "cpu": "x64" }, "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg=="],
|
||||
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A=="],
|
||||
|
||||
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
|
||||
|
||||
@@ -208,6 +212,8 @@
|
||||
|
||||
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="],
|
||||
|
||||
"@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
|
||||
|
||||
"@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
|
||||
@@ -226,9 +232,11 @@
|
||||
|
||||
"@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
|
||||
|
||||
"@types/node": ["@types/node@26.4.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.2.4", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw=="],
|
||||
"@types/react-dom": ["@types/react-dom@19.2.5", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg=="],
|
||||
|
||||
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
|
||||
|
||||
@@ -272,10 +280,12 @@
|
||||
|
||||
"@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="],
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.5", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA=="],
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.1.0", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "oxc-transform-react": "^0.145.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler", "oxc-transform-react"] }, "sha512-qd2BzUBehkov86WFhg0JkEFEYyCLG9uPCe6qWTY/kRlss9OvJrOF2UbIWT7p+8IzZHkEu0DNGHc4HSv+JdDLsw=="],
|
||||
|
||||
"blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="],
|
||||
|
||||
"bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
|
||||
@@ -350,9 +360,9 @@
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="],
|
||||
|
||||
"lucide-react": ["lucide-react@1.31.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-G8u2eEtoHUnUa9f8lbvqDhCiORMnYLdUEo06EEG9MQvHQrInKcX3Pa2TH39MM5qyzRcWETxB0+aOwAPI1g1kEg=="],
|
||||
"lucide-react": ["lucide-react@1.34.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-vnjGJNI7Htk5+oWW8gXGuaLgwgAb0T6/iZbBrp9JCfRFwdNWZ0YTm3eyxjOLgwN6r8iyAf3UA70zNmBRBNv7yg=="],
|
||||
|
||||
"miniflare": ["miniflare@5.20260811.1-alpha", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.2", "undici": "7.29.0", "workerd": "1.20260811.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" } }, "sha512-DtOG0BeanIxs2sH0smFvExZD89cBQwGckbHiFkRJrrNAUu3NGClZkUxqu+zy7HYfKBAgq935EMY49vIPm3JVdA=="],
|
||||
"miniflare": ["miniflare@5.20260826.0-alpha", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.2", "undici": "7.29.0", "workerd": "1.20260826.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" } }, "sha512-ZXR3Bieg+B5MK0T/zYIWaZiCGCb9Z3en4+/TleYKhIGPLx/e0cRcG/ZvvTviUapVBBK2zODB+aiypdIojU3x6Q=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="],
|
||||
|
||||
@@ -384,7 +394,7 @@
|
||||
|
||||
"reselect": ["reselect@5.2.0", "", {}, "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw=="],
|
||||
|
||||
"rolldown": ["rolldown@1.2.3", "", { "dependencies": { "@oxc-project/types": "=0.143.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.2.3", "@rolldown/binding-darwin-arm64": "1.2.3", "@rolldown/binding-darwin-x64": "1.2.3", "@rolldown/binding-freebsd-x64": "1.2.3", "@rolldown/binding-linux-arm-gnueabihf": "1.2.3", "@rolldown/binding-linux-arm64-gnu": "1.2.3", "@rolldown/binding-linux-arm64-musl": "1.2.3", "@rolldown/binding-linux-ppc64-gnu": "1.2.3", "@rolldown/binding-linux-s390x-gnu": "1.2.3", "@rolldown/binding-linux-x64-gnu": "1.2.3", "@rolldown/binding-linux-x64-musl": "1.2.3", "@rolldown/binding-openharmony-arm64": "1.2.3", "@rolldown/binding-win32-arm64-msvc": "1.2.3", "@rolldown/binding-win32-x64-msvc": "1.2.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A=="],
|
||||
"rolldown": ["rolldown@1.2.6", "", { "dependencies": { "@oxc-project/types": "=0.147.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.6", "@rolldown/binding-android-arm64": "1.2.6", "@rolldown/binding-darwin-arm64": "1.2.6", "@rolldown/binding-darwin-x64": "1.2.6", "@rolldown/binding-freebsd-x64": "1.2.6", "@rolldown/binding-linux-arm-gnueabihf": "1.2.6", "@rolldown/binding-linux-arm64-gnu": "1.2.6", "@rolldown/binding-linux-arm64-musl": "1.2.6", "@rolldown/binding-linux-ppc64-gnu": "1.2.6", "@rolldown/binding-linux-s390x-gnu": "1.2.6", "@rolldown/binding-linux-x64-gnu": "1.2.6", "@rolldown/binding-linux-x64-musl": "1.2.6", "@rolldown/binding-openharmony-arm64": "1.2.6", "@rolldown/binding-win32-arm64-msvc": "1.2.6", "@rolldown/binding-win32-x64-msvc": "1.2.6" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA=="],
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
@@ -406,22 +416,26 @@
|
||||
|
||||
"undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="],
|
||||
|
||||
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
|
||||
|
||||
"unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="],
|
||||
|
||||
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
|
||||
|
||||
"victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="],
|
||||
|
||||
"vite": ["vite@8.2.1", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", "postcss": "^8.5.25", "rolldown": "~1.2.1", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.4.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw=="],
|
||||
"vite": ["vite@8.2.2", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", "postcss": "^8.5.26", "rolldown": "~1.2.4", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.4.0 || ^0.5.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q=="],
|
||||
|
||||
"workerd": ["workerd@1.20260811.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260811.1", "@cloudflare/workerd-darwin-arm64": "1.20260811.1", "@cloudflare/workerd-linux-64": "1.20260811.1", "@cloudflare/workerd-linux-arm64": "1.20260811.1", "@cloudflare/workerd-windows-64": "1.20260811.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-kh+FFm55JQ4ssxhHZV9VPdMQq3D1nHxNJgwxMtWGD4dGppJvLySdguTRDKgeNTvgq6heSz+6TTXyPSDGj8Yllw=="],
|
||||
"workerd": ["workerd@1.20260826.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260826.1", "@cloudflare/workerd-darwin-arm64": "1.20260826.1", "@cloudflare/workerd-linux-64": "1.20260826.1", "@cloudflare/workerd-linux-arm64": "1.20260826.1", "@cloudflare/workerd-windows-64": "1.20260826.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-oTG9ot5zxO9OjjKCskt86+nVrBL0kiUqExHnYaTg4OCkHf/K4zr+9hYvgwmG8DdCWG0TQGErHzD+1h50TM5b+w=="],
|
||||
|
||||
"wrangler": ["wrangler@4.123.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "5.20260811.1-alpha", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260811.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260811.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-VXo2I1oa0x9aGAKIFPRSQPqTh0RBY5Ktl44YOhNmsJQFUdJKDA2vVTU6Xj+FC2koll6orJqWZN8jbXVIk9O67Q=="],
|
||||
"wrangler": ["wrangler@4.127.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "5.20260826.0-alpha", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260826.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260826.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-4dPqcBEMJfGeZeNnjHT7ThNJs+EiNYxUTg4ywqIdQubcXHBhFeVMQyHV4A9AOhZRFr83cckqdds034KGcr/dtw=="],
|
||||
|
||||
"ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
|
||||
"ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="],
|
||||
|
||||
"youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="],
|
||||
|
||||
"youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="],
|
||||
|
||||
"miniflare/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
|
||||
}
|
||||
}
|
||||
@@ -6,22 +6,24 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"test": "bun test",
|
||||
"build": "tsc -b && vite build && vite build --config vite.admin.config.ts",
|
||||
"build:public": "tsc -b && vite build",
|
||||
"build:admin": "vite build --config vite.admin.config.ts",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^1.31.0",
|
||||
"lucide-react": "^1.34.0",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"recharts": "^3.10.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.4.0",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@vitejs/plugin-react": "^6.0.5",
|
||||
"@types/react-dom": "^19.2.5",
|
||||
"@vitejs/plugin-react": "^6.1.0",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.2.1"
|
||||
"vite": "^8.2.2"
|
||||
}
|
||||
}
|
||||
+141
-449
@@ -1,445 +1,168 @@
|
||||
import { KeyRound, Megaphone, Moon, Search, Sun, UserCircle } from "lucide-react";
|
||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from "react";
|
||||
import { api, ApiError, setToken } from "./api";
|
||||
import { lazy, Suspense, useMemo, useState, type FormEvent } from "react";
|
||||
import { demoMode, useApp } from "./AppProvider";
|
||||
import { NodeCard } from "./components/NodeCard";
|
||||
import { SiteLogo } from "./components/SiteLogo";
|
||||
import { StatsBar } from "./components/StatsBar";
|
||||
import { TurnstileWidget } from "./components/TurnstileWidget";
|
||||
import { demoConfig, demoExchangeRates, demoServers } from "./demo";
|
||||
import type { Config, ExchangeRates, LatencySample, LiveLatencyResult, Server } from "./types";
|
||||
import { resolveBackground, themeToggle } from "./theme";
|
||||
import { ui } from "./locale";
|
||||
import { derivePassword } from "./password";
|
||||
|
||||
const defaultConfig: Config = { ...demoConfig, site_description: "", site_name: "" };
|
||||
const demoMode = import.meta.env.DEV && new URLSearchParams(window.location.search).has("demo");
|
||||
const MAX_PLAYBACK_SAMPLES_PER_SERVER = 600;
|
||||
const NodeDetails = lazy(() => import("./components/NodeDetails").then((module) => ({ default: module.NodeDetails })));
|
||||
|
||||
interface LiveMetrics {
|
||||
timestamp: number;
|
||||
displayTimestamp: number;
|
||||
metrics: Partial<Server>;
|
||||
latencyResults?: LiveLatencyResult[];
|
||||
}
|
||||
function LoginGate() {
|
||||
const { config, dark, error, setError, login } = useApp();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [turnstileToken, setTurnstileToken] = useState("");
|
||||
const [turnstileReset, setTurnstileReset] = useState(0);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const locale = config.locale;
|
||||
|
||||
interface LiveSample {
|
||||
ts: number;
|
||||
data: Partial<Server> & { latency_results?: LiveLatencyResult[] };
|
||||
}
|
||||
|
||||
function mergeLiveResults(previous: LiveLatencyResult[] | undefined, incoming: LiveLatencyResult[]): LiveLatencyResult[] {
|
||||
const results = new Map<string, LiveLatencyResult>();
|
||||
for (const result of [...(previous ?? []), ...incoming]) {
|
||||
if (!result.task_id || !Number.isFinite(result.timestamp) || result.timestamp <= 0) continue;
|
||||
results.set(`${result.task_id}:${result.timestamp}`, result);
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await login(username, password, turnstileToken);
|
||||
setPassword("");
|
||||
setTurnstileToken("");
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : ui(locale, "登录失败", "Unable to sign in"));
|
||||
setTurnstileToken("");
|
||||
setTurnstileReset((value) => value + 1);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return Array.from(results.values())
|
||||
.sort((left, right) => left.timestamp - right.timestamp)
|
||||
.slice(-4096);
|
||||
|
||||
return (
|
||||
<section className="dashboard-login-gate glass-panel">
|
||||
<SiteLogo src={config.logo_url} alt="" width="46" height="46" />
|
||||
<div className="dashboard-login-copy">
|
||||
<h1>{ui(locale, "登录仪表盘", "Sign in to dashboard")}</h1>
|
||||
<p>{ui(locale, "此仪表盘仅限登录后访问", "This dashboard requires an administrator sign-in")}</p>
|
||||
</div>
|
||||
<form className="dashboard-login-form" onSubmit={(event) => void submit(event)}>
|
||||
<label><span>{ui(locale, "用户名", "Username")}</span><input autoFocus autoComplete="username" value={username} onChange={(event) => setUsername(event.target.value)} required /></label>
|
||||
<label><span>{ui(locale, "密码", "Password")}</span><input type="password" autoComplete="current-password" value={password} onChange={(event) => setPassword(event.target.value)} required /></label>
|
||||
{config.turnstile_login_enabled ? <div className="dashboard-login-turnstile"><TurnstileWidget siteKey={config.turnstile_site_key} action="admin-login" theme={dark ? "dark" : "light"} resetKey={turnstileReset} onVerify={setTurnstileToken} onError={setError} /></div> : null}
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
<button className="primary-btn dashboard-login-submit" disabled={busy || (config.turnstile_login_enabled && !turnstileToken)} type="submit"><KeyRound size={16} />{busy ? ui(locale, "登录中", "Signing in") : ui(locale, "登录", "Sign in")}</button>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function mergeLiveLatency(server: Server, results: LiveLatencyResult[]): LatencySample[] {
|
||||
const latest = new Map<string, LiveLatencyResult>();
|
||||
for (const result of results) {
|
||||
const current = latest.get(result.task_id);
|
||||
if (!current || result.timestamp >= current.timestamp) latest.set(result.task_id, result);
|
||||
function VerificationGate() {
|
||||
const { config, dark, error, setError, verify } = useApp();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const locale = config.locale;
|
||||
|
||||
async function run(token: string) {
|
||||
if (!token || busy) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await verify(token);
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : ui(locale, "Cloudflare 验证失败", "Cloudflare verification failed"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
let changed = false;
|
||||
const merged = server.latency.map((definition) => {
|
||||
const result = latest.get(definition.task_id);
|
||||
if (!result || result.timestamp <= definition.timestamp) return definition;
|
||||
changed = true;
|
||||
return {
|
||||
...definition,
|
||||
server_id: server.id,
|
||||
timestamp: result.timestamp,
|
||||
latency_ms: result.latency_ms,
|
||||
packet_loss: result.packet_loss,
|
||||
};
|
||||
});
|
||||
return changed ? merged : server.latency;
|
||||
|
||||
return (
|
||||
<section className="verification-gate">
|
||||
<SiteLogo src={config.logo_url} alt="" width="52" height="52" />
|
||||
<div><h1>{ui(locale, "访问验证", "Access verification")}</h1><p>{config.site_name}</p></div>
|
||||
{config.turnstile_site_key
|
||||
? <TurnstileWidget siteKey={config.turnstile_site_key} action="public-dashboard" theme={dark ? "dark" : "light"} onVerify={(token) => void run(token)} onError={setError} />
|
||||
: <p className="form-error">{ui(locale, "Turnstile 尚未正确配置", "Turnstile is not configured")}</p>}
|
||||
{busy ? <span className="verification-status">{ui(locale, "正在验证", "Verifying")}</span> : null}
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function routeServerId() {
|
||||
const match = window.location.pathname.match(/^\/instance\/([^/]+)\/?$/);
|
||||
if (!match) return null;
|
||||
try { return decodeURIComponent(match[1]); } catch { return null; }
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [config, setConfig] = useState(defaultConfig);
|
||||
const [configReady, setConfigReady] = useState(demoMode);
|
||||
const [servers, setServers] = useState<Server[]>([]);
|
||||
const [liveMetrics, setLiveMetrics] = useState<Record<string, LiveMetrics>>({});
|
||||
const [clockNow, setClockNow] = useState(() => Date.now());
|
||||
const [exchangeRates, setExchangeRates] = useState<ExchangeRates | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
function HomeView() {
|
||||
const { carrierLatency, config, error, exchangeRates, loading, openServer, reload, servers } = useApp();
|
||||
const [query, setQuery] = useState("");
|
||||
const [group, setGroup] = useState("__all__");
|
||||
const [selectedId, setSelectedId] = useState<string | null>(routeServerId);
|
||||
const [needsVerification, setNeedsVerification] = useState(false);
|
||||
const [verificationBusy, setVerificationBusy] = useState(false);
|
||||
const [needsLogin, setNeedsLogin] = useState(false);
|
||||
const [loginUsername, setLoginUsername] = useState("");
|
||||
const [loginPassword, setLoginPassword] = useState("");
|
||||
const [loginTurnstileToken, setLoginTurnstileToken] = useState("");
|
||||
const [loginTurnstileReset, setLoginTurnstileReset] = useState(0);
|
||||
const [loginBusy, setLoginBusy] = useState(false);
|
||||
const [appearance, setAppearance] = useState<"light" | "dark" | null>(() => {
|
||||
const stored = localStorage.getItem("nodeflare-theme");
|
||||
return stored === "light" || stored === "dark" ? stored : null;
|
||||
});
|
||||
const [systemDark, setSystemDark] = useState(() => matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
const wsRef = useRef<WebSocket[]>([]);
|
||||
const liveConnectedRef = useRef(false);
|
||||
const playbackRef = useRef<Map<string, LiveSample[]>>(new Map());
|
||||
const serversRef = useRef<Server[]>([]);
|
||||
const dark = appearance ? appearance === "dark" : config.default_theme === "system" ? systemDark : config.default_theme === "dark";
|
||||
const background = resolveBackground(config.background_url, dark);
|
||||
const blur = themeToggle(config, "enableBlur");
|
||||
const liveServers = useMemo(() => servers.map((server) => {
|
||||
const live = liveMetrics[server.id];
|
||||
const merged = !live ? server : (() => {
|
||||
const latency = live.latencyResults?.length ? mergeLiveLatency(server, live.latencyResults) : server.latency;
|
||||
if (live.timestamp <= (server.timestamp ?? 0)) {
|
||||
return latency === server.latency ? server : { ...server, latency };
|
||||
}
|
||||
return { ...server, ...live.metrics, latency, timestamp: live.timestamp };
|
||||
})();
|
||||
// Keep the card clock moving between Agent samples, while preserving the
|
||||
// actual sample timestamp for freshness/online checks.
|
||||
if (!merged.timestamp || !merged.uptime || !Number.isFinite(merged.uptime)) return merged;
|
||||
const elapsed = Math.max(0, Math.floor(clockNow / 1000 - merged.timestamp));
|
||||
return elapsed > 0 && elapsed <= config.offline_threshold_seconds
|
||||
? { ...merged, uptime: merged.uptime + elapsed }
|
||||
: merged;
|
||||
}), [clockNow, config.offline_threshold_seconds, liveMetrics, servers]);
|
||||
const locale = config.locale;
|
||||
|
||||
const load = useCallback(async (quiet = false) => {
|
||||
if (!quiet) setLoading(true);
|
||||
if (demoMode) {
|
||||
setConfig(demoConfig);
|
||||
setConfigReady(true);
|
||||
setServers(demoServers);
|
||||
setExchangeRates(demoExchangeRates);
|
||||
setError("");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await api.bootstrap();
|
||||
setConfig(result.config);
|
||||
setConfigReady(true);
|
||||
setServers(result.servers);
|
||||
setExchangeRates(result.exchange_rates);
|
||||
setNeedsVerification(result.access === "turnstile");
|
||||
setNeedsLogin(result.access === "login");
|
||||
if (result.access !== "ok") {
|
||||
playbackRef.current.clear();
|
||||
setLiveMetrics({});
|
||||
}
|
||||
setError("");
|
||||
} catch (reason) {
|
||||
if (reason instanceof ApiError && reason.status === 401) {
|
||||
setNeedsLogin(true);
|
||||
setNeedsVerification(false);
|
||||
setServers([]);
|
||||
playbackRef.current.clear();
|
||||
setLiveMetrics({});
|
||||
setError("");
|
||||
} else if (reason instanceof ApiError && reason.status === 403) {
|
||||
setNeedsVerification(true);
|
||||
setNeedsLogin(false);
|
||||
setServers([]);
|
||||
playbackRef.current.clear();
|
||||
setLiveMetrics({});
|
||||
setError(reason.message);
|
||||
} else {
|
||||
setError(reason instanceof Error ? reason.message : ui(config.locale, "无法加载节点状态", "Unable to load server status"));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
async function loginDashboard(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (loginBusy) return;
|
||||
setLoginBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const passwordDerived = await derivePassword(loginPassword, config.password_client_salt);
|
||||
const result = await api.login(loginUsername.trim(), loginPassword, passwordDerived, loginTurnstileToken);
|
||||
setToken(result.token);
|
||||
setLoginPassword("");
|
||||
setLoginTurnstileToken("");
|
||||
setNeedsLogin(false);
|
||||
await load();
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : ui(config.locale, "登录失败", "Unable to sign in"));
|
||||
setLoginTurnstileToken("");
|
||||
setLoginTurnstileReset((value) => value + 1);
|
||||
} finally {
|
||||
setLoginBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const verifyDashboard = useCallback(async (token: string) => {
|
||||
if (!token || verificationBusy) return;
|
||||
setVerificationBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await api.verifyTurnstile(token);
|
||||
setNeedsVerification(false);
|
||||
await load();
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : ui(config.locale, "Cloudflare 验证失败", "Cloudflare verification failed"));
|
||||
} finally {
|
||||
setVerificationBusy(false);
|
||||
}
|
||||
}, [load, verificationBusy]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
serversRef.current = servers;
|
||||
for (const server of servers) {
|
||||
const samples = playbackRef.current.get(server.id);
|
||||
if (!samples || !server.timestamp) continue;
|
||||
const fresh = samples.filter((sample) => sample.ts > server.timestamp!);
|
||||
if (fresh.length) playbackRef.current.set(server.id, fresh);
|
||||
else playbackRef.current.delete(server.id);
|
||||
}
|
||||
}, [servers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (needsLogin || needsVerification) return;
|
||||
let ticks = 0;
|
||||
const timer = window.setInterval(() => {
|
||||
ticks += 1;
|
||||
if (!liveConnectedRef.current || ticks % 20 === 0) void load(true);
|
||||
}, 15_000);
|
||||
return () => clearInterval(timer);
|
||||
}, [load, needsLogin, needsVerification]);
|
||||
|
||||
useEffect(() => {
|
||||
let previous = Date.now();
|
||||
const timer = window.setInterval(() => {
|
||||
const current = Date.now();
|
||||
const elapsed = Math.max(0, Math.min(5_000, current - previous));
|
||||
previous = current;
|
||||
setClockNow(current);
|
||||
if (elapsed === 0) return;
|
||||
setLiveMetrics((currentMetrics) => {
|
||||
if (!playbackRef.current.size) return currentMetrics;
|
||||
const next = { ...currentMetrics };
|
||||
for (const [serverId, samples] of playbackRef.current) {
|
||||
const state = next[serverId];
|
||||
if (!state || !samples.length) continue;
|
||||
const displayTimestamp = state.displayTimestamp + elapsed / 1000;
|
||||
let selected: LiveSample | undefined;
|
||||
while (samples.length && samples[0].ts <= displayTimestamp) selected = samples.shift();
|
||||
if (selected) {
|
||||
const metrics = { ...selected.data };
|
||||
const latencyResults = Array.isArray(metrics.latency_results) ? metrics.latency_results : [];
|
||||
delete metrics.latency_results;
|
||||
next[serverId] = {
|
||||
timestamp: selected.ts,
|
||||
displayTimestamp,
|
||||
metrics,
|
||||
latencyResults: latencyResults.length
|
||||
? mergeLiveResults(state.latencyResults, latencyResults)
|
||||
: state.latencyResults,
|
||||
};
|
||||
} else {
|
||||
next[serverId] = { ...state, displayTimestamp };
|
||||
}
|
||||
if (!samples.length) playbackRef.current.delete(serverId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, 1_000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle("dark", dark);
|
||||
document.documentElement.style.colorScheme = dark ? "dark" : "light";
|
||||
document.documentElement.dataset.blur = blur ? "on" : "off";
|
||||
document.documentElement.lang = config.locale;
|
||||
}, [blur, config.locale, dark]);
|
||||
|
||||
useEffect(() => {
|
||||
let link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
|
||||
if (!link) {
|
||||
link = document.createElement("link");
|
||||
link.rel = "icon";
|
||||
document.head.append(link);
|
||||
}
|
||||
link.href = config.favicon_url || config.logo_url || "/logo.svg";
|
||||
}, [config.favicon_url, config.logo_url]);
|
||||
|
||||
useEffect(() => {
|
||||
const media = matchMedia("(prefers-color-scheme: dark)");
|
||||
const update = () => setSystemDark(media.matches);
|
||||
media.addEventListener("change", update);
|
||||
return () => media.removeEventListener("change", update);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onPop = () => setSelectedId(routeServerId());
|
||||
window.addEventListener("popstate", onPop);
|
||||
return () => window.removeEventListener("popstate", onPop);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!configReady) return;
|
||||
const selected = liveServers.find((server) => server.id === selectedId);
|
||||
document.title = selected ? `${selected.name} · ${config.site_name}` : config.site_name;
|
||||
}, [config.site_name, configReady, liveServers, selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading || needsLogin || needsVerification || demoMode) return;
|
||||
const reconnects: number[] = [];
|
||||
let cancelled = false;
|
||||
const connect = () => {
|
||||
if (cancelled) return;
|
||||
const endpoint = new URL(location.origin);
|
||||
endpoint.protocol = endpoint.protocol === "https:" ? "wss:" : "ws:";
|
||||
endpoint.pathname = "/api/ws";
|
||||
endpoint.search = "";
|
||||
if (selectedId) endpoint.searchParams.set("server_id", selectedId);
|
||||
const socket = new WebSocket(endpoint);
|
||||
wsRef.current.push(socket);
|
||||
socket.onopen = () => {
|
||||
liveConnectedRef.current = true;
|
||||
};
|
||||
socket.onclose = () => {
|
||||
wsRef.current = wsRef.current.filter((current) => current !== socket);
|
||||
liveConnectedRef.current = wsRef.current.some((current) => current.readyState === WebSocket.OPEN);
|
||||
if (!cancelled) reconnects.push(window.setTimeout(connect, 3000));
|
||||
};
|
||||
socket.onerror = () => {
|
||||
liveConnectedRef.current = false;
|
||||
};
|
||||
socket.onmessage = (event) => {
|
||||
if (event.data === "pong") return;
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.type === "server" && message.server?.id) {
|
||||
setServers((current) => current.map((server) => server.id === message.server.id
|
||||
? message.server
|
||||
: server));
|
||||
return;
|
||||
}
|
||||
if (message.type === "batchUpdate" && Array.isArray(message.updates)) {
|
||||
const replayCached = message.cached === true;
|
||||
setLiveMetrics((current) => {
|
||||
const next = { ...current };
|
||||
for (const update of message.updates as Array<{
|
||||
serverId?: string;
|
||||
samples?: LiveSample[];
|
||||
reportAgeMs?: number;
|
||||
}>) {
|
||||
if (!update.serverId || !Array.isArray(update.samples) || !update.samples.length) continue;
|
||||
const samples = update.samples
|
||||
.filter((sample) => Number.isFinite(sample.ts) && sample.data && typeof sample.data === "object")
|
||||
.sort((left, right) => left.ts - right.ts);
|
||||
const previous = next[update.serverId];
|
||||
const pending = playbackRef.current.get(update.serverId) ?? [];
|
||||
const persistedTimestamp = serversRef.current.find((server) => server.id === update.serverId)?.timestamp ?? 0;
|
||||
const appliedTimestamp = Math.max(previous?.timestamp ?? 0, persistedTimestamp);
|
||||
const seen = new Set<number>([
|
||||
...pending.map((sample) => sample.ts),
|
||||
]);
|
||||
const incoming = samples.filter((sample) => sample.ts > appliedTimestamp && !seen.has(sample.ts));
|
||||
if (!incoming.length) continue;
|
||||
const reportAgeSeconds = replayCached && Number.isFinite(update.reportAgeMs)
|
||||
? Math.max(0, update.reportAgeMs! / 1000)
|
||||
: 0;
|
||||
const cursor = replayCached
|
||||
? Math.max(previous?.displayTimestamp ?? 0, incoming[incoming.length - 1].ts + reportAgeSeconds)
|
||||
: previous?.displayTimestamp ?? incoming[0].ts;
|
||||
const all = [...pending, ...incoming]
|
||||
.sort((left, right) => left.ts - right.ts)
|
||||
.slice(-MAX_PLAYBACK_SAMPLES_PER_SERVER);
|
||||
let selected: LiveSample | undefined;
|
||||
while (all.length && all[0].ts <= cursor) selected = all.shift();
|
||||
if (selected) {
|
||||
const metrics = { ...selected.data };
|
||||
const latencyResults = Array.isArray(metrics.latency_results) ? metrics.latency_results : [];
|
||||
delete metrics.latency_results;
|
||||
next[update.serverId] = {
|
||||
timestamp: selected.ts,
|
||||
displayTimestamp: cursor,
|
||||
metrics,
|
||||
latencyResults: latencyResults.length
|
||||
? mergeLiveResults(previous?.latencyResults, latencyResults)
|
||||
: previous?.latencyResults,
|
||||
};
|
||||
} else if (!previous) {
|
||||
continue;
|
||||
}
|
||||
if (all.length) playbackRef.current.set(update.serverId, all);
|
||||
else playbackRef.current.delete(update.serverId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch { /* Ignore non-protocol messages. */ }
|
||||
};
|
||||
};
|
||||
connect();
|
||||
const heartbeat = window.setInterval(() => wsRef.current.forEach((socket) => socket.readyState === WebSocket.OPEN && socket.send("ping")), 30_000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
liveConnectedRef.current = false;
|
||||
clearInterval(heartbeat);
|
||||
reconnects.forEach(clearTimeout);
|
||||
wsRef.current.forEach((socket) => socket.close());
|
||||
wsRef.current = [];
|
||||
};
|
||||
}, [load, loading, needsLogin, needsVerification, selectedId]);
|
||||
|
||||
const groups = useMemo(() => ["__all__", ...Array.from(new Set(liveServers.map((server) => server.group_name || "默认")))], [liveServers]);
|
||||
const visible = useMemo(() => liveServers.filter((server) => {
|
||||
const groups = useMemo(
|
||||
() => ["__all__", ...Array.from(new Set(servers.map((server) => server.group_name || "默认")))],
|
||||
[servers],
|
||||
);
|
||||
const visible = useMemo(() => servers.filter((server) => {
|
||||
const text = `${server.name} ${server.region} ${server.tags} ${server.group_name}`.toLowerCase();
|
||||
const groupMatches = !config.show_groups || group === "__all__" || (server.group_name || "默认") === group;
|
||||
const queryMatches = !config.show_search || text.includes(query.trim().toLowerCase());
|
||||
return groupMatches && queryMatches;
|
||||
}), [liveServers, query, group, config.show_groups, config.show_search]);
|
||||
const selected = liveServers.find((server) => server.id === selectedId) ?? null;
|
||||
}), [config.show_groups, config.show_search, group, query, servers]);
|
||||
|
||||
function goHome() {
|
||||
window.history.pushState({}, "", demoMode ? "/?demo=1" : "/");
|
||||
setSelectedId(null);
|
||||
window.scrollTo({ top: 0, behavior: "auto" });
|
||||
}
|
||||
return (
|
||||
<div className="home-content">
|
||||
<StatsBar servers={servers} config={config} exchangeRates={exchangeRates} />
|
||||
{config.show_search || config.show_groups ? <div className="toolbar">
|
||||
{config.show_search ? <div className="search-box"><Search size={16} /><input aria-label={ui(locale, "搜索节点", "Search servers")} placeholder={ui(locale, "搜索节点", "Search servers")} value={query} onChange={(event) => setQuery(event.target.value)} /></div> : null}
|
||||
{config.show_groups ? <div className="group-tabs" aria-label={ui(locale, "节点分组", "Server groups")}>{groups.map((item) => <button className={group === item ? "active" : ""} key={item} onClick={() => setGroup(item)}>{item === "__all__" ? ui(locale, "全部", "All") : item}</button>)}</div> : null}
|
||||
<span className="result-count">{ui(locale, `${visible.length} 个节点`, `${visible.length} servers`)}</span>
|
||||
</div> : null}
|
||||
{error ? <div className="error-band"><span>{error}</span><button onClick={() => void reload()}>{ui(locale, "重试", "Retry")}</button></div> : null}
|
||||
{loading && !servers.length ? (
|
||||
<div className={`loading-grid ${carrierLatency ? "carrier-latency" : ""}`}>{Array.from({ length: 8 }).map((_, index) => <span key={index} />)}</div>
|
||||
) : visible.length ? (
|
||||
<section className={`node-grid ${carrierLatency ? "carrier-latency" : ""}`}>
|
||||
{visible.map((server) => <NodeCard key={server.id} server={server} config={config} onOpen={() => openServer(server)} />)}
|
||||
</section>
|
||||
) : !error ? (
|
||||
<div className="empty-state"><strong>{servers.length ? ui(locale, "没有匹配的节点", "No matching servers") : ui(locale, "尚未添加节点", "No servers added")}</strong></div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function openServer(server: Server) {
|
||||
const suffix = demoMode ? "?demo=1" : "";
|
||||
window.history.pushState({}, "", `/instance/${encodeURIComponent(server.id)}${suffix}`);
|
||||
setSelectedId(server.id);
|
||||
window.scrollTo({ top: 0, behavior: "auto" });
|
||||
}
|
||||
function DetailView() {
|
||||
const { config, goHome, liveMetrics, loading, selectedId, servers } = useApp();
|
||||
const locale = config.locale;
|
||||
const selected = servers.find((server) => server.id === selectedId) ?? null;
|
||||
|
||||
function openAdmin() {
|
||||
window.location.assign("/admin");
|
||||
if (selected) {
|
||||
return (
|
||||
<Suspense fallback={<div className="chart-loading">{ui(locale, "正在加载节点", "Loading server")}</div>}>
|
||||
<NodeDetails
|
||||
server={selected}
|
||||
liveLatencyResults={liveMetrics[selected.id]?.latencyResults ?? []}
|
||||
threshold={config.offline_threshold_seconds}
|
||||
retentionDays={config.history_retention_days}
|
||||
locale={locale}
|
||||
demo={demoMode}
|
||||
onClose={goHome}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
if (loading) return <div className="chart-loading">{ui(locale, "正在加载节点", "Loading server")}</div>;
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<strong>{ui(locale, "节点不存在或已隐藏", "Server not found or hidden")}</strong>
|
||||
<button className="primary-btn" onClick={goHome}>{ui(locale, "返回首页", "Back")}</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
const next = dark ? "light" : "dark";
|
||||
localStorage.setItem("nodeflare-theme", next);
|
||||
setAppearance(next);
|
||||
}
|
||||
export default function App() {
|
||||
const { access, background, config, configReady, dark, error, loading, reload, selectedId, toggleTheme } = useApp();
|
||||
const locale = config.locale;
|
||||
|
||||
if (!configReady) {
|
||||
return <div className="app-bootstrap" aria-busy={loading}>{error ? <div className="error-band"><span>{error}</span><button onClick={() => void load()}>重试</button></div> : null}</div>;
|
||||
return (
|
||||
<div className="app-bootstrap" aria-busy={loading}>
|
||||
{error ? <div className="error-band"><span>{error}</span><button onClick={() => void reload()}>{ui(locale, "重试", "Retry")}</button></div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -449,50 +172,19 @@ export default function App() {
|
||||
<div className="container header-inner">
|
||||
<div className="brand"><SiteLogo src={config.logo_url} alt="" width="36" height="36" /><strong>{config.site_name}</strong></div>
|
||||
<div className="header-actions">
|
||||
<button className="icon-btn" onClick={toggleTheme} title={dark ? ui(config.locale, "浅色主题", "Light theme") : ui(config.locale, "深色主题", "Dark theme")}>{dark ? <Sun size={18} /> : <Moon size={18} />}</button>
|
||||
<button className="icon-btn" onClick={openAdmin} title={ui(config.locale, "进入后台", "Administration")}><UserCircle size={18} /></button>
|
||||
<button className="icon-btn" onClick={toggleTheme} title={dark ? ui(locale, "浅色主题", "Light theme") : ui(locale, "深色主题", "Dark theme")}>{dark ? <Sun size={18} /> : <Moon size={18} />}</button>
|
||||
<button className="icon-btn" onClick={() => window.location.assign("/admin")} title={ui(locale, "进入后台", "Administration")}><UserCircle size={18} /></button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="container main-content">
|
||||
{config.site_announcement ? <div className="site-announcement"><Megaphone size={16} /><span>{config.site_announcement}</span></div> : null}
|
||||
{needsLogin ? (
|
||||
<section className="dashboard-login-gate glass-panel">
|
||||
<SiteLogo src={config.logo_url} alt="" width="46" height="46" />
|
||||
<div className="dashboard-login-copy"><h1>{ui(config.locale, "登录仪表盘", "Sign in to dashboard")}</h1><p>{ui(config.locale, "此仪表盘仅限登录后访问", "This dashboard requires an administrator sign-in")}</p></div>
|
||||
<form className="dashboard-login-form" onSubmit={(event) => void loginDashboard(event)}>
|
||||
<label><span>{ui(config.locale, "用户名", "Username")}</span><input autoFocus autoComplete="username" value={loginUsername} onChange={(event) => setLoginUsername(event.target.value)} required /></label>
|
||||
<label><span>{ui(config.locale, "密码", "Password")}</span><input type="password" autoComplete="current-password" value={loginPassword} onChange={(event) => setLoginPassword(event.target.value)} required /></label>
|
||||
{config.turnstile_login_enabled ? <div className="dashboard-login-turnstile"><TurnstileWidget siteKey={config.turnstile_site_key} action="admin-login" theme={dark ? "dark" : "light"} resetKey={loginTurnstileReset} onVerify={setLoginTurnstileToken} onError={setError} /></div> : null}
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
<button className="primary-btn dashboard-login-submit" disabled={loginBusy || (config.turnstile_login_enabled && !loginTurnstileToken)} type="submit"><KeyRound size={16} />{loginBusy ? ui(config.locale, "登录中", "Signing in") : ui(config.locale, "登录", "Sign in")}</button>
|
||||
</form>
|
||||
</section>
|
||||
) : needsVerification ? (
|
||||
<section className="verification-gate">
|
||||
<SiteLogo src={config.logo_url} alt="" width="52" height="52" />
|
||||
<div><h1>{ui(config.locale, "访问验证", "Access verification")}</h1><p>{config.site_name}</p></div>
|
||||
{config.turnstile_site_key ? <TurnstileWidget siteKey={config.turnstile_site_key} action="public-dashboard" theme={dark ? "dark" : "light"} onVerify={(token) => void verifyDashboard(token)} onError={setError} /> : <p className="form-error">{ui(config.locale, "Turnstile 尚未正确配置", "Turnstile is not configured")}</p>}
|
||||
{verificationBusy ? <span className="verification-status">{ui(config.locale, "正在验证", "Verifying")}</span> : null}
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
</section>
|
||||
) : selectedId ? selected ? (
|
||||
<Suspense fallback={<div className="chart-loading">{ui(config.locale, "正在加载节点", "Loading server")}</div>}><NodeDetails server={selected} liveLatencyResults={liveMetrics[selected.id]?.latencyResults ?? []} threshold={config.offline_threshold_seconds} retentionDays={config.history_retention_days} locale={config.locale} demo={demoMode} onClose={goHome} /></Suspense>
|
||||
) : loading ? <div className="chart-loading">{ui(config.locale, "正在加载节点", "Loading server")}</div> : <div className="empty-state"><strong>{ui(config.locale, "节点不存在或已隐藏", "Server not found or hidden")}</strong><button className="primary-btn" onClick={goHome}>{ui(config.locale, "返回首页", "Back")}</button></div> : <div className="home-content">
|
||||
<StatsBar servers={liveServers} config={config} exchangeRates={exchangeRates} />
|
||||
{config.show_search || config.show_groups ? <div className="toolbar">
|
||||
{config.show_search ? <div className="search-box"><Search size={16} /><input aria-label={ui(config.locale, "搜索节点", "Search servers")} placeholder={ui(config.locale, "搜索节点", "Search servers")} value={query} onChange={(event) => setQuery(event.target.value)} /></div> : null}
|
||||
{config.show_groups ? <div className="group-tabs" aria-label={ui(config.locale, "节点分组", "Server groups")}>{groups.map((item) => <button className={group === item ? "active" : ""} key={item} onClick={() => setGroup(item)}>{item === "__all__" ? ui(config.locale, "全部", "All") : item}</button>)}</div> : null}
|
||||
<span className="result-count">{ui(config.locale, `${visible.length} 个节点`, `${visible.length} servers`)}</span>
|
||||
</div> : null}
|
||||
{error ? <div className="error-band"><span>{error}</span><button onClick={() => void load()}>{ui(config.locale, "重试", "Retry")}</button></div> : null}
|
||||
{loading && !liveServers.length ? <div className="loading-grid">{Array.from({ length: 8 }).map((_, index) => <span key={index} />)}</div> : visible.length ? (
|
||||
<section className="node-grid">{visible.map((server) => <NodeCard key={server.id} server={server} config={config} onOpen={() => openServer(server)} />)}</section>
|
||||
) : !error ? <div className="empty-state"><strong>{liveServers.length ? ui(config.locale, "没有匹配的节点", "No matching servers") : ui(config.locale, "尚未添加节点", "No servers added")}</strong></div> : null}
|
||||
</div>}
|
||||
{access === "login" ? <LoginGate />
|
||||
: access === "turnstile" ? <VerificationGate />
|
||||
: selectedId ? <DetailView />
|
||||
: <HomeView />}
|
||||
</main>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { api, ApiError, setToken } from "./api";
|
||||
import { demoConfig, demoExchangeRates, demoServers } from "./demo";
|
||||
import {
|
||||
advancePlayback,
|
||||
applyBatch,
|
||||
mergeServerLive,
|
||||
pruneStalePlayback,
|
||||
type LiveMetricsMap,
|
||||
type PlaybackBuffer,
|
||||
} from "./live";
|
||||
import { ui } from "./locale";
|
||||
import { resolveBackground, themeToggle } from "./theme";
|
||||
import { connectLive } from "./transport";
|
||||
import type { Config, ExchangeRates, Server } from "./types";
|
||||
import { derivePassword } from "./password";
|
||||
|
||||
const search = new URLSearchParams(window.location.search);
|
||||
export const demoMode = import.meta.env.DEV && search.has("demo");
|
||||
/**
|
||||
* `?demo=1&carrier=1` previews the per-carrier card layout locally. Gated on
|
||||
* `demoMode` so a stray `?carrier=1` cannot affect a real deployment.
|
||||
*/
|
||||
const demoViewConfig: Config = demoMode && search.has("carrier")
|
||||
? { ...demoConfig, theme_options: { ...demoConfig.theme_options, showCarrierLatency: true } }
|
||||
: demoConfig;
|
||||
const defaultConfig: Config = { ...demoViewConfig, site_description: "", site_name: "" };
|
||||
const BOOTSTRAP_POLL_INTERVAL = 15_000;
|
||||
const BOOTSTRAP_POLL_TICKS_WHEN_LIVE = 20;
|
||||
const MAX_CLOCK_STEP_MS = 5_000;
|
||||
|
||||
type Access = "ok" | "login" | "turnstile";
|
||||
|
||||
export interface AppState {
|
||||
config: Config;
|
||||
configReady: boolean;
|
||||
servers: Server[];
|
||||
liveMetrics: LiveMetricsMap;
|
||||
exchangeRates: ExchangeRates | null;
|
||||
loading: boolean;
|
||||
error: string;
|
||||
setError: (message: string) => void;
|
||||
access: Access;
|
||||
dark: boolean;
|
||||
blur: boolean;
|
||||
background: string;
|
||||
carrierLatency: boolean;
|
||||
toggleTheme: () => void;
|
||||
selectedId: string | null;
|
||||
openServer: (server: Server) => void;
|
||||
goHome: () => void;
|
||||
reload: () => Promise<void>;
|
||||
login: (username: string, password: string, turnstileToken: string) => Promise<void>;
|
||||
verify: (token: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const AppContext = createContext<AppState | null>(null);
|
||||
|
||||
export function useApp(): AppState {
|
||||
const state = useContext(AppContext);
|
||||
if (!state) throw new Error("useApp must be used within AppProvider");
|
||||
return state;
|
||||
}
|
||||
|
||||
function routeServerId() {
|
||||
const match = window.location.pathname.match(/^\/instance\/([^/]+)\/?$/);
|
||||
if (!match) return null;
|
||||
try { return decodeURIComponent(match[1]); } catch { return null; }
|
||||
}
|
||||
|
||||
export function AppProvider({ children }: { children: ReactNode }) {
|
||||
const [config, setConfig] = useState(defaultConfig);
|
||||
const [configReady, setConfigReady] = useState(demoMode);
|
||||
const [servers, setServers] = useState<Server[]>([]);
|
||||
const [liveMetrics, setLiveMetrics] = useState<LiveMetricsMap>({});
|
||||
const [clockNow, setClockNow] = useState(() => Date.now());
|
||||
const [exchangeRates, setExchangeRates] = useState<ExchangeRates | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [access, setAccess] = useState<Access>("ok");
|
||||
const [selectedId, setSelectedId] = useState<string | null>(routeServerId);
|
||||
const [appearance, setAppearance] = useState<"light" | "dark" | null>(() => {
|
||||
const stored = localStorage.getItem("nodeflare-theme");
|
||||
return stored === "light" || stored === "dark" ? stored : null;
|
||||
});
|
||||
const [systemDark, setSystemDark] = useState(() => matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
const liveConnectedRef = useRef(false);
|
||||
const playbackRef = useRef<PlaybackBuffer>(new Map());
|
||||
const serversRef = useRef<Server[]>([]);
|
||||
|
||||
const dark = appearance ? appearance === "dark" : config.default_theme === "system" ? systemDark : config.default_theme === "dark";
|
||||
const background = resolveBackground(config.background_url, dark);
|
||||
const blur = themeToggle(config, "enableBlur");
|
||||
const carrierLatency = config.show_latency && themeToggle(config, "showCarrierLatency", false);
|
||||
|
||||
const reload = useCallback(async (quiet = false) => {
|
||||
if (!quiet) setLoading(true);
|
||||
if (demoMode) {
|
||||
setConfig(demoViewConfig);
|
||||
setConfigReady(true);
|
||||
setServers(demoServers);
|
||||
setExchangeRates(demoExchangeRates);
|
||||
setError("");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await api.bootstrap();
|
||||
setConfig(result.config);
|
||||
setConfigReady(true);
|
||||
setServers(result.servers);
|
||||
setExchangeRates(result.exchange_rates);
|
||||
setAccess(result.access);
|
||||
if (result.access !== "ok") {
|
||||
playbackRef.current.clear();
|
||||
setLiveMetrics({});
|
||||
}
|
||||
setError("");
|
||||
} catch (reason) {
|
||||
const status = reason instanceof ApiError ? reason.status : 0;
|
||||
if (status === 401 || status === 403) {
|
||||
setAccess(status === 401 ? "login" : "turnstile");
|
||||
setServers([]);
|
||||
playbackRef.current.clear();
|
||||
setLiveMetrics({});
|
||||
setError(status === 403 && reason instanceof Error ? reason.message : "");
|
||||
} else {
|
||||
setError(reason instanceof Error ? reason.message : ui(config.locale, "无法加载节点状态", "Unable to load server status"));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (username: string, password: string, turnstileToken: string) => {
|
||||
const derived = await derivePassword(password, config.password_client_salt);
|
||||
const result = await api.login(username.trim(), password, derived, turnstileToken);
|
||||
setToken(result.token);
|
||||
setAccess("ok");
|
||||
await reload();
|
||||
}, [config.password_client_salt, reload]);
|
||||
|
||||
const verify = useCallback(async (token: string) => {
|
||||
await api.verifyTurnstile(token);
|
||||
setAccess("ok");
|
||||
await reload();
|
||||
}, [reload]);
|
||||
|
||||
useEffect(() => { void reload(); }, [reload]);
|
||||
|
||||
useEffect(() => {
|
||||
serversRef.current = servers;
|
||||
pruneStalePlayback(playbackRef.current, servers);
|
||||
}, [servers]);
|
||||
|
||||
// Poll as a safety net: often when the live feed is down, rarely when it is
|
||||
// healthy, so a missed frame cannot leave the page stale indefinitely.
|
||||
useEffect(() => {
|
||||
if (access !== "ok") return;
|
||||
let ticks = 0;
|
||||
const timer = window.setInterval(() => {
|
||||
ticks += 1;
|
||||
if (!liveConnectedRef.current || ticks % BOOTSTRAP_POLL_TICKS_WHEN_LIVE === 0) void reload(true);
|
||||
}, BOOTSTRAP_POLL_INTERVAL);
|
||||
return () => clearInterval(timer);
|
||||
}, [access, reload]);
|
||||
|
||||
useEffect(() => {
|
||||
let previous = Date.now();
|
||||
const timer = window.setInterval(() => {
|
||||
const current = Date.now();
|
||||
const elapsed = Math.max(0, Math.min(MAX_CLOCK_STEP_MS, current - previous));
|
||||
previous = current;
|
||||
setClockNow(current);
|
||||
if (elapsed === 0) return;
|
||||
setLiveMetrics((currentMetrics) => advancePlayback(currentMetrics, playbackRef.current, elapsed / 1000));
|
||||
}, 1_000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading || access !== "ok" || demoMode) return;
|
||||
return connectLive({ serverId: selectedId }, {
|
||||
onServer: (server) => setServers((current) => current.map((entry) => entry.id === server.id ? server : entry)),
|
||||
onBatch: (updates, cached) => setLiveMetrics((current) => applyBatch(current, updates, {
|
||||
cached,
|
||||
playback: playbackRef.current,
|
||||
servers: serversRef.current,
|
||||
})),
|
||||
onConnectedChange: (connected) => { liveConnectedRef.current = connected; },
|
||||
wakeTargets: () => {
|
||||
const now = Date.now() / 1000;
|
||||
return serversRef.current
|
||||
.filter((server) => server.timestamp && now - server.timestamp <= config.offline_threshold_seconds)
|
||||
.map((server) => server.id);
|
||||
},
|
||||
});
|
||||
}, [access, config.offline_threshold_seconds, loading, selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle("dark", dark);
|
||||
document.documentElement.style.colorScheme = dark ? "dark" : "light";
|
||||
document.documentElement.dataset.blur = blur ? "on" : "off";
|
||||
document.documentElement.lang = config.locale;
|
||||
}, [blur, config.locale, dark]);
|
||||
|
||||
useEffect(() => {
|
||||
let link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
|
||||
if (!link) {
|
||||
link = document.createElement("link");
|
||||
link.rel = "icon";
|
||||
document.head.append(link);
|
||||
}
|
||||
link.href = config.favicon_url || config.logo_url || "/logo.svg";
|
||||
}, [config.favicon_url, config.logo_url]);
|
||||
|
||||
useEffect(() => {
|
||||
const media = matchMedia("(prefers-color-scheme: dark)");
|
||||
const update = () => setSystemDark(media.matches);
|
||||
media.addEventListener("change", update);
|
||||
return () => media.removeEventListener("change", update);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onPop = () => setSelectedId(routeServerId());
|
||||
window.addEventListener("popstate", onPop);
|
||||
return () => window.removeEventListener("popstate", onPop);
|
||||
}, []);
|
||||
|
||||
const liveServers = useMemo(
|
||||
() => servers.map((server) => mergeServerLive(server, liveMetrics[server.id], clockNow, config.offline_threshold_seconds)),
|
||||
[clockNow, config.offline_threshold_seconds, liveMetrics, servers],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!configReady) return;
|
||||
const selected = liveServers.find((server) => server.id === selectedId);
|
||||
document.title = selected ? `${selected.name} · ${config.site_name}` : config.site_name;
|
||||
}, [config.site_name, configReady, liveServers, selectedId]);
|
||||
|
||||
const goHome = useCallback(() => {
|
||||
window.history.pushState({}, "", demoMode ? "/?demo=1" : "/");
|
||||
setSelectedId(null);
|
||||
window.scrollTo({ top: 0, behavior: "auto" });
|
||||
}, []);
|
||||
|
||||
const openServer = useCallback((server: Server) => {
|
||||
window.history.pushState({}, "", `/instance/${encodeURIComponent(server.id)}${demoMode ? "?demo=1" : ""}`);
|
||||
setSelectedId(server.id);
|
||||
window.scrollTo({ top: 0, behavior: "auto" });
|
||||
}, []);
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setAppearance((current) => {
|
||||
const resolved = current ?? (dark ? "dark" : "light");
|
||||
const next = resolved === "dark" ? "light" : "dark";
|
||||
localStorage.setItem("nodeflare-theme", next);
|
||||
return next;
|
||||
});
|
||||
}, [dark]);
|
||||
|
||||
const value = useMemo<AppState>(() => ({
|
||||
config,
|
||||
configReady,
|
||||
servers: liveServers,
|
||||
liveMetrics,
|
||||
exchangeRates,
|
||||
loading,
|
||||
error,
|
||||
setError,
|
||||
access,
|
||||
dark,
|
||||
blur,
|
||||
background,
|
||||
carrierLatency,
|
||||
toggleTheme,
|
||||
selectedId,
|
||||
openServer,
|
||||
goHome,
|
||||
reload: () => reload(),
|
||||
login,
|
||||
verify,
|
||||
}), [access, background, blur, carrierLatency, config, configReady, dark, error, exchangeRates, goHome, liveMetrics, liveServers, loading, login, openServer, reload, selectedId, toggleTheme, verify]);
|
||||
|
||||
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
|
||||
}
|
||||
+5
-2
@@ -1,4 +1,4 @@
|
||||
import type { AdminServer, AlertRule, AlertRuleInput, Bootstrap, CloudflareUsage, Config, DatabaseStats, ExchangeRates, HistoryPoint, LatencySample, LatencyTask, LatencyTaskInput, LatencyTestPoint, Server, ServerInput, Settings, Theme, ThemeSettingsSchema } from "./types";
|
||||
import type { AdminServer, AlertRule, AlertRuleInput, Bootstrap, CloudflareUsage, Config, DatabaseStats, ExchangeRates, HistoryPoint, LatencySample, LatencyTask, LatencyTaskInput, LatencyTestPoint, Server, ServerInput, Settings, TelegramSettings, TelegramSettingsInput, Theme, ThemeSettingsSchema } from "./types";
|
||||
|
||||
const TOKEN_KEY = "nodeflare-admin-token";
|
||||
export const ADMIN_UNAUTHORIZED_EVENT = "nodeflare:admin-unauthorized";
|
||||
@@ -71,6 +71,10 @@ export const api = {
|
||||
request<void>(`/api/admin/alert-rules/${encodeURIComponent(id)}`, { method: "PATCH", body: JSON.stringify(input) }, true),
|
||||
deleteAlertRule: (id: string) =>
|
||||
request<void>(`/api/admin/alert-rules/${encodeURIComponent(id)}`, { method: "DELETE" }, true),
|
||||
telegramSettings: () => request<{ telegram: TelegramSettings | null }>("/api/admin/telegram", {}, true),
|
||||
saveTelegramSettings: (input: TelegramSettingsInput) =>
|
||||
request<void>("/api/admin/telegram", { method: "PUT", body: JSON.stringify(input) }, true),
|
||||
testTelegram: () => request<void>("/api/admin/telegram/test", { method: "POST" }, true),
|
||||
themeSettings: () => request<ThemeSettingsSchema>("/api/admin/theme-settings", {}, true),
|
||||
themes: () => request<{ themes: Theme[] }>("/api/admin/themes", {}, true),
|
||||
addTheme: (input: Pick<Theme, "name" | "description" | "url">) =>
|
||||
@@ -117,5 +121,4 @@ export const api = {
|
||||
databaseStats: () => request<DatabaseStats>("/api/admin/database", {}, true),
|
||||
cloudflareUsage: () => request<CloudflareUsage>("/api/admin/cloudflare-usage", {}, true),
|
||||
clearHistory: () => request<void>("/api/admin/history", { method: "DELETE" }, true),
|
||||
testNotification: () => request<void>("/api/admin/notifications/test", { method: "POST" }, true),
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { UiLocale } from "./locale";
|
||||
|
||||
interface TimedPoint {
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lower quartile of the observed sampling deltas. A quartile keeps repeated
|
||||
* outages from inflating what we treat as the normal cadence, which is what
|
||||
* decides where a timeline break belongs.
|
||||
*/
|
||||
export function typicalInterval(points: readonly TimedPoint[]): number | null {
|
||||
const intervals = points
|
||||
.slice(1)
|
||||
.map((point, index) => point.timestamp - points[index].timestamp)
|
||||
.filter((interval) => interval > 0 && Number.isFinite(interval))
|
||||
.sort((left, right) => left - right);
|
||||
return intervals.length ? intervals[Math.floor((intervals.length - 1) / 4)] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a break point whenever the timeline skips more than the expected
|
||||
* cadence, so a chart never draws a straight line across an outage.
|
||||
* `minGap`/`maxGap` use the same unit as `timestamp`.
|
||||
*/
|
||||
export function insertTimelineGaps<T extends TimedPoint>(
|
||||
points: readonly T[],
|
||||
createGap: (timestamp: number) => T,
|
||||
{ minGap, maxGap }: { minGap: number; maxGap: number },
|
||||
): T[] {
|
||||
if (points.length < 2) return [...points];
|
||||
const interval = typicalInterval(points);
|
||||
if (interval === null) return [...points];
|
||||
const threshold = Math.min(Math.max(minGap, interval * 1.5), maxGap);
|
||||
const result: T[] = [points[0]];
|
||||
for (let index = 1; index < points.length; index += 1) {
|
||||
const previous = points[index - 1];
|
||||
const current = points[index];
|
||||
if (current.timestamp - previous.timestamp > threshold) {
|
||||
result.push(createGap(previous.timestamp + interval));
|
||||
}
|
||||
result.push(current);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Widest break a chart of this span should draw, in milliseconds. */
|
||||
export function chartGapLimit(hours: number): number {
|
||||
if (hours <= 0) return 30_000;
|
||||
return Math.max(5 * 60_000, (hours * 60 * 60_000) / 36);
|
||||
}
|
||||
|
||||
/** Fixed axis window so a partially populated range keeps its true scale. */
|
||||
export function chartTimeRange(hours: number, now = Date.now()) {
|
||||
const start = now - hours * 60 * 60_000;
|
||||
return {
|
||||
domain: [start, now] as [number, number],
|
||||
ticks: Array.from({ length: 5 }, (_, index) => start + ((now - start) * index) / 4),
|
||||
};
|
||||
}
|
||||
|
||||
export function chartTimeLabel(timestamp: number, hours: number, locale: UiLocale): string {
|
||||
const date = new Date(timestamp);
|
||||
if (hours <= 1) return date.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false });
|
||||
if (hours <= 4) return date.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit", hour12: false });
|
||||
if (hours <= 24) return date.toLocaleString(locale, { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false });
|
||||
return date.toLocaleDateString(locale, { month: "2-digit", day: "2-digit" });
|
||||
}
|
||||
|
||||
export function clockLabel(timestamp: number, locale: UiLocale): string {
|
||||
return new Date(timestamp).toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit", hour12: false });
|
||||
}
|
||||
@@ -41,13 +41,14 @@ import { useDialog } from "./useDialog";
|
||||
import { SiteLogo } from "./SiteLogo";
|
||||
import { LatencyManager } from "./LatencyManager";
|
||||
import { AlertRuleManager } from "./AlertRuleManager";
|
||||
import { TelegramSettings } from "./TelegramSettings";
|
||||
import { Flag } from "./Flag";
|
||||
import pkg from "../../package.json";
|
||||
|
||||
const VERSION = import.meta.env.VITE_NODEFLARE_VERSION || pkg.version;
|
||||
|
||||
type AdminTab = "servers" | "latency" | "appearance" | "themes" | "themeSettings" | "alerts" | "security" | "data" | "about";
|
||||
type AgentPlatform = "linux" | "windows" | "macos";
|
||||
type AgentPlatform = "linux" | "windows" | "macos" | "freebsd";
|
||||
|
||||
interface AgentInstallInfo {
|
||||
agent_token: string;
|
||||
@@ -376,13 +377,6 @@ export function AdminPanel({
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function testNotification() {
|
||||
setBusy(true); setError(""); setNotice("");
|
||||
try { await api.testNotification(); setNotice("测试通知已发送"); }
|
||||
catch (reason) { setError(reason instanceof Error ? reason.message : "测试通知失败"); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function loadDatabase() {
|
||||
setBusy(true); setError("");
|
||||
try {
|
||||
@@ -499,6 +493,9 @@ export function AdminPanel({
|
||||
if (installPlatform === "macos") {
|
||||
return `curl -fsSL ${installer}/install-macos.sh | sudo sh -s -- -e ${shellLiteral(origin)} -t ${shellLiteral(install.agent_token)}${shellMirror}`;
|
||||
}
|
||||
if (installPlatform === "freebsd") {
|
||||
return `fetch -qo - ${installer}/install-freebsd.sh | sudo sh -s -- -e ${shellLiteral(origin)} -t ${shellLiteral(install.agent_token)}${shellMirror}`;
|
||||
}
|
||||
return `curl -fsSL ${installer}/agent.sh | sudo sh -s -- -e ${shellLiteral(origin)} -t ${shellLiteral(install.agent_token)}${shellMirror}`;
|
||||
}, [install, installPlatform]);
|
||||
const serverDialog = useDialog<HTMLFormElement>(editing !== null, () => setEditing(null));
|
||||
@@ -617,7 +614,7 @@ export function AdminPanel({
|
||||
<div className="form-grid"><label><span>站点名称</span><input required value={settings.site_name} onChange={(event) => updateSettings("site_name", event.target.value)} /></label><label><span>站点描述</span><input value={settings.site_description} onChange={(event) => updateSettings("site_description", event.target.value)} /></label></div>
|
||||
<label><span>站点公告</span><textarea rows={3} maxLength={1000} value={settings.site_announcement} onChange={(event) => updateSettings("site_announcement", event.target.value)} /></label>
|
||||
<div className="form-grid three"><label><span>界面语言</span><select value={settings.locale} onChange={(event) => updateSettings("locale", event.target.value as Settings["locale"])}><option value="zh-CN">简体中文</option><option value="en">English</option></select></label><label><span>站点 Logo 地址</span><input type="url" maxLength={1000} value={settings.logo_url} onChange={(event) => updateSettings("logo_url", event.target.value)} placeholder="https://example.com/logo.svg" /></label><label><span>浏览器图标地址</span><input type="url" maxLength={1000} value={settings.favicon_url} onChange={(event) => updateSettings("favicon_url", event.target.value)} placeholder="https://example.com/favicon.png" /></label></div>
|
||||
<div className="form-grid"><label><span>离线判定(秒)</span><input type="number" min="30" max="3600" value={settings.offline_threshold_seconds} onChange={(event) => updateSettings("offline_threshold_seconds", Number(event.target.value))} /></label><label><span>历史保留(天)</span><input type="number" min="1" max="365" value={settings.history_retention_days} onChange={(event) => updateSettings("history_retention_days", Number(event.target.value))} /></label></div>
|
||||
<div className="form-grid"><label><span>离线判定(秒)</span><input type="number" min="30" max="3600" value={settings.offline_threshold_seconds} onChange={(event) => updateSettings("offline_threshold_seconds", Number(event.target.value))} /></label><label><span>历史保留(天)</span><input type="number" min="1" max="30" value={settings.history_retention_days} onChange={(event) => updateSettings("history_retention_days", Number(event.target.value))} /></label></div>
|
||||
</> : null}
|
||||
|
||||
{tab === "themeSettings" ? <>
|
||||
@@ -631,10 +628,10 @@ export function AdminPanel({
|
||||
|
||||
{tab === "alerts" ? <>
|
||||
<div className="section-title"><AlertTriangle size={15} />通知与告警</div>
|
||||
<div className="form-grid"><label><span>Telegram Bot Token</span><input autoComplete="off" type="password" value={settings.notification_endpoint} onChange={(event) => updateSettings("notification_endpoint", event.target.value)} placeholder="123456789:AA..." /></label><label><span>Telegram Chat ID</span><input value={settings.notification_target} onChange={(event) => updateSettings("notification_target", event.target.value)} placeholder="个人、群组或频道 ID" /></label></div>
|
||||
<Toggle label="启用通知与告警" checked={settings.notification_enabled} onChange={(value) => updateSettings("notification_enabled", value)} />
|
||||
<div className="form-grid three"><label><span>离线告警延迟(分钟)</span><input type="number" min="2" max="1440" value={settings.offline_alert_minutes} onChange={(event) => updateSettings("offline_alert_minutes", Number(event.target.value))} /></label><label><span>到期提醒(天)</span><input type="number" min="0" max="365" value={settings.expiry_alert_days} onChange={(event) => updateSettings("expiry_alert_days", Number(event.target.value))} /></label><label><span>通知测试</span><button type="button" className="secondary-btn" disabled={busy} onClick={() => void testNotification()}>发送测试通知</button></label></div>
|
||||
<p className="settings-hint">通过 Telegram Bot 发送告警;告警状态会记录在 D1,同一故障和恢复只发送一次。</p>
|
||||
<div className="form-grid three"><label><span>离线告警延迟(分钟)</span><input type="number" min="2" max="1440" value={settings.offline_alert_minutes} onChange={(event) => updateSettings("offline_alert_minutes", Number(event.target.value))} /></label><label><span>到期提醒(天)</span><input type="number" min="0" max="365" value={settings.expiry_alert_days} onChange={(event) => updateSettings("expiry_alert_days", Number(event.target.value))} /></label><label><span>流量提醒起始阈值(%)</span><input type="number" min="50" max="100" value={settings.traffic_alert_percentage} onChange={(event) => updateSettings("traffic_alert_percentage", Number(event.target.value))} /></label></div>
|
||||
<p className="settings-hint">流量达到起始阈值后,每增加 5 个百分点生成一次事件,最多提醒到 100%。</p>
|
||||
<TelegramSettings onError={setError} onNotice={setNotice} />
|
||||
<AlertRuleManager servers={servers} onError={setError} onNotice={setNotice} />
|
||||
</> : null}
|
||||
|
||||
@@ -690,7 +687,7 @@ export function AdminPanel({
|
||||
<div className="form-actions"><button type="button" className="secondary-btn" onClick={() => setEditing(null)}>取消</button><button className="primary-btn" disabled={busy}><Save size={16} />保存节点</button></div>
|
||||
</form></div> : null}
|
||||
|
||||
{installCommand ? <div className="submodal-backdrop" role="presentation" onMouseDown={installDialog.onBackdropMouseDown}><section ref={installDialog.dialogRef} className="install-modal glass-panel" role="dialog" aria-modal="true" aria-labelledby="install-dialog-title" tabIndex={-1}><header><div><span className="eyebrow">Agent 部署</span><h3 id="install-dialog-title">安装命令</h3></div><div className="segmented install-platform" aria-label="Agent 平台"><button type="button" className={installPlatform === "linux" ? "active" : ""} onClick={() => setInstallPlatform("linux")}>Linux</button><button type="button" className={installPlatform === "windows" ? "active" : ""} onClick={() => setInstallPlatform("windows")}>Windows</button><button type="button" className={installPlatform === "macos" ? "active" : ""} onClick={() => setInstallPlatform("macos")}>macOS ARM</button></div></header><div className="install-list"><pre>{installCommand}</pre></div><div className="form-actions"><button className="secondary-btn" type="button" onClick={() => setInstall(null)}>关闭</button><button className="primary-btn" type="button" onClick={() => void copyInstallCommand()}><Copy size={16} />复制</button></div></section></div> : null}
|
||||
{installCommand ? <div className="submodal-backdrop" role="presentation" onMouseDown={installDialog.onBackdropMouseDown}><section ref={installDialog.dialogRef} className="install-modal glass-panel" role="dialog" aria-modal="true" aria-labelledby="install-dialog-title" tabIndex={-1}><header><div><span className="eyebrow">Agent 部署</span><h3 id="install-dialog-title">安装命令</h3></div><div className="segmented install-platform" aria-label="Agent 平台"><button type="button" className={installPlatform === "linux" ? "active" : ""} onClick={() => setInstallPlatform("linux")}>Linux</button><button type="button" className={installPlatform === "windows" ? "active" : ""} onClick={() => setInstallPlatform("windows")}>Windows</button><button type="button" className={installPlatform === "macos" ? "active" : ""} onClick={() => setInstallPlatform("macos")}>macOS ARM</button><button type="button" className={installPlatform === "freebsd" ? "active" : ""} onClick={() => setInstallPlatform("freebsd")}>FreeBSD</button></div></header><div className="install-list"><pre>{installCommand}</pre></div><div className="form-actions"><button className="secondary-btn" type="button" onClick={() => setInstall(null)}>关闭</button><button className="primary-btn" type="button" onClick={() => void copyInstallCommand()}><Copy size={16} />复制</button></div></section></div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,8 @@ import {
|
||||
} from "../format";
|
||||
import type { Config, Server } from "../types";
|
||||
import { ui } from "../locale";
|
||||
import { useNodeLatency, type LatencyBar } from "../hooks/useNodeLatency";
|
||||
import { carrierSelection, themeToggle } from "../theme";
|
||||
import { useNodeLatency, type CarrierLatencyRow, type LatencyBar } from "../hooks/useNodeLatency";
|
||||
import { Flag } from "./Flag";
|
||||
import { OSIcon } from "./OSIcon";
|
||||
import { ProgressBar } from "./ProgressBar";
|
||||
@@ -40,25 +41,55 @@ function CompactLine({ icon, children, tone = "" }: { icon: React.ReactNode; chi
|
||||
return <b className={tone}><span>{icon}</span><em>{children}</em></b>;
|
||||
}
|
||||
|
||||
function QualityBars({ bars }: { bars: LatencyBar[] }) {
|
||||
return (
|
||||
<div className="quality-bars" style={{ gridTemplateColumns: `repeat(${Math.max(1, bars.length)}, minmax(0, 1fr))` }}>
|
||||
{bars.map((bar, index) => {
|
||||
const alignment = bars.length === 1 || (index >= 3 && index < bars.length - 3)
|
||||
? "center"
|
||||
: index < 3 ? "start" : "end";
|
||||
return <span className="quality-bar" key={bar.key}>
|
||||
<i className={bar.tone} />
|
||||
<small className={`quality-tooltip ${alignment}`} role="tooltip">{bar.tooltip}</small>
|
||||
</span>;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QualityPanel({ label, value, bars }: {
|
||||
label: string;
|
||||
value: string;
|
||||
bars: LatencyBar[];
|
||||
}) {
|
||||
return (
|
||||
<div className="quality-panel">
|
||||
<div className="quality-panel" aria-label={`${label} ${value}`}>
|
||||
<div><span>{label}</span><b>{value}</b></div>
|
||||
<div className="quality-bars" style={{ gridTemplateColumns: `repeat(${Math.max(1, bars.length)}, minmax(0, 1fr))` }}>
|
||||
{bars.map((bar, index) => {
|
||||
const alignment = bars.length === 1 || (index >= 3 && index < bars.length - 3)
|
||||
? "center"
|
||||
: index < 3 ? "start" : "end";
|
||||
return <span className="quality-bar" key={bar.key}>
|
||||
<i className={bar.tone} />
|
||||
<small className={`quality-tooltip ${alignment}`} role="tooltip">{bar.tooltip}</small>
|
||||
</span>;
|
||||
})}
|
||||
</div>
|
||||
<QualityBars bars={bars} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One column per metric, one row per carrier line. */
|
||||
function CarrierPanel({ label, rows, kind, empty }: {
|
||||
label: string;
|
||||
rows: CarrierLatencyRow[];
|
||||
kind: "latency" | "loss";
|
||||
empty: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="quality-panel carrier-panel">
|
||||
<div><span>{label}</span></div>
|
||||
{rows.length ? <div className="carrier-rows">
|
||||
{rows.map((row) => <div className="carrier-row" key={row.id}>
|
||||
<div className="carrier-row-head" title={row.name}>
|
||||
<i style={{ backgroundColor: row.color }} />
|
||||
<span>{row.label}</span>
|
||||
<b>{kind === "latency" ? row.latencyDisplay : row.lossDisplay}</b>
|
||||
</div>
|
||||
<QualityBars bars={kind === "latency" ? row.latencyBars : row.lossBars} />
|
||||
</div>)}
|
||||
</div> : <div className="empty-inline">{empty}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -70,11 +101,17 @@ export function NodeCard({ server, config, onOpen }: { server: Server; config: C
|
||||
const disk = percent(server.disk_used, server.disk_total);
|
||||
const usedTraffic = trafficUsed(server);
|
||||
const traffic = server.traffic_limit > 0 ? Math.min(100, (usedTraffic / server.traffic_limit) * 100) : 0;
|
||||
const quality = useNodeLatency(server, config.show_latency);
|
||||
const locale = config.locale;
|
||||
const showCarriers = themeToggle(config, "showCarrierLatency", false);
|
||||
// The hook keys its memo on the selection values, not the object identity,
|
||||
// so rebuilding this each render is free.
|
||||
const quality = useNodeLatency(server, config.show_latency, locale, showCarriers ? carrierSelection(config) : null);
|
||||
const price = formatPrice(server, locale);
|
||||
const remainingValue = remainingAssetValue(server.price, server.billing_cycle, server.expires_at);
|
||||
const showExpiryPanel = config.show_expiry || config.show_price;
|
||||
const carrierEmpty = quality.loading
|
||||
? ui(locale, "加载中", "Loading")
|
||||
: ui(locale, "未匹配到线路", "No lines matched");
|
||||
|
||||
return (
|
||||
<button className={`node-card glass-panel ${online ? "" : "offline"}`} onClick={onOpen} type="button" aria-label={ui(locale, `查看 ${server.name} 详情`, `View ${server.name} details`)}>
|
||||
@@ -118,8 +155,13 @@ export function NodeCard({ server, config, onOpen }: { server: Server; config: C
|
||||
</div>
|
||||
|
||||
{config.show_latency ? <div className="quality-grid">
|
||||
<QualityPanel label={ui(locale, "延迟", "Latency")} value={quality.latencyDisplay} bars={quality.latencyBars} />
|
||||
<QualityPanel label={ui(locale, "丢包", "Packet loss")} value={quality.lossDisplay} bars={quality.lossBars} />
|
||||
{showCarriers ? <>
|
||||
<CarrierPanel label={ui(locale, "延迟", "Latency")} rows={quality.carriers} kind="latency" empty={carrierEmpty} />
|
||||
<CarrierPanel label={ui(locale, "丢包", "Packet loss")} rows={quality.carriers} kind="loss" empty={carrierEmpty} />
|
||||
</> : <>
|
||||
<QualityPanel label={ui(locale, "延迟", "Latency")} value={quality.latencyDisplay} bars={quality.latencyBars} />
|
||||
<QualityPanel label={ui(locale, "丢包", "Packet loss")} value={quality.lossDisplay} bars={quality.lossBars} />
|
||||
</>}
|
||||
</div> : null}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
@@ -22,6 +21,7 @@ import {
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { api } from "../api";
|
||||
import { chartGapLimit, chartTimeLabel, chartTimeRange, insertTimelineGaps } from "../chart";
|
||||
import { demoHistory, demoLatencyHistory, demoLatencyTasks } from "../demo";
|
||||
import { displayGpuDevices, formatBytes, formatCpuName, formatSpeed, formatUptime, isOnline, number } from "../format";
|
||||
import type { HistoryPoint, LatencySample, LatencyTestPoint, LiveLatencyResult, Server } from "../types";
|
||||
@@ -98,7 +98,7 @@ function average(values: number[]): number | null {
|
||||
}
|
||||
|
||||
function latencyBucketSeconds(hours: number, taskCount: number): number {
|
||||
const boundedHours = Math.max(1, Math.min(24 * 365, Math.trunc(hours)));
|
||||
const boundedHours = Math.max(1, Math.min(24 * 30, Math.trunc(hours)));
|
||||
const boundedTasks = Math.max(1, Math.min(128, Math.trunc(taskCount)));
|
||||
const base = boundedHours === 1 ? 60
|
||||
: boundedHours <= 4 ? 120
|
||||
@@ -134,37 +134,64 @@ interface LatencyChartPoint {
|
||||
value: number | null;
|
||||
}
|
||||
|
||||
function latencyChartGapLimit(hours: number): number {
|
||||
return Math.max(5 * 60_000, (hours * 60 * 60_000) / 36);
|
||||
interface LoadChartPoint {
|
||||
timestamp: number;
|
||||
time: string;
|
||||
cpu: number | null;
|
||||
mem_used: number | null;
|
||||
mem_total: number;
|
||||
disk_used: number | null;
|
||||
disk_total: number;
|
||||
net_in: number | null;
|
||||
net_out: number | null;
|
||||
}
|
||||
|
||||
function insertLatencyGaps(points: LatencyChartPoint[], hours: number): LatencyChartPoint[] {
|
||||
if (points.length < 2) return points;
|
||||
const intervals = points.slice(1)
|
||||
.map((point, index) => point.timestamp - points[index].timestamp)
|
||||
.filter((interval) => interval > 0 && Number.isFinite(interval))
|
||||
.sort((left, right) => left - right);
|
||||
if (!intervals.length) return points;
|
||||
const typicalInterval = intervals[Math.floor((intervals.length - 1) / 4)];
|
||||
const threshold = Math.min(Math.max(10_000, typicalInterval * 1.5), latencyChartGapLimit(hours));
|
||||
const result: LatencyChartPoint[] = [points[0]];
|
||||
for (let index = 1; index < points.length; index += 1) {
|
||||
const previous = points[index - 1];
|
||||
const current = points[index];
|
||||
if (current.timestamp - previous.timestamp > threshold) {
|
||||
result.push({ timestamp: previous.timestamp + typicalInterval, label: "", value: null });
|
||||
}
|
||||
result.push(current);
|
||||
}
|
||||
return result;
|
||||
return insertTimelineGaps(
|
||||
points,
|
||||
(timestamp) => ({ timestamp, label: "", value: null }),
|
||||
{ minGap: 10_000, maxGap: chartGapLimit(hours) },
|
||||
);
|
||||
}
|
||||
|
||||
function latencyChartTime(timestamp: number, hours: number, locale: "zh-CN" | "en"): string {
|
||||
const date = new Date(timestamp);
|
||||
if (hours <= 1) return date.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false });
|
||||
if (hours <= 4) return date.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit", hour12: false });
|
||||
if (hours <= 24) return date.toLocaleString(locale, { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false });
|
||||
return date.toLocaleDateString(locale, { month: "2-digit", day: "2-digit" });
|
||||
function loadChartTime(timestamp: number, hours: number, locale: "zh-CN" | "en"): string {
|
||||
return new Date(timestamp * 1000).toLocaleString(locale, hours >= 24
|
||||
? { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false }
|
||||
: { hour: "2-digit", minute: "2-digit", hour12: false });
|
||||
}
|
||||
|
||||
function insertLoadGaps(points: HistoryPoint[], hours: number, locale: "zh-CN" | "en"): LoadChartPoint[] {
|
||||
const mapped = points
|
||||
.filter((point) => Number.isFinite(point.timestamp) && point.timestamp > 0)
|
||||
.sort((left, right) => left.timestamp - right.timestamp)
|
||||
.map((point) => ({
|
||||
timestamp: point.timestamp,
|
||||
time: loadChartTime(point.timestamp, hours, locale),
|
||||
cpu: Number.isFinite(point.cpu) ? point.cpu : null,
|
||||
mem_used: Number.isFinite(point.mem_used) ? point.mem_used : null,
|
||||
mem_total: Math.max(0, number(point.mem_total)),
|
||||
disk_used: Number.isFinite(point.disk_used) ? point.disk_used : null,
|
||||
disk_total: Math.max(0, number(point.disk_total)),
|
||||
net_in: Number.isFinite(point.net_in) ? point.net_in : null,
|
||||
net_out: Number.isFinite(point.net_out) ? point.net_out : null,
|
||||
}));
|
||||
// Load history keeps second-resolution timestamps, so the gap bounds are
|
||||
// the millisecond limits divided back down.
|
||||
return insertTimelineGaps(
|
||||
mapped,
|
||||
(timestamp) => ({
|
||||
timestamp,
|
||||
time: "",
|
||||
cpu: null,
|
||||
mem_used: null,
|
||||
mem_total: 0,
|
||||
disk_used: null,
|
||||
disk_total: 0,
|
||||
net_in: null,
|
||||
net_out: null,
|
||||
}),
|
||||
{ minGap: 10, maxGap: chartGapLimit(hours) / 1000 },
|
||||
);
|
||||
}
|
||||
|
||||
const tooltipStyle = {
|
||||
@@ -189,6 +216,7 @@ export function NodeDetails({ server, liveLatencyResults, threshold, retentionDa
|
||||
const [points, setPoints] = useState<HistoryPoint[]>([]);
|
||||
const [latencyPoints, setLatencyPoints] = useState<LatencySample[]>([]);
|
||||
const [latencyTasks, setLatencyTasks] = useState<LatencyTestPoint[]>([]);
|
||||
const [hiddenLatencyTaskIds, setHiddenLatencyTaskIds] = useState<Set<string>>(() => new Set());
|
||||
const [loadLoading, setLoadLoading] = useState(true);
|
||||
const [latencyLoading, setLatencyLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState("");
|
||||
@@ -284,6 +312,14 @@ export function NodeDetails({ server, liveLatencyResults, threshold, retentionDa
|
||||
setLatencyPoints((current) => mergeLatencySamples(current, samples, latencyHours, latencyTasks.length));
|
||||
}, [demo, latencyHours, latencyTasks, liveLatencyResults, server.id]);
|
||||
|
||||
useEffect(() => {
|
||||
setHiddenLatencyTaskIds((current) => {
|
||||
const available = new Set(latencyTasks.map((task) => task.id));
|
||||
const next = new Set([...current].filter((id) => available.has(id)));
|
||||
return next.size === current.size ? current : next;
|
||||
});
|
||||
}, [latencyTasks]);
|
||||
|
||||
const online = isOnline(server, threshold);
|
||||
const gpuDevices = useMemo(() => displayGpuDevices(server.gpus), [server.gpus]);
|
||||
const gpuNames = gpuDevices.map((gpu) => gpu.model).join(" · ");
|
||||
@@ -297,12 +333,11 @@ export function NodeDetails({ server, liveLatencyResults, threshold, retentionDa
|
||||
].filter((range) => range.value <= Math.max(24, retentionDays * 24));
|
||||
const ranges = chartType === "load" ? loadRanges : loadRanges.filter((range) => range.value > 0);
|
||||
const hours = chartType === "load" ? loadHours : latencyHours;
|
||||
const data = useMemo(() => points.map((point) => ({
|
||||
...point,
|
||||
time: new Date(point.timestamp * 1000).toLocaleString(locale, hours >= 24
|
||||
? { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false }
|
||||
: { hour: "2-digit", minute: "2-digit", hour12: false }),
|
||||
})), [points, hours, locale]);
|
||||
const loadChartHours = loadHours === 0 ? 1 : loadHours;
|
||||
const data = useMemo(
|
||||
() => insertLoadGaps(points, loadChartHours, locale),
|
||||
[loadChartHours, locale, points],
|
||||
);
|
||||
const latencySeries = useMemo(() => {
|
||||
return latencyTasks.map((task, index) => {
|
||||
const samples = latencyPoints.filter((point) => point.task_id === task.id);
|
||||
@@ -311,7 +346,7 @@ export function NodeDetails({ server, liveLatencyResults, threshold, retentionDa
|
||||
.sort((left, right) => left.timestamp - right.timestamp)
|
||||
.map((sample) => ({
|
||||
timestamp: sample.timestamp * 1000,
|
||||
label: latencyChartTime(sample.timestamp * 1000, hours, locale),
|
||||
label: chartTimeLabel(sample.timestamp * 1000, hours, locale),
|
||||
value: sample.latency_ms >= 0 ? sample.latency_ms : null,
|
||||
}));
|
||||
return {
|
||||
@@ -324,25 +359,30 @@ export function NodeDetails({ server, liveLatencyResults, threshold, retentionDa
|
||||
};
|
||||
});
|
||||
}, [hours, latencyPoints, latencyTasks, locale]);
|
||||
const visibleLatencySeries = useMemo(
|
||||
() => latencySeries.filter((series) => !hiddenLatencyTaskIds.has(series.id)),
|
||||
[hiddenLatencyTaskIds, latencySeries],
|
||||
);
|
||||
const latencyData = useMemo(() => {
|
||||
const timestamps = new Set<number>();
|
||||
for (const series of latencySeries) {
|
||||
for (const series of visibleLatencySeries) {
|
||||
for (const point of series.points) timestamps.add(point.timestamp);
|
||||
}
|
||||
return Array.from(timestamps)
|
||||
.sort((left, right) => left - right)
|
||||
.map((timestamp) => ({ timestamp, label: latencyChartTime(timestamp, hours, locale) }));
|
||||
}, [hours, latencySeries, locale]);
|
||||
const latencyTimeRange = useMemo(() => {
|
||||
const now = Date.now();
|
||||
const start = now - latencyHours * 60 * 60_000;
|
||||
return {
|
||||
domain: [start, now] as [number, number],
|
||||
ticks: Array.from({ length: 5 }, (_, index) => start + ((now - start) * index) / 4),
|
||||
};
|
||||
}, [latencyHours, latencyPoints]);
|
||||
.map((timestamp) => ({ timestamp, label: chartTimeLabel(timestamp, hours, locale) }));
|
||||
}, [hours, locale, visibleLatencySeries]);
|
||||
const latencyTimeRange = useMemo(() => chartTimeRange(latencyHours), [latencyHours, latencyPoints]);
|
||||
const last = data[data.length - 1];
|
||||
const pingEnabled = latencyTasks.length > 0;
|
||||
const toggleLatencyTask = (taskId: string) => {
|
||||
setHiddenLatencyTaskIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(taskId)) next.delete(taskId);
|
||||
else next.add(taskId);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="detail-page">
|
||||
@@ -401,23 +441,38 @@ export function NodeDetails({ server, liveLatencyResults, threshold, retentionDa
|
||||
) : (
|
||||
<section className="latency-overview glass-panel">
|
||||
<div className="latency-summary-grid" style={{ gridTemplateColumns: `repeat(${Math.max(1, latencySeries.length)}, minmax(180px, 1fr))` }}>
|
||||
{latencySeries.map((series) => <div className="latency-summary" key={series.id}>
|
||||
{latencySeries.map((series) => <div className={`latency-summary ${hiddenLatencyTaskIds.has(series.id) ? "hidden" : ""}`} key={series.id}>
|
||||
<span><i style={{ backgroundColor: series.color }} />{series.name}</span>
|
||||
<strong>{series.latency == null ? "--" : `${series.latency.toFixed(2)} ms`}</strong>
|
||||
<small>{series.loss == null ? "--" : `${series.loss.toFixed(2)}%`} 平均丢包</small>
|
||||
</div>)}
|
||||
</div>
|
||||
<div className="latency-task-legend" aria-label={ui(locale, "延迟任务", "Latency tasks")}>
|
||||
{latencySeries.map((series) => {
|
||||
const hidden = hiddenLatencyTaskIds.has(series.id);
|
||||
return <button
|
||||
className={`latency-task-toggle ${hidden ? "hidden" : ""}`}
|
||||
key={series.id}
|
||||
type="button"
|
||||
aria-pressed={!hidden}
|
||||
title={hidden ? ui(locale, "显示此任务", "Show this task") : ui(locale, "隐藏此任务", "Hide this task")}
|
||||
onClick={() => toggleLatencyTask(series.id)}
|
||||
>
|
||||
<i style={{ backgroundColor: series.color }} />
|
||||
<span>{series.name}</span>
|
||||
</button>;
|
||||
})}
|
||||
</div>
|
||||
<div className="latency-line-chart">
|
||||
{latencyData.length ? <ResponsiveContainer width="100%" height="100%">
|
||||
{latencyData.length && visibleLatencySeries.length ? <ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={latencyData} margin={{ top: 10, right: 12, left: 4, bottom: 2 }}>
|
||||
<CartesianGrid stroke="var(--chart-grid)" strokeDasharray="3 3" />
|
||||
<XAxis dataKey="timestamp" type="number" domain={latencyTimeRange.domain} ticks={latencyTimeRange.ticks} allowDataOverflow tick={{ fontSize: 10 }} minTickGap={40} tickFormatter={(value) => latencyChartTime(Number(value), latencyHours, locale)} />
|
||||
<XAxis dataKey="timestamp" type="number" domain={latencyTimeRange.domain} ticks={latencyTimeRange.ticks} allowDataOverflow tick={{ fontSize: 10 }} minTickGap={40} tickFormatter={(value) => chartTimeLabel(Number(value), latencyHours, locale)} />
|
||||
<YAxis tick={{ fontSize: 10 }} width={48} unit="ms" />
|
||||
<Tooltip contentStyle={tooltipStyle} labelFormatter={(value) => latencyChartTime(Number(value), latencyHours, locale)} formatter={(value, name) => [value == null ? "--" : `${Number(value).toFixed(1)} ms`, name]} />
|
||||
<Legend iconType="rect" iconSize={9} wrapperStyle={{ fontSize: 11, paddingTop: 10 }} />
|
||||
{latencySeries.map((series) => <Line key={series.id} data={series.points} type="monotone" dataKey="value" name={series.name} stroke={series.color} strokeWidth={2} dot={false} connectNulls={false} isAnimationActive={false} />)}
|
||||
<Tooltip contentStyle={tooltipStyle} labelFormatter={(value) => chartTimeLabel(Number(value), latencyHours, locale)} formatter={(value, name) => [value == null ? "--" : `${Number(value).toFixed(1)} ms`, name]} />
|
||||
{visibleLatencySeries.map((series) => <Line key={series.id} data={series.points} type="monotone" dataKey="value" name={series.name} stroke={series.color} strokeWidth={2} dot={false} connectNulls={false} isAnimationActive={false} />)}
|
||||
</LineChart>
|
||||
</ResponsiveContainer> : <div className="latency-empty">{ui(locale, "暂无延迟数据", "No latency data")}</div>}
|
||||
</ResponsiveContainer> : <div className="latency-empty">{visibleLatencySeries.length ? ui(locale, "暂无延迟数据", "No latency data") : ui(locale, "已隐藏全部任务", "All tasks are hidden")}</div>}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -11,5 +11,12 @@ export function SiteLogo({ src = "", ...props }: SiteLogoProps) {
|
||||
const [failedSource, setFailedSource] = useState("");
|
||||
const resolved = failedSource === requested ? DEFAULT_LOGO : requested;
|
||||
|
||||
return <img {...props} src={resolved} onError={() => setFailedSource(requested)} />;
|
||||
return <img
|
||||
{...props}
|
||||
src={resolved}
|
||||
onError={() => {
|
||||
// Do not retry the built-in fallback forever when it is unavailable.
|
||||
if (resolved !== DEFAULT_LOGO) setFailedSource(requested);
|
||||
}}
|
||||
/>;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { CheckCircle2, Send, Save } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api } from "../api";
|
||||
import type { TelegramSettingsInput } from "../types";
|
||||
|
||||
const defaultTemplate = "{{title}}\n\n服务器:{{server}}\n{{message}}\n时间:{{time}}";
|
||||
const emptySettings: TelegramSettingsInput = {
|
||||
bot_token: "",
|
||||
chat_id: "",
|
||||
message_thread_id: null,
|
||||
template: defaultTemplate,
|
||||
};
|
||||
|
||||
export function TelegramSettings({ onError, onNotice }: {
|
||||
onError: (message: string) => void;
|
||||
onNotice: (message: string) => void;
|
||||
}) {
|
||||
const [settings, setSettings] = useState<TelegramSettingsInput>(emptySettings);
|
||||
const [configured, setConfigured] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const result = await api.telegramSettings();
|
||||
if (result.telegram) {
|
||||
setSettings({
|
||||
bot_token: result.telegram.bot_token,
|
||||
chat_id: result.telegram.chat_id,
|
||||
message_thread_id: result.telegram.message_thread_id,
|
||||
template: result.telegram.template,
|
||||
});
|
||||
setConfigured(true);
|
||||
} else {
|
||||
setSettings(emptySettings);
|
||||
setConfigured(false);
|
||||
}
|
||||
} catch (reason) {
|
||||
onError(reason instanceof Error ? reason.message : "读取 Telegram 配置失败");
|
||||
}
|
||||
}, [onError]);
|
||||
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
function update<K extends keyof TelegramSettingsInput>(key: K, value: TelegramSettingsInput[K]) {
|
||||
setSettings((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setBusy(true);
|
||||
onError("");
|
||||
try {
|
||||
await api.saveTelegramSettings({
|
||||
...settings,
|
||||
bot_token: settings.bot_token.trim(),
|
||||
chat_id: settings.chat_id.trim(),
|
||||
template: settings.template.trim(),
|
||||
});
|
||||
await load();
|
||||
onNotice("Telegram 配置已保存");
|
||||
} catch (reason) {
|
||||
onError(reason instanceof Error ? reason.message : "保存 Telegram 配置失败");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function test() {
|
||||
setBusy(true);
|
||||
onError("");
|
||||
try {
|
||||
await api.testTelegram();
|
||||
onNotice("Telegram 测试消息已发送");
|
||||
} catch (reason) {
|
||||
onError(reason instanceof Error ? reason.message : "Telegram 测试消息发送失败");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const ready = Boolean(settings.bot_token.trim() && settings.chat_id.trim() && settings.template.trim());
|
||||
|
||||
return <section className="telegram-settings" aria-labelledby="telegram-settings-title">
|
||||
<header className="telegram-settings-header">
|
||||
<div className="telegram-settings-title">
|
||||
<span className="telegram-mark"><Send size={18} /></span>
|
||||
<div><h3 id="telegram-settings-title">Telegram</h3><span className={`telegram-state ${configured ? "configured" : ""}`}>{configured ? <CheckCircle2 size={13} /> : null}{configured ? "已配置" : "未配置"}</span></div>
|
||||
</div>
|
||||
</header>
|
||||
<div className="telegram-fields">
|
||||
<label className="telegram-token-field"><span>Bot Token</span><input type="password" autoComplete="off" value={settings.bot_token} onChange={(event) => update("bot_token", event.target.value)} placeholder="123456789:AA..." /></label>
|
||||
<label><span>Chat ID</span><input value={settings.chat_id} onChange={(event) => update("chat_id", event.target.value)} placeholder="-1001234567890" /></label>
|
||||
<label><span>话题 ID(可选)</span><input type="number" min="1" value={settings.message_thread_id ?? ""} onChange={(event) => update("message_thread_id", event.target.value ? Number(event.target.value) : null)} /></label>
|
||||
</div>
|
||||
<label className="telegram-template"><span>消息模板</span><textarea rows={5} maxLength={4000} value={settings.template} onChange={(event) => update("template", event.target.value)} /></label>
|
||||
<div className="telegram-actions">
|
||||
<button type="button" className="secondary-btn" disabled={busy || !configured} onClick={() => void test()}><Send size={15} />发送测试</button>
|
||||
<button type="button" className="primary-btn" disabled={busy || !ready} onClick={() => void save()}><Save size={15} />保存 Telegram</button>
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,139 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { insertTimelineGaps, typicalInterval } from "./chart";
|
||||
import { BAR_COUNT, bucketSamples, selectCarrierTasks } from "./latency";
|
||||
import type { LatencySample } from "./types";
|
||||
|
||||
function sample(taskId: string, timestamp: number, latency = 30, loss = 0): LatencySample {
|
||||
return {
|
||||
task_id: taskId,
|
||||
server_id: "server-1",
|
||||
name: taskId,
|
||||
task_type: "icmp",
|
||||
target: "example.com",
|
||||
port: null,
|
||||
timestamp,
|
||||
latency_ms: latency,
|
||||
packet_loss: loss,
|
||||
};
|
||||
}
|
||||
|
||||
describe("selectCarrierTasks", () => {
|
||||
const tasks = [
|
||||
{ id: "a", name: "上海电信" },
|
||||
{ id: "b", name: "China Mobile CN" },
|
||||
{ id: "c", name: "cucc-guangzhou" },
|
||||
];
|
||||
|
||||
test("matches carriers by name when nothing is configured", () => {
|
||||
const selected = selectCarrierTasks(tasks, { telecom: "", mobile: "", unicom: "" });
|
||||
expect(selected.get("telecom")?.id).toBe("a");
|
||||
expect(selected.get("mobile")?.id).toBe("b");
|
||||
expect(selected.get("unicom")?.id).toBe("c");
|
||||
});
|
||||
|
||||
test("honours explicit task names and ignores unmatched slots", () => {
|
||||
const selected = selectCarrierTasks(tasks, { telecom: "cucc-guangzhou", mobile: "", unicom: "missing" });
|
||||
expect(selected.get("telecom")?.id).toBe("c");
|
||||
expect(selected.has("mobile")).toBe(false);
|
||||
expect(selected.has("unicom")).toBe(false);
|
||||
});
|
||||
|
||||
test("never assigns one task to two slots", () => {
|
||||
const selected = selectCarrierTasks([{ id: "only", name: "电信移动联通" }], { telecom: "", mobile: "", unicom: "" });
|
||||
expect([...selected.values()].map((task) => task.id)).toEqual(["only"]);
|
||||
});
|
||||
|
||||
test("falls back to backend order for unmatched slots", () => {
|
||||
const selected = selectCarrierTasks(
|
||||
[{ id: "x", name: "Tokyo" }, { id: "y", name: "Osaka" }],
|
||||
{ telecom: "", mobile: "", unicom: "" },
|
||||
);
|
||||
expect(selected.get("telecom")?.id).toBe("x");
|
||||
expect(selected.get("mobile")?.id).toBe("y");
|
||||
expect(selected.has("unicom")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bucketSamples", () => {
|
||||
const now = 3_600;
|
||||
|
||||
test("returns one bucket per bar", () => {
|
||||
const points = Array.from({ length: 60 }, (_, index) => sample("a", 60 * index + 60));
|
||||
expect(bucketSamples(points, 3_600, now)).toHaveLength(BAR_COUNT);
|
||||
});
|
||||
|
||||
test("leaves older buckets empty when a node only just came back", () => {
|
||||
// Five minutes of samples at the end of a one-hour window.
|
||||
const points = Array.from({ length: 5 }, (_, index) => sample("a", now - 300 + 60 * index));
|
||||
const buckets = bucketSamples(points, 3_600, now);
|
||||
const filled = buckets.filter((bucket) => bucket.latency !== null);
|
||||
expect(filled.length).toBeGreaterThan(0);
|
||||
expect(filled.length).toBeLessThanOrEqual(4);
|
||||
expect(buckets[0].latency).toBeNull();
|
||||
});
|
||||
|
||||
test("bridges buckets for a task slower than one bucket", () => {
|
||||
// One sample every 10 minutes cannot fill 20 three-minute buckets, so each
|
||||
// reading carries forward rather than alternating filled/empty bars.
|
||||
const points = Array.from({ length: 6 }, (_, index) => sample("a", now - 3_000 + 600 * index));
|
||||
const buckets = bucketSamples(points, 3_600, now);
|
||||
const filled = buckets.filter((bucket) => bucket.latency !== null);
|
||||
expect(filled.length).toBeGreaterThan(6);
|
||||
// Nothing is invented before the first sample.
|
||||
expect(buckets[0].latency).toBeNull();
|
||||
expect(buckets[buckets.length - 1].latency).not.toBeNull();
|
||||
});
|
||||
|
||||
test("does not bridge a real outage at normal cadence", () => {
|
||||
const before = Array.from({ length: 5 }, (_, index) => sample("a", now - 3_600 + 60 * index));
|
||||
const after = Array.from({ length: 5 }, (_, index) => sample("a", now - 240 + 60 * index));
|
||||
const buckets = bucketSamples([...before, ...after], 3_600, now);
|
||||
expect(buckets.filter((bucket) => bucket.latency === null).length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
test("keeps the grid pinned to the requested window", () => {
|
||||
const points = Array.from({ length: 6 }, (_, index) => sample("a", now - 3_000 + 600 * index));
|
||||
const buckets = bucketSamples(points, 3_600, now);
|
||||
expect(buckets[0].timestamp).toBe(now - 3_600);
|
||||
expect(buckets[buckets.length - 1].timestamp).toBe(now - 3_600 / BAR_COUNT);
|
||||
});
|
||||
|
||||
test("averages within a bucket and ignores negative readings", () => {
|
||||
const points = [sample("a", now - 30, 10), sample("a", now - 20, 30), sample("a", now - 10, -1)];
|
||||
const buckets = bucketSamples(points, 3_600, now);
|
||||
const last = buckets[buckets.length - 1];
|
||||
expect(last.latency).toBe(20);
|
||||
});
|
||||
|
||||
test("returns nothing when there are no usable samples", () => {
|
||||
expect(bucketSamples([], 3_600, now)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("insertTimelineGaps", () => {
|
||||
test("breaks the line across an outage but not across normal cadence", () => {
|
||||
const points = [
|
||||
{ timestamp: 0, value: 1 },
|
||||
{ timestamp: 60, value: 2 },
|
||||
{ timestamp: 120, value: 3 },
|
||||
{ timestamp: 3_600, value: 4 },
|
||||
];
|
||||
const result = insertTimelineGaps(
|
||||
points,
|
||||
(timestamp) => ({ timestamp, value: null as number | null }),
|
||||
{ minGap: 10, maxGap: 600 },
|
||||
);
|
||||
expect(result).toHaveLength(5);
|
||||
expect(result[3].value).toBeNull();
|
||||
expect(result[4].timestamp).toBe(3_600);
|
||||
});
|
||||
|
||||
test("leaves short series untouched", () => {
|
||||
expect(insertTimelineGaps([{ timestamp: 5 }], () => ({ timestamp: 0 }), { minGap: 1, maxGap: 2 })).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("uses the lower quartile so outages do not inflate the cadence", () => {
|
||||
const points = [{ timestamp: 0 }, { timestamp: 60 }, { timestamp: 120 }, { timestamp: 900 }];
|
||||
expect(typicalInterval(points)).toBe(60);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
import { clockLabel } from "./chart";
|
||||
import { ui, type UiLocale } from "./locale";
|
||||
import type { LatencySample } from "./types";
|
||||
|
||||
export type LatencyTone = "good" | "fair" | "warning" | "poor" | "danger" | "empty";
|
||||
|
||||
export interface LatencyBar {
|
||||
key: string;
|
||||
tone: LatencyTone;
|
||||
tooltip: string;
|
||||
}
|
||||
|
||||
export interface CarrierSlotKey {
|
||||
telecom: string;
|
||||
mobile: string;
|
||||
unicom: string;
|
||||
}
|
||||
|
||||
export const BAR_COUNT = 20;
|
||||
|
||||
/**
|
||||
* Carrier lines, in display order. Aliases cover the Chinese names plus the
|
||||
* common latin spellings and operator codes, so an admin who names a task
|
||||
* "CT-Guangzhou" or "电信广州" lands in the same slot without extra config.
|
||||
*/
|
||||
export const CARRIER_SLOTS = [
|
||||
{ key: "telecom" as const, label: "电信", labelEn: "Telecom", color: "#fb7185", aliases: ["电信", "chinatelecom", "telecom", "ctcc", "ct"] },
|
||||
{ key: "mobile" as const, label: "移动", labelEn: "Mobile", color: "#34d399", aliases: ["移动", "chinamobile", "mobile", "cmcc", "cm"] },
|
||||
{ key: "unicom" as const, label: "联通", labelEn: "Unicom", color: "#60a5fa", aliases: ["联通", "chinaunicom", "unicom", "cucc", "cu"] },
|
||||
] as const;
|
||||
|
||||
export type CarrierSlot = typeof CARRIER_SLOTS[number];
|
||||
|
||||
export function latencyTone(value: number): LatencyTone {
|
||||
if (value <= 60) return "good";
|
||||
if (value <= 100) return "fair";
|
||||
if (value <= 160) return "warning";
|
||||
if (value <= 200) return "poor";
|
||||
return "danger";
|
||||
}
|
||||
|
||||
export function lossTone(value: number): LatencyTone {
|
||||
if (value <= 1) return "good";
|
||||
if (value <= 3) return "fair";
|
||||
if (value <= 6) return "warning";
|
||||
if (value <= 9) return "poor";
|
||||
return "danger";
|
||||
}
|
||||
|
||||
export function emptyBars(label: string): LatencyBar[] {
|
||||
return Array.from({ length: BAR_COUNT }, (_, index) => ({ key: `empty-${index}`, tone: "empty" as const, tooltip: label }));
|
||||
}
|
||||
|
||||
function normalizeTaskName(name: string): string {
|
||||
return name.normalize("NFKC").toLowerCase().replace(/[\s_·、,,/|-]+/g, "");
|
||||
}
|
||||
|
||||
export interface LatencyTaskRef {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the three carrier slots. Explicit names win; when every name is
|
||||
* blank we match on the task name, then fill any slot still empty using the
|
||||
* backend order so a two-task setup still renders two lines.
|
||||
*/
|
||||
export function selectCarrierTasks(
|
||||
tasks: readonly LatencyTaskRef[],
|
||||
configured: CarrierSlotKey,
|
||||
): Map<CarrierSlot["key"], LatencyTaskRef> {
|
||||
const selected = new Map<CarrierSlot["key"], LatencyTaskRef>();
|
||||
const used = new Set<string>();
|
||||
const names = CARRIER_SLOTS.map((slot) => configured[slot.key].trim());
|
||||
const explicit = names.some((name) => name !== "");
|
||||
|
||||
if (explicit) {
|
||||
const byName = new Map<string, LatencyTaskRef>();
|
||||
for (const task of tasks) {
|
||||
const name = task.name.trim();
|
||||
if (name && !byName.has(name)) byName.set(name, task);
|
||||
}
|
||||
CARRIER_SLOTS.forEach((slot, index) => {
|
||||
const task = names[index] ? byName.get(names[index]) : undefined;
|
||||
if (!task || used.has(task.id)) return;
|
||||
selected.set(slot.key, task);
|
||||
used.add(task.id);
|
||||
});
|
||||
return selected;
|
||||
}
|
||||
|
||||
const available = tasks.map((task) => ({ task, normalized: normalizeTaskName(task.name) }));
|
||||
for (const slot of CARRIER_SLOTS) {
|
||||
const match = available.find((candidate) => !used.has(candidate.task.id)
|
||||
&& slot.aliases.some((alias) => candidate.normalized.includes(alias)));
|
||||
if (!match) continue;
|
||||
selected.set(slot.key, match.task);
|
||||
used.add(match.task.id);
|
||||
}
|
||||
for (const slot of CARRIER_SLOTS) {
|
||||
if (selected.has(slot.key)) continue;
|
||||
const match = available.find((candidate) => !used.has(candidate.task.id));
|
||||
if (!match) break;
|
||||
selected.set(slot.key, match.task);
|
||||
used.add(match.task.id);
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
export interface LatencyBucket {
|
||||
timestamp: number;
|
||||
latency: number | null;
|
||||
loss: number | null;
|
||||
}
|
||||
|
||||
function validValue(value: number): number | null {
|
||||
return Number.isFinite(value) && value >= 0 ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bucket samples across the requested window rather than across the extent of
|
||||
* whatever data came back. A node that only reported for the last five minutes
|
||||
* of an hour shows fifteen empty buckets instead of twenty healthy ones.
|
||||
*
|
||||
* The grid always spans exactly `windowSeconds`, so a bar's position means the
|
||||
* same thing on every card. When a task samples more slowly than one bucket,
|
||||
* its last reading carries forward for one cadence — otherwise a 10-minute
|
||||
* task would alternate between filled and empty bars and read as flapping.
|
||||
*/
|
||||
export function bucketSamples(points: readonly LatencySample[], windowSeconds: number, now = Date.now() / 1000): LatencyBucket[] {
|
||||
const samples = points
|
||||
.filter((point) => Number.isFinite(point.timestamp) && point.timestamp > 0)
|
||||
.map((point) => ({ timestamp: point.timestamp, latency: validValue(point.latency_ms), loss: validValue(point.packet_loss) }))
|
||||
.filter((point) => point.latency !== null || point.loss !== null)
|
||||
.sort((left, right) => left.timestamp - right.timestamp);
|
||||
if (!samples.length) return [];
|
||||
|
||||
const deltas = samples
|
||||
.slice(1)
|
||||
.map((sample, index) => sample.timestamp - samples[index].timestamp)
|
||||
.filter((delta) => delta > 0 && Number.isFinite(delta))
|
||||
.sort((left, right) => left - right);
|
||||
const cadence = deltas.length ? deltas[Math.floor((deltas.length - 1) / 4)] : 0;
|
||||
const span = Math.max(1, windowSeconds);
|
||||
const bucketSize = span / BAR_COUNT;
|
||||
const end = Math.max(now, samples[samples.length - 1].timestamp);
|
||||
const start = end - span;
|
||||
// Only bridge buckets when the task genuinely samples slower than the grid.
|
||||
const carryFor = cadence > bucketSize ? cadence * 1.5 : 0;
|
||||
|
||||
const buckets: LatencyBucket[] = [];
|
||||
let cursor = 0;
|
||||
let carried: LatencyBucket | null = null;
|
||||
let carriedAt = -Infinity;
|
||||
while (cursor < samples.length && samples[cursor].timestamp < start) {
|
||||
carried = { timestamp: 0, latency: samples[cursor].latency, loss: samples[cursor].loss };
|
||||
carriedAt = samples[cursor].timestamp;
|
||||
cursor += 1;
|
||||
}
|
||||
|
||||
for (let index = 0; index < BAR_COUNT; index += 1) {
|
||||
const bucketStart = start + bucketSize * index;
|
||||
// The final bucket includes its upper bound so a sample landing exactly on
|
||||
// `end` is not dropped.
|
||||
const bucketEnd = bucketStart + bucketSize;
|
||||
const inclusive = index === BAR_COUNT - 1;
|
||||
let latencySum = 0;
|
||||
let latencyCount = 0;
|
||||
let lossSum = 0;
|
||||
let lossCount = 0;
|
||||
while (cursor < samples.length
|
||||
&& (inclusive ? samples[cursor].timestamp <= bucketEnd : samples[cursor].timestamp < bucketEnd)) {
|
||||
const sample = samples[cursor];
|
||||
if (sample.latency !== null) { latencySum += sample.latency; latencyCount += 1; }
|
||||
if (sample.loss !== null) { lossSum += sample.loss; lossCount += 1; }
|
||||
carriedAt = sample.timestamp;
|
||||
cursor += 1;
|
||||
}
|
||||
if (latencyCount || lossCount) {
|
||||
const bucket = {
|
||||
timestamp: Math.round(bucketStart),
|
||||
latency: latencyCount ? latencySum / latencyCount : null,
|
||||
loss: lossCount ? lossSum / lossCount : null,
|
||||
};
|
||||
carried = bucket;
|
||||
buckets.push(bucket);
|
||||
continue;
|
||||
}
|
||||
const bridged = carryFor > 0 && carried !== null && bucketStart - carriedAt <= carryFor;
|
||||
buckets.push({
|
||||
timestamp: Math.round(bucketStart),
|
||||
latency: bridged ? carried!.latency : null,
|
||||
loss: bridged ? carried!.loss : null,
|
||||
});
|
||||
}
|
||||
return buckets;
|
||||
}
|
||||
|
||||
export function latencyBars(buckets: readonly LatencyBucket[], prefix: string, locale: UiLocale): LatencyBar[] {
|
||||
const missing = ui(locale, "无采样数据", "No samples");
|
||||
return buckets.map((bucket, index) => ({
|
||||
key: `${prefix}-latency-${bucket.timestamp}-${index}`,
|
||||
tone: bucket.latency === null ? "empty" as const : latencyTone(bucket.latency),
|
||||
tooltip: `${clockLabel(bucket.timestamp * 1000, locale)}\n${bucket.latency === null ? missing : `${Math.round(bucket.latency)} ms`}`,
|
||||
}));
|
||||
}
|
||||
|
||||
export function lossBars(buckets: readonly LatencyBucket[], prefix: string, locale: UiLocale): LatencyBar[] {
|
||||
const missing = ui(locale, "无采样数据", "No samples");
|
||||
return buckets.map((bucket, index) => ({
|
||||
key: `${prefix}-loss-${bucket.timestamp}-${index}`,
|
||||
tone: bucket.loss === null ? "empty" as const : lossTone(bucket.loss),
|
||||
tooltip: `${clockLabel(bucket.timestamp * 1000, locale)}\n${bucket.loss === null ? missing : `${bucket.loss.toFixed(1)}%`}`,
|
||||
}));
|
||||
}
|
||||
|
||||
export function averageOf(values: readonly number[]): number | null {
|
||||
const valid = values.filter((value) => Number.isFinite(value) && value >= 0);
|
||||
return valid.length ? valid.reduce((sum, value) => sum + value, 0) / valid.length : null;
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
advancePlayback,
|
||||
applyBatch,
|
||||
mergeLiveLatency,
|
||||
mergeLiveResults,
|
||||
mergeServerLive,
|
||||
pruneStalePlayback,
|
||||
type LiveMetricsMap,
|
||||
type PlaybackBuffer,
|
||||
} from "./live";
|
||||
import type { Server } from "./types";
|
||||
|
||||
function server(overrides: Partial<Server> = {}): Server {
|
||||
return {
|
||||
id: "s1",
|
||||
name: "node",
|
||||
region: "JP",
|
||||
group_name: "",
|
||||
tags: "",
|
||||
expires_at: null,
|
||||
traffic_limit: 0,
|
||||
traffic_limit_type: "sum",
|
||||
price: 0,
|
||||
billing_cycle: 30,
|
||||
currency: "CNY",
|
||||
auto_renewal: false,
|
||||
reset_day: 1,
|
||||
timestamp: 1_000,
|
||||
cpu: 10,
|
||||
load1: 0, load5: 0, load15: 0,
|
||||
mem_used: 0, mem_total: 0, swap_used: 0, swap_total: 0,
|
||||
disk_used: 0, disk_total: 0,
|
||||
net_in: 0, net_out: 0, net_rx_total: 0, net_tx_total: 0,
|
||||
uptime: 100,
|
||||
processes: 0, tcp_connections: 0, udp_connections: 0,
|
||||
cpu_cores: 1, cpu_model: null, os: null, kernel: null, arch: null,
|
||||
virtualization: null, gpu_usage: null, gpu_model: null, agent_version: null,
|
||||
disk_read_bps: null, disk_write_bps: null, disk_read_iops: null,
|
||||
disk_write_iops: null, disk_await_ms: null, disk_utilization: null,
|
||||
disks: [],
|
||||
gpus: [],
|
||||
latency: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("applyBatch", () => {
|
||||
test("shows the first sample immediately and buffers the rest", () => {
|
||||
const playback: PlaybackBuffer = new Map();
|
||||
const next = applyBatch({}, [{
|
||||
serverId: "s1",
|
||||
samples: [
|
||||
{ ts: 1_010, data: { cpu: 11 } },
|
||||
{ ts: 1_011, data: { cpu: 12 } },
|
||||
{ ts: 1_012, data: { cpu: 13 } },
|
||||
],
|
||||
}], { cached: false, playback, servers: [server()] });
|
||||
|
||||
expect(next.s1.timestamp).toBe(1_010);
|
||||
expect(next.s1.metrics.cpu).toBe(11);
|
||||
expect(playback.get("s1")?.map((sample) => sample.ts)).toEqual([1_011, 1_012]);
|
||||
});
|
||||
|
||||
test("ignores samples already covered by the persisted row", () => {
|
||||
const playback: PlaybackBuffer = new Map();
|
||||
const next = applyBatch({}, [{
|
||||
serverId: "s1",
|
||||
samples: [{ ts: 900, data: { cpu: 5 } }],
|
||||
}], { cached: false, playback, servers: [server({ timestamp: 1_000 })] });
|
||||
|
||||
expect(next.s1).toBeUndefined();
|
||||
expect(playback.size).toBe(0);
|
||||
});
|
||||
|
||||
test("skips ahead on a cached replay instead of re-playing the window", () => {
|
||||
const playback: PlaybackBuffer = new Map();
|
||||
const next = applyBatch({}, [{
|
||||
serverId: "s1",
|
||||
samples: [{ ts: 1_010, data: { cpu: 11 } }, { ts: 1_070, data: { cpu: 12 } }],
|
||||
reportAgeMs: 5_000,
|
||||
}], { cached: true, playback, servers: [server()] });
|
||||
|
||||
expect(next.s1.timestamp).toBe(1_070);
|
||||
expect(playback.size).toBe(0);
|
||||
});
|
||||
|
||||
test("does not re-buffer a sample it is already holding", () => {
|
||||
const playback: PlaybackBuffer = new Map([["s1", [{ ts: 1_011, data: { cpu: 12 } }]]]);
|
||||
const current: LiveMetricsMap = {
|
||||
s1: { timestamp: 1_010, displayTimestamp: 1_010, metrics: { cpu: 11 } },
|
||||
};
|
||||
applyBatch(current, [{
|
||||
serverId: "s1",
|
||||
samples: [{ ts: 1_011, data: { cpu: 12 } }, { ts: 1_012, data: { cpu: 13 } }],
|
||||
}], { cached: false, playback, servers: [server()] });
|
||||
|
||||
expect(playback.get("s1")?.map((sample) => sample.ts)).toEqual([1_011, 1_012]);
|
||||
});
|
||||
|
||||
test("keeps latency results out of the metric patch", () => {
|
||||
const playback: PlaybackBuffer = new Map();
|
||||
const next = applyBatch({}, [{
|
||||
serverId: "s1",
|
||||
samples: [{
|
||||
ts: 1_010,
|
||||
data: { cpu: 11, latency_results: [{ task_id: "t1", timestamp: 1_010, latency_ms: 20, packet_loss: 0 }] },
|
||||
}],
|
||||
}], { cached: false, playback, servers: [server()] });
|
||||
|
||||
expect("latency_results" in next.s1.metrics).toBe(false);
|
||||
expect(next.s1.latencyResults).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("advancePlayback", () => {
|
||||
test("releases a buffered sample once the cursor reaches it", () => {
|
||||
const playback: PlaybackBuffer = new Map([["s1", [{ ts: 1_011, data: { cpu: 12 } }]]]);
|
||||
const next = advancePlayback(
|
||||
{ s1: { timestamp: 1_010, displayTimestamp: 1_010, metrics: { cpu: 11 } } },
|
||||
playback,
|
||||
1,
|
||||
);
|
||||
expect(next.s1.timestamp).toBe(1_011);
|
||||
expect(next.s1.metrics.cpu).toBe(12);
|
||||
expect(playback.size).toBe(0);
|
||||
});
|
||||
|
||||
test("advances only the cursor when nothing is due yet", () => {
|
||||
const playback: PlaybackBuffer = new Map([["s1", [{ ts: 1_020, data: { cpu: 12 } }]]]);
|
||||
const next = advancePlayback(
|
||||
{ s1: { timestamp: 1_010, displayTimestamp: 1_010, metrics: { cpu: 11 } } },
|
||||
playback,
|
||||
1,
|
||||
);
|
||||
expect(next.s1.timestamp).toBe(1_010);
|
||||
expect(next.s1.displayTimestamp).toBe(1_011);
|
||||
expect(playback.get("s1")).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("is a no-op with an empty buffer", () => {
|
||||
const current: LiveMetricsMap = { s1: { timestamp: 1, displayTimestamp: 1, metrics: {} } };
|
||||
expect(advancePlayback(current, new Map(), 1)).toBe(current);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeServerLive", () => {
|
||||
test("applies a newer live sample and ticks uptime", () => {
|
||||
const merged = mergeServerLive(
|
||||
server({ timestamp: 1_000, uptime: 100 }),
|
||||
{ timestamp: 1_010, displayTimestamp: 1_010, metrics: { cpu: 42 } },
|
||||
1_015_000,
|
||||
180,
|
||||
);
|
||||
expect(merged.cpu).toBe(42);
|
||||
expect(merged.uptime).toBe(105);
|
||||
});
|
||||
|
||||
test("keeps persisted metrics when the live sample is older", () => {
|
||||
const merged = mergeServerLive(
|
||||
server({ timestamp: 2_000, cpu: 10, uptime: null }),
|
||||
{ timestamp: 1_000, displayTimestamp: 1_000, metrics: { cpu: 99 } },
|
||||
2_000_000,
|
||||
180,
|
||||
);
|
||||
expect(merged.cpu).toBe(10);
|
||||
});
|
||||
|
||||
test("does not tick uptime past the offline threshold", () => {
|
||||
const merged = mergeServerLive(server({ timestamp: 1_000, uptime: 100 }), undefined, 9_000_000, 180);
|
||||
expect(merged.uptime).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeLiveResults / mergeLiveLatency", () => {
|
||||
test("deduplicates by task and timestamp, keeping chronological order", () => {
|
||||
const merged = mergeLiveResults(
|
||||
[{ task_id: "t1", timestamp: 10, latency_ms: 1, packet_loss: 0 }],
|
||||
[
|
||||
{ task_id: "t1", timestamp: 10, latency_ms: 2, packet_loss: 0 },
|
||||
{ task_id: "t1", timestamp: 5, latency_ms: 3, packet_loss: 0 },
|
||||
{ task_id: "", timestamp: 20, latency_ms: 4, packet_loss: 0 },
|
||||
],
|
||||
);
|
||||
expect(merged.map((result) => result.timestamp)).toEqual([5, 10]);
|
||||
expect(merged[1].latency_ms).toBe(2);
|
||||
});
|
||||
|
||||
test("overlays the newest reading onto the persisted definition", () => {
|
||||
const base = server({
|
||||
latency: [{
|
||||
task_id: "t1", server_id: "s1", name: "Tokyo", task_type: "icmp",
|
||||
target: "example.com", port: null, timestamp: 100, latency_ms: 50, packet_loss: 0,
|
||||
}],
|
||||
});
|
||||
const merged = mergeLiveLatency(base, [{ task_id: "t1", timestamp: 200, latency_ms: 12, packet_loss: 1 }]);
|
||||
expect(merged[0].latency_ms).toBe(12);
|
||||
expect(merged[0].name).toBe("Tokyo");
|
||||
});
|
||||
|
||||
test("returns the same array when nothing is newer", () => {
|
||||
const base = server({
|
||||
latency: [{
|
||||
task_id: "t1", server_id: "s1", name: "Tokyo", task_type: "icmp",
|
||||
target: "example.com", port: null, timestamp: 300, latency_ms: 50, packet_loss: 0,
|
||||
}],
|
||||
});
|
||||
expect(mergeLiveLatency(base, [{ task_id: "t1", timestamp: 200, latency_ms: 12, packet_loss: 1 }])).toBe(base.latency);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pruneStalePlayback", () => {
|
||||
test("drops buffered samples the persisted row already covers", () => {
|
||||
const playback: PlaybackBuffer = new Map([["s1", [
|
||||
{ ts: 900, data: {} },
|
||||
{ ts: 1_100, data: {} },
|
||||
]]]);
|
||||
pruneStalePlayback(playback, [server({ timestamp: 1_000 })]);
|
||||
expect(playback.get("s1")?.map((sample) => sample.ts)).toEqual([1_100]);
|
||||
});
|
||||
|
||||
test("removes the entry entirely once nothing is left", () => {
|
||||
const playback: PlaybackBuffer = new Map([["s1", [{ ts: 900, data: {} }]]]);
|
||||
pruneStalePlayback(playback, [server({ timestamp: 1_000 })]);
|
||||
expect(playback.has("s1")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
import type { LatencySample, LiveLatencyResult, Server } from "./types";
|
||||
|
||||
/** A single Agent report as replayed by the Worker. */
|
||||
export interface LiveSample {
|
||||
ts: number;
|
||||
data: Partial<Server> & { latency_results?: LiveLatencyResult[] };
|
||||
}
|
||||
|
||||
/**
|
||||
* The sample currently on screen for one server. `timestamp` is the real
|
||||
* sample time used for freshness checks; `displayTimestamp` is the playback
|
||||
* cursor, which advances every second so buffered samples are shown at the
|
||||
* cadence they were produced instead of all at once.
|
||||
*/
|
||||
export interface LiveMetrics {
|
||||
timestamp: number;
|
||||
displayTimestamp: number;
|
||||
metrics: Partial<Server>;
|
||||
latencyResults?: LiveLatencyResult[];
|
||||
}
|
||||
|
||||
export type LiveMetricsMap = Record<string, LiveMetrics>;
|
||||
|
||||
/** Pending samples per server, ordered by sample time. */
|
||||
export type PlaybackBuffer = Map<string, LiveSample[]>;
|
||||
|
||||
export interface BatchUpdate {
|
||||
serverId?: string;
|
||||
samples?: LiveSample[];
|
||||
reportAgeMs?: number;
|
||||
}
|
||||
|
||||
export const MAX_PLAYBACK_SAMPLES_PER_SERVER = 600;
|
||||
const MAX_LIVE_LATENCY_RESULTS = 4096;
|
||||
|
||||
export function mergeLiveResults(
|
||||
previous: LiveLatencyResult[] | undefined,
|
||||
incoming: LiveLatencyResult[],
|
||||
): LiveLatencyResult[] {
|
||||
const results = new Map<string, LiveLatencyResult>();
|
||||
for (const result of [...(previous ?? []), ...incoming]) {
|
||||
if (!result.task_id || !Number.isFinite(result.timestamp) || result.timestamp <= 0) continue;
|
||||
results.set(`${result.task_id}:${result.timestamp}`, result);
|
||||
}
|
||||
return Array.from(results.values())
|
||||
.sort((left, right) => left.timestamp - right.timestamp)
|
||||
.slice(-MAX_LIVE_LATENCY_RESULTS);
|
||||
}
|
||||
|
||||
/** Overlay the newest live result onto each persisted latency definition. */
|
||||
export function mergeLiveLatency(server: Server, results: LiveLatencyResult[]): LatencySample[] {
|
||||
const latest = new Map<string, LiveLatencyResult>();
|
||||
for (const result of results) {
|
||||
const current = latest.get(result.task_id);
|
||||
if (!current || result.timestamp >= current.timestamp) latest.set(result.task_id, result);
|
||||
}
|
||||
let changed = false;
|
||||
const merged = server.latency.map((definition) => {
|
||||
const result = latest.get(definition.task_id);
|
||||
if (!result || result.timestamp <= definition.timestamp) return definition;
|
||||
changed = true;
|
||||
return {
|
||||
...definition,
|
||||
server_id: server.id,
|
||||
timestamp: result.timestamp,
|
||||
latency_ms: result.latency_ms,
|
||||
packet_loss: result.packet_loss,
|
||||
};
|
||||
});
|
||||
return changed ? merged : server.latency;
|
||||
}
|
||||
|
||||
function splitSample(sample: LiveSample) {
|
||||
const metrics = { ...sample.data };
|
||||
const latencyResults = Array.isArray(metrics.latency_results) ? metrics.latency_results : [];
|
||||
delete metrics.latency_results;
|
||||
return { metrics, latencyResults };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold a `batchUpdate` frame into the live map, buffering anything newer than
|
||||
* the playback cursor. `playback` is mutated in place — it is a ref-held
|
||||
* buffer rather than render state, because it changes on every frame.
|
||||
*/
|
||||
export function applyBatch(
|
||||
current: LiveMetricsMap,
|
||||
updates: readonly BatchUpdate[],
|
||||
context: { cached: boolean; playback: PlaybackBuffer; servers: readonly Server[] },
|
||||
): LiveMetricsMap {
|
||||
const next = { ...current };
|
||||
for (const update of updates) {
|
||||
if (!update.serverId || !Array.isArray(update.samples) || !update.samples.length) continue;
|
||||
const samples = update.samples
|
||||
.filter((sample) => Number.isFinite(sample.ts) && sample.data && typeof sample.data === "object")
|
||||
.sort((left, right) => left.ts - right.ts);
|
||||
const previous = next[update.serverId];
|
||||
const pending = context.playback.get(update.serverId) ?? [];
|
||||
const persistedTimestamp = context.servers.find((server) => server.id === update.serverId)?.timestamp ?? 0;
|
||||
const appliedTimestamp = Math.max(previous?.timestamp ?? 0, persistedTimestamp);
|
||||
const seen = new Set<number>(pending.map((sample) => sample.ts));
|
||||
const incoming = samples.filter((sample) => sample.ts > appliedTimestamp && !seen.has(sample.ts));
|
||||
if (!incoming.length) continue;
|
||||
|
||||
// A cached replay arrives late, so start the cursor past the report age
|
||||
// instead of re-playing the whole window in real time.
|
||||
const reportAgeSeconds = context.cached && Number.isFinite(update.reportAgeMs)
|
||||
? Math.max(0, update.reportAgeMs! / 1000)
|
||||
: 0;
|
||||
const cursor = context.cached
|
||||
? Math.max(previous?.displayTimestamp ?? 0, incoming[incoming.length - 1].ts + reportAgeSeconds)
|
||||
: previous?.displayTimestamp ?? incoming[0].ts;
|
||||
const all = [...pending, ...incoming]
|
||||
.sort((left, right) => left.ts - right.ts)
|
||||
.slice(-MAX_PLAYBACK_SAMPLES_PER_SERVER);
|
||||
let selected: LiveSample | undefined;
|
||||
while (all.length && all[0].ts <= cursor) selected = all.shift();
|
||||
if (selected) {
|
||||
const { metrics, latencyResults } = splitSample(selected);
|
||||
next[update.serverId] = {
|
||||
timestamp: selected.ts,
|
||||
displayTimestamp: cursor,
|
||||
metrics,
|
||||
latencyResults: latencyResults.length
|
||||
? mergeLiveResults(previous?.latencyResults, latencyResults)
|
||||
: previous?.latencyResults,
|
||||
};
|
||||
} else if (!previous) {
|
||||
continue;
|
||||
}
|
||||
if (all.length) context.playback.set(update.serverId, all);
|
||||
else context.playback.delete(update.serverId);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Move every playback cursor forward and release any sample it has reached. */
|
||||
export function advancePlayback(
|
||||
current: LiveMetricsMap,
|
||||
playback: PlaybackBuffer,
|
||||
elapsedSeconds: number,
|
||||
): LiveMetricsMap {
|
||||
if (!playback.size) return current;
|
||||
const next = { ...current };
|
||||
for (const [serverId, samples] of playback) {
|
||||
const state = next[serverId];
|
||||
if (!state || !samples.length) continue;
|
||||
const displayTimestamp = state.displayTimestamp + elapsedSeconds;
|
||||
let selected: LiveSample | undefined;
|
||||
while (samples.length && samples[0].ts <= displayTimestamp) selected = samples.shift();
|
||||
if (selected) {
|
||||
const { metrics, latencyResults } = splitSample(selected);
|
||||
next[serverId] = {
|
||||
timestamp: selected.ts,
|
||||
displayTimestamp,
|
||||
metrics,
|
||||
latencyResults: latencyResults.length
|
||||
? mergeLiveResults(state.latencyResults, latencyResults)
|
||||
: state.latencyResults,
|
||||
};
|
||||
} else {
|
||||
next[serverId] = { ...state, displayTimestamp };
|
||||
}
|
||||
if (!samples.length) playback.delete(serverId);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold the live sample onto its persisted server row, then keep the uptime
|
||||
* clock ticking between Agent reports while the sample is still fresh.
|
||||
*/
|
||||
export function mergeServerLive(
|
||||
server: Server,
|
||||
live: LiveMetrics | undefined,
|
||||
clockNow: number,
|
||||
offlineThresholdSeconds: number,
|
||||
): Server {
|
||||
const merged = !live ? server : (() => {
|
||||
const latency = live.latencyResults?.length ? mergeLiveLatency(server, live.latencyResults) : server.latency;
|
||||
if (live.timestamp <= (server.timestamp ?? 0)) {
|
||||
return latency === server.latency ? server : { ...server, latency };
|
||||
}
|
||||
return { ...server, ...live.metrics, latency, timestamp: live.timestamp };
|
||||
})();
|
||||
if (!merged.timestamp || !merged.uptime || !Number.isFinite(merged.uptime)) return merged;
|
||||
const elapsed = Math.max(0, Math.floor(clockNow / 1000 - merged.timestamp));
|
||||
return elapsed > 0 && elapsed <= offlineThresholdSeconds
|
||||
? { ...merged, uptime: merged.uptime + elapsed }
|
||||
: merged;
|
||||
}
|
||||
|
||||
/** Drop buffered samples the persisted rows have already caught up to. */
|
||||
export function pruneStalePlayback(playback: PlaybackBuffer, servers: readonly Server[]) {
|
||||
for (const server of servers) {
|
||||
const samples = playback.get(server.id);
|
||||
if (!samples || !server.timestamp) continue;
|
||||
const fresh = samples.filter((sample) => sample.ts > server.timestamp!);
|
||||
if (fresh.length) playback.set(server.id, fresh);
|
||||
else playback.delete(server.id);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import { AppProvider } from "./AppProvider";
|
||||
import "./styles/dashboard.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(<StrictMode><App /></StrictMode>);
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode><AppProvider><App /></AppProvider></StrictMode>,
|
||||
);
|
||||
@@ -226,8 +226,22 @@
|
||||
.alert-server-picker label { display: flex; min-width: 0; align-items: center; gap: 9px; border-bottom: 1px solid var(--admin-border); padding: 10px 11px; }
|
||||
.alert-server-picker span:last-child { overflow: hidden; color: var(--admin-text); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.telegram-settings { display: grid; gap: 15px; border-top: 1px solid var(--admin-border); padding-top: 18px; }
|
||||
.telegram-settings-header { display: flex; min-height: 38px; align-items: center; justify-content: space-between; gap: 14px; }
|
||||
.telegram-settings-title { display: flex; min-width: 0; align-items: center; gap: 10px; }
|
||||
.telegram-settings-title > div { display: flex; min-width: 0; align-items: baseline; flex-wrap: wrap; gap: 8px; }
|
||||
.telegram-settings-title h3 { margin: 0; color: var(--admin-text); font-size: 16px; }
|
||||
.telegram-mark { display: inline-flex; width: 34px; height: 34px; flex: 0 0 auto; align-items: center; justify-content: center; border-radius: 6px; color: var(--admin-accent); background: var(--admin-active-bg); }
|
||||
.telegram-state { display: inline-flex; align-items: center; gap: 4px; color: var(--admin-muted); font-size: 11px; }
|
||||
.telegram-state.configured { color: var(--success); }
|
||||
.telegram-fields { display: grid; gap: 15px; grid-template-columns: minmax(220px, 1.4fr) minmax(180px, 1fr) minmax(130px, .65fr); }
|
||||
.telegram-template textarea { min-height: 116px; resize: vertical; }
|
||||
.telegram-actions { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: 8px; }
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.form-grid.three { grid-template-columns: 1fr 1fr; }
|
||||
.telegram-fields { grid-template-columns: 1fr 1fr; }
|
||||
.telegram-token-field { grid-column: 1 / -1; }
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
@@ -250,6 +264,8 @@
|
||||
.usage-do-breakdown { grid-template-columns: 1fr; }
|
||||
.usage-table th, .usage-table td { padding-inline: 5px; font-size: 11px; }
|
||||
.editor-modal, .install-modal { padding: 15px 13px; }
|
||||
.telegram-fields { grid-template-columns: 1fr; }
|
||||
.telegram-token-field { grid-column: auto; }
|
||||
.editor-toggles { grid-template-columns: 1fr 1fr; }
|
||||
.latency-task-row { padding-block: 8px; grid-template-columns: 70px minmax(0, 1fr) auto; }
|
||||
.latency-task-row .latency-task-meta:nth-of-type(2) { display: none; }
|
||||
|
||||
@@ -25,6 +25,12 @@
|
||||
--chart-grid: rgb(83 104 116 / 0.15);
|
||||
--shadow: 0 10px 32px rgb(31 48 58 / 0.09), inset 0 1px 0 rgb(255 255 255 / 0.58);
|
||||
--node-offline-bg: rgb(255 255 255 / 0.2);
|
||||
/* Card geometry lives here so the grid, skeletons and the latency panels
|
||||
cannot drift apart and make rows jump as data arrives. */
|
||||
--node-card-min-height: 336px;
|
||||
--node-card-carrier-min-height: 416px;
|
||||
--quality-panel-height: 44px;
|
||||
--carrier-panel-height: 124px;
|
||||
color: var(--foreground);
|
||||
background: var(--background);
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", "PingFang SC", "Noto Sans SC", sans-serif;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
@import "./base.css";
|
||||
|
||||
.app-shell { position: relative; z-index: 0; display: flex; min-height: 100vh; flex-direction: column; }
|
||||
.app-shell { position: relative; z-index: 0; isolation: isolate; display: flex; min-height: 100vh; flex-direction: column; }
|
||||
.app-bootstrap { display: grid; min-height: 100vh; place-items: center; padding: 24px; background: var(--background); }
|
||||
.app-bootstrap .error-band { width: min(520px, 100%); }
|
||||
.theme-background { position: fixed; z-index: -2; inset: 0; opacity: 0; background-position: center; background-repeat: no-repeat; background-size: cover; transition: opacity 180ms ease; }
|
||||
.theme-background { position: fixed; z-index: -1; inset: 0; pointer-events: none; opacity: 0; background-position: center; background-repeat: no-repeat; background-size: cover; transition: opacity 180ms ease; }
|
||||
.theme-background::after { position: absolute; content: ""; inset: 0; background: color-mix(in srgb, var(--background) 18%, transparent); }
|
||||
.container { width: min(1440px, 100%); margin: 0 auto; padding-inline: 32px; }
|
||||
|
||||
@@ -44,7 +44,8 @@
|
||||
.result-count { margin-left: auto; color: var(--muted-foreground); font-size: 11px; white-space: nowrap; }
|
||||
|
||||
.node-grid, .loading-grid { display: grid; align-items: start; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); }
|
||||
.node-card { position: relative; display: flex; width: 100%; min-width: 0; min-height: 336px; align-self: start; flex-direction: column; overflow: hidden; border-radius: 12px; padding: 0; text-align: left; box-shadow: var(--node-card-shadow); transition: border-color 160ms ease, background-color 160ms ease, box-shadow 160ms ease; }
|
||||
.node-card { position: relative; display: flex; width: 100%; min-width: 0; min-height: var(--node-card-min-height); align-self: start; flex-direction: column; overflow: hidden; border-radius: 12px; padding: 0; text-align: left; box-shadow: var(--node-card-shadow); transition: border-color 160ms ease, background-color 160ms ease, box-shadow 160ms ease; }
|
||||
.node-grid.carrier-latency .node-card, .loading-grid.carrier-latency span { min-height: var(--node-card-carrier-min-height); }
|
||||
.node-card:hover { z-index: 2; border-color: color-mix(in srgb, var(--foreground) 24%, var(--node-card-border)); background: var(--node-card-hover); box-shadow: 0 12px 38px rgb(15 23 42 / .2); }
|
||||
.dark .node-card:hover { box-shadow: 0 12px 38px rgb(0 0 0 / .54); }
|
||||
.node-card.offline { box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger-bar) 30%, transparent), var(--node-card-shadow); }
|
||||
@@ -82,8 +83,17 @@
|
||||
.data-panel b svg { width: 10px; height: 10px; }
|
||||
.data-panel em { overflow: hidden; font-style: normal; font-variant-numeric: tabular-nums; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.quality-grid { display: grid; gap: 6px; grid-template-columns: 1fr 1fr; }
|
||||
.quality-panel { display: flex; min-width: 0; height: 44px; flex-direction: column; gap: 6px; overflow: visible; border-radius: 8px; padding: 8px; background: var(--node-data-bg); }
|
||||
.quality-panel { display: flex; min-width: 0; height: var(--quality-panel-height); flex-direction: column; gap: 6px; overflow: visible; border-radius: 8px; padding: 8px; background: var(--node-data-bg); }
|
||||
.quality-panel > div:first-child { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--muted-foreground); font-size: 12px; line-height: 1; }
|
||||
.carrier-panel { height: var(--carrier-panel-height); }
|
||||
.carrier-rows { display: flex; min-height: 0; flex: 1; flex-direction: column; justify-content: space-between; gap: 5px; }
|
||||
.carrier-row { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 3px; }
|
||||
.carrier-row-head { display: flex; min-width: 0; align-items: center; gap: 5px; color: var(--muted-foreground); font-size: 11px; line-height: 1.2; }
|
||||
.carrier-row-head i { width: 7px; height: 7px; flex: 0 0 auto; border-radius: 50%; }
|
||||
.carrier-row-head span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.carrier-row-head b { margin-left: auto; flex: 0 0 auto; color: var(--foreground); font-weight: 500; font-variant-numeric: tabular-nums; }
|
||||
.carrier-row .quality-bars { min-height: 8px; }
|
||||
.empty-inline { display: grid; min-height: 0; flex: 1; place-items: center; color: var(--muted-foreground); font-size: 11px; }
|
||||
.quality-panel b { color: var(--foreground); font-size: 12px; font-weight: 500; font-variant-numeric: tabular-nums; }
|
||||
.quality-bars { display: grid; min-height: 0; flex: 1; align-items: end; gap: 1px; opacity: .85; }
|
||||
.quality-bars > span { position: relative; display: flex; height: 100%; min-width: 0; align-items: end; overflow: visible; border-radius: 1px; background: color-mix(in srgb, var(--foreground) 6%, transparent); }
|
||||
@@ -101,6 +111,12 @@
|
||||
.quality-bar:hover { z-index: 20; }
|
||||
.quality-bar:hover .quality-tooltip { visibility: visible; opacity: 1; transform: translateY(0); }
|
||||
.quality-bar:hover .quality-tooltip.center { transform: translate(-50%, 0); }
|
||||
/* Keyboard users reach the card, not each bar, so surface the bar readings
|
||||
whenever the card itself has focus. */
|
||||
.node-card:focus-visible .quality-bar:first-child .quality-tooltip,
|
||||
.node-card:focus-visible .quality-bar:last-child .quality-tooltip { visibility: visible; opacity: 1; transform: translateY(0); }
|
||||
.node-card:focus-visible .quality-bar:first-child .quality-tooltip.center,
|
||||
.node-card:focus-visible .quality-bar:last-child .quality-tooltip.center { transform: translate(-50%, 0); }
|
||||
|
||||
.error-band { display: flex; align-items: center; justify-content: space-between; border: 1px solid color-mix(in srgb, var(--danger) 32%, var(--border)); border-radius: 7px; padding: 12px 14px; color: var(--danger); background: color-mix(in srgb, var(--danger) 7%, transparent); font-size: 12px; }
|
||||
.error-band button { font-weight: 700; }
|
||||
@@ -120,7 +136,7 @@
|
||||
.dashboard-login-turnstile .turnstile-widget { width: 100%; }
|
||||
.dashboard-login-submit { width: 100%; min-height: 42px; }
|
||||
.verification-status { color: var(--primary); font-size: 11px; }
|
||||
.loading-grid span { height: 336px; border: 1px solid var(--border); border-radius: 12px; background: var(--card); animation: loading-pulse 1.3s ease-in-out infinite alternate; }
|
||||
.loading-grid span { height: var(--node-card-min-height); border: 1px solid var(--border); border-radius: 12px; background: var(--card); animation: loading-pulse 1.3s ease-in-out infinite alternate; }
|
||||
@keyframes loading-pulse { to { opacity: .46; } }
|
||||
|
||||
.detail-page { display: flex; flex-direction: column; gap: 14px; }
|
||||
@@ -162,6 +178,14 @@
|
||||
.latency-summary > span i { width: 9px; height: 9px; flex: 0 0 auto; border-radius: 2px; }
|
||||
.latency-summary strong { display: block; margin-top: 6px; font-size: 22px; font-weight: 650; font-variant-numeric: tabular-nums; letter-spacing: 0; }
|
||||
.latency-summary small { display: block; margin-top: 5px; color: var(--muted-foreground); font-size: 12px; font-variant-numeric: tabular-nums; }
|
||||
.latency-summary.hidden { opacity: .42; }
|
||||
.latency-task-legend { display: flex; min-width: 0; align-items: center; gap: 6px; overflow-x: auto; border-bottom: 1px solid var(--border); padding: 10px 16px; }
|
||||
.latency-task-toggle { display: inline-flex; min-width: 0; min-height: 28px; flex: 0 0 auto; align-items: center; gap: 6px; border: 1px solid transparent; border-radius: 6px; padding: 4px 8px; color: var(--muted-foreground); font-size: 11px; white-space: nowrap; }
|
||||
.latency-task-toggle:hover { color: var(--foreground); background: color-mix(in srgb, var(--foreground) 6%, transparent); }
|
||||
.latency-task-toggle:focus-visible { outline: 2px solid var(--primary); outline-offset: 1px; }
|
||||
.latency-task-toggle i { width: 8px; height: 8px; flex: 0 0 auto; border-radius: 2px; }
|
||||
.latency-task-toggle.hidden { border-color: var(--border); color: var(--muted-foreground); opacity: .58; }
|
||||
.latency-task-toggle.hidden i { opacity: .38; }
|
||||
.latency-line-chart { width: 100%; height: 320px; padding: 16px 18px 10px 10px; }
|
||||
.latency-empty { display: grid; width: 100%; height: 100%; place-items: center; color: var(--muted-foreground); font-size: 13px; }
|
||||
|
||||
@@ -194,7 +218,6 @@
|
||||
.search-box { width: 100%; }
|
||||
.group-tabs { max-width: calc(100% - 58px); }
|
||||
.node-grid, .loading-grid { grid-template-columns: 1fr; }
|
||||
.node-card { min-height: 336px; }
|
||||
.detail-hero { padding: 14px 16px; }
|
||||
.detail-hero > .icon-btn { margin-left: -4px; }
|
||||
.detail-flag { font-size: 19px; }
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { CarrierSlotKey } from "./latency";
|
||||
import { ASSET_CURRENCIES, type AssetCurrency, type Config, type ThemeSettingValue } from "./types";
|
||||
|
||||
function option(config: Config, key: string): ThemeSettingValue | undefined {
|
||||
@@ -16,6 +17,24 @@ export function themeToggle(config: Config, key: string, fallback = true) {
|
||||
return typeof value === "boolean" ? value : fallback;
|
||||
}
|
||||
|
||||
export function themeText(config: Config, key: string) {
|
||||
const value = option(config, key);
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Carrier line names as configured in the theme settings. Blank entries are
|
||||
* intentional: the latency layer falls back to name matching when all three
|
||||
* are empty, so an unconfigured site still shows sensible lines.
|
||||
*/
|
||||
export function carrierSelection(config: Config): CarrierSlotKey {
|
||||
return {
|
||||
telecom: themeText(config, "telecomLatencyTask"),
|
||||
mobile: themeText(config, "mobileLatencyTask"),
|
||||
unicom: themeText(config, "unicomLatencyTask"),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveBackground(raw: string, dark: boolean) {
|
||||
const parts = raw.split("|").map((part) => part.trim());
|
||||
if (!parts[0] && !parts[1]) return "";
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { BatchUpdate } from "./live";
|
||||
import type { Server } from "./types";
|
||||
|
||||
const RECONNECT_DELAY = 3_000;
|
||||
const HEARTBEAT_INTERVAL = 30_000;
|
||||
const WAKE_CONCURRENCY = 6;
|
||||
const WAKE_TIMEOUT = 5_000;
|
||||
const WAKE_SETTLE = 100;
|
||||
|
||||
export interface LiveTransportHandlers {
|
||||
onServer: (server: Server) => void;
|
||||
onBatch: (updates: BatchUpdate[], cached: boolean) => void;
|
||||
onConnectedChange: (connected: boolean) => void;
|
||||
/** Online servers that should be woken when the overview connects. */
|
||||
wakeTargets: () => string[];
|
||||
}
|
||||
|
||||
export interface LiveTransportOptions {
|
||||
/** When set, the socket follows a single server instead of the overview. */
|
||||
serverId: string | null;
|
||||
}
|
||||
|
||||
function endpoint(serverId: string | null): URL {
|
||||
const url = new URL(location.origin);
|
||||
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
||||
url.pathname = "/api/ws";
|
||||
url.search = "";
|
||||
if (serverId) url.searchParams.set("server_id", serverId);
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* WSS-only live feed. Returns a disposer; the caller owns when the connection
|
||||
* starts and stops. Reconnects on close, and on the overview it opens a short
|
||||
* socket per online server so hibernating Durable Objects resume reporting.
|
||||
*/
|
||||
export function connectLive(
|
||||
{ serverId }: LiveTransportOptions,
|
||||
handlers: LiveTransportHandlers,
|
||||
): () => void {
|
||||
const sockets: WebSocket[] = [];
|
||||
const reconnects: number[] = [];
|
||||
const wakeTimers: number[] = [];
|
||||
const wakeSockets = new Set<WebSocket>();
|
||||
let cancelled = false;
|
||||
|
||||
const reportConnected = () => {
|
||||
handlers.onConnectedChange(sockets.some((socket) => socket.readyState === WebSocket.OPEN));
|
||||
};
|
||||
|
||||
const wakeOverviewAgents = () => {
|
||||
if (serverId || cancelled) return;
|
||||
const queue = Array.from(new Set(handlers.wakeTargets()));
|
||||
let cursor = 0;
|
||||
let active = 0;
|
||||
const launch = () => {
|
||||
while (!cancelled && active < WAKE_CONCURRENCY && cursor < queue.length) {
|
||||
const socket = new WebSocket(endpoint(queue[cursor++]));
|
||||
wakeSockets.add(socket);
|
||||
active += 1;
|
||||
let finished = false;
|
||||
let timeout = 0;
|
||||
const finish = () => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
clearTimeout(timeout);
|
||||
wakeSockets.delete(socket);
|
||||
active -= 1;
|
||||
socket.onopen = null;
|
||||
socket.onclose = null;
|
||||
socket.onerror = null;
|
||||
if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) {
|
||||
socket.close();
|
||||
}
|
||||
launch();
|
||||
};
|
||||
timeout = window.setTimeout(finish, WAKE_TIMEOUT);
|
||||
wakeTimers.push(timeout);
|
||||
socket.onopen = () => wakeTimers.push(window.setTimeout(finish, WAKE_SETTLE));
|
||||
socket.onclose = finish;
|
||||
socket.onerror = finish;
|
||||
}
|
||||
};
|
||||
launch();
|
||||
};
|
||||
|
||||
const connect = () => {
|
||||
if (cancelled) return;
|
||||
const socket = new WebSocket(endpoint(serverId));
|
||||
sockets.push(socket);
|
||||
socket.onopen = () => {
|
||||
handlers.onConnectedChange(true);
|
||||
wakeOverviewAgents();
|
||||
};
|
||||
socket.onclose = () => {
|
||||
const index = sockets.indexOf(socket);
|
||||
if (index >= 0) sockets.splice(index, 1);
|
||||
reportConnected();
|
||||
if (!cancelled) reconnects.push(window.setTimeout(connect, RECONNECT_DELAY));
|
||||
};
|
||||
socket.onerror = () => handlers.onConnectedChange(false);
|
||||
socket.onmessage = (event) => {
|
||||
if (event.data === "pong") return;
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.type === "server" && message.server?.id) {
|
||||
handlers.onServer(message.server as Server);
|
||||
return;
|
||||
}
|
||||
if (message.type === "batchUpdate" && Array.isArray(message.updates)) {
|
||||
handlers.onBatch(message.updates as BatchUpdate[], message.cached === true);
|
||||
}
|
||||
} catch { /* Ignore non-protocol messages. */ }
|
||||
};
|
||||
};
|
||||
|
||||
connect();
|
||||
const heartbeat = window.setInterval(() => {
|
||||
for (const socket of sockets) {
|
||||
if (socket.readyState === WebSocket.OPEN) socket.send("ping");
|
||||
}
|
||||
}, HEARTBEAT_INTERVAL);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
handlers.onConnectedChange(false);
|
||||
clearInterval(heartbeat);
|
||||
reconnects.forEach(clearTimeout);
|
||||
wakeTimers.forEach(clearTimeout);
|
||||
wakeSockets.forEach((socket) => socket.close());
|
||||
wakeSockets.clear();
|
||||
sockets.forEach((socket) => socket.close());
|
||||
sockets.length = 0;
|
||||
};
|
||||
}
|
||||
+10
-2
@@ -275,14 +275,22 @@ export interface Settings extends Omit<Config, "password_client_salt"> {
|
||||
new_password_derived?: string;
|
||||
turnstile_secret_key: string;
|
||||
notification_enabled: boolean;
|
||||
notification_endpoint: string;
|
||||
notification_target: string;
|
||||
offline_alert_minutes: number;
|
||||
expiry_alert_days: number;
|
||||
traffic_alert_percentage: number;
|
||||
cloudflare_account_id: string;
|
||||
cloudflare_api_token: string;
|
||||
}
|
||||
|
||||
export interface TelegramSettings {
|
||||
bot_token: string;
|
||||
chat_id: string;
|
||||
message_thread_id: number | null;
|
||||
template: string;
|
||||
}
|
||||
|
||||
export type TelegramSettingsInput = Pick<TelegramSettings, "bot_token" | "chat_id" | "message_thread_id" | "template">;
|
||||
|
||||
type AlertMetric = "cpu" | "memory" | "disk" | "net_in" | "net_out";
|
||||
type AlertAggregation = "average" | "continuous";
|
||||
|
||||
|
||||
@@ -20,5 +20,6 @@
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
|
||||
}
|
||||
@@ -2,7 +2,8 @@
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
{ "path": "./tsconfig.node.json" },
|
||||
{ "path": "./tsconfig.test.json" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "../node_modules/.cache/nodeflare/tsconfig.test.tsbuildinfo",
|
||||
"composite": true,
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"types": ["bun", "vite/client"],
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/**/*.ts", "src/**/*.tsx"]
|
||||
}
|
||||
+149
-60
@@ -36,6 +36,12 @@ CREATE TABLE servers (
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_servers_sort
|
||||
ON servers(sort_order, created_at);
|
||||
|
||||
-- 分钟粒度历史。下面三张 metric_* 表结构完全一致,统一由
|
||||
-- db::metric_history_ddl 生成,src/db.rs 的 metric_history_ddl_matches_migration
|
||||
-- 负责防止漂移——轮换要在运行时重建 metric_history,DDL 因此在 Rust 里也存了一份。
|
||||
CREATE TABLE metric_history (
|
||||
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
timestamp INTEGER NOT NULL,
|
||||
@@ -70,6 +76,45 @@ CREATE TABLE metric_history (
|
||||
PRIMARY KEY(server_id, timestamp)
|
||||
) WITHOUT ROWID;
|
||||
|
||||
-- 分钟粒度历史的上一代。保留期靠表轮换而不是 DELETE:DELETE 每删一行计一行
|
||||
-- D1 写入,DROP TABLE 是页级回收、不计行写入。轮换后当前表是空的,所以近期
|
||||
-- 读取(<=24h)必须同时读这两张表,两代合起来覆盖 24-48 小时。这里预建空表,
|
||||
-- 保证 union 读取在首次轮换之前就不会撞上缺表。
|
||||
CREATE TABLE metric_history_old (
|
||||
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
timestamp INTEGER NOT NULL,
|
||||
cpu REAL NOT NULL DEFAULT 0,
|
||||
load1 REAL NOT NULL DEFAULT 0,
|
||||
load5 REAL NOT NULL DEFAULT 0,
|
||||
load15 REAL NOT NULL DEFAULT 0,
|
||||
mem_used INTEGER NOT NULL DEFAULT 0,
|
||||
mem_total INTEGER NOT NULL DEFAULT 0,
|
||||
swap_used INTEGER NOT NULL DEFAULT 0,
|
||||
swap_total INTEGER NOT NULL DEFAULT 0,
|
||||
disk_used INTEGER NOT NULL DEFAULT 0,
|
||||
disk_total INTEGER NOT NULL DEFAULT 0,
|
||||
net_in REAL NOT NULL DEFAULT 0,
|
||||
net_out REAL NOT NULL DEFAULT 0,
|
||||
net_rx_total INTEGER NOT NULL DEFAULT 0,
|
||||
net_tx_total INTEGER NOT NULL DEFAULT 0,
|
||||
processes INTEGER NOT NULL DEFAULT 0,
|
||||
tcp_connections INTEGER NOT NULL DEFAULT 0,
|
||||
udp_connections INTEGER NOT NULL DEFAULT 0,
|
||||
gpu_usage REAL NOT NULL DEFAULT 0,
|
||||
disk_read_bps REAL NOT NULL DEFAULT 0,
|
||||
disk_write_bps REAL NOT NULL DEFAULT 0,
|
||||
disk_read_iops REAL NOT NULL DEFAULT 0,
|
||||
disk_write_iops REAL NOT NULL DEFAULT 0,
|
||||
disk_await_ms REAL NOT NULL DEFAULT 0,
|
||||
disk_utilization REAL NOT NULL DEFAULT 0,
|
||||
sample_count INTEGER NOT NULL DEFAULT 1 CHECK(sample_count > 0),
|
||||
latest_timestamp INTEGER NOT NULL,
|
||||
latest_json TEXT NOT NULL CHECK(json_valid(latest_json)),
|
||||
latency_json TEXT NOT NULL DEFAULT '[]' CHECK(json_valid(latency_json)),
|
||||
PRIMARY KEY(server_id, timestamp)
|
||||
) WITHOUT ROWID;
|
||||
|
||||
-- 小时粒度历史。分钟表轮换掉之后的长期留存,按 history_retention_days 删除。
|
||||
CREATE TABLE metric_history_hourly (
|
||||
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
timestamp INTEGER NOT NULL,
|
||||
@@ -104,52 +149,13 @@ CREATE TABLE metric_history_hourly (
|
||||
PRIMARY KEY(server_id, timestamp)
|
||||
) WITHOUT ROWID;
|
||||
|
||||
CREATE INDEX idx_servers_sort
|
||||
ON servers(sort_order, created_at);
|
||||
|
||||
INSERT INTO settings(id, value, updated_at) VALUES (
|
||||
1,
|
||||
json_patch(
|
||||
'{
|
||||
"site_description": "轻量、实时的服务器运行状态",
|
||||
"site_announcement": "",
|
||||
"logo_url": "",
|
||||
"favicon_url": "",
|
||||
"locale": "zh-CN",
|
||||
"public_dashboard": "true",
|
||||
"history_cache_version": "0",
|
||||
"default_theme": "system",
|
||||
"active_theme_id": "builtin-nodeflare-glass",
|
||||
"background_url": "",
|
||||
"theme_options": "{}",
|
||||
"show_search": "true",
|
||||
"show_groups": "true",
|
||||
"show_stats": "true",
|
||||
"show_assets": "true",
|
||||
"show_traffic": "true",
|
||||
"show_speed": "true",
|
||||
"show_price": "true",
|
||||
"show_expiry": "true",
|
||||
"show_latency": "true",
|
||||
"show_uptime": "true",
|
||||
"admin_username": "",
|
||||
"admin_password_hash": "",
|
||||
"turnstile_enabled": "false",
|
||||
"turnstile_login_enabled": "true",
|
||||
"turnstile_site_key": "",
|
||||
"turnstile_secret_key": "",
|
||||
"notification_enabled": "false",
|
||||
"notification_endpoint": "",
|
||||
"notification_target": "",
|
||||
"offline_alert_minutes": "5",
|
||||
"expiry_alert_days": "7",
|
||||
"cloudflare_account_id": "",
|
||||
"cloudflare_api_token": ""
|
||||
}',
|
||||
json_object('password_client_salt', lower(hex(randomblob(16))))
|
||||
),
|
||||
unixepoch()
|
||||
);
|
||||
-- 每台机器最新一次上报的落点。和 metric_history 分开,避免轮换影响实时读取。
|
||||
CREATE TABLE server_latest_state (
|
||||
server_id TEXT PRIMARY KEY REFERENCES servers(id) ON DELETE CASCADE,
|
||||
latest_timestamp INTEGER NOT NULL,
|
||||
latest_json TEXT NOT NULL CHECK(json_valid(latest_json)),
|
||||
last_batch_id TEXT NOT NULL DEFAULT ''
|
||||
) WITHOUT ROWID;
|
||||
|
||||
CREATE TABLE exchange_rate_snapshots (
|
||||
base_currency TEXT PRIMARY KEY,
|
||||
@@ -172,14 +178,6 @@ CREATE TABLE themes (
|
||||
CREATE INDEX idx_themes_created
|
||||
ON themes(created_at DESC);
|
||||
|
||||
INSERT INTO exchange_rate_snapshots (
|
||||
base_currency, rates_json, source, rate_date, fetched_at, attempted_at
|
||||
) VALUES (
|
||||
'CNY',
|
||||
'{"CNY":1,"USD":0.14799,"CAD":0.2086,"HKD":1.1594,"EUR":0.1275,"GBP":0.11027,"JPY":23.707,"RUB":11.560694,"CHF":0.120661,"INR":14.248668,"VND":3875.968992,"THB":4.97107}',
|
||||
'default', '', 0, 0
|
||||
);
|
||||
|
||||
CREATE TABLE latency_tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
@@ -222,12 +220,103 @@ CREATE TABLE alert_rule_servers (
|
||||
PRIMARY KEY(rule_id, server_id)
|
||||
) WITHOUT ROWID;
|
||||
|
||||
CREATE TABLE alert_states (
|
||||
rule_id TEXT NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE,
|
||||
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
active INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY(rule_id, server_id)
|
||||
-- 通知只走 Telegram,单行配置表。
|
||||
CREATE TABLE notification_telegram (
|
||||
id INTEGER PRIMARY KEY CHECK(id = 1),
|
||||
bot_token TEXT NOT NULL,
|
||||
chat_id TEXT NOT NULL,
|
||||
message_thread_id INTEGER CHECK(message_thread_id IS NULL OR message_thread_id > 0),
|
||||
template TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE notification_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
dedupe_key TEXT NOT NULL UNIQUE,
|
||||
kind TEXT NOT NULL CHECK(kind IN (
|
||||
'offline', 'online', 'expiry', 'resource_alert', 'resource_recovery', 'traffic', 'test'
|
||||
)),
|
||||
server_id TEXT REFERENCES servers(id) ON DELETE SET NULL,
|
||||
server_name TEXT NOT NULL DEFAULT '',
|
||||
title TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
details_json TEXT NOT NULL DEFAULT '{}' CHECK(json_valid(details_json)),
|
||||
occurred_at INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE notification_deliveries (
|
||||
event_id TEXT PRIMARY KEY REFERENCES notification_events(id) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL CHECK(status IN ('pending', 'sent', 'failed', 'dead')),
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
next_attempt_at INTEGER NOT NULL,
|
||||
last_error TEXT,
|
||||
sent_at INTEGER,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
) WITHOUT ROWID;
|
||||
|
||||
CREATE INDEX idx_notification_deliveries_pending
|
||||
ON notification_deliveries(status, next_attempt_at, created_at);
|
||||
|
||||
CREATE TABLE notification_state (
|
||||
id INTEGER PRIMARY KEY CHECK(id = 1),
|
||||
value TEXT NOT NULL CHECK(json_valid(value)),
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO notification_state(id, value, updated_at)
|
||||
VALUES (1, '{"offline":[],"expiry":[],"resources":[],"traffic":{}}', unixepoch());
|
||||
|
||||
INSERT INTO settings(id, value, updated_at) VALUES (
|
||||
1,
|
||||
json_patch(
|
||||
'{
|
||||
"site_description": "轻量、实时的服务器运行状态",
|
||||
"site_announcement": "",
|
||||
"logo_url": "",
|
||||
"favicon_url": "",
|
||||
"locale": "zh-CN",
|
||||
"public_dashboard": "true",
|
||||
"history_cache_version": "0",
|
||||
"default_theme": "system",
|
||||
"active_theme_id": "builtin-nodeflare-glass",
|
||||
"background_url": "",
|
||||
"theme_options": "{}",
|
||||
"show_search": "true",
|
||||
"show_groups": "true",
|
||||
"show_stats": "true",
|
||||
"show_assets": "true",
|
||||
"show_traffic": "true",
|
||||
"show_speed": "true",
|
||||
"show_price": "true",
|
||||
"show_expiry": "true",
|
||||
"show_latency": "true",
|
||||
"show_uptime": "true",
|
||||
"admin_username": "",
|
||||
"admin_password_hash": "",
|
||||
"turnstile_enabled": "false",
|
||||
"turnstile_login_enabled": "true",
|
||||
"turnstile_site_key": "",
|
||||
"turnstile_secret_key": "",
|
||||
"notification_enabled": "false",
|
||||
"offline_alert_minutes": "5",
|
||||
"expiry_alert_days": "7",
|
||||
"traffic_alert_percentage": "80",
|
||||
"cloudflare_account_id": "",
|
||||
"cloudflare_api_token": ""
|
||||
}',
|
||||
json_object('password_client_salt', lower(hex(randomblob(16))))
|
||||
),
|
||||
unixepoch()
|
||||
);
|
||||
|
||||
INSERT INTO exchange_rate_snapshots (
|
||||
base_currency, rates_json, source, rate_date, fetched_at, attempted_at
|
||||
) VALUES (
|
||||
'CNY',
|
||||
'{"CNY":1,"USD":0.14799,"CAD":0.2086,"HKD":1.1594,"EUR":0.1275,"GBP":0.11027,"JPY":23.707,"RUB":11.560694,"CHF":0.120661,"INR":14.248668,"VND":3875.968992,"THB":4.97107}',
|
||||
'default', '', 0, 0
|
||||
);
|
||||
|
||||
PRAGMA optimize;
|
||||
+3
-3
@@ -3,7 +3,7 @@
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"repository": "https://github.com/imengying/NodeFlare",
|
||||
"packageManager": "bun@1.3.14",
|
||||
"packageManager": "bun@1.4.0",
|
||||
"workspaces": [
|
||||
"frontend"
|
||||
],
|
||||
@@ -19,8 +19,8 @@
|
||||
"test:smoke": "sh scripts/smoke-test.sh"
|
||||
},
|
||||
"devDependencies": {
|
||||
"wrangler": "^4.123.0",
|
||||
"ws": "^8.21.0"
|
||||
"wrangler": "^4.127.0",
|
||||
"ws": "^8.21.3"
|
||||
},
|
||||
"overrides": {
|
||||
"nanoid": "3.3.18"
|
||||
|
||||
+28
-97
@@ -77,11 +77,7 @@ server_input='{"name":"Smoke Test Node","region":"JP","group_name":"Test","tags"
|
||||
server_id=
|
||||
latency_task_id=
|
||||
alert_rule_id=
|
||||
stress_report_file=
|
||||
cleanup() {
|
||||
if [ -n "$stress_report_file" ]; then
|
||||
rm -f "$stress_report_file"
|
||||
fi
|
||||
if [ -n "$alert_rule_id" ]; then
|
||||
monitor_curl --silent --show-error -H "Authorization: Bearer $admin_token" \
|
||||
-X DELETE "$MONITOR_BASE_URL/api/admin/alert-rules/$alert_rule_id" >/dev/null || true
|
||||
@@ -97,6 +93,18 @@ cleanup() {
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
request -H "Authorization: Bearer $admin_token" \
|
||||
"$MONITOR_BASE_URL/api/admin/telegram" | \
|
||||
jq -e '.telegram == null or (.telegram.bot_token == "********" and (.telegram.chat_id | length > 0))' >/dev/null
|
||||
invalid_telegram_status=$(monitor_curl --silent --output /dev/null --write-out '%{http_code}' \
|
||||
-X PUT -H "Authorization: Bearer $admin_token" -H 'Content-Type: application/json' \
|
||||
--data '{"bot_token":"invalid","chat_id":"","message_thread_id":null,"template":"{{message}}"}' \
|
||||
"$MONITOR_BASE_URL/api/admin/telegram")
|
||||
[ "$invalid_telegram_status" = "400" ]
|
||||
removed_channels_status=$(monitor_curl --silent --output /dev/null --write-out '%{http_code}' \
|
||||
-H "Authorization: Bearer $admin_token" "$MONITOR_BASE_URL/api/admin/notification-channels")
|
||||
[ "$removed_channels_status" = "404" ]
|
||||
|
||||
latency_task_json=$(request -H "Authorization: Bearer $admin_token" \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data '{"name":"Smoke TCP","task_type":"tcp","target":"example.com","port":443,"interval_seconds":60,"default_enabled":true,"server_ids":[]}' \
|
||||
@@ -110,106 +118,29 @@ server_json=$(request -H "Authorization: Bearer $admin_token" \
|
||||
server_id=$(printf '%s' "$server_json" | jq -er '.id')
|
||||
agent_token=$(printf '%s' "$server_json" | jq -er '.agent_token')
|
||||
MONITOR_BASE_URL="$MONITOR_BASE_URL" MONITOR_ADMIN_TOKEN="$admin_token" \
|
||||
MONITOR_AGENT_TOKEN="$agent_token" node scripts/websocket-smoke.mjs
|
||||
MONITOR_AGENT_TOKEN="$agent_token" MONITOR_SERVER_ID="$server_id" \
|
||||
MONITOR_LATENCY_TASK_ID="$latency_task_id" \
|
||||
node scripts/websocket-smoke.mjs
|
||||
token_without_admin_status=$(monitor_curl --silent --output /dev/null --write-out '%{http_code}' \
|
||||
"$MONITOR_BASE_URL/api/admin/servers/$server_id/token")
|
||||
[ "$token_without_admin_status" = "401" ]
|
||||
request -H "Authorization: Bearer $admin_token" \
|
||||
"$MONITOR_BASE_URL/api/admin/servers/$server_id/token" | \
|
||||
jq -e --arg token "$agent_token" '.agent_token == $token' >/dev/null
|
||||
agent_version=$(sh scripts/resolve-version.sh)
|
||||
|
||||
sample=$(jq -nc --arg agent_version "$agent_version" '{
|
||||
timestamp:((((now|floor) / 60 | floor) + 1) * 60 + 10),cpu:18.5,load1:0.42,load5:0.36,load15:0.31,
|
||||
mem_used:2147483648,mem_total:4294967296,swap_used:0,swap_total:0,disk_used:21474836480,disk_total:53687091200,
|
||||
net_in:4096,net_out:2048,net_rx_total:1073741824,net_tx_total:536870912,
|
||||
uptime:86400,processes:90,tcp_connections:18,udp_connections:4,cpu_cores:2,
|
||||
cpu_model:"Smoke CPU",os:"Debian 12",kernel:"6.1",arch:"x86_64",virtualization:"kvm",
|
||||
gpu_usage:32.5,gpu_model:"NVIDIA T4",disk_read_bps:4194304,disk_write_bps:2097152,
|
||||
disk_read_iops:120,disk_write_iops:48,disk_await_ms:1.4,disk_utilization:8.2,
|
||||
disks:[{name:"/dev/vda1",mount_point:"/",used:21474836480,total:53687091200,read_bps:4194304,write_bps:2097152,read_iops:120,write_iops:48,await_ms:1.4,utilization:8.2}],
|
||||
gpus:[{model:"NVIDIA T4",usage:32.5,memory_used:1073741824,memory_total:17179869184}],
|
||||
agent_version:$agent_version,latency_results:[]
|
||||
}')
|
||||
report=$(jq -nc --argjson sample "$sample" '{samples:[$sample]}')
|
||||
invalid_token_status=$(monitor_curl --silent --output /dev/null --write-out '%{http_code}' \
|
||||
-H 'Authorization: Bearer invalid-agent-token' -H 'Content-Type: application/json' \
|
||||
--data "$report" "$MONITOR_BASE_URL/api/agent/report")
|
||||
[ "$invalid_token_status" = "401" ]
|
||||
report_response=$(request -H "Authorization: Bearer $agent_token" -H 'Content-Type: application/json' \
|
||||
-H 'CF-Connecting-IP: 8.8.8.8' \
|
||||
--data "$report" "$MONITOR_BASE_URL/api/agent/report")
|
||||
printf '%s' "$report_response" | jq -e \
|
||||
--arg task_id "$latency_task_id" \
|
||||
'.collect_interval == 5 and .agent_mirror == "https://mirror.example.com" and .auto_update == true and (.latency_tasks | any(.id == $task_id and .task_type == "tcp" and .target == "example.com" and .port == 443))' >/dev/null
|
||||
request -H "Authorization: Bearer $agent_token" \
|
||||
"$MONITOR_BASE_URL/api/agent/config" | jq -e \
|
||||
--arg task_id "$latency_task_id" \
|
||||
'.collect_interval == 5 and .agent_mirror == "https://mirror.example.com" and (.latency_tasks | any(.id == $task_id))' >/dev/null
|
||||
legacy_report_status=$(monitor_curl --silent --output /dev/null --write-out '%{http_code}' \
|
||||
-X POST -H "Authorization: Bearer $agent_token" -H 'Content-Type: application/json' \
|
||||
--data '{}' "$MONITOR_BASE_URL/api/agent/report")
|
||||
[ "$legacy_report_status" = "404" ]
|
||||
for legacy_path in /api/agent/config /api/agent/live; do
|
||||
legacy_status=$(monitor_curl --silent --output /dev/null --write-out '%{http_code}' \
|
||||
-H "Authorization: Bearer $agent_token" "$MONITOR_BASE_URL$legacy_path")
|
||||
[ "$legacy_status" = "404" ]
|
||||
done
|
||||
request -H "Authorization: Bearer $admin_token" "$MONITOR_BASE_URL/api/admin/servers" | \
|
||||
jq -e --arg id "$server_id" '.servers | any(.id == $id and .last_ip == "8.8.8.8")' >/dev/null
|
||||
|
||||
latency_report=$(printf '%s' "$report" | jq --arg task_id "$latency_task_id" \
|
||||
'.samples[0].timestamp += 1 |
|
||||
.samples[0].latency_results=[{
|
||||
task_id:$task_id,
|
||||
timestamp:.samples[0].timestamp,
|
||||
latency_ms:28.4,
|
||||
packet_loss:25
|
||||
}]')
|
||||
request -H "Authorization: Bearer $agent_token" -H 'Content-Type: application/json' \
|
||||
--data "$latency_report" "$MONITOR_BASE_URL/api/agent/report" | jq -e '.collect_interval == 5' >/dev/null
|
||||
latency_report_second=$(printf '%s' "$report" | jq --arg task_id "$latency_task_id" \
|
||||
'.samples[0].timestamp += 2 |
|
||||
.samples[0].latency_results=[{
|
||||
task_id:$task_id,
|
||||
timestamp:.samples[0].timestamp,
|
||||
latency_ms:48.4,
|
||||
packet_loss:75
|
||||
}]')
|
||||
request -H "Authorization: Bearer $agent_token" -H 'Content-Type: application/json' \
|
||||
--data "$latency_report_second" "$MONITOR_BASE_URL/api/agent/report" | jq -e '.collect_interval == 5' >/dev/null
|
||||
|
||||
# A whole Agent batch must collapse into one traffic-cycle write while preserving
|
||||
# counter resets. Replaying the same batch must not count any bytes twice.
|
||||
traffic_report=$(printf '%s' "$report" | jq '.samples = [
|
||||
(.samples[0] | .timestamp += 3 | .net_rx_total=2147483648 | .net_tx_total=1073741824),
|
||||
(.samples[0] | .timestamp += 4 | .net_rx_total=268435456 | .net_tx_total=134217728),
|
||||
(.samples[0] | .timestamp += 5 | .net_rx_total=536870912 | .net_tx_total=268435456)
|
||||
]')
|
||||
request -H "Authorization: Bearer $agent_token" -H 'Content-Type: application/json' \
|
||||
--data "$traffic_report" "$MONITOR_BASE_URL/api/agent/report" | jq -e '.collect_interval == 5' >/dev/null
|
||||
request -H "Authorization: Bearer $agent_token" -H 'Content-Type: application/json' \
|
||||
--data "$traffic_report" "$MONITOR_BASE_URL/api/agent/report" | jq -e '.collect_interval == 5' >/dev/null
|
||||
|
||||
request -H "Authorization: Bearer $admin_token" "$MONITOR_BASE_URL/api/bootstrap" | jq -e --arg id "$server_id" --arg task_id "$latency_task_id" '.servers | any(.id == $id and .cpu == 18.5 and .gpu_usage == 32.5 and .disk_await_ms == 1.4 and (.gpus | length) == 1 and (.disks | length) == 1 and .disk_used == 21474836480 and .traffic_limit == 107374182400 and .net_rx_total == 2684354560 and .net_tx_total == 1342177280 and .price == 9.9 and (has("last_ip") | not) and (.latency | any(.task_id == $task_id and .latency_ms == 48.4 and .packet_loss == 75)))' >/dev/null
|
||||
|
||||
# Changing the reset day starts a new traffic cycle. Only growth after the first
|
||||
# sample in that cycle counts toward usage.
|
||||
request -H "Authorization: Bearer $admin_token" -H 'Content-Type: application/json' -X PATCH \
|
||||
--data "$(printf '%s' "$server_input" | jq '.reset_day=2')" \
|
||||
"$MONITOR_BASE_URL/api/admin/servers/$server_id" >/dev/null
|
||||
cycle_report=$(printf '%s' "$report" | jq '.samples = [
|
||||
(.samples[0] | .timestamp += 6 | .net_rx_total=805306368 | .net_tx_total=536870912),
|
||||
(.samples[0] | .timestamp += 7 | .net_rx_total=1073741824 | .net_tx_total=805306368)
|
||||
]')
|
||||
request -H "Authorization: Bearer $agent_token" -H 'Content-Type: application/json' \
|
||||
--data "$cycle_report" "$MONITOR_BASE_URL/api/agent/report" | jq -e '.collect_interval == 5' >/dev/null
|
||||
request -H "Authorization: Bearer $admin_token" "$MONITOR_BASE_URL/api/bootstrap" | \
|
||||
jq -e --arg id "$server_id" '.servers | any(.id == $id and .net_rx_total == 268435456 and .net_tx_total == 268435456)' >/dev/null
|
||||
|
||||
# Exercise the protocol maximum so batch aggregation and request-size limits
|
||||
# stay covered when an Agent flushes a full pending queue.
|
||||
stress_report_file=$(mktemp "${TMPDIR:-/tmp}/nodeflare-stress.XXXXXX")
|
||||
printf '%s' "$report" | jq '(now | floor) as $start | .samples = [
|
||||
range(0; 720) as $index |
|
||||
(.samples[0] |
|
||||
.timestamp=($start + $index + 10) |
|
||||
.net_rx_total=(1073741824 + (($index + 1) * 4096)) |
|
||||
.net_tx_total=(805306368 + (($index + 1) * 2048)))
|
||||
]' >"$stress_report_file"
|
||||
request -H "Authorization: Bearer $agent_token" -H 'Content-Type: application/json' \
|
||||
--data-binary "@$stress_report_file" "$MONITOR_BASE_URL/api/agent/report" | jq -e '.collect_interval == 5' >/dev/null
|
||||
request -H "Authorization: Bearer $admin_token" "$MONITOR_BASE_URL/api/history/$server_id?hours=1" | jq -e '.points | length >= 1 and any(.gpu_usage == 32.5)' >/dev/null
|
||||
history_cache_header=$(monitor_curl --silent --dump-header - --output /dev/null \
|
||||
-H "Authorization: Bearer $admin_token" "$MONITOR_BASE_URL/api/history/$server_id?hours=1" | \
|
||||
@@ -223,10 +154,10 @@ request -H "Authorization: Bearer $admin_token" -H 'Content-Type: application/js
|
||||
--data '{"name":"Smoke TCP","task_type":"tcp","target":"example.com","port":443,"interval_seconds":60,"default_enabled":true,"server_ids":[]}' \
|
||||
"$MONITOR_BASE_URL/api/admin/latency-tasks/$latency_task_id" >/dev/null
|
||||
request -H "Authorization: Bearer $admin_token" "$MONITOR_BASE_URL/api/latency/$server_id?hours=1" | jq -e --arg task_id "$latency_task_id" '(.tasks | all(.id != $task_id)) and (.points | all(.task_id != $task_id))' >/dev/null
|
||||
stale_latency_response=$(request -H "Authorization: Bearer $agent_token" -H 'Content-Type: application/json' \
|
||||
--data "$latency_report" "$MONITOR_BASE_URL/api/agent/report")
|
||||
printf '%s' "$stale_latency_response" | jq -e --arg task_id "$latency_task_id" \
|
||||
'.latency_tasks | all(.id != $task_id)' >/dev/null
|
||||
MONITOR_BASE_URL="$MONITOR_BASE_URL" MONITOR_ADMIN_TOKEN="$admin_token" \
|
||||
MONITOR_AGENT_TOKEN="$agent_token" MONITOR_SERVER_ID="$server_id" \
|
||||
MONITOR_LATENCY_TASK_ID="$latency_task_id" MONITOR_EXPECT_TASK_ASSIGNED=0 \
|
||||
MONITOR_CONFIG_ONLY=1 node scripts/websocket-smoke.mjs
|
||||
|
||||
alert_rule_json=$(request -H "Authorization: Bearer $admin_token" -H 'Content-Type: application/json' \
|
||||
--data "$(jq -nc --arg server_id "$server_id" '{name:"Smoke CPU",metric:"cpu",threshold:80,duration_minutes:5,aggregation:"average",enabled:true,server_ids:[$server_id]}')" \
|
||||
|
||||
+246
-23
@@ -3,9 +3,15 @@ import WebSocket from "ws";
|
||||
const baseUrl = process.env.MONITOR_BASE_URL;
|
||||
const adminToken = process.env.MONITOR_ADMIN_TOKEN;
|
||||
const agentToken = process.env.MONITOR_AGENT_TOKEN;
|
||||
const serverId = process.env.MONITOR_SERVER_ID;
|
||||
const latencyTaskId = process.env.MONITOR_LATENCY_TASK_ID;
|
||||
const expectTaskAssigned = process.env.MONITOR_EXPECT_TASK_ASSIGNED !== "0";
|
||||
const configOnly = process.env.MONITOR_CONFIG_ONLY === "1";
|
||||
|
||||
if (!baseUrl || !adminToken || !agentToken) {
|
||||
throw new Error("MONITOR_BASE_URL, MONITOR_ADMIN_TOKEN and MONITOR_AGENT_TOKEN are required");
|
||||
if (!baseUrl || !adminToken || !agentToken || !serverId || !latencyTaskId) {
|
||||
throw new Error(
|
||||
"MONITOR_BASE_URL, MONITOR_ADMIN_TOKEN, MONITOR_AGENT_TOKEN, MONITOR_SERVER_ID and MONITOR_LATENCY_TASK_ID are required",
|
||||
);
|
||||
}
|
||||
|
||||
function websocketUrl(path) {
|
||||
@@ -14,10 +20,10 @@ function websocketUrl(path) {
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function openSocket(path, token) {
|
||||
function openSocket(path, token, headers = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = new WebSocket(websocketUrl(path), {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
headers: { Authorization: `Bearer ${token}`, ...headers },
|
||||
});
|
||||
const timer = setTimeout(() => {
|
||||
socket.terminate();
|
||||
@@ -38,6 +44,36 @@ function openSocket(path, token) {
|
||||
});
|
||||
}
|
||||
|
||||
function expectSocketStatus(path, token, statusCode) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = new WebSocket(websocketUrl(path), {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
const timer = setTimeout(() => {
|
||||
socket.terminate();
|
||||
reject(new Error(`${path} did not reject the WebSocket handshake`));
|
||||
}, 5_000);
|
||||
socket.once("open", () => {
|
||||
clearTimeout(timer);
|
||||
socket.terminate();
|
||||
reject(new Error(`${path} unexpectedly accepted the WebSocket handshake`));
|
||||
});
|
||||
socket.once("unexpected-response", (_request, response) => {
|
||||
clearTimeout(timer);
|
||||
response.resume();
|
||||
if (response.statusCode === statusCode) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`${path} returned HTTP ${response.statusCode}, expected ${statusCode}`));
|
||||
}
|
||||
});
|
||||
socket.once("error", (error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function waitForMessage(socket, expected) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error(`WebSocket did not receive ${expected}`)), 5_000);
|
||||
@@ -54,29 +90,216 @@ function waitForMessage(socket, expected) {
|
||||
});
|
||||
}
|
||||
|
||||
function closeSocket(socket) {
|
||||
function waitForJsonMessage(socket, expected, predicate) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
socket.terminate();
|
||||
reject(new Error("WebSocket close timed out"));
|
||||
}, 5_000);
|
||||
socket.once("close", () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
});
|
||||
socket.once("error", (error) => {
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer);
|
||||
socket.off("message", onMessage);
|
||||
socket.off("close", onClose);
|
||||
socket.off("error", onError);
|
||||
};
|
||||
const fail = (error) => {
|
||||
cleanup();
|
||||
reject(error);
|
||||
});
|
||||
socket.close();
|
||||
};
|
||||
const onMessage = (data) => {
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(data.toString());
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!predicate(message)) return;
|
||||
cleanup();
|
||||
resolve(message);
|
||||
};
|
||||
const onClose = () => fail(new Error(`WebSocket closed before receiving ${expected}`));
|
||||
const onError = (error) => fail(error);
|
||||
const timer = setTimeout(
|
||||
() => fail(new Error(`WebSocket did not receive ${expected}`)),
|
||||
5_000,
|
||||
);
|
||||
socket.on("message", onMessage);
|
||||
socket.once("close", onClose);
|
||||
socket.once("error", onError);
|
||||
});
|
||||
}
|
||||
|
||||
const dashboard = await openSocket("/api/ws", adminToken);
|
||||
const pong = waitForMessage(dashboard, "pong");
|
||||
dashboard.send("ping");
|
||||
await pong;
|
||||
await closeSocket(dashboard);
|
||||
function closeSocket(socket) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (socket.readyState === WebSocket.CLOSED) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer);
|
||||
socket.off("close", onClose);
|
||||
socket.off("error", onError);
|
||||
};
|
||||
const onClose = () => {
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
const onError = (error) => {
|
||||
cleanup();
|
||||
reject(error);
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
cleanup();
|
||||
socket.terminate();
|
||||
reject(new Error("WebSocket close timed out"));
|
||||
}, 5_000);
|
||||
socket.once("close", onClose);
|
||||
socket.once("error", onError);
|
||||
if (socket.readyState === WebSocket.OPEN) socket.close();
|
||||
});
|
||||
}
|
||||
|
||||
const agent = await openSocket("/api/agent/live", agentToken);
|
||||
await closeSocket(agent);
|
||||
await expectSocketStatus("/api/agent/ws", "invalid-agent-token", 401);
|
||||
|
||||
const dashboard = configOnly ? null : await openSocket("/api/ws", adminToken);
|
||||
let agent;
|
||||
try {
|
||||
if (dashboard) {
|
||||
const pong = waitForMessage(dashboard, "pong");
|
||||
dashboard.send("ping");
|
||||
await pong;
|
||||
}
|
||||
|
||||
agent = await openSocket("/api/agent/ws", agentToken, { "CF-Connecting-IP": "8.8.8.8" });
|
||||
const config = await waitForJsonMessage(
|
||||
agent,
|
||||
"Agent config",
|
||||
(message) => message.type === "config" && message.config,
|
||||
);
|
||||
const hasTask = config.config.latency_tasks?.some(
|
||||
(task) =>
|
||||
task.id === latencyTaskId &&
|
||||
task.task_type === "tcp" &&
|
||||
task.target === "example.com" &&
|
||||
task.port === 443,
|
||||
);
|
||||
if (
|
||||
config.config.collect_interval !== 5 ||
|
||||
config.config.agent_mirror !== "https://mirror.example.com" ||
|
||||
config.config.auto_update !== true ||
|
||||
hasTask !== expectTaskAssigned
|
||||
) {
|
||||
throw new Error(`Invalid Agent config: ${JSON.stringify(config)}`);
|
||||
}
|
||||
if (!configOnly) {
|
||||
const timestamp = Math.floor(Date.now() / 1_000);
|
||||
const baseSample = {
|
||||
timestamp,
|
||||
cpu: 18.5,
|
||||
load1: 0.42,
|
||||
load5: 0.36,
|
||||
load15: 0.31,
|
||||
mem_used: 2147483648,
|
||||
mem_total: 4294967296,
|
||||
swap_used: 0,
|
||||
swap_total: 0,
|
||||
disk_used: 21474836480,
|
||||
disk_total: 53687091200,
|
||||
net_in: 4096,
|
||||
net_out: 2048,
|
||||
net_rx_total: 1073741824,
|
||||
net_tx_total: 536870912,
|
||||
uptime: 86400,
|
||||
processes: 90,
|
||||
tcp_connections: 18,
|
||||
udp_connections: 4,
|
||||
cpu_cores: 2,
|
||||
cpu_model: "Smoke CPU",
|
||||
os: "Debian 12",
|
||||
kernel: "6.1",
|
||||
arch: "x86_64",
|
||||
virtualization: "kvm",
|
||||
gpu_usage: 32.5,
|
||||
gpu_model: "NVIDIA T4",
|
||||
agent_version: "smoke",
|
||||
disk_read_bps: 4194304,
|
||||
disk_write_bps: 2097152,
|
||||
disk_read_iops: 120,
|
||||
disk_write_iops: 48,
|
||||
disk_await_ms: 1.4,
|
||||
disk_utilization: 8.2,
|
||||
disks: [
|
||||
{
|
||||
name: "/dev/vda1",
|
||||
mount_point: "/",
|
||||
used: 21474836480,
|
||||
total: 53687091200,
|
||||
read_bps: 4194304,
|
||||
write_bps: 2097152,
|
||||
read_iops: 120,
|
||||
write_iops: 48,
|
||||
await_ms: 1.4,
|
||||
utilization: 8.2,
|
||||
},
|
||||
],
|
||||
gpus: [
|
||||
{ model: "NVIDIA T4", usage: 32.5, memory_used: 1073741824, memory_total: 17179869184 },
|
||||
],
|
||||
latency_results: [],
|
||||
};
|
||||
const sample = (offset, overrides = {}) => ({
|
||||
...structuredClone(baseSample),
|
||||
timestamp: timestamp + offset,
|
||||
...overrides,
|
||||
});
|
||||
const samples = [
|
||||
sample(0),
|
||||
sample(1, {
|
||||
latency_results: [
|
||||
{ task_id: latencyTaskId, timestamp: timestamp + 1, latency_ms: 28.4, packet_loss: 25 },
|
||||
],
|
||||
}),
|
||||
sample(2, {
|
||||
latency_results: [
|
||||
{ task_id: latencyTaskId, timestamp: timestamp + 2, latency_ms: 48.4, packet_loss: 75 },
|
||||
],
|
||||
}),
|
||||
sample(3, { net_rx_total: 2147483648, net_tx_total: 1073741824 }),
|
||||
sample(4, { net_rx_total: 268435456, net_tx_total: 134217728 }),
|
||||
sample(5, { net_rx_total: 536870912, net_tx_total: 268435456 }),
|
||||
];
|
||||
const latestTimestamp = timestamp + 5;
|
||||
const ackPromise = waitForJsonMessage(
|
||||
agent,
|
||||
"Agent metric ACK",
|
||||
(message) => message.type === "ack" && message.realtimeHint === false,
|
||||
);
|
||||
const updatePromise = waitForJsonMessage(
|
||||
dashboard,
|
||||
"dashboard batchUpdate",
|
||||
(message) =>
|
||||
message.type === "batchUpdate" &&
|
||||
message.updates?.some(
|
||||
(update) =>
|
||||
update.serverId === serverId &&
|
||||
update.samples?.some(
|
||||
(entry) => entry.ts === latestTimestamp && entry.data?.cpu === 18.5,
|
||||
),
|
||||
),
|
||||
);
|
||||
agent.send(JSON.stringify({ type: "update", batchId: `smoke-${timestamp}`, samples }));
|
||||
const [ack] = await Promise.all([ackPromise, updatePromise]);
|
||||
if (
|
||||
ack.ts <= 0 ||
|
||||
ack.persisted !== true ||
|
||||
ack.persistenceError !== false ||
|
||||
!Number.isInteger(ack.persistedThroughTs) ||
|
||||
ack.persistedThroughTs < latestTimestamp ||
|
||||
ack.nextD1WriteAfterMs !== 120_000 ||
|
||||
ack.nextWssReportAfterMs !== 5_000
|
||||
) {
|
||||
throw new Error(`Invalid Agent metric ACK: ${JSON.stringify(ack)}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await Promise.all([
|
||||
agent ? closeSocket(agent) : Promise.resolve(),
|
||||
dashboard ? closeSocket(dashboard) : Promise.resolve(),
|
||||
]);
|
||||
}
|
||||
+24
-17
@@ -1,7 +1,13 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use worker::{wasm_bindgen::JsValue, Fetch, Method, Request, RequestInit, Result};
|
||||
use worker::{wasm_bindgen::JsValue, Headers, Method, Request, RequestInit, Result};
|
||||
|
||||
use crate::outbound::{fetch_with_timeout, read_json_limited};
|
||||
|
||||
const GRAPHQL_URL: &str = "https://api.cloudflare.com/client/v4/graphql";
|
||||
const GRAPHQL_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const GRAPHQL_MAX_BYTES: usize = 1024 * 1024;
|
||||
const DURABLE_OBJECTS_WEBSOCKET_MESSAGE_BILLING_RATIO: i64 = 20;
|
||||
const USAGE_QUERY: &str = r#"
|
||||
query CloudflareUsage(
|
||||
@@ -228,13 +234,10 @@ fn summarize_durable_objects(
|
||||
summary.duration += group.sum.duration;
|
||||
}
|
||||
}
|
||||
summary.billable_requests = summary
|
||||
.http_requests
|
||||
.saturating_add(summary.hibernation_wakeups)
|
||||
.saturating_add(ceil_div_nonnegative(
|
||||
summary.inbound_websocket_messages,
|
||||
DURABLE_OBJECTS_WEBSOCKET_MESSAGE_BILLING_RATIO,
|
||||
));
|
||||
summary.billable_requests = summary.http_requests.saturating_add(ceil_div_nonnegative(
|
||||
summary.inbound_websocket_messages,
|
||||
DURABLE_OBJECTS_WEBSOCKET_MESSAGE_BILLING_RATIO,
|
||||
));
|
||||
summary
|
||||
}
|
||||
|
||||
@@ -266,19 +269,23 @@ async fn query_period(token: &str, account_id: &str, date: &str) -> Result<Usage
|
||||
end_time: &end_time,
|
||||
},
|
||||
})?;
|
||||
let headers = Headers::new();
|
||||
headers.set("Authorization", &format!("Bearer {token}"))?;
|
||||
headers.set("Content-Type", "application/json")?;
|
||||
headers.set("Accept", "application/json")?;
|
||||
let mut init = RequestInit::new();
|
||||
init.with_method(Method::Post)
|
||||
.with_headers(headers)
|
||||
.with_body(Some(JsValue::from_str(&body)));
|
||||
let request = Request::new_with_init(GRAPHQL_URL, &init)?;
|
||||
request
|
||||
.headers()
|
||||
.set("Authorization", &format!("Bearer {token}"))?;
|
||||
request.headers().set("Content-Type", "application/json")?;
|
||||
request.headers().set("Accept", "application/json")?;
|
||||
|
||||
let mut response = Fetch::Request(request).send().await?;
|
||||
let Some(mut response) = fetch_with_timeout(request, GRAPHQL_TIMEOUT).await? else {
|
||||
return Err(worker::Error::RustError(
|
||||
"Cloudflare GraphQL 请求超时".to_string(),
|
||||
));
|
||||
};
|
||||
let status = response.status_code();
|
||||
let payload: GraphQlResponse = response.json().await?;
|
||||
let payload: GraphQlResponse = read_json_limited(&mut response, GRAPHQL_MAX_BYTES).await?;
|
||||
let errors = payload.errors.unwrap_or_default();
|
||||
if !(200..300).contains(&status) || !errors.is_empty() {
|
||||
let message = errors
|
||||
@@ -390,7 +397,7 @@ mod tests {
|
||||
vec![DurableObjectsPeriodicGroup {
|
||||
sum: DurableObjectsPeriodicSum {
|
||||
duration: 2.5,
|
||||
inbound_websocket_msg_count: 21,
|
||||
inbound_websocket_msg_count: 20,
|
||||
outbound_websocket_msg_count: 11,
|
||||
},
|
||||
}],
|
||||
@@ -398,7 +405,7 @@ mod tests {
|
||||
assert_eq!(summary.http_requests, 7);
|
||||
assert_eq!(summary.hibernation_wakeups, 3);
|
||||
assert_eq!(summary.raw_requests, 10);
|
||||
assert_eq!(summary.billable_requests, 12);
|
||||
assert_eq!(summary.billable_requests, 8);
|
||||
assert_eq!(summary.outbound_websocket_messages, 11);
|
||||
assert_eq!(summary.duration, 2.5);
|
||||
assert_eq!(ceil_div_nonnegative(0, 20), 0);
|
||||
|
||||
@@ -1,22 +1,77 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::{Deserialize, Serialize, Serializer};
|
||||
use worker::{wasm_bindgen::JsValue, D1Database, Date, Result};
|
||||
use worker::{wasm_bindgen::JsValue, D1Database, D1PreparedStatement, Date, Result};
|
||||
|
||||
use crate::models::{
|
||||
AgentConfigView, AgentIdentityRow, AgentReport, AlertRuleInput, AlertRuleView, HistoryPoint,
|
||||
ServerInput, ServerView, SettingsInput, ThemeInput, ThemeView,
|
||||
};
|
||||
|
||||
pub(crate) const HISTORY_WRITE_INTERVAL_SECONDS: i64 = 120;
|
||||
pub(crate) const MAX_HISTORY_RETENTION_DAYS: i64 = 30;
|
||||
|
||||
/// 分钟粒度历史按天轮换,`metric_history` 的上一代留在 `metric_history_old`。
|
||||
/// 轮换而不是 DELETE 的理由:DELETE 每行计一行 D1 写入,DROP TABLE 不计。
|
||||
/// 代价是当前表在刚轮换后是空的,所以任何近期读取都必须走 [`RECENT_HISTORY`]。
|
||||
pub(crate) const HISTORY_ROTATION_INTERVAL_SECONDS: i64 = 86_400;
|
||||
|
||||
/// 分钟粒度历史的读取源。两代合起来覆盖 24-48 小时。
|
||||
///
|
||||
/// 轮换边界上同一分钟桶可能在两张表里各有一行(当前表刚建好、旧表还留着同
|
||||
/// 一分钟的前半段)。所有消费方都按 `sample_count` 加权聚合,两个半行合并后
|
||||
/// 与单行等价,所以重复是良性的,不需要去重。
|
||||
pub(crate) const RECENT_HISTORY: &str =
|
||||
"SELECT * FROM metric_history UNION ALL SELECT * FROM metric_history_old";
|
||||
|
||||
/// `metric_history` 及其轮换副本的表结构,两处必须完全一致。
|
||||
///
|
||||
/// 轮换要在运行时重建表,所以 DDL 不能只存在于 migration 里。
|
||||
/// `metric_history_ddl_matches_migration` 测试比对本函数与 migration 的文本,
|
||||
/// 防止两边漂移。
|
||||
pub(crate) fn metric_history_ddl(table: &str) -> String {
|
||||
format!(
|
||||
r#"CREATE TABLE {table} (
|
||||
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
timestamp INTEGER NOT NULL,
|
||||
cpu REAL NOT NULL DEFAULT 0,
|
||||
load1 REAL NOT NULL DEFAULT 0,
|
||||
load5 REAL NOT NULL DEFAULT 0,
|
||||
load15 REAL NOT NULL DEFAULT 0,
|
||||
mem_used INTEGER NOT NULL DEFAULT 0,
|
||||
mem_total INTEGER NOT NULL DEFAULT 0,
|
||||
swap_used INTEGER NOT NULL DEFAULT 0,
|
||||
swap_total INTEGER NOT NULL DEFAULT 0,
|
||||
disk_used INTEGER NOT NULL DEFAULT 0,
|
||||
disk_total INTEGER NOT NULL DEFAULT 0,
|
||||
net_in REAL NOT NULL DEFAULT 0,
|
||||
net_out REAL NOT NULL DEFAULT 0,
|
||||
net_rx_total INTEGER NOT NULL DEFAULT 0,
|
||||
net_tx_total INTEGER NOT NULL DEFAULT 0,
|
||||
processes INTEGER NOT NULL DEFAULT 0,
|
||||
tcp_connections INTEGER NOT NULL DEFAULT 0,
|
||||
udp_connections INTEGER NOT NULL DEFAULT 0,
|
||||
gpu_usage REAL NOT NULL DEFAULT 0,
|
||||
disk_read_bps REAL NOT NULL DEFAULT 0,
|
||||
disk_write_bps REAL NOT NULL DEFAULT 0,
|
||||
disk_read_iops REAL NOT NULL DEFAULT 0,
|
||||
disk_write_iops REAL NOT NULL DEFAULT 0,
|
||||
disk_await_ms REAL NOT NULL DEFAULT 0,
|
||||
disk_utilization REAL NOT NULL DEFAULT 0,
|
||||
sample_count INTEGER NOT NULL DEFAULT 1 CHECK(sample_count > 0),
|
||||
latest_timestamp INTEGER NOT NULL,
|
||||
latest_json TEXT NOT NULL CHECK(json_valid(latest_json)),
|
||||
latency_json TEXT NOT NULL DEFAULT '[]' CHECK(json_valid(latency_json)),
|
||||
PRIMARY KEY(server_id, timestamp)
|
||||
) WITHOUT ROWID"#
|
||||
)
|
||||
}
|
||||
|
||||
const SERVER_SELECT: &str = r#"
|
||||
WITH latest_state AS (
|
||||
SELECT s0.id AS server_id, COALESCE(
|
||||
(SELECT h.latest_json FROM metric_history h
|
||||
WHERE h.server_id = s0.id ORDER BY h.timestamp DESC LIMIT 1),
|
||||
(SELECT h.latest_json FROM metric_history_hourly h
|
||||
WHERE h.server_id = s0.id ORDER BY h.timestamp DESC LIMIT 1)
|
||||
) AS state
|
||||
SELECT s0.id AS server_id, l.latest_json AS state
|
||||
FROM servers s0
|
||||
LEFT JOIN server_latest_state l ON l.server_id = s0.id
|
||||
)
|
||||
SELECT
|
||||
s.id, s.name, s.region, s.group_name, s.tags, s.hidden,
|
||||
@@ -133,18 +188,6 @@ pub struct TrafficCounterState {
|
||||
pub used_tx: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TrafficCounterContext {
|
||||
configured_reset_day: i64,
|
||||
cycle_key: i64,
|
||||
traffic_reset_day: i64,
|
||||
traffic_timestamp: i64,
|
||||
raw_rx: i64,
|
||||
raw_tx: i64,
|
||||
used_rx: i64,
|
||||
used_tx: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
pub struct HistoryMetricAggregate {
|
||||
#[serde(rename = "n")]
|
||||
@@ -357,11 +400,9 @@ pub struct SettingsView {
|
||||
#[serde(serialize_with = "serialize_secret")]
|
||||
pub turnstile_secret_key: String,
|
||||
pub notification_enabled: bool,
|
||||
#[serde(serialize_with = "serialize_secret")]
|
||||
pub notification_endpoint: String,
|
||||
pub notification_target: String,
|
||||
pub offline_alert_minutes: i64,
|
||||
pub expiry_alert_days: i64,
|
||||
pub traffic_alert_percentage: i64,
|
||||
#[serde(serialize_with = "serialize_secret")]
|
||||
pub cloudflare_account_id: String,
|
||||
#[serde(serialize_with = "serialize_secret")]
|
||||
@@ -415,12 +456,6 @@ pub struct AlertMetricRow {
|
||||
pub report_interval: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AlertStateRow {
|
||||
rule_id: String,
|
||||
server_id: String,
|
||||
}
|
||||
|
||||
fn now() -> i64 {
|
||||
Date::now().as_millis() as i64 / 1000
|
||||
}
|
||||
@@ -468,6 +503,7 @@ pub(crate) fn traffic_cycle_key(timestamp: i64, reset_day: i64) -> i64 {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl TrafficCounterState {
|
||||
pub fn apply(&mut self, report: &AgentReport, configured_reset_day: i64) {
|
||||
if report.timestamp <= self.timestamp {
|
||||
@@ -501,31 +537,6 @@ impl TrafficCounterState {
|
||||
self.raw_rx = report.net_rx_total;
|
||||
self.raw_tx = report.net_tx_total;
|
||||
}
|
||||
|
||||
pub fn extend(&mut self, reports: &[AgentReport], configured_reset_day: i64) {
|
||||
let mut reports = reports.iter().collect::<Vec<_>>();
|
||||
reports.sort_by_key(|report| report.timestamp);
|
||||
for report in reports {
|
||||
self.apply(report, configured_reset_day);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TrafficCounterContext {
|
||||
fn into_parts(self) -> (TrafficCounterState, i64) {
|
||||
(
|
||||
TrafficCounterState {
|
||||
cycle_key: self.cycle_key,
|
||||
reset_day: self.traffic_reset_day,
|
||||
timestamp: self.traffic_timestamp,
|
||||
raw_rx: self.raw_rx,
|
||||
raw_tx: self.raw_tx,
|
||||
used_rx: self.used_rx,
|
||||
used_tx: self.used_tx,
|
||||
},
|
||||
self.configured_reset_day,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list_servers(db: &D1Database, include_hidden: bool) -> Result<Vec<ServerView>> {
|
||||
@@ -563,12 +574,7 @@ pub async fn agent_live_context(db: &D1Database, id: &str) -> Result<Option<Agen
|
||||
let mut context: Option<AgentLiveContext> = db
|
||||
.prepare(
|
||||
r#"WITH latest_state AS (
|
||||
SELECT COALESCE(
|
||||
(SELECT latest_json FROM metric_history
|
||||
WHERE server_id = ?1 ORDER BY timestamp DESC LIMIT 1),
|
||||
(SELECT latest_json FROM metric_history_hourly
|
||||
WHERE server_id = ?1 ORDER BY timestamp DESC LIMIT 1)
|
||||
) AS state
|
||||
SELECT latest_json AS state FROM server_latest_state WHERE server_id = ?1
|
||||
) SELECT
|
||||
s.report_interval,
|
||||
s.collect_interval,
|
||||
@@ -584,7 +590,7 @@ pub async fn agent_live_context(db: &D1Database, id: &str) -> Result<Option<Agen
|
||||
s.tx_correction,
|
||||
COALESCE(CAST(json_extract(m.state, '$.report.timestamp') AS INTEGER), 0) AS last_persisted_at
|
||||
FROM servers s
|
||||
CROSS JOIN latest_state m
|
||||
LEFT JOIN latest_state m ON true
|
||||
WHERE s.id = ?1
|
||||
LIMIT 1"#,
|
||||
)
|
||||
@@ -808,11 +814,19 @@ pub async fn list_alert_rules(db: &D1Database) -> Result<Vec<AlertRuleView>> {
|
||||
Ok(rules)
|
||||
}
|
||||
|
||||
async fn replace_alert_rule_servers(
|
||||
pub async fn has_enabled_alert_rules(db: &D1Database) -> Result<bool> {
|
||||
Ok(db
|
||||
.prepare("SELECT 1 AS present FROM alert_rules WHERE enabled=1 LIMIT 1")
|
||||
.first::<serde_json::Value>(None)
|
||||
.await?
|
||||
.is_some())
|
||||
}
|
||||
|
||||
fn alert_rule_server_statements(
|
||||
db: &D1Database,
|
||||
rule_id: &str,
|
||||
server_ids: &[String],
|
||||
) -> Result<()> {
|
||||
) -> Result<Vec<D1PreparedStatement>> {
|
||||
let mut statements = vec![db
|
||||
.prepare("DELETE FROM alert_rule_servers WHERE rule_id = ?1")
|
||||
.bind(&[text(rule_id)])?];
|
||||
@@ -820,34 +834,43 @@ async fn replace_alert_rule_servers(
|
||||
for server_id in server_ids {
|
||||
statements.push(insert.clone().bind(&[text(rule_id), text(server_id)])?);
|
||||
}
|
||||
db.batch(statements).await?;
|
||||
Ok(())
|
||||
Ok(statements)
|
||||
}
|
||||
|
||||
pub async fn create_alert_rule(db: &D1Database, id: &str, input: &AlertRuleInput) -> Result<()> {
|
||||
let timestamp = now();
|
||||
db.prepare(
|
||||
"INSERT INTO alert_rules(id, name, metric, threshold, duration_minutes, \
|
||||
let mut statements = vec![db
|
||||
.prepare(
|
||||
"INSERT INTO alert_rules(id, name, metric, threshold, duration_minutes, \
|
||||
aggregation, enabled, created_at, updated_at) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?8)",
|
||||
)
|
||||
.bind(&[
|
||||
text(id),
|
||||
text(input.name.trim()),
|
||||
text(input.metric.trim()),
|
||||
number(input.threshold),
|
||||
number(input.duration_minutes),
|
||||
text(input.aggregation.trim()),
|
||||
JsValue::from_bool(input.enabled),
|
||||
number(timestamp),
|
||||
])?
|
||||
.run()
|
||||
.await?;
|
||||
replace_alert_rule_servers(db, id, &input.server_ids).await
|
||||
)
|
||||
.bind(&[
|
||||
text(id),
|
||||
text(input.name.trim()),
|
||||
text(input.metric.trim()),
|
||||
number(input.threshold),
|
||||
number(input.duration_minutes),
|
||||
text(input.aggregation.trim()),
|
||||
JsValue::from_bool(input.enabled),
|
||||
number(timestamp),
|
||||
])?];
|
||||
statements.extend(alert_rule_server_statements(db, id, &input.server_ids)?);
|
||||
db.batch(statements).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_alert_rule(db: &D1Database, id: &str, input: &AlertRuleInput) -> Result<bool> {
|
||||
let result = db
|
||||
let exists = db
|
||||
.prepare("SELECT 1 AS present FROM alert_rules WHERE id=?1 LIMIT 1")
|
||||
.bind(&[text(id)])?
|
||||
.first::<serde_json::Value>(None)
|
||||
.await?
|
||||
.is_some();
|
||||
if !exists {
|
||||
return Ok(false);
|
||||
}
|
||||
let mut statements = vec![db
|
||||
.prepare(
|
||||
"UPDATE alert_rules SET name=?2, metric=?3, threshold=?4, duration_minutes=?5, \
|
||||
aggregation=?6, enabled=?7, updated_at=?8 WHERE id=?1",
|
||||
@@ -861,14 +884,10 @@ pub async fn update_alert_rule(db: &D1Database, id: &str, input: &AlertRuleInput
|
||||
text(input.aggregation.trim()),
|
||||
JsValue::from_bool(input.enabled),
|
||||
number(now()),
|
||||
])?
|
||||
.run()
|
||||
.await?;
|
||||
let changed = result.meta()?.and_then(|meta| meta.changes).unwrap_or(0) > 0;
|
||||
if changed {
|
||||
replace_alert_rule_servers(db, id, &input.server_ids).await?;
|
||||
}
|
||||
Ok(changed)
|
||||
])?];
|
||||
statements.extend(alert_rule_server_statements(db, id, &input.server_ids)?);
|
||||
db.batch(statements).await?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub async fn delete_alert_rule(db: &D1Database, id: &str) -> Result<bool> {
|
||||
@@ -886,65 +905,22 @@ pub(crate) fn alert_window_covered(
|
||||
current_time: i64,
|
||||
) -> bool {
|
||||
let window_seconds = duration_minutes.clamp(1, 1440) * 60;
|
||||
let report_interval = row.report_interval.clamp(15, 3600).max(60);
|
||||
let expected_samples = ((window_seconds + report_interval - 1) / report_interval).max(1);
|
||||
let sample_interval = row
|
||||
.report_interval
|
||||
.clamp(15, 3600)
|
||||
.max(HISTORY_WRITE_INTERVAL_SECONDS);
|
||||
let expected_samples = ((window_seconds + sample_interval - 1) / sample_interval).max(1);
|
||||
let required_samples = if expected_samples == 1 {
|
||||
1
|
||||
} else {
|
||||
((expected_samples * 3 + 4) / 5).max(2)
|
||||
};
|
||||
let fresh_within = (report_interval * 2).max(120);
|
||||
let fresh_within = sample_interval * 2;
|
||||
row.sample_count >= required_samples
|
||||
&& row.first_timestamp <= row.last_timestamp
|
||||
&& current_time.saturating_sub(row.last_timestamp) <= fresh_within
|
||||
}
|
||||
|
||||
pub async fn active_alert_states(db: &D1Database) -> Result<std::collections::HashSet<String>> {
|
||||
let rows: Vec<AlertStateRow> = db
|
||||
.prepare("SELECT rule_id, server_id FROM alert_states WHERE active=1")
|
||||
.all()
|
||||
.await?
|
||||
.results()?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|row| format!("{}:{}", row.rule_id, row.server_id))
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn sync_active_alert_states(
|
||||
db: &D1Database,
|
||||
previous: &std::collections::HashSet<String>,
|
||||
current: &std::collections::HashSet<String>,
|
||||
) -> Result<()> {
|
||||
let mut statements = Vec::new();
|
||||
let delete = db.prepare("DELETE FROM alert_states WHERE rule_id=?1 AND server_id=?2");
|
||||
let insert = db.prepare(
|
||||
"INSERT INTO alert_states(rule_id, server_id, active, updated_at) VALUES (?1, ?2, 1, ?3) \
|
||||
ON CONFLICT(rule_id, server_id) DO UPDATE SET active=1, updated_at=excluded.updated_at",
|
||||
);
|
||||
let timestamp = now();
|
||||
for key in previous.difference(current) {
|
||||
let Some((rule_id, server_id)) = key.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
statements.push(delete.clone().bind(&[text(rule_id), text(server_id)])?);
|
||||
}
|
||||
for key in current.difference(previous) {
|
||||
let Some((rule_id, server_id)) = key.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
statements.push(insert.clone().bind(&[
|
||||
text(rule_id),
|
||||
text(server_id),
|
||||
number(timestamp),
|
||||
])?);
|
||||
}
|
||||
if !statements.is_empty() {
|
||||
db.batch(statements).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn history(db: &D1Database, id: &str, hours: i64) -> Result<Vec<HistoryPoint>> {
|
||||
let hours = hours.clamp(1, 24 * 30);
|
||||
let since = now() - hours * 3600;
|
||||
@@ -956,9 +932,9 @@ pub async fn history(db: &D1Database, id: &str, hours: i64) -> Result<Vec<Histor
|
||||
_ => 14_400,
|
||||
};
|
||||
let source = if hours <= 24 {
|
||||
"SELECT * FROM metric_history".to_string()
|
||||
RECENT_HISTORY.to_string()
|
||||
} else {
|
||||
"SELECT * FROM metric_history UNION ALL SELECT * FROM metric_history_hourly".to_string()
|
||||
format!("{RECENT_HISTORY} UNION ALL SELECT * FROM metric_history_hourly")
|
||||
};
|
||||
let query = format!(
|
||||
r#"SELECT
|
||||
@@ -1007,79 +983,30 @@ pub(crate) fn reports_after(reports: &[AgentReport], timestamp: i64) -> Vec<Agen
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn save_reports(
|
||||
db: &D1Database,
|
||||
server_id: &str,
|
||||
reports: &[AgentReport],
|
||||
) -> Result<Option<HistoryPoint>> {
|
||||
let context: TrafficCounterContext = db
|
||||
.prepare(
|
||||
r#"WITH latest_state AS (
|
||||
SELECT COALESCE(
|
||||
(SELECT latest_json FROM metric_history
|
||||
WHERE server_id = ?1 ORDER BY timestamp DESC LIMIT 1),
|
||||
(SELECT latest_json FROM metric_history_hourly
|
||||
WHERE server_id = ?1 ORDER BY timestamp DESC LIMIT 1)
|
||||
) AS state
|
||||
) SELECT
|
||||
s.reset_day AS configured_reset_day,
|
||||
COALESCE(CAST(json_extract(m.state, '$.traffic.cycle_key') AS INTEGER), 0) AS cycle_key,
|
||||
COALESCE(CAST(json_extract(m.state, '$.traffic.reset_day') AS INTEGER), s.reset_day) AS traffic_reset_day,
|
||||
COALESCE(CAST(json_extract(m.state, '$.traffic.timestamp') AS INTEGER), 0) AS traffic_timestamp,
|
||||
COALESCE(CAST(json_extract(m.state, '$.traffic.raw_rx') AS INTEGER), 0) AS raw_rx,
|
||||
COALESCE(CAST(json_extract(m.state, '$.traffic.raw_tx') AS INTEGER), 0) AS raw_tx,
|
||||
COALESCE(CAST(json_extract(m.state, '$.traffic.used_rx') AS INTEGER), 0) AS used_rx,
|
||||
COALESCE(CAST(json_extract(m.state, '$.traffic.used_tx') AS INTEGER), 0) AS used_tx
|
||||
FROM servers s
|
||||
CROSS JOIN latest_state m
|
||||
WHERE s.id = ?1
|
||||
LIMIT 1"#,
|
||||
)
|
||||
.bind(&[text(server_id)])?
|
||||
.first(None)
|
||||
.await?
|
||||
.ok_or_else(|| worker::Error::RustError("节点不存在".to_string()))?;
|
||||
let (mut traffic, configured_reset_day) = context.into_parts();
|
||||
let fresh_reports = reports_after(reports, traffic.timestamp);
|
||||
if fresh_reports.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
pub(crate) fn valid_report_batch_id(batch_id: &str) -> bool {
|
||||
!batch_id.is_empty()
|
||||
&& batch_id.len() <= 128
|
||||
&& batch_id
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':'))
|
||||
}
|
||||
|
||||
let mut aggregate = HistoryMetricAggregate::default();
|
||||
aggregate.extend(&fresh_reports);
|
||||
let Some(history) = aggregate.point() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut latency = crate::latency::LatencyMetricAggregates::default();
|
||||
let received_at = now();
|
||||
for report in &fresh_reports {
|
||||
latency.extend(&report.latency_results, received_at);
|
||||
}
|
||||
traffic.extend(&fresh_reports, configured_reset_day);
|
||||
save_reports_with_history(
|
||||
db,
|
||||
server_id,
|
||||
&fresh_reports,
|
||||
&history,
|
||||
aggregate.sample_count(),
|
||||
&traffic,
|
||||
&latency,
|
||||
)
|
||||
.await?;
|
||||
Ok(Some(history))
|
||||
pub(crate) struct ReportBatchRef<'a> {
|
||||
pub reports: &'a [AgentReport],
|
||||
pub id: &'a str,
|
||||
}
|
||||
|
||||
pub async fn save_reports_with_history(
|
||||
db: &D1Database,
|
||||
server_id: &str,
|
||||
reports: &[AgentReport],
|
||||
batch: ReportBatchRef<'_>,
|
||||
history_point: &HistoryPoint,
|
||||
sample_count: i64,
|
||||
traffic: &TrafficCounterState,
|
||||
latency: &crate::latency::LatencyMetricAggregates,
|
||||
) -> Result<()> {
|
||||
let Some(latest_report) = reports.iter().max_by_key(|report| report.timestamp) else {
|
||||
return Ok(());
|
||||
) -> Result<bool> {
|
||||
let Some(latest_report) = batch.reports.iter().max_by_key(|report| report.timestamp) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let latest_timestamp = latest_report.timestamp;
|
||||
let mut latest_report = latest_report.clone();
|
||||
@@ -1092,7 +1019,8 @@ pub async fn save_reports_with_history(
|
||||
})
|
||||
.to_string();
|
||||
let latency_json = latency.stored_json()?;
|
||||
db.prepare(
|
||||
let batch_id = batch.id;
|
||||
let metric = db.prepare(
|
||||
r#"INSERT INTO metric_history (
|
||||
server_id, timestamp, cpu, load1, load5, load15,
|
||||
mem_used, mem_total, swap_used, swap_total, disk_used, disk_total,
|
||||
@@ -1101,10 +1029,13 @@ pub async fn save_reports_with_history(
|
||||
disk_read_bps, disk_write_bps, disk_read_iops, disk_write_iops,
|
||||
disk_await_ms, disk_utilization, sample_count,
|
||||
latest_timestamp, latest_json, latency_json
|
||||
) VALUES (
|
||||
) SELECT
|
||||
?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14,
|
||||
?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26,
|
||||
?27, ?28, ?29, ?30
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM server_latest_state
|
||||
WHERE server_id = ?1 AND last_batch_id = ?31
|
||||
) ON CONFLICT(server_id, timestamp) DO UPDATE SET
|
||||
cpu=(metric_history.cpu * metric_history.sample_count +
|
||||
excluded.cpu * excluded.sample_count) /
|
||||
@@ -1200,8 +1131,8 @@ pub async fn save_reports_with_history(
|
||||
)) FROM (SELECT * FROM latency_tasks ORDER BY task_id)
|
||||
) END
|
||||
WHERE excluded.latest_timestamp > metric_history.latest_timestamp"#,
|
||||
)
|
||||
.bind(&[
|
||||
);
|
||||
let metric = metric.bind(&[
|
||||
text(server_id),
|
||||
number(history_point.timestamp),
|
||||
number(history_point.cpu),
|
||||
@@ -1232,10 +1163,34 @@ pub async fn save_reports_with_history(
|
||||
number(latest_timestamp),
|
||||
text(&latest_json),
|
||||
text(&latency_json),
|
||||
])?
|
||||
.run()
|
||||
.await?;
|
||||
Ok(())
|
||||
text(batch_id),
|
||||
])?;
|
||||
let snapshot = db
|
||||
.prepare(
|
||||
r#"INSERT INTO server_latest_state(
|
||||
server_id, latest_timestamp, latest_json, last_batch_id
|
||||
) SELECT ?1, ?2, ?3, ?4 WHERE NOT EXISTS (
|
||||
SELECT 1 FROM server_latest_state
|
||||
WHERE server_id=?1 AND last_batch_id=?4
|
||||
) ON CONFLICT(server_id) DO UPDATE SET
|
||||
latest_timestamp=excluded.latest_timestamp,
|
||||
latest_json=excluded.latest_json,
|
||||
last_batch_id=excluded.last_batch_id
|
||||
WHERE excluded.latest_timestamp >= server_latest_state.latest_timestamp"#,
|
||||
)
|
||||
.bind(&[
|
||||
text(server_id),
|
||||
number(latest_timestamp),
|
||||
text(&latest_json),
|
||||
text(batch_id),
|
||||
])?;
|
||||
let results = db.batch(vec![metric, snapshot]).await?;
|
||||
Ok(results
|
||||
.first()
|
||||
.and_then(|result| result.meta().ok().flatten())
|
||||
.and_then(|meta| meta.changes)
|
||||
.unwrap_or(0)
|
||||
> 0)
|
||||
}
|
||||
|
||||
fn bool_setting(values: &HashMap<String, String>, key: &str, fallback: bool) -> bool {
|
||||
@@ -1300,8 +1255,9 @@ pub async fn settings(
|
||||
history_retention_days: integer_setting(
|
||||
&values,
|
||||
"history_retention_days",
|
||||
default_retention.clamp(1, 365),
|
||||
),
|
||||
default_retention.clamp(1, MAX_HISTORY_RETENTION_DAYS),
|
||||
)
|
||||
.clamp(1, MAX_HISTORY_RETENTION_DAYS),
|
||||
history_cache_version: integer_setting(&values, "history_cache_version", 0),
|
||||
default_theme: string_setting(&values, "default_theme", "system"),
|
||||
active_theme_id: string_setting(&values, "active_theme_id", crate::theme::BUILTIN_THEME_ID),
|
||||
@@ -1332,10 +1288,10 @@ pub async fn settings(
|
||||
turnstile_site_key: string_setting(&values, "turnstile_site_key", ""),
|
||||
turnstile_secret_key: string_setting(&values, "turnstile_secret_key", ""),
|
||||
notification_enabled: bool_setting(&values, "notification_enabled", false),
|
||||
notification_endpoint: string_setting(&values, "notification_endpoint", ""),
|
||||
notification_target: string_setting(&values, "notification_target", ""),
|
||||
offline_alert_minutes: integer_setting(&values, "offline_alert_minutes", 5),
|
||||
expiry_alert_days: integer_setting(&values, "expiry_alert_days", 7),
|
||||
traffic_alert_percentage: integer_setting(&values, "traffic_alert_percentage", 80)
|
||||
.clamp(50, 100),
|
||||
cloudflare_account_id: string_setting(&values, "cloudflare_account_id", ""),
|
||||
cloudflare_api_token: string_setting(&values, "cloudflare_api_token", ""),
|
||||
})
|
||||
@@ -1379,7 +1335,7 @@ pub async fn update_settings(
|
||||
push_setting!("offline_threshold_seconds", &value);
|
||||
}
|
||||
if let Some(value) = input.history_retention_days {
|
||||
let value = value.clamp(1, 365).to_string();
|
||||
let value = value.clamp(1, MAX_HISTORY_RETENTION_DAYS).to_string();
|
||||
push_setting!("history_retention_days", &value);
|
||||
}
|
||||
if let Some(value) = input.default_theme.as_deref() {
|
||||
@@ -1457,16 +1413,6 @@ pub async fn update_settings(
|
||||
if let Some(value) = input.notification_enabled {
|
||||
push_setting!("notification_enabled", if value { "true" } else { "false" });
|
||||
}
|
||||
if let Some(value) = input
|
||||
.notification_endpoint
|
||||
.as_deref()
|
||||
.filter(|value| value.trim() != SECRET_MASK)
|
||||
{
|
||||
push_setting!("notification_endpoint", value.trim());
|
||||
}
|
||||
if let Some(value) = input.notification_target.as_deref() {
|
||||
push_setting!("notification_target", value.trim());
|
||||
}
|
||||
if let Some(value) = input.offline_alert_minutes {
|
||||
let value = value.clamp(2, 1440).to_string();
|
||||
push_setting!("offline_alert_minutes", &value);
|
||||
@@ -1475,6 +1421,10 @@ pub async fn update_settings(
|
||||
let value = value.clamp(0, 365).to_string();
|
||||
push_setting!("expiry_alert_days", &value);
|
||||
}
|
||||
if let Some(value) = input.traffic_alert_percentage {
|
||||
let value = value.clamp(50, 100).to_string();
|
||||
push_setting!("traffic_alert_percentage", &value);
|
||||
}
|
||||
if let Some(value) = input
|
||||
.cloudflare_account_id
|
||||
.as_deref()
|
||||
@@ -1601,17 +1551,41 @@ pub async fn delete_theme(db: &D1Database, id: &str) -> Result<bool> {
|
||||
Ok(deleted)
|
||||
}
|
||||
|
||||
/// 把分钟历史压成小时粒度,然后轮换分钟表、按保留期删除过期小时行。
|
||||
///
|
||||
/// 压缩读两代分钟表,区间是 `[保留期下界, 当前整小时)`,小时表按覆盖语义写入
|
||||
/// (不累加,见那条 SQL 里的注释)。三者合起来让压缩天然幂等:
|
||||
///
|
||||
/// - 读两代保证轮换即将丢弃的行一定已经压过,不需要「哪一代压过了」的状态标记。
|
||||
/// - 上界卡在整小时,避免把还在写的小时压进归档。
|
||||
/// - 覆盖语义让重复压缩变成重算同一个值,跑几次结果都一样。
|
||||
///
|
||||
/// 所以这个函数崩在任何一步、下一次 cron 重跑都是对的。
|
||||
///
|
||||
/// 顺序是「压缩 + 删除过期小时行(同一个 batch)→ 轮换分钟表」。
|
||||
///
|
||||
/// 调用方(`src/lib.rs` 的 cron)已经把频率限制在一天一次,与
|
||||
/// [`HISTORY_ROTATION_INTERVAL_SECONDS`] 一致。分钟数据因此保留 24-48 小时而不
|
||||
/// 是精确 24 小时;小时数据仍然精确按 `retention_days` 删除。
|
||||
pub async fn cleanup_history(db: &D1Database, retention_days: i64) -> Result<()> {
|
||||
let (cutoff, recent_cutoff) = history_cutoffs(now(), retention_days);
|
||||
let current = now();
|
||||
let cutoff = history_cutoff(current, retention_days);
|
||||
// 只压已经走完的小时;当前这个未完成的小时留给下一次 cron。
|
||||
let complete_hours_before = current.div_euclid(3600) * 3600;
|
||||
let compact = db
|
||||
.prepare(
|
||||
r#"WITH ranked AS (
|
||||
SELECT *, (timestamp / 3600) * 3600 AS bucket,
|
||||
r#"WITH recent AS (
|
||||
-- 两代分钟表,等价于 db::RECENT_HISTORY。下面 ranked 和 latency_ranked
|
||||
-- 都从这里取,保证指标和延迟压的是同一批源行。
|
||||
SELECT * FROM metric_history UNION ALL SELECT * FROM metric_history_old
|
||||
), ranked AS (
|
||||
SELECT h.*, (h.timestamp / 3600) * 3600 AS bucket,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY server_id, timestamp / 3600
|
||||
PARTITION BY h.server_id, h.timestamp / 3600
|
||||
ORDER BY latest_timestamp DESC
|
||||
) AS latest_position
|
||||
FROM metric_history WHERE timestamp >= ?1 AND timestamp < ?2
|
||||
FROM recent h
|
||||
WHERE h.timestamp >= ?1 AND h.timestamp < ?2
|
||||
), metrics AS (
|
||||
SELECT
|
||||
server_id, bucket,
|
||||
@@ -1637,8 +1611,7 @@ pub async fn cleanup_history(db: &D1Database, retention_days: i64) -> Result<()>
|
||||
MAX(disk_await_ms) AS disk_await_ms,
|
||||
MAX(disk_utilization) AS disk_utilization,
|
||||
SUM(sample_count) AS sample_count,
|
||||
MAX(CASE WHEN latest_position = 1 THEN latest_timestamp END) AS latest_timestamp,
|
||||
MAX(CASE WHEN latest_position = 1 THEN latest_json END) AS latest_json
|
||||
MAX(CASE WHEN latest_position = 1 THEN latest_timestamp END) AS latest_timestamp
|
||||
FROM ranked GROUP BY server_id, bucket
|
||||
), latency_ranked AS (
|
||||
SELECT h.server_id, (h.timestamp / 3600) * 3600 AS bucket,
|
||||
@@ -1654,7 +1627,7 @@ pub async fn cleanup_history(db: &D1Database, retention_days: i64) -> Result<()>
|
||||
PARTITION BY h.server_id, h.timestamp / 3600, json_extract(j.value, '$.task_id')
|
||||
ORDER BY CAST(json_extract(j.value, '$.latest_timestamp') AS INTEGER) DESC
|
||||
) AS latest_position
|
||||
FROM metric_history h, json_each(h.latency_json) j
|
||||
FROM recent h, json_each(h.latency_json) j
|
||||
WHERE h.timestamp >= ?1 AND h.timestamp < ?2
|
||||
), latency_tasks AS (
|
||||
SELECT server_id, bucket, task_id,
|
||||
@@ -1691,58 +1664,73 @@ pub async fn cleanup_history(db: &D1Database, retention_days: i64) -> Result<()>
|
||||
m.net_in, m.net_out, m.net_rx_total, m.net_tx_total, m.processes,
|
||||
m.tcp_connections, m.udp_connections, m.gpu_usage, m.disk_read_bps,
|
||||
m.disk_write_bps, m.disk_read_iops, m.disk_write_iops, m.disk_await_ms,
|
||||
m.disk_utilization, m.sample_count, m.latest_timestamp, m.latest_json,
|
||||
m.disk_utilization, m.sample_count, m.latest_timestamp, '{}',
|
||||
COALESCE(l.latency_json, '[]')
|
||||
FROM metrics m LEFT JOIN latency_packed l
|
||||
ON l.server_id = m.server_id AND l.bucket = m.bucket
|
||||
WHERE true
|
||||
-- 直接覆盖,不做累加。上面的 CTE 是从两代分钟表里把这个小时桶的全部
|
||||
-- 源行重新算了一遍,所以 excluded 就是该桶的完整真值。
|
||||
-- 一个桶的源行在分钟表里可见 24-48 小时,而它在写完后的第一次 cron 就会
|
||||
-- 被压缩,所以每次压缩看到的都是完整的桶;等源行被轮换丢弃时,这个桶也
|
||||
-- 不再出现在 union 里,不会再触发 upsert。
|
||||
-- 覆盖语义顺带让压缩对乱序到达免疫:迟到的行只是让下一次重算包含它。
|
||||
ON CONFLICT(server_id, timestamp) DO UPDATE SET
|
||||
cpu=excluded.cpu, load1=excluded.load1, load5=excluded.load5,
|
||||
load15=excluded.load15, mem_used=excluded.mem_used, mem_total=excluded.mem_total,
|
||||
swap_used=excluded.swap_used, swap_total=excluded.swap_total,
|
||||
disk_used=excluded.disk_used, disk_total=excluded.disk_total,
|
||||
net_in=excluded.net_in, net_out=excluded.net_out,
|
||||
net_rx_total=excluded.net_rx_total, net_tx_total=excluded.net_tx_total,
|
||||
processes=excluded.processes, tcp_connections=excluded.tcp_connections,
|
||||
udp_connections=excluded.udp_connections, gpu_usage=excluded.gpu_usage,
|
||||
disk_read_bps=excluded.disk_read_bps, disk_write_bps=excluded.disk_write_bps,
|
||||
disk_read_iops=excluded.disk_read_iops, disk_write_iops=excluded.disk_write_iops,
|
||||
disk_await_ms=excluded.disk_await_ms, disk_utilization=excluded.disk_utilization,
|
||||
load15=excluded.load15, mem_used=excluded.mem_used,
|
||||
mem_total=excluded.mem_total, swap_used=excluded.swap_used,
|
||||
swap_total=excluded.swap_total, disk_used=excluded.disk_used,
|
||||
disk_total=excluded.disk_total, net_in=excluded.net_in,
|
||||
net_out=excluded.net_out, net_rx_total=excluded.net_rx_total,
|
||||
net_tx_total=excluded.net_tx_total, processes=excluded.processes,
|
||||
tcp_connections=excluded.tcp_connections,
|
||||
udp_connections=excluded.udp_connections,
|
||||
gpu_usage=excluded.gpu_usage, disk_read_bps=excluded.disk_read_bps,
|
||||
disk_write_bps=excluded.disk_write_bps,
|
||||
disk_read_iops=excluded.disk_read_iops,
|
||||
disk_write_iops=excluded.disk_write_iops,
|
||||
disk_await_ms=excluded.disk_await_ms,
|
||||
disk_utilization=excluded.disk_utilization,
|
||||
sample_count=excluded.sample_count,
|
||||
latest_timestamp=excluded.latest_timestamp, latest_json=excluded.latest_json,
|
||||
latest_timestamp=excluded.latest_timestamp,
|
||||
latest_json=excluded.latest_json,
|
||||
latency_json=excluded.latency_json"#,
|
||||
)
|
||||
.bind(&[number(cutoff), number(recent_cutoff)])?;
|
||||
let delete_recent = db
|
||||
.prepare(
|
||||
"DELETE FROM metric_history AS h WHERE timestamp < ?1 AND timestamp < ( \
|
||||
SELECT MAX(newest.timestamp) FROM metric_history AS newest \
|
||||
WHERE newest.server_id = h.server_id \
|
||||
)",
|
||||
)
|
||||
.bind(&[number(recent_cutoff)])?;
|
||||
.bind(&[number(cutoff), number(complete_hours_before)])?;
|
||||
let delete_archive = db
|
||||
.prepare("DELETE FROM metric_history_hourly WHERE timestamp < ?1")
|
||||
.bind(&[number(cutoff)])?;
|
||||
db.batch(vec![compact, delete_recent, delete_archive])
|
||||
// DDL 不和上面的语句放进同一个 batch:batch 是隐式事务,而 SQLite 不允许
|
||||
// 在事务里 ALTER TABLE ... RENAME。压缩+删除先提交,轮换单独走;轮换中途
|
||||
// 失败下一次 cron 重来即可,重复压缩无害(见上)。
|
||||
db.batch(vec![compact, delete_archive]).await?;
|
||||
rotate_recent_history(db).await
|
||||
}
|
||||
|
||||
/// 丢弃上一代分钟历史,把当前代降级为上一代,再建一张空的当前代。
|
||||
async fn rotate_recent_history(db: &D1Database) -> Result<()> {
|
||||
db.prepare("DROP TABLE IF EXISTS metric_history_old")
|
||||
.run()
|
||||
.await?;
|
||||
db.prepare("ALTER TABLE metric_history RENAME TO metric_history_old")
|
||||
.run()
|
||||
.await?;
|
||||
db.prepare(metric_history_ddl("metric_history"))
|
||||
.run()
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn history_cutoffs(current: i64, retention_days: i64) -> (i64, i64) {
|
||||
let cutoff = current - retention_days.clamp(1, 365) * 86_400;
|
||||
let recent_cutoff = (current - 86_400).max(cutoff);
|
||||
(cutoff, recent_cutoff)
|
||||
/// 保留期下界,对齐到整小时。
|
||||
fn history_cutoff(current: i64, retention_days: i64) -> i64 {
|
||||
let cutoff = current - retention_days.clamp(1, MAX_HISTORY_RETENTION_DAYS) * 86_400;
|
||||
cutoff.div_euclid(3600) * 3600
|
||||
}
|
||||
|
||||
pub async fn clear_history(db: &D1Database) -> Result<()> {
|
||||
db.batch(vec![
|
||||
db.prepare(
|
||||
"DELETE FROM metric_history AS h WHERE timestamp < ( \
|
||||
SELECT MAX(newest.timestamp) FROM metric_history AS newest \
|
||||
WHERE newest.server_id = h.server_id \
|
||||
)",
|
||||
),
|
||||
db.prepare("DELETE FROM metric_history"),
|
||||
db.prepare("DELETE FROM metric_history_old"),
|
||||
db.prepare("DELETE FROM metric_history_hourly"),
|
||||
])
|
||||
.await?;
|
||||
@@ -1814,20 +1802,25 @@ pub async fn database_stats(db: &D1Database, offline_threshold: i64) -> Result<D
|
||||
r#"SELECT
|
||||
(SELECT COUNT(*) FROM servers) AS server_count,
|
||||
(SELECT COUNT(*) FROM servers s WHERE COALESCE(
|
||||
(SELECT latest_timestamp FROM metric_history h
|
||||
WHERE h.server_id = s.id ORDER BY timestamp DESC LIMIT 1),
|
||||
(SELECT MAX(latest_timestamp) FROM (
|
||||
SELECT server_id, latest_timestamp FROM metric_history UNION ALL
|
||||
SELECT server_id, latest_timestamp FROM metric_history_old
|
||||
) h WHERE h.server_id = s.id),
|
||||
(SELECT latest_timestamp FROM metric_history_hourly h
|
||||
WHERE h.server_id = s.id ORDER BY timestamp DESC LIMIT 1),
|
||||
0
|
||||
) >= ?1) AS online_count,
|
||||
((SELECT COUNT(*) FROM metric_history) +
|
||||
(SELECT COUNT(*) FROM metric_history_old) +
|
||||
(SELECT COUNT(*) FROM metric_history_hourly)) AS history_rows,
|
||||
(SELECT MIN(timestamp) FROM (
|
||||
SELECT timestamp FROM metric_history UNION ALL
|
||||
SELECT timestamp FROM metric_history_old UNION ALL
|
||||
SELECT timestamp FROM metric_history_hourly
|
||||
)) AS oldest_history,
|
||||
(SELECT MAX(timestamp) FROM (
|
||||
SELECT timestamp FROM metric_history UNION ALL
|
||||
SELECT timestamp FROM metric_history_old UNION ALL
|
||||
SELECT timestamp FROM metric_history_hourly
|
||||
)) AS newest_history"#,
|
||||
)
|
||||
@@ -1875,8 +1868,9 @@ pub async fn update_expiry(db: &D1Database, id: &str, expires_at: i64) -> Result
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
alert_window_covered, secret_for_api, traffic_cycle_key, AlertMetricRow,
|
||||
HistoryMetricAggregate, TrafficCounterState, SECRET_MASK,
|
||||
alert_window_covered, history_cutoff, metric_history_ddl, secret_for_api,
|
||||
traffic_cycle_key, AlertMetricRow, HistoryMetricAggregate, TrafficCounterState,
|
||||
MAX_HISTORY_RETENTION_DAYS, SECRET_MASK,
|
||||
};
|
||||
use crate::models::AgentReport;
|
||||
|
||||
@@ -1895,9 +1889,9 @@ mod tests {
|
||||
#[test]
|
||||
fn alert_coverage_tracks_report_interval_and_freshness() {
|
||||
let current = 10_000;
|
||||
assert!(alert_window_covered(&row(6, current - 30, 60), 10, current));
|
||||
assert!(alert_window_covered(&row(3, current - 30, 60), 10, current));
|
||||
assert!(!alert_window_covered(
|
||||
&row(5, current - 30, 60),
|
||||
&row(2, current - 30, 60),
|
||||
10,
|
||||
current
|
||||
));
|
||||
@@ -1913,6 +1907,48 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aligns_history_cutoff_and_caps_retention() {
|
||||
let current = 40 * 86_400 + 12 * 3600 + 34 * 60 + 56;
|
||||
let cutoff = history_cutoff(current, 365);
|
||||
assert_eq!(cutoff % 3600, 0);
|
||||
assert_eq!(
|
||||
cutoff,
|
||||
(40 - MAX_HISTORY_RETENTION_DAYS) * 86_400 + 12 * 3600
|
||||
);
|
||||
}
|
||||
|
||||
/// 轮换要在运行时重建 `metric_history`,DDL 因此在 Rust 里存了一份。
|
||||
/// 这个测试比对它和 migration 的文本,防止两边漂移。三张 metric_* 表必须同构:
|
||||
/// 轮换靠 RENAME 把当前代变成上一代,压缩靠 UNION ALL 同时读它们。
|
||||
#[test]
|
||||
fn metric_history_ddl_matches_migration() {
|
||||
let schema = include_str!("../migrations/initial_01.sql");
|
||||
|
||||
let table_body = |sql: &str, table: &str| -> String {
|
||||
let head = format!("CREATE TABLE {table} (");
|
||||
let start = sql.find(&head).unwrap_or_else(|| {
|
||||
panic!("migration 里找不到 {table}");
|
||||
}) + head.len();
|
||||
let rest = &sql[start..];
|
||||
let end = rest.find(") WITHOUT ROWID").expect("缺少 WITHOUT ROWID");
|
||||
rest[..end].split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
};
|
||||
|
||||
let generated = table_body(&metric_history_ddl("metric_history"), "metric_history");
|
||||
for table in [
|
||||
"metric_history",
|
||||
"metric_history_old",
|
||||
"metric_history_hourly",
|
||||
] {
|
||||
assert_eq!(
|
||||
generated,
|
||||
table_body(schema, table),
|
||||
"migration 的 {table} 与 db::metric_history_ddl 不一致"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masks_stored_secrets_in_api_responses() {
|
||||
assert_eq!(secret_for_api(""), "");
|
||||
|
||||
+10
-8
@@ -2,9 +2,9 @@ use std::{collections::BTreeMap, time::Duration};
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use worker::{D1Database, Error, Method, Request, Result};
|
||||
use worker::{D1Database, Error, Headers, Method, Request, RequestInit, Result};
|
||||
|
||||
use crate::outbound::fetch_with_timeout;
|
||||
use crate::outbound::{fetch_with_timeout, read_json_limited};
|
||||
|
||||
use crate::{cloudflare, db};
|
||||
|
||||
@@ -13,6 +13,7 @@ const PRIMARY_URL: &str = "https://open.er-api.com/v6/latest/CNY";
|
||||
const FALLBACK_URL: &str = "https://api.frankfurter.dev/v1/latest?base=CNY";
|
||||
const REFRESH_INTERVAL: i64 = 86_400;
|
||||
const RETRY_INTERVAL: i64 = 3_600;
|
||||
const UPSTREAM_JSON_MAX_BYTES: usize = 256 * 1024;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct FetchedRates {
|
||||
@@ -133,11 +134,12 @@ fn parse_er_api(value: &Value) -> Option<FetchedRates> {
|
||||
}
|
||||
|
||||
async fn fetch_json(url: &str) -> Result<Value> {
|
||||
let request = Request::new(url, Method::Get)?;
|
||||
request.headers().set("Accept", "application/json")?;
|
||||
request
|
||||
.headers()
|
||||
.set("User-Agent", "NodeFlare-Exchange-Rates")?;
|
||||
let headers = Headers::new();
|
||||
headers.set("Accept", "application/json")?;
|
||||
headers.set("User-Agent", "NodeFlare-Exchange-Rates")?;
|
||||
let mut init = RequestInit::new();
|
||||
init.with_method(Method::Get).with_headers(headers);
|
||||
let request = Request::new_with_init(url, &init)?;
|
||||
let Some(mut response) = fetch_with_timeout(request, Duration::from_secs(8)).await? else {
|
||||
return Err(Error::RustError(
|
||||
"exchange-rate upstream request timed out".to_string(),
|
||||
@@ -149,7 +151,7 @@ async fn fetch_json(url: &str) -> Result<Value> {
|
||||
"exchange-rate upstream returned HTTP {status}"
|
||||
)));
|
||||
}
|
||||
response.json().await
|
||||
read_json_limited(&mut response, UPSTREAM_JSON_MAX_BYTES).await
|
||||
}
|
||||
|
||||
async fn fetch_latest() -> Result<FetchedRates> {
|
||||
|
||||
+43
-38
@@ -201,6 +201,21 @@ pub async fn task_count(db: &D1Database) -> Result<i64> {
|
||||
.ok_or_else(|| worker::Error::RustError("无法读取延迟任务数量".to_string()))
|
||||
}
|
||||
|
||||
pub async fn server_ids_for_task(db: &D1Database, task_id: &str) -> Result<Vec<String>> {
|
||||
let assignments: Vec<AssignmentRow> = db
|
||||
.prepare(
|
||||
"SELECT task_id, server_id FROM latency_task_servers WHERE task_id = ?1 ORDER BY server_id ASC",
|
||||
)
|
||||
.bind(&[text(task_id)])?
|
||||
.all()
|
||||
.await?
|
||||
.results()?;
|
||||
Ok(assignments
|
||||
.into_iter()
|
||||
.map(|assignment| assignment.server_id)
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn tasks_for_server(db: &D1Database, server_id: &str) -> Result<Vec<AgentLatencyTask>> {
|
||||
db.prepare(
|
||||
"SELECT t.id, t.name, t.task_type, t.target, t.port, t.interval_seconds \
|
||||
@@ -342,14 +357,6 @@ pub async fn latest_all(db: &D1Database) -> Result<Vec<LatencySample>> {
|
||||
db.prepare(latest_query(None)).all().await?.results()
|
||||
}
|
||||
|
||||
pub async fn latest_for_server(db: &D1Database, server_id: &str) -> Result<Vec<LatencySample>> {
|
||||
db.prepare(latest_query(Some("a.server_id = ?1")))
|
||||
.bind(&[text(server_id)])?
|
||||
.all()
|
||||
.await?
|
||||
.results()
|
||||
}
|
||||
|
||||
fn latest_query(filter: Option<&str>) -> String {
|
||||
let filter = filter.map_or(String::new(), |value| format!("WHERE {value}"));
|
||||
format!(
|
||||
@@ -360,7 +367,9 @@ fn latest_query(filter: Option<&str>) -> String {
|
||||
INNER JOIN latency_tasks t ON t.id = a.task_id {filter} \
|
||||
), latest AS ( \
|
||||
SELECT assignments.*, COALESCE( \
|
||||
(SELECT j.value FROM metric_history h, json_each(h.latency_json) j \
|
||||
(SELECT j.value FROM (SELECT * FROM metric_history \
|
||||
UNION ALL SELECT * FROM metric_history_old) h, \
|
||||
json_each(h.latency_json) j \
|
||||
WHERE h.server_id = assignments.server_id \
|
||||
AND json_extract(j.value, '$.task_id') = assignments.task_id \
|
||||
AND CAST(json_extract(j.value, '$.latest_timestamp') AS INTEGER) >= assignments.assigned_at \
|
||||
@@ -379,8 +388,21 @@ fn latest_query(filter: Option<&str>) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
/// 把一张历史表的 `latency_json` 摊平成延迟样本行。
|
||||
fn latency_samples_from(table: &str) -> String {
|
||||
format!(
|
||||
"SELECT h.server_id, h.timestamp, json_extract(j.value, '$.task_id') AS task_id, \
|
||||
CAST(json_extract(j.value, '$.latency_ms') AS REAL) AS latency_ms, \
|
||||
CAST(json_extract(j.value, '$.packet_loss') AS REAL) AS packet_loss, \
|
||||
CAST(json_extract(j.value, '$.sample_count') AS INTEGER) AS sample_count, \
|
||||
CAST(json_extract(j.value, '$.success_count') AS INTEGER) AS success_count, \
|
||||
CAST(json_extract(j.value, '$.latest_timestamp') AS INTEGER) AS latest_timestamp \
|
||||
FROM {table} h, json_each(h.latency_json) j"
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn history(db: &D1Database, server_id: &str, hours: i64) -> Result<Vec<LatencySample>> {
|
||||
let hours = hours.clamp(1, 24 * 365);
|
||||
let hours = hours.clamp(1, 24 * crate::db::MAX_HISTORY_RETENTION_DAYS);
|
||||
let cutoff = (Date::now().as_millis() as i64 / 1000) - hours * 3600;
|
||||
let task_count = db
|
||||
.prepare("SELECT COUNT(*) AS count FROM latency_task_servers WHERE server_id = ?1")
|
||||
@@ -392,33 +414,16 @@ pub async fn history(db: &D1Database, server_id: &str, hours: i64) -> Result<Vec
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let bucket = history_bucket_seconds(hours, task_count);
|
||||
let source = if hours <= 24 {
|
||||
"SELECT h.server_id, h.timestamp, json_extract(j.value, '$.task_id') AS task_id, \
|
||||
CAST(json_extract(j.value, '$.latency_ms') AS REAL) AS latency_ms, \
|
||||
CAST(json_extract(j.value, '$.packet_loss') AS REAL) AS packet_loss, \
|
||||
CAST(json_extract(j.value, '$.sample_count') AS INTEGER) AS sample_count, \
|
||||
CAST(json_extract(j.value, '$.success_count') AS INTEGER) AS success_count, \
|
||||
CAST(json_extract(j.value, '$.latest_timestamp') AS INTEGER) AS latest_timestamp \
|
||||
FROM metric_history h, json_each(h.latency_json) j"
|
||||
.to_string()
|
||||
} else {
|
||||
"SELECT h.server_id, h.timestamp, json_extract(j.value, '$.task_id') AS task_id, \
|
||||
CAST(json_extract(j.value, '$.latency_ms') AS REAL) AS latency_ms, \
|
||||
CAST(json_extract(j.value, '$.packet_loss') AS REAL) AS packet_loss, \
|
||||
CAST(json_extract(j.value, '$.sample_count') AS INTEGER) AS sample_count, \
|
||||
CAST(json_extract(j.value, '$.success_count') AS INTEGER) AS success_count, \
|
||||
CAST(json_extract(j.value, '$.latest_timestamp') AS INTEGER) AS latest_timestamp \
|
||||
FROM metric_history h, json_each(h.latency_json) j \
|
||||
UNION ALL \
|
||||
SELECT h.server_id, h.timestamp, json_extract(j.value, '$.task_id') AS task_id, \
|
||||
CAST(json_extract(j.value, '$.latency_ms') AS REAL) AS latency_ms, \
|
||||
CAST(json_extract(j.value, '$.packet_loss') AS REAL) AS packet_loss, \
|
||||
CAST(json_extract(j.value, '$.sample_count') AS INTEGER) AS sample_count, \
|
||||
CAST(json_extract(j.value, '$.success_count') AS INTEGER) AS success_count, \
|
||||
CAST(json_extract(j.value, '$.latest_timestamp') AS INTEGER) AS latest_timestamp \
|
||||
FROM metric_history_hourly h, json_each(h.latency_json) j"
|
||||
.to_string()
|
||||
};
|
||||
// 分钟历史分两代(见 db::RECENT_HISTORY),近期区间必须都读。
|
||||
let mut tables = vec!["metric_history", "metric_history_old"];
|
||||
if hours > 24 {
|
||||
tables.push("metric_history_hourly");
|
||||
}
|
||||
let source = tables
|
||||
.into_iter()
|
||||
.map(latency_samples_from)
|
||||
.collect::<Vec<_>>()
|
||||
.join(" UNION ALL ");
|
||||
let query = format!(
|
||||
"SELECT h.task_id, h.server_id, t.name, t.task_type, t.target, t.port, \
|
||||
(h.timestamp / {bucket}) * {bucket} AS timestamp, \
|
||||
@@ -441,7 +446,7 @@ pub async fn history(db: &D1Database, server_id: &str, hours: i64) -> Result<Vec
|
||||
}
|
||||
|
||||
fn history_bucket_seconds(hours: i64, task_count: i64) -> i64 {
|
||||
let hours = hours.clamp(1, 24 * 365);
|
||||
let hours = hours.clamp(1, 24 * crate::db::MAX_HISTORY_RETENTION_DAYS);
|
||||
let task_count = task_count.clamp(1, MAX_LATENCY_TASKS as i64);
|
||||
let base = match hours {
|
||||
1 => 60,
|
||||
|
||||
+69
-39
@@ -13,6 +13,7 @@ mod turnstile;
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::net::Ipv4Addr;
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_util::TryStreamExt;
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
@@ -30,9 +31,9 @@ pub(crate) const ADMIN_HTML: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/a
|
||||
pub(crate) const ADMIN_SCRIPT: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/admin.js"));
|
||||
pub(crate) const ADMIN_STYLE: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/admin.css"));
|
||||
pub(crate) const API_JSON_MAX_BYTES: usize = 1024 * 1024;
|
||||
pub(crate) const AGENT_JSON_MAX_BYTES: usize = 16 * 1024 * 1024;
|
||||
pub(crate) const MAX_AGENT_SAMPLES: usize = 720;
|
||||
pub(crate) const MAX_AGENT_LATENCY_RESULTS: usize = 4096;
|
||||
const SERVER_RENEWAL_INTERVAL_SECONDS: i64 = 60 * 60;
|
||||
const REMOTE_THEME_FETCH_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
pub(crate) const VERSION: &str = match option_env!("NODEFLARE_VERSION") {
|
||||
Some(version) => version,
|
||||
None => env!("CARGO_PKG_VERSION"),
|
||||
@@ -342,9 +343,10 @@ async fn remote_theme_response(path: &str, base: &str) -> Result<Option<Response
|
||||
return Ok(Some(secure_public_response(mutable_response(response)?)?));
|
||||
}
|
||||
let request = Request::new(&remote_url, Method::Get)?;
|
||||
let mut response = match Fetch::Request(request).send().await {
|
||||
Ok(response) if (200..300).contains(&response.status_code()) => response,
|
||||
Ok(response) => {
|
||||
let mut response = match outbound::fetch_with_timeout(request, REMOTE_THEME_FETCH_TIMEOUT).await
|
||||
{
|
||||
Ok(Some(response)) if (200..300).contains(&response.status_code()) => response,
|
||||
Ok(Some(response)) => {
|
||||
console_warn!(
|
||||
"remote theme returned HTTP {} for {}",
|
||||
response.status_code(),
|
||||
@@ -356,6 +358,14 @@ async fn remote_theme_response(path: &str, base: &str) -> Result<Option<Response
|
||||
Ok(Some(remote_theme_failure("远程主题资源暂时不可用")?))
|
||||
};
|
||||
}
|
||||
Ok(None) => {
|
||||
console_warn!("remote theme request timed out for {remote_url}");
|
||||
return if is_index {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(remote_theme_failure("远程主题资源请求超时")?))
|
||||
};
|
||||
}
|
||||
Err(error) => {
|
||||
console_warn!("remote theme request failed for {remote_url}: {error}");
|
||||
return if is_index {
|
||||
@@ -370,7 +380,7 @@ async fn remote_theme_response(path: &str, base: &str) -> Result<Option<Response
|
||||
} else {
|
||||
theme::ASSET_MAX_BYTES
|
||||
};
|
||||
let Some(body) = theme::read_response_limited(&mut response, limit).await? else {
|
||||
let Some(body) = outbound::read_response_limited(&mut response, limit).await? else {
|
||||
return if is_index {
|
||||
Ok(None)
|
||||
} else {
|
||||
@@ -415,11 +425,15 @@ async fn remote_theme_preview_response(
|
||||
return remote_theme_failure("远程主题来源不受支持");
|
||||
};
|
||||
let request = Request::new(&index, Method::Get)?;
|
||||
let mut response = Fetch::Request(request).send().await?;
|
||||
let Some(mut response) =
|
||||
outbound::fetch_with_timeout(request, REMOTE_THEME_FETCH_TIMEOUT).await?
|
||||
else {
|
||||
return remote_theme_failure("主题预览页面请求超时");
|
||||
};
|
||||
if !(200..300).contains(&response.status_code()) {
|
||||
return remote_theme_failure("主题预览页面暂时不可用");
|
||||
}
|
||||
let Some(body) = theme::read_response_limited(&mut response, theme::INDEX_MAX_BYTES).await?
|
||||
let Some(body) = outbound::read_response_limited(&mut response, theme::INDEX_MAX_BYTES).await?
|
||||
else {
|
||||
return remote_theme_failure("主题预览页面大小无效");
|
||||
};
|
||||
@@ -759,7 +773,10 @@ pub(crate) fn validate_report(report: &AgentReport) -> Option<&'static str> {
|
||||
report.disk_await_ms,
|
||||
report.disk_utilization,
|
||||
];
|
||||
if floats.iter().any(|value| !value.is_finite()) {
|
||||
if floats
|
||||
.iter()
|
||||
.any(|value| !value.is_finite() || *value < 0.0)
|
||||
{
|
||||
return Some("指标包含非法数值");
|
||||
}
|
||||
if !(0.0..=100.0).contains(&report.cpu) || !(0.0..=100.0).contains(&report.gpu_usage) {
|
||||
@@ -783,6 +800,12 @@ pub(crate) fn validate_report(report: &AgentReport) -> Option<&'static str> {
|
||||
if counters.iter().any(|value| *value < 0) {
|
||||
return Some("计数指标不能为负数");
|
||||
}
|
||||
if report.mem_used > report.mem_total
|
||||
|| report.swap_used > report.swap_total
|
||||
|| report.disk_used > report.disk_total
|
||||
{
|
||||
return Some("已用资源不能超过总量");
|
||||
}
|
||||
if report.gpu_model.chars().count() > 240 {
|
||||
return Some("GPU 型号字段过长");
|
||||
}
|
||||
@@ -796,6 +819,7 @@ pub(crate) fn validate_report(report: &AgentReport) -> Option<&'static str> {
|
||||
|| disk.mount_point.chars().count() > 240
|
||||
|| disk.used < 0
|
||||
|| disk.total < 0
|
||||
|| disk.used > disk.total
|
||||
|| ![
|
||||
disk.read_bps,
|
||||
disk.write_bps,
|
||||
@@ -811,6 +835,7 @@ pub(crate) fn validate_report(report: &AgentReport) -> Option<&'static str> {
|
||||
gpu.model.chars().count() > 240
|
||||
|| gpu.memory_used < 0
|
||||
|| gpu.memory_total < 0
|
||||
|| gpu.memory_used > gpu.memory_total
|
||||
|| gpu
|
||||
.usage
|
||||
.is_some_and(|usage| !usage.is_finite() || !(0.0..=100.0).contains(&usage))
|
||||
@@ -932,9 +957,7 @@ pub(crate) fn client_ip(req: &Request) -> Option<String> {
|
||||
fn rate_limit_binding(method: Method, path: &str) -> Option<&'static str> {
|
||||
if method == Method::Post && matches!(path, "/api/admin/login" | "/api/turnstile/verify") {
|
||||
Some("AUTH_RATE_LIMITER")
|
||||
} else if (method == Method::Post && path == "/api/agent/report")
|
||||
|| (method == Method::Get && matches!(path, "/api/agent/live" | "/api/agent/config"))
|
||||
{
|
||||
} else if method == Method::Get && path == "/api/agent/ws" {
|
||||
Some("AGENT_RATE_LIMITER")
|
||||
} else if path.starts_with("/api/") {
|
||||
Some("API_RATE_LIMITER")
|
||||
@@ -963,7 +986,8 @@ fn agent_rate_limit_key(req: &Request) -> String {
|
||||
}
|
||||
|
||||
fn allow_on_rate_limit_failure(binding: &str) -> bool {
|
||||
binding != "AUTH_RATE_LIMITER"
|
||||
let _ = binding;
|
||||
false
|
||||
}
|
||||
|
||||
async fn request_within_rate_limit(env: &Env, binding: &str, key: String) -> bool {
|
||||
@@ -1070,7 +1094,7 @@ pub(crate) fn server_id(path: &str, prefix: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle(req: Request, env: Env, ctx: Context) -> Result<Response> {
|
||||
async fn handle(req: Request, env: Env) -> Result<Response> {
|
||||
let method = req.method();
|
||||
let path = req.path();
|
||||
|
||||
@@ -1082,19 +1106,14 @@ async fn handle(req: Request, env: Env, ctx: Context) -> Result<Response> {
|
||||
|
||||
let database = env.d1("DB")?;
|
||||
|
||||
if method == Method::Post && path == "/api/agent/report" {
|
||||
return routes::agent::report(req, env, ctx, &database).await;
|
||||
}
|
||||
if method == Method::Get && path == "/api/agent/config" {
|
||||
return routes::agent::config(&req, &database).await;
|
||||
}
|
||||
if method == Method::Get && path == "/api/agent/live" {
|
||||
return routes::agent::live_websocket(req, &env, &database).await;
|
||||
if method == Method::Get && path == "/api/agent/ws" {
|
||||
return routes::agent::websocket(req, &env, &database).await;
|
||||
}
|
||||
|
||||
let default_name = env_text(&env, "SITE_NAME", "NodeFlare");
|
||||
let default_threshold = env_number(&env, "OFFLINE_THRESHOLD_SECONDS", 180).clamp(30, 3600);
|
||||
let default_retention = env_number(&env, "HISTORY_RETENTION_DAYS", 30).clamp(1, 365);
|
||||
let default_retention =
|
||||
env_number(&env, "HISTORY_RETENTION_DAYS", 30).clamp(1, db::MAX_HISTORY_RETENTION_DAYS);
|
||||
let default_username = env_text(&env, "ADMIN_USERNAME", "");
|
||||
let settings = db::settings(
|
||||
&database,
|
||||
@@ -1165,7 +1184,7 @@ async fn handle(req: Request, env: Env, ctx: Context) -> Result<Response> {
|
||||
secure_public_response(mutable_response(response)?)
|
||||
}
|
||||
#[event(fetch, respond_with_errors)]
|
||||
async fn fetch(req: Request, env: Env, ctx: Context) -> Result<Response> {
|
||||
async fn fetch(req: Request, env: Env, _ctx: Context) -> Result<Response> {
|
||||
let method = req.method();
|
||||
let method_name = format!("{method:?}");
|
||||
let path = req.path();
|
||||
@@ -1193,7 +1212,7 @@ async fn fetch(req: Request, env: Env, ctx: Context) -> Result<Response> {
|
||||
}
|
||||
}
|
||||
|
||||
match handle(req, env, ctx).await {
|
||||
match handle(req, env).await {
|
||||
Ok(response) => Ok(response),
|
||||
Err(err) => {
|
||||
console_error!(
|
||||
@@ -1218,7 +1237,8 @@ async fn scheduled(_event: ScheduledEvent, env: Env, _ctx: ScheduleContext) {
|
||||
};
|
||||
let default_name = env_text(&env, "SITE_NAME", "NodeFlare");
|
||||
let default_threshold = env_number(&env, "OFFLINE_THRESHOLD_SECONDS", 180).clamp(30, 3600);
|
||||
let default_retention = env_number(&env, "HISTORY_RETENTION_DAYS", 30).clamp(1, 365);
|
||||
let default_retention =
|
||||
env_number(&env, "HISTORY_RETENTION_DAYS", 30).clamp(1, db::MAX_HISTORY_RETENTION_DAYS);
|
||||
let default_username = env_text(&env, "ADMIN_USERNAME", "");
|
||||
let settings = db::settings(
|
||||
&database,
|
||||
@@ -1239,12 +1259,15 @@ async fn scheduled(_event: ScheduledEvent, env: Env, _ctx: ScheduleContext) {
|
||||
.flatten()
|
||||
.and_then(|value| value.parse::<i64>().ok())
|
||||
.unwrap_or(0);
|
||||
if current - last_cleanup >= 86_400 {
|
||||
if current.saturating_sub(last_cleanup) >= db::HISTORY_ROTATION_INTERVAL_SECONDS {
|
||||
if let Err(err) = db::cleanup_history(&database, retention).await {
|
||||
console_error!("history cleanup failed: {err}");
|
||||
} else {
|
||||
let _ = db::save_setting(&database, "last_history_cleanup", ¤t.to_string()).await;
|
||||
}
|
||||
if let Err(err) = notify::cleanup(&database, current).await {
|
||||
console_error!("notification cleanup failed: {err}");
|
||||
}
|
||||
}
|
||||
match exchange::refresh(&database, current, false).await {
|
||||
Ok((rates, true)) => console_log!(
|
||||
@@ -1255,13 +1278,28 @@ async fn scheduled(_event: ScheduledEvent, env: Env, _ctx: ScheduleContext) {
|
||||
Ok((_, false)) => {}
|
||||
Err(err) => console_error!("exchange-rate refresh failed: {err}"),
|
||||
}
|
||||
if let Err(err) = notify::renew_servers(&database).await {
|
||||
console_error!("server renewal failed: {err}");
|
||||
let last_server_renewal = db::get_setting(&database, "last_server_renewal")
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|value| value.parse::<i64>().ok())
|
||||
.unwrap_or(0);
|
||||
if current.saturating_sub(last_server_renewal) >= SERVER_RENEWAL_INTERVAL_SECONDS {
|
||||
if let Err(err) = notify::renew_servers(&database).await {
|
||||
console_error!("server renewal failed: {err}");
|
||||
} else {
|
||||
let _ = db::save_setting(&database, "last_server_renewal", ¤t.to_string()).await;
|
||||
}
|
||||
}
|
||||
if let Ok(settings) = settings {
|
||||
if let Err(err) = notify::check_alerts(&database, &env, &settings).await {
|
||||
console_error!("alert check failed: {err}");
|
||||
}
|
||||
if settings.notification_enabled {
|
||||
if let Err(err) = notify::process_pending(&database, current).await {
|
||||
console_error!("notification delivery processing failed: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1284,15 +1322,7 @@ mod tests {
|
||||
Some("AUTH_RATE_LIMITER")
|
||||
);
|
||||
assert_eq!(
|
||||
rate_limit_binding(Method::Post, "/api/agent/report"),
|
||||
Some("AGENT_RATE_LIMITER")
|
||||
);
|
||||
assert_eq!(
|
||||
rate_limit_binding(Method::Get, "/api/agent/live"),
|
||||
Some("AGENT_RATE_LIMITER")
|
||||
);
|
||||
assert_eq!(
|
||||
rate_limit_binding(Method::Get, "/api/agent/config"),
|
||||
rate_limit_binding(Method::Get, "/api/agent/ws"),
|
||||
Some("AGENT_RATE_LIMITER")
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -1301,7 +1331,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(rate_limit_binding(Method::Get, "/"), None);
|
||||
assert!(!allow_on_rate_limit_failure("AUTH_RATE_LIMITER"));
|
||||
assert!(allow_on_rate_limit_failure("API_RATE_LIMITER"));
|
||||
assert!(!allow_on_rate_limit_failure("API_RATE_LIMITER"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+329
-129
@@ -1,15 +1,21 @@
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashSet;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use worker::*;
|
||||
|
||||
use crate::db::{AgentLiveContext, AlertMetricRow, HistoryMetricAggregate};
|
||||
use crate::db::{
|
||||
AgentLiveContext, AlertMetricRow, HistoryMetricAggregate, HISTORY_WRITE_INTERVAL_SECONDS,
|
||||
};
|
||||
use crate::latency::LatencyMetricAggregates;
|
||||
use crate::models::{AgentReport, AlertRuleView, HistoryPoint, ServerView};
|
||||
use crate::models::{AgentConfigView, AgentReport, AlertRuleView, HistoryPoint, ServerView};
|
||||
|
||||
const MAX_LIVE_SAMPLES: usize = 720;
|
||||
const MAX_LIVE_LATENCY_RESULTS: usize = 4096;
|
||||
const AGENT_MESSAGE_WINDOW_SECONDS: i64 = 60;
|
||||
const MAX_AGENT_MESSAGES_PER_WINDOW: u32 = 120;
|
||||
const MAX_AGENT_SAMPLES_PER_WINDOW: u32 = 1440;
|
||||
const MAX_AGENT_BYTES_PER_WINDOW: u32 = 4 * 1024 * 1024;
|
||||
const LIVE_REPORT_DIVISOR: i64 = 15;
|
||||
const MAX_SERIALIZED_ATTACHMENT_BYTES: usize = 15 * 1024;
|
||||
const MAX_CACHED_LIVE_SAMPLES: usize = 32;
|
||||
@@ -20,8 +26,11 @@ const CACHED_LIVE_TTL_SECONDS: i64 = 5 * 60;
|
||||
const ALERT_WINDOW_SECONDS: i64 = 24 * 60 * 60;
|
||||
const MAX_ALERT_WINDOW_SAMPLES: usize = 24 * 60 + 1;
|
||||
const ALERT_STORAGE_PREFIX: &str = "resource-alert:";
|
||||
const ALERT_COLLECTION_STATE_KEY: &str = "resource-alert-enabled-until";
|
||||
const ALERT_COLLECTION_LEASE_SECONDS: i64 = 10 * 60;
|
||||
const DASHBOARD_HUB_NAME: &str = "dashboard";
|
||||
|
||||
fn server_hub_name(server_id: &str) -> String {
|
||||
format!("server:{server_id}")
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
struct AlertWindowSample(i64, u32, f64, f64, f64, f64, f64);
|
||||
@@ -165,6 +174,8 @@ fn evaluate_alert_window(
|
||||
struct AgentLiveBatch {
|
||||
#[serde(rename = "type")]
|
||||
message_type: String,
|
||||
#[serde(rename = "batchId")]
|
||||
batch_id: String,
|
||||
samples: Vec<AgentReport>,
|
||||
}
|
||||
|
||||
@@ -198,6 +209,45 @@ struct CachedLiveSample {
|
||||
data: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
struct AgentMessageBudget {
|
||||
#[serde(rename = "w")]
|
||||
window_started_at: i64,
|
||||
#[serde(rename = "m")]
|
||||
messages: u32,
|
||||
#[serde(rename = "s")]
|
||||
samples: u32,
|
||||
#[serde(rename = "b")]
|
||||
bytes: u32,
|
||||
}
|
||||
|
||||
fn consume_agent_message_budget(
|
||||
budget: &mut AgentMessageBudget,
|
||||
now: i64,
|
||||
message_bytes: usize,
|
||||
sample_count: usize,
|
||||
) -> bool {
|
||||
if budget.window_started_at <= 0
|
||||
|| now < budget.window_started_at
|
||||
|| now.saturating_sub(budget.window_started_at) >= AGENT_MESSAGE_WINDOW_SECONDS
|
||||
{
|
||||
*budget = AgentMessageBudget {
|
||||
window_started_at: now,
|
||||
..AgentMessageBudget::default()
|
||||
};
|
||||
}
|
||||
budget.messages = budget.messages.saturating_add(1);
|
||||
budget.samples = budget
|
||||
.samples
|
||||
.saturating_add(u32::try_from(sample_count).unwrap_or(u32::MAX));
|
||||
budget.bytes = budget
|
||||
.bytes
|
||||
.saturating_add(u32::try_from(message_bytes).unwrap_or(u32::MAX));
|
||||
budget.messages <= MAX_AGENT_MESSAGES_PER_WINDOW
|
||||
&& budget.samples <= MAX_AGENT_SAMPLES_PER_WINDOW
|
||||
&& budget.bytes <= MAX_AGENT_BYTES_PER_WINDOW
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
struct SocketAttachment {
|
||||
#[serde(rename = "r")]
|
||||
@@ -212,6 +262,8 @@ struct SocketAttachment {
|
||||
collect_interval: i64,
|
||||
#[serde(rename = "dw")]
|
||||
last_d1_write_at: i64,
|
||||
#[serde(rename = "pw")]
|
||||
persisted_through: i64,
|
||||
#[serde(rename = "df")]
|
||||
d1_flush: Option<D1FlushSnapshot>,
|
||||
#[serde(rename = "tr")]
|
||||
@@ -224,6 +276,8 @@ struct SocketAttachment {
|
||||
latest_samples: Vec<CachedLiveSample>,
|
||||
#[serde(rename = "lr")]
|
||||
latest_received_at: i64,
|
||||
#[serde(rename = "mb")]
|
||||
message_budget: AgentMessageBudget,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
@@ -247,12 +301,18 @@ fn begin_d1_flush(attachment: &mut SocketAttachment) -> Option<D1FlushSnapshot>
|
||||
Some(snapshot)
|
||||
}
|
||||
|
||||
fn finish_d1_flush(attachment: &mut SocketAttachment, succeeded: bool, persisted_at: i64) {
|
||||
fn finish_d1_flush(
|
||||
attachment: &mut SocketAttachment,
|
||||
succeeded: bool,
|
||||
persisted_at: i64,
|
||||
persisted_through: i64,
|
||||
) {
|
||||
let Some(snapshot) = attachment.d1_flush.take() else {
|
||||
return;
|
||||
};
|
||||
if succeeded {
|
||||
attachment.last_d1_write_at = persisted_at;
|
||||
attachment.persisted_through = attachment.persisted_through.max(persisted_through);
|
||||
} else {
|
||||
attachment.history_aggregate.merge(snapshot.history);
|
||||
attachment.latency_aggregates.merge(snapshot.latency);
|
||||
@@ -275,7 +335,7 @@ fn next_d1_write_ms(attachment: &SocketAttachment, now: i64) -> i64 {
|
||||
if attachment.d1_flush.is_some() {
|
||||
return 0;
|
||||
}
|
||||
let interval = attachment.report_interval.clamp(15, 3600);
|
||||
let interval = HISTORY_WRITE_INTERVAL_SECONDS;
|
||||
if attachment.last_d1_write_at <= 0 {
|
||||
return interval * 1000;
|
||||
}
|
||||
@@ -290,6 +350,8 @@ fn next_d1_write_ms(attachment: &SocketAttachment, now: i64) -> i64 {
|
||||
fn live_ack_payload(
|
||||
timestamp: i64,
|
||||
persisted: bool,
|
||||
persistence_error: bool,
|
||||
persisted_through: i64,
|
||||
next_d1_write_after_ms: i64,
|
||||
next_wss_report_after_ms: i64,
|
||||
realtime_hint: bool,
|
||||
@@ -298,12 +360,22 @@ fn live_ack_payload(
|
||||
"type": "ack",
|
||||
"ts": timestamp,
|
||||
"persisted": persisted,
|
||||
"persistenceError": persistence_error,
|
||||
"persistedThroughTs": persisted_through,
|
||||
"nextD1WriteAfterMs": next_d1_write_after_ms,
|
||||
"nextWssReportAfterMs": next_wss_report_after_ms,
|
||||
"realtimeHint": realtime_hint
|
||||
}))?)
|
||||
}
|
||||
|
||||
fn agent_config_payload(timestamp: i64, config: &AgentConfigView) -> Result<String> {
|
||||
Ok(serde_json::to_string(&serde_json::json!({
|
||||
"type": "config",
|
||||
"ts": timestamp,
|
||||
"config": config
|
||||
}))?)
|
||||
}
|
||||
|
||||
fn apply_live_traffic(report: &mut AgentReport, traffic: &mut TrafficAttachment) {
|
||||
if report.timestamp <= traffic.timestamp {
|
||||
report.net_rx_total = traffic.used_rx.saturating_add(traffic.rx_correction).max(0);
|
||||
@@ -443,6 +515,7 @@ fn agent_attachment(
|
||||
report_interval: context.report_interval.clamp(15, 3600),
|
||||
collect_interval: context.collect_interval.clamp(1, 60),
|
||||
last_d1_write_at: context.last_persisted_at,
|
||||
persisted_through: context.last_persisted_at,
|
||||
d1_flush: None,
|
||||
traffic: Some(TrafficAttachment {
|
||||
reset_day: context.reset_day.clamp(1, 31),
|
||||
@@ -460,6 +533,7 @@ fn agent_attachment(
|
||||
latency_aggregates: LatencyMetricAggregates::default(),
|
||||
latest_samples: Vec::new(),
|
||||
latest_received_at: 0,
|
||||
message_budget: AgentMessageBudget::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -496,12 +570,14 @@ fn dashboard_attachment() -> SocketAttachment {
|
||||
report_interval: 0,
|
||||
collect_interval: 0,
|
||||
last_d1_write_at: 0,
|
||||
persisted_through: 0,
|
||||
d1_flush: None,
|
||||
traffic: None,
|
||||
history_aggregate: HistoryMetricAggregate::default(),
|
||||
latency_aggregates: LatencyMetricAggregates::default(),
|
||||
latest_samples: Vec::new(),
|
||||
latest_received_at: 0,
|
||||
message_budget: AgentMessageBudget::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -509,37 +585,53 @@ fn dashboard_attachment() -> SocketAttachment {
|
||||
pub struct LiveHub {
|
||||
state: State,
|
||||
env: Env,
|
||||
alert_collection_until: Cell<i64>,
|
||||
active_d1_flushes: RefCell<HashSet<String>>,
|
||||
}
|
||||
|
||||
impl LiveHub {
|
||||
async fn alert_collection_enabled(&self, current_time: i64) -> Result<bool> {
|
||||
let mut enabled_until = self.alert_collection_until.get();
|
||||
if enabled_until < 0 {
|
||||
enabled_until = self
|
||||
.state
|
||||
.storage()
|
||||
.get::<i64>(ALERT_COLLECTION_STATE_KEY)
|
||||
.await?
|
||||
.unwrap_or(0);
|
||||
self.alert_collection_until.set(enabled_until);
|
||||
async fn broadcast_to_global_dashboard(&self, server_id: &str, payload: &str) -> bool {
|
||||
let result = async {
|
||||
let namespace = self.env.durable_object("LIVE_HUB")?;
|
||||
let stub = namespace.id_from_name(DASHBOARD_HUB_NAME)?.get_stub()?;
|
||||
let headers = Headers::new();
|
||||
headers.set("X-Server-ID", server_id)?;
|
||||
let mut init = RequestInit::new();
|
||||
init.with_method(Method::Post)
|
||||
.with_headers(headers)
|
||||
.with_body(Some(worker::wasm_bindgen::JsValue::from_str(payload)));
|
||||
let req = Request::new_with_init("https://live.internal/push", &init)?;
|
||||
let mut response = stub.fetch_with_request(req).await?;
|
||||
ensure_live_success(&response, "global-push")?;
|
||||
response.json::<bool>().await
|
||||
}
|
||||
.await;
|
||||
match result {
|
||||
Ok(active) => active,
|
||||
Err(error) => {
|
||||
console_warn!("global live broadcast failed for {server_id}: {error:?}");
|
||||
false
|
||||
}
|
||||
}
|
||||
Ok(enabled_until >= current_time)
|
||||
}
|
||||
|
||||
async fn configure_alert_collection(&self, enabled: bool, current_time: i64) -> Result<()> {
|
||||
let enabled_until = if enabled {
|
||||
current_time.saturating_add(ALERT_COLLECTION_LEASE_SECONDS)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
self.state
|
||||
.storage()
|
||||
.put(ALERT_COLLECTION_STATE_KEY, enabled_until)
|
||||
async fn record_global_alert_point(&self, server_id: &str, point: &HistoryPoint) {
|
||||
let result = async {
|
||||
let response = post_live_action(
|
||||
&self.env,
|
||||
"record-alert-sample",
|
||||
"record-alert-sample",
|
||||
&serde_json::to_string(&AlertRecordRequest {
|
||||
server_id: server_id.to_string(),
|
||||
point: point.clone(),
|
||||
})?,
|
||||
)
|
||||
.await?;
|
||||
self.alert_collection_until.set(enabled_until);
|
||||
Ok(())
|
||||
ensure_live_success(&response, "record-alert-sample")
|
||||
}
|
||||
.await;
|
||||
if let Err(error) = result {
|
||||
console_warn!("global alert window write failed for {server_id}: {error:?}");
|
||||
}
|
||||
}
|
||||
|
||||
fn dashboard_active_for(&self, server_id: &str) -> bool {
|
||||
@@ -570,6 +662,8 @@ impl LiveHub {
|
||||
let Ok(payload) = live_ack_payload(
|
||||
now,
|
||||
false,
|
||||
false,
|
||||
attachment.persisted_through,
|
||||
next_d1_write_ms(&attachment, now),
|
||||
agent_wss_interval_ms(&attachment, realtime_active),
|
||||
true,
|
||||
@@ -645,9 +739,6 @@ impl LiveHub {
|
||||
"invalid alert server identity".to_string(),
|
||||
));
|
||||
}
|
||||
if !self.alert_collection_enabled(crate::now()).await? {
|
||||
return Ok(());
|
||||
}
|
||||
let key = alert_storage_key(server_id);
|
||||
let storage = self.state.storage();
|
||||
let mut samples = storage
|
||||
@@ -663,8 +754,6 @@ impl LiveHub {
|
||||
request: &AlertEvaluationRequest,
|
||||
) -> Result<Vec<AlertEvaluationValue>> {
|
||||
let enabled = request.rules.iter().any(|rule| rule.enabled);
|
||||
self.configure_alert_collection(enabled, request.current_time)
|
||||
.await?;
|
||||
if !enabled {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
@@ -713,7 +802,6 @@ impl DurableObject for LiveHub {
|
||||
Self {
|
||||
state,
|
||||
env,
|
||||
alert_collection_until: Cell::new(-1),
|
||||
active_d1_flushes: RefCell::new(HashSet::new()),
|
||||
}
|
||||
}
|
||||
@@ -748,7 +836,6 @@ impl DurableObject for LiveHub {
|
||||
}
|
||||
Some("clear-alert-windows") => {
|
||||
self.state.storage().delete_all().await?;
|
||||
self.alert_collection_until.set(-1);
|
||||
return Response::empty();
|
||||
}
|
||||
Some("remove-alert-samples") => {
|
||||
@@ -766,6 +853,9 @@ impl DurableObject for LiveHub {
|
||||
_ => {}
|
||||
}
|
||||
let payload = req.text().await?;
|
||||
if payload.len() > 1024 * 1024 {
|
||||
return Response::error("live broadcast payload too large", 413);
|
||||
}
|
||||
let server_id = req.headers().get("X-Server-ID")?.unwrap_or_default();
|
||||
let mut sockets = self.state.get_websockets_with_tag("all");
|
||||
if !server_id.is_empty() {
|
||||
@@ -774,12 +864,13 @@ impl DurableObject for LiveHub {
|
||||
.get_websockets_with_tag(&format!("server:{server_id}")),
|
||||
);
|
||||
}
|
||||
let dashboard_active = !sockets.is_empty();
|
||||
for socket in sockets {
|
||||
if socket.send_with_str(&payload).is_err() {
|
||||
let _ = socket.close(Some(1011), Some("send failed"));
|
||||
}
|
||||
}
|
||||
return Response::empty();
|
||||
return Response::from_json(&dashboard_active);
|
||||
}
|
||||
|
||||
let is_upgrade = req
|
||||
@@ -804,14 +895,23 @@ impl DurableObject for LiveHub {
|
||||
Ok(context) => context,
|
||||
Err(_) => return Response::error("Invalid live Agent context", 400),
|
||||
};
|
||||
let database = self.env.d1("DB")?;
|
||||
let Some(config) = crate::db::agent_config(&database, &server_id).await? else {
|
||||
return Response::error("Agent server not found", 404);
|
||||
};
|
||||
let config_payload = agent_config_payload(crate::now(), &config)?;
|
||||
let agent_tag = format!("agent:{server_id}");
|
||||
for existing in self.state.get_websockets_with_tag(&agent_tag) {
|
||||
let _ = existing.close(Some(1008), Some("replaced by a newer Agent connection"));
|
||||
}
|
||||
pair.server.serialize_attachment(agent_attachment(
|
||||
server_id.clone(),
|
||||
hidden,
|
||||
context,
|
||||
))?;
|
||||
let agent_tag = format!("agent:{server_id}");
|
||||
self.state
|
||||
.accept_websocket_with_tags(&pair.server, &["agents", agent_tag.as_str()]);
|
||||
pair.server.send_with_str(&config_payload)?;
|
||||
return Response::from_websocket(pair.client);
|
||||
}
|
||||
let server_id = req
|
||||
@@ -838,10 +938,13 @@ impl DurableObject for LiveHub {
|
||||
return Ok(());
|
||||
};
|
||||
if attachment.role != "agent" {
|
||||
return Ok(());
|
||||
return ws.close(Some(1008), Some("dashboard messages are not accepted"));
|
||||
}
|
||||
let WebSocketIncomingMessage::String(message) = message else {
|
||||
return Ok(());
|
||||
return ws.close(
|
||||
Some(1003),
|
||||
Some("binary live metric payload is not accepted"),
|
||||
);
|
||||
};
|
||||
if message.len() > 1024 * 1024 {
|
||||
return ws.close(Some(1009), Some("live metric payload too large"));
|
||||
@@ -852,10 +955,19 @@ impl DurableObject for LiveHub {
|
||||
if batch.message_type != "update"
|
||||
|| batch.samples.is_empty()
|
||||
|| batch.samples.len() > MAX_LIVE_SAMPLES
|
||||
|| !crate::db::valid_report_batch_id(&batch.batch_id)
|
||||
{
|
||||
return ws.close(Some(1007), Some("invalid live metric batch"));
|
||||
}
|
||||
let received_at = crate::now();
|
||||
if !consume_agent_message_budget(
|
||||
&mut attachment.message_budget,
|
||||
received_at,
|
||||
message.len(),
|
||||
batch.samples.len(),
|
||||
) {
|
||||
return ws.close(Some(1008), Some("live metric message rate exceeded"));
|
||||
}
|
||||
let mut latency_result_count = 0usize;
|
||||
for report in &batch.samples {
|
||||
if report.timestamp <= 0
|
||||
@@ -873,6 +985,8 @@ impl DurableObject for LiveHub {
|
||||
let Some(server_id) = attachment.server_id.clone() else {
|
||||
return ws.close(Some(1008), Some("missing server identity"));
|
||||
};
|
||||
let unpersisted_reports =
|
||||
crate::db::reports_after(&batch.samples, attachment.persisted_through);
|
||||
let mut traffic = attachment
|
||||
.traffic
|
||||
.take()
|
||||
@@ -905,10 +1019,10 @@ impl DurableObject for LiveHub {
|
||||
.extend(&latency_results, received_at);
|
||||
}
|
||||
|
||||
let report_interval = attachment.report_interval.clamp(15, 3600);
|
||||
let due_for_d1 = received_at.saturating_sub(attachment.last_d1_write_at) >= report_interval;
|
||||
let due_for_d1 = received_at.saturating_sub(attachment.last_d1_write_at)
|
||||
>= HISTORY_WRITE_INTERVAL_SECONDS;
|
||||
let flush_active = self.active_d1_flushes.borrow().contains(&server_id);
|
||||
let mut flush = if !fresh_reports.is_empty()
|
||||
let mut flush = if !unpersisted_reports.is_empty()
|
||||
&& (due_for_d1 || attachment.d1_flush.is_some())
|
||||
&& !flush_active
|
||||
{
|
||||
@@ -924,6 +1038,7 @@ impl DurableObject for LiveHub {
|
||||
ws.serialize_attachment(&attachment)?;
|
||||
}
|
||||
|
||||
let mut global_dashboard_active = false;
|
||||
if !fresh_reports.is_empty() && !attachment.hidden {
|
||||
let mut sockets = self.state.get_websockets_with_tag("all");
|
||||
sockets.extend(
|
||||
@@ -935,9 +1050,13 @@ impl DurableObject for LiveHub {
|
||||
let _ = dashboard.close(Some(1011), Some("send failed"));
|
||||
}
|
||||
}
|
||||
global_dashboard_active = self
|
||||
.broadcast_to_global_dashboard(&server_id, &payload)
|
||||
.await;
|
||||
}
|
||||
|
||||
let mut persisted = false;
|
||||
let mut persistence_error = false;
|
||||
let mut attachment_serialized = false;
|
||||
if let Some((database, snapshot)) = flush.take() {
|
||||
self.active_d1_flushes
|
||||
@@ -951,19 +1070,23 @@ impl DurableObject for LiveHub {
|
||||
let metrics_result = crate::db::save_reports_with_history(
|
||||
&database,
|
||||
&server_id,
|
||||
&fresh_reports,
|
||||
crate::db::ReportBatchRef {
|
||||
reports: &unpersisted_reports,
|
||||
id: &batch.batch_id,
|
||||
},
|
||||
&history_point,
|
||||
sample_count,
|
||||
&traffic_state,
|
||||
&snapshot.latency,
|
||||
)
|
||||
.await;
|
||||
let write_succeeded = match metrics_result {
|
||||
let (write_succeeded, newly_written) = match metrics_result {
|
||||
Err(error) => {
|
||||
console_warn!("live D1 write failed for {server_id}: {error:?}");
|
||||
false
|
||||
persistence_error = true;
|
||||
(false, false)
|
||||
}
|
||||
Ok(()) => true,
|
||||
Ok(inserted) => (true, inserted),
|
||||
};
|
||||
self.active_d1_flushes.borrow_mut().remove(&server_id);
|
||||
|
||||
@@ -976,7 +1099,12 @@ impl DurableObject for LiveHub {
|
||||
{
|
||||
return ws.close(Some(1011), Some("invalid live state after D1 write"));
|
||||
}
|
||||
finish_d1_flush(&mut current_attachment, write_succeeded, received_at);
|
||||
finish_d1_flush(
|
||||
&mut current_attachment,
|
||||
write_succeeded,
|
||||
received_at,
|
||||
traffic_state.timestamp,
|
||||
);
|
||||
if !trim_socket_attachment(&mut current_attachment) {
|
||||
return ws.close(Some(1011), Some("live state exceeds attachment limit"));
|
||||
}
|
||||
@@ -985,20 +1113,36 @@ impl DurableObject for LiveHub {
|
||||
attachment_serialized = true;
|
||||
persisted = write_succeeded;
|
||||
|
||||
if write_succeeded {
|
||||
if let Err(error) = self.record_alert_point(&server_id, &history_point).await {
|
||||
console_warn!("live alert window write failed for {server_id}: {error:?}");
|
||||
if newly_written {
|
||||
match crate::db::has_enabled_alert_rules(&database).await {
|
||||
Ok(true) => {
|
||||
self.record_global_alert_point(&server_id, &history_point)
|
||||
.await;
|
||||
}
|
||||
Ok(false) => {}
|
||||
Err(error) => {
|
||||
console_warn!("enabled alert rule check failed for {server_id}: {error:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let realtime_active = !attachment.hidden && self.dashboard_active_for(&server_id);
|
||||
let realtime_active = !attachment.hidden
|
||||
&& (global_dashboard_active || self.dashboard_active_for(&server_id));
|
||||
let next_wss_ms = agent_wss_interval_ms(&attachment, realtime_active);
|
||||
let next_d1_ms = if persisted {
|
||||
report_interval * 1000
|
||||
HISTORY_WRITE_INTERVAL_SECONDS * 1000
|
||||
} else {
|
||||
next_d1_write_ms(&attachment, received_at)
|
||||
};
|
||||
let ack = live_ack_payload(received_at, persisted, next_d1_ms, next_wss_ms, false)?;
|
||||
let ack = live_ack_payload(
|
||||
received_at,
|
||||
persisted,
|
||||
persistence_error,
|
||||
attachment.persisted_through,
|
||||
next_d1_ms,
|
||||
next_wss_ms,
|
||||
false,
|
||||
)?;
|
||||
if !attachment_serialized {
|
||||
if !trim_socket_attachment(&mut attachment) {
|
||||
return ws.close(Some(1011), Some("live state exceeds attachment limit"));
|
||||
@@ -1034,23 +1178,27 @@ fn outbound_close_code(code: usize) -> Option<u16> {
|
||||
|
||||
async fn post_live_action(env: &Env, path: &str, action: &str, body: &str) -> Result<Response> {
|
||||
let namespace = env.durable_object("LIVE_HUB")?;
|
||||
let stub = namespace.id_from_name("dashboard")?.get_stub()?;
|
||||
let stub = namespace.id_from_name(DASHBOARD_HUB_NAME)?.get_stub()?;
|
||||
let headers = Headers::new();
|
||||
headers.set("Content-Type", "application/json")?;
|
||||
headers.set("X-Live-Action", action)?;
|
||||
let mut init = RequestInit::new();
|
||||
init.with_method(Method::Post)
|
||||
.with_headers(headers)
|
||||
.with_body(Some(worker::wasm_bindgen::JsValue::from_str(body)));
|
||||
let req = Request::new_with_init(&format!("https://live.internal/{path}"), &init)?;
|
||||
req.headers().set("Content-Type", "application/json")?;
|
||||
req.headers().set("X-Live-Action", action)?;
|
||||
stub.fetch_with_request(req).await
|
||||
}
|
||||
|
||||
pub async fn record_alert_sample(env: &Env, server_id: &str, point: &HistoryPoint) -> Result<()> {
|
||||
let body = serde_json::to_string(&AlertRecordRequest {
|
||||
server_id: server_id.to_string(),
|
||||
point: point.clone(),
|
||||
})?;
|
||||
post_live_action(env, "record-alert-sample", "record-alert-sample", &body).await?;
|
||||
Ok(())
|
||||
fn ensure_live_success(response: &Response, action: &str) -> Result<()> {
|
||||
let status = response.status_code();
|
||||
if (200..300).contains(&status) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::RustError(format!(
|
||||
"live action {action} returned HTTP {status}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn evaluate_resource_alerts(
|
||||
@@ -1079,11 +1227,14 @@ pub async fn evaluate_resource_alerts(
|
||||
&serde_json::to_string(&request)?,
|
||||
)
|
||||
.await?;
|
||||
ensure_live_success(&response, "evaluate-alerts")?;
|
||||
response.json::<Vec<AlertEvaluationValue>>().await
|
||||
}
|
||||
|
||||
pub async fn clear_alert_windows(env: &Env) -> Result<()> {
|
||||
post_live_action(env, "clear-alert-windows", "clear-alert-windows", "{}").await?;
|
||||
let response =
|
||||
post_live_action(env, "clear-alert-windows", "clear-alert-windows", "{}").await?;
|
||||
ensure_live_success(&response, "clear-alert-windows")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1091,26 +1242,29 @@ pub async fn remove_alert_samples(env: &Env, server_ids: &[String]) -> Result<()
|
||||
if server_ids.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
post_live_action(
|
||||
let response = post_live_action(
|
||||
env,
|
||||
"remove-alert-samples",
|
||||
"remove-alert-samples",
|
||||
&serde_json::to_string(server_ids)?,
|
||||
)
|
||||
.await?;
|
||||
ensure_live_success(&response, "remove-alert-samples")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn broadcast(env: &Env, server_id: &str, payload: &str) -> Result<()> {
|
||||
let namespace = env.durable_object("LIVE_HUB")?;
|
||||
let stub = namespace.id_from_name("dashboard")?.get_stub()?;
|
||||
fn copied_request_headers(req: &Request) -> Result<Headers> {
|
||||
let headers = Headers::new();
|
||||
for (name, value) in req.headers().entries() {
|
||||
headers.append(&name, &value)?;
|
||||
}
|
||||
Ok(headers)
|
||||
}
|
||||
|
||||
fn request_with_headers(req: &Request, headers: Headers) -> Result<Request> {
|
||||
let mut init = RequestInit::new();
|
||||
init.with_method(Method::Post)
|
||||
.with_body(Some(worker::wasm_bindgen::JsValue::from_str(payload)));
|
||||
let req = Request::new_with_init("https://live.internal/push", &init)?;
|
||||
req.headers().set("X-Server-ID", server_id)?;
|
||||
stub.fetch_with_request(req).await?;
|
||||
Ok(())
|
||||
init.with_method(req.method()).with_headers(headers);
|
||||
Request::new_with_init(req.url()?.as_str(), &init)
|
||||
}
|
||||
|
||||
pub async fn disconnect_agents(env: &Env, server_ids: &[String]) -> Result<()> {
|
||||
@@ -1118,25 +1272,41 @@ pub async fn disconnect_agents(env: &Env, server_ids: &[String]) -> Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
let namespace = env.durable_object("LIVE_HUB")?;
|
||||
let stub = namespace.id_from_name("dashboard")?.get_stub()?;
|
||||
let mut init = RequestInit::new();
|
||||
init.with_method(Method::Post)
|
||||
.with_body(Some(worker::wasm_bindgen::JsValue::from_str(
|
||||
&serde_json::to_string(server_ids)?,
|
||||
)));
|
||||
let req = Request::new_with_init("https://live.internal/disconnect-agents", &init)?;
|
||||
req.headers().set("Content-Type", "application/json")?;
|
||||
req.headers().set("X-Live-Action", "disconnect-agents")?;
|
||||
stub.fetch_with_request(req).await?;
|
||||
for server_id in server_ids {
|
||||
let stub = namespace
|
||||
.id_from_name(&server_hub_name(server_id))?
|
||||
.get_stub()?;
|
||||
let headers = Headers::new();
|
||||
headers.set("Content-Type", "application/json")?;
|
||||
headers.set("X-Live-Action", "disconnect-agents")?;
|
||||
let mut init = RequestInit::new();
|
||||
init.with_method(Method::Post)
|
||||
.with_headers(headers)
|
||||
.with_body(Some(worker::wasm_bindgen::JsValue::from_str(
|
||||
&serde_json::to_string(std::slice::from_ref(server_id))?,
|
||||
)));
|
||||
let req = Request::new_with_init("https://live.internal/disconnect-agents", &init)?;
|
||||
let response = stub.fetch_with_request(req).await?;
|
||||
ensure_live_success(&response, "disconnect-agents")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn upgrade(req: Request, env: &Env) -> Result<Response> {
|
||||
let namespace = env.durable_object("LIVE_HUB")?;
|
||||
let stub = namespace.id_from_name("dashboard")?.get_stub()?;
|
||||
let req = req.clone_mut()?;
|
||||
req.headers().delete("X-Live-Agent-ID")?;
|
||||
req.headers().delete("X-Live-Agent-Hidden")?;
|
||||
let server_id = req
|
||||
.url()?
|
||||
.query_pairs()
|
||||
.find_map(|(key, value)| (key == "server_id").then(|| value.to_string()))
|
||||
.filter(|value| !value.is_empty() && value.len() <= 80 && !value.contains('/'));
|
||||
let hub_name = server_id
|
||||
.as_deref()
|
||||
.map(server_hub_name)
|
||||
.unwrap_or_else(|| DASHBOARD_HUB_NAME.to_string());
|
||||
let stub = namespace.id_from_name(&hub_name)?.get_stub()?;
|
||||
let headers = copied_request_headers(&req)?;
|
||||
headers.delete("X-Live-Agent-ID")?;
|
||||
headers.delete("X-Live-Agent-Hidden")?;
|
||||
for header in [
|
||||
"X-Live-Report-Interval",
|
||||
"X-Live-Collect-Interval",
|
||||
@@ -1152,8 +1322,9 @@ pub async fn upgrade(req: Request, env: &Env) -> Result<Response> {
|
||||
"X-Live-Tx-Correction",
|
||||
"X-Live-Last-Persisted-At",
|
||||
] {
|
||||
req.headers().delete(header)?;
|
||||
headers.delete(header)?;
|
||||
}
|
||||
let req = request_with_headers(&req, headers)?;
|
||||
stub.fetch_with_request(req).await
|
||||
}
|
||||
|
||||
@@ -1165,48 +1336,42 @@ pub async fn upgrade_agent(
|
||||
context: AgentLiveContext,
|
||||
) -> Result<Response> {
|
||||
let namespace = env.durable_object("LIVE_HUB")?;
|
||||
let stub = namespace.id_from_name("dashboard")?.get_stub()?;
|
||||
let req = req.clone_mut()?;
|
||||
req.headers().set("X-Live-Agent-ID", server_id)?;
|
||||
req.headers()
|
||||
.set("X-Live-Agent-Hidden", if hidden { "1" } else { "0" })?;
|
||||
req.headers().set(
|
||||
let stub = namespace
|
||||
.id_from_name(&server_hub_name(server_id))?
|
||||
.get_stub()?;
|
||||
let headers = copied_request_headers(&req)?;
|
||||
headers.set("X-Live-Agent-ID", server_id)?;
|
||||
headers.set("X-Live-Agent-Hidden", if hidden { "1" } else { "0" })?;
|
||||
headers.set(
|
||||
"X-Live-Report-Interval",
|
||||
&context.report_interval.to_string(),
|
||||
)?;
|
||||
req.headers().set(
|
||||
headers.set(
|
||||
"X-Live-Collect-Interval",
|
||||
&context.collect_interval.to_string(),
|
||||
)?;
|
||||
req.headers()
|
||||
.set("X-Live-Reset-Day", &context.reset_day.to_string())?;
|
||||
req.headers()
|
||||
.set("X-Live-Cycle-Key", &context.cycle_key.to_string())?;
|
||||
req.headers().set(
|
||||
headers.set("X-Live-Reset-Day", &context.reset_day.to_string())?;
|
||||
headers.set("X-Live-Cycle-Key", &context.cycle_key.to_string())?;
|
||||
headers.set(
|
||||
"X-Live-Traffic-Reset-Day",
|
||||
&context.traffic_reset_day.to_string(),
|
||||
)?;
|
||||
req.headers().set(
|
||||
headers.set(
|
||||
"X-Live-Traffic-Timestamp",
|
||||
&context.traffic_timestamp.to_string(),
|
||||
)?;
|
||||
req.headers()
|
||||
.set("X-Live-Raw-Rx", &context.raw_rx.to_string())?;
|
||||
req.headers()
|
||||
.set("X-Live-Raw-Tx", &context.raw_tx.to_string())?;
|
||||
req.headers()
|
||||
.set("X-Live-Used-Rx", &context.used_rx.to_string())?;
|
||||
req.headers()
|
||||
.set("X-Live-Used-Tx", &context.used_tx.to_string())?;
|
||||
req.headers()
|
||||
.set("X-Live-Rx-Correction", &context.rx_correction.to_string())?;
|
||||
req.headers()
|
||||
.set("X-Live-Tx-Correction", &context.tx_correction.to_string())?;
|
||||
req.headers().set(
|
||||
headers.set("X-Live-Raw-Rx", &context.raw_rx.to_string())?;
|
||||
headers.set("X-Live-Raw-Tx", &context.raw_tx.to_string())?;
|
||||
headers.set("X-Live-Used-Rx", &context.used_rx.to_string())?;
|
||||
headers.set("X-Live-Used-Tx", &context.used_tx.to_string())?;
|
||||
headers.set("X-Live-Rx-Correction", &context.rx_correction.to_string())?;
|
||||
headers.set("X-Live-Tx-Correction", &context.tx_correction.to_string())?;
|
||||
headers.set(
|
||||
"X-Live-Last-Persisted-At",
|
||||
&context.last_persisted_at.to_string(),
|
||||
)?;
|
||||
req.headers().delete("Authorization")?;
|
||||
headers.delete("Authorization")?;
|
||||
let req = request_with_headers(&req, headers)?;
|
||||
stub.fetch_with_request(req).await
|
||||
}
|
||||
|
||||
@@ -1214,11 +1379,12 @@ pub async fn upgrade_agent(
|
||||
mod tests {
|
||||
use super::{
|
||||
agent_wss_interval_ms, batch_update_parts_at, begin_d1_flush, cached_update_value,
|
||||
finish_d1_flush, live_ack_payload, next_d1_write_ms, outbound_close_code,
|
||||
trim_cached_live_samples, trim_socket_attachment, update_alert_window,
|
||||
AlertEvaluationServer, AlertWindowSample, CachedLiveSample, SocketAttachment,
|
||||
TrafficAttachment, ALERT_WINDOW_SECONDS, CACHED_LIVE_TTL_SECONDS, MAX_CACHED_LIVE_BYTES,
|
||||
MAX_CACHED_LIVE_SAMPLES, MAX_SERIALIZED_ATTACHMENT_BYTES,
|
||||
consume_agent_message_budget, finish_d1_flush, live_ack_payload, next_d1_write_ms,
|
||||
outbound_close_code, trim_cached_live_samples, trim_socket_attachment, update_alert_window,
|
||||
AgentMessageBudget, AlertEvaluationServer, AlertWindowSample, CachedLiveSample,
|
||||
SocketAttachment, TrafficAttachment, ALERT_WINDOW_SECONDS, CACHED_LIVE_TTL_SECONDS,
|
||||
MAX_AGENT_BYTES_PER_WINDOW, MAX_AGENT_MESSAGES_PER_WINDOW, MAX_AGENT_SAMPLES_PER_WINDOW,
|
||||
MAX_CACHED_LIVE_BYTES, MAX_CACHED_LIVE_SAMPLES, MAX_SERIALIZED_ATTACHMENT_BYTES,
|
||||
};
|
||||
use crate::db::HistoryMetricAggregate;
|
||||
use crate::latency::LatencyMetricAggregates;
|
||||
@@ -1251,6 +1417,30 @@ mod tests {
|
||||
assert_eq!(outbound_close_code(5000), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_agent_messages_per_connection_window() {
|
||||
let mut budget = AgentMessageBudget::default();
|
||||
assert!(consume_agent_message_budget(
|
||||
&mut budget,
|
||||
100,
|
||||
MAX_AGENT_BYTES_PER_WINDOW as usize,
|
||||
MAX_AGENT_SAMPLES_PER_WINDOW as usize,
|
||||
));
|
||||
assert!(!consume_agent_message_budget(&mut budget, 100, 1, 0));
|
||||
|
||||
let mut message_budget = AgentMessageBudget::default();
|
||||
for _ in 0..MAX_AGENT_MESSAGES_PER_WINDOW {
|
||||
assert!(consume_agent_message_budget(&mut message_budget, 100, 1, 1));
|
||||
}
|
||||
assert!(!consume_agent_message_budget(
|
||||
&mut message_budget,
|
||||
100,
|
||||
1,
|
||||
1
|
||||
));
|
||||
assert!(consume_agent_message_budget(&mut message_budget, 160, 1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_payload_contains_cycle_adjusted_traffic_and_latency() {
|
||||
let report = AgentReport {
|
||||
@@ -1327,20 +1517,24 @@ mod tests {
|
||||
report_interval: 60,
|
||||
collect_interval: 1,
|
||||
last_d1_write_at: 1_200,
|
||||
persisted_through: 1_190,
|
||||
d1_flush: None,
|
||||
traffic: None,
|
||||
history_aggregate: HistoryMetricAggregate::default(),
|
||||
latency_aggregates: LatencyMetricAggregates::default(),
|
||||
latest_samples: Vec::new(),
|
||||
latest_received_at: 0,
|
||||
message_budget: AgentMessageBudget::default(),
|
||||
};
|
||||
assert_eq!(agent_wss_interval_ms(&attachment, true), 4_000);
|
||||
assert_eq!(agent_wss_interval_ms(&attachment, false), 60_000);
|
||||
assert_eq!(next_d1_write_ms(&attachment, 1_235), 25_000);
|
||||
let hint: serde_json::Value =
|
||||
serde_json::from_str(&live_ack_payload(1_235, false, 25_000, 4_000, true).unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(next_d1_write_ms(&attachment, 1_235), 85_000);
|
||||
let hint: serde_json::Value = serde_json::from_str(
|
||||
&live_ack_payload(1_235, false, false, 1_190, 25_000, 4_000, true).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(hint["realtimeHint"], true);
|
||||
assert_eq!(hint["persistedThroughTs"], 1_190);
|
||||
assert_eq!(hint["nextWssReportAfterMs"], 4_000);
|
||||
|
||||
let mut corrected_down = traffic;
|
||||
@@ -1378,12 +1572,14 @@ mod tests {
|
||||
report_interval: 60,
|
||||
collect_interval: 1,
|
||||
last_d1_write_at: 0,
|
||||
persisted_through: 0,
|
||||
d1_flush: None,
|
||||
traffic: None,
|
||||
history_aggregate: HistoryMetricAggregate::default(),
|
||||
latency_aggregates: LatencyMetricAggregates::default(),
|
||||
latest_samples: samples,
|
||||
latest_received_at: 100,
|
||||
message_budget: AgentMessageBudget::default(),
|
||||
};
|
||||
let update = cached_update_value(&attachment, 102, Some("node-a")).unwrap();
|
||||
assert_eq!(update["serverId"], "node-a");
|
||||
@@ -1448,12 +1644,14 @@ mod tests {
|
||||
report_interval: 60,
|
||||
collect_interval: 1,
|
||||
last_d1_write_at: 60,
|
||||
persisted_through: 60,
|
||||
d1_flush: None,
|
||||
traffic: None,
|
||||
history_aggregate: HistoryMetricAggregate::default(),
|
||||
latency_aggregates: LatencyMetricAggregates::default(),
|
||||
latest_samples: Vec::new(),
|
||||
latest_received_at: 0,
|
||||
message_budget: AgentMessageBudget::default(),
|
||||
};
|
||||
attachment
|
||||
.history_aggregate
|
||||
@@ -1485,12 +1683,13 @@ mod tests {
|
||||
.latency_aggregates
|
||||
.extend(&second.latency_results, second.timestamp);
|
||||
assert_eq!(successful_snapshot.history.point().unwrap().cpu, 10.0);
|
||||
finish_d1_flush(&mut successful, true, 180);
|
||||
finish_d1_flush(&mut successful, true, 180, 120);
|
||||
|
||||
let pending = successful.history_aggregate.point().unwrap();
|
||||
assert_eq!(pending.cpu, 20.0);
|
||||
assert_eq!(pending.net_in, 500.0);
|
||||
assert_eq!(successful.last_d1_write_at, 180);
|
||||
assert_eq!(successful.persisted_through, 120);
|
||||
assert!(successful.d1_flush.is_none());
|
||||
let latency = serde_json::to_value(&successful.latency_aggregates).unwrap();
|
||||
assert_eq!(latency["v"]["task-a"][0].as_u64(), Some(1));
|
||||
@@ -1504,12 +1703,13 @@ mod tests {
|
||||
.latency_aggregates
|
||||
.extend(&second.latency_results, second.timestamp);
|
||||
assert_eq!(failed_snapshot.history.point().unwrap().cpu, 10.0);
|
||||
finish_d1_flush(&mut attachment, false, 180);
|
||||
finish_d1_flush(&mut attachment, false, 180, 120);
|
||||
|
||||
let retried = attachment.history_aggregate.point().unwrap();
|
||||
assert_eq!(retried.cpu, 15.0);
|
||||
assert_eq!(retried.net_in, 500.0);
|
||||
assert_eq!(attachment.last_d1_write_at, 60);
|
||||
assert_eq!(attachment.persisted_through, 60);
|
||||
assert!(attachment.d1_flush.is_none());
|
||||
let latency = serde_json::to_value(&attachment.latency_aggregates).unwrap();
|
||||
assert_eq!(latency["v"]["task-a"][0].as_u64(), Some(2));
|
||||
|
||||
+11
-8
@@ -82,14 +82,23 @@ pub struct SettingsInput {
|
||||
pub turnstile_site_key: Option<String>,
|
||||
pub turnstile_secret_key: Option<String>,
|
||||
pub notification_enabled: Option<bool>,
|
||||
pub notification_endpoint: Option<String>,
|
||||
pub notification_target: Option<String>,
|
||||
pub offline_alert_minutes: Option<i64>,
|
||||
pub expiry_alert_days: Option<i64>,
|
||||
pub traffic_alert_percentage: Option<i64>,
|
||||
pub cloudflare_account_id: Option<String>,
|
||||
pub cloudflare_api_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TelegramNotificationInput {
|
||||
pub bot_token: String,
|
||||
pub chat_id: String,
|
||||
#[serde(default)]
|
||||
pub message_thread_id: Option<i64>,
|
||||
pub template: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ThemeInput {
|
||||
@@ -176,12 +185,6 @@ pub struct AgentGpuMetric {
|
||||
pub memory_total: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AgentReportBatch {
|
||||
pub samples: Vec<AgentReport>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AgentReport {
|
||||
|
||||
+753
-124
@@ -1,65 +1,607 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use worker::{
|
||||
console_warn, wasm_bindgen::JsValue, D1Database, Env, Fetch, Method, Request, RequestInit,
|
||||
Result,
|
||||
console_warn, wasm_bindgen::JsValue, D1Database, Headers, Method, Request, RequestInit, Result,
|
||||
};
|
||||
|
||||
use crate::db::{self, SettingsView};
|
||||
use crate::auth::sha256_hex;
|
||||
use crate::db::{self, SettingsView, SECRET_MASK};
|
||||
use crate::live;
|
||||
use crate::models::TelegramNotificationInput;
|
||||
use crate::outbound::{fetch_with_timeout, read_response_limited};
|
||||
|
||||
#[derive(Default, Deserialize, Serialize, PartialEq, Eq)]
|
||||
const NOTIFICATION_TIMEOUT: Duration = Duration::from_secs(8);
|
||||
const NOTIFICATION_MAX_BYTES: usize = 64 * 1024;
|
||||
const MAX_DELIVERY_ATTEMPTS: i64 = 6;
|
||||
const DELIVERY_BATCH_SIZE: i64 = 5;
|
||||
const TEMPLATE_KEYS: [&str; 7] = [
|
||||
"event",
|
||||
"title",
|
||||
"server",
|
||||
"message",
|
||||
"time",
|
||||
"value",
|
||||
"threshold",
|
||||
];
|
||||
|
||||
#[derive(Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
|
||||
struct AlertState {
|
||||
offline: HashSet<String>,
|
||||
expiry: HashSet<String>,
|
||||
resources: HashSet<String>,
|
||||
traffic: HashMap<String, TrafficAlertState>,
|
||||
}
|
||||
|
||||
pub fn valid_config(token: &str, chat_id: &str) -> bool {
|
||||
let token = token.trim();
|
||||
!chat_id.trim().is_empty()
|
||||
&& token.split_once(':').is_some_and(|(bot_id, secret)| {
|
||||
!bot_id.is_empty()
|
||||
&& bot_id.chars().all(|value| value.is_ascii_digit())
|
||||
&& secret.len() >= 20
|
||||
&& secret
|
||||
.chars()
|
||||
.all(|value| value.is_ascii_alphanumeric() || matches!(value, '_' | '-'))
|
||||
})
|
||||
#[derive(Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
|
||||
struct TrafficAlertState {
|
||||
cycle_key: i64,
|
||||
step: i64,
|
||||
}
|
||||
|
||||
fn notification_request(settings: &SettingsView, message: &str) -> Result<(String, String)> {
|
||||
let token = settings.notification_endpoint.trim();
|
||||
let chat_id = settings.notification_target.trim();
|
||||
if !valid_config(token, chat_id) {
|
||||
return Err(worker::Error::RustError(
|
||||
"请填写有效的 Telegram Bot Token 和 Chat ID".to_string(),
|
||||
));
|
||||
#[derive(Clone, Deserialize)]
|
||||
struct TelegramSettingsRow {
|
||||
bot_token: String,
|
||||
chat_id: String,
|
||||
message_thread_id: Option<i64>,
|
||||
template: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct TelegramSettingsView {
|
||||
bot_token: String,
|
||||
chat_id: String,
|
||||
message_thread_id: Option<i64>,
|
||||
template: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct TelegramConfig {
|
||||
bot_token: String,
|
||||
chat_id: String,
|
||||
#[serde(default)]
|
||||
message_thread_id: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct NewEvent {
|
||||
id: String,
|
||||
dedupe_key: String,
|
||||
kind: String,
|
||||
server_id: Option<String>,
|
||||
server_name: String,
|
||||
title: String,
|
||||
message: String,
|
||||
details: Value,
|
||||
occurred_at: i64,
|
||||
}
|
||||
|
||||
struct EventSource<'a> {
|
||||
server_id: Option<&'a str>,
|
||||
server_name: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PendingDeliveryRow {
|
||||
event_id: String,
|
||||
attempts: i64,
|
||||
kind: String,
|
||||
server_name: String,
|
||||
title: String,
|
||||
message: String,
|
||||
details_json: String,
|
||||
occurred_at: i64,
|
||||
bot_token: String,
|
||||
chat_id: String,
|
||||
message_thread_id: Option<i64>,
|
||||
template: String,
|
||||
}
|
||||
|
||||
fn text(value: &str) -> JsValue {
|
||||
JsValue::from_str(value)
|
||||
}
|
||||
|
||||
fn number(value: impl ToString) -> JsValue {
|
||||
JsValue::from_f64(value.to_string().parse::<f64>().unwrap_or(0.0))
|
||||
}
|
||||
|
||||
fn event(
|
||||
dedupe_key: String,
|
||||
kind: &str,
|
||||
source: EventSource<'_>,
|
||||
title: String,
|
||||
message: String,
|
||||
details: Value,
|
||||
occurred_at: i64,
|
||||
) -> NewEvent {
|
||||
NewEvent {
|
||||
id: sha256_hex(&dedupe_key).chars().take(32).collect(),
|
||||
dedupe_key,
|
||||
kind: kind.to_string(),
|
||||
server_id: source.server_id.map(str::to_string),
|
||||
server_name: source.server_name.to_string(),
|
||||
title,
|
||||
message,
|
||||
details,
|
||||
occurred_at,
|
||||
}
|
||||
|
||||
Ok((
|
||||
format!("https://api.telegram.org/bot{token}/sendMessage"),
|
||||
serde_json::json!({ "chat_id": chat_id, "text": message }).to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn send(settings: &SettingsView, message: &str) -> Result<()> {
|
||||
let (url, body) = notification_request(settings, message)?;
|
||||
fn valid_telegram_token(value: &str) -> bool {
|
||||
let value = value.trim();
|
||||
value.split_once(':').is_some_and(|(bot_id, secret)| {
|
||||
!bot_id.is_empty()
|
||||
&& bot_id.chars().all(|ch| ch.is_ascii_digit())
|
||||
&& secret.len() >= 20
|
||||
&& secret
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-'))
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_template(template: &str) -> Option<String> {
|
||||
if template.trim().is_empty() || template.chars().count() > 4000 {
|
||||
return Some("通知模板长度应为 1 至 4000 个字符".to_string());
|
||||
}
|
||||
let mut rest = template;
|
||||
while let Some(start) = rest.find("{{") {
|
||||
if rest[..start].contains("}}") {
|
||||
return Some("通知模板占位符格式无效".to_string());
|
||||
}
|
||||
let after = &rest[start + 2..];
|
||||
let Some(end) = after.find("}}") else {
|
||||
return Some("通知模板占位符未闭合".to_string());
|
||||
};
|
||||
let placeholder = &after[..end];
|
||||
let key = placeholder.trim();
|
||||
if placeholder != key || !TEMPLATE_KEYS.contains(&key) {
|
||||
return Some(format!("通知模板包含不支持的占位符:{{{{{key}}}}}"));
|
||||
}
|
||||
rest = &after[end + 2..];
|
||||
}
|
||||
if rest.contains("}}") {
|
||||
return Some("通知模板占位符格式无效".to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn normalized_telegram(
|
||||
input: &TelegramNotificationInput,
|
||||
previous: Option<&TelegramSettingsRow>,
|
||||
) -> std::result::Result<TelegramConfig, String> {
|
||||
if let Some(message) = validate_template(&input.template) {
|
||||
return Err(message);
|
||||
}
|
||||
let bot_token = if input.bot_token.trim() == SECRET_MASK {
|
||||
let Some(previous) = previous else {
|
||||
return Err("Telegram Bot Token 不能为空".to_string());
|
||||
};
|
||||
previous.bot_token.clone()
|
||||
} else {
|
||||
input.bot_token.trim().to_string()
|
||||
};
|
||||
let chat_id = input.chat_id.trim().to_string();
|
||||
if !valid_telegram_token(&bot_token)
|
||||
|| chat_id.is_empty()
|
||||
|| chat_id.chars().count() > 128
|
||||
|| input.message_thread_id.is_some_and(|value| value <= 0)
|
||||
{
|
||||
return Err("Telegram Bot Token、Chat ID 或话题 ID 格式无效".to_string());
|
||||
}
|
||||
Ok(TelegramConfig {
|
||||
bot_token,
|
||||
chat_id,
|
||||
message_thread_id: input.message_thread_id,
|
||||
})
|
||||
}
|
||||
|
||||
async fn telegram_row(db_conn: &D1Database) -> Result<Option<TelegramSettingsRow>> {
|
||||
db_conn
|
||||
.prepare(
|
||||
"SELECT bot_token, chat_id, message_thread_id, template \
|
||||
FROM notification_telegram WHERE id=1",
|
||||
)
|
||||
.first(None)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn telegram_settings(db_conn: &D1Database) -> Result<Option<TelegramSettingsView>> {
|
||||
Ok(telegram_row(db_conn)
|
||||
.await?
|
||||
.map(|row| TelegramSettingsView {
|
||||
bot_token: SECRET_MASK.to_string(),
|
||||
chat_id: row.chat_id,
|
||||
message_thread_id: row.message_thread_id,
|
||||
template: row.template,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn save_telegram(
|
||||
db_conn: &D1Database,
|
||||
input: &TelegramNotificationInput,
|
||||
timestamp: i64,
|
||||
) -> Result<std::result::Result<(), String>> {
|
||||
let previous = telegram_row(db_conn).await?;
|
||||
let config = match normalized_telegram(input, previous.as_ref()) {
|
||||
Ok(value) => value,
|
||||
Err(message) => return Ok(Err(message)),
|
||||
};
|
||||
db_conn
|
||||
.prepare(
|
||||
"INSERT INTO notification_telegram( \
|
||||
id, bot_token, chat_id, message_thread_id, template, updated_at \
|
||||
) VALUES (1, ?1, ?2, ?3, ?4, ?5) \
|
||||
ON CONFLICT(id) DO UPDATE SET bot_token=excluded.bot_token, chat_id=excluded.chat_id, \
|
||||
message_thread_id=excluded.message_thread_id, template=excluded.template, \
|
||||
updated_at=excluded.updated_at",
|
||||
)
|
||||
.bind(&[
|
||||
text(&config.bot_token),
|
||||
text(&config.chat_id),
|
||||
config
|
||||
.message_thread_id
|
||||
.map(number)
|
||||
.unwrap_or(JsValue::NULL),
|
||||
text(input.template.trim()),
|
||||
number(timestamp),
|
||||
])?
|
||||
.run()
|
||||
.await?;
|
||||
Ok(Ok(()))
|
||||
}
|
||||
|
||||
fn value_text(value: &Value) -> String {
|
||||
value
|
||||
.as_str()
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| value.to_string())
|
||||
}
|
||||
|
||||
fn template_values(
|
||||
kind: &str,
|
||||
title: &str,
|
||||
server: &str,
|
||||
message: &str,
|
||||
time: &str,
|
||||
details: &Value,
|
||||
) -> Vec<(&'static str, String)> {
|
||||
vec![
|
||||
("event", kind.to_string()),
|
||||
("title", title.to_string()),
|
||||
("server", server.to_string()),
|
||||
("message", message.to_string()),
|
||||
("time", time.to_string()),
|
||||
(
|
||||
"value",
|
||||
details.get("value").map(value_text).unwrap_or_default(),
|
||||
),
|
||||
(
|
||||
"threshold",
|
||||
details.get("threshold").map(value_text).unwrap_or_default(),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn render_template(template: &str, values: &[(&str, String)]) -> String {
|
||||
let mut output = String::with_capacity(template.len());
|
||||
let mut rest = template;
|
||||
while let Some(start) = rest.find("{{") {
|
||||
output.push_str(&rest[..start]);
|
||||
let after = &rest[start + 2..];
|
||||
let Some(end) = after.find("}}") else {
|
||||
output.push_str(&rest[start..]);
|
||||
return output;
|
||||
};
|
||||
let key = &after[..end];
|
||||
if let Some((_, value)) = values.iter().find(|(candidate, _)| *candidate == key) {
|
||||
output.push_str(value);
|
||||
} else {
|
||||
output.push_str(&rest[start..start + end + 4]);
|
||||
}
|
||||
rest = &after[end + 2..];
|
||||
}
|
||||
output.push_str(rest);
|
||||
output
|
||||
}
|
||||
|
||||
fn iso_time(timestamp: i64) -> String {
|
||||
let value = worker::js_sys::Date::new(&JsValue::from_f64(timestamp as f64 * 1000.0));
|
||||
value
|
||||
.to_iso_string()
|
||||
.as_string()
|
||||
.unwrap_or_else(|| timestamp.to_string())
|
||||
}
|
||||
|
||||
async fn send_request(url: &str, headers: Headers, body: &str) -> Result<()> {
|
||||
let mut init = RequestInit::new();
|
||||
init.with_method(Method::Post)
|
||||
.with_body(Some(JsValue::from_str(&body)));
|
||||
let request = Request::new_with_init(&url, &init)?;
|
||||
request.headers().set("Content-Type", "application/json")?;
|
||||
let response = Fetch::Request(request).send().await?;
|
||||
if !(200..300).contains(&response.status_code()) {
|
||||
.with_headers(headers)
|
||||
.with_body(Some(JsValue::from_str(body)));
|
||||
let request = Request::new_with_init(url, &init)
|
||||
.map_err(|_| worker::Error::RustError("通知服务请求无效".to_string()))?;
|
||||
let Some(mut response) = fetch_with_timeout(request, NOTIFICATION_TIMEOUT)
|
||||
.await
|
||||
.map_err(|_| worker::Error::RustError("通知服务请求失败".to_string()))?
|
||||
else {
|
||||
return Err(worker::Error::RustError("通知服务请求超时".to_string()));
|
||||
};
|
||||
let status = response.status_code();
|
||||
read_response_limited(&mut response, NOTIFICATION_MAX_BYTES)
|
||||
.await?
|
||||
.ok_or_else(|| worker::Error::RustError("通知服务响应体大小无效".to_string()))?;
|
||||
if !(200..300).contains(&status) {
|
||||
return Err(worker::Error::RustError(format!(
|
||||
"通知服务返回 HTTP {}",
|
||||
response.status_code()
|
||||
"通知服务返回 HTTP {status}"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_delivery(row: &PendingDeliveryRow) -> Result<()> {
|
||||
let details = serde_json::from_str::<Value>(&row.details_json).unwrap_or_else(|_| json!({}));
|
||||
let occurred_at = iso_time(row.occurred_at);
|
||||
let values = template_values(
|
||||
&row.kind,
|
||||
&row.title,
|
||||
&row.server_name,
|
||||
&row.message,
|
||||
&occurred_at,
|
||||
&details,
|
||||
);
|
||||
let rendered_message = render_template(&row.template, &values);
|
||||
let headers = Headers::new();
|
||||
headers.set("Content-Type", "application/json")?;
|
||||
let mut body = json!({ "chat_id": row.chat_id, "text": rendered_message });
|
||||
if let Some(message_thread_id) = row.message_thread_id {
|
||||
body["message_thread_id"] = json!(message_thread_id);
|
||||
}
|
||||
send_request(
|
||||
&format!("https://api.telegram.org/bot{}/sendMessage", row.bot_token),
|
||||
headers,
|
||||
&body.to_string(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn retry_delay(attempts: i64) -> i64 {
|
||||
match attempts {
|
||||
0 | 1 => 60,
|
||||
2 => 300,
|
||||
3 => 900,
|
||||
_ => 3600,
|
||||
}
|
||||
}
|
||||
|
||||
async fn attempt_delivery(
|
||||
db_conn: &D1Database,
|
||||
row: &PendingDeliveryRow,
|
||||
timestamp: i64,
|
||||
) -> Result<Option<String>> {
|
||||
let attempts = row.attempts + 1;
|
||||
match send_delivery(row).await {
|
||||
Ok(()) => {
|
||||
db_conn
|
||||
.prepare(
|
||||
"UPDATE notification_deliveries SET status='sent', attempts=?2, sent_at=?3, \
|
||||
last_error=NULL, updated_at=?3 WHERE event_id=?1",
|
||||
)
|
||||
.bind(&[text(&row.event_id), number(attempts), number(timestamp)])?
|
||||
.run()
|
||||
.await?;
|
||||
Ok(None)
|
||||
}
|
||||
Err(error) => {
|
||||
let message = error.to_string().chars().take(300).collect::<String>();
|
||||
let status = if attempts >= MAX_DELIVERY_ATTEMPTS {
|
||||
"dead"
|
||||
} else {
|
||||
"failed"
|
||||
};
|
||||
db_conn
|
||||
.prepare(
|
||||
"UPDATE notification_deliveries SET status=?2, attempts=?3, next_attempt_at=?4, \
|
||||
last_error=?5, updated_at=?6 WHERE event_id=?1",
|
||||
)
|
||||
.bind(&[
|
||||
text(&row.event_id),
|
||||
text(status),
|
||||
number(attempts),
|
||||
number(timestamp.saturating_add(retry_delay(attempts))),
|
||||
text(&message),
|
||||
number(timestamp),
|
||||
])?
|
||||
.run()
|
||||
.await?;
|
||||
Ok(Some(message))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const DELIVERY_SELECT: &str =
|
||||
"SELECT d.event_id, d.attempts, e.kind, e.server_name, e.title, e.message, \
|
||||
e.details_json, e.occurred_at, t.bot_token, t.chat_id, t.message_thread_id, t.template \
|
||||
FROM notification_deliveries d \
|
||||
JOIN notification_events e ON e.id=d.event_id \
|
||||
JOIN notification_telegram t ON t.id=1";
|
||||
|
||||
pub async fn process_pending(db_conn: &D1Database, timestamp: i64) -> Result<usize> {
|
||||
let rows: Vec<PendingDeliveryRow> = db_conn
|
||||
.prepare(format!(
|
||||
"{DELIVERY_SELECT} WHERE d.status IN ('pending', 'failed') AND d.next_attempt_at<=?1 \
|
||||
ORDER BY d.created_at ASC LIMIT ?2"
|
||||
))
|
||||
.bind(&[number(timestamp), number(DELIVERY_BATCH_SIZE)])?
|
||||
.all()
|
||||
.await?
|
||||
.results()?;
|
||||
let count = rows.len();
|
||||
for row in rows {
|
||||
if let Some(message) = attempt_delivery(db_conn, &row, timestamp).await? {
|
||||
console_warn!("telegram notification delivery failed: {message}");
|
||||
}
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
pub async fn test_telegram(
|
||||
db_conn: &D1Database,
|
||||
test_id: &str,
|
||||
timestamp: i64,
|
||||
) -> Result<std::result::Result<(), String>> {
|
||||
if telegram_row(db_conn).await?.is_none() {
|
||||
return Ok(Err("尚未保存 Telegram 配置".to_string()));
|
||||
};
|
||||
let test_event = event(
|
||||
format!("test:telegram:{test_id}"),
|
||||
"test",
|
||||
EventSource {
|
||||
server_id: None,
|
||||
server_name: "NodeFlare",
|
||||
},
|
||||
"NodeFlare 测试通知".to_string(),
|
||||
"Telegram 配置成功,事件投递链路工作正常。".to_string(),
|
||||
json!({}),
|
||||
timestamp,
|
||||
);
|
||||
let event_insert = db_conn
|
||||
.prepare(
|
||||
"INSERT INTO notification_events( \
|
||||
id, dedupe_key, kind, server_id, server_name, title, message, details_json, occurred_at, created_at \
|
||||
) VALUES (?1, ?2, 'test', NULL, ?3, ?4, ?5, '{}', ?6, ?6)",
|
||||
)
|
||||
.bind(&[
|
||||
text(&test_event.id),
|
||||
text(&test_event.dedupe_key),
|
||||
text(&test_event.server_name),
|
||||
text(&test_event.title),
|
||||
text(&test_event.message),
|
||||
number(timestamp),
|
||||
])?;
|
||||
let delivery_insert = db_conn
|
||||
.prepare(
|
||||
"INSERT INTO notification_deliveries( \
|
||||
event_id, status, attempts, next_attempt_at, created_at, updated_at \
|
||||
) VALUES (?1, 'pending', 0, ?2, ?2, ?2)",
|
||||
)
|
||||
.bind(&[text(&test_event.id), number(timestamp)])?;
|
||||
db_conn.batch(vec![event_insert, delivery_insert]).await?;
|
||||
let row = db_conn
|
||||
.prepare(format!("{DELIVERY_SELECT} WHERE d.event_id=?1"))
|
||||
.bind(&[text(&test_event.id)])?
|
||||
.first::<PendingDeliveryRow>(None)
|
||||
.await?
|
||||
.ok_or_else(|| worker::Error::RustError("测试投递记录创建失败".to_string()))?;
|
||||
match attempt_delivery(db_conn, &row, timestamp).await? {
|
||||
Some(message) => Ok(Err(message)),
|
||||
None => Ok(Ok(())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn has_telegram(db_conn: &D1Database) -> Result<bool> {
|
||||
Ok(db_conn
|
||||
.prepare("SELECT 1 AS found FROM notification_telegram WHERE id=1")
|
||||
.first::<i64>(Some("found"))
|
||||
.await?
|
||||
.is_some())
|
||||
}
|
||||
|
||||
async fn notification_state(db_conn: &D1Database) -> Result<AlertState> {
|
||||
let raw = db_conn
|
||||
.prepare("SELECT value FROM notification_state WHERE id=1")
|
||||
.first::<String>(Some("value"))
|
||||
.await?
|
||||
.unwrap_or_else(|| "{}".to_string());
|
||||
Ok(serde_json::from_str(&raw).unwrap_or_default())
|
||||
}
|
||||
|
||||
async fn persist_events_and_state(
|
||||
db_conn: &D1Database,
|
||||
events: &[NewEvent],
|
||||
state: &AlertState,
|
||||
timestamp: i64,
|
||||
) -> Result<()> {
|
||||
let state_json = serde_json::to_string(state)?;
|
||||
let state_update = db_conn
|
||||
.prepare("UPDATE notification_state SET value=?1, updated_at=?2 WHERE id=1")
|
||||
.bind(&[text(&state_json), number(timestamp)])?;
|
||||
if events.is_empty() {
|
||||
state_update.run().await?;
|
||||
return Ok(());
|
||||
}
|
||||
let events_json = serde_json::to_string(events)?;
|
||||
let event_insert = db_conn
|
||||
.prepare(
|
||||
"INSERT OR IGNORE INTO notification_events( \
|
||||
id, dedupe_key, kind, server_id, server_name, title, message, details_json, occurred_at, created_at \
|
||||
) SELECT json_extract(value, '$.id'), json_extract(value, '$.dedupe_key'), \
|
||||
json_extract(value, '$.kind'), json_extract(value, '$.server_id'), \
|
||||
json_extract(value, '$.server_name'), json_extract(value, '$.title'), \
|
||||
json_extract(value, '$.message'), json_extract(value, '$.details'), \
|
||||
json_extract(value, '$.occurred_at'), ?2 FROM json_each(?1)",
|
||||
)
|
||||
.bind(&[text(&events_json), number(timestamp)])?;
|
||||
let delivery_insert = db_conn
|
||||
.prepare(
|
||||
"INSERT OR IGNORE INTO notification_deliveries( \
|
||||
event_id, status, attempts, next_attempt_at, created_at, updated_at \
|
||||
) SELECT e.id, 'pending', 0, ?2, ?2, ?2 \
|
||||
FROM json_each(?1) j JOIN notification_events e ON e.id=json_extract(j.value, '$.id') \
|
||||
WHERE EXISTS (SELECT 1 FROM notification_telegram WHERE id=1)",
|
||||
)
|
||||
.bind(&[text(&events_json), number(timestamp)])?;
|
||||
db_conn
|
||||
.batch(vec![event_insert, delivery_insert, state_update])
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn traffic_used(server: &crate::models::ServerView) -> Option<i64> {
|
||||
let rx = server.net_rx_total?;
|
||||
let tx = server.net_tx_total?;
|
||||
Some(match server.traffic_limit_type.as_str() {
|
||||
"max" => rx.max(tx),
|
||||
"min" => rx.min(tx),
|
||||
"up" => tx,
|
||||
"down" => rx,
|
||||
_ => rx.saturating_add(tx),
|
||||
})
|
||||
}
|
||||
|
||||
fn traffic_step(used: i64, limit: i64, threshold: i64) -> i64 {
|
||||
if limit <= 0 {
|
||||
return 0;
|
||||
}
|
||||
let percent = ((i128::from(used.max(0)) * 100) / i128::from(limit)).min(100) as i64;
|
||||
if percent < threshold {
|
||||
0
|
||||
} else {
|
||||
threshold + ((percent - threshold) / 5) * 5
|
||||
}
|
||||
}
|
||||
|
||||
fn gibibytes(value: i64) -> String {
|
||||
format!("{:.2} GiB", value.max(0) as f64 / 1024_f64.powi(3))
|
||||
}
|
||||
|
||||
pub async fn renew_servers(db_conn: &D1Database) -> Result<()> {
|
||||
let current_time = crate::now();
|
||||
for server in db::list_servers(db_conn, true).await? {
|
||||
if server.auto_renewal == 0 {
|
||||
continue;
|
||||
}
|
||||
let Some(expires_at) = server.expires_at else {
|
||||
continue;
|
||||
};
|
||||
if let Some(next_expiry) = renewed_expiry(expires_at, server.billing_cycle, current_time) {
|
||||
db::update_expiry(db_conn, &server.id, next_expiry).await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn renewed_expiry(expires_at: i64, billing_cycle_days: i64, current_time: i64) -> Option<i64> {
|
||||
let renew_before = i128::from(current_time) + 86_400;
|
||||
let expires_at = i128::from(expires_at);
|
||||
@@ -72,72 +614,113 @@ fn renewed_expiry(expires_at: i64, billing_cycle_days: i64, current_time: i64) -
|
||||
Some((expires_at + cycles * cycle).min(i128::from(i64::MAX)) as i64)
|
||||
}
|
||||
|
||||
pub async fn renew_servers(db_conn: &D1Database) -> Result<()> {
|
||||
let current_time = worker::Date::now().as_millis() as i64 / 1000;
|
||||
for server in db::list_servers(db_conn, true).await? {
|
||||
if server.auto_renewal == 0 {
|
||||
continue;
|
||||
}
|
||||
let Some(expires_at) = server.expires_at else {
|
||||
continue;
|
||||
};
|
||||
if let Some(next_expiry) = renewed_expiry(expires_at, server.billing_cycle, current_time) {
|
||||
db::update_expiry(db_conn, &server.id, next_expiry).await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn check_alerts(db_conn: &D1Database, env: &Env, settings: &SettingsView) -> Result<()> {
|
||||
if !settings.notification_enabled || settings.notification_endpoint.trim().is_empty() {
|
||||
pub async fn check_alerts(
|
||||
db_conn: &D1Database,
|
||||
env: &worker::Env,
|
||||
settings: &SettingsView,
|
||||
) -> Result<()> {
|
||||
if !settings.notification_enabled || !has_telegram(db_conn).await? {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let servers = db::list_servers(db_conn, true).await?;
|
||||
let stored = db::get_setting(db_conn, "alert_state").await?;
|
||||
let previous: AlertState = stored
|
||||
.as_deref()
|
||||
.and_then(|value| serde_json::from_str(value).ok())
|
||||
.unwrap_or_default();
|
||||
let previous = notification_state(db_conn).await?;
|
||||
let mut current = AlertState::default();
|
||||
let current_time = worker::Date::now().as_millis() as i64 / 1000;
|
||||
let current_time = crate::now();
|
||||
let offline_after = settings.offline_alert_minutes.clamp(2, 1440) * 60;
|
||||
let mut messages = Vec::new();
|
||||
let mut events = Vec::new();
|
||||
|
||||
for server in &servers {
|
||||
let server_id = server.id.clone();
|
||||
let is_offline = server
|
||||
.timestamp
|
||||
.map(|timestamp| current_time - timestamp > offline_after)
|
||||
.unwrap_or(current_time - server.created_at > offline_after);
|
||||
let source_timestamp = server.timestamp.unwrap_or(server.created_at);
|
||||
let is_offline = current_time.saturating_sub(source_timestamp) > offline_after;
|
||||
if server.offline_notify_disabled == 0 && is_offline {
|
||||
current.offline.insert(server_id.clone());
|
||||
if !previous.offline.contains(&server_id) {
|
||||
messages.push(format!(
|
||||
"[离线] {} 已超过 {} 分钟未上报",
|
||||
server.name, settings.offline_alert_minutes
|
||||
current.offline.insert(server.id.clone());
|
||||
if !previous.offline.contains(&server.id) {
|
||||
events.push(event(
|
||||
format!("offline:{}:{source_timestamp}", server.id),
|
||||
"offline",
|
||||
EventSource {
|
||||
server_id: Some(&server.id),
|
||||
server_name: &server.name,
|
||||
},
|
||||
"服务器离线".to_string(),
|
||||
format!(
|
||||
"已超过 {} 分钟未收到状态上报。",
|
||||
settings.offline_alert_minutes
|
||||
),
|
||||
json!({ "threshold": settings.offline_alert_minutes }),
|
||||
current_time,
|
||||
));
|
||||
}
|
||||
} else if previous.offline.contains(&server_id) {
|
||||
messages.push(format!("[恢复] {} 已恢复上报", server.name));
|
||||
} else if previous.offline.contains(&server.id) {
|
||||
events.push(event(
|
||||
format!("online:{}:{source_timestamp}", server.id),
|
||||
"online",
|
||||
EventSource {
|
||||
server_id: Some(&server.id),
|
||||
server_name: &server.name,
|
||||
},
|
||||
"服务器恢复".to_string(),
|
||||
"状态上报已恢复。".to_string(),
|
||||
json!({}),
|
||||
current_time,
|
||||
));
|
||||
}
|
||||
|
||||
if settings.expiry_alert_days > 0 {
|
||||
if let Some(value) = server.expires_at {
|
||||
let days = (value - current_time + 86_399) / 86_400;
|
||||
if let Some(expires_at) = server.expires_at {
|
||||
let days = (expires_at - current_time + 86_399) / 86_400;
|
||||
if days >= 0 && days <= settings.expiry_alert_days {
|
||||
let key = format!("{server_id}:{value}");
|
||||
let key = format!("{}:{expires_at}", server.id);
|
||||
current.expiry.insert(key.clone());
|
||||
if !previous.expiry.contains(&key) {
|
||||
messages.push(format!("[到期] {} 剩余 {} 天", server.name, days));
|
||||
events.push(event(
|
||||
format!("expiry:{key}"),
|
||||
"expiry",
|
||||
EventSource {
|
||||
server_id: Some(&server.id),
|
||||
server_name: &server.name,
|
||||
},
|
||||
"服务器即将到期".to_string(),
|
||||
format!("剩余 {days} 天到期。"),
|
||||
json!({ "value": days, "threshold": settings.expiry_alert_days }),
|
||||
current_time,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if server.traffic_limit > 0 {
|
||||
if let Some(used) = traffic_used(server) {
|
||||
let cycle_key = db::traffic_cycle_key(current_time, server.reset_day);
|
||||
let step = traffic_step(
|
||||
used,
|
||||
server.traffic_limit,
|
||||
settings.traffic_alert_percentage,
|
||||
);
|
||||
current
|
||||
.traffic
|
||||
.insert(server.id.clone(), TrafficAlertState { cycle_key, step });
|
||||
let previous_traffic = previous.traffic.get(&server.id);
|
||||
if step >= settings.traffic_alert_percentage
|
||||
&& previous_traffic
|
||||
.is_none_or(|state| state.cycle_key != cycle_key || step > state.step)
|
||||
{
|
||||
events.push(event(
|
||||
format!("traffic:{}:{cycle_key}:{step}", server.id),
|
||||
"traffic",
|
||||
EventSource {
|
||||
server_id: Some(&server.id),
|
||||
server_name: &server.name,
|
||||
},
|
||||
"流量限额提醒".to_string(),
|
||||
format!("本周期已使用 {} / {}({}%)。", gibibytes(used), gibibytes(server.traffic_limit), step),
|
||||
json!({ "value": step, "threshold": settings.traffic_alert_percentage, "used_bytes": used, "limit_bytes": server.traffic_limit }),
|
||||
current_time,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let previous_resources = db::active_alert_states(db_conn).await?;
|
||||
let mut current_resources = HashSet::new();
|
||||
let mut evaluated_resources = HashSet::new();
|
||||
let mut eligible_resources = HashSet::new();
|
||||
let rules = db::list_alert_rules(db_conn).await?;
|
||||
@@ -173,19 +756,14 @@ pub async fn check_alerts(db_conn: &D1Database, env: &Env, settings: &SettingsVi
|
||||
.map(|server_id| format!("{}:{server_id}", rule.id)),
|
||||
);
|
||||
}
|
||||
let metric_label = match rule.metric.as_str() {
|
||||
"cpu" => "CPU",
|
||||
"memory" => "内存",
|
||||
"disk" => "磁盘",
|
||||
"net_in" => "下行",
|
||||
"net_out" => "上行",
|
||||
let (metric_label, unit) = match rule.metric.as_str() {
|
||||
"cpu" => ("CPU", "%"),
|
||||
"memory" => ("内存", "%"),
|
||||
"disk" => ("磁盘", "%"),
|
||||
"net_in" => ("下行", "MiB/s"),
|
||||
"net_out" => ("上行", "MiB/s"),
|
||||
_ => continue,
|
||||
};
|
||||
let unit = if matches!(rule.metric.as_str(), "net_in" | "net_out") {
|
||||
"MiB/s"
|
||||
} else {
|
||||
"%"
|
||||
};
|
||||
for value in values_by_rule.remove(&rule.id).unwrap_or_default() {
|
||||
if !db::alert_window_covered(&value, rule.duration_minutes, current_time) {
|
||||
continue;
|
||||
@@ -193,56 +771,107 @@ pub async fn check_alerts(db_conn: &D1Database, env: &Env, settings: &SettingsVi
|
||||
let key = format!("{}:{}", rule.id, value.server_id);
|
||||
evaluated_resources.insert(key.clone());
|
||||
if value.value >= rule.threshold {
|
||||
current_resources.insert(key.clone());
|
||||
if !previous_resources.contains(&key) {
|
||||
messages.push(format!(
|
||||
"[资源] {} · {}:{} {:.1}{} >= {:.1}{}({} 分钟{})",
|
||||
value.name,
|
||||
rule.name,
|
||||
metric_label,
|
||||
value.value,
|
||||
unit,
|
||||
rule.threshold,
|
||||
unit,
|
||||
rule.duration_minutes,
|
||||
if rule.aggregation == "continuous" {
|
||||
"持续"
|
||||
} else {
|
||||
"平均"
|
||||
current.resources.insert(key.clone());
|
||||
if !previous.resources.contains(&key) {
|
||||
events.push(event(
|
||||
format!("resource:{}:{}:{}:alert", rule.id, value.server_id, value.last_timestamp),
|
||||
"resource_alert",
|
||||
EventSource {
|
||||
server_id: Some(&value.server_id),
|
||||
server_name: &value.name,
|
||||
},
|
||||
format!("资源告警:{}", rule.name),
|
||||
format!("{} {:.1}{unit},阈值 {:.1}{unit},{} 分钟{}。", metric_label, value.value, rule.threshold, rule.duration_minutes, if rule.aggregation == "continuous" { "持续超限" } else { "窗口平均" }),
|
||||
json!({ "value": value.value, "threshold": rule.threshold, "rule": rule.name, "metric": rule.metric }),
|
||||
current_time,
|
||||
));
|
||||
}
|
||||
} else if previous_resources.contains(&key) {
|
||||
messages.push(format!("[恢复] {} · {} 已恢复正常", value.name, rule.name));
|
||||
} else if previous.resources.contains(&key) {
|
||||
events.push(event(
|
||||
format!("resource:{}:{}:{}:recovery", rule.id, value.server_id, value.last_timestamp),
|
||||
"resource_recovery",
|
||||
EventSource {
|
||||
server_id: Some(&value.server_id),
|
||||
server_name: &value.name,
|
||||
},
|
||||
format!("资源恢复:{}", rule.name),
|
||||
format!("{metric_label} 已恢复到阈值以内。"),
|
||||
json!({ "value": value.value, "threshold": rule.threshold, "rule": rule.name, "metric": rule.metric }),
|
||||
current_time,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
for key in &previous_resources {
|
||||
for key in &previous.resources {
|
||||
if eligible_resources.contains(key) && !evaluated_resources.contains(key) {
|
||||
current_resources.insert(key.clone());
|
||||
current.resources.insert(key.clone());
|
||||
}
|
||||
}
|
||||
if previous != current || !events.is_empty() {
|
||||
persist_events_and_state(db_conn, &events, ¤t, current_time).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
if !messages.is_empty() {
|
||||
let body = format!("NodeFlare 告警\n\n{}", messages.join("\n"));
|
||||
send(settings, &body).await?;
|
||||
}
|
||||
if previous != current {
|
||||
db::save_setting(db_conn, "alert_state", &serde_json::to_string(¤t)?).await?;
|
||||
}
|
||||
db::sync_active_alert_states(db_conn, &previous_resources, ¤t_resources).await
|
||||
pub async fn cleanup(db_conn: &D1Database, timestamp: i64) -> Result<()> {
|
||||
let cutoff = timestamp.saturating_sub(30 * 86_400);
|
||||
db_conn
|
||||
.prepare("DELETE FROM notification_events WHERE created_at<?1")
|
||||
.bind(&[number(cutoff)])?
|
||||
.run()
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::renewed_expiry;
|
||||
use super::{render_template, renewed_expiry, retry_delay, traffic_step, validate_template};
|
||||
|
||||
#[test]
|
||||
fn advances_expired_servers_without_looping() {
|
||||
fn renders_whitelisted_templates() {
|
||||
let values = [
|
||||
("title", "离线".to_string()),
|
||||
("message", "超时".to_string()),
|
||||
];
|
||||
assert_eq!(
|
||||
render_template("{{title}}: {{message}}", &values),
|
||||
"离线: 超时"
|
||||
);
|
||||
assert!(validate_template("{{unknown}}").is_some());
|
||||
assert!(validate_template("{{ title }}").is_some());
|
||||
assert_eq!(
|
||||
render_template(
|
||||
"{{server}}",
|
||||
&[
|
||||
("server", "{{title}}".to_string()),
|
||||
("title", "告警".to_string())
|
||||
]
|
||||
),
|
||||
"{{title}}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn traffic_steps_and_retry_backoff_are_bounded() {
|
||||
assert_eq!(traffic_step(79, 100, 80), 0);
|
||||
assert_eq!(traffic_step(80, 100, 80), 80);
|
||||
assert_eq!(traffic_step(89, 100, 80), 85);
|
||||
assert_eq!(traffic_step(120, 100, 80), 100);
|
||||
assert_eq!(
|
||||
[
|
||||
retry_delay(1),
|
||||
retry_delay(2),
|
||||
retry_delay(3),
|
||||
retry_delay(4)
|
||||
],
|
||||
[60, 300, 900, 3600]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expiry_renewal_skips_future_dates() {
|
||||
let day = 86_400;
|
||||
assert_eq!(renewed_expiry(80 * day, 30, 100 * day), Some(110 * day));
|
||||
assert_eq!(renewed_expiry(101 * day, 30, 100 * day), Some(131 * day));
|
||||
assert_eq!(renewed_expiry(102 * day, 30, 100 * day), None);
|
||||
assert_eq!(renewed_expiry(i64::MIN, 1, i64::MAX), Some(i64::MAX));
|
||||
}
|
||||
}
|
||||
+52
-2
@@ -1,7 +1,10 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_util::{future::select, pin_mut};
|
||||
use worker::{AbortController, Delay, Fetch, Request, Response, Result};
|
||||
use futures_util::{future::select, pin_mut, TryStreamExt};
|
||||
use serde::de::DeserializeOwned;
|
||||
use worker::{AbortController, Delay, Error, Fetch, Request, Response, Result};
|
||||
|
||||
const RESPONSE_BODY_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
pub async fn fetch_with_timeout(request: Request, timeout: Duration) -> Result<Option<Response>> {
|
||||
let controller = AbortController::default();
|
||||
@@ -19,3 +22,50 @@ pub async fn fetch_with_timeout(request: Request, timeout: Duration) -> Result<O
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn read_response_limited(
|
||||
response: &mut Response,
|
||||
limit: usize,
|
||||
) -> Result<Option<Vec<u8>>> {
|
||||
let read = async {
|
||||
let declared = response
|
||||
.headers()
|
||||
.get("Content-Length")?
|
||||
.and_then(|value| value.parse::<usize>().ok());
|
||||
if declared.is_some_and(|length| length == 0 || length > limit) {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let mut body = Vec::with_capacity(declared.unwrap_or(0).min(limit));
|
||||
let mut stream = response.stream()?;
|
||||
while let Some(mut chunk) = stream.try_next().await? {
|
||||
let Some(length) = body.len().checked_add(chunk.len()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if length > limit {
|
||||
return Ok(None);
|
||||
}
|
||||
body.append(&mut chunk);
|
||||
}
|
||||
Ok((!body.is_empty()).then_some(body))
|
||||
};
|
||||
let timeout = Delay::from(RESPONSE_BODY_TIMEOUT);
|
||||
pin_mut!(read, timeout);
|
||||
match select(read, timeout).await {
|
||||
futures_util::future::Either::Left((body, _)) => body,
|
||||
futures_util::future::Either::Right(((), _)) => Err(Error::RustError(
|
||||
"upstream response body timed out".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn read_json_limited<T: DeserializeOwned>(
|
||||
response: &mut Response,
|
||||
limit: usize,
|
||||
) -> Result<T> {
|
||||
let body = read_response_limited(response, limit)
|
||||
.await?
|
||||
.ok_or_else(|| Error::RustError("upstream response body is empty or too large".into()))?;
|
||||
serde_json::from_slice(&body)
|
||||
.map_err(|error| Error::RustError(format!("upstream returned invalid JSON: {error}")))
|
||||
}
|
||||
+61
-26
@@ -65,6 +65,19 @@ pub(crate) async fn route(mut req: Request, ctx: &RouteContext) -> Result<RouteO
|
||||
return handled(alert_rules_patch_delete(&mut req, ctx, &method, &path).await);
|
||||
}
|
||||
|
||||
if method == Method::Get && path == "/api/admin/telegram" {
|
||||
return handled(json(
|
||||
&serde_json::json!({ "telegram": notify::telegram_settings(&ctx.database).await? }),
|
||||
200,
|
||||
));
|
||||
}
|
||||
if method == Method::Put && path == "/api/admin/telegram" {
|
||||
return handled(telegram_put(&mut req, ctx).await);
|
||||
}
|
||||
if method == Method::Post && path == "/api/admin/telegram/test" {
|
||||
return handled(telegram_test(ctx).await);
|
||||
}
|
||||
|
||||
if method == Method::Get && path == "/api/admin/servers" {
|
||||
let servers = db::list_servers(&ctx.database, true).await?;
|
||||
return handled(json(&serde_json::json!({ "servers": servers }), 200));
|
||||
@@ -140,10 +153,6 @@ pub(crate) async fn route(mut req: Request, ctx: &RouteContext) -> Result<RouteO
|
||||
db::increment_setting(&ctx.database, "history_cache_version").await?;
|
||||
return handled(no_content());
|
||||
}
|
||||
if method == Method::Post && path == "/api/admin/notifications/test" {
|
||||
return handled(notifications_test(ctx).await);
|
||||
}
|
||||
|
||||
Ok(RouteOutcome::Unmatched(req))
|
||||
}
|
||||
|
||||
@@ -170,6 +179,15 @@ fn short_durable_id(ctx: &RouteContext) -> Result<String> {
|
||||
.collect::<String>())
|
||||
}
|
||||
|
||||
fn unique_admin_id(ctx: &RouteContext, namespace: &str) -> Result<String> {
|
||||
let source = format!(
|
||||
"{namespace}:{}:{}",
|
||||
short_durable_id(ctx)?,
|
||||
worker::Date::now().as_millis()
|
||||
);
|
||||
Ok(sha256_hex(&source).chars().take(24).collect())
|
||||
}
|
||||
|
||||
async fn latency_tasks_post(req: &mut Request, ctx: &RouteContext) -> Result<Response> {
|
||||
let input: LatencyTaskInput = match request_json(req, API_JSON_MAX_BYTES).await {
|
||||
Ok(value) => value,
|
||||
@@ -185,8 +203,12 @@ async fn latency_tasks_post(req: &mut Request, ctx: &RouteContext) -> Result<Res
|
||||
if input.server_ids.iter().any(|id| !server_ids.contains(id)) {
|
||||
return error("延迟任务包含不存在的服务器", 400);
|
||||
}
|
||||
let id = short_durable_id(ctx)?;
|
||||
let assigned_server_ids = input.server_ids.clone();
|
||||
let id = unique_admin_id(ctx, "latency-task")?;
|
||||
latency::create_task(&ctx.database, &id, &input, now()).await?;
|
||||
if let Err(error) = live::disconnect_agents(&ctx.env, &assigned_server_ids).await {
|
||||
console_error!("failed to refresh Agents after latency task creation: {error}");
|
||||
}
|
||||
db::increment_setting(&ctx.database, "history_cache_version").await?;
|
||||
json(&serde_json::json!({ "id": id }), 201)
|
||||
}
|
||||
@@ -201,7 +223,11 @@ async fn latency_tasks_patch_delete(
|
||||
return error("延迟任务 ID 无效", 400);
|
||||
};
|
||||
if *method == Method::Delete {
|
||||
let assigned_server_ids = latency::server_ids_for_task(&ctx.database, &id).await?;
|
||||
return if latency::delete_task(&ctx.database, &id).await? {
|
||||
if let Err(error) = live::disconnect_agents(&ctx.env, &assigned_server_ids).await {
|
||||
console_error!("failed to refresh Agents after latency task deletion: {error}");
|
||||
}
|
||||
db::increment_setting(&ctx.database, "history_cache_version").await?;
|
||||
no_content()
|
||||
} else {
|
||||
@@ -223,7 +249,14 @@ async fn latency_tasks_patch_delete(
|
||||
{
|
||||
return error("延迟任务包含不存在的服务器", 400);
|
||||
}
|
||||
let mut affected_server_ids = latency::server_ids_for_task(&ctx.database, &id).await?;
|
||||
affected_server_ids.extend(input.server_ids.iter().cloned());
|
||||
affected_server_ids.sort();
|
||||
affected_server_ids.dedup();
|
||||
if latency::update_task(&ctx.database, &id, &input, now()).await? {
|
||||
if let Err(error) = live::disconnect_agents(&ctx.env, &affected_server_ids).await {
|
||||
console_error!("failed to refresh Agents after latency task update: {error}");
|
||||
}
|
||||
db::increment_setting(&ctx.database, "history_cache_version").await?;
|
||||
no_content()
|
||||
} else {
|
||||
@@ -578,16 +611,29 @@ async fn cloudflare_usage(ctx: &RouteContext) -> Result<Response> {
|
||||
}
|
||||
}
|
||||
|
||||
async fn notifications_test(ctx: &RouteContext) -> Result<Response> {
|
||||
if ctx.settings.notification_endpoint.trim().is_empty() {
|
||||
return error("请先填写 Telegram Bot Token 和 Chat ID", 400);
|
||||
async fn telegram_put(req: &mut Request, ctx: &RouteContext) -> Result<Response> {
|
||||
let input: crate::models::TelegramNotificationInput =
|
||||
match request_json(req, API_JSON_MAX_BYTES).await {
|
||||
Ok(value) => value,
|
||||
Err(_) => return error("Telegram 配置格式无效", 400),
|
||||
};
|
||||
match notify::save_telegram(&ctx.database, &input, now()).await? {
|
||||
Ok(()) => no_content(),
|
||||
Err(message) => error(&message, 400),
|
||||
}
|
||||
if let Err(err) = notify::send(&ctx.settings, "NodeFlare 测试通知:通知渠道配置成功。").await
|
||||
}
|
||||
|
||||
async fn telegram_test(ctx: &RouteContext) -> Result<Response> {
|
||||
match notify::test_telegram(
|
||||
&ctx.database,
|
||||
&unique_admin_id(ctx, "notification-test")?,
|
||||
now(),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
console_error!("test notification failed: {err}");
|
||||
return error("测试通知发送失败,请检查 Bot Token 和 Chat ID", 502);
|
||||
Ok(()) => no_content(),
|
||||
Err(message) => error(&format!("Telegram 测试消息发送失败:{message}"), 502),
|
||||
}
|
||||
no_content()
|
||||
}
|
||||
|
||||
async fn settings_patch(req: &mut Request, ctx: &RouteContext) -> Result<Response> {
|
||||
@@ -717,21 +763,10 @@ async fn settings_patch(req: &mut Request, ctx: &RouteContext) -> Result<Respons
|
||||
return error("启用 Turnstile 前必须填写站点密钥和私钥", 400);
|
||||
}
|
||||
if input
|
||||
.notification_enabled
|
||||
.unwrap_or(settings.notification_enabled)
|
||||
.traffic_alert_percentage
|
||||
.is_some_and(|value| !(50..=100).contains(&value))
|
||||
{
|
||||
let token = submitted_secret(
|
||||
input.notification_endpoint.as_deref(),
|
||||
&settings.notification_endpoint,
|
||||
);
|
||||
let chat_id = input
|
||||
.notification_target
|
||||
.as_deref()
|
||||
.unwrap_or(&settings.notification_target)
|
||||
.trim();
|
||||
if !notify::valid_config(token, chat_id) {
|
||||
return error("Telegram Bot Token 或 Chat ID 格式无效", 400);
|
||||
}
|
||||
return error("流量提醒起始阈值应为 50 至 100", 400);
|
||||
}
|
||||
if input
|
||||
.cloudflare_account_id
|
||||
|
||||
+3
-129
@@ -1,139 +1,13 @@
|
||||
use std::net::IpAddr;
|
||||
|
||||
use worker::{Context, D1Database, Env, Request, Response, Result};
|
||||
use worker::{D1Database, Env, Request, Response, Result};
|
||||
|
||||
use crate::auth::bearer_token;
|
||||
use crate::db;
|
||||
use crate::latency;
|
||||
use crate::live;
|
||||
use crate::models::AgentReportBatch;
|
||||
use crate::sha256_hex;
|
||||
use crate::{
|
||||
client_ip, error, no_content, now, public_server, request_json, validate_report,
|
||||
AGENT_JSON_MAX_BYTES, MAX_AGENT_LATENCY_RESULTS, MAX_AGENT_SAMPLES,
|
||||
};
|
||||
use crate::{client_ip, error};
|
||||
|
||||
async fn config_response(
|
||||
database: &D1Database,
|
||||
server_id: &str,
|
||||
agent_config_hash: &str,
|
||||
received_at: i64,
|
||||
changed_status: u16,
|
||||
) -> Result<Response> {
|
||||
let Some(config) = db::agent_config(database, server_id).await? else {
|
||||
return error("节点不存在", 404);
|
||||
};
|
||||
let config_json = serde_json::to_string(&config)?;
|
||||
let config_hash = sha256_hex(&config_json);
|
||||
let mut response = if agent_config_hash == config_hash {
|
||||
no_content()?
|
||||
} else {
|
||||
crate::json(&config, changed_status)?
|
||||
};
|
||||
response
|
||||
.headers_mut()
|
||||
.set("X-Agent-Config-Sha256", &config_hash)?;
|
||||
response
|
||||
.headers_mut()
|
||||
.set("X-NodeFlare-Server-Time", &received_at.to_string())?;
|
||||
response.headers_mut().set("Cache-Control", "no-store")?;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub(crate) async fn report(
|
||||
mut req: Request,
|
||||
env: Env,
|
||||
ctx: Context,
|
||||
database: &D1Database,
|
||||
) -> Result<Response> {
|
||||
let agent_config_hash = req
|
||||
.headers()
|
||||
.get("X-Agent-Config-Sha256")?
|
||||
.unwrap_or_default();
|
||||
let token = match bearer_token(&req) {
|
||||
Some(value) => value,
|
||||
None => return error("缺少 Agent Token", 401),
|
||||
};
|
||||
let batch: AgentReportBatch = match request_json(&mut req, AGENT_JSON_MAX_BYTES).await {
|
||||
Ok(value) => value,
|
||||
Err(_) => return error("指标格式无效", 400),
|
||||
};
|
||||
if batch.samples.is_empty() || batch.samples.len() > MAX_AGENT_SAMPLES {
|
||||
return error("每批应包含 1 至 720 个样本", 400);
|
||||
}
|
||||
let received_at = now();
|
||||
for report in &batch.samples {
|
||||
if report.timestamp <= 0 || (report.timestamp - received_at).abs() > 7200 {
|
||||
return error("样本时间戳超出允许范围", 400);
|
||||
}
|
||||
if let Some(message) = validate_report(report) {
|
||||
return error(message, 400);
|
||||
}
|
||||
}
|
||||
let Some(identity) = db::get_agent_identity(database, &token).await? else {
|
||||
return error("Agent Token 无效", 401);
|
||||
};
|
||||
let server_id = identity.id;
|
||||
if let Some(ip) = client_ip(&req).and_then(|value| value.parse::<IpAddr>().ok()) {
|
||||
db::update_last_ip(database, &server_id, &ip.to_string()).await?;
|
||||
}
|
||||
let latency_result_count = batch
|
||||
.samples
|
||||
.iter()
|
||||
.flat_map(|report| report.latency_results.iter())
|
||||
.count();
|
||||
if latency_result_count > MAX_AGENT_LATENCY_RESULTS {
|
||||
return error("每批最多包含 4096 条延迟结果", 400);
|
||||
}
|
||||
let alert_point = db::save_reports(database, &server_id, &batch.samples).await?;
|
||||
if let Some(point) = alert_point.as_ref() {
|
||||
if let Err(error) = live::record_alert_sample(&env, &server_id, point).await {
|
||||
worker::console_warn!("resource alert sample write failed: {error:?}");
|
||||
}
|
||||
}
|
||||
|
||||
if identity.hidden == 0 {
|
||||
let server = db::get_server(database, &server_id, false).await?;
|
||||
let samples = latency::latest_for_server(database, &server_id).await?;
|
||||
let payload = server
|
||||
.map(|server| public_server(server, samples))
|
||||
.map(|server| {
|
||||
serde_json::to_string(&serde_json::json!({
|
||||
"type": "server",
|
||||
"server": server
|
||||
}))
|
||||
})
|
||||
.transpose()?;
|
||||
let broadcast_server_id = server_id.clone();
|
||||
if let Some(payload) = payload {
|
||||
ctx.wait_until(async move {
|
||||
let _ = live::broadcast(&env, &broadcast_server_id, &payload).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
config_response(database, &server_id, &agent_config_hash, received_at, 202).await
|
||||
}
|
||||
|
||||
pub(crate) async fn config(req: &Request, database: &D1Database) -> Result<Response> {
|
||||
let agent_config_hash = req
|
||||
.headers()
|
||||
.get("X-Agent-Config-Sha256")?
|
||||
.unwrap_or_default();
|
||||
let token = match bearer_token(req) {
|
||||
Some(value) => value,
|
||||
None => return error("缺少 Agent Token", 401),
|
||||
};
|
||||
let Some(identity) = db::get_agent_identity(database, &token).await? else {
|
||||
return error("Agent Token 无效", 401);
|
||||
};
|
||||
config_response(database, &identity.id, &agent_config_hash, now(), 200).await
|
||||
}
|
||||
|
||||
pub(crate) async fn live_websocket(
|
||||
req: Request,
|
||||
env: &Env,
|
||||
database: &D1Database,
|
||||
) -> Result<Response> {
|
||||
pub(crate) async fn websocket(req: Request, env: &Env, database: &D1Database) -> Result<Response> {
|
||||
let token = match bearer_token(&req) {
|
||||
Some(value) => value,
|
||||
None => return error("缺少 Agent Token", 401),
|
||||
|
||||
@@ -341,7 +341,7 @@ async fn latency(req: &Request, ctx: &RouteContext) -> Result<Response> {
|
||||
if db::get_server(&ctx.database, &id, false).await?.is_none() {
|
||||
return error("节点不存在", 404);
|
||||
}
|
||||
let hours = requested_hours(req, 24 * 365)?;
|
||||
let hours = requested_hours(req, 24 * db::MAX_HISTORY_RETENTION_DAYS)?;
|
||||
let cache_key = if ctx.settings.public_dashboard {
|
||||
Some(history_cache_key(
|
||||
&req.url()?,
|
||||
|
||||
+42
-31
@@ -1,12 +1,16 @@
|
||||
use futures_util::TryStreamExt;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::Value;
|
||||
use worker::{Error, Fetch, Method, Request, Response, Result, Url};
|
||||
use worker::{Error, Method, Request, Result, Url};
|
||||
|
||||
use crate::outbound::{fetch_with_timeout, read_response_limited};
|
||||
|
||||
pub const BUILTIN_THEME_ID: &str = "builtin-nodeflare-glass";
|
||||
pub const BUILTIN_THEME_NAME: &str = "NodeFlare Glass";
|
||||
pub const INDEX_MAX_BYTES: usize = 4 * 1024 * 1024;
|
||||
pub const ASSET_MAX_BYTES: usize = 16 * 1024 * 1024;
|
||||
const SETTINGS_MAX_BYTES: usize = 64 * 1024;
|
||||
const REMOTE_FETCH_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
pub struct ResolvedTheme {
|
||||
pub source_url: String,
|
||||
@@ -143,37 +147,13 @@ fn settings_url(base: &str) -> Option<String> {
|
||||
raw_github_base(base).map(|base| remote_url(base.as_str(), "theme.json"))
|
||||
}
|
||||
|
||||
pub async fn read_response_limited(
|
||||
response: &mut Response,
|
||||
limit: usize,
|
||||
) -> Result<Option<Vec<u8>>> {
|
||||
let declared = response
|
||||
.headers()
|
||||
.get("Content-Length")?
|
||||
.and_then(|value| value.parse::<usize>().ok());
|
||||
if declared.is_some_and(|length| length == 0 || length > limit) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut body = Vec::with_capacity(declared.unwrap_or(0).min(limit));
|
||||
let mut stream = response.stream()?;
|
||||
while let Some(mut chunk) = stream.try_next().await? {
|
||||
let Some(length) = body.len().checked_add(chunk.len()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if length > limit {
|
||||
return Ok(None);
|
||||
}
|
||||
body.append(&mut chunk);
|
||||
}
|
||||
Ok((!body.is_empty()).then_some(body))
|
||||
}
|
||||
|
||||
pub async fn validate_remote(base: &str) -> Result<()> {
|
||||
let index = index_url(base)
|
||||
.ok_or_else(|| Error::RustError("主题资源地址不是 GitHub Raw 地址".to_string()))?;
|
||||
let request = Request::new(&index, Method::Get)?;
|
||||
let mut response = Fetch::Request(request).send().await?;
|
||||
let Some(mut response) = fetch_with_timeout(request, REMOTE_FETCH_TIMEOUT).await? else {
|
||||
return Err(Error::RustError("主题 index.html 请求超时".to_string()));
|
||||
};
|
||||
let status = response.status_code();
|
||||
if !(200..300).contains(&status) {
|
||||
return Err(Error::RustError(format!(
|
||||
@@ -219,6 +199,33 @@ pub fn builtin_settings_schema() -> Value {
|
||||
"label": "总览显示在线节点",
|
||||
"type": "toggle",
|
||||
"default": true
|
||||
},
|
||||
{
|
||||
"key": "showCarrierLatency",
|
||||
"label": "节点卡片分线路显示延迟",
|
||||
"type": "toggle",
|
||||
"default": false
|
||||
},
|
||||
{
|
||||
"key": "telecomLatencyTask",
|
||||
"label": "电信线路任务名称",
|
||||
"type": "text",
|
||||
"default": "",
|
||||
"placeholder": "留空时按任务名称自动匹配"
|
||||
},
|
||||
{
|
||||
"key": "mobileLatencyTask",
|
||||
"label": "移动线路任务名称",
|
||||
"type": "text",
|
||||
"default": "",
|
||||
"placeholder": "留空时按任务名称自动匹配"
|
||||
},
|
||||
{
|
||||
"key": "unicomLatencyTask",
|
||||
"label": "联通线路任务名称",
|
||||
"type": "text",
|
||||
"default": "",
|
||||
"placeholder": "留空时按任务名称自动匹配"
|
||||
}
|
||||
]
|
||||
})
|
||||
@@ -323,7 +330,9 @@ pub async fn remote_settings_schema(base: &str) -> Result<Value> {
|
||||
let url = settings_url(base)
|
||||
.ok_or_else(|| Error::RustError("主题资源地址不是 GitHub Raw 地址".to_string()))?;
|
||||
let request = Request::new(&url, Method::Get)?;
|
||||
let mut response = Fetch::Request(request).send().await?;
|
||||
let Some(mut response) = fetch_with_timeout(request, REMOTE_FETCH_TIMEOUT).await? else {
|
||||
return Err(Error::RustError("主题 theme.json 请求超时".to_string()));
|
||||
};
|
||||
if response.status_code() == 404 {
|
||||
return Ok(empty_settings_schema("remote"));
|
||||
}
|
||||
@@ -346,7 +355,9 @@ pub async fn remote_settings_schema(base: &str) -> Result<Value> {
|
||||
pub async fn remote_theme_version(base: &str) -> Option<String> {
|
||||
let url = settings_url(base)?;
|
||||
let request = Request::new(&url, Method::Get).ok()?;
|
||||
let mut response = Fetch::Request(request).send().await.ok()?;
|
||||
let mut response = fetch_with_timeout(request, REMOTE_FETCH_TIMEOUT)
|
||||
.await
|
||||
.ok()??;
|
||||
if !(200..300).contains(&response.status_code()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
+8
-4
@@ -1,12 +1,13 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use worker::{wasm_bindgen::JsValue, Method, Request, RequestInit, Result};
|
||||
use worker::{wasm_bindgen::JsValue, Headers, Method, Request, RequestInit, Result};
|
||||
|
||||
use crate::outbound::fetch_with_timeout;
|
||||
use crate::outbound::{fetch_with_timeout, read_json_limited};
|
||||
|
||||
const SITEVERIFY_URL: &str = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
|
||||
const VERIFY_TIMEOUT_SECONDS: u64 = 8;
|
||||
const VERIFY_RESPONSE_MAX_BYTES: usize = 64 * 1024;
|
||||
pub const ADMIN_LOGIN_ACTION: &str = "admin-login";
|
||||
pub const PUBLIC_DASHBOARD_ACTION: &str = "public-dashboard";
|
||||
|
||||
@@ -59,11 +60,13 @@ pub async fn verify(
|
||||
response: token,
|
||||
remoteip: remote_ip,
|
||||
})?;
|
||||
let headers = Headers::new();
|
||||
headers.set("Content-Type", "application/json")?;
|
||||
let mut init = RequestInit::new();
|
||||
init.with_method(Method::Post)
|
||||
.with_headers(headers)
|
||||
.with_body(Some(JsValue::from_str(&body)));
|
||||
let request = Request::new_with_init(SITEVERIFY_URL, &init)?;
|
||||
request.headers().set("Content-Type", "application/json")?;
|
||||
|
||||
let Some(mut response) =
|
||||
fetch_with_timeout(request, Duration::from_secs(VERIFY_TIMEOUT_SECONDS)).await?
|
||||
@@ -73,7 +76,8 @@ pub async fn verify(
|
||||
if !(200..300).contains(&response.status_code()) {
|
||||
return Ok(false);
|
||||
}
|
||||
let result: VerifyResponse = response.json().await?;
|
||||
let result: VerifyResponse =
|
||||
read_json_limited(&mut response, VERIFY_RESPONSE_MAX_BYTES).await?;
|
||||
Ok(matches_request(&result, expected_hostname, expected_action))
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"/admin",
|
||||
"/admin/*",
|
||||
"/admin-assets/*",
|
||||
"/__scheduled",
|
||||
"/__theme-active/*",
|
||||
"/__theme-preview/*"
|
||||
]
|
||||
|
||||
Reference in new issue
Block a user